핵심 한 줄
유니크 dedup은 정확하지만 뒤늦은 중복을 락 앞에 줄 세운다. 승자가 커밋할 때까지 DB 커넥션을 붙잡고 기다리게 하는 대신, IN_PROGRESS/COMPLETED 상태를 심어 즉시 갈라친다 — 끝났으면 결과 재생, 처리 중이면 즉시 거절(409). 블로킹을 논블로킹으로. 그리고 이걸 하려면 “느린 작업은 트랜잭션 밖으로” 빼야 한다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
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=COMPLETED로UPDATE. 결과를 저장하고 커밋.
이제 뒤늦은 중복이 오면 어떻게 되나. 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은 프록시를 안 탄다). 둘 — 트랜잭션 전파. 짧은 선점 트랜잭션과, 트랜잭션 없는 바깥 복구 로직을 분리하려면 애초에 둘을 다른 빈으로 갈라놔야 한다. flush를 saveAndFlush로 강제한 것도 같은 맥락이다 — 유니크 위반을 커밋 시점이 아니라 바로 그 자리에서 터뜨려야 잡을 수 있으니까.
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)**라면? 응답이 안 오면? 다음 편.