KKflow

BUG · 버그 도감 #17

121해를 받았는데 절기가 0건이었다: 조용한 실패는 없는 것보다 나쁘다

사주 플로우가 한국천문연구원 절기 시각을 받아 오는 첫 실행에서, 공휴일은 25해치가 들어왔는데 절기는 한 건도 남지 않았습니다. 읽지 못한 항목을 '안 쓰는 항목'과 같은 줄에서 조용히 버린 탓에 원인을 짚을 단서도 없었습니다. 로그를 고친 뒤 10분 만에 절기 695건이 들어온 저녁의 기록입니다.

· KKflow · 읽는 시간 약 8분

버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.

프로젝트 사주 플로우 · 시기 2026년 8월 13~14일 · 관련 기술 공공데이터 API, GitHub Actions, 오류 로그

배경: 절기 시각을 천문연구원 값으로 바꾸기

사주 플로우는 사주의 해와 달을 절기의 절입 시각으로 가릅니다. 처음에는 이 시각을 lunar-javascript라는 라이브러리로 계산했습니다. 2026년 8월 13일 오후 4시 14분(한국 시각)의 커밋은 그 값을 한국천문연구원 발표값으로 바꾸기 시작하면서 이유를 이렇게 적었습니다. 라이브러리 값은 중국 표준시 기준이라 발표 시각과 1~2분 어긋날 수 있고, "1분은 작아 보이지만 입춘 경계에 태어난 사람은 그 1분에 연주와 월주가 함께 바뀐다 — 여덟 글자 중 넷이다."

방식은 이렇습니다. 공공데이터포털의 한국천문연구원 특일 정보 API에서 절기와 공휴일을 받아, 저장소 안의 표 파일(lib/saju/kasiData.ts)로 굳혀 둡니다. 화면을 열 때마다 공공 API를 부르면 "남의 서버 사정에 이쪽 화면이 끌려다닌다"는 것이 커밋의 설명입니다. 받아 오기는 GitHub Actions 워크플로로 돌리고, 결과 표는 봇이 커밋으로 올립니다. 표가 비어 있으면 앱은 예전처럼 라이브러리 계산값을 씁니다.

이 마지막 성질, 즉 표가 비어도 앱은 멀쩡히 돈다는 점이 이번 버그의 무대입니다. 이 작업의 커밋들은 모두 Claude Code와 함께 작성했습니다.

증상: 공휴일은 들어오고 절기만 0건

그날 저녁 봇이 올린 표 커밋 두 개가 그대로 남아 있습니다.

  • 오후 6시 10분 — 2025~2028년을 받은 첫 실행. 공휴일은 네 해치가 들어왔는데 절기 표는 {}, 빈 객체였습니다.
  • 오후 7시 10분 — 1930~2050년, 121해를 통째로 받은 실행. 공휴일은 2004~2028년 25해치가 들어왔는데 절기 표는 또 비어 있었습니다.
16:14 받아 오기 도입 표는 빈 채로 시작 18:10 첫 실행 2025~2028 공휴일 4해 · 절기 0건 19:10 1930~2050 (121해) 공휴일 25해 · 절기 0건 20:24 로그 고침 시각 표기 넉넉하게 · 첫 항목 찍기 20:34 다시 받기 절기 695건 (2000~2028)
2026년 8월 13일(한국 시각) 저녁의 커밋 기록입니다. 받아 온 표는 저장소에 자동 커밋으로 남아 있어서, 어느 실행에서 절기가 비었는지 그대로 확인할 수 있습니다.

공휴일이 들어왔으니 서비스키나 네트워크 문제는 아닙니다. 같은 API의 다른 기능(절기)만 비었습니다. 그런데 로그에는 "절기 0건"이라는 결과만 있었습니다. 다음 커밋(오후 8시 24분)의 첫 문장이 이 상황을 그대로 적었습니다. "121해를 다 받고도 절기가 0건이었다. 그런데 로그에는 「0건」만 있어서, 안 내주는 것인지 우리가 못 읽은 것인지 가릴 수가 없었다."

앱 화면에는 아무 이상이 없었습니다. 절기 표가 비면 라이브러리 계산으로 돌아가도록 만들어 두었기 때문입니다. 사용자 입장에서는 고치기 전과 똑같은 값이 나오고, 오류 메시지도 없습니다. 실패가 대체 경로 뒤에 완전히 숨어 있었습니다.

원인: '안 쓰는 것'과 '못 읽은 것'을 같은 줄에서 버렸다

