진료 예약 시스템 설계: 타임슬롯과 중복 예약 방지
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_keyunique 위반은 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 섹션 같은 다중 스레드 테스트를 실제 데이터베이스에서 실행하세요. 시스템이 절대 두 사람이 같은 슬롯을 예약하지 않도록 하는 것을 증명하는 가장 신뢰할 수 있는 방법입니다.