
마크다운에 인터랙티브 Quiz 컴포넌트 구현하기
(수정: 2025년 12월 24일 오후 02:35)
마크다운에 인터랙티브 Quiz 컴포넌트 구현하기
블로그 포스트에 학습용 퀴즈를 삽입하고 싶었습니다. 마크다운 본문 중간에 특수 마커를 넣으면 React 컴포넌트로 렌더링되는 방식을 구현한 과정을 정리합니다.
목표
- 마크다운 본문에
❓{...}❓형식으로 퀴즈 삽입 - 객관식: 보기 순서 랜덤화 + 정답 추적
- 주관식: 텍스트 입력 후 정답 비교
- 복수 문제:
❓[{...}, {...}]❓형식으로 일괄 채점
마커 파싱 로직
콘텐츠 분리
마크다운 본문을 일반 텍스트와 특수 마커로 분리합니다.
const parseContent = (content: string) => { const parts: ContentPart[] = []; // 퀴즈 마커: ❓{...}❓ 또는 ❓[...]❓ const quizRegex = /❓([\s\S]*?)❓/g; let lastIndex = 0; let match; while ((match = quizRegex.exec(content)) !== null) { // 마커 이전의 일반 마크다운 if (match.index > lastIndex) { parts.push({ type: "markdown", content: content.slice(lastIndex, match.index), }); } // 퀴즈 마커 parts.push({ type: "quiz", content: match[1].trim(), }); lastIndex = match.index + match[0].length; } // 남은 마크다운 if (lastIndex < content.length) { parts.push({ type: "markdown", content: content.slice(lastIndex), }); } return parts; };
JSON 파싱 시 주의사항
Notion에서 복사한 텍스트는 특수 따옴표(""'')를 사용할 수 있습니다. 파싱 전에 정규화가 필요합니다.
const normalizeQuotes = (str: string): string => { return str .replace(/[""]/g, '"') // 특수 쌍따옴표 → 일반 쌍따옴표 .replace(/['']/g, "'"); // 특수 홑따옴표 → 일반 홑따옴표 }; const parseQuizData = (content: string) => { try { const normalized = normalizeQuotes(content); const data = JSON.parse(normalized); // 배열이면 QuizList, 객체면 단일 Quiz if (Array.isArray(data)) { return { type: "list", items: data }; } if (data.items && Array.isArray(data.items)) { return { type: "list", items: data.items }; } return { type: "single", data }; } catch (e) { console.error("Quiz JSON 파싱 실패:", e); return null; } };
Quiz 컴포넌트 구현
Fisher-Yates 셔플 알고리즘
객관식 보기 순서를 랜덤화하면서 정답 위치를 추적해야 합니다.
const shuffleWithAnswer = (options: string[], answerIndex: number) => { // 인덱스 배열 생성 const indices = options.map((_, i) => i); // Fisher-Yates 셔플 for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } // 셔플된 옵션과 새로운 정답 위치 const shuffledOptions = indices.map((i) => options[i]); const shuffledAnswer = indices.indexOf(answerIndex); return { shuffledOptions, shuffledAnswer }; };
Fisher-Yates 알고리즘은 O(n) 시간복잡도로 배열을 균등하게 셔플합니다.
컴포넌트 상태 관리
const Quiz = ({ question, options, answer, explanation }: QuizProps) => { const [selected, setSelected] = useState<number | null>(null); const [showResult, setShowResult] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); // 주관식 여부 const isSubjective = !options || options.length === 0; // 마운트 시 한 번만 셔플 (useMemo) const { shuffledOptions, shuffledAnswer } = useMemo(() => { if (isSubjective || typeof answer !== "number") { return { shuffledOptions: [], shuffledAnswer: 0 }; } // answer는 1-based → 0-based로 변환 return shuffleWithAnswer(options, answer - 1); }, []); const handleSelect = (index: number) => { if (showResult) return; setSelected(index); setShowResult(true); }; const isCorrect = selected === shuffledAnswer; // ...렌더링 };
핵심 구현 포인트
1. 정답 번호 체계
Notion에서 작성할 때 직관적으로 "3번이 정답"이라고 쓸 수 있도록 1-based 번호를 사용합니다.
{ "question": "React의 상태 관리 훅은?", "options": ["useEffect", "useRef", "useState", "useMemo"], "answer": 3, "explanation": "useState는 컴포넌트의 상태를 관리하는 훅입니다." }
코드에서는 0-based 인덱스로 변환하여 처리합니다.
2. 페이지 전환 시 상태 초기화
SPA에서 다른 포스트로 이동해도 컴포넌트가 언마운트되지 않을 수 있습니다. key prop으로 강제 리마운트합니다.
{parts.map((part, index) => { if (part.type === "quiz") { return ( <Quiz key={`${slug}-quiz-${index}`} // slug 포함으로 페이지별 고유 {...quizData} /> ); } // ... })}
3. 접기/펼치기 UX
정답 확인 후 퀴즈를 접을 수 있어 스크롤 길이를 줄입니다.
{showResult && ( <button onClick={() => setIsCollapsed(!isCollapsed)}> {isCollapsed ? <ChevronDown /> : <ChevronUp />} </button> )}
QuizList: 복수 문제 일괄 채점
여러 문제를 한 번에 풀고 결과를 확인하는 컴포넌트입니다.
구조
const QuizList = ({ items }: QuizListProps) => { // 각 문제별 사용자 답변 const [answers, setAnswers] = useState<(number | null)[]>( items.map(() => null) ); const [showResult, setShowResult] = useState(false); // 각 문제별 셔플 정보 (마운트 시 고정) const shuffledItems = useMemo(() => { return items.map((item) => { if (!item.options) return { shuffledOptions: [], shuffledAnswer: 0 }; return shuffleWithAnswer(item.options, item.answer - 1); }); }, []); const handleSubmit = () => { setShowResult(true); }; const correctCount = answers.filter( (ans, i) => ans === shuffledItems[i].shuffledAnswer ).length; // ...렌더링 };
결과 표시
{showResult && ( <div className="result-summary"> <span>{correctCount} / {items.length} 정답</span> <span>({Math.round((correctCount / items.length) * 100)}%)</span> </div> )}
사용 예시
Notion에서 작성
> 다음 개념을 이해했는지 확인해보세요. ❓{"question":"React에서 상태가 변경되면?","options":["아무 일도 안 일어남","컴포넌트가 리렌더링됨","페이지가 새로고침됨","에러가 발생함"],"answer":2,"explanation":"상태가 변경되면 해당 컴포넌트와 자식 컴포넌트가 리렌더링됩니다."}❓
Quiz
React에서 상태가 변경되면?
렌더링 결과
마크다운 텍스트 중간에 인터랙티브한 퀴즈 UI가 삽입됩니다. 사용자가 보기를 클릭하면 정답 여부와 해설이 표시됩니다.
마무리
이 구현으로 학습 콘텐츠에 즉각적인 피드백을 제공할 수 있게 되었습니다. 핵심 포인트:
- 마커 기반 파싱: 마크다운과 컴포넌트의 분리
- Fisher-Yates 셔플: 정답 위치 추적과 함께 보기 랜덤화
- useMemo로 셔플 고정: 리렌더링 시에도 보기 순서 유지
- key prop으로 상태 초기화: 페이지 전환 시 새로운 퀴즈 인스턴스 생성