퀴즈를 발행 절차 끝에 붙이고 나서야 문제은행이 채워졌다

· 4분 읽기
목차

이 글은 퀴즈를 잘 만드는 법에 관한 것이 아니다. 손이 가야 돌아가던 일을 이미 있던 절차의 끝에 붙여서, 결심 없이도 돌아가게 만든 기록이다.

발행 절차와 그 바깥의 퀴즈

이 블로그에는 글이 나오기까지의 길이 있다. 글을 쓰고, 빌드와 린터로 검증하고, PR을 열어 Cloudflare Pages 프리뷰 배포에서 검수한다. 걸리면 고치고, 통과하면 머지하고 배포된다. 회고든 기술 해설이든 발행물은 전부 이 길을 지나간다.

한 가지가 더 있다. 글은 발행 전에 익명화를 통과한다. 사내 코드네임과 실명, 내부 식별자를 걷어내는 단계다. 그래서 PR 검수를 통과한 글은 곧 익명화까지 끝난 본문이다.

4월에 간격 반복 퀴즈 시스템을 만들었다. 문제은행에 문항을 넣어 두면 망각 곡선에 맞춰 복습 카드를 보내주는 구조다. 이건 위 절차 밖에 있었다. vault 노트를 재료로 문항을 만들어 배치로 집어넣는 방식이었고, 발행과는 아무 관계가 없었다.

문제은행이 멈춰 있었다

시스템 자체는 잘 돌았다. 카드는 때가 되면 왔다. 문제는 그 카드가 볼 때마다 같은 문항이었다는 것이다.

문항을 넣는 일이 수동이었고, 나는 그 일을 계속하지 않았다. 몇 번 만들어 넣다가 그만뒀다. 그동안 블로그에는 글이 계속 올라갔지만 문제은행은 4월 그대로였다.

복습 시스템에서 문항이 안 늘어나는 것은 시스템이 없는 것과 크게 다르지 않다. 같은 것을 반복해서 맞히는 데는 오래 걸리지 않는다.

재료가 곧 노출의 원천이었다

의지 문제로 보기 쉽지만, 손이 안 간 데는 이유가 있었다.

문항을 만들려면 글의 원본을 읽어야 한다. 그런데 내 글의 재료는 대부분 회사 일이다. 익명화 이전 원본에서 문항을 뽑으면 사내 식별자가 그대로 문제 텍스트에 실릴 수 있다. 문제은행은 /quiz 페이지로 공개되므로, 한 번 새면 그대로 공개면에 올라간다.

그래서 문항을 만들 때마다 무엇이 노출되는지 내가 읽어야 했다. 그 검토는 문항을 만드는 시간보다 길었다. 게다가 미룬다고 아무 일도 일어나지 않는 종류의 일이라, 밀리기 시작하니 끝까지 밀렸다.

발행 세션에서 만드는 방법도 생각했지만 같은 문제였다. 글을 쓰던 세션의 대화에는 익명화 이전 원본이 어딘가 남아 있다. 그 세션에 퀴즈까지 맡기면 원본이 문항으로 흘러갈 통로가 하나 더 생긴다.

결국 이 일에는 안전한 재료가 없었다. 그게 4개월 넘게 멈춰 있던 이유다.

이미 있던 파일이 재료였다

발상을 바꾼 지점은 검토를 더 잘하는 방법이 아니라, 검토가 필요 없는 재료를 찾는 것이었다.

익명화가 끝나 공개가 확정된 본문. 그건 이미 안전하다. 그리고 그 본문은 이미 존재한다. PR 검수를 통과한 그 markdown 파일이다. 새로 만들 것이 없고, 이미 있는 것을 재료로 지목하기만 하면 됐다.

그래서 퀴즈 생성을 발행 절차의 끝에, 발행 커밋 직전에 붙였다.

발행 절차의 흐름. PR 검수 다음 quiz 플래그에서 갈라져, 켜진 글만 퀴즈 생성과 사람의 검수를 거쳐 발행 커밋에 합류한다. 배포 이후 Worker가 5분마다 배포 SHA를 확인해 문제은행에 등록한다

버린 안도 있다. 프롬프트로 “사내 정보를 쓰지 마라”고 거는 방법이 제일 간단했지만, 그건 재료를 그대로 두고 출력만 단속하는 것이라 검토가 여전히 필요하다. 재료 자체를 바꾸면 검토할 것이 없어진다.

격리 프로세스에서 D1까지

실제로 어떻게 도는지 보자.

발행 커밋을 만들기 직전에, claude -p로 새 프로세스를 하나 띄우고 파일 경로 하나만 넘긴다. 이 프로세스는 내가 글을 쓰던 세션과 대화를 공유하지 않는다. 읽을 수 있는 것이 공개 확정 markdown뿐이라, 샐 원본이 애초에 없다.

출력은 src/data/blog/{slug}.quiz.json이다.

{
  "quizzes": [
    {
      "key": "isolated-generation",
      "type": "multiple_choice",
      "question": "...",
      "options": ["...", "...", "...", "..."],
      "answer": 1,
      "explanation": "...",
      "difficulty": "medium"
    }
  ]
}

