자동화 slecs

비회원 구매 흐름에 핀 검증과 주문 자동매칭까지 완성

목차

비회원 구매 흐름을 붙이는 작업은 처음 티켓 열었을 때만 해도 "그냥 게스트 세션 하나 파면 되겠지"였는데, 파고들수록 건드려야 할 게 늘어났다. 핀 검증, 배송지 저장, 주문-회원 자동매칭. 이 셋이 독립된 기능처럼 보이지만 사실 같은 경계 문제를 서로 다른 각도에서 바라보고 있었고, 결국 한 번에 묶어서 배포했다.

분리 배포를 고민하지 않은 건 아님. 근데 비회원 결제까지 열어놓고 매칭 로직을 나중에 붙이면, 그 사이에 발생한 주문은 어느 쪽에도 안 붙는 고아 상태로 남는다. 운영 쪽에서 수동 정리해야 하는 주문이 쌓이기 시작하면 결국 개발팀으로 다시 넘어온다. 차라리 배포 한 번을 크게 가는 게 낫다고 판단했음.

핀 검증 설계

비회원 인증 수단을 뭘로 할지가 첫 번째 결정 포인트였다. 이메일 링크 방식은 비회원이 주문할 때 쓴 이메일을 다시 확인해야 하는 컨텍스트 스위칭이 생기고, OTP는 발송 비용과 실패율 문제가 있음. 결국 4자리 핀 + 휴대폰 뒷 4자리 조합으로 결정했다. 기억 부담이 낮고, 두 값을 동시에 맞춰야 하니까 단순해 보여도 브루트포스 타겟이 되긴 어렵다.

저장 방식은 협의 없이 단방향 해시로 굳혔다. 핀을 평문으로 저장하거나 대칭 암호화하면, DB 유출 시 그 주문번호로 누구든 조회할 수 있게 된다. 해시에 주문번호를 솔트로 섞어서, 동일한 핀이라도 주문마다 다른 해시값이 나오게 했음.

import hashlib

def hash_pin(pin: str, order_id: str) -> str:
    salt = f"{order_id}:{pin}"
    return hashlib.sha256(salt.encode()).hexdigest()

def verify_pin(input_pin: str, order_id: str, stored_hash: str) -> bool:
    return hash_pin(input_pin, order_id) == stored_hash

레인지 어택 막는 용도로는 충분하다. 다만 솔트를 주문번호만 쓰면 예측 가능하다는 지적도 있었는데, 비회원 핀 자체가 고정 시크릿이 아니라 일회성 접근 수단에 가까워서 이 정도로 충분하다고 봤다.

시도 횟수 제한은 처음에 IP 기준으로 짰다가 롤백했다. 사내 테스트 중에 같은 망을 쓰는 다른 사람의 조회가 막히는 케이스가 바로 나왔음. IP 기준은 깔끔해 보이지만 NAT 환경이나 사무실 공유망에서는 안 통한다. 주문번호 기준으로 바꾸고, 5회 초과 시 해당 주문을 30분 잠금. 운영 화면에서 즉시 해제 가능하게 별도 어드민 액션도 열어뒀음.

인터셉터와 세션 경계

인증 게이트를 인터셉터로 뽑은 이유는 단순함. 컨트롤러마다 비회원 체크 로직을 심으면 나중에 라우트 하나 빠뜨리는 순간 구멍 생긴다. 화이트리스트 방식으로 비회원이 접근 가능한 경로만 명시하고, 나머지는 전부 차단.

