이 블로그에 퀴즈 컴포넌트를 추가하고 싶었다. MDX 파일에서 이렇게 쓸 수 있으면 좋겠다고 생각했다.
<ArticleQuiz>
<ArticleQuizItem
mode="multiple"
question="HTML에서 빈 태그란?"
choices={["닫는 태그가 없는 태그", "내용이 비어있는 태그", "주석 태그"]}
answer={0}
explanation="<br />, <img /> 같은 태그를 빈 태그라고 합니다."
/>
<ArticleQuizItem
mode="description"
question="React에서 조건부 렌더링에 쓰이는 연산자는?"
answer="삼항 연산자"
explanation="condition ? A : B 형태의 삼항 연산자가 가장 흔히 쓰입니다."
/>
</ArticleQuiz>
요구사항은 명확했다. 한 번에 하나의 문제만 보여주고, 정답
확인 후 다음 문제로 넘기고, 마지막에 점수를 보여준다. 부모인 ArticleQuiz가
현재 단계를 관리하면서, 자식 ArticleQuizItem에 콜백을 전달해야 한다.
처음에는 "요즘은 Context로 Compound Component 만드는 게 정석이지"라는 생각으로 Context부터 잡았다.
const QuizContext = React.createContext<{
currentStep: number;
score: number;
onResult: (isCorrect: boolean) => void;
onNext: () => void;
} | null>(null);
function ArticleQuiz({ children }: { children: React.ReactNode }) {
const [currentStep, setCurrentStep] = useState
ArticleQuizItem은 Context에서 상태를 꺼내 쓰면 된다. 여기까지는 깔끔했다.
function ArticleQuizItem({ question, answer, ...props }: QuizItemProps) {
const ctx = React.useContext(QuizContext);
if (!ctx) throw new Error("ArticleQuizItem must be inside ArticleQuiz");
const { currentStep, onResult, onNext } = ctx;
// ... 문제 UI 렌더링
}
문제는 각 QuizItem이 자신의 index를 알 수 없다는 것이었다.
"한 번에 하나만 보여준다"는 건, 부모가 currentStep이 0이면 첫 번째 문제만
보여주고 나머지는 숨겨야 한다는 뜻이다. Context 패턴에서는 모든 QuizItem이
동시에 마운트되어 있으니, 각자 "내가 currentStep 번째인지" 판단해야 한다.
function ArticleQuizItem({ question, answer }: QuizItemProps) {
const { currentStep } = React.useContext(QuizContext)!;
// 문제: 내 index를 어떻게 알지?
const myIndex = ???;
if (myIndex !== currentStep) return null;
// ...
}
Context에는 자식의 순서 정보가 없다. children이 몇 개이고,
내가 그 중 몇 번째인지는 Context가 알려줄 수 없는 정보다.
<ArticleQuiz>
<ArticleQuizItem index={0} question="..." />
<ArticleQuizItem index={1} question="..." />
<ArticleQuizItem index={2} question="..." />
</ArticleQuiz>
MDX 작성자가 직접 index를 매겨야 한다. 문제를 추가하거나 순서를 바꿀 때마다
index를 다시 계산해야 한다. 실수하기 딱 좋고, MDX 작성 경험을 망친다.
function ArticleQuiz({ children }) {
const registry = useRef<string[]>([]);
const register = (id: string) => {
if (!registry.current.includes(id)) registry.current.push(id);
return registry.current.indexOf(id);
};
return
렌더링 중에 ref를 mutate하는 건 React의 규칙에 어긋나고, Strict Mode에서 두 번
호출되면 index가 꼬인다. useEffect로 등록하면 첫 렌더에서 index를 알 수
없어서 깜빡임이 생긴다.
단순한 퀴즈 컴포넌트에 이 정도 복잡도는 과하다.
이 시점에서 발상을 바꿨다. "자식이 자기 index를 아는" 모델이 아니라, 부모가 자식을 골라서 보여주는 모델로 전환하면 어떨까?
찾아보니 React.Children이라는 유틸리티가 있었다. React 공식 문서에서는
레거시 API로 분류하고 있지만, 정확히 이 문제를 해결하기 위해 만들어진
도구였다.
function ArticleQuiz({ children }: ArticleQuizProps) {
const [currentStep, setCurrentStep] = useState(0);
const items = React.Children.toArray(children).filter(React.isValidElement);
const totalSteps = items.length;
return (
<div>
{React.cloneElement(items[currentStep] as
items[currentStep]으로 직접 골라서
보여준다. 자식이 자기 index를 알 필요가 없다cloneElement로 onResult, onNext를 전달한다.
QuizItem 입장에서는 그냥 props로 받으면 된다이 경험을 계기로 React.Children API를 제대로 공부했다. 정리하면 다음과 같다.
| API | 설명 |
|---|---|
React.Children.toArray(children) | children을 평탄화된 배열로 변환. key를 자동 부여 |
React.Children.map(children, fn) | children을 순회하며 변환. null/undefined는 자동 제거 |
React.Children.forEach(children, fn) | map과 동일하되 반환값 없음 |
React.Children.count(children) | 유효한 자식 노드 개수 반환 |
React에서 children prop은 타입이 일정하지 않다. 자식이
하나면 ReactElement, 여러 개면 배열, 텍스트면 문자열, 없으면 undefined.
React.Children.toArray는 이 불규칙한 타입을
항상 배열로 정규화해준다.
// children이 하나일 때
<Quiz><Item /></Quiz> // children = ReactElement (배열 아님!)
// children이 여러 개일 때
<Quiz><Item /><Item /></Quiz> // children = ReactElement[]
// toArray를 쓰면 항상 배열
const items = React.Children.toArray(children);
// 단일 원소 → [element], 배열 → 그대로, null → []
추가로 Fragment 안에 감싸진 중첩 구조도 평탄화하고, 각 원소에 안정적인 key를 자동 부여한다.
React.Children.map은 toArray + Array.map과 비슷하지만, 콜백에서 null을
반환하면 해당 원소를 자동 제거한다는 차이가 있다.
function Tabs({ children }: { children: React.ReactNode }) {
return (
<div>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return null; // 자동 제거
return React.cloneElement(child, { index });
count는 렌더링 가능한 자식 수를 반환한다. ArticleQuiz에서 프로그레스 바의
전체 단계 수를 계산할 때 items.length 대신 쓸 수도 있다. only는 children이
정확히 하나의 React element인지 검증하고, 아니면 에러를
던진다. Tooltip 같은 wrapper에서 유용하다.
function Tooltip({
children,
text,
}: {
children: React.ReactNode;
text: string;
}) {
const child = React.Children.only(children);
return React.cloneElement(child as React.ReactElement, {
onMouseEnter: showTooltip,
});
}
<div>로 감싸면 내부를 볼 수 없다// 이렇게 감싸면 ArticleQuiz가 QuizItem을 인식 못함
<ArticleQuiz>
<div>
<ArticleQuizItem ... /> {/* toArray로 접근 불가 */}
</div>
</ArticleQuiz>
React 공식 문서가 React.Children을 레거시로 분류한 건 사실이다. 범용
라이브러리에서는 Context 기반 패턴이 더 안전하다는 것도 맞다. 하지만 기술
선택은 환경과 제약 조건에 따라 달라진다.
ArticleQuiz는 일반 React 앱이 아니라 MDX 파일 안에서 사용
된다. MDX에서 컴포넌트를 쓸 때는 마크다운 작성자 입장에서의
작성 경험(DX)이 최우선이다.
{/* 데이터 배열 방식 — MDX에서 쓰면 이렇게 된다 */}
<ArticleQuiz
items={[
{
mode: "multiple",
question: "질문1",
choices: ["A", "B", "C"],
answer: 1,
explanation: "해설",
},
{
mode: "multiple",
question: "질문2",
choices: ["X", "Y", "Z"],
answer: 0,
explanation: "해설",
},
]}
/>
MDX 안에서 거대한 JavaScript 객체 리터럴을 작성해야 한다. 중괄호와 대괄호의 중첩, 쉼표 누락, 문자열 이스케이프 — 이런 것들이 글 쓰는 흐름을 끊는다. 반면 children 패턴은 각 문제가 독립적인 태그로 분리되어 있어서 읽기 쉽고 수정하기 편하다.
React.Children의 가장 큰 약점은 "사용하는 쪽에서 children 구조를 바꾸면 깨진다"는 것이다.
하지만 이 컴포넌트는 이 블로그 전용이다. 사용처가 MDX 파일뿐이고, 항상 <ArticleQuizItem>을 직접 나열하는 패턴으로만 쓴다.
아무도 ArticleQuiz 안에 <div>를 감싸거나 조건부 렌더링을 하지 않는다.
이 전제가 보장되는 한, React.Children의 한계는 실질적 문제가 되지 않는다.
되돌아보면, Context를 도입하려 했을 때 늘어났을 보일러플레이트를 생각해보자.
QuizContext 정의, Provider 래핑, useContext 훅, null 체크 에러 바운더리
— 그리고 그 모든 걸 해도 index 문제는 여전히 해결되지 않았다.
결국 React.Children.toArray를 써야 index를 알 수 있었을 것이고, 그러면
Context 레이어는 불필요한 중간 추상화가 된다.
"레거시니까 쓰지 말아야 한다"는 사고방식은 위험하다. 중요한 건
왜 이 API가 존재하는지, 어떤 한계가 있는지 이해한 상태에서 선택하는 것
이다. ArticleQuiz의 경우, MDX 환경 + 고정된 사용 패턴 + 부모 주도
렌더링이라는 세 가지 조건이 맞아떨어져서 React.Children이 최선의 선택이
되었다.
React does not recognize the `stepIndex` prop on a DOM element.
If you intentionally want it to appear in the DOM as a custom attribute,
spell it as lowercase `stepindex` instead.
원인은 cloneElement로 주입한 prop이 ArticleQuizItem 내부에서 {...props} 로 DOM까지 전달된 것이었다.
// ArticleQuiz — cloneElement로 내부 prop 주입
React.cloneElement(items[currentStep] as React.ReactElement, {
stepIndex: currentStep, // ← 이 prop이 문제
currentStep,
onResult: handleResult,
onNext: handleNext,
});
// ArticleQuizItem — ...props가 DOM div에 그대로 전달됨
function ArticleQuizItem({ mode, question, onResult, onNext, ...props }) {
return <div {...props}>...</div>;
// ^^^^^^^^ stepIndex, currentStep이 DOM attribute로 들어감
cloneElement는 기존 props에 새 props를 merge한다. ArticleQuizItem에서 명시적으로 destructure하지 않은 prop은 ...props에 포함되고, 이것이 <div>까지 흘러간다. React는 DOM element에 stepIndex 같은 비표준 attribute가 들어오면 경고를 띄운다.
해결은 간단하다. 내부 전용 prop을 destructure에서 빼내면 된다.
function ArticleQuizItem({
mode,
question,
choices,
answer,
explanation,
onResult,
onNext,
className,
stepIndex: _stepIndex, // destructure만 하고 사용하지 않음
currentStep: _currentStep, // DOM으로 전달되는 것을 방지
...props
}: ArticleQuizItemProps) {
return (
<div className={cn("flex flex-col gap-4", className)} {...props}>
...
</div>
이건 cloneElement 패턴에서 흔히 발생하는 문제다. 부모가 주입하는 prop과
DOM에 전달되는 prop의 경계가 암묵적이기 때문이다. 이 점이 앞서 언급한
"cloneElement의 암묵성" 한계와 직결된다. 주입되는 prop이
추가될 때마다 자식 컴포넌트의 destructure 목록도 함께 업데이트해야 한다는 점을
기억해둬야 한다.
React.Children.only(children)| children이 정확히 하나인지 검증. 아니면 에러 |