Spring
Spring과 JPA에서 Race Condition을 다루는 방법
Race Condition이란
여러 요청이 동시에 같은 자원을 읽고 수정할 때, 의도하지 않은 순서로 처리되어 데이터 정합성이 깨지는 상황을 말한다.
대표 예시는 다음과 같다.
- 재고 차감
- 중복 결제 방지
- 쿠폰 선착순 발급
- 좌석 예약
조회와 갱신이 분리된 흐름에서는 특히 쉽게 발생한다. 재고 차감을 예로 들면, 두 요청이 같은 재고를 읽은 뒤 각자 계산한 값을 저장하면 한 번의 차감이 사라진다.
sequenceDiagram
participant A as 요청 A
participant DB
participant B as 요청 B
A->>DB: 재고 조회 (stock = N)
B->>DB: 재고 조회 (stock = N)
A->>DB: stock = N - 1 저장
B->>DB: stock = N - 1 저장
Note over DB: 두 번 팔렸지만 재고는 한 번만 줄었다
왜 JPA에서 자주 문제가 되는가
JPA를 쓰면 엔티티 변경이 자연스럽게 보이기 때문에 동시성 문제도 자동으로 해결될 것처럼 착각하기 쉽다. 하지만 JPA는 ORM일 뿐이고, 동시성 제어는 DB 락 전략과 애플리케이션 설계가 결정한다.
해결 전략
1. 낙관적 락
버전 컬럼을 두고, 업데이트 시점에 버전이 바뀌었는지 검사한다.
- 장점: 읽기 경쟁이 많은 환경에 유리
- 단점: 충돌이 나면 재시도 정책이 필요
@Version 기반으로 쉽게 적용할 수 있다. Jakarta Persistence는 이 필드를 엔티티의 낙관적 락 값으로 정의한다(Version Javadoc). 충돌 처리에 따라 설계가 갈리는 사례는 뱅크샐러드 낙관적 락 리뷰에 있다.
2. 비관적 락
조회 시점부터 DB 락을 잡는다.
- 장점: 충돌을 강하게 제어 가능
- 단점: 대기 시간과 데드락 가능성 증가
Spring Data JPA에서는 리포지토리 쿼리 메서드에 @Lock을 붙여 LockModeType을 지정한다(Locking). 재고처럼 “절대 동시에 성공하면 안 되는” 케이스에서 자주 쓴다.
3. 원자적 업데이트
애플리케이션에서 읽고 계산하고 저장하는 대신, DB에 조건부 업데이트를 맡긴다.
예:
1
2
3
update product
set stock = stock - 1
where id = ? and stock > 0
읽기, 조건 확인, 쓰기가 SQL 한 문장 안에서 일어나므로 위 그림처럼 두 요청이 같은 값을 읽고 덮어쓰는 틈이 없다. 가능한 경우 가장 먼저 검토할 만하다.
4. 큐잉 또는 직렬화
경쟁이 아주 심한 경우에는 아예 처리 순서를 직렬화하는 편이 더 안정적이다.
- 메시지 큐
- 단일 파티션 소비
- Redis 분산 락 (분산 락이 있어도 레이스가 남는 경우)
실무 판단 기준
- 충돌이 자주 발생하는가
- 재시도가 가능한가
- 처리량보다 정합성이 더 중요한가
- 같은 자원에 경쟁이 집중되는가
예를 들어 선착순 이벤트는 정합성이 우선이고, 일반 조회성 화면은 처리량이 우선일 수 있다.
흔한 실수
@Transactional만 붙이면 안전하다고 생각함synchronized로 멀티 인스턴스 환경을 해결하려고 함- 락은 걸었지만 조회 범위가 넓어 병목이 심해짐
@Transactional은 원자성 보장과 동시성 제어를 동일하게 해결해주지 않는다. 위 그림의 경쟁을 어디까지 막는지는 격리 수준과 락이 정한다.
정리
Race Condition 대응은 락을 쓸지보다 어디서 경쟁이 발생하고 어떤 수준의 정합성을 보장해야 하는지를 먼저 정하는 문제다. 그다음 Spring/JPA에서는 낙관적 락, 비관적 락, 조건부 업데이트, 큐 기반 직렬화 중 문제 성격에 맞는 방식을 선택해야 한다.
댓글
아직 댓글이 없습니다