BUG · 버그 도감 #6
목록 순서만 바꿨는데 부산이 인천이 됐다: 공유 링크와 '몇 번째'
사주 플로우의 공유 링크에는 출생지 이름이 아니라 목록의 '몇 번째'가 실립니다. 출생지 목록을 북에서 남으로 다시 늘어놓자, 이미 퍼진 링크의 부산이 인천으로 바뀌었습니다. 오류 없이 남의 결과를 보여 주던 30분과, 링크용 목록을 따로 못 박은 해결을 정리했습니다.
버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.
프로젝트 사주 플로우 · 시기 2026년 8월 14일 · 관련 기술 URL 인코딩, 비트 패킹, 하위 호환
증상
사주 플로우는 회원가입이 없습니다. 결과를 저장하는 대신, 입력값(생년월일시, 성별, 출생지 등)을 짧은 코드로 줄여 결과 페이지 주소에 넣습니다. 이 주소를 친구에게 보내면 친구 화면에서도 같은 계산이 다시 돌아 같은 결과가 나옵니다. 이 방식은 회원가입 없는 웹 서비스를 만드는 네 가지 방법에서 "결과를 URL에 담기"로 소개했습니다.
8월 14일 오전, 이미 퍼진 링크를 열면 출생지가 다른 도시로 나오는 일이 생겼습니다. 커밋 기록의 예시는 이렇습니다. "1번이 부산이었는데 인천이 됐다." 부산에서 태어난 사람의 링크를 열면, 화면은 인천에서 태어난 사람의 사주를 보여 줬습니다.
무서운 점은 아무것도 깨져 보이지 않았다는 것입니다. 커밋 메시지의 표현대로 "값이 망가진 것도 오류가 난 것도 아니라 아무도 모른 채 남의 시주를 본다." 날짜도 시각도 그대로이고, 출생지만 조용히 바뀌어 있었습니다.
배경: 링크에는 '몇 번째'가 실린다
공유 코드는 짧아야 합니다. 그래서 사주 플로우는 입력값을 글자로 늘어놓지 않고, 정해진 칸에 숫자를 비트 단위로 빽빽하게 채워 넣습니다(비트 패킹). 출생지 칸은 5비트, 즉 0부터 31까지의 숫자 하나입니다. "서울"이라는 이름이 아니라 "출생지 목록의 0번째"가 실리는 것입니다.
문제는 그 "목록"이 무엇이었느냐입니다. 고치기 전 코드는 화면에 보여 주는 출생지 목록(CITIES)을 그대로 썼습니다(lib/saju/share.ts).
// 링크 만들기: 화면 목록에서 몇 번째인지
const regionIdx = Math.max(0, CITIES.findIndex((c) => c.name === (input.region ?? '서울')));
// 링크 풀기: 화면 목록의 그 번째
region: CITIES[p.region].name,
화면 목록의 순서가 곧 링크의 뜻이었던 셈입니다. 목록이 그대로인 동안에는 아무 문제가 없었습니다.
원인: 화면을 다듬은 커밋
같은 날 오전 10시 22분(한국 시각), 출생지 화면을 다듬는 커밋이 들어갔습니다. "경기 수원", "강원 강릉"처럼 도(道) 이름을 붙이고, 고르기 쉽게 북에서 남으로 다시 늘어놓았습니다. 사용자에게는 분명 나아진 변화였습니다.
하지만 순서가 바뀌는 순간 모든 번호의 뜻이 바뀌었습니다. 예전 목록은 서울, 부산, 대구, 인천… 순서였고, 새 목록은 서울, 인천, 경기 수원, 강원 춘천… 순서였습니다. 서울은 여전히 0번이라 무사했지만, 1번은 부산에서 인천이 됐습니다. 이미 친구에게 보낸 링크 속 숫자는 바꿀 방법이 없으니, 그 링크는 이제 다른 도시를 가리키게 됐습니다.
출생지가 바뀌면 사주 결과도 달라질 수 있습니다. 사주 플로우는 출생지의 경도로 진태양시(해의 위치 기준 시각)를 보정하는데, 부산(동경 약 129.08°)과 인천(약 126.71°)은 경도로 2.37°, 시간으로 약 9분 차이가 납니다. 시주의 경계 근처에 태어난 사람이라면 시주 두 글자가 바뀔 수 있는 차이입니다.
이 버그는 기록상 약 30분 동안 있었습니다. 순서를 바꾼 커밋이 10시 22분, 고친 커밋이 10시 54분에 합쳐졌습니다. 어떻게 알아챘는지는 커밋에 적혀 있지 않습니다.
해결: 링크용 목록을 따로 못 박다
해결의 핵심은 "보여 주는 순서"와 "링크에 싣는 순서"를 갈라 두는 것이었습니다. 링크용 목록 SHARE_REGIONS를 새로 만들고, 순서를 바꾸기 전의 목록을 그대로 옮겨 적었습니다.
export const SHARE_REGIONS = [
'서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종', '수원',
'청주', '전주', '창원', '포항', '강릉', '춘천', '목포', '여수', '제주', '평양',
] as const;
이 목록에는 코드 주석으로 규칙 세 가지가 붙어 있습니다.
- 뒤에 덧붙이는 것만 된다. 사이에 끼우거나 순서를 바꾸면 안 된다.
- 이름을 바꿔도 여기 글자는 그대로 둔다. "강릉"은 화면에서 "강원 강릉"이 됐지만 링크에는 여전히 13번 자리로 실린다. 지금 이름으로 바꾸는 일은 따로 둔 이름 대응표(
RENAMED)가 맡는다. - 지우면 안 된다. 지운 자리의 번호가 실린 링크가 이미 나가 있다.
이제 화면 목록은 북에서 남으로든 가나다순으로든 마음대로 바꿔도 링크가 흔들리지 않습니다.
함께 챙긴 것들
- 실제로 나간 링크로 확인 — "실제로 나갔던 코드 넷이 그때 그 곳으로 풀리는 것을 확인했다." 이 코드들은 테스트에 그대로 들어가, 같은 코드가 같은 출생지·같은 날짜·같은 사주로 풀리는지 매번 확인합니다.
- 목록 밖의 도시 — 출생지를 시·군·구 단위로 넓히면서 32자리에 다 담지 못하는 곳이 생겼습니다. 이런 곳은 경도가 가장 가까운 링크용 도시로 싣습니다. 맨 앞의 서울로 떨어뜨리면 안동에서 태어난 사람이 9분 어긋나지만, 가장 가까운 대구로 실으면 그 차이가 훨씬 작아집니다.
- 32자리 한계 — 출생지 칸은 5비트라 32자리가 한계입니다. 33번째를 덧붙이면 그 값이 옆 칸(생년월일)까지 넘쳐 조용히 다른 날짜가 됩니다. 그래서 "서른두 자리를 넘지 않는다"는 테스트가 이 수를 지킵니다.
교훈
- 밖으로 나간 숫자는 영원하다. 링크, 저장 파일, 데이터베이스처럼 한 번 밖으로 나간 값은 되돌릴 수 없습니다. 그 숫자가 가리키는 대상도 영원히 같아야 합니다.
- 화면 순서를 데이터의 뜻으로 쓰지 않는다. 화면은 계속 바뀌는 것입니다. 저장하고 주고받는 번호는 화면과 떨어진, 덧붙이기만 하는 목록에 둡니다.
- '그때의 값'을 테스트로 남긴다. 실제로 나간 코드가 그때와 같은 뜻으로 풀리는지 확인하는 테스트는, 누가 나중에 목록을 건드려도 바로 알려 줍니다.
- 오류 없이 틀리는 버그를 경계한다. 이 버그는 화면을 좋게 바꾼 커밋에서 나왔고, 아무 오류도 내지 않았습니다. 버그 도감 #2의 음력 변환도 같은 종류였습니다.
사주 플로우의 다른 기록은 사주 플로우 제작기와 버그 도감 #5: 버려진 초에 있습니다. 이 프로젝트는 Claude Code와 함께 작업했습니다.