문항 240개, 개념 55개, 세부분류 27개, 대과목 4개. 필기시험 하나를 위한 정적 사이트에 이만한 콘텐츠를 욱여넣으면서 빌드 도구는 단 하나도 쓰지 않았다. 번들러도, 프레임워크도, CI 워크플로도 없다. HTML과 CSS, 그리고 옛 방식 그대로의 자바스크립트 파일 몇 개가 전부였다.

미용사(일반) 필기시험을 준비하는 학습·모의고사 사이트를 만들면서 겪은 판단들을 정리한다. 특정 자격증 이야기지만 판단 자체는 어떤 학습·퀴즈 사이트에도 옮겨 담을 수 있다.

왜 번들러 없이 만들기로 했나

GitHub Pages에 그대로 올릴 정적 파일 몇 개면 충분했다. 서버 쪽 로직이 필요 없었다. 사용자 계정도, 결제도, 실시간 통신도 없이 JSON 데이터를 읽어 화면에 뿌리는 게 전부였다. 이런 조건에서 번들러(bundler, 여러 자바스크립트 파일을 하나로 묶어 브라우저에 최적화된 형태로 내보내는 도구)를 들이는 건 배보다 배꼽이 컸다.

그래서 ES5 IIFE(즉시 실행 함수, Immediately Invoked Function Expression — 함수를 선언과 동시에 실행해 그 안의 변수를 전역 공간에서 격리하는 옛 방식의 모듈화 패턴) 스타일 자바스크립트만으로 짰다. 파일을 저장소에 커밋하고 GitHub Pages의 "브랜치에서 배포" 설정만 켰다. 별도의 Actions 워크플로도 필요 없었다. 커밋 한 번에 12개 파일, 7,916줄이 올라갔고 그 상태 그대로 라이브 URL에서 확인됐다.

노트북 화면에 학습 개념 카드와 60분 시험 타이머가 함께 떠 있는 모습을 표현한 일러스트
번들러 없이 정적 파일만으로 학습 화면과 시험 화면을 함께 짜던 작업 장면을 표현한 일러스트.

npm 의존성을 하나도 깔지 않은 덕에 얻은 것도 있다. 공급망 취약점(supply chain 위험, 설치한 패키지의 패키지가 다시 취약점을 끌고 들어오는 문제)을 걱정할 일 자체가 없었다. 배포 후 페이지 로딩도 별도 번들 파일을 내려받지 않으니 빨랐다. 대신 타입 검사도, 린트도, 자동 테스트도 없다. 오타 하나가 배포된 뒤에야 화면에서 드러난다.

이 선택엔 대가가 따랐다. 모듈 시스템이 없으니 파일 사이의 의존 순서를 스크립트 태그 나열 순서로 손수 관리해야 했다. 더 아픈 대목은 따로 있었다. study·quiz·exam 등 여러 HTML 파일이 같은 상단 헤더를 각자 복사해 넣고 있다. 헤더 문구 하나를 고치려면 파일 네 개를 일일이 열어야 한다. 컴포넌트 시스템이 있었다면 한 곳만 고치면 됐을 일이다. 지금은 이 중복을 그대로 남겨 두고 있다. 빌드 도구 없이 얻는 단순함과 이런 반복 작업 사이의 저울질은 여전히 진행형이다.

서버가 필요한 프로젝트라면 얘기가 다르다. 인증과 결제까지 갖춘 스택을 고르는 게 맞는 경우도 있다(관련 글: SaaS 인증·과금 보일러플레이트). 이번엔 정반대였다. 정적 파일만으로 끝나는 문제에 무거운 스택을 얹었다면 유지보수 부담만 늘었을 것이다. 도구는 문제 크기에 맞춰 고르는 게 먼저고, 유행이나 습관은 그다음이다.

4단계 계층으로 콘텐츠를 쌓다

학습 콘텐츠는 대과목 4개 아래 중과목 27개, 그 아래 개념 55개를 두는 구조로 짰다. 대과목별 개념 수는 21·11·12·11로 갈렸다. 균등하게 나누지 않고 실제 출제 비중을 따라간 결과다. 데이터 파일 하나(concepts.json)에 categories[].subCategories[].concepts[] 형태로 전체 계층을 담고, 각 개념에는 제목·본문·요약·시험포인트를 필드로 뒀다.

대과목 4개, 중과목 27개, 개념 55개, 문항 240개로 이어지는 콘텐츠 계층 구조와 각 층의 역할을 정리한 다이어그램
대과목 → 중과목 → 개념 → 문항으로 이어지는 콘텐츠 계층 구조를 직접 그린 다이어그램.

