BUG · 버그 도감 #11
검색 결과에 '태으로'가 떴다: 값 뒤에 손으로 붙인 조사 스물한 곳
사주 플로우의 일주 페이지 설명이 '정재·태으로 풀어요'라고 검색 결과에 나가고 있었습니다. 한국어 조사는 앞말의 받침에 따라 달라지는데, 계산된 값 뒤에 조사를 손으로 고정해 둔 자리가 스물한 곳, 그중 일곱이 실제로 틀려 있었습니다. 받침을 보고 조사를 고르는 함수와, 다시는 손으로 적지 못하게 막은 검사를 정리했습니다.
버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.
프로젝트 사주 플로우 · 시기 2026년 8월 11일, 9월 22일 · 관련 기술 한국어 조사, 유니코드 한글, 정적 검사
증상: 검색 결과에 뜬 '태으로'
사주 플로우에는 육십갑자 일주마다 설명 페이지가 하나씩, 모두 예순 장 있습니다. 9월 22일 커밋은 이렇게 시작합니다. "네이버 검색 쪽을 보다가 일주 페이지의 설명을 열었더니 이랬다."
무자일주(戊子)는 일간 무(戊)가 일지 자(子)에 앉은 자리라 정재·태으로 풀어요.
"태"는 받침이 없으니 "태로"가 맞습니다. 그런데 이 문장은 페이지의 meta description, 즉 검색 결과에 제목 아래 요약으로 그대로 뜨는 문장이었습니다. 사람들이 사이트에 들어오기도 전에 가장 먼저 보는 글자가 틀려 있던 셈입니다.
원인: 바뀌는 값 뒤에 고정된 조사
한국어 조사는 앞말의 마지막 글자에 받침이 있느냐에 따라 모양이 바뀝니다. "은/는", "이/가", "을/를", "과/와", "으로/로"가 그렇습니다. 사람이 문장을 직접 쓸 때는 자연스럽게 맞춰 쓰지만, 프로그램이 계산한 값을 문장 틀에 끼워 넣을 때는 이야기가 달라집니다.
// 고치기 전 (app/ilju/[name]/page.tsx)
`… 앉은 자리라 ${i.god}·${i.stage}으로 풀어요.`
${i.stage} 자리에는 십이운성(장생·목욕·관대·건록·제왕·쇠·병·사·묘·절·태·양) 중 하나가 들어갑니다. "장생", "목욕", "건록", "병", "절", "양"처럼 받침이 있는 값이 들어오면 "으로"가 맞습니다. 하지만 "관대", "쇠", "사", "묘", "태"가 들어오면 "관대으로", "태으로"가 됩니다. 코드는 한 줄인데, 값에 따라 맞기도 하고 틀리기도 합니다.
한 곳이 아니었다
커밋은 같은 모양의 자리를 전부 찾았습니다. 값 뒤에 조사를 손으로 붙인 자리는 스물한 곳이었고, 그중 일곱 곳은 들어갈 수 있는 값 가운데 받침이 없는 것이 있어서 실제로 틀린 말이 나가고 있었습니다.
| 값의 종류 | 받침 없는 값 | 나가던 말 |
|---|---|---|
| 십이운성 | 관대·쇠·사·묘·태 | 관대으로 · 태으로 |
| 오행 | 화·토·수 | 화을 · 수으로 |
| 띠 | 쥐·소·토끼·개·돼지 | 쥐과 · 개은 |
| 절기 | 곡우·하지·대서·동지 | 하지이 |
| 천간 | 무·기·계 | 무이 |
이 버그가 오래 살아남은 이유도 커밋에 적혀 있습니다. "눈에 잘 안 띈다. 예순 장짜리 일주 페이지에서 틀린 것은 스물다섯 장쯤이고 나머지는 멀쩡하다 — 한 장만 열어 보면 '맞는데?'가 된다." 확인하려고 아무 페이지나 하나 열면 절반 넘는 확률로 멀쩡한 문장이 나옵니다. 틀린 페이지를 우연히 열어야만 보이는 버그였습니다.
공유 카드 이미지를 만드는 코드에서는 ${STEMS[…].hangul}${''}이처럼 값과 조사 사이에 빈 문자열을 끼워 둔 자리도 나왔습니다. 커밋은 이를 "아마 무언가를 피하려던 흔적인데, 조사는 그대로 틀린 채였다"고 적었습니다. 무엇을 피하려던 것인지는 기록에 없습니다.
고르는 함수는 이미 있었다
사실 이 문제는 처음이 아니었습니다. 한 달 전인 8월 11일 커밋 "조사를 앞말에 맞춰 붙인다"가 같은 종류의 버그 네 가지를 고쳤습니다.
- "내 사주에서 나는 토이라" — 오행 중 화·토·수는 받침이 없습니다.
- "오늘 지지 巳와 申가 육합" — 申은 "신"이라 "이"가 맞습니다.
- "강약이 신강/신약로 갈림" — 받침이 있으면 "으로"입니다.
- "두 번째 사람를 넣어 주세요" — "사람"은 받침이 있습니다.
이때 lib/ko.ts에 받침을 보고 조사를 고르는 josa() 함수가 생겼고, 다섯 자리를 이 함수로 옮겼습니다. 그런데 나머지 자리는 그대로 남았습니다. 9월 22일 커밋에 따르면 "바로 옆 줄에서 josa(…, '이가')를 제대로 쓰면서 그다음 줄만 손으로 적은 자리도 있었다." 도구는 있었지만, 모든 자리가 그 도구를 쓰고 있지는 않았던 것입니다.
받침은 어떻게 알아내나
josa()의 원리는 유니코드 한글의 번호 매기는 방식에 있습니다. 완성형 한글 11,172자는 "가"(U+AC00)부터 시작해 초성·중성·종성 순서로 빈틈없이 번호가 매겨져 있고, 종성(받침) 자리는 "받침 없음"을 포함해 28가지입니다. 그래서 글자 번호에서 "가"의 번호를 빼고 28로 나눈 나머지를 보면 받침을 알 수 있습니다.
// lib/ko.ts (요약)
function batchim(ch) {
const code = ch.codePointAt(0);
if (code < 0xac00 || code > 0xd7a3) return null; // 한글이 아니면 판단 안 함
return (code - 0xac00) % 28 !== 0; // 0이면 받침 없음
}
함수에는 세심한 규칙이 몇 가지 더 들어 있습니다.
- "으로/로"는 ㄹ 받침을 따로 본다. 받침이 있으면 "으로"지만, ㄹ 받침이면 "로"입니다(신약으로, 물로). 나머지가 8이면 ㄹ입니다.
- 한자는 읽는 소리로 판단한다. 사주 플로우는 丑, 申 같은 한자를 화면에 자주 보여 주는데, 한자만 보고는 받침을 알 수 없습니다. 함수 주석의 표현으로 "丑을 보고 조사를 고를 수는 없고, '축'을 보고 골라야 '축과'가 된다." 그래서 값의 한글 읽기를 함수에 넘깁니다.
- 판단할 수 없으면 받침 있는 쪽으로. 끝 글자가 한글이 아니면 "이/가"에서 "이"를 고릅니다. "'가'보다 '이'가 덜 어색하다"는 이유가 주석에 적혀 있습니다.
- "이에요/예요"도 고른다. 화면 말씨를 해요체로 맞추면서 "{값}입니다"가 "{값}이에요"로 바뀌었는데, 이쪽도 받침을 탑니다(목이에요, 화예요).
해결: 스물한 곳 전부, 맞는 자리까지
9월 22일 커밋은 스물한 곳을 전부 josa()나 withJosa()(낱말과 조사를 이어 붙이는 함수)로 바꿨습니다.
// 고친 뒤
`… 앉은 자리라 ${i.god}·${withJosa(i.stage, '으로')} 풀어요.`
눈여겨볼 점은 지금 맞게 나오는 자리까지 같이 바꿨다는 것입니다. 예를 들어 일주 페이지의 ${i.polarity}이 겹친 짝에는 음양 값("음"이나 "양")이 들어가는데, 둘 다 받침이 있어서 손으로 "이"를 적어도 틀릴 일이 없습니다. 그래도 withJosa(i.polarity, '이가')로 바꿨습니다. 커밋의 이유는 이렇습니다. "남겨 두면 다음 사람이 그걸 보고 따라 적는다." 지금 맞는 코드가 나중에 틀린 코드의 견본이 되기 때문입니다.
검사: 다시는 손으로 적지 못하게
사주 플로우에는 화면 문구의 규칙을 코드로 검사하는 테스트(tests/copyStyle.test.ts)가 있습니다. 해요체로 통일하기, 곧은 따옴표 쓰지 않기 같은 규칙 일곱 개가 이미 들어 있었고, 이번에 여덟째 규칙이 더해졌습니다. "값 뒤에 조사를 손으로 붙이지 않는다."
// tests/copyStyle.test.ts
const bad = findLines(/\$\{[^}\n]{1,60}\}(은|는|이|가|을|를|과|와|으로|이라)(?![가-힣])/);
소스 코드 전체에서 ${…} 바로 뒤에 조사가 붙은 줄을 찾습니다. 끝의 (?![가-힣])가 중요합니다. 조사 뒤에 한글이 더 이어지면 그건 조사가 아니라 낱말의 일부라서(${x}이름 같은 경우) 거릅니다. 커밋에는 "한 곳을 되돌려 걸리는 것을 확인했다"고 적혀 있습니다. 검사가 실제로 잡는지를, 일부러 버그를 되살려 확인한 것입니다. 조사를 고르는 함수가 lib/ko.ts 한 곳에만 있는지 확인하는 검사도 같이 들어갔습니다. 두 벌이 생기면 한쪽만 고쳐지기 때문입니다.
이 커밋 기준으로 자동 검사는 1,129개가 모두 통과했습니다. 고친 뒤 무자일주의 설명은 "… 앉은 자리라 정재·태로 풀어요."입니다. 이 커밋은 사용자 계정으로 올라가 있고 Claude가 공동 작성자로 함께 적혀 있습니다.
교훈
- 값이 바뀌는 자리에 조사를 고정하지 않는다. 한국어 서비스에서 계산된 값을 문장에 넣는다면, 조사는 받침을 보고 고르는 함수로 붙입니다. 유니코드 계산 몇 줄이면 됩니다.
- "한 장 열어 보니 맞던데"를 믿지 않는다. 값에 따라 맞고 틀리는 버그는 표본 하나로는 안 보입니다. 들어갈 수 있는 값을 다 넣어 보거나, 패턴 자체를 찾아야 합니다.
- 도구를 만들었으면 옛 자리를 다 옮긴다. 8월에 함수를 만들고 다섯 자리를 옮겼지만, 한 달 뒤 찾아보니 손으로 적은 자리가 스물한 곳 있었고 그중 일곱이 틀려 있었습니다. 도구를 만드는 것과 모든 자리가 그 도구를 쓰게 하는 것은 다른 일입니다.
- 맞는 코드도 견본이 된다. 지금은 우연히 맞는 손글씨 조사도, 다음 사람이 따라 적으면 틀린 코드가 됩니다. 규칙은 검사로 옮겨 두면 사람이 기억하지 않아도 됩니다.
- meta description도 화면이다. 검색 결과에 뜨는 요약문은 사이트의 첫인상입니다. 본문만큼 꼼꼼히 봐야 합니다.
사주 플로우의 다른 버그는 버그 도감 #7: 2월 31일생의 사주, #6: 공유 링크와 '몇 번째'에, 전체 과정은 사주 플로우 제작기에 있습니다. 출생 시각을 어떻게 보정하는지는 출생지별 시간 보정표에 정리했습니다.