
트랜잭션 안에서 외부 API를 부르면, 그 응답이 돌아올 때까지 DB 커넥션은 커밋도 롤백도 못 한 채로 열려 있습니다. 이 대기 시간이 PostgreSQL의 pg_stat_activity에 idle in transaction 상태로 그대로 쌓입니다. API가 평소보다 느려지거나 타임아웃이 나면 그 시간만큼 커넥션 풀이 잠기고, 뒤에 들어온 요청들은 커넥션을 못 받아 줄줄이 대기하게 됩니다. 원인은 코드 한 줄짜리 습관인데, 결과는 서비스 전체의 지연으로 번집니다.
트랜잭션 안에서 외부 API를 호출하면 생기는 일
Django의 transaction.atomic()이나 Spring의 @Transactional은 블록이 끝나는 시점에 커밋을 실행합니다. 그 블록 안에 결제 API나 알림 API 호출이 들어가 있으면, 그 호출이 끝날 때까지 커밋이 밀리고 DB 커넥션도 같이 붙잡혀 있습니다.
with transaction.atomic():
order = Order.objects.create(user=user, amount=5000)
# 외부 결제 API 호출 - 네트워크 상황에 따라 2~3초, 길면 10초도 걸림
response = requests.post(
"https://pay.example.com/charge",
json={"order_id": order.id, "amount": 5000},
timeout=10,
)
order.status = "paid" if response.ok else "failed"
order.save()
코드만 보면 자연스러워 보이지만, requests.post가 끝나기 전까지 이 트랜잭션은 커밋되지 않습니다. DB 입장에서는 아무 쿼리도 안 들어오는데 트랜잭션만 열려 있는 상태, 그게 idle in transaction입니다.
pg_stat_activity로 idle in transaction 세션을 직접 확인해 보세요
의심되는 상황이라면 추측하지 말고 바로 조회해 보시는 게 빠릅니다. PostgreSQL은 모든 세션의 현재 상태를 pg_stat_activity 뷰로 보여줍니다.

SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_age DESC;
여기서 query 컬럼에는 마지막으로 실행됐던 SQL 문장이 그대로 남아 있습니다. 즉 “마지막 쿼리가 끝난 지 몇 초 지났는지”가 아니라 “트랜잭션이 시작된 지 몇 초 지났는지”를 봐야 하는데, xact_start가 바로 그 기준입니다. 외부 API를 기다리는 동안은 쿼리가 하나도 안 실행되지만 xact_age는 계속 늘어나고, 이 값이 수 초 단위로 쌓여 있는 세션이 여러 개 보인다면 바로 이 패턴을 의심해 보시면 됩니다.
idle in transaction이 쌓이면 락과 VACUUM이 지연됩니다
열려 있는 트랜잭션은 자기만의 스냅숏(xmin)을 쥐고 있습니다. 다른 트랜잭션과의 격리성을 지키기 위해 필요한 구조인데, 문제는 이 스냅숏이 유지되는 동안 그보다 오래된 죽은 튜플(dead tuple)을 autovacuum이 치우지 못한다는 점입니다.
트래픽이 많은 테이블에서 이런 트랜잭션이 몇 개만 오래 걸려도 테이블이 눈에 띄게 부풀어 오릅니다(bloat). 게다가 API 호출 직전에 이미 UPDATE나 INSERT로 잡아둔 행 락도 커밋 전까지 계속 유지되므로, 같은 행을 건드리려는 다른 트랜잭션은 그 시간만큼 그냥 대기합니다.
커넥션 풀이 금방 바닥나는 이유는 무엇일까요?
커넥션 풀의 크기는 보통 고정값입니다. 풀 크기가 20이고, 트랜잭션 하나가 API 응답을 기다리느라 평균 2.5초씩 커넥션을 붙잡는다면, 초당 8건만 들어와도 수학적으로 풀이 금방 포화 상태에 가까워집니다.
PgBouncer 같은 커넥션 풀러를 앞에 둬도 이 문제는 그대로 남습니다. PgBouncer의 transaction pooling 모드는 “트랜잭션이 끝나야 클라이언트 커넥션을 재사용”하는 방식이라, 트랜잭션 자체가 API 응답을 기다리며 길어지면 PgBouncer 입장에서도 그 백엔드 커넥션을 돌려받지 못합니다. 풀러는 커넥션을 효율적으로 나눠줄 뿐, 트랜잭션이 길어지는 근본 원인을 없애주지는 않습니다.

외부 API 호출을 트랜잭션 바깥으로 빼는 두 가지 방법
가장 단순한 방법은 DB 쓰기를 커밋한 뒤에 API를 호출하는 순서 변경입니다. 다만 이렇게 하면 “DB에는 저장됐지만 API 호출은 아직 안 됐거나 실패한” 상태가 잠깐 존재하므로, 실패했을 때 상태를 되돌리거나 재시도하는 보정 로직을 따로 만들어야 합니다.
더 안전한 방법은 outbox 패턴입니다. 같은 트랜잭션 안에서 “해야 할 일”을 outbox 테이블에 기록만 하고 커밋한 뒤, 별도의 워커가 그 테이블을 읽어서 API를 호출하고 결과를 다시 업데이트합니다. 트랜잭션 자체는 짧게 끝나지만, 워커와 폴링(또는 메시지 큐) 인프라가 추가로 필요합니다.
| 방식 | 트랜잭션 길이 | 일관성 보장 | 구현 복잡도 |
|---|---|---|---|
| 트랜잭션 안에서 바로 호출 (안티패턴) | API 응답 시간만큼 늘어남 | 강함 (같은 트랜잭션) | 낮음 |
| 커밋 후 호출 | 짧음 | 약함 (실패 시 보정 로직 필요) | 중간 |
| Outbox 패턴 | 짧음 | 강함 (비동기 보장) | 높음 |
타임아웃 설정만으로는 근본 해결이 안 됩니다
PostgreSQL 9.6부터는 idle_in_transaction_session_timeout이라는 설정으로, 트랜잭션이 아무 쿼리 없이 일정 시간을 넘기면 세션을 강제로 끊어버릴 수 있습니다. postgresql.conf에 전역으로 걸거나, 세션 단위로 SET idle_in_transaction_session_timeout = '5s';처럼 지정하면 됩니다. 자세한 조건과 단위는 공식 문서의 타임아웃 항목에 정리돼 있습니다.
다만 이 값을 너무 짧게 잡으면 부작용이 생깁니다. 정상적인 배치 작업이나, 가끔 응답이 느린 외부 API를 기다리는 합법적인 트랜잭션까지 “terminating connection due to idle-in-transaction timeout” 에러로 끊어버릴 수 있습니다. 이 설정은 bloat와 락이 무한정 쌓이는 걸 막는 안전망이지, 애초에 트랜잭션 안에서 외부 호출을 하지 않도록 코드를 바꾸는 작업을 대신해주지는 않습니다.
지금 운영 중인 서비스라면, 오늘 pg_stat_activity에서 state = 'idle in transaction'인 세션이 몇 개나 떠 있는지부터 확인해 보시길 권합니다. 있다면 어떤 트랜잭션이 어떤 외부 호출을 기다리고 있는지 코드에서 역추적해서, 그 호출을 커밋 뒤로 옮길 수 있는지부터 검토해 보시면 됩니다.
