진료 예약 시스템 설계: 타임슬롯과 중복 예약 방지

1. 개요

의료 진료 예약은 단순해 보입니다: 환자가 의사를 선택하고, 시간을 선택한 후 “예약” 버튼을 클릭합니다. 어려움은 다른 곳에 있습니다. 수백 명이 아침 7시 클리닉이 예약을 오픈할 때 동시에 같은 슬롯을 클릭할 수 있습니다. 시스템은 두 사람이 같은 시간대를 예약하지 않도록 보장해야 합니다.

이 글은 전체 설계를 다룹니다: 요구사항, 데이터 모델, API, 예약 및 취소 흐름. 그 다음 가장 중요한 부분인 중복 예약 방지와 급증 트래픽 처리에 중점을 둡니다. 이 글을 읽고 나면 Spring Boot와 PostgreSQL을 이용해 핵심 예약 기능을 직접 구축할 수 있습니다. 이 핵심 기능은 많은 동시 요청이 있어도 정확히 작동합니다.

2. 요구사항

2.1. 기능 요구사항

  • 의사(또는 관리자)가 근무 일정을 생성합니다. 시스템은 고정된 슬롯들을 생성하는데, 예를 들어 각 슬롯은 15분입니다.
  • 각 슬롯은 수용 인원(capacity)을 가집니다. 전문 진료는 보통 1명입니다. 예방접종은 각 시간대에 10명일 수 있습니다.
  • 환자는 의사와 날짜별로 남은 슬롯을 보고 예약하거나 취소할 수 있습니다.
  • 한 환자는 같은 슬롯에 두 개의 유효한 예약을 가질 수 없습니다.
  • 예약이 성공하면 시스템이 SMS 또는 이메일 확인을 보냅니다.

2.2. 규모 및 제약 조건

  • 의사는 2,000명입니다. 각 의사는 매일 약 40개의 슬롯을 가집니다. 예약은 14일 전에 오픈되므로 약 110만 개의 슬롯이 활성 상태입니다.
  • 읽기 작업이 쓰기 작업보다 훨씬 많으며, 비율은 약 50:1입니다.
  • 급증 트래픽: 유명한 의사의 예약이 7:00에 오픈합니다. 처음 30초 동안 수십 개의 슬롯에 대해 초당 5,000개의 요청이 들어올 수 있습니다.
  • 강한 일관성을 예약 작업에 적용해야 합니다. 수용 인원을 절대 초과하면 안 됩니다. 남은 슬롯 목록은 몇 초 지연되어도 괜찮습니다.
  • 네트워크 오류 시 클라이언트가 재시도할 수 있습니다. 따라서 예약 작업은 멱등성을 가져야 합니다.

3. 데이터 모델

중요한 결정이 있습니다: 각 슬롯을 미리 행으로 생성하되 근무 일정에서 “남은 시간”을 동적으로 계산하지 않습니다. 각 슬롯이 한 행이면, 잠금과 수용 인원 확인을 할 구체적인 지점이 생깁니다. 경합이 한 행 범위 내에서 해결됩니다.

CREATE TABLE time_slot (
    id            BIGSERIAL PRIMARY KEY,
    doctor_id     BIGINT      NOT NULL,
    start_at      TIMESTAMPTZ NOT NULL,
    end_at        TIMESTAMPTZ NOT NULL,
    capacity      INT         NOT NULL CHECK (capacity > 0),
    booked_count  INT         NOT NULL DEFAULT 0,
    CONSTRAINT chk_booked CHECK (booked_count BETWEEN 0 AND capacity),
    CONSTRAINT uq_doctor_start UNIQUE (doctor_id, start_at)
);

