본문으로 건너뛰기
오세두
프로젝트 목록

One-Step Checkout

3단계 결제를 1단계로 줄인 이야기

연도
2022년
역할
프론트엔드 개발
팀 규모
8명
기술 스택
  • React
  • Redux
  • Node.js
  • Playwright

2021년 11월 로그를 열어 보면, 결제 1단계에 진입한 모바일 사용자 중 2단계로 넘어간 비율이 62%였고, 3단계에서 결제 버튼까지 누른 비율은 그중 다시 71%였습니다. 곱하면 44%입니다. 월 300만 세션 규모에서 이 숫자는 "개선 여지가 있다" 정도의 문장이 아니라, 매달 수만 건의 주문이 배송지 입력과 결제 수단 선택 사이에서 사라지고 있다는 뜻이었습니다.

세션 리플레이 100여 건을 돌려 보니 이탈 패턴이 하나 반복됐습니다. 사용자가 2단계에서 앞서 입력한 주소를 다시 확인하려고 뒤로 가고, 돌아오면 폼이 비어 있고, 그 자리에서 창을 닫습니다. 단계 자체가 정보를 숨기고 있었습니다.

명백한 답, 그리고 그것이 부족했던 이유#

세 화면을 한 화면으로 합치는 일은 어렵지 않습니다. 저도 그렇게 생각했고, 2주 만에 기존 스텝 컴포넌트 세 개를 한 페이지에 세로로 쌓고 Redux의 스텝별 슬라이스를 하나로 합친 버전을 만들었습니다. 마지막 submit에서 전체 payload를 POST /orders로 한 번 보내는 구조였습니다.

스테이징에서 카드 거절 시나리오를 테스트하다 이 설계가 틀렸다는 걸 알았습니다. 한 화면 결제의 진짜 어려움은 화면을 합치는 게 아니라, 모든 것이 동시에 유효해야 한다는 점입니다. 검증, 결제 수단 선택, 에러 복구가 하나의 화면과 하나의 submit을 공유하는 순간 부분 실패가 예외가 아니라 핵심 케이스가 됩니다. 주소는 서버에 저장됐는데 결제가 거절되는 경우, 재고가 그사이 빠진 경우, 쿠폰이 만료된 경우가 모두 같은 submit 안에서 발생합니다. 단일 payload 구조에서는 이 중 무엇이 남고 무엇이 사라졌는지 클라이언트가 알 방법이 없었고, 사용자가 결제 버튼을 두 번 누르면 중복 주문 위험까지 있었습니다.

서버 draft를 화면의 단일 출처로 삼기#

그래서 구조를 뒤집었습니다. 결제 화면에 진입하면 서버가 먼저 draft order를 발급하고, 각 섹션은 독립적으로 PATCH로 자기 부분만 커밋합니다. submit은 새 주문을 만드는 요청이 아니라 이미 서버에 존재하는 draft를 confirm하는 요청이 됩니다. 화면은 자기가 진실을 들고 있다고 믿지 않고, 서버 상태를 비추는 거울이 됩니다.

type Section = "items" | "shipping" | "payment";

type CheckoutState = {
  draftId: string;                      // 진입 시 서버가 발급
  committed: Record<Section, boolean>;   // PATCH가 성공해 서버에 남은 것
  dirty: Record<Section, boolean>;       // 입력됐지만 아직 보내지 않은 것
  blocking: { section: Section; code: string } | null;
  idempotencyKey: string;                // confirm 재시도용. 거절 시 재발급
};

committeddirty를 분리한 게 핵심이었습니다. 결제가 거절돼도 주소는 committed: true로 남아 있으니 다시 입력받을 이유가 없고, 화면은 실패한 섹션 하나만 열어 두면 됩니다. 에러 복구 규칙도 실패 종류별로 명시했습니다.

실패서버 draft화면 동작
카드 거절유지, payment만 무효화payment 섹션만 열고 포커스, key 재발급
재고 소진items 라인 갱신수량 변경 사실을 인라인 표시, confirm 잠금
주소 검증 실패shipping 커밋 거부필드 단위 에러, 다른 섹션은 그대로
confirm 타임아웃상태 불명같은 key로 재시도, 중복 생성 없음

