트랜잭션 안에서 외부 API를 부르면 idle in transaction이 쌓입니다

트랜잭션 안에서 외부

트랜잭션 안에서 외부 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 호출과 트랜잭션 처리 백엔드 코드

외부 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'인 세션이 몇 개나 떠 있는지부터 확인해 보시길 권합니다. 있다면 어떤 트랜잭션이 어떤 외부 호출을 기다리고 있는지 코드에서 역추적해서, 그 호출을 커밋 뒤로 옮길 수 있는지부터 검토해 보시면 됩니다.

to_datetime이 전체 시간의 절반을 먹을 때 — format 지정의 효과

Leave a Comment