[회고] 결제 시스템을 돌아보며

안녕하세요·2026년 6월 4일

1. 개요

과거 에듀테크 회사 재직 시절, 신규 앱 개발로 인해 서비스의 가짓수가 늘어나게 되었습니다. 당시 각 서비스별로 주문 도메인을 파편화되어 관리하기보다, 독자적인 결제 도메인을 따로 만들어 통합 관리하자는 판단에 따라 결제 도메인을 분리하게 되었습니다.

DB와 서버를 인프라 수준에서 격리하고 처음부터 결제 서비스를 개발하는 과정은 보람찼지만, 아쉽게도 당시에 이를 상세히 기록해두지 못했습니다. 이번 기회를 빌려 약 5년 전의 아키텍처를 다시 돌아보며, 그때 당시 제가 어떤 고민을 거쳐 시스템을 구축했었는지 복기하고, 지금의 시각과 비교해보고자 합니다.


2. 요구사항과 아키텍처

결제 시스템 다이어그램
결제 시스템은 각 개별 서비스가 주문(order)을 요청하면, 해당 요청 내용에 따라 외부 API(PG/인앱 스토어)를 호출하여 주문을 승인 처리하고, 서비스 측에서 주문 구매를 확인하면 재화를 지급한 뒤 주문을 최종 완료시키는 구조였습니다.

이러한 수명 주기 속에서 신뢰성을 확보하기 위해, 결제 시스템은 다음 사항들을 준수해야 했습니다.

  1. 결제 데이터의 위변조 차단: 특히 PG 결제의 경우 클라이언트 단에서 금액 변조를 시도할 수 있기 때문에, 실제 승인 전 서버 레이어에서 예비 주문을 생성하고 이를 검증하는 단계가 필요했습니다.
  2. 동일 주문에 대한 멱등성 보장: 네트워크 지연으로 인한 사용자의 중복 요청이나 외부 플랫폼(PG/스토어)의 중복 웹훅 발송 시에도, 중복 승인 및 재화 중복 지급이 발생하지 않도록 전역적인 멱등성을 유지해야 했습니다.
  3. 트랜잭션 유실 최소화 (Failover): 외부 API 통신 타임아웃이나 서비스 서버의 일시적인 장애로 인해 '결제는 완료되었으나 재화가 미지급된 주문'이 발생하더라도, 이를 복구할 수 있어야 했습니다.

이 세 가지 제약 조건을 해결하기 위해 전체 주문 처리 흐름을 [준비 - 승인 - 완료] 3단계로 격리하여 설계했습니다. 또한, AWS ECS Fargate를 활용한 다중 WAS 환경이었던 만큼 중복 진입 방지를 위한 예외 처리에 신경을 썼으며, 캐시와 Failover 메커니즘을 Redis로 구현하여 보완하고자 했습니다.


3. 결제 시스템 생명주기

준비 (PG)

PG 결제창 호출을 위한 세션을 확보하고 결제 신뢰성을 확보하는 단계입니다. PG사 외부 API가 요구하는 결제창 진입 URL(paymentUrl)을 서버 간 통신으로 안전하게 발급받습니다.

이 과정에서 결제 승인 전 RDB에 예비 주문 내역을 미리 등록해 둠으로써 결제 금액 위변조를 차단하고, 최종 결제까지 도달하지 못한 사용자의 결제 이탈 및 실패 추적을 위한 데이터를 확보합니다.

당시 설계하면서 중요하게 생각했던 로직은 다음과 같습니다.

  • 예비 주문 생성 및 컨텍스트 바인딩 (RDB & ThreadLocal)
    클라이언트의 요청이 진입하면 orders 테이블에 주문 상태를 requested로 삽입합니다. 이때 발급된 orderKey(주문을 서비스에서 식별하기 위한 키)와 orderId는 단일 요청 스레드 내부에서 안전하게 공유할 수 있도록 ThreadLocal에 바인딩하여 관리했습니다.

