"5장에서 가벼워진 BoardApp.tsx에 '계층형 대댓글(답글의 답글)'을 붙여보자. 그런데 저 4단계 깊숙한 자식 컴포넌트에서 [삭제] 버튼을 눌렀을 때, 왜 모든 중간 컴포넌트가 고통받아야 하는가?"
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
개념
한 줄 요약
설명 위치
재귀 컴포넌트 (CommentItem.tsx)
JSX 안에서 자기 자신을 다시 호출해 깊이가 정해지지 않은 트리를 그리는 컴포넌트
6.1 [기초 다지기] 박스
콜백 Props와 이벤트 릴레이
자식이 부모에게 사건을 알리기 위해 중간 컴포넌트들이 콜백을 계속 토스하는 현상
6.1 ~ 6.2
Props Drilling (프롭스 드릴링)
깊은 자식에게 값을 주기 위해 중간 컴포넌트들이 쓰지도 않는 Props를 전달하는 문제
6.2
IoC / DI와 React Context API
중간 컴포넌트를 건너뛰고 하위 트리 전체에 서비스를 직접 주입하는 아키텍처
6.3 ~ 6.4
createContext<CommentService | null>(null)
타입이 지정된 Context 사물함 생성
6.4 (코드 6-2)
React 19 <CommentContext value={...}>
.Provider 없이 Context 자체를 JSX 태그로 바로 렌더링하는 React 19 최신 문법
6.4 🆕 박스
React 19 use(CommentContext)
useContext를 대체하며 조건문(if) 안에서도 호출 가능한 최신 리소스 읽기 API
6.4 🆕 박스
Promise<void> 비동기 함수 타입
"끝나면 아무 값도 반환하지 않는 비동기 함수" 시그니처
6.4, 0장 0.4
TIP
🏁 이 장의 시작 파일 frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch05.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 이 장에서 손대는 파일: src/types/comment-di.ts, src/components/CommentItem.tsx, src/BoardApp.tsx 예제 패키지의 CommentItem.tsx와 comment-di.ts에는 이 장의 최종형(코드 6-4, 6-2)이 이미 들어 있습니다. 코드 6-1은 문제를 이해하기 위해 읽기만 하고, 6.4부터 따라 하면 됩니다.
코드 6-1은 읽기만 하는 1단계 코드입니다(타이핑하지 않아도 됨) 6-1은 전통적인 Props + 콜백 방식으로 만든 뼈대로, 6.2에서 이 방식의 문제(이벤트 릴레이)를 보여 주기 위한 코드입니다. 예제 패키지의 CommentItem.tsx는 이미 6.4의 최종형이므로 6-1로 덮어쓰지 마십시오. 화면 연결은 6.4의 [따라하기]에서 한 번에 합니다.
// [코드 6-1] 읽기만: Props + onDelete 콜백을 매 단계 중계하는 1단계 모습import React from 'react';export interface CommentNode { id: number; author: string; content: string; createdAt: string; children?: CommentNode[];}interface CommentItemProps { comment: CommentNode; depth?: number; onDelete: (id: number) => void; // 🚨 자식이 자식의 자식에게 계속 물려줘야 하는 콜백!}export function CommentItem({ comment, depth = 0, onDelete }: CommentItemProps) { return ( /* 자식 노드는 부모 노드 div 안에 중첩 렌더링되므로, 들여쓰기는 한 단계분(20px)만 주면 깊이만큼 자연스럽게 누적된다 */ <div className="comment-node" style={{ marginLeft: depth > 0 ? '20px' : '0' }}> <div className="comment-header"> <span className="author">{comment.author}</span> <span className="date">{comment.createdAt}</span> </div> <p className="content">{comment.content}</p> <div className="actions"> <button onClick={() => onDelete(comment.id)}>삭제</button> </div> {/* 대댓글이 존재할 경우 자기 자신을 재귀 렌더링 */} {comment.children && comment.children.length > 0 && ( <div className="children"> {comment.children.map((child) => ( <CommentItem key={child.id} comment={child} depth={depth + 1} onDelete={onDelete} /* 🚨 중간 노드가 쓰지도 않으면서 자식에게 또 전달(릴레이)! */ /> ))} </div> )} </div> );}
🔬 심화(건너뛰어도 됨): 들여쓰기를 depth * 20으로 주면 안 되는 이유
재귀 컴포넌트에서 자식 <CommentItem>은 부모 .comment-node div 안쪽에 중첩되어 렌더링됩니다. 따라서 부모의 marginLeft가 이미 자식에게 적용된 상태입니다. 여기에 marginLeft: depth * 20을 또 주면 Depth 1은 20px, Depth 2는 20 + 40 = 60px, Depth 3은 60 + 60 = 120px처럼 들여쓰기가 깊이에 따라 기하급수적으로 벌어집니다. 중첩 DOM 구조에서는 각 노드가 한 단계분(depth > 0 ? '20px' : '0')만 들여쓰면 깊이만큼 정확히 누적됩니다. 반대로 13장의 가상 스크롤처럼 트리를 **1차원 배열로 평탄화(Flatten)**해서 나란히 렌더링할 때는 부모-자식 DOM 중첩이 없으므로 그때는 depth * 20이 맞습니다.
📘 [기초 다지기] 재귀 컴포넌트는 어떻게 동작하고 언제 멈추는가?
자기 자신을 JSX 태그로 호출: 자바스크립트 함수 CommentItem 안에서 <CommentItem ... />을 반환하는 것은 재귀 함수가 자기 자신을 호출하는 것과 같습니다.
반드시 종료 조건(Base Case)이 있어야 한다: 재귀 함수에 탈출 조건이 필요하듯, 재귀 컴포넌트도 자식이 없는 단말 노드(Leaf Node)에 도달하면 {comment.children && comment.children.length > 0 && ...} 조건이 거짓이 되어 재귀 호출을 멈춥니다. 종료 조건 없이 무조건 <CommentItem>을 그리면 무한 렌더링(Maximum call stack size exceeded)에 빠집니다.
여기서 설계적 고민이 찾아옵니다. 저 깊숙한 곳에 있는 대대댓글 1-1-1에서 사용자가 [삭제], [답글 등록], [수정], [신고] 버튼을 눌렀다고 가정해 봅시다.
실제 서버 API를 호출하고 전체 댓글 트리를 갱신해야 하는 주체는 상위 컨테이너(BoardApp.tsx)입니다.
전통적인 Props와 콜백 전달 방식만을 고집하면:
중간에 낀 상위 댓글 노드나 레이아웃 래퍼 컴포넌트들은 해당 액션에 직접적인 관심이 없으면서도, 오직 자식의 이벤트를 최상위 부모에게 중계하기 위해 onDelete={onDelete}, onReply={onReply}, onEdit={onEdit}, onReport={onReport} 같은 콜백 Props를 끝없이 받아서 아래로 토스해야 합니다.
만약 댓글 내부에 프로필 카드, 액션 드롭다운 등 별도의 하위 컴포넌트가 더 끼어들면 Props Drilling(프롭스 드릴링) 깊이는 걷잡을 수 없이 깊어집니다.
Props/콜백 릴레이 방식 (손에서 손으로): 지하실 말단 인턴(Depth 3 대대댓글)이 본사 사장님(BoardApp)에게 보고하려고 1층 대리, 2층 과장, 3층 부장 선배들에게 무전기를 릴레이로 전달하는 꼴입니다. 2층 과장은 자기가 무슨 무전을 전달하는지도 모르면서 매번 중계만 합니다.
Context DI 방식 (공용 사물함): 사장님이 집안 공용 사물함에 직통 무전기를 넣어 두고(<CommentContext value={commentService}>), 지하실 인턴이 필요할 때 열쇠로 직접 꺼내 씁니다(use(CommentContext)). 중간 선배들은 아무것도 몰라도 됩니다!
그림으로 비교하면 다음과 같습니다. 실선은 화면 데이터를 그리는 방향(comment prop), 점선은 [삭제/답글] 요청이 가는 길입니다.
이 아이디어를 부르는 소프트웨어 공학 용어가 둘 있습니다:
IoC (Inversion of Control, 제어의 역전): "필요한 것을 내가 직접 만들거나 찾아오지 않고, 바깥에서 넣어 주는 것을 받아 쓴다"는 설계 원칙입니다. CommentItem은 axios를 직접 호출하지 않고, 위에서 넣어 준 CommentService 인터페이스를 쓰기만 합니다.
DI (Dependency Injection, 의존성 주입): IoC를 구현하는 구체적인 방법입니다. 컴포넌트가 의존하는 대상(댓글 서비스)을 바깥(상위 Context)에서 주입합니다.
NOTE
Spring 백엔드 개발자라면 익숙한 개념입니다! Spring의 @Autowired / 생성자 주입과 같은 철학입니다. 다만 React에는 전역 싱글톤 컨테이너 대신 컴포넌트 트리 기반의 Context가 있어서, 상위 컴포넌트가 <CommentContext value={service}>로 감싼 하위 트리 안의 모든 자손 컴포넌트가 가장 가까운 조상의 서비스를 주입받습니다.
6.4 React 19 <CommentContext>와 use()를 통한 타입 안전한 DI
이제 DI 계약서와 Context를 정의하고, BoardApp.tsx와 CommentItem.tsx에 연결해 봅시다.
[코드 6-2] DI 계약서 및 Context 정의 (frontend/src/types/comment-di.ts)
typescript
// frontend/src/types/comment-di.tsimport { createContext, use } from 'react';export interface CommentNode { id: number; parentId?: number | null; author: string; content: string; createdAt: string; children?: CommentNode[];}export interface CommentService { deleteComment: (commentId: number) => Promise<void>; // 백엔드 CommentDto.CreateRequest는 author와 content가 모두 @NotBlank이므로 author도 함께 받는다 replyComment: (parentId: number, author: string, content: string) => Promise<void>;}// 1. 타입 안전한 Context 객체 생성 (기본값은 null)export const CommentContext = createContext<CommentService | null>(null);// 2. 하위 컴포넌트가 서비스를 꺼내 쓸 전용 Custom Hook (React 19 use() API 활용)export function useCommentService(): CommentService | null { return use(CommentContext);}
예제 패키지의 comment-di.ts 아래쪽에는 평면 배열을 트리로 바꾸는 buildCommentTree와, 13장 가상 스크롤에서 쓸 FlatComment 타입·flattenCommentTree 함수도 들어 있습니다. 본 교재의 백엔드(GET /api/posts/{postId}/comments)는 이미 children이 채워진 트리를 내려주므로 이 장의 BoardApp.tsx는 응답을 그대로 씁니다.
🆕 처음 보는 문법: React 19의 <Context value={...}>와 use(Context)
React 19에서는 Context를 다루는 문법이 두 가지나 더 깔끔해졌습니다!
.Provider 생략 가능: React 18까지는 <CommentContext.Provider value={commentService}>라고 길게 써야 했지만, React 19부터는 <CommentContext value={commentService}>처럼 Context 객체 자체를 바로 JSX 태그로 렌더링합니다!
useContext 대신 use(CommentContext): React 19에 새로 도입된 use() API는 Context와 Promise(10장)를 모두 읽을 수 있는 만능 리더입니다. 특히 기존 useContext는 훅이라서 if문 안에서 절대 부를 수 없었지만, use(CommentContext)는 조건문(if)과 반복문 안에서도 자유롭게 호출할 수 있습니다!
실무에서 SELECT * FROM comment WHERE post_id = ?처럼 parentId만 가진 1차원 평면 배열을 내려주는 백엔드 API를 만났을 때 쓰는 O(N) 변환 함수입니다(comment-di.ts에 포함되어 있습니다):
typescript
export function buildCommentTree(flatComments: CommentNode[]): CommentNode[] { const map = new Map<number, CommentNode>(); const roots: CommentNode[] = []; for (const item of flatComments) { map.set(item.id, { ...item, children: [] }); } for (const item of flatComments) { const node = map.get(item.id)!; if (item.parentId && map.has(item.parentId)) { map.get(item.parentId)!.children!.push(node); } else { roots.push(node); } } return roots;}
주의: 이미 서버가 children 트리를 만들어 내려주는 응답(http://localhost:8080/api/posts/3/comments)에 이 함수를 또 돌리면 루트 댓글의 children이 빈 배열 []로 덮어써져 대댓글이 사라집니다. 서버와 클라이언트 중 누가 트리를 조립할지 API 계약서에 명확히 정해 두십시오.
[코드 6-3] 깊은 자식 CommentItem.tsx에서 다이렉트 Inject (useCommentService)
frontend/src/components/CommentItem.tsx의 최종형입니다. 이제 Props에 onDelete나 onReply 콜백이 전혀 없으며, 재귀 호출할 때도 이벤트를 릴레이하지 않습니다! 덤으로 6.2에서 말한 [답글] 기능도 작은 인라인 폼으로 붙였습니다.
tsx
// frontend/src/components/CommentItem.tsx (코드 6-3: Context DI가 적용된 최종 재귀 컴포넌트)import React, { useState } from 'react';import { useCommentService, type CommentNode } from '@/types/comment-di';interface CommentItemProps { comment: CommentNode; depth?: number;}export default function CommentItem({ comment, depth = 0 }: CommentItemProps) { // 🌟 가장 가까운 조상 <CommentContext>가 주입한 서비스를 직접 꺼낸다! const commentService = useCommentService(); // 이 댓글 노드만의 독립적인 답글 입력 폼 상태 const [isReplying, setIsReplying] = useState(false); const [replyAuthor, setReplyAuthor] = useState(''); const [replyContent, setReplyContent] = useState(''); function handleDelete() { commentService?.deleteComment(comment.id); } async function handleReply(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); if (!commentService) return; if (!replyAuthor.trim() || !replyContent.trim()) { alert('작성자와 내용을 모두 입력해 주세요.'); return; } await commentService.replyComment(comment.id, replyAuthor, replyContent); setReplyContent(''); setIsReplying(false); } return ( /* 자식 노드는 부모 노드 div 안에 중첩 렌더링되므로, 들여쓰기는 한 단계분(20px)만 주면 깊이만큼 누적된다 */ <div className="comment-node" style={{ marginLeft: depth > 0 ? '20px' : '0' }} > <div className="comment-header"> <span className="author">{comment.author}</span> <span className="date">{comment.createdAt}</span> </div> <p className="content">{comment.content}</p> <div className="actions"> {/* 🌟 중간 전달자 없이, 주입받은 서비스를 이 노드에서 바로 호출! */} <button type="button" className="btn-delete" onClick={handleDelete}> 삭제 </button> <button type="button" className="btn-reply" onClick={() => setIsReplying(!isReplying)} > 답글 </button> </div> {/* 답글 입력 폼: [답글]을 누른 노드에만 열린다 */} {isReplying && ( <form className="reply-form" onSubmit={handleReply}> <input value={replyAuthor} onChange={(e) => setReplyAuthor(e.target.value)} placeholder="작성자" /> <input value={replyContent} onChange={(e) => setReplyContent(e.target.value)} placeholder="답글 내용" /> <button type="submit">등록</button> </form> )} {/* 재귀 호출 시 더 이상 onDelete/onReply 이벤트 릴레이 보일러플레이트가 필요 없다! */} {comment.children && comment.children.length > 0 && ( <div className="children"> {comment.children.map((child) => ( <CommentItem key={child.id} comment={child} depth={depth + 1} /> ))} </div> )} </div> );}
🆕 처음 보는 문법: 코드 6-3 해설
const commentService = useCommentService();: 위쪽 조상 중 <CommentContext value={...}>로 감싼 컴포넌트를 찾아 그 서비스를 돌려줍니다.
setIsReplying(!isReplying): 클릭할 때마다 true/false가 뒤집혀 답글 폼이 열리고 닫힙니다. 이 useState는 CommentItem 컴포넌트 인스턴스(각 댓글 노드)마다 따로 생기므로, 한 댓글의 답글 폼을 열어도 다른 댓글의 폼은 열리지 않습니다!
[코드 6-4] BoardApp.tsx에서 서비스 제공(<CommentContext>) 및 댓글 트리 렌더링
이제 5장의 BoardApp.tsx에 댓글 상태(currentPostId, commentTree), 서비스 구현체(commentService), 그리고 <CommentContext value={commentService}> 영역을 연결합니다. 아래는 6장 변경사항이 모두 반영된 frontend/src/BoardApp.tsx 전체 완성 코드입니다(snapshots/BoardApp.ch06.tsx와 동일).
💡 [확인하기]: 백엔드 DataInitializer는 게시글 7건을 저장할 때 3번 글에 "루트 댓글 → 대댓글 → 대대댓글" 3단 트리와 루트 댓글 1개를 미리 넣어 둡니다. currentPostId 초기값이 3이므로 화면을 열면 하단에 들여쓰기된 3단 댓글 트리가 바로 표시됩니다!
가장 깊은 3단 대대댓글의 **[답글]**을 눌러 작성자와 내용을 입력하고 등록해 보십시오. 그 아래에 4단 대대대댓글이 한 단계 더 들여쓰기되어 즉시 나타납니다!
방금 등록한 4단 댓글의 **[삭제]**를 눌러보십시오. 중간의 1단·2단·3단 댓글 컴포넌트를 전혀 거치지 않고 BoardApp.tsx의 deleteComment가 바로 호출되어 깔끔하게 삭제됩니다.
위쪽 테이블에서 다른 게시글의 제목을 클릭하면 해당 글 번호로 바뀌며 "아직 댓글이 없습니다."가 표시됩니다.
🔬 심화(건너뛰어도 됨): Context 사용 시 리렌더링 주의점과 단위 테스트 Mocking 이점
Context Value 메모이제이션: 만약 하위 댓글이 수백 개인데 BoardApp의 검색창에 글자를 칠 때마다 하위 댓글이 리렌더링되는 것을 막고 싶다면, commentService 객체를 useMemo로 감싸 참조를 고정하고 CommentItem에 React.memo를 적용하거나(13장), React 19 Compiler를 활성화하면 됩니다.
단위 테스트 시 Mocking 이점: 14장에서 CommentItem.tsx를 단독으로 마운트하여 테스트할 때, 실제 Axios가 연결된 BoardApp.tsx 없이도 <CommentContext value={mockService}><CommentItem comment={sample} /></CommentContext>처럼 가짜(Mock) 서비스를 주입하여 0.01초 만에 격리 테스트할 수 있습니다!
미션: 재조회 없이 프론트엔드 메모리에서 특정 댓글 노드를 즉시 제거하는 재귀 함수 작성하기
코드 6-4에서는 댓글을 삭제한 뒤 await refreshComments()로 서버에서 트리를 다시 받아왔습니다. 만약 네트워크 재조회 없이 프론트엔드의 commentTree 배열(CommentNode[])에서 targetId와 일치하는 댓글(및 그 자식들)만 불변성을 지키며 제거하는 순수 재귀 함수 removeCommentRecursive(nodes: CommentNode[], targetId: number): CommentNode[]를 만든다면 어떻게 작성할 수 있을까요? (filter와 map을 조합해 보십시오!)
💡 미션 정답 코드 확인하기
typescript
function removeCommentRecursive( nodes: CommentNode[], targetId: number): CommentNode[] { return nodes .filter((node) => node.id !== targetId) // 1. 현재 층위에서 targetId 제거 .map((node) => ({ ...node, // 2. 자식 배열(children)이 있다면 자식들에 대해서도 똑같이 재귀 호출! children: node.children ? removeCommentRecursive(node.children, targetId) : [] }));}// 사용 예시:// setCommentTree((prevTree) => removeCommentRecursive(prevTree, commentId));
이 재귀 불변 변환 패턴은 10장의 낙관적 업데이트(useOptimistic)나 트리 구조 편집기에서 매우 유용하게 쓰입니다!
코드 6-1(콜백 릴레이 방식)에서 Depth 2(3단) 댓글의 [삭제] 버튼 클릭이 BoardApp.tsx에 닿을 때까지 onDelete prop은 몇 단계를 거쳐 전달되어야 하나요?
BoardApp.tsx에서 <CommentContext value={commentService}> 태그를 빼먹으면, 화면을 열고 [삭제]를 눌렀을 때 무슨 일이 일어나나요?
한 댓글에서 [답글]을 눌러 입력 폼을 열어도 다른 댓글의 답글 폼은 열리지 않습니다. 왜 그럴까요?
React 19에서 Context를 제공할 때와 읽을 때 React 18 이하와 비교해 어떤 문법이 새로워졌나요?
셀프 체크 정답
3단계입니다. BoardApp이 Depth 0에게 넘기고(1), Depth 0이 Depth 1에게 넘기고(2), Depth 1이 Depth 2에게 넘겨야(3) 비로소 Depth 2가 호출할 수 있습니다.
useCommentService()가 기본값인 null을 반환하므로, commentService?.deleteComment(...)가 옵셔널 체이닝(?.)에 의해 에러 없이 조용히 무시되어 아무 일도 일어나지 않습니다.
isReplying 상태는 CommentItem 컴포넌트 내부에서 선언된 useState이므로, 각 댓글 컴포넌트 인스턴스마다 독립된 Fiber 슬롯에 따로 생성되기 때문입니다.
제공할 때는 <CommentContext.Provider> 대신 .Provider를 생략한 <CommentContext value={...}>를 바로 쓸 수 있고, 읽을 때는 useContext 대신 조건문(if) 안에서도 호출 가능한 use(CommentContext) API를 사용할 수 있습니다.
Chapter 06. 계층형 댓글 트리와 React 19 Context API (DI 격리)
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
CommentItem.tsx)createContext<CommentService | null>(null)<CommentContext value={...}>.Provider없이 Context 자체를 JSX 태그로 바로 렌더링하는 React 19 최신 문법use(CommentContext)useContext를 대체하며 조건문(if) 안에서도 호출 가능한 최신 리소스 읽기 APIPromise<void>비동기 함수 타입TIP
🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx가snapshots/BoardApp.ch05.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 이 장에서 손대는 파일:src/types/comment-di.ts,src/components/CommentItem.tsx,src/BoardApp.tsx예제 패키지의
CommentItem.tsx와comment-di.ts에는 이 장의 최종형(코드 6-4, 6-2)이 이미 들어 있습니다. 코드 6-1은 문제를 이해하기 위해 읽기만 하고, 6.4부터 따라 하면 됩니다.6.1
BoardApp.tsx에 계층형 댓글 기능 추가하기 사내 기술 Q&A 게시판에서 가장 중요한 소통 창구는 질문 아래 달리는 **댓글과 그 댓글에 달리는 답글(대댓글, 대대댓글)**입니다.
트리(Tree) 구조 데이터는 깊이(Depth)가 고정되어 있지 않고 무한히 내려갈 수 있습니다.
우리는 JSX 안에서 자기 자신을 스스로 호출하는 **재귀 컴포넌트(
CommentItem.tsx)**를 만들어BoardApp.tsx에 붙여 보겠습니다.NOTE
코드 6-1은 읽기만 하는 1단계 코드입니다(타이핑하지 않아도 됨)
6-1은 전통적인 Props + 콜백 방식으로 만든 뼈대로, 6.2에서 이 방식의 문제(이벤트 릴레이)를 보여 주기 위한 코드입니다. 예제 패키지의
CommentItem.tsx는 이미 6.4의 최종형이므로 6-1로 덮어쓰지 마십시오. 화면 연결은 6.4의 [따라하기]에서 한 번에 합니다.[코드 6-1] 재귀 컴포넌트의 1단계 뼈대: 읽기만 (
CommentItem.tsx의 콜백 릴레이 모습) 🔬 심화(건너뛰어도 됨): 들여쓰기를
depth * 20으로 주면 안 되는 이유재귀 컴포넌트에서 자식
<CommentItem>은 부모.comment-nodediv 안쪽에 중첩되어 렌더링됩니다. 따라서 부모의marginLeft가 이미 자식에게 적용된 상태입니다.여기에
marginLeft: depth * 20을 또 주면 Depth 1은 20px, Depth 2는 20 + 40 = 60px, Depth 3은 60 + 60 = 120px처럼 들여쓰기가 깊이에 따라 기하급수적으로 벌어집니다. 중첩 DOM 구조에서는 각 노드가 한 단계분(depth > 0 ? '20px' : '0')만 들여쓰면 깊이만큼 정확히 누적됩니다.반대로 13장의 가상 스크롤처럼 트리를 **1차원 배열로 평탄화(Flatten)**해서 나란히 렌더링할 때는 부모-자식 DOM 중첩이 없으므로 그때는
depth * 20이 맞습니다.📘 [기초 다지기] 재귀 컴포넌트는 어떻게 동작하고 언제 멈추는가?
CommentItem안에서<CommentItem ... />을 반환하는 것은 재귀 함수가 자기 자신을 호출하는 것과 같습니다.{comment.children && comment.children.length > 0 && ...}조건이 거짓이 되어 재귀 호출을 멈춥니다. 종료 조건 없이 무조건<CommentItem>을 그리면 무한 렌더링(Maximum call stack size exceeded)에 빠집니다.6.2 Props Drilling과 이벤트 릴레이 보일러플레이트
여기서 설계적 고민이 찾아옵니다.
저 깊숙한 곳에 있는
대대댓글 1-1-1에서 사용자가 [삭제], [답글 등록], [수정], [신고] 버튼을 눌렀다고 가정해 봅시다.실제 서버 API를 호출하고 전체 댓글 트리를 갱신해야 하는 주체는 상위 컨테이너(
BoardApp.tsx)입니다.전통적인
Props와 콜백 전달 방식만을 고집하면:onDelete={onDelete},onReply={onReply},onEdit={onEdit},onReport={onReport}같은 콜백 Props를 끝없이 받아서 아래로 토스해야 합니다.6.3 IoC/DI를 프론트엔드에 투영하다: React Context API
쉬운 비유부터: "지하실 무전기 릴레이" vs "1층 공용 사물함"
BoardApp)에게 보고하려고 1층 대리, 2층 과장, 3층 부장 선배들에게 무전기를 릴레이로 전달하는 꼴입니다. 2층 과장은 자기가 무슨 무전을 전달하는지도 모르면서 매번 중계만 합니다.<CommentContext value={commentService}>), 지하실 인턴이 필요할 때 열쇠로 직접 꺼내 씁니다(use(CommentContext)). 중간 선배들은 아무것도 몰라도 됩니다!그림으로 비교하면 다음과 같습니다. 실선은 화면 데이터를 그리는 방향(
commentprop), 점선은 [삭제/답글] 요청이 가는 길입니다.이 아이디어를 부르는 소프트웨어 공학 용어가 둘 있습니다:
CommentItem은 axios를 직접 호출하지 않고, 위에서 넣어 준CommentService인터페이스를 쓰기만 합니다.NOTE
Spring 백엔드 개발자라면 익숙한 개념입니다!
Spring의
@Autowired/ 생성자 주입과 같은 철학입니다. 다만 React에는 전역 싱글톤 컨테이너 대신 컴포넌트 트리 기반의 Context가 있어서, 상위 컴포넌트가<CommentContext value={service}>로 감싼 하위 트리 안의 모든 자손 컴포넌트가 가장 가까운 조상의 서비스를 주입받습니다.6.4 React 19
<CommentContext>와use()를 통한 타입 안전한 DI 이제 DI 계약서와 Context를 정의하고,
BoardApp.tsx와CommentItem.tsx에 연결해 봅시다.[코드 6-2] DI 계약서 및 Context 정의 (
frontend/src/types/comment-di.ts) 예제 패키지의
comment-di.ts아래쪽에는 평면 배열을 트리로 바꾸는buildCommentTree와, 13장 가상 스크롤에서 쓸FlatComment타입·flattenCommentTree함수도 들어 있습니다. 본 교재의 백엔드(GET /api/posts/{postId}/comments)는 이미children이 채워진 트리를 내려주므로 이 장의BoardApp.tsx는 응답을 그대로 씁니다.🆕 처음 보는 문법: React 19의
<Context value={...}>와use(Context)React 19에서는 Context를 다루는 문법이 두 가지나 더 깔끔해졌습니다!
.Provider생략 가능: React 18까지는<CommentContext.Provider value={commentService}>라고 길게 써야 했지만, React 19부터는<CommentContext value={commentService}>처럼 Context 객체 자체를 바로 JSX 태그로 렌더링합니다!useContext대신use(CommentContext): React 19에 새로 도입된use()API는 Context와 Promise(10장)를 모두 읽을 수 있는 만능 리더입니다. 특히 기존useContext는 훅이라서if문 안에서 절대 부를 수 없었지만,use(CommentContext)는 조건문(if)과 반복문 안에서도 자유롭게 호출할 수 있습니다!🔬 심화(건너뛰어도 됨):
buildCommentTree— 평면 배열을 트리로 바꾸기 (O(N) 알고리즘)실무에서
SELECT * FROM comment WHERE post_id = ?처럼parentId만 가진 1차원 평면 배열을 내려주는 백엔드 API를 만났을 때 쓰는 O(N) 변환 함수입니다(comment-di.ts에 포함되어 있습니다):children트리를 만들어 내려주는 응답(http://localhost:8080/api/posts/3/comments)에 이 함수를 또 돌리면 루트 댓글의children이 빈 배열[]로 덮어써져 대댓글이 사라집니다. 서버와 클라이언트 중 누가 트리를 조립할지 API 계약서에 명확히 정해 두십시오.[코드 6-3] 깊은 자식
CommentItem.tsx에서 다이렉트 Inject (useCommentService) frontend/src/components/CommentItem.tsx의 최종형입니다. 이제 Props에onDelete나onReply콜백이 전혀 없으며, 재귀 호출할 때도 이벤트를 릴레이하지 않습니다! 덤으로 6.2에서 말한 [답글] 기능도 작은 인라인 폼으로 붙였습니다.🆕 처음 보는 문법: 코드 6-3 해설
const commentService = useCommentService();: 위쪽 조상 중<CommentContext value={...}>로 감싼 컴포넌트를 찾아 그 서비스를 돌려줍니다.setIsReplying(!isReplying): 클릭할 때마다true/false가 뒤집혀 답글 폼이 열리고 닫힙니다. 이useState는CommentItem컴포넌트 인스턴스(각 댓글 노드)마다 따로 생기므로, 한 댓글의 답글 폼을 열어도 다른 댓글의 폼은 열리지 않습니다![코드 6-4]
BoardApp.tsx에서 서비스 제공(<CommentContext>) 및 댓글 트리 렌더링 이제 5장의
BoardApp.tsx에 댓글 상태(currentPostId,commentTree), 서비스 구현체(commentService), 그리고<CommentContext value={commentService}>영역을 연결합니다.아래는 6장 변경사항이 모두 반영된
frontend/src/BoardApp.tsx전체 완성 코드입니다(snapshots/BoardApp.ch06.tsx와 동일).🔬 심화(건너뛰어도 됨): Context 사용 시 리렌더링 주의점과 단위 테스트 Mocking 이점
BoardApp의 검색창에 글자를 칠 때마다 하위 댓글이 리렌더링되는 것을 막고 싶다면,commentService객체를useMemo로 감싸 참조를 고정하고CommentItem에React.memo를 적용하거나(13장), React 19 Compiler를 활성화하면 됩니다.CommentItem.tsx를 단독으로 마운트하여 테스트할 때, 실제 Axios가 연결된BoardApp.tsx없이도<CommentContext value={mockService}><CommentItem comment={sample} /></CommentContext>처럼 가짜(Mock) 서비스를 주입하여 0.01초 만에 격리 테스트할 수 있습니다!6.5 무엇이 개선되었는가?
재귀적으로 수없이 호출되는 중간
CommentItem컴포넌트들은 이제 부모와 자식 간의 이벤트 중계(onDelete,onReply) 책임을 완전히 벗어던지고, 오직 자신의 댓글 노드 렌더링에만 집중하게 되었습니다.댓글의 삭제 및 답글 등록 비즈니스 로직이
BoardApp.tsx의commentService한곳에 집중되어, 어느 깊이에서 버튼을 누르든 일관된 서버 동기화가 보장됩니다.6.6 [직접 해보기] 실습 미션 💪
미션: 재조회 없이 프론트엔드 메모리에서 특정 댓글 노드를 즉시 제거하는 재귀 함수 작성하기
코드 6-4에서는 댓글을 삭제한 뒤
await refreshComments()로 서버에서 트리를 다시 받아왔습니다.만약 네트워크 재조회 없이 프론트엔드의
commentTree배열(CommentNode[])에서targetId와 일치하는 댓글(및 그 자식들)만 불변성을 지키며 제거하는 순수 재귀 함수removeCommentRecursive(nodes: CommentNode[], targetId: number): CommentNode[]를 만든다면 어떻게 작성할 수 있을까요? (filter와map을 조합해 보십시오!)💡 미션 정답 코드 확인하기
이 재귀 불변 변환 패턴은 10장의 낙관적 업데이트(
useOptimistic)나 트리 구조 편집기에서 매우 유용하게 쓰입니다!6.7 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄 요약
<CommentItem />)을 다시 호출해 깊이가 정해지지 않은 트리를 그리며, 자식이 없을 때 멈추는 종료 조건이 필수입니다.<CommentContext value={commentService}>**로 서비스를 제공하고 깊은 자식이 **use(CommentContext)**로 직접 꺼내 쓰면 중간 중계 코드가 완전히 사라집니다.바뀐 파일
src/types/comment-di.tsCommentNode,CommentService,CommentContext,useCommentService정의 (코드 6-2)src/components/CommentItem.tsxuseCommentService()로 삭제·답글을 직접 호출하는 재귀 컴포넌트 (코드 6-3)src/BoardApp.tsx<CommentContext value={commentService}>주입·댓글 영역 추가, 제목 클릭으로 글 선택 (= snapshots/BoardApp.ch06.tsx)💡 Ch 05 → Ch 06 핵심 변경 요약 (Diff Focus)
CommentItem.tsx컴포넌트 추가onDelete)를 걷어내고, 부모는 React 19<CommentContext value={commentService}>로 서비스 공급, 자식은use(CommentContext)기반 훅으로 단번에 호출자주 하는 실수 (Troubleshooting)
BoardApp.tsx에서<CommentItem>바깥을<CommentContext value={commentService}>로 감싸지 않음<CommentContext>래퍼 확인"아직 댓글이 없습니다."만 보임useState(3))의 제목을 클릭marginLeft: depth * 20으로 곱함marginLeft: depth > 0 ? '20px' : '0'으로 한 단계분만 지정buildCommentTree를 또 호출함setCommentTree(res.data)에 전달셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
BoardApp.tsx에 닿을 때까지onDeleteprop은 몇 단계를 거쳐 전달되어야 하나요?BoardApp.tsx에서<CommentContext value={commentService}>태그를 빼먹으면, 화면을 열고 [삭제]를 눌렀을 때 무슨 일이 일어나나요?셀프 체크 정답
BoardApp이 Depth 0에게 넘기고(1), Depth 0이 Depth 1에게 넘기고(2), Depth 1이 Depth 2에게 넘겨야(3) 비로소 Depth 2가 호출할 수 있습니다.useCommentService()가 기본값인null을 반환하므로,commentService?.deleteComment(...)가 옵셔널 체이닝(?.)에 의해 에러 없이 조용히 무시되어 아무 일도 일어나지 않습니다.isReplying상태는CommentItem컴포넌트 내부에서 선언된useState이므로, 각 댓글 컴포넌트 인스턴스마다 독립된 Fiber 슬롯에 따로 생성되기 때문입니다.<CommentContext.Provider>대신.Provider를 생략한<CommentContext value={...}>를 바로 쓸 수 있고, 읽을 때는useContext대신 조건문(if) 안에서도 호출 가능한use(CommentContext)API를 사용할 수 있습니다.다음 챕터(Part 3)로 이어지는 질문
축하합니다! Part 1과 Part 2를 거치며
BoardApp.tsx는 페이징, URL 동기화 검색, 폼 검증, 단위 테스트, 그리고 계층형 대댓글까지 갖춘 애플리케이션으로 성장했습니다.하지만 소스 코드를 가만히 들여다보면, 엔터프라이즈 규모로 가기 전 반드시 메워야 할 구멍이 남아 있습니다:
BoardApp.tsx안에interface Post를 따로 정의해 두었고,category: string처럼 아무 문자열이나 허용하는 느슨한 타입을 쓰고 있습니다.NOTICE), 개발글(DEV), 자유글(FREE)에 따라 필수 필드가 달라지는데 옵셔널(?)로만 처리하면 런타임undefined에러의 불씨가 남습니다.이어지는 **Part 3 (Chapter 07)**에서는 Spring Boot DTO와 1:1로 맞물리는 **엄격한 TypeScript 도메인 계약(Contract)과 판별된 유니온(Discriminated Unions)**을
BoardApp.tsx에 전면 적용해 보겠습니다!