트랜잭션 안에서 외부 HTTP 를 호출하면 응답을 기다리는 동안 커넥션이 점유된다
커넥션 풀 고갈, 중복검사 통과, 부분 실패가 같은 원인에서 발생합니다. 필수가 아닌 호출은 커밋 이후에 실행하도록 분리했습니다
backend · cover image
@Transactional 메서드 안에서 외부 HTTP 를 동기 호출하면, 외부 응답이 느려질 때 DB 커넥션과 트랜잭션이 그 시간만큼 점유됩니다. 평소에는 응답이 빨라서 확인되지 않다가, 외부 응답이 느려지면 세 가지 문제가 발생합니다.
- 커넥션 풀 고갈: 호출당 커넥션 점유 시간이 수십 초로 증가하면서 작은 풀의 커넥션이 금방 소진됩니다. 같은 풀을 사용하는 무관한 경로까지 같이 실패합니다.
- 커밋 지연으로 중복검사가 동작하지 않음: 트랜잭션이 커밋되지 않는 동안, 커밋된 행만 조회하는 SELECT-then-INSERT 중복검사는 계속 통과합니다. 사용자가 응답을 수신하지 못하고 재제출하면 매번 새 행이 생성됩니다.
- 부분 실패로 정합성이 훼손됨: 외부 부수효과(과금, 적립)가 먼저 발생한 뒤 트랜잭션이 롤백되면, 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-effort | afterCommit 으로 분리 | 포인트 적립, 가입 안내 메일, 슬랙 웹훅 |
| 외부 과금이 먼저 발생하는 구조 | 설계 재검토 (트랜잭션 분리 + 보상 로직) | 결제 승인 후 DB 저장 |
세 번째는 afterCommit 으로 해결되지 않습니다. 결제 승인이 먼저 진행되고 그 뒤에 DB 저장이 실패하면 돈만 지출된 상태가 되므로, 보상 트랜잭션을 따로 설계해야 합니다. 저희 쪽에서는 아직 남은 과제입니다.
쓰면서 확인한 주의점
registerSynchronization은 활성 트랜잭션이 있어야 동작합니다. 없으면IllegalStateException이 발생합니다.@Transactional프록시를 통과하는 경로에서만 사용해야 하고, self-invocation 경로에서는 트랜잭션이 적용되지 않습니다.afterCommit도 요청 스레드에서 동기 실행됩니다. 커넥션과 트랜잭션은 반환됐지만 HTTP 응답 지연은 그대로 남습니다. 응답까지 빨라야 하면@Async나 이벤트를 추가로 사용합니다.afterCommit안에서 던진 예외는 이미 커밋된 트랜잭션을 롤백하지 못합니다. 반드시 catch 한 뒤 로그를 기록하거나 보상 처리를 합니다.afterCommit안에서 DB 를 다시 사용하면 새 커넥션과 트랜잭션이 필요합니다. 가벼운 로그성 insert 정도면 몰라도, 무거우면 별도 설계가 필요합니다.- 캡처 변수는 effectively-final 이어야 합니다. 익명 클래스 본문이 길어지면 private 메서드로 분리하는 편이 읽기 좋았습니다.