단순 예약 기능 구현이 아니라, 동시성 문제를 의도적으로 재현하고 여러 해결책을 비교 검증하는 실험형 프로젝트입니다.
학부 시절 동아리 지원자 관리 서비스에서 면접 일정을 선착순으로 예약하는 기능을 만든 적이 있습니다. 당시엔 동시성 문제(중복 예약, 정원 초과)를 실제로 겪지 않았지만, 돌이켜보면 언제 터져도 이상하지 않은 코드였습니다. 운이 좋았을 뿐입니다. 이 프로젝트는 그 코드를 제대로 다시 만드는 리벤지 프로젝트입니다. 문제를 직접 재현하고, 여러 해결책을 비교 검증하며, 근거를 가지고 최종 방식을 선택하는 과정 전체를 기록합니다.
전체 기획은 PROJECT_PLAN.md, 데이터 모델은 docs/ERD.md에 있습니다.
동시성 문제를 두 종류로 분리하는 것이 이 프로젝트의 출발점입니다. 둘은 원인도 해결책도 다릅니다.
| 문제 | 원인 | 해결책 |
|---|---|---|
| 같은 사용자가 요청을 두 번 보냄 (더블클릭, 네트워크 재시도) | 요청 자체의 중복 | 요청의 정체성을 고정하는 키 — 여기서는 자연 키 UNIQUE(applicant_id, slot_id) |
| 다른 사용자들이 동시에 마지막 자리를 두고 경쟁 | 데이터 레이스 컨디션 | 조건부 UPDATE / 락 |
락 3종만 구현하면 경쟁은 막아도 중복 요청은 못 막습니다. 실무에서는 이 두 문제가 항상 함께 옵니다.
기획 당시에는 위 첫 행의 해법으로 멱등성 키를 넣을 계획이었지만, 실제로 ①을 구현하고 나서 도입하지 않기로 판단했습니다. 이 도메인에서는 (지원자, 슬롯) 쌍이 그 자체로 요청의 정체성이라 클라이언트가 별도 키를 발급할 이유가 없기 때문입니다. 멱등성 키가 꼭 필요한 것은 결제·송금처럼 요청 내용만으로 "같은 요청"인지 판별할 수 없는 연산입니다. (판단 근거 전문)
핵심 서사는 가장 단순한 도구에서 시작했고, 그것이 부족해지는 지점에서만 더 무거운 도구를 꺼냈다입니다.
| 순서 | 방어 수단 | 막는 문제 |
|---|---|---|
| 1 | UNIQUE(user_id, slot_id) |
중복 예약 (DB 레벨 최후 방어선) — 자연 키가 멱등성 키를 겸합니다 |
| 2 | 조건부 UPDATE ... WHERE remaining > 0 |
정원 초과(오버부킹) |
| 생략 — 1이 같은 문제를 이미 덮습니다 (근거) | ||
| 3 | 비관적 락 / 낙관적 락 / 분산 락 | 여러 단계·여러 테이블에 걸친 복잡한 트랜잭션 |
락(비관적 / 낙관적 / 분산) 3종은 조건부 UPDATE만으로 부족해지는 지점을 보여주기 위해 배치합니다. 처음부터 분산 락을 꺼내지 않은 이유, 최종적으로 무엇을 왜 선택했는지가 이 프로젝트의 핵심 논증입니다. (근거 정리: 아래 트레이드오프 분석 참조)
| 구분 | 기술 |
|---|---|
| 언어 / 프레임워크 | Java 21, Spring Boot 3.5.16 |
| ORM | Spring Data JPA |
| DB | MySQL 8.0 |
| 캐시 / 분산 락 | Redis 7 (Redisson — 2단계에서 도입) |
| 부하 테스트 | Gatling (1단계 문제 재현부터 도입) |
| 빌드 | Gradle (wrapper) |
요구사항: JDK 21, Docker.
# 1. MySQL + Redis 기동
docker compose up -d
docker compose ps # 두 컨테이너가 healthy 인지 확인
# 2. 빌드 + 테스트
./gradlew build # PowerShell/cmd 에서는 gradlew.bat
# 3. 앱 실행
./gradlew bootRun접속 기본값은 application.yml에 맞춰져 있어 컨테이너만 띄우면 그대로 붙습니다. 포트 등을 바꾸려면 환경변수(DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD, REDIS_HOST, REDIS_PORT)로 덮어쓰면 됩니다. 실험을 같은 조건에서 다시 돌리려면 docker compose down -v로 볼륨까지 초기화합니다.
스키마는 Hibernate가 생성하지 않고(ddl-auto: validate) Flyway 마이그레이션으로 버전 관리합니다. 방어 수단이 단계별로 하나씩 들어오는 과정 자체가 이 프로젝트의 논지이므로, 그 과정이 마이그레이션 이력에 남아야 하기 때문입니다. 테스트도 동일한 마이그레이션을 사용합니다 — 제약이 실제로 지켜지는지가 곧 측정 대상이라, 테스트가 다른 스키마를 보면 안 됩니다.
| 버전 | 내용 | 단계 |
|---|---|---|
V1__baseline_schema_without_guards.sql |
applicant / interview_slot / reservation. 방어 제약 없음 | 1단계 |
V2__add_unique_reservation.sql |
UNIQUE(applicant_id, slot_id) — 중복 예약 차단 |
2-1단계 ① |
(예정) V3 |
interview_slot.version (낙관적 락) |
2-2단계 ⑤ |
② 조건부 UPDATE는 스키마 변경이 없고, ③ 멱등성 키는 생략했으므로 V3은 낙관적 락 몫으로 확정입니다.
테이블 정의와 설계 근거는 docs/ERD.md에 있습니다.
이 섹션은 각 단계가 완료되는 대로 실제 측정값으로 채웁니다. 1단계(방어 없음) 완료, 2단계 진행 중(UNIQUE·조건부 UPDATE 도입 완료). 성능 수치는 방어 전체를 같은 조건에서 재측정하는 벤치마크 단계에서 한꺼번에 채웁니다 — 정합성은 방어를 넣은 시점에 통합 테스트로 검증합니다.
락 없는 POST /api/reservations에 정원 100 슬롯 하나 vs 서로 다른 지원자 500명을 atOnceUsers(500)로 한꺼번에 태우면, 세 가지 실패 모드가 한 실행에서 동시에 드러납니다.
- 오버부킹 — 확정 예약 113건 > 정원 100 (13건 초과).
- lost update — 카운터
remaining이 60번만 감소해 40에 갇힘(확정은 113건). 예약 행 수와 카운터가 서로 다른 값으로 둘 다 틀림. - 데드락 폭증 —
reserve요청의 77%(387/500)가 HTTP 500.reservation → interview_slotFK의 S→X 잠금 승격 데드락. - 가장 날카로운 관찰 — 409(정원 마감)가 0건. lost update로 카운터가 0에 닿지 못해, 앱은 슬롯이 꽉 찼다는 사실 자체를 인지하지 못함.
두 단계로 재현했습니다. 서비스 계층에서는 트랜잭션이 1ms 미만이라 간헐적으로만(15명 경쟁 시 3~8/10 라운드) 터지고, HTTP 지연이 경쟁 창을 넓히자 상시로 드러났습니다. 상세 근거·인터리빙 다이어그램은 서비스-계층 재현과 Gatling HTTP 부하에 있습니다.
동일한 부하 시나리오(capacity=100, contenders=500, atOnceUsers)에서 방어 수단별로 측정합니다. 1단계 수치는 대표 실행 기준이며 하드웨어·타이밍에 따라 실행마다 다릅니다(요점은 절대치가 아니라 세 실패 모드의 상시 재현).
| 방식 | TPS (reserve) | 응답시간 (평균 / p95) | 실패율 (500·데드락) | 데이터 정합성 (정원 초과/중복) |
|---|---|---|---|---|
| 방어 없음 (1단계) | 200 rps (전역) | 391ms / 1,126ms | 77% (387/500) | ❌ 오버부킹 +13 & lost update (409 0건) |
| UNIQUE 제약 | 측정 예정 | 측정 예정 | 측정 예정 | ✅ 중복 예약 0 (결정적) / ❌ 오버부킹은 그대로 |
| 조건부 UPDATE | 측정 예정 | 측정 예정 | 측정 예정 | ✅ 오버부킹 0 (결정적, 확정 == 정원) |
| 비관적 락 | 측정 예정 | 측정 예정 | 측정 예정 | ✅ 오버부킹 0 (결정적, 확정 == 정원) |
| 낙관적 락 + 재시도 | 측정 예정 | 측정 예정 | 측정 예정 | 측정 예정 |
| 분산 락 (Redisson) | 측정 예정 | 측정 예정 | 측정 예정 | 측정 예정 |
POST /api/reservations/{strategy}로 방어별 경로를 나란히 두어(baseline은 보존) 같은 부하로 비교할 수 있게 했습니다.
-
① UNIQUE 제약 — 같은 (지원자, 슬롯) 중복 예약은 애플리케이션에 락이 없어도 DB가 매번 막습니다. 다만 서로 다른 지원자의 정원 경쟁은 (지원자, 슬롯) 쌍이 매번 달라 오버부킹을 전혀 막지 못합니다. (상세)
-
② 조건부 UPDATE —
UPDATE ... SET remaining = remaining - 1 WHERE id = ? AND remaining > 0한 문장으로 오버부킹이 결정적으로 0이 됩니다. 락도 데드락도 재시도도 없습니다. baseline의 "간헐적으로 터짐"과 정면 대비되는 지점입니다. (상세) -
③ 멱등성 키 — 도입하지 않기로 판단 — 기획서에는 3순위 방어로 올려뒀지만, ①을 끝내고 다시 따져 보니 이 도메인에서는 (지원자, 슬롯) 쌍이 그 자체로 요청의 정체성이라 클라이언트가 별도 키를 발급할 이유가 없었습니다. 자연 키가 이미 멱등성 키이고, 거기엔 ①의 UNIQUE가 걸려 있습니다. 자연 키로 충분한 자리에 Redis
SETNX를 얹는 것은 "가벼운 것부터"라는 이 프로젝트의 논지를 스스로 배반하는 일이라 판단해 생략했습니다. (판단 근거) -
④ 비관적 락 — 오버부킹은 막지만 채택하지 않음 —
SELECT ... FOR UPDATE도 오버부킹을 결정적으로 0으로 만듭니다. 하지만 그건 ②가 이미 달성한 결과이고, 저경합에서는 7쌍 중 6쌍에서 ②가 더 빨랐습니다(중앙값 178 vs 279ms). 정합성이 동률이면 성능이 동률이기만 해도 락은 집니다 — 대기, 트랜잭션 결합, 데드락 위험이라는 구조적 비용을 대가 없이 치르기 때문입니다. 부수적으로 요청당 쿼리 수를 재보니 "락이 왕복을 더 쓴다"는 통념이 경로마다 방향이 다르다는 것도 확인했습니다(성공 3 vs 4, 거절 3 vs 2). (상세)
여기서 나온 정직한 발견 하나: 락 3종은 오버부킹을 조건부 UPDATE가 "못 막아서" 꺼내는 것이 아닙니다. 이 문제 모양(단일 행 · 산술 델타 · 한 컬럼 가드)에서는 조건부 UPDATE가 사실상 최적해이고, 락은 그 세 조건이 깨질 때(다중 행 불변식, 앱에서 계산하는 값, DB 밖으로 나가는 임계 구역)에만 값을 합니다. 락 3종은 그 경계를 실증하는 대조군입니다.
작성 예정 — 요청 흐름과 락/방어 계층이 작동하는 지점을 표시.
작성 예정 — 각 방식의 장단점과 적합한 상황, 이 프로젝트 규모에 맞는 현실적 선택과 그 근거.
작성 예정 — 구현 과정에서 실제로 부딪힌 문제와 해결 과정.
- 1단계 — 락 없는 기본 구현 + Gatling으로 정원 초과/중복 예약 재현·캡처 (서비스 계층 · HTTP 부하)
- 2단계 — 방어 수단 구현 및 벤치마크 — ① UNIQUE ✅ · ② 조건부 UPDATE ✅ · ③ 멱등성 키 ❌ 생략 · ④ 비관적 락 ✅ · ⑤⑥ 낙관적·분산 락 ⏳ · ⑦ 벤치마크
- 3단계 — 트레이드오프 분석 및 최종 선택 문서화
- 4단계 (선택) — 비동기 알림 분리, 동시성 통합 테스트, CI/CD, 배포
브랜치 단위 진행 상황과 다음 작업은 docs/STEP2-3-BRANCH-STRATEGY.md가 정본입니다.