핵심 한 줄

유니크 dedup은 정확하지만 뒤늦은 중복을 락 앞에 줄 세운다. 승자가 커밋할 때까지 DB 커넥션을 붙잡고 기다리게 하는 대신, IN_PROGRESS/COMPLETED 상태를 심어 즉시 갈라친다 — 끝났으면 결과 재생, 처리 중이면 즉시 거절(409). 블로킹을 논블로킹으로. 그리고 이걸 하려면 “느린 작업은 트랜잭션 밖으로” 빼야 한다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 정확한데, 느리다

5편에서 save()가 진짜 INSERT를 하게 만들었다. 이제 유니크 dedup이 작동한다 — 같은 멱등키가 두 번 오면 두 번째 INSERT는 유니크 제약에 걸려 튕긴다. 취소는 정확히 한 번만 일어난다.

정확하다. 그런데 한 가지가 마음에 걸린다. 두 번째 요청은 튕기기까지 얼마나 기다리는가.

InnoDB에서 같은 키로 동시에 INSERT가 들어오면, 뒤늦게 온 쪽은 곧바로 에러를 받지 않는다. 유니크 인덱스에 걸린 락을, 승자가 커밋(또는 롤백)할 때까지 대기한다. 승자의 트랜잭션이 짧으면 눈에 안 띈다. 하지만 취소 작업이 외부 PG를 호출하느라 2초, 3초 걸린다면? 뒤늦은 중복은 그 2초, 3초 동안 DB 커넥션 하나를 쥔 채로 멈춰 서 있다.

이런 요청이 몇 개만 겹쳐도 커넥션풀이 마른다. lock_wait_timeout을 넘기면 그제야 예외가 난다. 사용자 입장에서 “중복 요청”의 대가가 몇 초의 정지인 것이다. 중복은 어차피 버릴 요청인데, 버리기 위해 가장 비싼 자원을 붙잡고 기다린다.

문제는 정확성이 아니다. 대기 그 자체다.

2. 결정 — 기다리지 말고 상태를 보고 갈라쳐라

발상을 뒤집는다. 뒤늦은 중복이 락 앞에서 승자를 기다리는 이유는, “먼저 온 요청이 지금 무슨 상태인지”를 락 말고는 알 방법이 없어서다. 그러면 상태를 눈에 보이게 심으면 된다.

레코드에 상태를 둔다 — IN_PROGRESS, COMPLETED. 그리고 흐름을 세 조각으로 쪼갠다.

  • TX1 (선점): INSERT + status=IN_PROGRESS짧게 커밋한다. “이 키는 내가 잡았다”는 깃발만 꽂고 즉시 트랜잭션을 닫는다.
  • 트랜잭션 밖 (느린 작업): 실제 취소 처리. 외부 호출이든 뭐든 여기서. 어떤 락도 쥐지 않은 채로.
  • TX2 (완료): status=COMPLETEDUPDATE. 결과를 저장하고 커밋.

이제 뒤늦은 중복이 오면 어떻게 되나. TX1은 이미 짧게 커밋됐으니 유니크 인덱스에 락이 걸려 있지 않다. 중복의 INSERT는 기다리지 않고 즉시 유니크 위반으로 튕긴다. 튕긴 뒤 레코드를 조회해 상태를 본다.

  • COMPLETED면 → 이미 끝난 요청이다. 저장된 결과를 그대로 재생해 돌려준다.
  • IN_PROGRESS면 → 지금 누가 처리 중이다. 기다리지 않고 즉시 “처리 중”(409)으로 거절한다.

블로킹이 논블로킹이 됐다. 핵심은 상태가 아니라, 느린 작업을 트랜잭션 밖으로 뺐다는 것이다. 상태는 그걸 가능하게 하는 도구일 뿐이다.

3. 함정 1 — 같은 트랜잭션 안에서는 복구할 수 없다

