어떻게 쟀나

  • 기보 DB에 남은 32판의 판 모양과 문제 문장을 분석했습니다.
  • 소스 복사본에 판이 어느 경로로 나왔는지만 기록하게 고쳐, 난이도마다 30판씩 만들었습니다.
  • 실제 서비스와 같은 맥의 gemma4로 새 판 과정을 난이도마다 3번씩 돌렸습니다. 판 설계는 시간 제한을 풀고 6번 더 받아 봤습니다.

판 먼저 경로는 한 번도 성공하지 못했습니다

만든 90판이 모두 예비 경로에서 나왔습니다. 1편에서 본 단어 연결형 배치기입니다. 가운데에 단어 하나를 놓고 한 글자씩 걸쳐 붙이기 때문에, 판이 대칭이 아니고 한쪽이 비기 쉽습니다.

  • 180° 대칭인 판: 90판 중 0판
  • 보통·어려움 60판 중 18판에 6글자 이상 답이 들어갔습니다. 판 먼저 경로라면 5글자를 넘을 수 없습니다.
  • 평균 단어 수: 쉬움 10.7개, 보통 14.9개, 어려움 19.9개
  • 만드는 시간 중앙값: 쉬움 0.6초, 보통 1.9초, 어려움 6.6초. 어려움은 열 판 중 한 판이 22.9초를 넘었습니다.

실제로 저장된 판도 같습니다. 판 먼저로 바꾼 뒤 저장된 8판 중 대칭인 판은 0개였고, 5판에는 6~8글자 답이 있었습니다.

그동안 눈치채지 못한 이유가 있습니다. 판이 기준을 넘는지 보는 검사는 흰 칸 비율, 가로·세로 문제 수, 가운데 교차 수, 연결만 봅니다. 예비 경로 판도 이 기준은 넘기 때문에 90판 중 89판이 ‘통과’였습니다.

미리 그려 둔 판 24개에 판마다 10번씩 단어를 채워 봤습니다. 240번 모두 실패했고, 가장 많이 채운 경우도 단어 자리의 28%였습니다. 평균은 14%입니다. 다른 시드로 판마다 20번씩 더 돌려도 한 번도 성공하지 못했습니다. 무작위 판도 120개 중 0개였습니다. 미리 그려 둔 24개 중 두 쌍은 같은 판이었습니다(보통 4·7번, 어려움 5·8번).

왜 안 채워지나: 한글 음절

영어 십자말은 알파벳 26자로 교차합니다. 한글 십자말은 음절 하나가 한 칸이라 글자 종류가 훨씬 많습니다. 단어 은행에 나오는 음절만 545종이고, 그중 178종은 한 번만 나옵니다.

그래서 교차로 한 칸이 정해지면 남는 후보가 거의 없습니다. 5글자 칸에서 한 글자가 정해졌을 때 그 자리에 같은 음절을 가진 다른 5글자 단어는 평균 0.52개이고, 70%는 하나도 없습니다. 4글자도 평균 0.85개, 55%가 0개입니다.

미리 그려 둔 판은 흰 칸이 64~75%이고, 흰 칸 대부분이 가로와 세로 단어에 동시에 속합니다. 단어를 몇 개 넣고 나면 교차하는 칸 중 하나는 반드시 후보가 0개가 됩니다. 실제로 나간 판은 흰 칸이 36.4%이고, 가로·세로가 겹치는 칸은 30~37% 수준입니다.

AI 판 설계: 0/9

새 판을 만들 때마다 판 설계를 15초 기다립니다. 9번 중 8번은 15초 안에 답이 오지 않았고, 한 번은 답이 왔지만 쓸 수 있는 판이 없었습니다.

시간 제한을 풀고 6번 다시 받아 봤습니다. 답이 오는 데 13~52초가 걸렸고, 받은 후보 15개 중 규칙을 통과한 판은 0개, 자동 보정한 뒤에도 0개였습니다. 대부분 위 그림처럼 한쪽 열을 통째로 검게 칠한 덩어리였습니다. 1번은 답이 길어져 1,200토큰에서 잘렸습니다. 지금 구조에서 판 설계에 쓰는 15초는 매번 그냥 버려집니다.