중과목 27개를 정하는 일 자체가 만만치 않았다. 공식 출제기준과 표준교재를 나란히 놓고 겹치는 지점만 남기는 삼각측량(triangulation, 서로 다른 기준점 여러 개를 겹쳐 위치를 좁히는 방법) 방식으로 매핑했다. 한쪽 문서에만 있는 항목은 빼고, 두 문서가 같이 가리키는 항목만 중과목으로 확정했다. 그래야 어느 한 출처의 오류나 누락이 그대로 구조에 새겨지지 않는다.

화면 쪽에서는 "한 번에 개념 하나씩" 읽는 방식을 골랐다. 55개 개념을 한 줄로 늘어놓고 좌우 버튼으로만 넘긴다. 이전·다음 버튼 안에는 다음 개념 제목을 작은 글씨로 미리 보여준다. 지금 읽는 개념이 전체 55개 중 어디쯤인지, 다음엔 뭐가 나오는지 매번 헤매지 않게 하려는 장치다. 화면 가운데엔 "이 중과목 문제 풀기" 버튼을 두어 바로 그 범위의 문제풀이로 넘어가게 했다. 지금 읽던 개념이 어디였는지는 URL 해시에 그대로 남긴다. 새로고침을 하거나 링크를 다시 열어도 마지막으로 보던 개념부터 이어진다. 별도의 로그인이나 서버 저장 없이 URL 한 줄로 상태를 지켜낸 셈이다.

문제은행은 처음 80문항이던 것을 240문항으로 세 배 불렸다. 이론 96, 피부 24, 화장품 36, 위생 84문항으로 나눴다. 처음엔 27개 중과목 가운데 9개가 문제 0개인 빈 칸이었다. 그 빈 칸부터 채우는 걸 확장의 첫 기준으로 삼았다. 개념은 있는데 문제가 없으면 그 중과목은 배웠는지 확인할 방법이 없어서다. 각 문항에는 카테고리·질문·보기 4개·정답 인덱스·해설·소속 중과목을 필드로 붙였다. 해설 없이 정답만 보여주면 틀린 이유를 모른 채 다음 문제로 넘어가 버린다. 그래서 문항 하나마다 해설을 반드시 채우는 걸 원칙으로 삼았다.

시험 엔진의 규칙

모의고사는 60문항, 60분, 100점 만점, 60점 합격으로 고정했다. 실제 필기시험 채점 기준을 그대로 옮긴 숫자다. 화면엔 남은 시간을 보여주는 타이머와 60문항 전체를 한눈에 훑는 문항 네비게이션 그리드를 뒀다. 시험이 끝나면 오답만 따로 모아 보여주는 오답노트가 뜬다. 이 노트는 브라우저 기본 태그인 <details>(별도 자바스크립트 토글 로직 없이도 클릭 한 번으로 접고 펼 수 있는 여닫이 태그)로 구현해 코드를 더 늘리지 않았다.

문항 샘플링부터 60분 타이머, 채점, 60점 합격 판정과 오답 복습으로 이어지는 시험 엔진 흐름을 정리한 다이어그램
문항 샘플링부터 채점·오답 복습까지, 시험 엔진이 한 번의 응시를 처리하는 흐름을 직접 그린 다이어그램.

문제풀이 화면은 모의고사와 따로 뒀다. 카테고리별 모드와 랜덤 모드 두 가지를 기본으로 두고, URL에 ?sub= 파라미터를 붙이면 그 중과목 문제만 자동으로 시작한다. 학습 화면의 "이 중과목 문제 풀기" 버튼이 바로 이 파라미터를 만들어 넘긴다. 다만 문항이 아직 하나도 없는 중과목으로 이 링크를 타면 빈 화면 대신 일반 설정 화면으로 되돌리게 해뒀다. 데이터가 비었다고 사용자에게 에러 화면을 보여줄 이유는 없다. 모의고사 쪽은 60문항을 매번 다시 섞어 뽑는다. 같은 순서로 문제가 나오면 답을 문항 번호로 외워 버리는 부작용이 생긴다. 그걸 막으려고 응시할 때마다 240문항 전체 풀에서 새로 샘플링한다.

처음엔 이미 존재하는 정리 블로그의 문항과 설명을 참고해 그대로 옮기려 했다. 자료 수집을 맡은 에이전트가 그 접근에 저작권 문제가 있다고 짚었다. verbatim(원문 그대로) 복제는 출처를 밝히든 안 밝히든 별개의 위험이다. 그 순간 접근을 바꿨다.