CREATE TABLE appointment (
    id               BIGSERIAL PRIMARY KEY,
    slot_id          BIGINT      NOT NULL REFERENCES time_slot(id),
    patient_id       BIGINT      NOT NULL,
    status           VARCHAR(20) NOT NULL,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX uq_active_patient_slot
    ON appointment (slot_id, patient_id)
    WHERE status = 'BOOKED';

스키마의 각 제약 조건은 고유한 역할을 가집니다:

  • chk_booked는 최후의 방어선입니다. 코드에 버그가 있어도 데이터베이스가 booked_count가 capacity를 초과하는 것을 거부합니다.
  • uq_doctor_start는 일정 생성 작업이 다시 실행될 때 슬롯 중복을 방지합니다.
  • uq_active_patient_slot은 PostgreSQL의 부분 유니크 인덱스입니다. 한 환자는 각 슬롯에서 최대 하나의 BOOKED 상태 예약을 가질 수 있습니다. 취소된 예약은 카운트되지 않으므로 환자는 다시 예약할 수 있습니다.
  • idempotency_key UNIQUE는 클라이언트의 재시도가 두 번째 예약을 만들지 않도록 합니다.

time_slot에 해당하는 JPA 엔티티 (getter 생략):

@Entity
@Table(name = "time_slot")
public class TimeSlot {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long doctorId;
    private Instant startAt;
    private Instant endAt;
    private int capacity;
    private int bookedCount;

    protected TimeSlot() {}

    public TimeSlot(Long doctorId, Instant startAt, int capacity) {
        this.doctorId = doctorId;
        this.startAt = startAt;
        this.endAt = startAt.plus(15, ChronoUnit.MINUTES);
        this.capacity = capacity;
    }
    // getters...
}

4. API 설계

GET    /api/doctors/{doctorId}/slots?date=2026-10-12   -> 남은 슬롯 목록
POST   /api/appointments                               -> 예약
       Header: Idempotency-Key: 6f1c...
       Body:   { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id}                          -> 예약 취소

POST /api/appointments의 응답 코드:

201 Created   -> 예약 성공
200 OK        -> 같은 Idempotency-Key로 재시도, 기존 예약 반환
409 Conflict  -> 슬롯이 찼거나 환자가 이미 이 슬롯을 예약함
429 Too Many Requests -> 요청 제한 초과

클라이언트는 각 버튼 클릭마다 Idempotency-Key(UUID)를 생성합니다. 재시도할 때 클라이언트는 같은 key를 다시 보냅니다.

5. 중복 예약 방지

5.1. “확인 후 쓰기”가 잘못된 이유

많은 사람들이 먼저 생각하는 코드:

TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) {   // (1) 읽기
    slot.setBookedCount(slot.getBookedCount() + 1); // (2) 쓰기
    appointmentRepository.save(appointment);
}

두 스레드 A와 B가 단계 (1)에서 동시에 읽어서 booked_count = 0, capacity = 1을 봅시다. 둘 다 조건을 통과하고 둘 다 씁니다. 결과적으로 두 개의 예약이 같은 슬롯을 차지합니다. PostgreSQL의 기본 격리 수준 READ COMMITTED에서 이것이 바로 lost update 오류입니다.

5.2. 조건부 업데이트: 한 명령어에서 확인과 쓰기

해결책은 확인과 쓰기를 조건이 있는 하나의 UPDATE 명령어로 합치는 것입니다. 데이터베이스가 업데이트 중에 행을 잠급니다. 두 번째 스레드는 기다려야 하고, 그 다음 새 값에 대해 WHERE 조건을 다시 평가합니다.

public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {

    @Modifying
    @Query("""
        update TimeSlot s
           set s.bookedCount = s.bookedCount + 1
         where s.id = :id
           and s.bookedCount < s.capacity
           and s.startAt > :now
        """)
    int tryReserve(@Param("id") long id, @Param("now") Instant now);

    @Modifying
    @Query("update TimeSlot s set s.bookedCount = s.bookedCount - 1 where s.id = :id and s.bookedCount > 0")
    int release(@Param("id") long id);
}

tryReserve는 영향을 받은 행 수를 반환합니다. 결과가 1이면 슬롯을 확보했습니다. 결과가 0이면 슬롯이 꽉 찼거나, 시간이 지났거나, 존재하지 않습니다. SELECT ... FOR UPDATE가 필요 없고, version 컬럼으로 optimistic locking도 필요 없습니다. 잠금은 매우 짧은 시간만 유지됩니다.

예약 서비스:

@Service
public class BookingService {
    private final TimeSlotRepository slotRepository;
    private final AppointmentRepository appointmentRepository;
    private final Clock clock;

    public BookingService(TimeSlotRepository slotRepository,
                          AppointmentRepository appointmentRepository,
                          Clock clock) {
        this.slotRepository = slotRepository;
        this.appointmentRepository = appointmentRepository;
        this.clock = clock;
    }

    @Transactional
    public Appointment book(long slotId, long patientId, String idempotencyKey) {
        var existing = appointmentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return existing.get();                       // retry: 기존 결과 반환
        }
        if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
            throw new SlotFullException(slotId);
        }
        // saveAndFlush로 unique constraint 오류가 이 transaction 내에서 발생하도록
        return appointmentRepository.saveAndFlush(
                Appointment.booked(slotId, patientId, idempotencyKey));
    }
}

Clock은 빈으로 선언되어야 하며, 예를 들어 Clock.systemUTC()로 설정해 테스트에서 고정된 시간으로 대체할 수 있습니다. 코드는 다음과 같이 작동합니다:

  • Idempotency 확인 단계가 먼저 실행됩니다. 첫 번째 요청이 커밋된 후 클라이언트가 재시도하면, 클라이언트는 기존 예약을 다시 받습니다.
  • tryReserve가 성공하지만 saveAndFlush가 uq_active_patient_slot을 위반하면 (환자가 이미 예약을 가지고 있음), Spring이 DataIntegrityViolationException을 던집니다. 전체 트랜잭션이 롤백되고, booked_count 증가도 포함됩니다. 슬롯이 자동으로 반환됩니다.
  • 두 재시도 요청이 거의 동시에 도착할 수 있고 둘 다 key를 찾지 못할 수 있습니다. 그러면 한 요청이 idempotency_key UNIQUE를 위반합니다. 컨트롤러 레벨에서 이 오류를 잡고, key로 다시 읽고, 200을 반환합니다.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "이미 이 시간대에 예약이 있습니다"));
}

참고: 위의 예제는 코드를 짧게 유지하기 위해 모든 제약 조건 위반에 대해 409를 반환합니다. 실제 시스템에서는 제약 조건 이름으로 두 경우를 구분해야 합니다. uq_active_patient_slot 위반은 409를 반환합니다. idempotency_key unique 위반은 key로 예약을 다시 읽고 200을 반환합니다.

5.3. 예약 취소도 조건이 필요합니다

예약 취소도 race condition이 발생합니다. 환자가 “취소” 버튼을 두 번 누르면 booked_count가 두 번 감소할 수 있습니다. 해결책은 위와 같습니다: 상태 전환이 실제로 일어날 때만 감소시킵니다.

@Transactional
public void cancel(long appointmentId) {
    Appointment appt = appointmentRepository.findById(appointmentId).orElseThrow();
    int changed = appointmentRepository.markCancelled(appointmentId); // where status = 'BOOKED'
    if (changed == 1) {
        slotRepository.release(appt.getSlotId());
    }
}

markCancelled는 @Modifying 쿼리입니다: update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. 두 번째 취소 요청은 changed = 0을 받고 아무것도 하지 않습니다.

5.4. 다중 스레드 동시성 테스트

아래 테스트는 20명의 환자가 capacity 1인 한 슬롯을 놓고 경쟁합니다. H2가 partial index를 지원하지 않으므로 Testcontainers를 통해 실제 PostgreSQL로 테스트를 실행해야 합니다.

@SpringBootTest
class BookingServiceConcurrencyTest {

    @Autowired BookingService bookingService;
    @Autowired TimeSlotRepository slotRepository;

    @Test
    void onlyOnePatientGetsSingleCapacitySlot() throws Exception {
        TimeSlot slot = slotRepository.save(
                new TimeSlot(42L, Instant.now().plus(1, ChronoUnit.DAYS), 1));
        int patients = 20;
        var start = new CountDownLatch(1);
        var success = new AtomicInteger();

        try (ExecutorService pool = Executors.newFixedThreadPool(patients)) {
            for (int i = 0; i < patients; i++) {
                long patientId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        bookingService.book(slot.getId(), patientId, "key-" + patientId);
                        success.incrementAndGet();
                    } catch (SlotFullException ignored) {
                    }
                    return null;
                });
            }
            start.countDown();   // 20개 스레드 동시 해제
        }                        // close()는 모든 작업이 완료될 때까지 대기

        assertThat(success.get()).isEqualTo(1);
        assertThat(slotRepository.findById(slot.getId()).orElseThrow().getBookedCount())
                .isEqualTo(1);
    }
}

CountDownLatch는 모든 스레드가 동시에 시작하도록 하여 race condition이 더 쉽게 발생하도록 합니다. 예상 결과:

success      = 1
booked_count = 1
나머지 19개 스레드는 SlotFullException을 받음

5.1 섹션의 “읽기 후 쓰기” 방식으로 tryReserve를 대체하면, 이 테스트는 보통 success가 1보다 크게 실패합니다.

6. 예약 오픈 시 급증 트래픽 처리

Conditional update는 정확성을 보장하지만 부하 처리를 보장하지는 않습니다. 초당 5,000개 요청이 몇 개 행으로 몰리면 데이터베이스의 잠금 큐가 생깁니다. 커넥션 풀이 고갈되고 다른 API도 느려집니다. 세 가지 계층의 처리가 있습니다.

6.1. 남은 슬롯 목록 캐싱

대부분의 트래픽은 GET slots입니다. Redis에서 doctorId + date별로 짧은 TTL(예: 3~5초)로 결과를 캐시할 수 있습니다. 예약이나 취소가 성공하면 해당 key를 삭제합니다. 목록이 약간 오래될 수 있지만 괜찮습니다: 최종 결정은 여전히 데이터베이스의 tryReserve에 의해 이루어집니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

6.2. Rate limiting과 찬 슬롯 조기 차단

@Component
public class BookingGate {
    private final StringRedisTemplate redis;

    public BookingGate(StringRedisTemplate redis) { this.redis = redis; }