지금 시점에서 돌아본 ThreadLocal 도입의 타당성
당시에는 파라미터 오염을 막고 스레드 전역에서 주문 컨텍스트를 편리하게 공유하고자 ThreadLocal 기반의 유틸을 도입했습니다. 또한, 스레드 풀 오염 문제를 인지하고 있었기에 다음과 같이 서블릿 필터(Filter)를 구현하여 요청이 끝나는 시점에 컨텍스트를 비워주도록 방어벽을 세웠습니다.

 @Component
 public class ThreadLocalFilter implements Filter {
 
     @Override
     public void doFilter(ServletRequest request, >ServletResponse response, FilterChain chain) 
             throws IOException, ServletException {
         
         ThreadLocalUtil.init();
         chain.doFilter(request, response);
         ThreadLocalUtil.clean(); // 요청 종료 후 스레드 로컬 클린업
     }
 }

하지만 5년이 지난 지금의 시각으로 보면 두 가지 아쉬운 점이 보입니다.

  1. 예외 처리(try-finally)의 부재
    필터 내부에서 chain.doFilter()try - finally 블록으로 감싸지 않았기 때문에, 비즈니스 로직 중 예외가 발생하면 clean()이 실행되지 못하고 스레드가 오염된 채 풀에 반환되는 취약점이 있었습니다. 결제 도메인 특성상 예외 상황이 빈번하다는 점을 고려하면 위험한 구조입니다.
  2. 얕은 로직 대비 과도한 엔지니어링
    예비 주문으로 생성된 orderKeyorderId를 로컬 변수로 받아 다음 프로세스에 명시적으로 파라미터를 넘겨주는 구조였기에, 굳이 ThreadLocal이라는 전역 상태를 쓰지 않고 명시적인 메서드 인자 전달만으로도 충분히 안정적인 제어가 가능했을 것입니다. 기술의 편리함보다 결제 데이터의 명확성과 생명주기 제어를 최우선으로 두었어야 했다는 아쉬움이 남습니다.
  • 임시 주문 정보의 Redis 캐싱
    리턴받은 외부 결제 식별값과 내부 주문 식별자, 그리고 결제 요청 금액 등의 컨텍스트를 Order 객체 형태로 Redis에 적재합니다. 이 캐시는 사용자가 결제를 마치고 돌아와 최종 승인(Purchase) 단계를 밟을 때, DB 조회 없이 기존 주문 컨텍스트를 신속하게 복원하여 외부 PG 승인 API를 호출하기 위한 기반 데이터로 활용됩니다.

승인과 완료 (PG)

PG 결제 승인 요청을 통해 결제를 최종 확정하고, 해당 결과를 RDB 및 Redis에 반영하는 단계입니다. 이 과정에서 외부 PG사의 결제 승인 내역과 빌링 시스템의 주문 상태가 정확히 동기화되어야 합니다.

이후 서비스 서버가 유저에게 구매한 상품(재화)을 최종적으로 지급 처리한 후, 해당 주문을 빌링 시스템 내에서 완료 처리합니다. 이를 통해 PG 결제 거래가 종결되며, 해당 주문이 더 이상 재화 지급 대상으로 간주되지 않도록 상태가 갱신됩니다.