이 구조는 공짜가 아니었습니다. 결제 한 건당 네트워크 요청이 1회에서 평균 4.2회로 늘었습니다. 섹션별 PATCH를 debounce로 묶고, 사용자가 주소를 고치는 동안 결제 수단 PATCH가 먼저 도착하는 경합을 막기 위해 draft에 버전 번호를 두고 오래된 응답은 버렸습니다. 낙관적 UI도 섹션 단위로만 허용했습니다. 화면 전체를 낙관적으로 갱신하면 롤백해야 할 때 무엇을 되돌릴지 다시 알 수 없게 되기 때문입니다.

배운 것: 결제 화면의 상태 설계는 성공 경로가 아니라 부분 실패에서 무엇이 남아 있는가를 기준으로 시작해야 합니다. 성공 경로는 어느 구조에서든 동작합니다.

월 300만 세션 위에서 배포하기#

전체 매출이 지나가는 경로였으니 한 번에 바꿀 수는 없었습니다. 플래그로 트래픽을 1% → 5% → 20% → 50% → 100%로 6주간 올렸고, 각 구간의 롤백 기준을 올리기 전에 숫자로 못 박았습니다. 전환율이 아니라 결제 API 오류율과 거절 후 재시도 성공률을 기준으로 잡은 게 도움이 됐습니다. 전환율은 통계적으로 판단하려면 며칠이 필요한데, 재시도 성공률은 30분 안에 무너집니다.

Playwright 시나리오는 40여 개를 썼고 그중 절반이 실패·복구 경로였습니다. 결제사 sandbox의 거절 코드를 그대로 주입해서, 거절 후 다시 결제해 성공하는 경로를 매 배포마다 돌렸습니다. 8명 팀에서 결제 서버를 맡은 동료와 draft의 상태 전이표를 같은 문서에 두고 작업한 것이 이 단계에서 가장 크게 도움이 됐습니다. 프런트가 기대하는 잔존 상태와 서버가 실제로 남기는 상태가 어긋나는 순간을 리뷰에서 잡을 수 있었기 때문입니다. 최종 A/B 결과는 모바일 전환율 11% 개선이었습니다.

한 화면이 치른 비용#

솔직히 말하면 세 가지를 잃었습니다.

첫째, 작은 화면에서 폼이 길어졌습니다. iPhone SE 기준 문서 높이가 2,400px를 넘었고, 하단 sticky 요약 바와 "미완료 섹션으로 이동" 동작을 붙여야 했습니다. 그 이동 동작이 다시 스크롤 점프를 만들어서, 키보드가 올라온 상태에서의 스크롤 보정에 며칠을 더 썼습니다. 단계가 해 주던 일 하나는 진짜였습니다. 한 번에 하나만 보여 주는 것.

둘째, 퍼널 분석이 어려워졌습니다. 예전에는 URL 세 개만으로 어디서 사람이 빠지는지 알 수 있었는데, 이제 라우트가 하나입니다. 섹션 focus/blur 기반 커스텀 이벤트로 대체했지만 "섹션에 도달했다"의 정의를 팀이 새로 합의해야 했고, 이전 데이터와의 직접 비교는 포기했습니다.

셋째, 배포당 영향 범위가 커졌습니다. 전에는 결제 수단 버그가 3단계만 망쳤지만, 이제 한 라우트가 결제 전체입니다. 롤아웃을 6주로 늘린 이유가 여기에 있습니다.

다시 한다면#

draft order를 별도 리소스로 만들자고 더 밀어붙였을 것입니다. 서버 팀 일정이 빡빡해서 기존 cart API를 확장하는 쪽으로 타협했는데, cart의 수명은 30일이고 draft의 수명은 30분입니다. 이 두 TTL이 한 테이블에서 얽히면서 유령 draft를 정리하는 배치 잡을 이후 6개월 동안 유지보수했습니다. 당시에는 스프린트 두 개를 아끼는 선택이 합리적으로 보였고, 실제로는 훨씬 오래 갚았습니다.

그리고 계측을 나중에 붙인 것을 후회합니다. 섹션 단위 이벤트를 롤아웃 3주차에 넣어서, 1%와 5% 구간 데이터가 없습니다. 11%라는 결과는 A/B로 확인했지만 어느 변경이 얼마를 기여했는지는 지금도 설명하지 못합니다.