key는 글 안에서 유일한 영문 슬러그이고, D1의 id는 q_{slug}_{key}로 결정론적으로 만들어진다. 같은 글을 다시 sync해도 같은 id가 나오니 중복이 생기지 않는다. 이 파일은 Astro의 콘텐츠 glob(*.md) 밖이라 빌드에 잡히지 않고, 사이트 정적 산출물로도 내보내지 않는다. 정답이 공개면에 나가면 안 되기 때문이다.

퀴즈를 붙일지는 글마다 정한다. frontmatter의 quiz: true가 그 스위치이고 기본값은 false다. PR 검수를 마친 시점에 이 플래그를 보고 갈린다. 켜져 있으면 아래 생성과 검수 단계로 가고, 꺼져 있으면 그대로 발행 커밋으로 간다. 뉴스 큐레이션이나 짧은 글에 억지 문항을 만들면 문제은행 품질만 떨어지니, 이 판단은 자동으로 하지 않는다.

문항을 검수하고 승인하면 quiz 파일이 글과 같은 커밋에 실린다. 머지되면 Cloudflare Pages가 배포한다. 그다음부터가 코드 몫이다.

quiz Worker에 5분 주기 cron이 걸려 있다. 배포가 끝났다고 누가 알려주는 구조가 아니다. Worker가 5분마다 스스로 깨어나 확인하러 간다. tick마다 하는 일은 세 가지다.

  1. Cloudflare Pages Deployments API에서 최신 성공한 프로덕션 배포의 commit SHA를 읽는다. latest_stage.name === 'deploy'이고 status === 'success'인 것만 고른다.
  2. KV에 저장된 last-synced-sha와 비교한다. 같으면 아무것도 하지 않는다.
  3. 다르면 GitHub compare API로 base...head 구간을 훑어 src/data/blog/{slug}.quiz.json 패턴에 걸리는 변경만 추린다. 그 파일들을 읽어 /sync로 넘기고, 끝나면 KV의 SHA를 갱신한다.

배포 SHA가 기준이라는 것은 커밋 시점이 아니라 사이트에 실제로 올라간 시점을 본다는 뜻이다. draft: true인 글은 빌드에서 빠지므로 배포에도 없고, 따라서 문항도 등록되지 않는다. 알림을 받는 대신 확인하러 가는 방식이라 등록이 최대 5분 늦는다. 대신 한 번 실패해도 다음 tick이 같은 것을 다시 집는다.

sync 쪽은 소스 단위 대사(reconcile)다. blog:{slug}를 받아 그 소스의 문항 전체를 현재 파일 내용과 맞춘다. 파일에서 사라진 문항은 은퇴 처리되고, 남은 것은 upsert된다. 그래서 같은 tick이 두 번 돌아도, 실패해서 다음 주기에 다시 돌아도 결과가 같다.

입력 검증도 sync가 한다. answer는 타입마다 인코딩이 다르다. 객관식은 선지 인덱스(정수), OX는 boolean, 빈칸 채우기는 문자열 배열이다. 어긋나면 400으로 거절한다. 이걸 배포 후에 발견하면 늦으니 로컬에 scripts/verify-quiz.py를 두고 push 전에 같은 규칙으로 먼저 검사한다. 8월 말에 이 검증 없이 여섯 파일을 한꺼번에 push했다가 전부 거절당하고 실패 알림만 받은 적이 있다.

문항을 자르는 일만 남았다

생성이 자동이라고 나온 문항이 다 쓸 만한 것은 아니다. 최근 글들의 퀴즈를 채우면서 나온 문항 중 셋에 하나 이상을 잘랐다.

남긴 쪽은 이런 문항이다.

역할 넷에 도메인 둘을 곱해 프로파일 여덟 개가 되는 격자 문제를 저자가 푼 방식은?

본문의 실제 구조를 앵커로 삼고, 오답 선지가 “여덟 개를 전부 만들었다”는 그럴듯한 오해를 반영한다. 맞히려면 글의 판단을 기억해야 한다.

자른 쪽은 반대였다. 수치를 통째로 외우게 하는 문항, 본문 주제와 상관없는 부수 정보를 묻는 문항. 맞혀도 복습이 되지 않는다. 만드는 일은 기계가 잘하는데, 기억할 가치가 있는지 가리는 눈은 아직 없다.

검수 자리를 새로 만들지 않은 것도 의도였다. 이 저장소에는 원래 origin push 전에 확인을 받는 절차가 있다. 그 자리에서 문항 요약을 함께 보여주는 것으로 바꿨을 뿐이다. 새 습관을 하나 더 만들었으면 4월 시스템처럼 또 밀렸을 것이다. 이미 지키는 것에 얹는 게 맞았다.

재료를 고르고, 만들고, 등록하는 일은 코드가 한다. 무엇이 기억할 값어치가 있는지는 내가 정한다. 이번 작업에서 실제로 시간을 쓴 곳은 파이프라인을 짜는 데가 아니라 이 선을 어디에 그을지 정하는 데였다.

퀴즈를 직접 풀어보려면 공개 퀴즈 페이지로.

이어서 읽기