트랜잭션 안에서 외부 API 를 부르지 마라

결제사가 조금 느려졌을 뿐인데 결제와 상관없는 화면까지 우리 서비스가 통째로 멈췄어요. 트랜잭션 안에서 외부 API 를 부른 게 원인이었죠. 왜 그런지와 호출을 밖으로 빼는 법을 다뤄요.

주문을 저장하면서 결제 API 를 부르는 코드예요. 하나의 트랜잭션으로 묶어 두면 결제가 실패했을 때 주문도 같이 롤백되니까 안전해 보였어요.

평소엔 잘 돌았어요. 그런데 결제사 응답이 느려진 날, 우리 서비스가 통째로 멈췄어요. 결제와 아무 상관 없는 화면까지요.

DB 를 보니 커넥션이 전부 idle in transaction 이었어요. 쿼리를 돌리고 있는 게 아니라 전부 결제사 응답을 기다리는 중이었죠.

우리 코드는 한 줄도 안 바뀌었는데, 남의 서비스가 느려졌다는 이유로 우리가 죽은 겁니다. 그리고 결제사가 완전히 죽은 것도 아니었어요 — 그냥 조금 느려졌을 뿐이에요.

왜 그런가

트랜잭션을 연다는 건 DB 커넥션 하나를 점유한다는 뜻이에요. 그 안에서 외부 호출을 하면, 호출이 끝날 때까지 커넥션이 묶여 있어요.

문제는 시간 규모가 다르다는 거예요. DB 쿼리는 밀리초 단위지만 외부 API 는 초 단위예요. 평소 200ms 걸리던 호출이 5초로 늘어나면 커넥션 점유 시간이 25배가 돼요. 커넥션 수는 그대로인데 각자 25배 오래 붙잡고 있으니, 처리량은 25분의 1이 됩니다.

그리고 이건 곱해져요. 풀이 50개인데 각자 5초씩 잡고 있으면 초당 10건이 한계예요. 트래픽이 초당 100건이면 나머지 90건은 풀을 기다리며 쌓여요. 앱 스레드도 같이 말라붙고, 결제와 무관한 요청까지 커넥션을 못 받아요.

여기에 앞서 본 문제가 겹쳐요. 열린 트랜잭션은 스냅샷을 붙잡고 있어서 청소를 막아요. 결제사가 느린 동안 테이블이 부풀기 시작합니다.

정리하면 외부 호출을 트랜잭션에 넣는 순간, 우리 DB 의 가용성이 남의 서비스 응답 시간에 종속돼요. 우리가 통제할 수 없는 값에 우리 서비스의 수명이 걸리는 거예요.

코드로 보면

경계를 나눈다:

코드
 @Transactional
 void order() {
 save(order);
 paymentApi.charge();   // 여기서 5초를 기다린다
 save(payment);         // 그동안 커넥션은 묶여 있다
 }

· 1) 트랜잭션 안에서는 '보낼 것'만 기록한다
 @Transactional
 void order() {
 save(order);
 outbox.save(ChargeRequested(orderId));  // 같은 트랜잭션
 }

· 2) 커밋된 뒤 별도 워커가 실제로 호출한다
 worker: outbox 를 읽어 결제 API 호출 → 결과를 다시 저장
 실패하면 재시도. 멱등키를 반드시 함께 보낸다

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

해결의 핵심은 "원자성이 필요한 범위"를 다시 보는 거예요.

주문 저장과 결제 호출을 한 트랜잭션에 묶은 이유는 둘이 함께 성공하거나 함께 실패하길 원해서예요. 그런데 애초에 그게 불가능해요. 외부 API 는 우리 트랜잭션에 참여하지 않으니까요. 결제는 성공했는데 커밋 직전에 우리 서버가 죽으면, 롤백돼도 결제는 이미 나갔어요. 트랜잭션은 이 경우를 막아 주지 못해요.

즉, 트랜잭션으로 묶어서 얻는 안전은 애초에 환상이에요. 비용만 실재하고요.

표준적인 해법이 아웃박스 패턴이에요. 트랜잭션 안에서는 "결제를 요청해야 한다"는 사실만 같은 DB 에 기록해요. 주문과 이 기록은 같은 트랜잭션이니 원자적이에요. 커밋이 끝난 뒤 별도 워커가 그 기록을 읽어 실제 API 를 호출하고요.

이 구조의 이득은 셋이에요. 커넥션이 외부 응답을 기다리지 않고, 실패해도 기록이 남아 재시도가 가능하고, 외부 서비스가 죽어도 우리 주문 접수는 계속돼요.

대가는 결과적 일관성이에요. 주문 직후 잠깐 결제가 "처리 중" 상태예요. 이걸 UI 로 설계해야 해요. 대부분의 서비스에서 이건 오히려 정직한 표현이고, 사용자도 결제 처리에 시간이 걸린다는 걸 이해합니다.

그리고 재시도가 생기니 멱등성이 필수예요. 같은 결제 요청이 두 번 나가도 한 번만 청구되도록 멱등키를 보내야 해요. 이건 선택이 아니라 짝으로 오는 요구사항이에요.

처방

  1. 트랜잭션 경계에서 외부 호출을 밖으로 빼세요. 순서를 "트랜잭션 → 커밋 → 외부 호출"로 바꾸는 것만으로도 커넥션 점유가 사라져요.
  2. 원자성이 정말 필요하면 아웃박스 패턴을 쓰세요. 보낼 사실만 같은 트랜잭션에 기록하고, 실제 발송은 워커가 합니다.
  3. 재시도를 전제로 멱등키를 설계하세요. 아웃박스를 쓰는 순간 같은 요청이 두 번 나갈 수 있어요.
  4. 커밋 후 실행하는 훅을 쓸 때도 조심하세요. 프레임워크가 제공하는 커밋 후 콜백은 트랜잭션 밖이라 커넥션은 안 잡지만, 그 시점에 실패하면 아무 기록도 안 남아요. 아웃박스가 더 안전한 이유예요.
  5. 외부 호출에는 반드시 타임아웃을 거세요. 연결 타임아웃과 읽기 타임아웃 둘 다요. 이건 아웃박스를 쓰든 안 쓰든 필요해요.
  6. 코드 리뷰 규칙으로 못 박으세요. "트랜잭션 안에 HTTP 클라이언트 호출 금지"는 기계적으로 검사할 수 있는 규칙이에요. 정적 분석이나 아키텍처 테스트로 강제할 수 있습니다.

지금 확인해 보세요

커넥션이 무엇을 기다리는가:

코드
· idle in transaction 이 길면 트랜잭션 안에서 뭔가 기다리는 중이다
SELECT state, count(*),
       max(now() - state_change) AS longest
  FROM pg_stat_activity
 WHERE state LIKE 'idle in transaction%'
 GROUP BY state;
 1초를 넘는 게 상시로 있으면 외부 호출이 안에 있다

· 어떤 쿼리로 시작한 트랜잭션인지
SELECT pid, now()-xact_start AS age, left(query,60)
  FROM pg_stat_activity
 WHERE state = 'idle in transaction'
 ORDER BY age DESC LIMIT 10;

· 아웃박스로 바꿨다면 — 밀린 것의 나이를 본다
 SELECT count(*), max(now()-created_at) AS oldest
 FROM outbox WHERE processed_at IS NULL;

핵심 한 줄외부 호출은 트랜잭션 밖으로. 우리 가용성을 남에게 맡기지 마라.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →