일기 slecs

주문 결제에 쿠폰 서버 재검증과 결제대행사 파라미터 통합

목차

주문 처리 흐름을 하루 종일 만지작거렸다. 결제까지 이어지는 흐름에서 쿠폰이 끼어드는 부분이 생각보다 훨씬 까다로웠음.

쿠폰 적용을 주문 흐름에 끼워넣기

이커머스에서 쿠폰은 "할인액 빼면 끝"이 아니다. 결제 직전에 금액을 다시 계산해야 하는 까다로운 친구임. 클라이언트가 "나 쿠폰 썼고 1만 원 할인됐어"라고 보내는 값을 서버가 그냥 받아서 쓰면, 누군가 할인액을 조작해 0원 결제를 만들 수 있다. 오늘 보니 기존 코드가 정확히 그 상태였음.

문제의 핵심은 TOCTOU(time-of-check, time-of-use)다. 클라이언트가 쿠폰 유효성을 확인한 시점과 실제 결제가 일어나는 시점 사이에 얼마든지 상태가 바뀔 수 있다. 쿠폰이 만료됐거나, 최소 주문금액 미만으로 장바구니가 수정됐거나, 이미 다른 세션에서 소진됐을 수 있음. 클라이언트 계산값이 그 순간에 맞았더라도 서버가 결제 처리 시점에 다시 검증하지 않으면 정합성이 깨진다.

그래서 서버 재계산 + 검증 단계를 강제로 끼워넣었다. 검증 순서는 아래처럼 정리했음.

  • 쿠폰 코드 존재 여부
  • 만료일 체크
  • 최소 주문금액 충족 여부
  • 중복 사용 여부 (같은 쿠폰을 동시 요청으로 이중 소진하는 케이스 포함)
  • 적용 후 금액이 클라이언트 전송값과 일치하는지 비교, 불일치 시 400으로 거절

적용 순서도 명확히 했다. 상품 할인 → 쿠폰 → 포인트 → 결제대행사로 넘길 최종 금액. 이 순서가 뒤바뀌면 할인 중첩이 이상하게 계산되는 케이스가 생기고, 특히 포인트를 쿠폰보다 먼저 차감하면 포인트 최대 사용 가능액 계산 자체가 틀려짐.

페이지 빌더와 위젯 쪽에는 쿠폰 노출 영역만 만들어두고, 주문 흐름에서는 코드 입력만 받아 백엔드가 끝까지 책임지는 구조로 정리했다. 클라이언트가 알아야 할 건 "이 코드 써도 됩니까?" 뿐이고, 실제 할인액 계산은 서버가 단독으로 한다.

결제대행사 API 파라미터 정리

파라미터가 여러 호출부에 산만하게 흩어져 있어서 수정할 때마다 키 이름이 헷갈렸음. 카멜케이스와 스네이크케이스가 혼재해서 더 짜증. 어디선 order_no, 어디선 orderNo, 금액은 어디선 문자열로 보내고 어디선 정수로 보내고.

항목 변경 전 변경 후
주문번호 orderNo / order_no 혼용 orderNo 통일
금액 문자열 "amount" 정수 amount
가맹점 ID mid / merchantId 혼용 merchantId
콜백 URL 호출부에서 직접 조립 설정값 주입

DTO 한 곳으로 모으고 직렬화 규칙도 하나로 못박았다. 빌더 패턴으로 바꾼 게 의외로 효과적이었음. 필수 파라미터를 빠뜨리면 컴파일 타임에 잡힌다. 전에는 런타임에 결제 요청 보내고 나서야 대행사 서버 에러 응답 받고 디버깅했는데, 그 레이턴시가 꽤 낭비였음.

// 변경 전 - 호출부마다 직접 조립, 오타 사고 잦음
Map<String, Object> params = new HashMap<>();
params.put("order_no", orderId);    // 어디선 orderNo, 어디선 order_no
params.put("amount", "15000");      // 문자열로 보내는 사고
params.put("mid", merchantId);      // merchantId와 혼용

// 변경 후 - DTO + 빌더, 필수값 누락 시 컴파일 에러
PaymentRequestDto request = PaymentRequestDto.builder()
    .orderNo(orderId)
    .amount(15000)
    .merchantId(merchantId)
    .callbackUrl(config.getCallbackUrl())
    .build();

응답 DTO도 요청 DTO와 분리했다. 요청이랑 응답을 하나의 클래스로 합쳐놓으면 어떤 필드가 어느 방향에 쓰이는지 금방 헷갈림. 분리하면 각 클래스의 필드 목록 자체가 문서 역할을 하고, 요청 변경이 응답 매핑에 사이드이펙트 주는 상황도 막힘.

SQL 매퍼명 수정

오타 + 케이스 불일치 때문에 매퍼를 못 찾는 사고가 있었음. selectOrderlistselectOrderList 같은 자잘한 것들. MyBatis에서 매퍼 ID가 틀리면 런타임에 Invalid bound statement 에러가 나는데, 호출부 Java 코드가 멀쩡해 보이니 원인 찾는 데 괜히 시간을 쓰게 된다.

매퍼 ID는 호출부 메서드명과 1:1로 맞추는 게 디버깅 속도에서 가장 차이남. 기준 하나 - grep 한 번으로 XML 매퍼 ID와 Java 메서드명이 동시에 잡혀야 함.

# 둘 중 하나만 나오면 매핑이 깨진 것
grep -rn "selectOrderList" --include="*.java" --include="*.xml"

자동 매핑에 의존하다 보면 IDE 리팩터링이 XML까지 따라오지 않아서, 명명 규칙을 사람이 직접 지키는 수밖에 없다. 내 API 영역에서 쓰던 매퍼도 같은 규칙으로 일괄 정리했음.

회고

오늘 만진 영역이 4개였다. 페이지 빌더, 위젯, 주문, 내 API. 각각 주제가 달라 보이는데 결국 한 줄로 수렴함 - "사용자 입력을 믿지 말고 서버 경계에서 다시 검증".

쿠폰도, 결제 파라미터도, 매퍼명도 전부 신뢰 경계(trust boundary)와 명명 일관성 문제였다. 쿠폰은 클라이언트 계산값을 신뢰했을 때 금전 사고로 이어지는 케이스, 파라미터는 명명이 흩어졌을 때 호출부마다 조금씩 다른 값이 들어가는 케이스, 매퍼명은 XML과 Java 사이의 묵시적 계약이 깨지는 케이스. 포장만 다를 뿐 같은 이야기임.

신뢰 경계라는 개념이 보안 문서에서나 나오는 말처럼 느껴졌는데, 막상 코드 레벨에서 보면 DTO를 어디서 만드느냐, 검증 로직이 어느 레이어에 있느냐 같은 설계 결정이랑 직결된다. 경계가 명확할수록 테스트 범위도 명확해지고, 뭔가 터졌을 때 어디를 봐야 하는지도 바로 보임.

내일은 쿠폰 중복 사용 케이스 테스트를 더 깎고, 결제대행사 응답 실패 시나리오 회귀 테스트를 붙일 예정이다. 동시 요청으로 같은 쿠폰을 이중 소진하는 레이스 컨디션은 DB 레벨 락 없이는 막기가 까다로운데, 그쪽도 같이 들여다봐야 할 것 같음.

댓글 0

첫 댓글 달아줘.