DB 트랜잭션은 성공했는데 API는 실패했다면? 분산 환경의 애매한 실패 다루기
분산 환경에서 개발하다 보면 언젠가 반드시 마주치는 상황이 발생한다. DB에는 데이터가 정상적으로 저장됐는데, 그 직후 호출한 외부 API가 실패한 것이다. 결제 완료 레코드는 DB에 남았지만 알림 서비스 API가 타임아웃 났거나, 주문 정보는 저장됐는데 재고 서비스에 차감 요청을 보내다 네트워크 오류가 발생한 경우가 대표적이다.
이런 상황을 단순히 “예외가 발생했으니 롤백하면 되지 않냐”고 생각할 수 있지만, 문제는 DB 트랜잭션이 이미 커밋된 이후에 실패가 발생했다는 점이다. 롤백할 수 있는 시점이 지났고, 시스템은 부분적으로만 성공한 애매한 상태에 놓인다.
왜 이런 상황이 생기는가
단일 서버에서 하나의 DB만 사용한 경우에는 트랜잭션 하나로 모든 작업을 묶을 수 있었다. 성공하면 커밋, 실패하면 롤백. 비교적 단순했다.
그런데 마이크로서비스 아키텍처나 외부 연동이 많아지면서 상황이 달라졌다. 각 서비스는 자체 DB를 갖고 있고, 서비스 간 통신은 HTTP나 메시지 큐를 통해 이루어진다. 이 경우 하나의 비즈니스 트랜잭션이 여러 서비스에 걸쳐 진행되는데, 전통적인 ACID 트랜잭션으로 이 전체를 묶을 수 없다.
예를 들어 주문 서비스가 주문을 저장하고(자체 DB 커밋), 재고 서비스에 HTTP로 차감 요청을 보냈다고 가정한다. 재고 서비스 응답을 기다리는 도중 타임아웃이 발생했다면, 재고 서비스가 실제로 차감을 처리했는지 알 수 없다. 요청이 도달하지 못했을 수도 있고, 처리는 됐는데 응답만 못 받았을 수도 있다. 이것이 분산 환경 특유의 ‘애매한 실패’이다.
상황을 유형별로 나눠보기
애매한 실패를 다루려면 먼저 어떤 상황인지 명확하게 구분해야 한다.
DB 커밋 후 API 호출 자체가 실패한 경우
DB에는 데이터가 저장됐지만 API 호출 코드에 버그가 있거나, 외부 서비스가 완전히 다운되어 요청 자체가 실패한 경우이다. 이 경우 외부 서비스는 아무런 상태 변화가 없으므로, 나중에 재시도하면 된다.
API 요청을 보냈지만 응답을 받지 못한 경우
네트워크 타임아웃이 대표적이다. 요청이 외부 서비스에 도달했는지조차 확실하지 않다. 외부 서비스가 처리를 완료했지만 응답 패킷이 유실됐을 수도 있다. 이 상태에서 무조건 재시도하면 중복 처리가 발생할 수 있다.
API가 오류 응답을 반환한 경우
5xx 서버 오류는 외부 서비스가 처리를 완료했는지 불확실하다. 반면 4xx 오류라면 서버가 요청을 거부한 것이므로 처리되지 않았을 가능성이 높다. 오류 코드에 따라 대응 방식이 달라져야 한다.
핵심 전략 – 멱등성(Idempotency) 설계
분산 환경의 애매한 실패를 근본적으로 다루는 가장 중요한 개념은 멱등성이다. 같은 요청을 여러 번 보내도 결과가 동일하다면, 타임아웃이 발생했을 때 안전하게 재시도할 수 있다.
API를 설계할 때 멱등성을 보장하는 방법은 다음과 같다.
- 멱등성 키(Idempotency Key) 사용: 요청마다 고유한 UUID 등을 생성해 헤더에 포함한다. 서버는 이 키를 기준으로 중복 요청인지 확인하고, 중복이면 이전 결과를 그대로 반환한다. Stripe 같은 결제 API가 이 방식을 사용한다.
- PUT/PATCH 방식의 상태 지정: “재고를 10 감소시켜라”처럼 상대값으로 요청하면 중복 처리 시 값이 두 번 차감된다. “재고를 90으로 설정하라”처럼 절대값으로 요청하면 중복 처리해도 결과가 같다.
- 조건부 업데이트(Conditional Update):
If-Match헤더나 버전 번호를 활용해 이미 처리된 요청은 서버가 무시하도록 한다.
멱등성이 보장된 API라면 타임아웃 발생 시 결과를 알 수 없더라도 재시도를 안전하게 수행할 수 있다.
아웃박스 패턴(Outbox Pattern)
멱등성과 함께 많이 쓰이는 패턴이 아웃박스 패턴이다. DB 커밋과 외부 API 호출이 원자적으로 함께 이루어지지 않는다는 문제를 해결하는 방법이다.
기본 아이디어는 다음과 같다.
- 비즈니스 로직을 처리하면서 외부에 보낼 메시지(또는 이벤트)를 같은 DB 트랜잭션 안에서
outbox테이블에 함께 저장한다. - 별도의 프로세스(또는 스케줄러)가
outbox테이블을 주기적으로 읽어 실제 API 호출이나 메시지 발행을 수행한다. - 전송에 성공하면 해당 레코드를 처리 완료로 표시한다.
이 방식의 핵심은 비즈니스 데이터와 전송 의도를 동일한 트랜잭션으로 묶는다는 점이다. DB 커밋이 성공했다면 아웃박스에도 메시지가 저장된 것이 보장된다. 이후 전송 과정에서 실패하더라도 아웃박스에 레코드가 남아 있으므로 재시도할 수 있다.
단, 이 패턴도 최소 한 번 전송(At-Least-Once Delivery)을 보장할 뿐 정확히 한 번(Exactly-Once)을 보장하지는 않는다. 그래서 수신 측의 멱등성 처리와 함께 사용하는 것이 이상적이다.
보상 트랜잭션(Compensating Transaction)
멱등성이나 아웃박스 패턴만으로는 해결하기 어려운 상황도 있다. 이미 커밋된 작업을 되돌려야 할 때는 보상 트랜잭션을 사용한다.
보상 트랜잭션은 이미 실행된 작업의 효과를 논리적으로 취소하는 작업이다. DB 롤백처럼 물리적으로 되돌리는 것이 아니라, 반대 효과를 가진 새로운 작업을 실행하는 방식이다.
예를 들어 결제 처리 후 배송 서비스 API 호출이 최종적으로 실패했다면, 결제를 취소하는 API를 별도로 호출해 상태를 맞추는 것이다. 이 보상 트랜잭션 자체도 실패할 수 있으므로 재시도 로직과 함께 구현해야 하며, 보상 작업 역시 멱등성이 보장되어야 한다.
사가(Saga) 패턴은 이러한 보상 트랜잭션을 체계적으로 조율하는 방법으로, 각 단계의 성공/실패에 따라 다음 단계 혹은 보상 단계를 결정하는 방식으로 분산 트랜잭션을 관리한다.
실무에서 바로 적용할 수 있는 체크리스트
이론을 정리하면, 실무에서 분산 환경의 애매한 실패를 다룰 때 점검해야 할 사항은 다음과 같다.
- 타임아웃은 반드시 설정한다: 응답을 무한정 기다리는 코드는 시스템 전체를 멈출 수 있다. 적절한 커넥션 타임아웃과 읽기 타임아웃을 명시적으로 설정해야 한다.
- 재시도 로직에 지수 백오프를 적용한다: 실패 직후 즉시 재시도하면 오히려 외부 서비스에 부하를 준다. 재시도 간격을 점점 늘리는 지수 백오프(Exponential Backoff)와 최대 재시도 횟수를 설정한다.
- 외부 호출 결과를 반드시 기록한다: 성공/실패 여부, 요청 ID, 타임스탬프를 로그나 DB에 남겨야 나중에 어느 단계에서 실패했는지 파악하고 수동으로 복구할 수 있다.
- 데드레터 큐(Dead Letter Queue)를 활용한다: 메시지 큐를 사용하는 경우, 일정 횟수 이상 처리에 실패한 메시지는 DLQ로 이동시켜 나중에 별도로 처리할 수 있게 한다.
- 알림과 모니터링을 구성한다: 아웃박스 테이블에 오래된 미전송 레코드가 쌓이거나, 보상 트랜잭션 실패가 반복된다면 즉시 담당자에게 알림이 가야 한다.
은탄환은 없다
분산 환경에서 일관성을 완벽하게 보장하는 단일 해법은 존재하지 않는다. CAP 정리가 말하듯, 네트워크 파티션이 발생하는 상황에서 가용성과 일관성을 동시에 완전히 만족시킬 수는 없다.
중요한 것은 시스템이 어떤 종류의 실패에 노출되어 있는지 정확히 파악하고, 비즈니스 요구사항에 맞는 수준의 일관성 보장 전략을 선택하는 것이다. 결제처럼 정합성이 절대적으로 중요한 영역과, 알림 발송처럼 최종 일관성으로 충분한 영역은 서로 다른 전략을 적용해야 한다.
DB 트랜잭션이 성공했는데 API가 실패한 상황을 마주쳤을 때, 이를 단순한 버그로 처리하지 않고 멱등성, 아웃박스 패턴, 보상 트랜잭션 같은 도구를 적재적소에 활용한다면 훨씬 견고한 시스템을 만들 수 있다.