세 조각 흐름을 코드로 옮기다 첫 벽에 부딪힌다. 순진하게 짜면 이렇게 된다 — 하나의 @Transactional 메서드 안에서 INSERT를 시도하고, 유니크 위반이 나면 catch로 잡아 “아 이미 있네, 그럼 상태를 조회하자”로 복구.

이게 안 된다.

JPA/Hibernate에서 영속성 예외가 터지면, 스프링은 현재 트랜잭션을 rollback-only로 마킹한다. 이 트랜잭션은 이제 끝날 때 무조건 롤백당할 운명이다. 그런데 같은 트랜잭션 안에서 예외를 잡아 태연히 조회하고 정상 리턴하려 하면, 커밋 시점에 스프링이 UnexpectedRollbackException을 던진다 — “롤백하기로 돼 있는 트랜잭션을 왜 커밋하려 하냐”고.

그래서 선점(claim)을 별도 빈으로 격리했다.

@Component
public class IdemProcessor {
    @Transactional
    public IdemRecord claimInProgress(String headerKey){
        return repository.saveAndFlush(new IdemRecord(headerKey));
    }
 
    @Transactional
    public String complete(String key, String response) {
        IdemRecord idemRecord = repository.findById(key)
            .orElseThrow(() -> new IllegalArgumentException("not found"));
        idemRecord.complete(response);
        return idemRecord.getResponse();
    }
}

claimInProgress는 자기만의 @Transactional 안에서 saveAndFlush로 즉시 DB를 때린다. 유니크 위반이 나면 이 트랜잭션만 rollback-only가 되고, 예외는 경계를 넘어 바깥으로 던져진다. 바깥 지휘자인 IdemService@Transactional없다. 그러니 자기 트랜잭션이 오염될 걱정 없이, 던져진 예외를 받아 조용히 복구할 수 있다.

여기엔 스프링의 두 가지 진실이 깔려 있다. 하나 — @Transactional프록시로 동작하므로, 트랜잭션 경계는 다른 빈의 메서드를 호출할 때 생긴다(같은 클래스 안 self-invocation은 프록시를 안 탄다). 둘 — 트랜잭션 전파. 짧은 선점 트랜잭션과, 트랜잭션 없는 바깥 복구 로직을 분리하려면 애초에 둘을 다른 빈으로 갈라놔야 한다. flushsaveAndFlush로 강제한 것도 같은 맥락이다 — 유니크 위반을 커밋 시점이 아니라 바로 그 자리에서 터뜨려야 잡을 수 있으니까.

4. 함정 2 — 숨어 있던 두 번째 예외

선점을 격리하고 나서 지휘자를 짰다. 처음엔 이렇게 생각했다 — “선점하다 예외가 나면 그건 중복이다. 상태 보고 갈라치면 끝.”

틀렸다. 그리고 이걸 잡아낸 건 진단 테스트였다.

동시에 여러 스레드가 같은 키로 INSERT를 때리는 테스트를 짜면서, 예외를 catch (Exception e)로 뭉뚱그리지 않고 타입별로 수집했다. 그랬더니 기대했던 DataIntegrityViolationException(유니크 위반 = 중복) 말고, CannotAcquireLockException이 섞여 나왔다.

이건 성격이 완전히 다른 예외다. 데드락/락 획득 타임아웃 계열 — 즉 일시적(transient) 실패다. 중복이라서 튕긴 게 아니라, 순간적인 경합으로 락을 못 잡아 실패한 것뿐이다. 다시 시도하면 성공할 수 있다.

두 예외를 같게 다루면 안 된다. 정반대로 다뤄야 한다.

  • 영구적(중복): DataIntegrityViolationException. 재시도해봐야 또 튕긴다. 즉시 상태 조회로 갈라쳐 끝낸다(terminal).
  • 일시적(경합): CannotAcquireLockException. 재시도하면 뚫릴 수 있다. 잠깐 물러났다 다시 시도한다.