대신 세 가지를 근거로 삼았다. 이미 정제해 둔 concepts.json(직접 만든 자산), 기존 문항의 스타일과 난이도 감각, 그리고 미용사 표준지식(공개된 출제기준과 표준교재)이다. 이 셋을 재료로 신규 160문항을 완전히 새로 저술했다. 문장도 구조도 원본과 겹치지 않게 처음부터 다시 썼다.

그대로 베끼면 손이 빠르다는 유혹은 여전했다. 그래도 다시 쓰는 쪽을 택했다. 표현 몇 개를 바꾸는 수준의 각색은 결국 같은 구조를 숨기고 있을 뿐이다. 문항 하나를 새로 쓸 때마다 "이 문장이 원본 없이도 혼자 성립하는가"를 기준으로 삼았다. 성립하지 않으면 다시 썼다.

새로 쓴 문항을 그냥 믿을 순 없었다. Opus 계열 모델로 4회에 걸쳐 각도를 바꿔 검증했다. 사실관계를 확인하는 검증이 아니라 반박부터 시도하는 어드버서리얼(adversarial, 통과시키려는 태도가 아니라 틀렸다는 걸 증명하려 드는 검증 방식) 방식으로 돌렸다. 결과는 카테고리별로 갈렸다.

카테고리 검증한 신규 문항 수 발견된 오류
화장품학 24문항 0건
피부학 16문항 0건
공중위생 56문항 0건
이론(미용학개론 등) 64문항 1건(문두 표현 명확화 필요)

이론 파트에서 걸린 한 건은 사실관계 오류가 아니라 문두 표현이 애매해 고친 사례였다. 검증 과정에서 기존 문항의 오류도 하나 나왔다. "폐업신고는 7일 이내에 해야 한다"는 문항이 있었는데, 공중위생관리법 제3조 제2항에 따른 실제 기한은 20일이었다. 이 문항은 20일 기준으로 다시 쓰고, 새로 늘린 위생 문항 쪽에도 같은 규정을 다루는 문항을 하나 더 넣었다.

배포 뒤에 걸린 함정들

배포 직후엔 눈에 안 보이던 CSS 버그 하나가 있었다. 퀴즈·모의고사 화면 요소 몇 개가 [hidden] 속성으로 숨겨져 있어야 하는데 항상 화면에 겹쳐 보였다. HTML의 hidden 속성은 원래 브라우저 기본 스타일로 display:none을 받는다. 문제는 다른 CSS 규칙이 같은 요소에 display:flexdisplay:block을 명시하면 특정도(specificity, CSS 규칙끼리 충돌할 때 어느 쪽을 우선 적용할지 정하는 점수 체계) 계산에서 그 규칙이 이겨 버린다는 데 있었다. 결국 [hidden]{display:none}을 세 곳에 명시적으로 덧붙이고서야 요소가 제대로 숨었다.

검증 방식에도 함정이 하나 더 있다. 만든 화면은 반드시 별도의 브라우저 자동화 에이전트로 확인해야 한다는 원칙을 세워 뒀다. 그런데 두 검증 에이전트를 동시에 돌리다 서로 발이 걸렸다. 둘 다 같은 디버깅 포트(8765)로 같은 Chrome 인스턴스에 붙어 있었던 탓이다. 헤드리스 Chrome 자동화는 이 포트로 명령을 보내는데, 포트가 같으면 명령이 아무 탭에나 꽂혀 서로의 세션 화면을 가로챈다. 화면이 시키지도 않은 페이지로 저절로 넘어가는 이상 현상이 그래서 생겼다. 원인은 단순했다. 검증 에이전트를 동시에 두 개 띄우지 않고 하나씩 순서대로 돌리는 것으로 끝났다.

표준 절차 하나만으로는 다 안 잡히는 문제도 있는 셈이다. 도구를 병렬로 돌리면 빨라 보이지만 같은 자원을 공유하는 순간 그 이득은 사라진다. 포트를 나누는 것보다 순차 실행이 확실했다.

남은 숙제는 헤더 중복이다. study·quiz·exam·index 네 화면에 손으로 복붙해 둔 공용 헤더는 아직 그대로다. 관련 기록은 프로그래밍 카테고리에 계속 남긴다. 문구 하나를 고치려면 네 파일을 다 열어야 하는 구조는 콘텐츠가 더 늘어나면 언젠가 발목을 잡을 것이다. 다음 손볼 대상은 정해졌다. 이 헤더를 어떻게 한 곳으로 모으느냐다.