새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Project

[Sansam E-commerce] 5. MVP 이후 리팩토링 #3 : 테스트 코드 작성과 Outbox 패턴 구현기

  • -

👀 들어가기 전에

테스트 코드를 작성하다 보니 지금까지 잘 작동한다고 믿었던 코드 안에서도 여전히 예외 상황과 불완전한 흐름이 존재한다는 점이 하나 둘 눈에 들어오기 시작했다.

특히,

  • 실패했을 때 시스템이 어떤 상태로 남는지
  • 그 상태가 사용자에게 어떤 경험으로 전달되는지

를 다시금 차분하게 돌아보게 되었다.

실패를 어떻게 다루는지가 곧 사용자 경험이라는 생각이 들었고 그 계기로 주문, 결제, 재고 흐름 전반을 다시 점검하며 리팩토링을 진행하게 되었다.


👀 본론

 

RDB기반 Outbox 패턴 도입 (RDB를 활용한 DLQ 유사 구조 구현)

재고 차감은 주문 생성 시점(DB기준)에서 수행하도록 설계했고, 단일 UPDATE + InnoDB Next-Key Lock 기반으로 동시성 제어 및 Oversell 방지를 적용했다. 하지만 여기까지 구현하고 나니 한 가지 사실이 분명해졌다.

 

재고 차감을 아무리 강하게 보호하더라도, 전체 주문 / 결제 플로우 관점에서는 여전히 실패 시나리오가 남아있다.


문제 인식 : 재고 차감 이후 발생 가능한 불완전 상태

 

다음과 같은 상황들은 언제든 발생할 수 있다.

  • 재고는 정상적으로 차감되었으나 로직 오류로 잘못 차감된 경우
  • 주문은 성공하고 PG 결제 승인까지 완료되었으나, DB 저장 단계에서 실패한 경우
  • 주문은 생성되었으나 PG 결제가 실패한 경우
  • 주문 생성 중 DB 저장 실패로 인해 재고만 차감되고 주문 정보가 유실된 경우

즉, 재고 차감 자체는 성공했지만, 그 이후 단계의 실패로 인해 시스템 전체 상태가 어긋나는 상황이 충분히 발생할 수 있었다.

따라서 재고차감을 안전하게 만드는 것만으로는 부족했고 실패 이후 상태를 복구할 수 있는 보상로직이 반드시 필요하다고 판단했다.


왜 RDB 기반 Outbox 패턴을 선택했는가?

 

이 시점에서의 판단 기준은 다음과 같았다.

  • 이미 RDB 중심의 트랜잭션 경계를 잘 활용하고 있는 구조
  • 목표 트래픽/성능을 초과하기 전까지는 Kafka나 RabbitMQ같은 외부 인프라 도입은 최대한 미루고 싶었다.
  • 새로운 인프라보다 현재 시스템 내에서의 신뢰성을 먼저 끌어올리는 것이 우선이라고 생각했다.

그래서 선택한 방식이 RDB기반의 Outbox 패턴이었다.

 

이 패턴을 사용한 핵심 의도는 아래와 같다.

  • 트랜잭션 내부에서 발생한 실패 이벤트를 반드시 DB에 기록
  • 즉시 처리하지 못한 작업을 비동기적으로 재처리
  • 외부 메시지 큐 없이도 DLQ와 유사한 구조의 구현이 필요하다.

재고 보상 로직 : 주문 완료 후 결제 실패 시 재고 복구

 

아래는 주문 정보는 정상적으로 저장되었지만 결제가 실패한 경우 재고를 복구하는 흐름이다.

 

흐름 요약

1. 주문 트랜잭션 종료 시점에서 결제 실패 상태 감지

2. 즉시 재고를 복구하지 않고 StockCompensationOutbox 테이블에 복구 이벤트 기록

3. 별도 워커 또는 스케줄러가 Outbox 테이블을 주기적으로 polling

4. 재고 복구 로직 시도

 

이 구조를 통해 일시적인 장애 (DB, 네트워크, 외부 시스템) 상황에서도 재고는 안전하게 복구된다.

사용자가 주문을 확정하고 결제를 안하고 나가게 되는 경우에도 재고 보상 로직을 통해 복구가 가능하게 되었다.


결제 보상 로직 : Payment Compensation Outbox

 

재고와 동일한 맥락에서 결제 영역에서도 보상 로직의 필요성이 드러났다.

문제가 된 시나리오는 아래와 같다.

 

  • PG 결제는 성공
  • 결제 정보 DB 저장 실패

사용자와 서비스의 상태 불일치

 

여기서 다음 질문이 생겼다. 이 상황에 대한 결제 보상은 어떻게 할 것인가? 

