기술

외부 API를 트랜잭션 안에서 불렀다가 DB 전체가 멈출 뻔했습니다

2026.06.188분 읽기

한 B2B 식사 SaaS의 출시를 앞두고 백엔드 감사를 맡았습니다. 5개 영역을 병렬로 리뷰하고 DB 스키마는 직접 쿼리로 검증하는 식이었습니다. 처음에는 서로 다른 다섯 갈래의 결함 목록이 나올 줄 알았습니다. 그런데 정리하다 보니 겉모습만 다를 뿐 같은 뿌리에서 자란 문제들이었습니다.

공통점은 하나였습니다. DB 트랜잭션의 경계가 너무 넓어서, DB 작업이 아닌 것까지 그 안에 끌어안고 있었습니다. 외부 HTTP 호출이 트랜잭션 안에서 응답을 기다렸고, 실패를 기록하는 코드마저 실패하면 함께 롤백되는 트랜잭션 안에 들어가 있었습니다. 출시 전이라 사고는 나지 않았지만, 트래픽이 붙는 순간 터질 자리들이었습니다.


외부 호출이 트랜잭션 안에 있으면 느려지는 순간 전면 장애가 됩니다

가장 먼저 눈에 띈 것은 외부 통합회원 시스템, 그러니까 그룹 통합 멤버십 SaaS를 호출하는 코드였습니다. 회원 정보를 동기화하면서 외부 연동을 HTTP로 부르는데, 그 호출이 DB 트랜잭션 한가운데에 있었습니다. 코드만 보면 자연스럽습니다. 트랜잭션을 열고, DB를 쓰고, 외부를 부르고, 커밋합니다.

문제는 트랜잭션이 열려 있는 동안 DB 커넥션을 계속 쥐고 있다는 점입니다. 외부 호출이 1초면 1초, 10초면 10초 동안 그 커넥션은 반납되지 않습니다. 외부 시스템이 평소처럼 빠르면 티가 안 납니다. 하지만 외부가 느려지거나 무응답이 되면, 트랜잭션마다 커넥션 하나씩을 붙잡고 외부 응답을 기다리는 요청이 쌓입니다.

커넥션 풀에는 정해진 수의 커넥션밖에 없습니다. 외부를 기다리는 요청들이 풀을 다 점유해버리면, 외부와 아무 상관 없는 단순 조회 요청조차 커넥션을 못 받아 대기하다 죽습니다. 외부 시스템 하나가 느려졌을 뿐인데 우리 DB를 쓰는 모든 기능이 멈추는 전면 장애가 됩니다.

이미지1.png

그래서 외부 호출을 트랜잭션 밖으로 빼냈습니다. 선택지는 두 가지였습니다.

외부 호출 위치

장점

대가

트랜잭션 안

코드가 단순하고 DB와 외부가 한 덩어리로 묶임

외부 지연 시 커넥션 점유→풀 고갈→DB 무관 요청까지 전면 장애

트랜잭션 밖

외부가 느려도 커넥션을 즉시 반납, 풀이 안전

원자성이 깨져 'DB는 됐는데 외부는 실패'가 생김

안에 두는 단순함의 대가가 전면 장애라면 선택의 여지가 없었습니다. TransactionTemplate으로 DB 작업의 트랜잭션 경계를 명시적으로 좁히고, 외부 HTTP 호출은 그 경계 바깥에서 실행하도록 분리했습니다. 동시에 connect 5s, read 10s 타임아웃을 붙였습니다. 외부가 끝내 응답하지 않더라도 정해진 시간 뒤에는 끊고 빠져나오도록 해서, 무응답이 커넥션을 무한히 잡아먹는 경로를 막았습니다.


푸시 전송이 DB 커넥션을 쥐고 있을 이유가 없었습니다

같은 모양의 문제가 알림 발송에도 있었습니다. 발송 서비스에 클래스 레벨로 @Transactional이 걸려 있었고, 그 안에서 FCM 푸시를 HTTP로 보내고 있었습니다. 푸시 한 건이면 모르겠지만, 대량 발송이 돌면 전송이 끝날 때까지 DB 커넥션을 붙잡고 외부 푸시 서버를 기다리는 구조였습니다.

여기서 정리한 원칙은 단순합니다. DB에 꼭 써야 하는 것과 외부로 나가는 것을 분리하는 일입니다. 인앱 알림을 DB에 저장하는 일은 분명 DB 작업이니 트랜잭션이 필요합니다. 하지만 푸시를 외부로 쏘는 일은 DB 커넥션과 아무 관계가 없습니다.

그래서 발송 서비스의 @Transactional을 제거하고, 전송 오케스트레이션 자체는 트랜잭션 없이 돌게 했습니다. 인앱 알림 DB 저장만 별도 Writer로 떼어내 REQUIRES_NEW로 독립 커밋하도록 했습니다. 알림 기록은 자기만의 짧은 트랜잭션에서 커밋되고, 푸시 HTTP는 그 바깥에서 커넥션 점유 없이 나갑니다.