만약 진단 테스트 없이 예외를 뭉뚱그렸다면, 일시적 경합까지 “중복”으로 오판해 멀쩡한 요청을 거절했을 것이다.

5. 지휘자 — 두 예외를 갈라 다루는 루프

IdemService가 이 모든 걸 조율한다. @Transactional이 없다는 점을 다시 기억하자.

public String identityHeaderKey(String headerKey){
    int maxRetry = 5;
    boolean claimed = false;
    for (int attempt = 1; attempt <= maxRetry && !claimed; attempt++) {
        try {
            idemProcessor.claimInProgress(headerKey);
            claimed = true;
        } catch (DataIntegrityViolationException e) {      // 영구: 중복
            IdemRecord alreadyDone = repository.findById(headerKey)
                .orElseThrow(() -> new IllegalArgumentException("not found"));
            if (Objects.equals(alreadyDone.getStatus(), IdemProcessor.Status.COMPLETED)) {
                return alreadyDone.getResponse();          // 결과 재생
            }
            throw new DuplicateKeyException("in progress"); // 즉시 거절(409)
        } catch (CannotAcquireLockException e) {            // 일시: 경합
            backoff(attempt);                               // 물러났다 재시도
        }
    }
    if (!claimed) throw new IllegalArgumentException("락 경합 재시도 초과");
    return idemProcessor.complete(headerKey, "cancelresponse");
}

catch가 정확히 갈린다. DataIntegrityViolationException루프를 끝내는 분기다 — 재생하거나 거절하고 즉시 나간다. CannotAcquireLockException루프를 도는 분기다 — backoff 후 다음 시도로. 선점에 성공하면(claimed) 루프를 빠져나와 complete로 느린 작업의 결과를 커밋한다.

일시적 예외의 재시도는 그냥 다시 때리는 게 아니라 지수 백오프 + 지터로 한다.

private void backoff(int attempt) {
    try {
        long base = 10L * (1 << attempt);          // 10 * 2^attempt (ms)
        long jitter = (long) (Math.random() * 10); // 지터
        Thread.sleep(base + jitter);
    } catch (InterruptedException interruptedException) {
        Thread.currentThread().interrupt();        // 인터럽트 상태 복원
        throw new IllegalArgumentException("재시도 시도 중 인터럽트 :", interruptedException);
    }
}

대기 시간을 시도마다 두 배로 늘려(10·2^attempt) 경합이 심할수록 더 물러난다. 지터를 더하는 건, 여러 스레드가 똑같은 간격으로 물러나면 다시 동시에 몰려와 또 부딪히기 때문이다 — 조금씩 어긋나게 흩뿌린다. 그리고 InterruptedException을 삼키지 않고 Thread.currentThread().interrupt()인터럽트 상태를 복원한다. 잡아서 없애버리면 상위에서 이 스레드가 중단 요청을 받았다는 사실을 영영 모른다.

6. 원리 — 느린 작업은 트랜잭션 밖으로

이 편의 모든 결정은 한 문장으로 수렴한다. 느린 작업을 트랜잭션 안에 두지 마라.

트랜잭션은 락과 커넥션을 쥔다. 그 안에 느린 작업이 들어오면, 쥔 시간이 곧 남을 기다리게 하는 시간이 된다. 그래서 트랜잭션은 선점처럼 짧게 끊고, 느린 일은 밖으로 뺀다. 상태기계도, 예외를 영구/일시로 가르는 것도, 전부 “밖으로 뺀 그 사이”를 안전하게 다루기 위한 장치였다.

이 감각은 스택을 넘는다. 그리고 다음 질문으로 곧장 이어진다 — 트랜잭션 밖으로 뺀 그 “느린 작업”이, 만약 진짜 **외부 세계(HTTP)**라면? 응답이 안 오면? 다음 편.

07.트랜잭션 안에서 HTTP를 부르지 마라