당시 설계하면서 중요하게 생각했던 로직은 다음과 같습니다.

  • 승인과 완료의 상태 분리를 통한 결함 격리
    결제창을 통해 돈이 빠져나가는 '승인' 단계와 유저에게 실제 재화를 지급하는 '완료' 단계는 결제 시스템에서 높은 수준의 데이터 정합성을 요구합니다. 저는 이 두 단계를 물리적인 API로 격리하여 외부 장애나 서비스 서버의 일시적 결함이 결제 데이터의 유실로 이어지지 않도록 설계했습니다.
  • Redis 캐시 상태 갱신 및 만료 시간(TTL) 설정
    PG사 최종 승인이 완료되면 Redis 내 캐시 주문 상태를 PURCHASED로 변경하고 만료 시간(TTL)을 300초(5분)로 설정합니다. 이 캐시는 추후 서비스 서버의 검증 요청이나 외부 플랫폼의 중복 웹훅 응답이 도달했을 때, RDB 조회를 수행하기 전 트랜잭션 검증과 멱등성을 보장하는 캐시 역할을 수행합니다.
  • 구매 주문 검증을 통한 어뷰징 차단
    재화 지급 전 주문 상태를 조회하여, 상태가 PURCHASED가 아닌 비정상적인 요청일 경우 예외(NOT_PURCHASED_ORDER)를 발생시킵니다. 이를 통해 승인되지 않은 주문에 대한 요청이나 중복 지급 시도를 사전에 차단하도록 구현했습니다.

인앱 결제

구글 플레이스토어와 애플 앱스토어를 통한 인앱 결제 역시 앞서 언급한 PG 결제의 [구매 - 완료] 단계의 결함 격리 및 정합성 보장 메커니즘을 동일하게 공유합니다. 상품 지급 전 빌링 시스템을 통해 주문 상태를 검증하고 최종 완료(completed) 처리를 수행하는 흐름은 구조적으로 유사합니다.

그러나 인앱 결제는 사전 결제 준비 단계 없이 기기 내에서 결제가 선행된 후 검증이 시작된다는 점, 그리고 단발성 구매가 아닌 '정기 구독(Subscription)' 도메인을 처리해야 한다는 점에서 명확한 차이 비즈니스가 존재합니다.

당시 인앱 구독을 구현하며 중요하게 처리했던 로직은 다음과 같습니다.

  • 각 스토어별 영수증 및 구매 토큰 검증
    스토어별 검증기 구현체를 통해 Apple의 verifyReceipt 서버 통신 및 Google Play API를 호출하여 기기에서 넘어온 영수증 데이터의 유효성을 1차적으로 검증합니다.
  • 구글 구독 결제 지연(Defer) 및 기간 연장 처리
    구독 상품 검증 시, 결제 지연일(deferDay)이 존재하는 특수한 비즈니스 케이스를 대응해야 했습니다. 이 경우 구글 API를 통해 구독 기간을 연장(deferSubscription)하는 별도의 파이프라인을 거치도록 처리했습니다.
  • 명시적 승인 통보(Acknowledgement)를 통한 결제 유실 방지
    인앱 결제는 검증이 완료된 후 스토어 측에 "우리가 이 결제를 정상적으로 확인했다"는 승인 통보(ackProduct 또는 ackSubscription)를 명시적으로 보내야만 결제가 최종 확정됩니다. 만약 일정 시간 내에 이 통보를 보내지 않으면 스토어에서 유저의 결제를 강제로 환불 처리하기 때문에, RDB에 상태를 영속화한 직후 안전하게 ack를 전송하도록 순서를 제어했습니다.

인앱 결제 동시성 제어

인앱 결제는 모바일 기기의 네트워크 환경 특성상 동일한 구매 토큰에 대한 중복 요청이 빈번하게 발생했습니다. 당시 비즈니스 로직은 이러한 중복 처리를 방어하기 위해 다음과 같은 흐름으로 작성되어 있었습니다.

// 1. 진입점 단계에서 Redis를 통한 구매 토큰 중복 pre-check
Optional<Order> purchasedOrder = orderRedisRepository.findByInAppOrder_PurchasedToken(req.getPurchasedToken());
if (purchasedOrder.isPresent()) { ... return 기존주문정보; }

// 2. 외부 스토어(Google/Apple) 검증 API 호출
inappOrderService.approve(order);

// 3. RDB에 인앱 결제 정보 영속화 (이 단계에서 내부 orderKey 생성 / synchronized 적용)
orderService.purchase(order); 

