비회원 쿠폰 바로구매 버튼 먹통과 파트너 코드 유실 수정
목차
이커머스 파트너가 공유한 쿠폰 링크로 비회원이 들어왔을 때, 바로구매 버튼이 아무 반응도 안 했다. 로그인한 회원은 멀쩡히 동작하고 있었으니 처음엔 쿠폰 자체 로직 문제인가 싶었는데, 직접 비회원으로 따라가 보니 클릭 후 완전 무반응이거나 엉뚱한 페이지로 튕겼다. 거기다 파트너 코드도 조용히 사라지고 있었다.
비회원 진입 동선이 무너졌던 이유
원인은 두 갈래였다.
쿠폰 상세에서 바로구매를 호출할 때 세션의 사용자 식별자를 무조건 참조하고 있었다. 회원 기준으로만 설계된 가격 계산 로직이 그대로 공개 엔드포인트에 붙어 있었던 것이다. 비회원은 세션에 식별자가 없으니 내부에서 null 참조가 발생하거나 early return으로 빠져나갔다. 버튼은 렌더링되는데 클릭해도 아무 일도 일어나지 않는 이상한 상태가 됐다.
두 번째는 파트너 추천 파라미터(ref)가 로그인 리다이렉트 한 번 거치면 휘발되는 문제였다. 비회원이 바로구매를 누르면 로그인 페이지로 보내는데, 이 시점에 returnUrl을 만드는 과정에서 ref 파라미터가 빠졌다. 로그인 완료 후 복귀해도 파트너 코드 없는 상태로 도착했다. 파트너 입장에서는 추천 전환이 집계 안 되고, 수수료 정산 근거도 사라지는 셈이었다.
이 두 가지가 동시에 작동하다 보니 증상도 제각각이었다. 버튼 무반응인 케이스, 로그인 후 전혀 다른 곳으로 복귀하는 케이스, 쿠폰은 적용됐는데 파트너 코드만 날아간 케이스. 단일 원인을 찾으려다 처음에 헛방을 쐈다.
이런 구조적 취약점은 로그인 게이트 앞뒤로 상태가 분리되는 구간에서 흔히 생긴다. 페이지 로드 시점과 인증 통과 시점 사이에 어떤 상태를 어떻게 이어붙일지 미리 설계해두지 않으면 자연스럽게 유실이 생기고, 회원 테스트만 통과시켜놓고 비회원 플로우를 검증하지 않으면 이런 식으로 조용히 쌓인다.
무엇을 고쳤나
쿠폰 컨트롤러에 비회원 분기를 추가했다. 비회원이 쿠폰 상세를 봐도 미리보기 가격이 정상 계산되고, 바로구매 버튼은 클릭 가능한 상태여야 한다. 클릭 이후 로그인 유도는 주문 컨트롤러에서 처리하는 게 자연스럽다. 버튼을 아예 disabled로 굳혀두는 건 "이 버튼이 왜 안 되지?"만 남기고 아무 안내도 없는 최악의 UX다.
버튼 disabled 조건도 바꿨다. 기존엔 로그인 여부만 보고 disabled를 결정했는데, "이 버튼을 눌렀을 때 다음 동작이 정의되어 있는가"로 관점을 옮겼다. 비회원이면 로그인 유도로 분기, 회원이면 구매 플로우로 진입. 같은 버튼, 다른 다음 단계.
주문 컨트롤러는 비회원 감지 시 로그인 페이지로 보내되, 원래 쿠폰 ID와 파트너 코드를 returnUrl에 담아 살아남게 했다.
| 케이스 | 이전 | 이후 |
|---|---|---|
| 회원 + 파트너 링크 | 정상 | 정상 |
| 비회원 + 파트너 링크 | 버튼 무반응 | 로그인 후 바로구매로 복귀 |
| 비회원 + 일반 링크 | 버튼 무반응 | 로그인 후 상세로 복귀 |
흐름을 따라가면 이렇다.
GET /coupon/{id}?ref=PARTNER123
→ 비회원 클릭 (바로구매)
→ /login?returnUrl=/order/buy?couponId={id}&ref=PARTNER123
→ 로그인 성공
→ returnUrl로 복귀, 파트너 코드 보존
returnUrl 검증에는 화이트리스트를 도입했다. returnUrl 파라미터를 허용하는 순간 오픈 리다이렉트 취약점이 생긴다. 로그인 완료 후 임의 외부 URL로 사용자를 보내버리는 공격 벡터인데, 허용 도메인이나 경로 prefix를 체크하는 것만으로도 대부분 막힌다. returnUrl을 쓰는 순간 이 체크는 선택지가 아니다.
삽질 포인트
가장 오래 잡아먹은 건 인코딩 이중 적용이었다. 쿼리스트링을 만들 때 한 번 인코딩하고, 로그인 처리부에서 URL을 조립할 때 또 한 번 들어가 있었다. 거기다 로그인 처리부에서 디코딩 한 번을 더 하면서 타이밍이 어긋났다. 파트너 코드가 %2526 꼴로 깨진 채 들어오는 걸 보고서야 원인을 찾았다. 인코딩 함수가 여러 곳에 흩어져 있으면 어디서 이중 적용됐는지 추적하기가 생각보다 어렵다.
수정 방향은 단순했다. 쿼리스트링 만들 때 인코딩 책임을 한 군데로 모은 것이다. URL 조립 유틸 함수 하나가 인코딩까지 처리하고, 나머지 코드는 raw 값만 넘긴다. 규칙이 하나면 이중 적용도 막히고, 나중에 누군가 건드릴 때도 혼선이 준다.
비슷한 로그인 게이트 패턴을 다룰 때 챙겨둘 것들:
- returnUrl은 화이트리스트로 검증한다. 허용 도메인 prefix 체크 정도면 대부분 커버된다.
- 쿼리 안에 쿼리가 중첩될 때는 인코딩 레이어 수를 명시적으로 설계하고, 디코딩도 같은 횟수만큼 일어나야 한다는 걸 코드 어딘가에 명시해두는 편이 낫다.
- 로그인 후 복원해야 할 상태 목록을 미리 정의한다. returnUrl로만 생존하는 파라미터가 있다면 테스트에서 그 구간을 명시적으로 통과시켜야 한다.
- 비회원 시나리오는 회원 케이스와 분리해서 통합 테스트로 관리한다. 회원 케이스만 있으면 이런 버그가 오래 버티다 터진다.
이번 수정 이후 통합 테스트에 비회원 플로우 케이스를 추가하고, 회원/비회원 두 케이스를 항상 같이 돌리게 구성해뒀다. 이후에 컨트롤러를 건드릴 때 같은 실수가 반복되면 잡힌다.
파트너 유입 트래픽 중 비회원 비중이 생각보다 컸다. 쿠폰 링크는 SNS나 메시지로 퍼지면서 서비스를 처음 접하는 사람들이 몰리는 특성이 있고, 그 사람들이 처음으로 회원가입을 결정하는 시점에 바로구매가 먹통이었던 셈이다. 이 동선이 막혀 있던 동안 전환이 얼마나 깎였을지는 지금도 가늠하기 어렵다.
로그인 게이트를 거치는 모든 버튼과 액션은 비회원 케이스를 1급 시민으로 다뤄야 한다는 교훈을 얻었다. 회원이 되고 나서 원래 하려던 걸 자연스럽게 이어갈 수 있어야 한다는 게 기본인데, 이번엔 그 설계를 후순위로 미뤄둔 채 조용히 쌓이다 터진 것이다. 다음 비슷한 화면을 만들 땐 비회원 플로우를 처음부터 그려놓고 시작할 생각이다. 로그인 후 어디로 복귀하고 어떤 상태를 복원할지까지, 설계 단계에서 명시해두는 게 맞다.
댓글 0
첫 댓글 달아줘.