포스트

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. 큐잉 또는 직렬화

경쟁이 아주 심한 경우에는 아예 처리 순서를 직렬화하는 편이 더 안정적이다.


실무 판단 기준

  • 충돌이 자주 발생하는가
  • 재시도가 가능한가
  • 처리량보다 정합성이 더 중요한가
  • 같은 자원에 경쟁이 집중되는가

예를 들어 선착순 이벤트는 정합성이 우선이고, 일반 조회성 화면은 처리량이 우선일 수 있다.


흔한 실수

  • @Transactional만 붙이면 안전하다고 생각함
  • synchronized로 멀티 인스턴스 환경을 해결하려고 함
  • 락은 걸었지만 조회 범위가 넓어 병목이 심해짐

@Transactional은 원자성 보장과 동시성 제어를 동일하게 해결해주지 않는다. 위 그림의 경쟁을 어디까지 막는지는 격리 수준과 락이 정한다.


정리

Race Condition 대응은 락을 쓸지보다 어디서 경쟁이 발생하고 어떤 수준의 정합성을 보장해야 하는지를 먼저 정하는 문제다. 그다음 Spring/JPA에서는 낙관적 락, 비관적 락, 조건부 업데이트, 큐 기반 직렬화 중 문제 성격에 맞는 방식을 선택해야 한다.

참고한 자료외부 출처 2

외부 출처

데이터베이스 내부와 트랜잭션 Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

댓글

아직 댓글이 없습니다