개발 slecs

비회원 주문의 쿠폰 핀 발급 오류 수정

목차

비회원 주문이 끼는 순간 핀 생성 유틸이 조용히 망가지는 버그를 잡았다. 어드민 화면과 파트너 화면, 두 컨트롤러에서 같은 유틸을 쓰는데 비회원 분기 처리가 한쪽에만 있었고, 유틸 내부엔 null"0"으로 보정하는 레거시 코드가 살아 있었다. 두 구멍이 겹치는 케이스에서 NPE가 터지거나, 엉뚱한 회원에 매칭이 붙었다.

뭐가 문제였나

핀 생성 흐름을 처음 짤 때는 회원 식별값이 항상 있다고 가정했던 것 같다. 비회원 결제 비중이 작았으니 분기 처리를 미룬 거겠지. 그게 쌓이면서 세 곳에 각각 다른 방식으로 "비회원 예외 처리"가 덕지덕지 붙었다. 일관성이 없으니 한쪽을 고치면 다른 쪽이 터진다.

실제로 드러난 케이스는 크게 세 가지였다.

케이스 기존 동작 기대 동작
회원 주문 식별값으로 매칭 동일
비회원 + 연락처 있음 매칭 스킵 연락처로 매칭
비회원 + 연락처 없음 "0" 보정 후 충돌 null 유지 + 게스트 표시

세 번째 케이스가 제일 골치였다. 식별값을 null로 받으면 핀 유틸 내부에서 "0"으로 강제 캐스팅하는 로직이 있었는데, 이게 실제 회원번호 0번과 구분이 안 됐다. 시스템 초창기에 "일단 0 넣어두면 안 터지니까" 식으로 만든 보정 코드가 오랫동안 조용히 살아남은 거다. 연락처 매칭 유틸도 마찬가지 - 식별값이 있으면 그걸로 먼저 보고, 없으면 연락처를 보는 순서인데, "0"으로 보정이 먼저 돼버리면 연락처 분기 자체가 탔다.

이런 패턴은 꽤 흔하다. null을 특정 값으로 매핑하는 순간 "없음"과 "0번"의 구분이 사라지고, 이후에 타입이나 의미를 바로잡으려면 보정 코드가 있는 모든 지점을 다 찾아서 뒤집어야 한다. 처음에 nullnull로 그냥 흘려보냈으면 NPE 한 번 터지고 끝날 일이었을 수도 있다.

어떻게 고쳤나

보정 로직을 걷어내고, 비회원 여부를 별도 플래그로 명시적으로 들고 다니도록 바꿨다. 핀 유틸과 연락처 매칭 유틸 모두 식별값이 null일 수 있다는 전제를 정식으로 받아들이는 방향으로 다시 짰다.

isGuest = (memberSn == null)

if (isGuest) {
    pin = issueGuestPin(contactInfo)
    // 연락처 기반 매칭 시도 → 없으면 미매칭 큐로
} else {
    pin = issueMemberPin(memberSn)
    // 기존 회원 매칭 흐름 그대로
}

컨트롤러 양쪽(어드민/파트너)에서 각자 하던 비회원 판별 로직도 한 곳으로 모았다. 중복이 있으면 둘 중 하나가 빠지는 사고가 이번처럼 반복된다.

변경한 흐름에서 매칭 우선순위는 다음과 같이 명시했다.

  • 회원 식별값 있음 → 식별값으로 매칭
  • 식별값 없음 + 연락처 있음 → 연락처로 매칭
  • 식별값 없음 + 연락처 없음 → 매칭 실패, 미매칭 큐

매칭 실패라도 핀 자체는 발급된다. 미매칭 큐에 들어가면 운영팀이 사후에 연락처를 붙여 처리할 수 있다. 이 흐름은 이전에도 설계 의도로는 있었는데, 보정 로직 때문에 실제로는 탄 적이 없었다. 이번에 처음 제대로 살린 셈이다.

핀 발급 결과에 isGuestPin 플래그를 같이 내리는 것도 추가했다. 후속 알림이나 정산이 회원 발급과 분리되어 흐르도록 하기 위해서다. 같은 핀 발급이라도 회원/게스트 발급을 한 파이프라인에 섞어두면 나중에 통계나 정산 분리할 때 또 꼬인다.

파일은 결국 4개 건드렸다. 유틸 둘, 컨트롤러 둘. 패치 크기 대비 건드린 파일이 많다고 느낄 수 있는데, 애초에 비회원 개념이 한 군데 모여 있지 않아서 생긴 비용이다.

회고

이번 버그를 추적하면서 결국 중심 문제는 "비회원"이라는 상태가 도메인 모델에 1급 시민으로 자리잡지 못했다는 거였다. 회원 식별값 컬럼 하나에 "회원번호"와 "비회원 여부"라는 의미 두 개를 태운 게 화근이다. null이 "비회원"을 뜻하는데, 코드베이스 어디서도 그 약속이 명시적으로 선언된 적이 없다.

타입 시스템이 강한 언어라면 Optional<MemberSn>이나 별도 sealed type으로 이 구분을 강제할 수 있는데, 그게 없는 환경이라면 최소한 null을 어디서도 보정하지 않는 계약을 팀 전체가 공유해야 한다. 보정 코드가 한 군데 생기면 "이렇게 써도 되는구나"라고 읽히고, 비슷한 패턴이 번진다.

다음에 결제 플랫폼 쪽 모델 손볼 기회가 생기면 게스트 주문을 별도 타입으로 분리하는 걸 우선순위에 넣어야겠다. GuestOrderMemberOrder를 처음부터 다른 타입으로 다루면 컨트롤러에서 분기를 "까먹는" 일이 구조적으로 줄어든다. 지금 구조에서는 새 컨트롤러를 하나 더 만들 때 또 비회원 분기를 빠뜨릴 가능성이 있다.

미매칭 큐 흐름은 이번에 처음 제대로 살렸으니, 비회원 주문 비중이 늘어나는 추세에 맞춰 이쪽 모니터링 지표도 같이 잡기로 했다. 큐가 비어 있는 게 정상인지, 쌓이고 있는 건지 지켜볼 기준이 없으면 또 조용히 쌓이다가 터진다.

댓글 0

첫 댓글 달아줘.