커뮤니티 정원 동시성
발생 상황
커뮤니티 기수(코호트)에 정원(capacity)을 두는 기능을 구현하면서 동시성 문제를 발견했습니다.
마지막 자리가 남은 기수에 여러 사용자가 동시에 참가 요청을 보내면, 두 요청이 모두 정원 체크를 통과해 정원을 초과하는 상황이 발생합니다.
capacity = 10, currentCount = 9 (마지막 1자리)
트랜잭션 A: SELECT currentCount = 9 → isFull() = false → INSERT member → UPDATE count = 10
트랜잭션 B: SELECT currentCount = 9 → isFull() = false → INSERT member → UPDATE count = 11 ← 정원 초과
↑
A가 커밋되기 전에 B도 9를 읽음
두 트랜잭션이 동일한 currentCount 값을 읽고 각자 정원 체크를 통과했기 때문에 발생하는 Lost Update 문제입니다.
해결 방법
기수를 조회하는 시점에 SELECT ... FOR UPDATE로 **행 수준 락(row-level exclusive lock)**을 획득합니다. 락을 보유한 트랜잭션이 커밋할 때까지 다른 트랜잭션은 같은 행을 읽지 못하고 대기합니다.
트랜잭션 A: SELECT ... FOR UPDATE (락 획득)
→ isFull() = false → INSERT → count = 10 → 커밋 → 락 해제
트랜잭션 B: SELECT ... FOR UPDATE (A의 락 해제까지 대기)
→ A 커밋 후 진입 → currentCount = 10
→ isFull() = true → 400 COMMUNITY_FULL 반환
참가 처리 전체 흐름
1. 기수 조회 + 락 획득 (SELECT ... FOR UPDATE)
│
2. 동일 기수 중복 참가 여부 확인
│
3. isFull() 정원 체크 ← 락을 보유한 상태이므로 정확한 값 보장
│
4. 멤버 INSERT
│
5. currentCount++
│
6. 기수 UPDATE
│
7. 커밋 → 락 해제 → 대기 중인 다음 트랜잭션 진행
이 방식을 선택한 이유
낙관적 락(@Version)을 먼저 검토했습니다.
낙관적 락은 실제로 충돌이 발생했을 때만 예외를 던지고 재시도하는 방식입니다. 충돌이 드문 환경에서는 락 대기 없이 높은 처리량을 낼 수 있습니다.
그런데 커뮤니티 기수 참가는 특성상 정원이 거의 찰 때 요청이 집중됩니다. 마지막 몇 자리를 놓고 동시 요청이 몰리는 상황에서 낙관적 락을 사용하면 충돌이 빈번하게 발생하고, OptimisticLockException 후 재시도 로직을 클라이언트나 서버에서 처리해야 합니다. 재시도 폭풍이 오히려 DB 부하를 키울 수 있습니다.
비관적 락의 단점인 락 대기 시간은, 참가 처리 자체가 INSERT + UPDATE 정도로 빠르기 때문에 락 점유 시간이 매우 짧습니다. 데드락 위험도 낮고, 경합이 심한 상황에서 재시도 없이 순서대로 처리해 결과를 예측하기 쉽습니다.
아키텍처 설계 결정
도메인 레이어의 CommunityCohortRepository 인터페이스에는 findByIdWithLock() 메서드만 선언하고, SELECT FOR UPDATE 쿼리와 @Lock 어노테이션은 JPA 구현체에만 위치합니다. 도메인 모델이 JPA에 의존하지 않아 나중에 구현체를 교체하더라도 도메인 코드 수정이 필요 없습니다.