permit: /guest/order/**, /guest/pin, /guest/track
deny:   그 외 관리/주문 라우트
세션 마커: GUEST_ORDER_TOKEN (만료 30분)

세션 마커 없이 차단 라우트에 진입하면 로그인 페이지가 아니라 핀 입력 페이지로 보냄. 비회원이 로그인 화면을 보면 "나 계정 없는데"하고 그냥 이탈하는 경우가 많았다. 핀 입력 페이지로 우회시키면 "내 주문번호 조회하는 거구나"가 바로 연결됨. 작은 차이인데 이탈률에 영향이 있다.

만료 30분은 의도된 짧은 시간이다. 비회원 세션은 권한 범위가 좁아서 길게 열어놓을 이유가 없고, 공용 단말에서 조회 후 닫지 않아도 짧게 끊어지는 게 낫다.

주문-회원 자동매칭

매칭 로직이 이번 작업에서 제일 까다로운 부분이었다. 비회원으로 결제했는데 해당 휴대폰 번호가 이미 가입되어 있으면, 그 사람이 나중에 로그인했을 때 주문 목록에 안 보인다. 운영 CS로 오는 "내 주문이 없어요" 유형의 절반 이상이 이 케이스였다.

케이스 매칭 키 처리 방식
휴대폰 정확히 일치 phone hash 즉시 연결
이름 + 생년월일 + 휴대폰 뒷자리 복합 키 후보로 표시, 운영 승인
매칭 후보 다수 - 자동 매칭 보류, 운영 큐로

매칭 처리는 결제 완료 훅에서 비동기로 돌린다. 결제 트랜잭션 안에 매칭 로직을 넣으면 두 가지 문제가 생김. 하나는 락 시간이 길어지는 것, 다른 하나는 매칭 실패가 결제 실패로 전이되는 것. 주문 연결이 안 된 게 결제 자체를 실패시키면 안 된다. 주문 연결은 나중에 고칠 수 있지만 결제 실패는 고객 경험에서 회복이 어렵다.

비동기 처리의 단점은 매칭 완료 시점이 결제 직후가 아닌 경우가 생긴다는 것. 큐 지연이 있으면 몇 초에서 몇 분 차이가 날 수 있는데, 이 작업에서는 허용 범위로 봤다. 즉시 연결이 안 되더라도 다음 로그인 시점에는 반드시 보여야 한다는 조건만 충족하면 됨.

배송지 분리

비회원 배송지를 회원 주소록 테이블에 같이 넣으면 나중에 쿼리마다 is_guest 같은 플래그로 분기해야 하고, 주소록 기능 수정할 때마다 비회원 케이스를 같이 고려해야 한다. 데이터 모델이 점점 복잡해지는 전형적인 패턴. 그래서 비회원 배송지는 주문 레코드에만 붙여서 저장했음.

자동매칭 성공 이후에 "주소록에 저장하시겠습니까"를 다음 로그인 시점에 한 번 묻는 방식을 선택했다. 결제 시점에 묻는 것보다 로그인 후 묻는 게 더 자연스럽고, 이미 회원임이 확인된 상태에서 저장하는 거라 주소록 테이블 오염도 없다.

경계 정의가 먼저였어야 했다

돌아보면 코드보다 회원/비회원 경계를 어디서 그을 것인가를 결정하는 데 시간을 더 썼다. 인터셉터·매칭·핀 셋이 겉으로는 독립된 기능 같아도, 사실 같은 경계 규칙을 다른 레이어에서 각각 구현한 것이었다. 핀 검증에서 세운 "비회원 접근 단위는 주문번호"라는 규칙이 인터셉터 세션 설계에 영향을 주고, 매칭 기준에도 영향을 줬다. 그 연결 고리를 처음부터 명확히 해놨다면 중간에 인터셉터 세션 설계를 한 번 뒤집는 일은 안 일어났을 것 같다.

다음에 비슷한 작업을 한다면 코드 짜기 전에 경계 정의를 표로 먼저 굳히고 리뷰받는 단계를 넣을 것 같다. 텍스트로 쓰면 각자 다르게 읽는 경우가 있고, 표나 매트릭스 형태로 정리하면 엣지 케이스가 빈칸으로 드러나서 토론하기 훨씬 쉬움.

댓글 0

첫 댓글 달아줘.