// 4. 최종 단계에서 임시 주문 정보 Redis 캐싱
orderService.cacheOrder(order);

내부 식별자(orderKey)가 3번 단계에서 생성되기 때문에, 최종 캐싱(4번)은 마지막에 수행될 수밖에 없었습니다. 이로 인해 외부 API를 호출하는 동안 동시성 요청을 막지 못하는 공백이 발생합니다.

[요청 A] ── (1) Redis 조회(데이터 없음) ── (2) 스토어 API 호출 (수백 ms 지연) ────── (3) RDB 저장 (orderKey 생성) ── (4) Redis 캐싱
                                             │─── 공백의 시간 (Race Condition) ───│
[요청 B] ─────────────────────────────────── (1) Redis 조회(데이터 없음) ──> 방어벽 통과

지금 시점에서 돌아본 동시성 제어

구분당시 구현 방식실제 작동 결과 및 한계
Redis Pre-Check진입점 토큰 조회 (1번)시간 차를 두고 유입되는 순차적 중복 요청은 차단하나, 위 타임라인과 같은 동시성 요청은 방어 불가능
JVM `synchronized`RDB 저장 레이어 로컬 락 (3번)AWS ECS 다중 WAS 환경이므로 다른 인스턴스로 인입되는 요청 제어 불가. I/O 구간 스레드 대기로 풀 고갈 위험 존재
RDB 제어데이터베이스 단의 Unique 제약조건최종 정합성은 지켰으나, 외부 API 중복 호출을 막지 못하고 무거운 DB 레이어에 예외 처리 책임을 전가함

지금 다시 설계한다면 DB 의존성을 제거하고 애플리케이션 진입점에서 orderKey를 먼저 발급하는 구조로 변경할 것입니다. 이후 외부 스토어 API를 호출하기 전 단계에서 Redis에 주문 정보를 '처리 중(PENDING)' 상태로 미리 등록하는 캐싱을 적용합니다. 이 방식을 사용하면 외부 API 호출 도중 동시성 요청이 유입되더라도, 진입점에서 중복을 감지하고 차단할 수 있습니다.


4. 결제 실패 복구

승인과 완료의 단계 분리는 결함 격리를 보장하지만, 두 단계 사이에서 시스템이 다운될 경우 주문이 PURCHASED 상태로 정체되는 부작용이 있습니다.

당시 시스템은 이러한 고립된 주문을 자동으로 구제하여 최종 정합성을 맞추기 위해 다음과 같이 배치 시스템을 설계했습니다.

  • ShedLock을 활용한 분산 스케줄러 동시성 제어
    서버 다중화(WAS 이중화) 환경에서 1분마다 실행되는 배치 작업(onOrderKeyExpired)이 각 인스턴스에서 중복 실행되는 문제를 방지해야 했습니다. 복구 알림이 유저에게 다중 발송되는 일을 막기 위해 @SchedulerLock을 적용했으며, Redis에서 락을 획득한 하나의 인스턴스만 배치 작업을 실행하도록 제어했습니다.
  • Redis TTL 기반의 타임아웃 감지 및 복구 알림
    Redis에서 PURCHASED 상태의 캐시 주문 목록을 일괄 조회한 뒤, 각 캐시 키의 남은 만료 시간(TTL)을 검사합니다. 최초 300초 중 지연 시간이 약 170초를 초과하여 남은 TTL이 130초 이하인 건을 상품 미지급 상태로 판정합니다. 조건이 수렴하면 해당 주문의 서비스 API 서버(/failover)로 HTTP POST 요청을 발송하여 정상적으로 재화가 지급되도록 복구를 유도합니다.
  • 에러 발생 시 재시도 생명주기 연장 및 임계치 제어
    서비스 서버와의 통신 중 RestClientException 등의 예외가 발생하면, 주문 객체의 실패 카운트를 증가시키고 다음 크론 주기에서 재시도될 수 있도록 Redis 캐시 키의 만료 시간(TTL)을 150초로 강제 연장합니다. 다만, 지속적인 실패로 인해 재시도 허용 임계치(isMaximumFail)에 도달한 주문 건은 시스템 리소스 낭비를 막기 위해 추가 복구를 중단하고 캐시에서 영구 삭제 처리합니다.

