BUG · 버그 도감 #5
절기 시각이 41%나 1분씩 틀렸다: 범인은 버려진 초
사주 플로우가 계산한 절입 시각을 한국천문연구원 발표값 695건과 맞대어 보니 분까지 같은 것은 58%뿐이었습니다. 계산 라이브러리가 부정확한 줄 알았지만, 원인은 화면에 찍을 때 초를 버린 한 줄이었습니다. 반올림으로 바꿔 93%가 된 과정과, 보여 줄 값과 비교할 값을 나눈 이유를 정리했습니다.
버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.
프로젝트 사주 플로우 · 시기 2026년 8월 13~14일 · 관련 기술 JavaScript Date, 공공데이터 API, 시각 반올림
배경: 절기 1분이 왜 중요한가
사주에서 한 해와 한 달은 양력 1월 1일이나 음력 초하루가 아니라 절기로 바뀝니다. 해는 입춘에, 달은 경칩·청명 같은 열두 절기에 넘어갑니다. 그래서 절기가 시작되는 순간, 즉 절입 시각 앞뒤로 태어난 사람은 몇 분 차이로 사주가 달라집니다. 사주 플로우의 8월 13일 커밋은 이렇게 적었습니다. "1분은 작아 보이지만 입춘 경계에 태어난 사람은 그 1분에 연주와 월주가 함께 바뀐다 — 여덟 글자 중 넷이다."
그때까지 사주 플로우는 절입 시각을 천문 계산 라이브러리(lunar-javascript)로 냈습니다. 계산 방식 안내 화면에는 "계산으로 낸 값은 발표 시각과 1~2분 차이가 날 수 있다"고 적어 두었습니다. 어디까지나 짐작이었습니다.
8월 13일, 한국천문연구원이 공공데이터포털로 내주는 절기 시각을 받아 와 표로 저장하는 작업이 들어갔습니다. 발표값이 있는 해(2000년 이후)는 그 값을 그대로 쓰고, 없는 해만 계산으로 내는 구조입니다. 발표값이 생겼으니, 이제 계산값이 정말 얼마나 맞는지 잴 수 있게 됐습니다.
증상: 58%만 맞고, 41%는 정확히 1분 어긋남
다음 날 아침 커밋에 따르면, 발표값과 계산값을 695건 맞대어 본 결과는 이랬습니다.
- 분까지 똑같은 것: 58%
- 정확히 1분 어긋난 것: 41%
열 개 중 네 개가 틀린다면, 계산 라이브러리를 믿기 어렵다는 결론이 자연스럽습니다. 커밋 기록도 처음엔 그렇게 생각했다고 적었습니다. "나머지 41%가 정확히 1분씩 어긋나 있어서 계산이 그만큼 부정확한 줄 알았는데, 아니었다."
이상한 점은 어긋남의 모양이었습니다. 천문 계산이 부정확하다면 오차는 들쭉날쭉해야 합니다. 몇십 초 빠른 것도, 1~2분 늦은 것도 섞여 있어야 합니다. 그런데 어긋난 것들은 거의 모두 정확히 1분이었습니다. 이렇게 깔끔한 오차는 대개 계산이 아니라 표기에서 생깁니다.
원인: 화면에 찍을 때 초를 버리고 있었다
한 건을 골라 초까지 들여다보니 답이 나왔습니다. 커밋에 남은 예시입니다.
"2000년 처서는 실제로 03시 48분 31초다. 발표값은 03시 49분이고 우리는 03시 48분으로 적고 있었다. 천문 계산이 어긋난 것이 아니라 우리가 초를 버린 것이다."
한국천문연구원은 절기 시각을 분 단위로 발표하면서 초를 반올림합니다. 48분 31초는 49분이 됩니다. 사주 플로우는 같은 순간을 화면에 찍을 때 초를 버렸습니다. 48분 31초가 48분이 됩니다. 두 쪽의 천문 계산은 몇 초 차이로 거의 같았는데, 표기 방식이 달라서 초가 30초를 넘는 경우마다 정확히 1분씩 어긋나 보였던 것입니다. 41%라는 숫자가 "절반 가까이"인 것도 이걸로 설명됩니다. 초는 0~59 사이에 고르게 흩어지니, 버림과 반올림이 갈리는 경우는 이론상 절반 가까이 됩니다.
문제의 코드는 절입 시각을 한국 시각 문자열로 바꾸는 함수 하나였습니다(lib/saju/solarTerms.ts).
/** UTC ms → KST 표기 문자열 (절입 시각 노출용) */
export function formatKst(utcMs: number): string {
const d = new Date(utcMs + 9 * 3600_000);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
어디에도 "버린다"는 코드는 없습니다. 하지만 시각에서 시와 분만 꺼내 적으면 그 아래 초는 저절로 사라집니다. 03:48:31에서 분을 꺼내면 48이니까요. 따로 무언가를 하지 않으면 기본값이 버림인 셈입니다. 그래서 눈에 잘 띄지 않습니다.
해결: 보여 줄 때만 반올림한다
고친 코드는 한 줄입니다. 분 단위로 반올림한 시각을 만든 뒤에 시와 분을 꺼냅니다.
export function formatKst(utcMs: number): string {
const d = new Date(Math.round((utcMs + 9 * 3600_000) / 60_000) * 60_000);
// ...
이 한 줄로 일치율이 58%에서 93%로 올랐습니다. 코드 주석은 남은 7%도 설명합니다. "남은 차이는 대개 초가 30초 언저리인 것들이라 어느 쪽으로 굴러도 이상하지 않다." 예를 들어 몇 분 29.8초처럼 30초 경계에 걸린 값은 몇 초짜리 계산 차이로도 반올림 방향이 갈립니다. 안내 화면의 설명도 짐작이던 "1~2분 차이"에서 실측값으로 바뀌었습니다. "두 값을 695건 맞대어 보니 열에 아홉은 분까지 똑같고 나머지도 1분 안에 들어왔습니다."
중요한 것은 무엇을 고치지 않았는가입니다. 커밋은 이렇게 못박습니다.
"비교에 쓰는 값은 건드리지 않는다. 절입을 넘겼는지는 초까지 그대로 둔 시각으로 따져야 정확하고, 반올림은 사람에게 보여 줄 때만 한다."
어떤 사람이 절입 전에 태어났는지 후에 태어났는지는 사주를 정하는 판단입니다. 이 판단까지 반올림된 값으로 하면, 30초 이상 앞당겨지거나 늦춰진 가짜 경계로 사주를 가르게 됩니다. 그래서 판단은 초 단위 원래 값으로 하고, 반올림은 화면에 글자로 찍는 함수 안에서만 합니다. 이름도 formatKst, 즉 "표기"용 함수입니다.
숫자가 바꾼 것
이 수정은 화면의 1분만 고친 게 아니었습니다. 커밋은 더 큰 의미를 이렇게 적었습니다. 발표값이 없는 1999년 이전은 여전히 계산으로 절입 시각을 냅니다. 그런데 발표값이 있는 29년치에서 계산이 열에 아홉은 분까지 맞는다는 게 확인됐으니, 발표값이 없는 해의 계산도 믿을 근거가 생긴 것입니다. 58%로 남아 있었다면 "1999년 이전 사주는 정확하지 않을 수 있다"고 안내해야 했을지도 모릅니다.
같은 커밋에서 하나가 더 고쳐졌습니다. 절기와 공휴일을 가장 많이 보여 주는 달력 화면에 정작 그 값의 출처가 적혀 있지 않았습니다. 공공데이터는 출처를 밝히는 것이 이용 조건입니다. 달력 화면에 한국천문연구원 출처를 넣고, 앞으로 빠지지 않도록 "달력 화면도 출처를 밝힌다"는 자동 테스트를 추가했습니다.
20분쯤 뒤의 다음 커밋은 이 경험을 이렇게 돌아봅니다. "맞대어 보기 전에는 「아마 맞겠지」 말고는 할 말이 없었고, 맞대어 보고 나서야 우리가 초를 버리고 있었다는 것을 알았다." 그리고 같은 방식으로 음력→양력 변환도 기준값과 맞대어 보는 스크립트를 만들었습니다.
교훈
- 오차의 모양을 본다. 계산이 부정확하면 오차가 흩어집니다. 오차가 "정확히 1"처럼 깔끔하게 몰려 있으면 계산보다 표기·단위·반올림을 먼저 의심합니다.
- 날짜를 문자열로 만들 때 기본값은 버림이다. 시각에서 분까지만 꺼내 적으면 초는 말없이 버려집니다. 기준이 되는 자료가 반올림을 쓴다면 이쪽도 맞춰야 합니다.
- 보여 줄 값과 판단할 값을 나눈다. 반올림은 사람에게 읽히기 위한 것입니다. 경계를 가르는 판단은 가장 정밀한 원래 값으로 합니다.
- 짐작을 실측으로 바꾼다. "1~2분 차이가 날 수 있다"는 안내는 틀린 말은 아니었지만 근거가 없었습니다. 기준값과 한 번 맞대어 본 것만으로 버그를 찾았고, 안내 문구도 숫자로 바뀌었습니다.
사주 플로우가 받아 온 2000~2028년 절입 시각은 24절기 절입 시각표에서 볼 수 있습니다. 사주 플로우를 만든 전체 과정은 사주 플로우 제작기에, 같은 프로젝트의 다른 버그는 버그 도감 #2에 정리했습니다. 이 프로젝트는 Claude Code와 함께 작업했습니다.