BUG · 버그 도감 #7
2월 31일생의 사주가 나왔다: 없는 날짜가 조용히 3월이 되는 이유
사주 플로우의 생년월일 입력에서 2월 31일을 고를 수 있었고, 계산은 오류 없이 3월 초의 사주를 내놓았습니다. 날짜 계산이 넘친 날짜를 다음 달로 굴려 버리는 원리와, 입력 폼·검증 모듈·계산 함수 세 겹으로 막은 해결을 정리했습니다.
버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.
프로젝트 사주 플로우 · 시기 2026년 8월 5일 · 관련 기술 날짜 검증, 율리우스 적일(JDN), JavaScript Date
증상
사주 플로우의 첫 커밋은 2026년 8월 5일 아침(한국 시각)입니다. 같은 날 오후 3시 41분의 커밋이 첫 번째로 기록된 버그 수정입니다. 커밋 메시지는 이렇게 시작합니다.
"입력 폼이 항상 1~31일을 보여줘 2월 31일을 고를 수 있었고, JDN 계산이 다음 달로 굴러가 틀린 원국이 오류 없이 만들어졌다."
생년월일을 고르는 칸에서 일(日)은 달과 상관없이 늘 1일부터 31일까지 나왔습니다. 2월 31일, 4월 31일, 평년의 2월 29일처럼 달력에 없는 날짜를 고를 수 있었고, 버튼을 누르면 결과가 나왔습니다. 오류 메시지도 경고도 없이, 여덟 글자가 그럴듯하게 채워진 사주가 나왔습니다.
커밋은 이 버그의 무게를 이렇게 적었습니다. "원국은 틀리지 않는다"는 전제를 깨는 자리라고요. 사주 플로우는 계산이 정확하다는 것을 가장 앞에 내세우는 서비스라, 틀린 결과가 조용히 나오는 것은 가장 피해야 할 일이었습니다.
원인: 날짜 계산은 넘친 날을 다음 달로 굴린다
사주 플로우는 날짜의 간지(일주)를 율리우스 적일(JDN)로 계산합니다. JDN은 기원전 4713년 1월 1일부터 며칠째인지를 세는 번호로, 두 날짜 사이의 날 수를 셈하기 쉬워서 만세력 계산에 널리 쓰입니다. 날짜를 JDN으로 바꾸는 공식은 대략 "그 해까지의 날 수 + 그 달까지의 날 수 + 일"을 더하는 모양입니다.
이 공식은 "일"이 그 달의 실제 일수 안에 있는지 확인하지 않습니다. 2월 31일을 넣으면 2월 1일에 30일을 더한 날, 즉 3월 3일(평년)과 같은 번호가 나옵니다. 윤년이라면 3월 2일입니다. 계산은 정확히 돌아갔고, 다만 사람이 넣은 날짜가 존재하지 않았을 뿐입니다.
자바스크립트의 날짜 객체도 똑같이 동작합니다. 아래 코드는 원리를 보여 주는 예시입니다.
new Date(Date.UTC(2001, 1, 31)).toISOString()
// → "2001-03-03T00:00:00.000Z" (월은 0부터 세므로 1이 2월)
오류를 내는 대신 알아서 고쳐 주는 동작은 편리할 때도 있지만, 사용자가 잘못 고른 날짜를 그대로 받아들이는 곳에서는 함정이 됩니다. 2월 31일을 고른 사람은 3월 3일생의 일주를 받았고, 화면 어디에도 "그런 날짜는 없다"는 말이 없었습니다.
폼은 왜 31일을 보여 줬나
고치기 전의 입력 폼은 일(日) 목록을 이렇게 만들었습니다(components/BirthForm.tsx).
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) =>
<option key={day} value={day}>{day}일</option>)}
달이 무엇이든 31칸입니다. 만들 때 가장 간단한 방법이었고, 대부분의 날짜는 문제없이 지나갑니다. 문제는 한 달에 1~3일씩 생기는 "없는 날"이었습니다. 음력은 사정이 더 복잡합니다. 음력 한 달은 29일 또는 30일이고, 윤달은 해마다 있는 것도 아니어서, 어떤 해에는 "윤4월"이라는 달 자체가 없습니다.
해결: 세 겹으로 막기
커밋은 한 곳이 아니라 세 곳에서 막았다고 적었습니다.
- 입력 폼 — 고른 연·월에 실제로 있는 날짜만 보여 줍니다. 2001년 2월을 고르면 일 목록이 28일에서 끝납니다. 새로 만든 날짜 선택 부품(
DateSelect.tsx)이 그 달의 일수(daysInSolarMonth)만큼만 칸을 만듭니다. 두 시간 뒤 입력 화면이 단계별 바텀시트(BirthSheet.tsx)로 바뀐 뒤에도 같은 함수로 그 달의 마지막 날을 정하고, 음력은 그달에 30일이 있는지 확인해 29일 또는 30일까지만 보여 줍니다. - 검증 모듈 —
lib/saju/validate.ts가 양력은 월별 일수와 윤년 규칙으로, 음력은 "양력으로 바꿨다가 다시 음력으로 되돌려 같은지"로 확인합니다. 없는 윤달이나 없는 30일은 되돌린 값이 달라져서 걸립니다. - 계산 함수 맨 앞 — 사주를 계산하는
computeSaju가 계산을 시작하기 전에 검증을 부르고, 통과하지 못하면 바로 멈춥니다. 커밋의 표현으로 "어떤 호출 경로로도 우회 불가"입니다.
// lib/saju/index.ts
// 존재하지 않는 날짜는 여기서 막는다. 통과시키면 조용히 틀린 원국이 나온다.
assertValidInput(input);
폼에서 이미 막는데 왜 계산 함수에서도 막을까요? 사주 플로우에는 폼 말고도 계산으로 들어오는 길이 있기 때문입니다. 공유 링크를 열면 링크 속 코드에서 날짜를 꺼내 바로 계산하고, 궁합 화면은 두 사람의 입력을 받습니다. 폼만 막으면 링크를 손으로 고친 주소 같은 다른 길로 없는 날짜가 다시 들어올 수 있습니다. 가장 안쪽인 계산 입구에서 막아 두면 어느 길로 오든 걸립니다.
막을 때는 이유를 말해 준다
검증에 걸리면 사용자에게 보여 줄 문구도 함께 만듭니다. 지금 코드의 문구는 이렇습니다.
- "2001년 2월은 28일까지 있어요. 2월 31일은 없는 날짜예요." 같은 양력 문구
- "음력 ○○년에는 윤○월 ○일이 없어요. 윤달 여부와 날짜를 확인해 주세요." 같은 음력 문구
"잘못된 입력입니다"로 끝내지 않고, 그 달이 며칠까지 있는지까지 알려 줍니다. 테스트에도 "오류 문구가 사용자에게 보여줄 만하다"는 항목이 따로 있습니다. 이 커밋으로 자동 테스트는 140건에서 165건으로 늘었고, "2월 31일은 계산되지 않고 던진다", "없는 날짜들을 전부 던진다", "없는 윤달도 던진다" 같은 항목이 들어갔습니다.
이 검증은 나중에도 살아남았습니다. 8월 17일 음력 변환을 한국 음력 기준으로 바꿀 때(버그 도감 #2) 음력 날짜 확인도 새 변환 함수를 쓰도록 함께 옮겨졌습니다.
교훈
- "알아서 고쳐 주는" 계산을 조심한다. 날짜 계산은 넘친 날을 다음 달로 굴리는 경우가 많습니다. 편리해 보이지만, 사람이 잘못 넣은 값을 오류 없이 다른 값으로 바꿔 버립니다.
- 입력 화면은 처음부터 불가능한 값을 고를 수 없게 만든다. 고른 뒤에 혼내는 것보다, 없는 날짜는 아예 목록에 없는 편이 낫습니다.
- 그래도 가장 안쪽에서 한 번 더 막는다. 폼은 여러 입구 중 하나일 뿐입니다. 링크, 다른 화면, 나중에 생길 기능까지 생각하면 계산 입구가 마지막 문입니다.
- 막을 때는 왜 안 되는지 말해 준다. "28일까지 있어요"라는 한 줄이 사용자를 다음 행동으로 이끕니다.
사주 플로우의 다른 버그는 #5 버려진 초, #6 공유 링크와 '몇 번째'에, 전체 과정은 사주 플로우 제작기에 있습니다. 이 프로젝트는 Claude Code와 함께 작업했습니다.