받아 온 절기 항목을 표로 옮기는 원래 코드는 이랬습니다(scripts/fetch-kasi.mjs, 요약).

for (const it of await fetchYear('get24DivisionsInfo', key, year)) {
  const hanja = TERM_KO_TO_HANJA[it.dateName];
  if (!hanja) continue; // 잡절 등 우리가 안 쓰는 것은 버린다
  const kst = String(it.kst ?? '').padStart(4, '0');
  if (!/^\d{4}$/.test(kst)) continue;
  jie[...] = `${날짜} ${kst.slice(0, 2)}:${kst.slice(2)}`;
}

건너뛰는 자리가 두 군데 있습니다. 하나는 절기 이름(dateName)을 표의 한자 이름으로 바꾸지 못할 때, 다른 하나는 절입 시각(kst)이 숫자 네 자리 꼴이 아닐 때입니다. 첫 번째 continue에는 "잡절 등 우리가 안 쓰는 것은 버린다"라는 주석이 붙어 있습니다. 정상적으로 버려야 하는 항목을 위한 줄입니다.

문제는 예상과 다른 모양으로 온 항목도 같은 줄로 빠진다는 점입니다. 이름 앞뒤에 공백이 하나 붙어 와도, 시각이 5:59처럼 콜론이 낀 모양으로 와도, 그 항목은 "우리가 안 쓰는 것"과 똑같이 소리 없이 버려집니다. 절기 스물네 개가 모두 같은 모양으로 오니, 모양이 하나 어긋나면 스물네 개가 전부, 121해 내내 버려집니다.

고치기 전 고친 뒤 받아 온 항목 절기 24 × 121해 이름표 맞추기 dateName → 漢字 시각 읽기 kst → HH:MM 표에 넣기 KASI_JIE continue (조용히 버림) 안 쓰는 것과 못 읽은 것이 한곳에 결과: 절기 0건 → 계산으로 대신 화면은 멀쩡해 보임 받아 온 항목 절기 24 × 121해 이름표 맞추기 dateName → 漢字 시각 읽기 kst → HH:MM 표에 넣기 KASI_JIE 안 쓰는 이름(잡절 등) 버림 — 원래 버리던 것 읽지 못함 → 첫 항목을 로그에 그대로 0건이면 '어디가 어긋났는지' 안내 + 시작 전에 값이 확실한 해의 한 달(2월)만 먼저 받아 응답 모양을 찍어 본다
받아 오기 스크립트(scripts/fetch-kasi.mjs)의 흐름입니다. 고치기 전에는 '우리가 안 쓰는 항목'과 '우리가 못 읽은 항목'이 같은 한 줄로 버려졌습니다.

한 가지 덧붙이면, 같은 스크립트에서 공휴일 이름은 원래부터 .trim()으로 공백을 걷어 내고 쓰고 있었고, 절기 이름에는 그 처리가 없었습니다. 실제로 어느 쪽이 어긋났는지는 커밋에 적혀 있지 않습니다. 커밋 스스로도 "어느 모양으로 올지 확신할 수 없어 넉넉하게 받는다"고 적었습니다. 확실한 것은 고친 뒤의 결과입니다.

해결: 넉넉하게 읽고, 못 읽으면 말하고, 먼저 찔러 본다

오후 8시 24분 커밋은 세 가지를 바꿨습니다. 커밋 설명에는 이 한 줄이 있습니다. "조용한 실패는 없는 것보다 나쁘다."

1. 시각과 이름을 넉넉하게 받기

function hhmm(raw) {
  const m = String(raw ?? '').trim().match(/^(\d{1,2}):?(\d{2})$/);
  if (!m) return null;
  ...
}
const hanja = TERM_KO_TO_HANJA[String(it.dateName ?? '').trim()];

시각은 '0559'든 559든 '5:59'든 같은 값으로 읽고, 앞뒤 공백도 걷어 냅니다. 절기 이름에도 공휴일과 같은 .trim()이 붙었습니다. 시와 분이 범위를 벗어나면(24시, 60분) 읽지 않습니다.

2. 받았는데 하나도 못 썼으면 첫 항목을 그대로 찍기

한 해의 항목을 받았는데 하나도 표에 못 넣었다면, 그건 "그 해는 안 내준다"가 아니라 "우리가 못 읽었다"입니다. 이때 읽지 못한 첫 항목을 JSON 그대로 로그에 한 번 찍습니다. 커밋 설명대로 "이름표가 어긋난 것인지 시각 표기가 다른 것인지가 그 한 줄에 다 보인다." 끝까지 절기가 0건이면, 항목 자체가 0건이었는지(활용신청 문제) 항목은 있는데 못 읽었는지(이름표나 시각 표기 문제) 확인하라는 안내도 출력합니다.

