Skip to content

SangHyeok0908/Concurrency-Control

Repository files navigation

선착순 면접 예약 시스템 — 동시성 제어 실험

단순 예약 기능 구현이 아니라, 동시성 문제를 의도적으로 재현하고 여러 해결책을 비교 검증하는 실험형 프로젝트입니다.

학부 시절 동아리 지원자 관리 서비스에서 면접 일정을 선착순으로 예약하는 기능을 만든 적이 있습니다. 당시엔 동시성 문제(중복 예약, 정원 초과)를 실제로 겪지 않았지만, 돌이켜보면 언제 터져도 이상하지 않은 코드였습니다. 운이 좋았을 뿐입니다. 이 프로젝트는 그 코드를 제대로 다시 만드는 리벤지 프로젝트입니다. 문제를 직접 재현하고, 여러 해결책을 비교 검증하며, 근거를 가지고 최종 방식을 선택하는 과정 전체를 기록합니다.

전체 기획은 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 정원 초과(오버부킹)
3 멱등성 키 생략 — 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 도입 완료). 성능 수치는 방어 전체를 같은 조건에서 재측정하는 벤치마크 단계에서 한꺼번에 채웁니다 — 정합성은 방어를 넣은 시점에 통합 테스트로 검증합니다.

1단계 — 방어 없는 baseline에서 무엇이 깨졌나

락 없는 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_slot FK의 S→X 잠금 승격 데드락.
  • 가장 날카로운 관찰 — 409(정원 마감)가 0건. lost update로 카운터가 0에 닿지 못해, 앱은 슬롯이 꽉 찼다는 사실 자체를 인지하지 못함.

두 단계로 재현했습니다. 서비스 계층에서는 트랜잭션이 1ms 미만이라 간헐적으로만(15명 경쟁 시 3~8/10 라운드) 터지고, HTTP 지연이 경쟁 창을 넓히자 상시로 드러났습니다. 상세 근거·인터리빙 다이어그램은 서비스-계층 재현Gatling HTTP 부하에 있습니다.

Before / After 부하 테스트

동일한 부하 시나리오(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) 측정 예정 측정 예정 측정 예정 측정 예정

2단계 — 방어를 하나씩 넣으며 관찰한 것

POST /api/reservations/{strategy}로 방어별 경로를 나란히 두어(baseline은 보존) 같은 부하로 비교할 수 있게 했습니다.

  • ① UNIQUE 제약 — 같은 (지원자, 슬롯) 중복 예약은 애플리케이션에 락이 없어도 DB가 매번 막습니다. 다만 서로 다른 지원자의 정원 경쟁은 (지원자, 슬롯) 쌍이 매번 달라 오버부킹을 전혀 막지 못합니다. (상세)

  • ② 조건부 UPDATEUPDATE ... 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가 정본입니다.

About

동시성 문제를 의도적으로 재현하고 여러 해결책을 비교 검증하는 실험형 프로젝트입니다.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages