@Transactional 안에서 외부 API를 호출하면 안 되는 이유 (커넥션 풀 실측)

트랜잭션 안에서 외부 API를 부르면 6.5배 느려지고, 부하가 조금 오르면 35%가 실패합니다.

[배경 - 왜 안 된다고들 하는가]

@Transactional 안에서 외부 API를 호출하지 말라는 말은 자주 듣습니다. 저도 알고 있다고 생각했어요. 커넥션을 오래 잡고 있어서 안 좋다는 정도로요.

그런데 “얼마나 안 좋은지”는 답할 수 없었어요. 트래픽이 적은 서비스에서는 문제가 안 생기니 실감할 기회도 없었습니다.

마침 두 프로젝트 모두 외부 호출이 많습니다. 공지 알림은 FCM을 부르고, 메일 서비스는 Gmail API를 부르고, 클라우드 콘솔은 OpenStack API를 부릅니다. 그래서 재보기로 했어요.

[문제 상황 분석 - 커넥션은 언제 반납되는가]

트랜잭션의 수명이 곧 커넥션의 수명입니다

Spring에서 @Transactional 이 붙은 메서드에 들어가면 커넥션을 하나 가져옵니다. 그리고 메서드가 끝나 커밋되거나 롤백될 때까지 그 커넥션을 반납하지 않아요.

메서드 안에서 무엇을 하든 상관없습니다. DB 작업을 하든, 계산을 하든, 외부 API 응답을 기다리든 커넥션은 계속 잡혀 있어요.

@Transactional
public void handle(...) {
    order.markPending();          // DB 작업
    externalApi.call();           // 300ms 대기. 이 동안에도 커넥션은 내 것
    order.markCompleted();        // DB 작업
}

가운데 줄이 문제예요. DB 입장에서는 아무 일도 안 하는데 커넥션 하나가 묶여 있습니다.

산수로 보면 이렇습니다

커넥션 풀이 10개이고 외부 호출이 300ms 걸린다고 해봅시다.

한 요청이 커넥션을 300ms 붙잡으니, 10개 풀로는 초당 약 33건밖에 처리하지 못해요. DB가 아무리 한가해도 이 상한을 넘지 못합니다.

그리고 풀이 비면 다음 요청은 기다립니다. HikariCP의 connection-timeout 을 넘기면 예외가 나요.

HikariPool-1 - Connection is not available, request timed out after 3000ms

이 예외의 무서운 점은 원인과 증상이 멀리 떨어져 있다는 것입니다. FCM이 느려진 건데 그와 무관한 조회 API가 죽어요. 로그만 보면 DB 장애처럼 보입니다.

[해결 방법 - 트랜잭션 밖으로 빼기]

방법은 단순해요. 트랜잭션을 쪼개서 외부 호출을 밖으로 빼는 겁니다.

// 1. 짧은 트랜잭션으로 상태만 기록하고 커넥션 반납
service.markPending(orderId);

// 2. 커넥션 없이 외부 호출
Result result = externalApi.call();

// 3. 다시 짧은 트랜잭션으로 결과 반영
service.markCompleted(orderId, result);

커넥션을 잡는 구간이 DB 작업 시간만큼으로 줄어듭니다. 외부 응답을 기다리는 300ms 동안 커넥션은 풀에 돌아가 있어요.

대신 트랜잭션이 나뉘니 원자성이 깨집니다. 2번과 3번 사이에 서버가 죽으면 외부에는 반영됐는데 DB에는 안 남는 상태가 돼요. 이건 Outbox 패턴이나 보상 트랜잭션으로 따로 풀어야 하는 문제입니다. 공짜가 아니에요.

[성과 - 개선 전후 비교]

HikariCP 풀 10개, 외부 API 응답 300ms, 커넥션 획득 타임아웃 3초로 두 방식을 비교했습니다. PostgreSQL 15입니다.

동시 요청 60건

방식성공획득 실패전체 소요커넥션 대기 최대
트랜잭션 안에서 호출60건0건2,142ms1,837ms
트랜잭션 밖에서 호출60건0건330ms23ms

전체 소요가 6.5배 차이예요. 커넥션 대기 시간은 1,837ms 대 23ms로 80배 벌어졌습니다.

여기서는 실패가 없습니다. 60건을 10개 커넥션으로 처리하면 6회전이고, 6 × 300ms = 1.8초라 3초 타임아웃 안에 들어오거든요. 느리기만 하고 죽지는 않는 구간입니다.

동시 요청 150건

부하를 올려봤어요.

방식성공획득 실패전체 소요커넥션 대기 최대
트랜잭션 안에서 호출97건53건3,312ms3,000ms
트랜잭션 밖에서 호출150건0건356ms51ms

여기서 무너집니다. 150건 중 53건이 커넥션을 못 받고 실패했어요. 전체의 35%입니다.

반면 트랜잭션 밖으로 뺀 쪽은 150건을 356ms에 전부 처리했습니다. 60건일 때(330ms)와 거의 차이가 없어요. 커넥션을 짧게 쓰니 요청이 늘어도 회전이 빨라서 버팁니다.

두 표를 같이 보면 성격이 드러납니다. 트랜잭션 안에서 호출하는 구조는 부하가 늘 때 선형으로 나빠지는 게 아니라 임계점에서 무너져요. 60건에서는 그냥 느렸는데 150건에서는 3분의 1이 실패했습니다.

[결론]

@Transactional 안에서 외부 API를 호출하면, 같은 부하에서 처리 시간이 6.5배 늘고 부하가 조금만 더 오르면 35%가 실패합니다.

정리하면 이렇습니다.

  • 커넥션 점유 시간은 트랜잭션 수명과 같다. 외부 응답을 기다리는 동안에도 잡혀 있다
  • 풀 크기 나누기 외부 호출 시간이 처리량의 상한이 된다
  • 임계점을 넘으면 무관한 API까지 커넥션을 못 받아 같이 죽는다
  • 트랜잭션을 쪼개 외부 호출을 밖으로 빼면 해결되지만 원자성을 잃는다

남은 한계를 적어둘게요.

첫째, 원자성 문제를 이 글에서 풀지 않았습니다. 트랜잭션을 쪼개면 중간 상태가 생겨요. 저는 이걸 Outbox 테이블로 다루고 있는데, 그건 별도로 정리한 주제입니다.

둘째, 외부 호출을 300ms 고정으로 흉내냈습니다. 실제 API는 응답 시간이 들쭉날쭉하고, 꼬리 지연이 길면 상황이 더 나빠져요. 평균이 아니라 P99로 계산해야 정확합니다.

셋째, 풀 크기를 키우면 되는 것 아니냐는 반론이 가능합니다. 어느 정도는 맞아요. 다만 커넥션은 DB 쪽 자원이기도 해서 무한정 늘릴 수 없고, 근본적으로는 DB가 놀고 있는 시간에 커넥션을 잡아두는 구조 자체가 낭비입니다. 풀 크기는 DB 작업량에 맞춰야지 외부 API 응답 시간에 맞출 게 아니에요.

넷째, 비동기로 바꾸면 또 다른 이야기가 됩니다. 외부 호출을 별도 스레드로 넘기면 커넥션 문제는 사라지지만 스레드 풀과 관측성 문제가 새로 생겨요.

“안 좋다”고만 알고 있던 걸 숫자로 보니 훨씬 명확해졌습니다. 6.5배와 35%는 기억에 남는 숫자예요.