포스트

트랜잭션 안에서 외부 API를 부르면 안 되는 이유

DB 트랜잭션이 열린 채 외부 API를 호출하면 커넥션을 점유한 채 네트워크 대기에 묶여 풀이 고갈된다. 외부 호출을 트랜잭션 경계 밖으로 빼는 설계를 다룬다.

트랜잭션 안에서 외부 API를 부르면 안 되는 이유

DB 저장과 외부 시스템 연동이 한 흐름에 섞인 작업을 한 적이 있다. 주문을 저장하면서 외부 결제·알림 API를 호출하는 식이다. 무심코 이 둘을 같은 @Transactional 메서드 안에 넣으면, 부하가 올라갈 때 시스템 전체가 멈춘다. 핵심은 “트랜잭션이 점유한 DB 커넥션을 느린 외부 호출에 묶어 두지 않는 것”이다.

트랜잭션은 시작과 동시에 커넥션을 잡는다

스프링에서 @Transactional 메서드가 시작되면 풀에서 DB 커넥션 하나를 빌려 와 메서드가 끝날 때까지 붙들고 있는다. 트랜잭션의 본질이 “이 작업이 끝날 때 함께 커밋/롤백한다”이므로, 같은 커넥션을 메서드 전체 동안 유지해야 하기 때문이다.

여기에 외부 HTTP 호출을 끼워 넣으면 어떤 일이 벌어지는가?

1
2
3
4
5
6
7
8
// 안티패턴: 트랜잭션 안에서 외부 호출
@Transactional
public void placeOrder(OrderRequest req) {
    Order order = orderRepository.save(req.toEntity());   // DB 커넥션 점유 시작
    paymentClient.charge(order.getId(), req.amount());     // 네트워크 대기 (수백ms~수초)
    notificationClient.send(order.getUserId());            // 또 대기
    order.markPaid();                                      // 여기까지 커넥션 계속 점유
}

DB 작업 자체는 수 밀리초면 끝난다. 그러나 외부 호출은 수백 밀리초에서 수 초, 상대가 느려지면 타임아웃까지 멈춰 있다. 그 시간 내내 DB 커넥션은 아무 일도 안 하면서 점유된다.

왜 시스템 전체가 멈추는가

커넥션 풀은 크기가 유한하다(예: 20개). 동시 요청 50개가 각각 트랜잭션 안에서 2초짜리 외부 호출을 기다린다고 하자. 커넥션 20개는 전부 “외부 응답 대기” 상태로 묶이고, 나머지 30개 요청은 커넥션을 못 빌려 대기 큐에 쌓인다. 외부 시스템 하나가 느려진 것뿐인데 내 DB 커넥션이 고갈되고 무관한 모든 요청이 줄줄이 막힌다. 장애가 전파된다.

graph LR
    R[요청 폭주] --> P{커넥션 풀 20}
    P -->|점유| W1[외부호출 대기]
    P -->|점유| W2[외부호출 대기]
    P -->|점유| W3[...20개 전부 대기]
    P -.->|빌릴 커넥션 없음| Q[대기 큐 폭증 → 타임아웃]

해법 — 외부 호출을 경계 밖으로

원칙은 단순하다. 트랜잭션은 짧게, DB 작업만. 외부 호출은 트랜잭션 밖에서.

1
2
3
4
5
6
7
8
9
10
11
12
// DB 작업만 트랜잭션으로 짧게
@Transactional
public Order createOrder(OrderRequest req) {
    return orderRepository.save(req.toEntity());   // 커밋 후 즉시 커넥션 반납
}

// 외부 호출은 트랜잭션이 끝난 뒤
public void placeOrder(OrderRequest req) {
    Order order = orderService.createOrder(req);    // 트랜잭션 1: 저장 (짧음)
    paymentClient.charge(order.getId(), req.amount());  // 커넥션 안 잡힌 상태에서 대기
    orderService.markPaid(order.getId());           // 트랜잭션 2: 상태 갱신 (짧음)
}

쪼개면 “저장은 됐는데 결제 실패” 같은 중간 상태가 생긴다. 이건 트랜잭션으로 묶어 가릴 문제가 아니라 명시적으로 설계할 문제다. 결제 전이라면 PENDING 상태로 두고, 실패 시 보상 처리(취소·재시도)를 한다. 결과를 확실히 반영해야 하면 외부 호출 의도를 DB에 먼저 기록하고 별도 워커가 재시도하는 아웃박스(outbox) 패턴을 쓴다.

커밋 직후에만 외부 호출을 하고 싶다면 트랜잭션 동기화 콜백을 쓴다.

1
2
3
4
5
6
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override public void afterCommit() {
            notificationClient.send(order.getUserId());  // 커밋 확정 후 발사
        }
    });

운영 함정

함정 1 — 타임아웃 미설정. 외부 클라이언트에 connect/read 타임아웃을 안 걸면, 상대가 응답을 영영 안 줄 때 호출 스레드가 무한 대기한다. 트랜잭션 밖이라도 스레드 풀이 고갈된다. 외부 호출에는 항상 타임아웃을 건다.

함정 2 — afterCommit 호출의 실패. 커밋 후 콜백에서 던진 예외는 이미 커밋된 트랜잭션을 롤백하지 못한다. 이 경로의 실패는 별도 재시도·로그로 다뤄야 한다.

면접 한 줄 Q&A

  • Q. 왜 트랜잭션 안에서 외부 API를 부르면 안 되나? A. 트랜잭션이 DB 커넥션을 메서드 끝까지 점유하는데, 느린 외부 호출이 그 커넥션을 네트워크 대기에 묶어 풀을 고갈시키고 장애를 전파한다.
  • Q. 그럼 저장과 외부 호출의 정합성은? A. 트랜잭션으로 묶지 말고 PENDING 상태·보상 처리·아웃박스로 명시 설계한다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.