donghakim.dev — zsh
← ls ../blog

트랜잭션 안에서 외부 HTTP 를 부르면 커넥션이 그만큼 잡혀 있다

커넥션 풀 고갈, 중복검사 무력화, 부분 실패가 같은 원인에서 나옵니다. 필수가 아닌 호출은 커밋 이후로 뺐습니다

backend · cover image

@Transactional 메서드 안에서 외부 HTTP 를 동기 호출하면, 외부 응답이 느려질 때 DB 커넥션과 트랜잭션이 그 시간만큼 잡혀 있습니다. 평소에는 응답이 빨라서 티가 안 나다가, 외부가 느려지는 순간 세 갈래로 터집니다.

  1. 커넥션 풀 고갈 — 호출당 커넥션 점유가 수십 초로 늘면서 작은 풀이 금방 마릅니다. 같은 풀을 쓰는 무관한 경로까지 같이 죽습니다.
  2. 커밋 지연으로 중복검사가 무력화 — 트랜잭션이 커밋되지 않는 동안, 커밋된 행만 조회하는 SELECT-then-INSERT 중복검사는 계속 통과합니다. 사용자가 응답을 못 받고 재제출하면 매번 새 행이 생깁니다.
  3. 부분 실패로 정합성이 깨짐 — 외부 부수효과(과금, 적립)가 먼저 일어난 뒤 트랜잭션이 롤백되면, DB 에는 없는데 외부에는 남는 불일치가 생깁니다.

프로모션 트래픽이 몰린 날 장애에서 세 가지가 전부 실제로 나왔습니다. 회원가입 트랜잭션이 길어지면서 중복 계정이 생겼고(2번), 결제 확인 경로가 커넥션을 오래 잡으면서 풀이 말랐고(1번), 롤백된 가입 건에 포인트만 적립돼 있었습니다(3번).


커밋 이후로 미루기

필수가 아닌 외부호출은 커밋 이후로 옮깁니다. Spring 의 TransactionSynchronizationManager 를 씁니다.

// @Transactional 메서드 안, DB write 를 마친 뒤
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        afterJoinCommit(user, isHashCode, customerNo);   // 외부호출 묶음을 private 메서드로
    }
});
return user;   // 이 직후 커밋 → 커밋 성공 시에만 afterCommit 실행
  • 트랜잭션은 DB write 만 담고 수백 ms 안에 커밋됩니다.
  • afterCommit 은 커밋이 성공했을 때만 실행되므로, 롤백되면 외부 부수효과도 안 일어나 정합성이 맞습니다.
  • 외부호출 실패는 catch 후 로그만 남깁니다. 가입 자체는 이미 유효하기 때문입니다.

무엇을 남기고 무엇을 뺄지

전부 빼면 되는 게 아니라, 실패했을 때 본 처리를 되돌려야 하는지로 갈립니다.

외부호출 성격위치
실패 시 롤백돼야 하는 필수 로직트랜잭션 안에 유지 (타임아웃은 짧게)가입 시 외부 시스템 프로필 생성
실패해도 본 처리가 유효한 best-effortafterCommit 으로 분리포인트 적립, 가입 안내 메일, 슬랙 웹훅
외부 과금이 먼저 일어나는 구조설계 재검토 (트랜잭션 분리 + 보상 로직)결제 승인 후 DB 저장

세 번째는 afterCommit 으로 해결되지 않습니다. 결제 승인이 먼저 일어나고 그 뒤에 DB 저장이 실패하면 돈만 나간 상태가 되므로, 보상 트랜잭션을 따로 설계해야 합니다. 저희 쪽에서는 아직 남은 과제입니다.


쓰면서 걸린 것들

  • registerSynchronization활성 트랜잭션이 있어야 동작합니다. 없으면 IllegalStateException 이 납니다. @Transactional 프록시를 통과하는 경로에서만 써야 하고, self-invocation 경로에서는 안 걸립니다.
  • afterCommit요청 스레드에서 동기 실행됩니다. 커넥션과 트랜잭션은 풀렸지만 HTTP 응답 지연은 그대로 남습니다. 응답까지 빨라야 하면 @Async 나 이벤트를 추가로 씁니다.
  • afterCommit 안에서 던진 예외는 이미 커밋된 트랜잭션을 되돌리지 못합니다. 반드시 catch 하고 로그나 보상 처리로 받습니다.
  • afterCommit 안에서 DB 를 다시 쓰면 새 커넥션과 트랜잭션이 필요합니다. 가벼운 로그성 insert 정도면 몰라도, 무거우면 별도 설계가 필요합니다.
  • 캡처 변수는 effectively-final 이어야 합니다. 익명 클래스 본문이 길어지면 private 메서드로 빼는 편이 읽기 좋았습니다.
#aftercommit#connection-pool#data-integrity#spring#transaction#webclient