일기 slecs

비회원 파트너 링크 결제 흐름과 쿠폰 발급 유틸 분리

목차

파트너 링크에서 유입된 비회원이 결제까지 완료하는 흐름을 새로 깔았다. WIP 커밋이라 절반 정도 된 상태.

비회원이 파트너 링크로 들어왔을 때

이커머스에서 회원 가입 → 로그인 → 구매가 정석이지만, 파트너가 외부 SNS에 링크를 뿌리는 시나리오에선 비회원도 그냥 결제까지 끝내고 싶다. 가입 강제하면 이탈률이 확 올라간다.

문제는 시스템이 "사용자 = 회원"이라는 전제 위에 쌓여 있다는 거다. 세션에 회원 식별값이 있다는 걸 당연하게 쓴 코드가 서비스 곳곳에 박혀 있고, 비회원이 들어오면 조용히 깨진다. NPE가 터지면 차라리 낫다. 조용히 0원 찍히는 게 더 무섭다.

비회원 결제 지원의 핵심 요소를 정리하면 이렇다:

  • 파트너 링크 파라미터를 유실 없이 결제까지 전달하는 체인
  • 회원 식별값에 의존하던 코드에 비회원 분기 추가
  • 장바구니, 가격, 쿠폰 등 회원 전제 로직의 격리 또는 추상화

이번 작업은 그 세 가지를 동시에 건드리는 거라 범위가 작지 않았다.

진입점에서 막힌 것들

상품 상세부터 손봤다. 기존엔 세션에 회원 식별값이 박혀 있다는 전제가 도처에 깔려 있어서, 비회원이 들어오면 NPE는 안 떠도 추천 가격/할인이 0원으로 찍히는 사일런트 버그가 있었다.

파트너 링크 파라미터를 쿠키에 보존한 이유는 결제까지 따라다녀야 해서다. URL 파라미터는 페이지 이동하면서 날아가기 쉽고, SPA에서 히스토리 조작이 들어가면 더 불안정하다. 짧은 TTL 쿠키에 박아두고 결제 완료 시점에 소비하는 게 안전하다.

추천인 식별 방식도 바꿨다. 기존엔 세션 기반으로 조회하던 걸, 링크 토큰 기준으로 재조회하도록 수정했다. 세션과 링크 토큰은 라이프사이클이 다르다. 세션은 브라우저 닫으면 날아갈 수 있고, 탭 분리 상황에서는 꼬이기도 한다. 링크 토큰을 기준으로 잡으면 이 두 문제가 사라진다.

구간 회원 흐름 비회원 흐름
상품 진입 세션 ID 기준 가격 링크 토큰 기준 가격
장바구니 DB 영속화 쿠키/임시 보관
결제 회원 정보 자동 채움 입력폼 수동 채움
쿠폰 보유분 + 링크 발급분 링크 발급분만

장바구니 쿠키/임시 보관 방식은 브라우저를 닫으면 날아간다. UX로는 아쉽지만, 비회원 장바구니를 DB에 영속화하면 고아 데이터 관리 문제가 생긴다. 어느 시점에 지울지, 만료 정책은 어떻게 잡을지 — 이게 생각보다 복잡해진다. 지금은 일단 임시 보관으로 가고, 나중에 필요하면 단기 TTL 테이블 쪽으로 올릴 계획이다.

쿠폰 발급 유틸 분리

기존 쿠폰 발급 로직이 회원 서비스 안에 private 메서드로 박혀 있었다. 비회원 흐름에서도 똑같이 발급해야 해서 공용 유틸로 떼냈다. 떼내고 보니 회원 쪽에도 중복 코드가 두 군데 있었다 — 이건 덤으로 정리했다.

전체 흐름은 이렇다.

파트너 링크 진입
  → 토큰 검증
  → 비회원 식별자 발급 (UUID + 쿠키)
  → 쿠폰 조건 매칭 → 임시 발급
  → 결제 확정 시 정식 발급으로 전환

"임시 발급 → 정식 전환" 단계를 두는 이유는 어뷰징 방어다. 결제 미완료 상태로 쿠폰을 영구 발급해버리면 결제를 끝내지 않아도 쿠폰을 챙길 수 있는 구멍이 생긴다. 결제대행사 콜백에서 한 번 더 확정하는 구조로 만들었다.

유틸로 분리할 때 인터페이스를 어떻게 잡느냐가 관건이었다. 회원 ID와 비회원 UUID를 함께 수용하려면 식별자 타입을 추상화해야 한다. 지금은 간단하게 issuerId: string, issuerType: 'MEMBER' | 'GUEST' 페어로 받도록 했다. 나중에 B2B 케이스가 생기면 타입 하나 더 추가하면 되는 구조다.

// 공용 유틸 시그니처 (개념 스케치)
function issueCoupon(params: {
  issuerId: string;
  issuerType: 'MEMBER' | 'GUEST';
  couponCode: string;
  status: 'TEMPORARY' | 'CONFIRMED';
}): Promise<CouponIssueResult>

회원 쪽에 있던 중복 코드 두 군데는 이 유틸을 쓰도록 교체했다. 사이드이펙트가 거의 없는 단순 정리라 테스트 통과는 바로 됐다.

남은 것, 그리고 제일 무서운 것

WIP로 자른 이유가 몇 가지 있다.

  • 결제대행사 콜백에서 비회원 주문건을 회원 주문건과 같은 큐로 흘릴지 분리할지 결정 못 했다
  • 비회원 주문 내역 조회를 주문번호+휴대폰으로 할지, 매직링크 메일로 할지도 미결
  • 파트너 정산 집계에 비회원 매출이 잡히는지 검증 안 됨

세 번째가 제일 무섭다. 정산 누락은 곧바로 파트너 컴플레인으로 이어지는데, 비회원 주문이 파트너 식별값 없이 떨어지면 통째로 정산에서 빠진다. 쿠키에 파트너 토큰을 보존하는 작업은 했지만, 그게 결제 완료 이벤트까지 제대로 넘어가는지는 아직 E2E로 확인 못 했다. 다음 작업 1순위로 잡았다.

결제 큐를 통합할지 분리할지는 트레이드오프가 있다. 통합하면 기존 주문 처리 파이프라인을 재사용할 수 있어서 구현이 빠르다. 단, 주문 처리 로직이 회원 ID를 필수로 가정하는 곳마다 nullable 처리가 줄줄이 들어가야 한다. 분리하면 비회원 전용 파이프라인을 따로 유지해야 하는 관리 비용이 생긴다. 아직 판단 유보 중이고, 콜백 구조 확인하면서 결정할 예정.

회고

회원/주문/상품/쿠폰 네 영역을 한 번에 건드리니 변경 범위가 너무 컸다. "이 정도면 리뷰 통과 못 하겠다" 싶어서 "비회원 진입 + 쿠폰 유틸 분리"까지만 1차로 끊었다. 결제/정산 연결은 후속 PR로 분리한다.

"한 번에 다 끝내자"는 본능을 누르고 끊어 친 게 이번 회고의 메인이다. 솔직히 처음엔 귀찮아서라도 한 PR에 다 넣고 싶었다. 근데 비회원 진입만 해도 파일 변경이 제법 되는데, 거기에 결제 콜백까지 붙이면 리뷰어가 어디를 봐야 할지 모르는 PR이 된다. 코드 변경 범위가 넓어질수록 리뷰 품질은 내려가고 머지까지 걸리는 시간은 늘어난다. 중요한 변경일수록 작게 끊어야 빠르게 검증되고, 문제 생겼을 때 롤백 범위도 좁다.

총괄 포지션에서 PR 범위를 조율하다 보면, 개발자가 "기왕 건드린 김에"라는 마음으로 범위를 키우는 패턴을 자주 본다. 이번에 내가 그 본능에 끌렸다. 결과적으로 끊은 게 맞았다고 생각하고, 이 판단 기준을 앞으로도 유지하려고 기록해 둔다.

댓글 0

첫 댓글 달아줘.