과거 에듀테크 회사 재직 시절, 신규 앱 개발로 인해 서비스의 가짓수가 늘어나게 되었습니다. 당시 각 서비스별로 주문 도메인을 파편화되어 관리하기보다, 독자적인 결제 도메인을 따로 만들어 통합 관리하자는 판단에 따라 결제 도메인을 분리하게 되었습니다.
DB와 서버를 인프라 수준에서 격리하고 처음부터 결제 서비스를 개발하는 과정은 보람찼지만, 아쉽게도 당시에 이를 상세히 기록해두지 못했습니다. 이번 기회를 빌려 약 5년 전의 아키텍처를 다시 돌아보며, 그때 당시 제가 어떤 고민을 거쳐 시스템을 구축했었는지 복기하고, 지금의 시각과 비교해보고자 합니다.

결제 시스템은 각 개별 서비스가 주문(order)을 요청하면, 해당 요청 내용에 따라 외부 API(PG/인앱 스토어)를 호출하여 주문을 승인 처리하고, 서비스 측에서 주문 구매를 확인하면 재화를 지급한 뒤 주문을 최종 완료시키는 구조였습니다.
이러한 수명 주기 속에서 신뢰성을 확보하기 위해, 결제 시스템은 다음 사항들을 준수해야 했습니다.
이 세 가지 제약 조건을 해결하기 위해 전체 주문 처리 흐름을 [준비 - 승인 - 완료] 3단계로 격리하여 설계했습니다. 또한, AWS ECS Fargate를 활용한 다중 WAS 환경이었던 만큼 중복 진입 방지를 위한 예외 처리에 신경을 썼으며, 캐시와 Failover 메커니즘을 Redis로 구현하여 보완하고자 했습니다.

PG 결제창 호출을 위한 세션을 확보하고 결제 신뢰성을 확보하는 단계입니다. PG사 외부 API가 요구하는 결제창 진입 URL(paymentUrl)을 서버 간 통신으로 안전하게 발급받습니다.
이 과정에서 결제 승인 전 RDB에 예비 주문 내역을 미리 등록해 둠으로써 결제 금액 위변조를 차단하고, 최종 결제까지 도달하지 못한 사용자의 결제 이탈 및 실패 추적을 위한 데이터를 확보합니다.
당시 설계하면서 중요하게 생각했던 로직은 다음과 같습니다.
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년이 지난 지금의 시각으로 보면 두 가지 아쉬운 점이 보입니다.
- 예외 처리(try-finally)의 부재
필터 내부에서chain.doFilter()를try - finally블록으로 감싸지 않았기 때문에, 비즈니스 로직 중 예외가 발생하면clean()이 실행되지 못하고 스레드가 오염된 채 풀에 반환되는 취약점이 있었습니다. 결제 도메인 특성상 예외 상황이 빈번하다는 점을 고려하면 위험한 구조입니다.- 얕은 로직 대비 과도한 엔지니어링
예비 주문으로 생성된orderKey와orderId를 로컬 변수로 받아 다음 프로세스에 명시적으로 파라미터를 넘겨주는 구조였기에, 굳이ThreadLocal이라는 전역 상태를 쓰지 않고 명시적인 메서드 인자 전달만으로도 충분히 안정적인 제어가 가능했을 것입니다. 기술의 편리함보다 결제 데이터의 명확성과 생명주기 제어를 최우선으로 두었어야 했다는 아쉬움이 남습니다.
Order 객체 형태로 Redis에 적재합니다. 이 캐시는 사용자가 결제를 마치고 돌아와 최종 승인(Purchase) 단계를 밟을 때, DB 조회 없이 기존 주문 컨텍스트를 신속하게 복원하여 외부 PG 승인 API를 호출하기 위한 기반 데이터로 활용됩니다.
PG 결제 승인 요청을 통해 결제를 최종 확정하고, 해당 결과를 RDB 및 Redis에 반영하는 단계입니다. 이 과정에서 외부 PG사의 결제 승인 내역과 빌링 시스템의 주문 상태가 정확히 동기화되어야 합니다.
이후 서비스 서버가 유저에게 구매한 상품(재화)을 최종적으로 지급 처리한 후, 해당 주문을 빌링 시스템 내에서 완료 처리합니다. 이를 통해 PG 결제 거래가 종결되며, 해당 주문이 더 이상 재화 지급 대상으로 간주되지 않도록 상태가 갱신됩니다.
당시 설계하면서 중요하게 생각했던 로직은 다음과 같습니다.
PURCHASED로 변경하고 만료 시간(TTL)을 300초(5분)로 설정합니다. 이 캐시는 추후 서비스 서버의 검증 요청이나 외부 플랫폼의 중복 웹훅 응답이 도달했을 때, RDB 조회를 수행하기 전 트랜잭션 검증과 멱등성을 보장하는 캐시 역할을 수행합니다.PURCHASED가 아닌 비정상적인 요청일 경우 예외(NOT_PURCHASED_ORDER)를 발생시킵니다. 이를 통해 승인되지 않은 주문에 대한 요청이나 중복 지급 시도를 사전에 차단하도록 구현했습니다.
구글 플레이스토어와 애플 앱스토어를 통한 인앱 결제 역시 앞서 언급한 PG 결제의 [구매 - 완료] 단계의 결함 격리 및 정합성 보장 메커니즘을 동일하게 공유합니다. 상품 지급 전 빌링 시스템을 통해 주문 상태를 검증하고 최종 완료(completed) 처리를 수행하는 흐름은 구조적으로 유사합니다.
그러나 인앱 결제는 사전 결제 준비 단계 없이 기기 내에서 결제가 선행된 후 검증이 시작된다는 점, 그리고 단발성 구매가 아닌 '정기 구독(Subscription)' 도메인을 처리해야 한다는 점에서 명확한 차이 비즈니스가 존재합니다.
당시 인앱 구독을 구현하며 중요하게 처리했던 로직은 다음과 같습니다.
verifyReceipt 서버 통신 및 Google Play API를 호출하여 기기에서 넘어온 영수증 데이터의 유효성을 1차적으로 검증합니다.deferDay)이 존재하는 특수한 비즈니스 케이스를 대응해야 했습니다. 이 경우 구글 API를 통해 구독 기간을 연장(deferSubscription)하는 별도의 파이프라인을 거치도록 처리했습니다.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 호출 도중 동시성 요청이 유입되더라도, 진입점에서 중복을 감지하고 차단할 수 있습니다.
승인과 완료의 단계 분리는 결함 격리를 보장하지만, 두 단계 사이에서 시스템이 다운될 경우 주문이 PURCHASED 상태로 정체되는 부작용이 있습니다.
당시 시스템은 이러한 고립된 주문을 자동으로 구제하여 최종 정합성을 맞추기 위해 다음과 같이 배치 시스템을 설계했습니다.

onOrderKeyExpired)이 각 인스턴스에서 중복 실행되는 문제를 방지해야 했습니다. 복구 알림이 유저에게 다중 발송되는 일을 막기 위해 @SchedulerLock을 적용했으며, 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 레이어의 구조를 간결하게 유지할 수 있었습니다.
/failover 호출은 타당한가기술은 계속해서 변화하지만, '데이터의 신뢰성'이라는 결제 도메인의 본질적인 가치는 변하지 않습니다. 과거의 제약 조건 속에서 최선의 정답을 찾으려 했던 경험은, 현재 더 복잡한 시스템을 설계할 때도 기술적 트레이드오프를 판단하는 중요한 이정표가 될 것입니다.