[TIL] 트랜잭션·Redis 정합성, TOCTOU, 보상 트랜잭션 — #150에서 배운 백엔드 포인트

DB 트랜잭션과 Redis 정합성, TOCTOU 경쟁 조건, self-invocation, 보상 트랜잭션, DB UPDATE를 활용한 뮤텍스 등 #150 구현에서 정리한 백엔드 핵심 포인트입니다.

📝 TIL (Today I Learned)
DB 트랜잭션과 외부 시스템(Redis)이 함께 움직일 때, “원자성”과 “정합성”을 어떻게 지켜야 하는지 #150 구현에서 다시 정리했다.


들어가며

백엔드는 DB만 잘 쓰면 끝나는 경우가 거의 없다. 실무에서는 Redis 같은 외부 저장소, 메시지/알림, 스케줄러가 같이 엮인다. 이때 제일 흔하게 터지는 문제가 두 가지다.

  1. DB는 롤백됐는데 Redis는 바뀌어 있다(정합성 문제)
  2. 확인(check)과 사용(use) 사이에 상태가 바뀐다(TOCTOU 경쟁 조건)

오늘의 구현은 이 두 가지가 동시에 등장하는 케이스였고, 그래서 “트랜잭션 경계”와 “원자성”을 다시 정리하게 됐다.


1. DB 트랜잭션과 Redis 정합성 — afterCommit 패턴

문제 \n@Transactional 메서드 안에서 Redis를 먼저 변경해 버리면, 뒤에서 DB 작업이 실패해 롤백되더라도 Redis는 이미 변경된 상태로 남는다. 즉 “DB는 실패했는데 Redis는 성공”이라는 상태 불일치가 생긴다.

해결 \nDB 트랜잭션이 커밋된 뒤에만 Redis 변경을 수행한다. Spring에서는 TransactionSynchronizationManagerafterCommit() 콜백으로 이를 구현할 수 있다.

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        // DB 커밋이 확정된 후에만 실행됨
        redisService.doSomething();
    }
});

백엔드 포인트 \n- afterCommit()는 DB 커밋이 “확정된 이후”에만 호출된다. 즉 DB 롤백이면 Redis 변경 로직이 실행되지 않는다.\n- 반대로 afterCommit()에서 Redis가 실패하면, DB는 이미 커밋이 끝난 상태다. 이 경우에는 보상 트랜잭션(Compensation) 이 필요하다. “커밋 이후 외부 시스템 실패”는 롤백으로 되돌릴 수 없기 때문이다.


2. TOCTOU 경쟁 조건 — 검증과 상태 변경을 한 번에

TOCTOU(Time-Of-Check-Time-Of-Use)는 “확인한 시점”과 “사용한 시점” 사이에 다른 요청이 끼어들어, 검증이 무효화되는 문제다.

예를 들어, 큐 상태를 읽고(get) 그다음에 큐에서 제거(remove)하는 두 단계가 따로라면, 그 사이에 사용자가 다른 큐로 이동했을 수 있다.

❌ 비원자적 패턴
getQueueStatus(userId)   ← 여기서 queueId = "Q1" 확인
    ... 시간 흐름 ...
dequeueUser(userId)      ← 이 시점엔 queueId가 "Q2"로 바뀌었을 수도!

해결 \n검증과 삭제를 Redis Lua 스크립트로 묶어 Redis 서버에서 단일 원자 연산으로 실행한다.

local stored = redis.call('GET', userQueueKey)
if stored ~= expectedQueueId then
  return {0, 'QUEUE_ID_MISMATCH'}
end
redis.call('ZREM', queueKey, userId)
return {1, 'OK'}

백엔드 포인트 \nRedis는 Lua 스크립트를 원자적으로 실행한다. 스크립트 도중에 다른 명령이 끼어들 수 없기 때문에, “검증 → 삭제”를 한 단위로 보장할 수 있다. 분산 환경에서 TOCTOU를 줄이려면, 상태 확인과 상태 변경을 가능한 한 한 번의 원자 연산으로 묶는 습관이 중요하다.


3. @Transactional self-invocation — 왜 REQUIRES_NEW가 무시되는가

문제 \nSpring의 @Transactional은 AOP 프록시가 메서드 호출을 가로채서 트랜잭션을 시작/종료한다. 그런데 같은 클래스 안에서 this.inner()처럼 호출하면, 호출이 프록시를 거치지 않고 실제 객체로 바로 들어가서 트랜잭션 애노테이션이 무시될 수 있다. 특히 REQUIRES_NEW 같은 전파 옵션을 기대할 때 크게 헷갈리기 쉽다.

public void outer() {
    this.inner(); // ❌ 프록시 우회
}

@Transactional(propagation = REQUIRES_NEW)
public void inner() { ... }

해결 \n트랜잭션 경계가 필요한 메서드를 별도 Spring Bean으로 분리해서, 호출이 항상 프록시를 타도록 만든다.

@Service
public class AiCallCompensationService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void compensateForFailedDequeue(Long aiCallId, String reason) { ... }
}

백엔드 포인트 \n“내가 애노테이션을 붙였는데 왜 적용이 안 되지?”의 대표 원인 중 하나가 self-invocation이다. 트랜잭션을 “메서드 단위”로 섬세하게 쪼개야 할 때는, 구조적으로 프록시를 우회하지 않게 설계해야 한다.


4. 보상 트랜잭션 — 되돌릴 수 없으면 균형을 맞춘다

분산 시스템에서는 DB처럼 ACID 롤백이 외부 시스템(Redis, 메시지 브로커 등)에 그대로 적용되지 않는다. 따라서 “모든 걸 롤백으로 되돌린다”가 아니라, 실패 케이스를 분류하고 반대 작업(보상) 으로 균형을 맞추는 방식이 필요하다.

상황처리
DB 커밋 성공 + Redis 제거 성공정상 완료
DB 커밋 성공 + Redis NOT_IN_QUEUE이미 제거됨, 보상 불필요
DB 커밋 성공 + Redis QUEUE_ID_MISMATCHAiCall을 FAILED로 보상
DB 롤백Redis는 건드리지 않음(afterCommit 미실행)

핵심 원리 \n되돌리기(rollback)가 불가능하면, 반대 작업(compensation)으로 상태를 “일관된 실패 상태”로 맞춘다. 중요한 것은 “정상/실패”를 감으로 처리하지 않고, 외부 시스템 응답 코드를 상태 머신처럼 명시적으로 매핑하는 것이다.


5. DB 원자적 UPDATE로 뮤텍스 만들기

문제 \n동시 요청이 두 개 들어오면, 둘 다 existsActiveByUserId() = false를 보고 동시에 AiCall을 만들 수 있다. SELECT로 “없음”을 확인해도, 그 직후 다른 요청이 생성해 버리면 경쟁 조건이 생긴다.

해결 \nDB의 UPDATE ... WHERE ... 는 행 잠금(row lock) 아래에서 원자적으로 실행된다. 그래서 영향받은 행 수(row count) 를 “내가 선점했는지”의 신호로 사용할 수 있다.

int cancelled = matchingQueueRepository.cancelMatchingQueueByQueueId(queueId);
if (cancelled == 0) {
    throw new CustomException(AI_CALL_ALREADY_IN_PROGRESS);
}

백엔드 포인트 \n별도의 SELECT FOR UPDATE를 도입하기 전에, “상태 전이”를 UPDATE WHERE status = ... 로 표현하면 구현이 더 단순해지는 경우가 많다. 특히 “WAITING → CANCELLED” 같은 상태 전이는 DB가 가장 잘하는 영역이다.


6. “검증은 변경 전, 변경은 커밋 후” 순서

이 구현에서 유효했던 원칙은 다음이다.

  • 검증은 상태 변경 전에 최대한 끝낸다.
  • 외부 시스템 변경은 커밋 이후로 미룬다.

acceptAiCallOffer의 흐름을 요약하면 이런 순서다.

[Read-only 검증]  ① Redis 큐 상태 확인 (QUEUE_NOT_FOUND 조기 반환)
[Read-only 검증]  ② existsActiveByUserId (AI_CALL_ALREADY_IN_PROGRESS 조기 반환)
[Read-only 검증]  ③ categoryRepository.findById (CATEGORY_NOT_FOUND 조기 반환)
─────────────────────────────────────────────────────
[DB 상태 변경]    ④ cancelMatchingQueueByQueueId (뮤텍스)
[DB 상태 변경]    ⑤ aiCallRepository.save
─────────────────────────────────────────────────────
[DB 커밋 후]      ⑥ afterCommit: dequeueUserIfMatch (Redis 변경)

백엔드 포인트 \n이 순서로 가면 “검증 실패”는 어떤 상태도 바꾸지 않고 끝난다. 그리고 DB가 롤백되면 afterCommit이 실행되지 않으므로 Redis가 보호된다. 반대로 커밋 이후 Redis 변경 실패는 “보상 트랜잭션”으로 흡수한다.


정리

  • 정합성: DB와 Redis를 함께 바꿀 때는 afterCommit로 “커밋 이후 변경”을 기본값으로 둔다.\n- 원자성: TOCTOU를 피하려면 “검증 + 상태 변경”을 Lua 같은 단일 원자 연산으로 묶는다.\n- 트랜잭션 구조: @Transactional은 프록시 기반이므로 self-invocation을 피하도록 빈을 분리한다.\n- 실패 처리: 커밋 이후 외부 시스템 실패는 롤백이 아니라 보상 트랜잭션으로 정리한다.\n- 동시성 제어: DB의 원자적 UPDATE WHERE와 row count는 간단하고 강력한 뮤텍스가 된다.