    public boolean allowPatient(long patientId) {
        String key = "rl:booking:" + patientId;
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofSeconds(10));
        }
        return count != null && count <= 5;          // 최대 5회 예약 / 10초
    }

    public boolean isKnownFull(long slotId) {
        return Boolean.TRUE.equals(redis.hasKey("slot:full:" + slotId));
    }

    public void markFull(long slotId) {
        redis.opsForValue().set("slot:full:" + slotId, "1", Duration.ofSeconds(30));
    }
}

컨트롤러는 먼저 allowPatient를 호출합니다. 결과가 false이면 컨트롤러는 429를 반환합니다. tryReserve가 0을 반환하면 markFull을 호출합니다. 같은 슬롯으로 오는 이후 요청은 Redis에서 즉시 거부되고 데이터베이스에 도달하지 않습니다. 30초 TTL은 누군가 예약을 취소할 때 슬롯이 자동으로 “다시 열리도록” 도와줍니다.

참고: Redis를 capacity의 source of truth로 사용하지 마세요. 예를 들어 카운터를 DECR하고 예약이 완료되었다고 생각하지 마세요. Redis와 데이터베이스가 불일치하면 (Redis 재시작, 트랜잭션 롤백), 과도하게 판매하거나 슬롯을 잃을 수 있습니다. Redis는 데이터베이스 앞의 필터로만 사용해야 합니다.

6.3. Outbox를 통한 비동기 알림 전송

예약 트랜잭션 내에서 SMS 서비스를 호출하지 마세요. 대신 같은 트랜잭션 내에서 outbox_event 테이블에 행을 하나 더 추가합니다. Worker(또는 Debezium)가 AppointmentBooked 이벤트를 Kafka로 푸시하고, 알림 서비스가 그 이벤트를 소비합니다. 따라서 예약 트랜잭션은 짧고 외부 서비스에 의존하지 않습니다. 또한 롤백된 예약에 SMS를 보내지 않습니다.

7. 설계 확장이 필요한 경우

  • 선불 필요 시 슬롯 보유: HELD 상태와 expires_at 컬럼을 추가합니다. tryReserve는 여전히 booked_count를 증가시킵니다. 정기적인 작업이 만료된 HELD 예약을 EXPIRED로 변경하고 release를 호출합니다. 슬롯을 무기한 보유하지 마세요.
  • 환자가 같은 시간에 두 의사 예약 차단: slot_id 기준 유니크 인덱스는 이 경우 충분하지 않습니다. tstzrange(start_at, end_at)을 appointment에 추가하고, PostgreSQL의 exclusion constraint(EXCLUDE USING gist, btree_gist 확장 필요)를 patient_id와 시간 범위 쌍에 사용합니다.
  • 유연한 슬롯 기간 (의사가 15, 30 또는 45분 세션을 받음): 고정 슬롯 모델은 더 이상 맞지 않습니다. capacity 카운팅 대신 각 의사별 시간 범위에 대한 exclusion constraint이 필요합니다.
  • 사용하지 마세요 중복 방지만을 위해 전체 시스템에 SERIALIZABLE을 사용하는 것을. 이 방법은 정확하지만 높은 부하에서 많은 serialization 오류가 발생합니다. Conditional update와 제약 조건이 훨씬 낮은 비용으로 문제를 해결합니다.
  • 사용하지 마세요 한 행만 잠가면 되는 경우 distributed lock(Redisson, Zookeeper)을. 데이터베이스가 이미 잠금을 제공하고 있으며, 추가하는 각 컴포넌트는 고장 날 가능성이 있는 또 다른 지점입니다.

8. 결론

의료 예약 시스템의 설계는 한 가지 원칙을 중심으로 합니다: 중복 방지를 강한 일관성을 가진 곳(데이터베이스)으로 내립니다. 구체적으로:

  • 각 슬롯을 capacity와 booked_count를 가진 행으로 모델링합니다.
  • 조건부 업데이트를 사용해 확인과 쓰기를 한 번의 원자적 단계로 수행합니다.
  • CHECK 제약 조건과 partial unique index를 최후의 방어선으로 사용합니다.
  • Idempotency-Key를 사용해 안전하게 재시도합니다.

위의 계층(Redis 캐시, rate limiting, 슬롯 가득함 표시, Kafka를 통한 outbox)은 예약 오픈 시 급증 트래픽을 처리하도록 시스템을 도와줍니다. 이 계층은 예약의 정확성에 영향을 주지 않습니다. 마지막으로, 항상 5.4 섹션 같은 다중 스레드 테스트를 실제 데이터베이스에서 실행하세요. 시스템이 절대 두 사람이 같은 슬롯을 예약하지 않도록 하는 것을 증명하는 가장 신뢰할 수 있는 방법입니다.

system-designspring-bootpostgresqlredisconcurrency