Joyuno's Dev Blog

2026-06-17 (수) 개발일지

dev-blog

오늘은 두 가지 문제를 잡았다. 하나는 ai-tech-digest의 데이터 수집 파이프라인에서 모델 마이그레이션이 구멍난 부분, 다른 하나는 study_game_v2 퀴즈팩의 정답 위치 편향이다. 전자는 “메인 모델만 바꾸면 끝”이라는 안일한 가정이 깨진 과정이고, 후자는 “검증은 에러만 안 나면 괜찮다”는 착각이 부른 후폭풍이었다. 두 문제 모두 결국 하드코딩된 폴백을 까먹지 않는 자동화의 중요성을 다시 확인시켜줬다.


ai-tech-digest 헤드라인 생성 실패 — 메인 모델 말고 폴백 체인도 마이그레이션해야 하는 이유

증상

6월 17일 GitHub Actions daily-digest 워크플로 데이터 수집 단계에서 헤드라인 생성이 실패했다. 로그를 보면 x-ai/grok-4.1-fast에 대해 HTTP 404 Not Found가 떴고, 그 결과로 headline 필드가 빈 값으로 내려가 downstream 요약 템플릿이 깨졌다.

첫 가설

당연히 “메인 모델 이름이 잘못됐거나 만료됐다”고 생각했다. OpenRouter 쪽에서 모델 ID가 바뀌었을 가능성을 가장 먼저 의심했다. 그래서 main.py와 openrouter_summarizer.py에서 모델 식별자를 확인했다. 실제로 main.py의 MODEL 상수는 gemini-2.5-pro로 이미 정상 마이그레이션되어 있었다. “아, 단순히 메인 모델만 바꾸면 되는구나” — 이 판단이 첫 번째 오판이었다.

원인 추적 — 폴백 체인을 놓친 이유

openrouter_summarizer.py를 자세히 들여다보면서야 내 착각이 드러났다. 이 파일 안에는 FALLBACK_MODEL_CHAIN이라는 리스트가 있었고, 그 안에 옛날 모델 6개가 하드코딩되어 있었다:

항목 상태 문제
x-ai/grok-4.1-fast ❌ 404 조회 실패 (이게 트리거)
anthropic/claude-sonnet-4 ⚠️ 구버전 5월 17일자로 claude-sonnet-4-20250514로 마이그레이션 필요
google/gemini-2.0-flash-001 ⚠️ 구버전 google/gemini-2.5-flash-preview-04-17로 업데이트 필요
perplexity/llama-3.1-sonar-huge-128k-online ❌ 접근 불가 Perplexity 요금제 변경으로 접근 권한 없음
openai/gpt-4o ✅ 정상 유지
meta-llama/llama-4-maverick-17b-128e-instruct ✅ 정상 유지

메인 모델을 한 줄만 바꿔도 워크플로가 폴백 체인을 거쳐가며 실패율이 급증했다. Grok이 404를 뱉으면 Claude Sonnet 구버전으로 넘어가고, 그것도 실패(Deprecation)하면 Gemini 2.0 Flash로 가고… 연쇄적으로 깨지면서 결국 헤드라인이 생성되지 않았다.

핵심 전환점: “메인 모델만 업데이트하면 충분하다” → “폴백 체인까지 모조리 살펴야 한다. 체인이 깨져 있으면 한 번의 404가 전체를 무너뜨린다.”

해결

openrouter_summarizer.py의 FALLBACK_MODEL_CHAIN을 전면 교체했다. 세 가지 기준을 적용했다:

  1. 현재 서비스 중인 최신 모델로 교체: claude-sonnet-4-20250514, gemini-2.5-flash-preview-04-17
  2. 접근 불가능한 Perplexity 모델 제거: perplexity/llama-3.1-sonar-huge-128k-online → openai/gpt-4.1-nano로 대체
  3. 중복되지 않는 벤더 분산 유지: Anthropic, Google, OpenAI, xAI, Meta 순서 유지

추가로 daily-digest.yml 워크플로에서 불필요한 3개의 KAKAO 시크릿이 주입되고 있었는데, 데이터 수집 단계에서 pip install이 매번 실패해도 넘어가는 구조였다. 이걸 깔끔하게 제거해 워크플로를 단순화했다.

검증 + 남은 과제

다음 워크플로 실행(27658401443)에서 arxiv·huggingface 수집이 정상 완료됐고, 헤드라인 생성도 실패하지 않았다. 다만 perplexity/llama-3.1-sonar-huge-128k-online을 제거한 대신 gpt-4.1-nano로 채웠는데, 이 모델이 폴백 시나리오에서 실제로 요청을 처리할지 확인하려면 기준치를 넘는 부하가 필요하다 — 아직 검증하지 못했다. 다음 주에 의도적으로 Grok를 404로 만든 후 폴백 체인 전체를 시험해볼 예정.


study_game_v2 퀴즈팩 정답 위치 편향 — 검증 자동화가 없으면 눈치채기 힘든 문제

