동시성
Spring Boot, Oracle, MSA 환경에서 주문·재고·결제 도메인을 운영하며
Repeatable Read와 Read Committed 격리수준이 실제 서비스에서는 어떻게 문제를 만들 수 있었는지를 정리한다.


1. 문제 배경

  • Repeatable Read는 왜 재고 도메인에서 위험한가
  • Read Committed라고 해서 안전한가
  • 그렇다면 무엇을 믿어야 하는가

특히 Repeatable Read는
겉보기에는 더 안전해 보이지만,
재고 도메인에서는 오히려 위험한 착각을 만들 수 있다.


2. Repeatable Read vs Read Committed

Repeatable Read (RR)

  • 트랜잭션 시작 시점의 스냅샷을 끝까지 유지
  • 같은 SELECT는 항상 같은 결과
  • 트랜잭션 내부 논리적 일관성 보장

하지만 다음과 같은 특성이 있다.

  • 과거 상태를 계속 신뢰하게 만든다
  • 재조회가 재검증이 되지 않는다
  • 긴 트랜잭션에서는 현실과 괴리가 커진다

Read Committed (RC)

  • 각 SQL 실행 시점의 최신 커밋 데이터를 조회
  • 재조회 = 최신 상태 반영
  • 실시간 상태가 중요한 도메인에 적합

Read Committed는
재고처럼 “지금 이 순간의 상태”가 중요한 자원에 더 어울린다.


4. Repeatable Read의 문제 Dirty Read

Repeatable Read의 가장 큰 특징은
트랜잭션 시작 시점의 스냅샷을 끝까지 유지한다는 점이다.

이 특성은 다음과 같은 착각을 만든다.

“결제 직전에 재고를 다시 조회했으니 안전하다”

상황 설정

  • 상품 A 재고: 1개
  • 격리수준: Repeatable Read

타임라인

사용자 A

BEGIN;
SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 1 (snapshot)

사용자 B (거의 동시에)

BEGIN;
SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 1 (같은 snapshot)

A가 결제 직전 재고 재조회후 재고 차감

SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 1 (snapshot)

-- 1이상이기때문에 UPDATE 실행
UPDATE stock
SET qty = qty - 1
WHERE product_id = 'A';

COMMIT;
-- 실제 재고: 0

B가 결제 직전 재고 재조회후 재고 차감

SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 1 (여전히 snapshot)

-- 1이상이기때문에 UPDATE 실행
UPDATE stock
SET qty = qty - 1 -- -1로 업데이트
WHERE product_id = 'A';

COMMIT;

개발자 입장에서는
“결제 직전에 다시 조회했으니 안전하다”고 착각하기 쉽지만 재고는 -1개가 되었다..


5. 왜 이 문제는 Read Committed에서는 발생하지 않는가

같은 상황에서 Read Committed라면 다음과 같다.

SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 0

최신 Commit을 반영하기때문에 재고차감 자체를 실행하지않는다.

Repeatable Read에서만 가능한
“이미 소진된 재고를 남아 있다고 믿는 착각”이
Read Committed에서는 구조적으로 발생하지 않는다.


5. Read Committed는 문제 없는가?

Read Committed는 매 쿼리마다 최신 커밋 데이터를 조회한다.
따라서 위와 같은 “스냅샷 착각” 문제는 발생하지 않는다.

그러나 Read Committed 역시 경쟁 조건 자체를 해결해주지는 않는다.

5.1 만약 재고 직전 조회가 없다면?

상황 설정

  • 상품 A 재고: 1개
  • 격리수준: Repeatable Read

타임라인

사용자 A

BEGIN;
SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 1 (snapshot)

사용자 B (거의 동시에)

BEGIN;
SELECT qty FROM stock WHERE product_id = 'A';
-- 결과: qty = 1 (같은 snapshot)

A가 결제 직전 재고 재조회후 재고 차감

UPDATE stock
SET qty = qty - 1
WHERE product_id = 'A';

COMMIT;
-- 실제 재고: 0, DB도 0으로 처리

B가 결제 직전 재고 재조회후 재고 차감

UPDATE stock
SET qty = qty - 1 -- -1로 업데이트, 실제 재고:0
WHERE product_id = 'A';

COMMIT;

재고는 0개지만 -1개로 입력되는 문제


5.2. Read Committed는 문제 없는가? - Lost Update

SELECT qty FROM stock WHERE product_id = 'A';
-- qty = 1

두 트랜잭션이 거의 동시에 위 쿼리를 실행하면
둘 다 qty = 1을 볼 수 있다.

이후 각각 다음을 수행한다.

UPDATE stock SET qty = 0 WHERE product_id = 'A';

결과적으로:

  • 주문은 2건 성공
  • 재고는 0
  • 초과 판매 발생

Read Committed는 Dirty Read를 막아줄 뿐,
Lost Update를 막아주지 않는다.

Read Committed는 최신 데이터를 보여줄 뿐
동시성 경쟁을 해결해주지는 않는다


6. 해결

  • update문에 조건문을 추가(where qty > 0)
  • 낙관적 락을 적용(where version = 1)

한 문장 정리

재고와 같은 경쟁 자원은
트랜잭션 격리수준보다
원자적인 UPDATE 설계가 더 중요하다.

Repeatable Read에서 UPDATE는 스냅샷을 볼까?
아니다.
Repeatable Read에서도 다음과 같이 동작한다.
SELECT → 스냅샷 기준 조회 (Snapshot Read)
UPDATE / DELETE / SELECT FOR UPDATE → 최신 데이터 기준 (Current Read)
즉, 문제의 원인은 UPDATE가 아니다.
문제는 스냅샷 기반 SELECT 결과를 검증 수단으로 신뢰한 설계다.

Concurrency

댓글 쓰기