외부 트랜잭션을 태운 것에 대해서도 롤백이 가능한가?

 

결론부터 이야기하자면, 외부 api를 통한 트랜잭션에 대한 롤백은 불가하다.

해당 주문에 대해서는 결제 취소 처리를 시켜야하는 것이다.

아래와 같이 트랜잭션이 설계가 된다.

(사실 이 전에 했던 고민들로 외부 API 호출부가 분리되어있지만 그 부분에 대한 것은 다음 포스트에 작성하도록 하겠다.)

DB 저장 실패시의 로직

 

 

그렇다면 여기서 다시 한 번 생기는 의문이 있다.

Payment Cancel api 즉, 외부 pg사에서 문제가 생겨, Payment Cancel이 안되는 경우에 대해서는 어떻게 보상할 것인가. 혹은 방지할 것인가.

만약 외부 pg사로 가는 cancel로직까지 먹통이라면?

 

아까 위에서 사용한 아이디어를 조큼 얻어왔다. 결제 취소 또한 하나의 보상 이벤트로 간주했다.

 

 

Compensation Outbox

 

  • PG 결제 성공 + DB 저장 실패 시
    → 즉시 취소 API 호출 ❌
    → PaymentCompensationOutbox에 “결제 취소 필요” 이벤트 기록 ⭕
  • 별도 워커가 해당 이벤트를 재처리
  • 실패 시 재적재 → 사실상 RDB 기반 DLQ 역할

이를 통해 위에서 제기한 문제와 같은 상황을 방지하고 

결과

  • 결제 취소 실패로 인한 데이터 불일치 가능성 최소화
  • 외부 메시지 큐 없이도 보상 이벤트의 신뢰성, 추적성, 복구 가능성 확보

라는 효과를 얻을 수 있었다.


테스트 전략 및 Jacoco 커버리지 100%

 

보상 로직은 실패 시나리오가 핵심인 코드이기 때문에, 테스트를 통해 실행 경로를 최대한 보장하는 것이 중요하다고 판단했다.

단위 테스트를 통해, 정상 흐름, 실패 흐름, 예외 분기 최대한 검증을 했으며

통합 테스트를 통해 트랜잭션 경계, DB 연동, Outbox 재처리 흐름을 보완해 나갔다.

그 결과, Jacoco 기준 테스트 커버리지 100%를 달성할 수 있었다. 

(Domain부터 Repository, Service까지 모든 곳의 코드를 꼼꼼히 점검했다.)


테스트 커버리지 100%를 달성하며 느낀점

 

물론, 테스트 코드를 100% 달성하기까지 작성하며 '굳이 이렇게까지 해야하나?'라는 생각이 들었던 코드들도 많았던 것 같다.

사실 주요한 예외 처리만 잘 진행해서 70%~ 80%만 나오더라도 충분하다고 생각한다.

다만, 이번엔 사이드 프로젝트로 진행하는 만큼 이번 기회에 100%를 찍어보고 싶었다.

 

 

==========================================

2026.02.04

Unit Testing이라는 책을 거의 다 읽어가면서, 한때 테스트 커버리지 100%를 목표로 삼았던 것이 얼마나 피상적인 기준이었는지를 새삼 깨닫고 있다. 중요한 것은 커버리지 수치 자체가 아니라 테스트가 왜 존재하는가에 대한 본질적인 목적이다. 테스트의 핵심은 행위의 검증과 변경에 대한 신뢰 확보이지, 모든 분기를 빠짐없이 통과시키는 것이 아니다.

 

위처럼 분기마다 테스트를 강제하다 보면 테스트는 구현 세부사항과 과도하게 결합되고 그 결과 리팩터링에 대한 내성은 오히려 급격히 떨어질 수밖에 없다. 이는 테스트가 변경을 돕는 안전망이 아니라 변경을 가로막는 장애물이 되는 순간이다.

 

결국 커버리지 집착은 테스트의 본질과 목적에서 멀어지게 만드는 선택이었음을 이제야 분명히 인식하게 된다.

👀 결론

 

이번 리팩토링을 통해 가장 크게 느낀 점이 있다.

정상 흐름을 다루는 것만큼 실패를 어떻게 다루느냐가 중요하다.

 

재고, 주문, 결제는 사용자의 경험과 직결되는 도메인이다.

한 번의 실패 경험은 단순히 에러메시지로 끝나는게 아니라 서비스 전체에 대한 불신으로 이어질 수 있다.

그렇기에 기능구현도 좋지만, 

  • 실패했을 때 시스템이 어떤 상태로 남는가
  • 그 상태를 어떻게 복구할 수 있는가
  • 사용자는 이 과정을 어떻게 체감하는가

가 중요하다는 것을 체감했다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.