접수번호 칸에 스무 자 가까운 글자를 넣어야 조회가 됐다.
A/S 를 신청한 고객이 홈페이지에서 진행 상황을 볼 수 있게 하는 화면이었다. 고객은 회원이 아니다. 아이디도 비밀번호도 없다. 가진 건 접수할 때 받은 안내 문자 한 통과 자기 전화번호뿐이다. 그래서 처음 만든 조회는 그 두 가지를 받았다. 문자에 적힌 접수번호, 그리고 접수할 때 남긴 전화번호의 뒷자리 네 개.
동작은 했다. 그런데 폰을 들고 직접 쳐 보니 손이 먼저 알았다. 영문 두 글자로 시작해서 숫자가 길게 이어지는 번호를, 문자 앱과 브라우저를 오가며 옮겨 적어야 했다. 수리 기사를 기다리는 어르신이라면 더 힘들 일이었다.
회원이 아닌 사람에게 "내 것"을 증명하게 하기
번호 길이를 줄이기 전에, 이 조회가 무엇을 지키는지부터 정리해야 했다.
홈페이지는 Astro 로 만든 정적 사이트다. 서버 코드가 없으니 조회는 A/S 관리 시스템이 연 조회 API 하나에 기댄다. 홈페이지 폼은 번호 두 개를 POST 로 보내고, 맞으면 시스템이 그 접수의 진행 현황 화면 주소를 돌려준다. 진행 현황 화면은 원래 문자 링크로 열던 로그인 없는 보기 전용 화면이고, 주소 끝에는 추측할 수 없는 무작위 토큰이 붙어 있다. 조회는 결국 "번호 두 개를 맞히면 그 토큰을 내어 주는 창구"다.
const response = await fetch(form.dataset.endpoint!, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ receiptNumber, phoneLast4 }),
});
const result = await response.json().catch(() => ({}));
if (response.ok && result.ok && result.url && result.url.startsWith("https://")) {
location.href = result.url;
return;
}
API 가 돌려주는 건 이 주소 하나뿐이다. 이름도, 주소도, 접수 내용도 응답에 싣지 않는다. 틀렸을 때도 마찬가지로, 접수번호가 틀렸는지 전화번호가 틀렸는지 구분하지 않고 같은 문장만 돌려준다. 어느 쪽이 맞았는지 알려 주는 순간 두 번호를 따로따로 맞혀 볼 수 있게 되기 때문이다. 홈페이지 쪽에서는 받은 주소가 https:// 로 시작할 때만 이동하게 했다. 응답이 이상하게 오더라도 엉뚱한 곳으로 고객을 보내지 않기 위한 마지막 확인이다.
스무 자를 여섯 자리로
긴 접수번호의 문제는 길이만이 아니었다. 영문과 숫자가 섞여 있어서 폰에서는 자판을 바꿔 가며 쳐야 했다. 게다가 접수번호는 사내에서 쓰는 관리용 번호라, 고객이 외워 둘 이유도 그 모양에 익숙할 이유도 없었다.
그래서 조회 전용 번호를 따로 두기로 했다. 숫자 여섯 자리, 순번이 아니라 암호학적 난수로 뽑는다. 순번이 아니니 내 번호를 안다고 옆 번호를 짐작할 수 없다. 첫 자리가 0 이어도 된다. 접수가 등록되는 순간 발급해서 안내 문자에 접수번호와 함께 싣는다.
여섯 자리면 백만 가지다. 접수 건이 쌓이면 겹칠 수 있다. 그래서 겹치지 않아야 하는 범위를 좁혔다. 지금 조회할 수 있는 건, 즉 진행 중이거나 끝난 지 오래되지 않은 건끼리만 번호가 겹치지 않으면 된다. 조회 기간이 지난 건의 번호는 다시 쓸 수 있다. 새 번호를 뽑았는데 살아 있는 건과 겹치면 다시 뽑고, 그래도 계속 겹치면 번호 없이 둔다. 그 건은 긴 접수번호로 계속 조회할 수 있으니 조회가 막히지는 않는다.
옛 번호와 겹칠 수 있다는 건 조회할 때도 고려해야 했다. 같은 여섯 자리에 걸리는 후보를 여러 건 받아 와서, 전화번호 뒷자리가 맞는 것 중 조회 기간 안에 있는 건을 고른다.
입력 칸은 하나로 뒀다. 시스템 쪽에서 들어온 값을 보고 판단한다.
public static Query? ParseQuery(string? input)
{
var s = NormalizeReceipt(input); // 글자·숫자만 남기고 대문자로
if (s == null) return null;
if (s.All(char.IsAsciiDigit))
{
if (s.Length == LookupCodeLength) return new Query(QueryKind.LookupCode, s);
if (s.Length == ReceiptDigits) return new Query(QueryKind.Receipt, ReceiptPrefix + s);
}
return new Query(QueryKind.Receipt, s);
}
숫자 여섯 자리면 조회번호, 접수번호의 숫자 부분만큼 길면 앞의 영문을 빼먹은 접수번호로 보고 붙여 준다. 그 외는 지금처럼 접수번호다. 두 번째 갈래는 입력 칸에 숫자 자판(inputmode="numeric")을 띄우기로 하면서 생긴 것이다. 숫자 자판에서는 영문을 칠 수 없으니, 긴 번호를 넣는 고객은 숫자만 넣게 된다. 그걸 실패로 돌려보내지 않았다.
482 917 이라고 적어 보냈다
문자에 번호를 어떻게 적을지도 정해야 했다. 여섯 자리를 붙여 쓰면 한눈에 안 들어온다. 옮겨 적다가 한 자리를 빼먹거나 두 번 치기 쉽다. 전화번호를 끊어 읽듯 세 자리씩 끊었다. 문자에는 "조회번호: 482 917" 처럼 들어간다. 관리 화면과 접수증에도 같은 표기로 보이게 했다.
끊어 적으면 이번엔 입력 쪽이 그 공백을 견뎌야 한다. 고객은 본 대로 띄어 칠 수도, 붙여 칠 수도, 하이픈을 넣을 수도 있다. 홈페이지는 보내기 전에 공백과 하이픈을 지우고, 시스템은 글자와 숫자 말고는 다시 한 번 걸러 낸다. 어느 쪽으로 쳐도 같은 번호가 된다.
입력 칸 예시도 처음엔 482917 로 붙여 썼다가 문자와 똑같이 482 917 로 바꿨다. 본 모양과 예시가 다르면 띄어 써야 하는지부터 고민하게 된다. 칸 자체는 고정폭 글꼴에 글자 사이를 넓혀서, 쳐 넣은 숫자가 한 자리씩 또렷하게 보이게 했다.
몇 번 남았는지 크게 말해 주기
여섯 자리로 줄인 대가는 분명했다. 사람에게 쉬운 번호는 기계에게도 쉽다. 전화번호 뒷자리까지 함께 맞아야 하긴 하지만, 아무 제한이 없으면 같은 창구를 계속 두드려 볼 수 있다.
시스템은 틀린 시도를 기억한다. 같은 IP 에서 짧은 시간 안에 여러 번 틀리면 그 IP 의 조회를 잠시 막는다. IP 를 바꿔 가며 두드리는 경우를 위해 전체 실패 횟수에도 따로 상한을 뒀다. 기록은 메모리에만 두고, 일정 시간이 지난 실패부터 차례로 빠진다. 실패 로그에는 입력한 번호를 남기지 않는다.
이 장치를 붙이고 나서 엉뚱한 곳에서 문제가 났다. 서버에는 없는 주소를 훑는 정찰 요청을 잡아 IP 를 차단하는 장치가 따로 있었는데, 조회가 "맞는 접수 없음"을 404 로 답하자 그 404 들이 정찰로 오인됐다. 번호를 몇 번 잘못 친 고객이 조회뿐 아니라 사이트 전체에서 막힐 수 있는 구조였다. 실제 기능에 연결된 요청은 404 여도 정찰 기록에서 빼는 것으로 고쳤다. 없는 주소를 훑는 요청은 지금도 그대로 잡힌다.
막는 것까지는 서버의 일이다. 남은 건 막히는 사람에게 어떻게 알리느냐였다. 처음엔 틀릴 때마다 같은 빨간 문장만 나왔다. 몇 번을 더 틀릴 수 있는지, 막혔다면 언제 풀리는지 알 길이 없었다.
그래서 시스템이 틀렸을 때 남은 횟수를, 막혔을 때 다시 조회할 수 있기까지 남은 분을 숫자로 함께 돌려주게 했다. 홈페이지는 그 숫자를 보고 화면을 바꾼다.
// 막힘 — 429, 또는 이번에 틀려서 남은 횟수가 0
if (response.status === 429 || result.remainingTries === 0) {
blocked(Math.max(1, result.retryAfterMinutes ?? FALLBACK_MINUTES));
return;
}
// 남은 횟수가 몇 번 안 남았으면 노란 경고 상자를 따로 크게
if (typeof result.remainingTries === "number" && result.remainingTries <= WARN_AT) {
show(form.dataset.notFound ?? "", false);
warn(
fill(form.dataset.triesTitle ?? "", result.remainingTries),
fill(form.dataset.triesBody ?? "", result.remainingTries),
);
}
남은 횟수가 넉넉할 때는 "번호를 다시 확인해 주세요" 한 줄이면 된다. 몇 번 안 남았을 때부터는 빨간 안내 아래에 굵은 테두리의 노란 상자가 따로 뜬다. 제목은 "앞으로 N번 더 틀리면 조회가 막힙니다", 본문은 문자로 받은 번호를 천천히 다시 확인해 달라는 말이다. 경고는 읽히지 않으면 없는 것과 같다.
막히면 노란 상자가 "N분 동안 조회가 막혔습니다"로 바뀌고, 급하면 고객만족센터로 전화하라는 안내가 붙는다. 조회 버튼은 그 시간만큼 잠기고, 버튼 글자가 "N분 뒤 다시 조회"로 바뀐다. 남은 분은 15초마다 다시 계산하고, 0 이 되면 버튼이 풀리면서 경고도 사라진다. 버튼을 잠그는 건 어디까지나 안내다. 새로 고침하면 버튼은 풀리지만, 실제로 막는 건 서버라 다음 조회에서 다시 같은 화면을 만난다. 화면이 서버보다 관대할 수는 있어도, 화면이 서버의 규칙을 대신하지는 않게 했다.
실제 제한 숫자는 홈페이지 코드에 들어 있지 않다. 몇 번, 몇 분인지는 늘 서버가 알려 준 값을 그대로 보여 준다. 서버에서 규칙을 바꿔도 홈페이지를 다시 배포할 필요가 없다.
안내 문구는 폼 위에 있어야 했다
번호를 바꾸고 경고를 키우는 사이, 화면 위쪽의 안내 문구도 두 번 옮겼다.
처음 안내는 페이지 상단, 사진이 깔린 머리 영역의 긴 한 문단이었다. 무엇을 넣으면 접수부터 완료까지 무엇을 볼 수 있는지 한 문장에 다 담았다. 정확했지만 한 번에 읽히지 않았다.
먼저 이 문단을 둘로 갈랐다. 굵은 요약 한 줄은 "번호와 전화번호 뒷자리로 진행 상황을 바로 확인하세요", 그 아래 보조 설명은 "궁금하시면 넣어 보세요, 바로 알려 드립니다"에 가까운 말투로 썼다. 해야 할 일을 먼저 말하고, 설명은 뒤로 미뤘다.
그런데 고쳐 놓고 폰으로 다시 보니 위치가 문제였다. 사진 위에 얹힌 작은 흰 글씨는 어르신 눈에 잘 들어오지 않는다. 그리고 그 안내를 읽은 다음에야 아래로 스크롤해서 입력 칸을 찾아야 했다. 안내와 입력 사이가 멀었다.
그래서 안내를 머리 영역에서 빼서 조회 폼 맨 위로 옮겼다. 연한 바탕의 상자 안에 요약 한 줄을 크고 굵게, 보조 설명을 그보다 한 단계 작게 넣었다. 머리 영역에는 페이지 제목만 남았다. 이제 고객은 안내를 읽고 시선을 내리면 바로 그 번호를 넣을 칸을 만난다.
조회 페이지까지 오는 길도 짧게 했다. 메인 첫 화면에는 원래 "A/S 신청하기"와 "서비스 살펴보기" 버튼 두 개가 있었다. 그 사이에 "A/S 진행 조회" 버튼을 넣었다. 신청 버튼만큼 눈에 띄는 흰 바탕에 시계 아이콘을 붙였다. 이미 신청한 사람이 홈페이지에 다시 오는 이유는 대부분 "언제 오나"를 확인하려는 것이다. 그 사람에게 메뉴를 뒤지게 할 이유가 없었다.
조회 화면이 보여 주지 않는 것
번호 두 개만 맞히면 열리는 화면이니, 그 화면에 무엇을 올릴지가 마지막 방어선이다. 번호가 새어 나가도 잃는 게 적어야 한다.
진행 현황 화면에는 고객 이름도, 전화번호도, 상세 주소도 나오지 않는다. 지역은 주소에서 시·도와 시·군·구까지만 잘라서 보여 준다. 증상은 줄바꿈과 태그를 걷어 내고 한 줄로 만든 뒤, 길면 잘라서 끝에 말줄임표를 붙인다. 기사 메모와 금액, 담당자 연락처는 처음부터 고객용 화면으로 넘기지 않는다. 고객이 보는 건 접수번호, 제품, 증상, 담당 센터, 그리고 접수 → 센터 배정 → 방문·수리 → 완료로 묶은 네 단계다. 내부의 세세한 상태는 고객이 이해하기 쉬운 이 네 칸 중 하나로 접힌다.
화면에는 유효기간도 있다. 처리가 끝나고 일정 기간이 지나면 조회번호로도, 문자 링크로도 열리지 않는다. 그 기간이 지나면 여섯 자리 번호도 풀려서 새 접수에 다시 쓰일 수 있다. 앞에서 번호가 겹치는 범위를 좁힐 수 있었던 것도 이 유효기간 덕분이다.
돌아보면 조회번호를 여섯 자리로 줄인 건 편의를 위한 결정이었지만, 그 결정이 시도 횟수 제한과 막힘 안내, 보여 줄 정보의 범위까지 끌고 왔다. 번호를 짧게 만든 만큼 다른 곳에서 지켜야 했다.
다음 편에서는 같은 틀로 만든 두 번째 홈페이지로 넘어간다. 한 번 만든 구조를 다른 회사 홈페이지에 옮기면서 무엇을 그대로 두고 무엇을 바꿨는지, 그리고 페이지 말고 홈페이지가 조용히 하는 일들을 이야기하려 한다.