AI 문제: 2/9, 그리고 문제의 질

문제 작성은 25초 제한입니다. 9번 중 7번이 시간을 넘겨 단어 은행의 기본 문장으로 나갔습니다. 새 판 하나를 받기까지 35~54초, 중앙값 44초가 걸렸고 그 대부분이 두 번의 AI 대기 시간입니다.

실제로 저장된 8판도 비슷합니다. AI가 문제를 다시 쓴 판은 2판뿐입니다.

AI가 쓴 문장에도 문제가 있었습니다.

  • 퀴즈가 아니라 사전식 설명이 됩니다. “2026년 미국·멕시코·캐나다가 함께 연 축구 대회”는 “여러 나라의 축구 국가대표팀들이 모여 치르는 국제적인 축구 대회”가 됐습니다. 어렵게 만들려고 넣은 시사 문제가 평범한 뜻풀이로 바뀝니다.
  • 틀린 인용: 공자 문제에 “‘학이 되어 배우고 때로 익히면’이라는 가르침”이 나왔습니다. 논어 첫 구절은 “배우고 때때로 익히면”입니다.
  • 시제: 이미 끝난 2026년 동계올림픽을 “개최될 예정인 겨울철 스포츠 축제”라고 썼습니다.
  • 없는 책: 달랑베르를 “『필쇠논』을 저술한 인물”이라고 했습니다.
  • 대전광역시를 “광역 자치구”라고 했습니다.

단어 은행과 시사 단어

  • 5글자 칸을 채우려고 단어를 자른 항목이 있습니다. ‘인천국제공’, ‘김포국제공’, ‘시외버스터’, ‘국회의원선’ 같은 8개이고, 문제는 “…의 앞말”입니다. 샌프란시스코 다리의 답은 ‘금문교’가 아니라 ‘금문’입니다.
  • 문제 자체가 틀린 항목도 있습니다. ‘냉장고문’의 문제가 “찬장을 여닫는 문”입니다.
  • 위키백과 무작위 문서에서 온 답: ‘시발리크급구축함’, ‘란도어과’, ‘일리네어레코즈’, ‘임혁동음이의’ 같은 단어입니다. ‘동음이의’는 문서 제목에 붙는 말입니다. 문제는 위키 첫 문장을 70자에서 자른 것이라, ‘이채윤가수’의 문제가 “채윤(본명: 이슬, …)은 대한민국의 가수이다”처럼 답을 거의 드러냅니다. 저장된 8판 중 4판에 이런 단어가 들어갔습니다.
  • 최신 소식은 실제로는 거의 쓰이지 않습니다. 새 판 API가 뉴스를 보고 AI가 단어를 고르는 기능을 꺼 둔 채로 부릅니다. 헤드라인은 ‘네팔’·‘홍수’·‘빙하’가 들어 있으면 네팔과 히말라야를 넣는 규칙 하나에만 씁니다. 다른 나라 홍수 기사에도 네팔이 들어갑니다. 시사 문제는 대부분 손으로 넣은 12개입니다.
  • 난이도별 어휘 등급(흔한 말·보통 말·드문 말)은 계산하는 함수만 있고 생성기에서는 쓰지 않습니다. 지금 난이도는 판 크기, 공개 칸, 답 길이로만 갈립니다.

이어 풀기는 잘 됩니다

중간 저장한 판은 기보에서 다시 열어 이어 풀 수 있었고, 빈 판은 지워졌습니다. 답을 통째로 넣게 바꾼 뒤 끝낸 20문제 판은 24번, 27번 입력해서 24분, 15분 걸렸습니다.

하나는 고쳐야 합니다. 게임방이 5초마다 진행 시간을 저장해서, 아무것도 입력하지 않은 판을 탭에 띄워 두기만 해도 시간이 쌓입니다. 빈 판 하나에 13시간 넘게 기록돼 있었고, 대기실을 거치지 않아서 지워지지도 않았습니다.