실패를 남기는 코드가 그 실패와 함께 사라지고 있었습니다

가장 고약했던 것은 세 번째였습니다. 동기화 서비스가 클래스 레벨 @Transactional이었는데, 동기화가 실패했을 때 그 실패를 로그로 남기는 코드까지 같은 트랜잭션 안에 들어 있었습니다.

흐름을 따라가 보면 모순이 보입니다. 동기화가 실패하면 트랜잭션이 롤백됩니다. 그런데 실패를 기록하는 코드도 그 트랜잭션 안에 있으니, 롤백될 때 실패 기록까지 같이 지워집니다. 정작 증거가 가장 필요한 순간에 그 증거가 사라지는 구조였습니다. 운영 입장에서는 '동기화가 가끔 안 맞는데 로그에는 아무것도 없다'가 되는 셈입니다.

이미지2.png

수정 자체는 작았습니다. 동기화 서비스의 클래스 레벨 @Transactional을 제거해서, 실패 기록이 본 작업의 롤백에 끌려가지 않게 했습니다. 탈퇴 처리 중 외부와 상태가 어긋나는 desync는 별도 경보 테이블에 남겨서, 본 트랜잭션이 어떻게 되든 그 사실만은 독립적으로 보존되도록 했습니다.


원자성을 포기한 대가는 가시성으로 갚아야 했습니다

외부 호출을 트랜잭션 밖으로 빼면 공짜로 얻는 게 아닙니다. 원자성이 깨집니다. DB는 커밋됐는데 그 뒤 외부 호출이 실패하면, DB와 외부의 상태가 어긋나는 구멍이 생깁니다. 트랜잭션 안에 다 넣었을 때는 적어도 '함께 성공하거나 함께 실패'였는데, 밖으로 빼면서 그 보장을 잃은 것입니다.

이 구멍을 메우는 방법은 원자성을 되찾는 게 아니라 어긋남을 보이게 만드는 것이었습니다. DB는 됐는데 외부가 실패한 경우를 desync로 감지해 경보로 남기고, 같은 desync가 반복 감지되면 중복 경보는 skip해서 노이즈를 줄였습니다. 운영이 'DB와 외부가 지금 어긋나 있다'를 눈으로 보고 손으로 맞출 수 있게 된 것입니다.

원자성을 포기했다면, 그 대가는 가시성으로 갚아야 합니다. 보이지 않는 불일치가 가장 위험합니다.

이 가드를 한 데서 부수효과를 경계 안에서 다룬다는 점은, 전에 0원 결제 사례를 정리하면서 본 결론과 같은 줄기였습니다. 경계를 어디에 긋느냐가 결국 무엇을 보장하고 무엇을 포기할지를 정합니다.


트랜잭션은 짧고 DB만, 외부와 부수효과는 경계 밖으로

세 결함은 다른 화면, 다른 기능, 다른 담당자의 코드였지만 처방은 하나로 모였습니다. 트랜잭션은 짧게, DB 작업만 담는다는 원칙입니다. 그 원칙을 실무 점검 항목으로 풀면 이렇습니다.

트랜잭션 안에서 외부 I/O(HTTP·푸시·결제·메일)를 호출하지 않는다. 외부 호출은 경계 밖으로 빼고 connect/read 타임아웃을 반드시 붙인다.

실패·감사·경보 로그는 실패하면 롤백될 트랜잭션에 두지 않는다. REQUIRES_NEW나 별도 트랜잭션으로 독립 커밋한다.

외부를 경계 밖으로 빼서 깨진 원자성은 desync 감지·보상·경보 한 세트로 가시화한다.

여러 DB 쓰기 사이의 원자성은 한 트랜잭션으로 묶되, 외부와 부수효과만 밖으로 들어낸다.

다만 이 원칙을 기계적으로 밀어붙이면 다른 함정에 빠집니다. REQUIRES_NEW는 별도 커넥션을 새로 잡기 때문에 남발하면 오히려 풀을 압박합니다. 감사나 경보처럼 정말 독립 커밋이 필요한 곳에만 써야 합니다. 트랜잭션을 너무 잘게 쪼개도 여러 DB 쓰기 사이의 원자성이 깨질 수 있습니다. DB만의 작업은 한 트랜잭션으로 묶는 게 맞습니다.

그리고 외부를 밖으로 빼는 것만으로 진짜 원자성이 필요한 자리를 해결하지는 못합니다. 결제처럼 DB와 외부가 반드시 함께 성공해야 하는 흐름이라면 outbox나 saga 같은 패턴이 필요합니다. 이번 감사에서 한 일은 그 단계까지 가기 전에, 적어도 외부 하나가 느려졌다고 DB 전체가 멈추는 경로부터 닫은 것입니다. 트랜잭션을 짧고 DB만으로 줄이고, 그 바깥에서 일어나는 일은 보이게 만들었습니다.

#운영의기술#트랜잭션#커넥션풀#외부API#타임아웃#백엔드감사#장애대응#스프링트랜잭션