Redis를 사용한 이유
사용자가 많은 서비스는 아니었지만 그 당시 Redis를 최대한 활용하고 싶었고 아키텍처를 리뷰할 시니어 엔지니어도 없었기에 이대로 구현을 했었습니다.
그럼에도 Redis를 사용할 명목은 있었습니다.

  • 유휴 Redis 자원 활용
    당시 사내 인프라에서 Redis는 로그인 세션 관리 외에는 거의 사용되지 않고 있어 자원 공간에 여유가 있었습니다. 반면, 트래픽이 몰리는 비즈니스 핵심 RDB는 이미 많은 부하를 견디고 있었기에, 실패 복구의 실시간 모니터링 부담을 유휴 Redis 자원으로 이격시키는 것이 전체 시스템 가용성 측면에서 유리하다고 판단했습니다.
  • RDB 스키마 변경 및 테이블 생성의 부담 최소화
    분산 스케줄러 기능인 ShedLock을 구현하기 위해 RDB에 별도의 락 관리용 테이블을 생성하고 마이그레이션하는 것은 결제 시스템 운영 관점에서 적지 않은 부담이었습니다. RDB 레이어를 건드리지 않고 Redis 내에서 키 기반으로 락을 관리함으로써, DB 레이어의 구조를 간결하게 유지할 수 있었습니다.

그럼에도 이 구조가 타당한가?

  • Redis는 타당한가
    주문 상태를 RDB와 Redis에 동시에 두고, Redis TTL과 목록 조회 기능을 복구 배치 알고리즘의 핵심으로 삼은 구조는 당시 인프라 환경에서 효율적인 선택이었습니다. 하지만 엄격하게 보면 '원천 데이터'와 캐시의 경계가 흐려진 설계였습니다. Redis가 메모리 압박으로 키를 방출하거나 장애로 인해 재시작될 경우 복구 기준 자체가 유실될 위험이 있기 때문입니다. 단순 보조 수단이어야 할 캐시 계층을 업무 정합성의 판단 근거인 복구 제어면으로 활용한 점은 아키텍처적인 약점이 맞습니다.
  • /failover 호출은 타당한가
    배치 루프 내부에서 서비스 서버의 HTTP API를 동기식으로 직접 호출하는 방식 역시 복잡성과 취약점을 동반했습니다. 서비스 서버의 일시적 지연이나 장애가 배치 시스템의 스레드 병목으로 이어질 수 있고, 네트워크 단절로 인한 중복 호출 시 멱등성을 완벽히 보장하기 어렵기 때문입니다. 만약 지금 다시 설계한다면 동기식 REST API 호출 대신 AWS SQS 같은 메시지 큐 서비스를 활용할 것입니다. 배치는 정체된 주문을 감지하여 큐에 이벤트를 발행(Publish)하고 빠르게 빠지며, 서비스 서버가 이를 비동기로 소비(Consume)하여 재화를 지급하는 이벤트 기반 구조를 구축함으로써 시스템 간 결합도를 낮추고 안정성을 확보했을 것입니다.

5. 마무리

기술은 계속해서 변화하지만, '데이터의 신뢰성'이라는 결제 도메인의 본질적인 가치는 변하지 않습니다. 과거의 제약 조건 속에서 최선의 정답을 찾으려 했던 경험은, 현재 더 복잡한 시스템을 설계할 때도 기술적 트레이드오프를 판단하는 중요한 이정표가 될 것입니다.

profile
게으르지 않으려고 노력하는 사람

0개의 댓글