SVG 다이어그램·파라메트릭 생성기: 200개 시드로 358문제 테스트
Source: Dev.to
“직각삼각형 A에서 빗변 BC를 구하세요. AB = 12 cm, AC = 9 cm, BC = ?” 학생은 문제를 읽고 그림을 살펴봅니다… 그런데 그림에는 AB = 3 cm라고 적혀 있습니다. 전혀 다른 삼각형이죠. 문제에 제시된 값은 무작위로 생성되지만, 그림은 고정돼 있습니다. 이런 버그는 프로그램을 크래시 시키지는 않지만, 교육 도구를 쓸 수 없게 만듭니다.
Radar College는 프랑스 중학교 시험을 위한 퀴즈 플랫폼입니다. React 아키텍처를 구축하고 TypeScript로 마이그레이션한 뒤 남은 큰 과제는 정적 문제에서 파라메트릭 문제로 전환하는 것이었습니다 — 생성된 값에 맞는 SVG 다이어그램을 함께 제공하는 것이죠. 그리고 더 중요한 것은, 이 모든 것을 미치지 않고 테스트할 방법을 찾는 것이었습니다.
정적 문제의 문제점
초기 버전에는 약 300개의 하드코딩된 문제가 있었습니다. 각 문제는 텍스트 질문, 4개의 선택지, 힌트 하나로 구성되었습니다. 작동은 했지만, 3번 정도 시도하면 학생은 문제를 기억하게 됩니다. 무작위화는 문제 선택 순서와 답안 섞기 정도만 영향을 주었고, 실제 수치 자체는 바뀌지 않았습니다.
수학·물리에서는 이것이 치명적입니다. “답은 25 cm이다” 라는 것을 외운 학생은 아무것도 배우지 못한 것이죠. 매 시도마다 숫자가 바뀌어야 하고, 오답 선택지(잘못된 답)도 정답을 기반으로 계산돼서 설득력 있게 보여야 합니다. 무작위 쓰레기가 아니라, 흔히 저지르는 실수(제곱근을 빼먹음, 부호를 뒤바꿈, 곱셈 대신 덧셈 등)에 해당하는 오답이어야 합니다.
제너레이터의 구조
각 파라메트릭 문제는 gen(rnd) 함수이며, 시드가 지정된 PRNG을 받아 { q, options, correct, hint } 객체를 반환합니다. PRNG는 Mulberry32이며, 32비트, 결정적, 빠릅니다. 같은 시드는 언제나 같은 문제를 만들기 때문에, 히스토리에는 시드만 저장하고 복습 시 정확히 같은 문제를 재구성할 수 있습니다.
// Pythagorean theorem generator — compute the hypotenuse
{ key:'pyt-1', gen: (rnd) => {
const TRIPLETS = [[3,4,5],[5,12,13],[8,15,17],[7,24,25],[6,8,10]];
const [a0, b0, c0] = TRIPLETS[Math.floor(rnd() * TRIPLETS.length)];
const k = 1 + Math.floor(rnd() * 3); // multiplier 1..3
const [a, b, c] = [a0*k, b0*k, c0*k];
return {
q: <>Calculate the hypotenuse BC:
,
options: [`${c} cm`, `${a+b} cm`, `${a*a+b*b} cm`, `${Math.abs(a-b)} cm`],
correct: 0,
hint: `BC² = ${a}² + ${b}² = ${a*a+b*b} → BC = ${c} cm.`,
};
}}
세 가지를 주목하세요. 첫째, 피타고라스 삼중항 풀(pool) 덕분에 빗변은 항상 정수입니다 — 중학교 퀴즈에 √41 ≈ 6.403 같은 비정수가 나오지 않게 됩니다. 둘째, 곱셈 인자 k 덕분에 값이 다양해지면서도 정수 영역을 벗어나지 않습니다. 셋째, 오답은 무작위가 아니라 a+b(피타고라스 대신 덧셈 실수), a²+b²(제곱근을 빼먹은 경우), |a-b|(뺄셈 실수)와 같이 고정된 패턴을 따릅니다.
값에 맞춰 그려지는 SVG 키트
위의 “ 컴포넌트는 장식용이 아니라, 값들을 prop으로 받아 일치하는 다이어그램을 그리는 React 컴포넌트입니다 — 라벨이 붙은 변, 표시된 직각, 그리고 찾고자 하는 측정값에 “?”가 표시됩니다.
// _svg-kit.tsx — Parametric right triangle
function TriangleRectangle({ ab, ac, bc }: {
ab?: string | number;
ac?: string | number;
bc?: string | number;
} = {}) {
const lAB = ab !== undefined ? `AB = ${ab}` : 'AB';
const lAC = ac !== undefined ? `AC = ${ac}` : 'AC';
const lBC = bc !== undefined ? `BC = ${bc}` : 'BC (hypotenuse)';
return (
{/* right angle */}
{lAB}
{lAC}
{lBC}
);
}
같은 패턴이 키트 전반에 적용됩니다: ConfigThales(6개의 prop: AM, AB, AN, AC, MN, BC), TriangleTrigo(각도, 맞은편/인접/빗변 변), GrapheAffine(기울기, y절편) 등. 제너레이터가 무작위 값을 선택하면 그 값을 SVG 컴포넌트에 전달하고, 다이어그램은 언제나 문제와 동일한 숫자를 표시합니다.
전기 회로와 3D 부피
키트는 기하학을 넘어섭니다. 8학년 물리에서는 전기 회로 다이어그램이 필요합니다. 정적인 PNG 대신 조합 가능한 SVG 원시 요소들을 만들었습니다:
// Primitives: Fil, Pile, Resistance, Lampe, Amperemetre, Voltmetre
// Compositions: CircuitSerie, CircuitParallele, CircuitCourtCircuit…
function CircuitSerie() {
return (
same I everywhere · U = U₁ + U₂
);
}
은 점들을 잇는 폴리라인을 그리고, 은 라벨을 붙일 수 있는 사각형을, “ 은 안에 글자가 들어간 원을 그립니다(예: 전류계는 “A”, 전압계는 “V”). 이러한 원시 요소들을 조합해 완전한 회로와 주석이 달린 수식을 만들 수 있습니다.
부피(7학년 수학)도 같은 접근법을 사용합니다: Cube3D, Pave3D, Cylindre3D, Sphere3D, Cone3D 컴포넌트를 카발리어 투시법으로 구현했습니다. 그리고 기하 변환(8학년 수학)에서는 SymetrieAxiale, SymetrieCentrale, Translation 등을 제공해 스타일화된 F 도형과 그 이미지(변환 결과)를 보여줍니다. 전체 20개의 SVG 컴포넌트가 _svg-kit.tsx 하나 파일에 모여 약 300줄을 차지합니다.
중복 오답 함정
오답을 계산할 때 발생하는 까다로운 경우가 있습니다: 오답이 정답과 같은 값이 될 때입니다. 예를 들어, 한 변이 4인 정사각형의 경우 면적은 16, 둘레도 16이 됩니다. “둘레 대신 면적”이라는 오답을 넣으면 옵션에 16이 두 번 나타납니다.
첫 번째 직관은 값을 다시 굴리는 것이지만, 시드가 지정된 PRNG에서는 굴린 값을 버릴 수 없습니다 — 결정성을 깨뜨리기 때문입니다. 해결책은 이미 사용된 값을 Set에 저장하고, 충돌이 발생하면 체계적으로 값을 조정하는 것입니다.
// Anti-duplicate pattern in every gen
const good = computeAnswer(a, b);
const used = new Set([good]);
const opts = [good];
for (const distractor of [wrongSign, wrongFormula, wrongOp]) {
let v = distractor