고칠 방향

  • 판 설계에서 AI를 빼거나 뒤로 미룹니다. 지금은 15초를 쓰고 얻는 것이 없습니다. 판은 프로그램이 만들되, 가로·세로가 겹치는 칸을 줄인 성긴 판으로 갑니다.
  • 단어를 판에 맞춰 찾습니다. ‘○스○○’처럼 칸에 이미 정해진 글자를 주고 모델이 후보 단어를 내게 한 뒤, 사전으로 확인해서 넣습니다. 미리 정한 단어 은행만으로는 4·5글자가 모자랍니다.
  • 판을 미리 만들어 둡니다. 판 하나에 44초쯤 걸리니 난이도마다 뒤에서 만들어 쌓아 두고 바로 꺼내 주면 됩니다. 그러면 시간 제한도 늘릴 수 있고 검수할 시간도 생깁니다.
  • 문제를 검수합니다. 시사 문제는 퀴즈 형식을 지키게 하고, 날짜·시제와 인용을 한 번 더 확인합니다. 위키의 ‘동음이의’ 문서와 지나치게 긴 고유명사는 빼고, 정답 일부가 문제에 들어가는지도 봅니다.
  • 잘린 단어와 틀린 문제를 정리하고, 어휘 등급을 실제 난이도에 반영합니다.

저장된 32판 전체입니다. 단어 수와 판 사용률은 분명히 좋아졌습니다. 다만 그 판은 계획한 ‘판 먼저’가 아니라 예비 경로에서 나왔고, 남은 일은 판 모양과 문제의 질입니다.

이 단계의 실제 코드

판을 채우는 순서입니다. 미리 그린 판 → 무작위 판 → 단어 연결형 배치기 순으로 시도합니다. 측정에서는 앞의 두 단계가 한 번도 성공하지 못해 늘 마지막 줄까지 내려갔습니다.

lib/gridFill.tstypescript · 15줄
for (const mask of shuffle(readyBoards(size, cap), rng)) {
  const filled = tryMask(mask);
  if (filled) return filled;
}
for (let attempt = 0; attempt < 8; attempt++) {
  const mask = makePattern(size, 2, cap, rng);
  if (!mask) continue;
  const filled = tryMask(mask);
  if (filled) return filled;
}
for (let attempt = 0; attempt < 5; attempt++) {
  const connected = tryBuildConnected(size, maxLen, bank, prefer, avoid, rng);
  if (connected) return connected;
}
return null;

시사 단어를 모으는 부분입니다. 새 판 API는 useLlm을 false로 넘기기 때문에 뉴스 기반 AI 단어는 비어 있고, 헤드라인은 네팔 규칙에만 쓰입니다.

lib/dynamicBank.tstypescript · 18줄
export async function loadDynamicBank(
  difficulty: DiffKey,
  avoid: Iterable<string>,
  options?: { useLlm?: boolean },
): Promise<DynamicBankResult> {
  const avoidList = [...avoid];
  const [headlines, wiki] = await Promise.all([fetchNewsHeadlines(), fetchWikiEntries(16)]);
  const llm = options?.useLlm === false ? [] : await fetchLlmEntries(difficulty, headlines, avoidList);
  const newsHints: WordEntry[] = [];
  if (headlines.some((h) => h.includes("네팔") || h.includes("홍수") || h.includes("빙하"))) {
    const nepal = asHangulEntry("네팔", "이번에 히말라야 빙하가 무너져 큰 홍수가 난 나라");
    const himal = asHangulEntry("히말라야", "올해 빙하가 붕괴해 네팔에 대홍수를 일으킨 산맥");
    if (nepal) newsHints.push(nepal);
    if (himal) newsHints.push(himal);
  }
  const entries = [...llm, ...newsHints, ...wiki];
  const source: DynamicBankResult["source"] = llm.length >= 12 ? "llm" : wiki.length ? "wiki" : headlines.length ? "news" : "bank";
  return { entries, source };

5글자를 맞추려고 자른 단어들입니다.

lib/words.tstypescript · 3줄
{ word: "시외버스터", clue: "시외버스가 모이는 곳의 앞말" },
{ word: "인천국제공", clue: "서해 관문 국제공항의 앞말" },
{ word: "김포국제공", clue: "서울 서쪽 국제공항의 앞말" },

다음 글은 ‘4. 전체 소스 코드와 사용한 도구’입니다.