3. 121해를 돌기 전에 한 달만 먼저

본 작업을 시작하기 전에, 값이 확실히 있는 해(올해)의 2월 한 달만 먼저 받아 몇 건이 왔는지와 첫 항목의 모양을 찍습니다. "몇 초면 끝나고, 어긋났으면 121해를 도는 대신 첫 줄에서 드러난다." 121해 × 12달이면 요청이 1,452번인데, 그 전체를 다 돌고 나서야 0건을 알게 되는 일을 막는 것입니다.

10분 뒤인 오후 8시 34분, 봇이 다시 받아 올린 표에는 절기 695건이 들어 있었습니다. 범위는 2000~2028년이었고, 1930~1999년과 2029년 이후는 API가 내주지 않아 계산값으로 남았습니다. 695건은 29해 × 24절기(696건)에서 하나가 빠진 수로, 이 사이트의 24절기 절입 시각표도 같은 표를 쓰기 때문에 2019년 대한 한 칸이 비어 있다고 적어 두었습니다.

다음 날: 'fetch failed' 서른한 줄

같은 교훈이 다음 날 한 번 더 나왔습니다. 8월 14일 오후 2시 1분 커밋에 따르면, 전날 잡절(초복·한식·단오·칠석)을 받는 실행이 서른한 해를 통째로 실패했는데, 로그에 남은 것은 "「fetch failed」 서른한 줄과 30분"이었습니다.

Node.js의 fetch는 연결 단계에서 실패하면 fetch failed라는 메시지만 주고, 진짜 원인(주소를 못 찾았는지, 상대가 끊었는지, 시간이 넘었는지)은 오류 객체의 cause 안에 접어 둡니다. 커밋은 그것을 펴서 "fetch failed ← ENOTFOUND getaddrinfo …"처럼 적게 했습니다. 그리고 헛돌지 않게 두 가지를 더했습니다.

  • 시작 전에 종류마다(절기·공휴일·잡절) 한 달씩 찔러 보고, 하나도 닿지 않으면 그 자리에서 멈춥니다. "30분이 15초가 된다."
  • 도중에 세 해가 잇따라 실패하면 접고, 그때까지 받은 것은 표에 얹은 뒤 실패한 해를 적어 줍니다.

찔러 보는 달을 고르는 기준도 적혀 있습니다. 절기는 2월, 공휴일은 1월, 잡절은 7월처럼 그 종류의 값이 반드시 있는 달입니다. 값이 없는 달로 찔러 보면 "멀쩡한 실행이 고장으로 읽힌다." 이 커밋은 가짜 응답으로 세 갈래(안 닿음·도중에 끊김·정상)를 모두 돌려 확인했고, 도중에 끊긴 경우에도 이미 받은 절기 695건과 공휴일 425건이 그대로 남는 것을 확인했다고 적었습니다.

교훈

  • 버리는 이유가 둘이면 버리는 줄도 둘이어야 한다. "원래 안 쓰는 것"과 "예상과 달라서 못 읽은 것"을 같은 continue로 보내면, 두 번째가 생겨도 아무도 모릅니다.
  • 대체 경로는 실패를 숨긴다. 표가 비면 계산으로 돌아가게 한 설계는 사용자를 지켰지만, 같은 이유로 실패가 화면에 전혀 드러나지 않았습니다. 대체 경로가 있는 곳일수록 로그가 더 자세해야 합니다.
  • 숫자 0은 원인을 말해 주지 않는다. "0건"은 안 내준 것일 수도, 못 읽은 것일 수도, 요청이 안 닿은 것일 수도 있습니다. 실패했을 때 실제로 받은 것의 한 조각을 그대로 남겨 두면 대개 그 한 줄로 원인이 보입니다.
  • 큰 작업 전에 작게 찔러 본다. 1,452번 요청을 다 돌기 전에 1번으로 응답 모양을 확인하면, 틀렸을 때 몇 초 만에 알 수 있습니다. 찔러 볼 때는 값이 확실히 있는 자리를 골라야 합니다.

절기 시각이 왜 분 단위까지 중요한지는 버그 도감 #5: 절기 시각이 41%나 1분씩 틀렸다와 버그 도감 #15: 절기가 드는 날 하루 안에서 바뀌는 달에 있습니다. 사주 플로우가 만들어진 과정은 사주 플로우 제작기에 정리했습니다.

함께 읽으면 좋은 글