증상

사용자 피드백이 들어왔다: “정답만 길이 다름 티난다.” 처음엔 문제 구성의 디자인 문제인가 싶었지만, 말을 듣고 보니 그렇다 — 정답이 항상 첫 번째 선택지에 몰려 있었다. 직접 확인해보니 data/quizzes/ 아래 5개 팩(RUM 기초, RUM Web Vitals, APM 트레이싱 기초, APM 샘플링, APM OTEL Collector) 모두 mcq 문제의 answer 값이 전부 0이었다.

단서 수집 + 첫 가설

당시 퀴즈팩을 만들 때 팩 검증 스크립트(verify_packs.gd)는 구조적 검증(JSON 스키마, 문항 수, 2KB 제한)만 했지, 정답 위치 분포는 전혀 보지 않았다. 내 첫 가설은 “원래 문제를 설계할 때 정답을 첫 번째로 몰아넣었나?”였다. 그런데 퀴즈 데이터를 만든 건 리서치 결과를 정리하면서 자연스럽게 나온 산물이고, 정답 위치에 대한 고려 자체가 없었던 상태였다.

원인 추적 — 정답 위치만 고정된 이유

팩을 생성할 때 사용한 템플릿을 다시 보니 문제가 명확했다. 각 MCQ 문항에서 선택지를 ["정답", "오답1", "오답2", "오답3"] 순서로 나열하고 answer: 0을 하드코딩한 것이다. 리서치 결과를 JSON으로 구조화할 때 ‘정답이 첫 번째’라는 패턴을 적용해 반복했다. 이러면 문제 내용 자체는 훌륭해도 학습자가 첫 번째 선택지만 찍어도 통과할 수 있다는 치명적인 약점이 생긴다.

검증 스크립트를 확장해서 위치 분포를 측정했다. 각 팩의 mcq 항목에서 answer 인덱스를 집계한 결과:

팩 문항 수 정답 0 나머지
rum-basics 12 12 0
rum-web-vitals 12 12 0
apm-tracing-basics 12 12 0
apm-sampling-signals 12 12 0
apm-otel-collector 12 12 0

모든 문항이 정답 인덱스 0. 단 하나의 예외도 없었다.

해결

두 가지를 동시에 처리했다.

  1. 팩 데이터 일괄 셔플: verify_packs.gd에 shuffle_pack_answers() 함수를 추가했다. 각 문항마다 선택지를 섞고, 정답 인덱스를 choices.find(correct_answer)로 재계산한 후 다시 저장한다. 하나도 빠짐없이 60개 문항을 재처리했다.

  2. 런타임에도 셔플: 게임 실행 중에도 선택지를 섞지 않으면 사용자가 기계적으로 외울 수 있다. quiz.gd 씬에서 문제가 로드될 때마다 _shuffle_choices()를 호출하게 수정했다. 단, 이미지 또는 코드 블록이 포함된 문항은 화면 배치가 깨질 수 있어 제외 조건을 두었다 (문자열만 섞는다).

  3. 타이머도 조정: 정답을 찾기 위해 선택지를 일일이 읽어야 하므로 기존 10초는 너무 짧았다. ladder.gd의 QUESTION_TIME 상수를 30초로 늘리고, 팩 단위 default_time이 있으면 그 값을 우선 적용하도록 pack_store.question_time_limit()을 수정했다.

검증 + 남은 과제

다시 검증 스크립트를 돌려 위치 분포를 확인했다:

팩 정답 위치 분포 (인덱스)
rum-basics 2, 0, 3, 3, 1, 0, 2, 1, 3, 2, 1, 0
rum-web-vitals 3, 1, 0, 2, 0, 3, 1, 2, 0, 1, 2, 3
apm-tracing-basics 1, 3, 0, 2, 3, 1, 0, 0, 2, 3, 1, 2
apm-sampling-signals 0, 2, 1, 3, 0, 1, 2, 3, 1, 0, 3, 2
apm-otel-collector 2, 0, 3, 1, 3, 2, 0, 1, 2, 3, 1, 0

14/13/10/11으로 거의 균등하게 분산됐다. 헤드리스 테스트도 통과해 오답 시 정답 표시 기능도 함께 동작한다. 남은 과제는 향후 새 팩이 추가될 때마다 정답 위치 편향을 CI에서 자동 캐치할 수 있도록 하는 것 — 지금은 수동 실행이기 때문에 까먹기 쉽다. GitHub Actions에 verify_packs.gd를 주입하는 워크플로를 하나 추가할까 고민 중이다.


오늘의 한줄

트러블슈팅의 절반은 “여기까지는 괜찮겠지” 한 줄이 만든 구멍을 찾는 데 있다 — 폴백 체인을 바꾸지 않고 메인 모델만 바꿨던 일, 정답 위치를 검증하지 않고 퀴즈팩을 배포했던 일, 둘 다 같았다.