Skip to content

Chapter 06. 계층형 댓글 트리와 React 19 Context API (DI 격리) ​

"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) 안에서도 호출 가능한 최신 리소스 읽기 API6.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 BoardApp.tsx에 계층형 댓글 기능 추가하기 ​

사내 기술 Q&A 게시판에서 가장 중요한 소통 창구는 질문 아래 달리는 **댓글과 그 댓글에 달리는 답글(대댓글, 대대댓글)**입니다.

트리(Tree) 구조 데이터는 깊이(Depth)가 고정되어 있지 않고 무한히 내려갈 수 있습니다.
우리는 JSX 안에서 자기 자신을 스스로 호출하는 **재귀 컴포넌트(CommentItem.tsx)**를 만들어 BoardApp.tsx에 붙여 보겠습니다.

text
[댓글 1] (루트 - Depth 0)
  ├── [대댓글 1-1] (Depth 1)
  │     └── [대대댓글 1-1-1] (Depth 2)  <-- 여기서 [삭제] 버튼 클릭!
  └── [대댓글 1-2] (Depth 1)

NOTE

코드 6-1은 읽기만 하는 1단계 코드입니다(타이핑하지 않아도 됨)
6-1은 전통적인 Props + 콜백 방식으로 만든 뼈대로, 6.2에서 이 방식의 문제(이벤트 릴레이)를 보여 주기 위한 코드입니다. 예제 패키지의 CommentItem.tsx는 이미 6.4의 최종형이므로 6-1로 덮어쓰지 마십시오. 화면 연결은 6.4의 [따라하기]에서 한 번에 합니다.

[코드 6-1] 재귀 컴포넌트의 1단계 뼈대: 읽기만 (CommentItem.tsx의 콜백 릴레이 모습) ​

tsx
// [코드 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)에 빠집니다.

6.2 Props Drilling과 이벤트 릴레이 보일러플레이트 ​

여기서 설계적 고민이 찾아옵니다.
저 깊숙한 곳에 있는 대대댓글 1-1-1에서 사용자가 [삭제], [답글 등록], [수정], [신고] 버튼을 눌렀다고 가정해 봅시다.

실제 서버 API를 호출하고 전체 댓글 트리를 갱신해야 하는 주체는 상위 컨테이너(BoardApp.tsx)입니다.

전통적인 Props와 콜백 전달 방식만을 고집하면:

  • 중간에 낀 상위 댓글 노드나 레이아웃 래퍼 컴포넌트들은 해당 액션에 직접적인 관심이 없으면서도, 오직 자식의 이벤트를 최상위 부모에게 중계하기 위해 onDelete={onDelete}, onReply={onReply}, onEdit={onEdit}, onReport={onReport} 같은 콜백 Props를 끝없이 받아서 아래로 토스해야 합니다.
  • 만약 댓글 내부에 프로필 카드, 액션 드롭다운 등 별도의 하위 컴포넌트가 더 끼어들면 Props Drilling(프롭스 드릴링) 깊이는 걷잡을 수 없이 깊어집니다.

6.3 IoC/DI를 프론트엔드에 투영하다: React Context API ​

쉬운 비유부터: "지하실 무전기 릴레이" vs "1층 공용 사물함" ​

  • 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.ts
import { 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를 다루는 문법이 두 가지나 더 깔끔해졌습니다!

  1. .Provider 생략 가능: React 18까지는 <CommentContext.Provider value={commentService}>라고 길게 써야 했지만, React 19부터는 <CommentContext value={commentService}>처럼 Context 객체 자체를 바로 JSX 태그로 렌더링합니다!
  2. 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에 포함되어 있습니다):

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와 동일).

tsx
// frontend/src/BoardApp.tsx (코드 6-4: Chapter 06 완성본)
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { usePagination } from '@/hooks/usePagination';
import { usePostFilter } from '@/hooks/usePostFilter';
import { useFormValidation } from '@/hooks/useFormValidation';
import {
  CommentContext,
  type CommentNode,
  type CommentService
} from '@/types/comment-di';
import CommentItem from '@/components/CommentItem';
import './BoardApp.css';

export interface Post {
  id: number;
  title: string;
  author: string;
  content: string;
  createdAt: string;
  category: string;
}

export default function BoardApp() {
  // 1. 도메인 상태
  const [posts, setPosts] = useState<Post[]>([]);
  const [totalCount, setTotalCount] = useState<number>(0);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
  const [form, setForm] = useState({ title: '', author: '', content: '' });

  // 2. 검색 필터 합성
  const { keyword, category, applyFilter, resetFilter } = usePostFilter(
    (nextFilter) => {
      setPage(1);
      loadPosts(1, nextFilter.keyword, nextFilter.category);
    }
  );
  const [inputKeyword, setInputKeyword] = useState<string>(keyword);
  const [selectedCategory, setSelectedCategory] = useState<string>(category);

  useEffect(() => {
    setInputKeyword(keyword);
    setSelectedCategory(category);
  }, [keyword, category]);

  // 3. 페이지네이션 합성
  const {
    currentPage,
    pageSize,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    setPage
  } = usePagination({
    totalCount,
    onPageChange: (newPage) => loadPosts(newPage, keyword, category)
  });

  // 4. 폼 유효성 검사 합성
  const { errors, validate, clearErrors } = useFormValidation();

  // 5. 계층형 댓글 상태 (샘플 3단 댓글이 달린 3번 글로 시작)
  const [currentPostId, setCurrentPostId] = useState<number>(3);
  const [commentTree, setCommentTree] = useState<CommentNode[]>([]);

  async function refreshComments(targetPostId = currentPostId) {
    try {
      const res = await axios.get<CommentNode[]>(
        `/api/posts/${targetPostId}/comments`
      );
      setCommentTree(Array.isArray(res.data) ? res.data : []);
    } catch (err) {
      console.error('댓글 로딩 실패:', err);
    }
  }

  // 선택된 게시글 ID가 바뀔 때마다 댓글 트리를 다시 불러온다
  useEffect(() => {
    refreshComments(currentPostId);
  }, [currentPostId]);

  // 하위 댓글 트리 전체에 주입할 댓글 비즈니스 서비스 정의
  const commentService: CommentService = {
    async deleteComment(commentId: number) {
      if (!window.confirm('정말 이 댓글을 삭제하시겠습니까?')) return;
      await axios.delete(`/api/posts/${currentPostId}/comments/${commentId}`);
      await refreshComments(currentPostId);
    },
    async replyComment(parentId: number, author: string, content: string) {
      await axios.post(`/api/posts/${currentPostId}/comments`, {
        parentId,
        author,
        content
      });
      await refreshComments(currentPostId);
    }
  };

  // 6. 게시글 서버 통신
  async function loadPosts(
    targetPage = currentPage,
    targetKeyword = keyword,
    targetCategory = category
  ) {
    setIsLoading(true);
    try {
      const res = await axios.get<{ items: Post[]; total: number }>('/api/posts', {
        params: {
          page: targetPage,
          size: pageSize,
          keyword: targetKeyword,
          category: targetCategory
        }
      });
      setPosts(res.data.items || []);
      setTotalCount(res.data.total ?? 0);
    } catch (err) {
      alert('게시글 목록을 불러오지 못했습니다.');
    } finally {
      setIsLoading(false);
    }
  }

  function handleSearch() {
    applyFilter(inputKeyword, selectedCategory);
  }

  function handleReset() {
    setInputKeyword('');
    setSelectedCategory('ALL');
    resetFilter();
  }

  async function handleDelete(id: number) {
    if (!window.confirm('정말 삭제하시겠습니까?')) return;
    try {
      await axios.delete(`/api/posts/${id}`);
      await loadPosts();
    } catch (err) {
      alert('게시글 삭제에 실패했습니다.');
    }
  }

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!validate(form.title, form.content)) return;
    setIsSubmitting(true);
    try {
      await axios.post('/api/posts', { ...form, category: 'DEV' });
      setForm({ title: '', author: '', content: '' });
      clearErrors();
      await loadPosts();
    } catch (err) {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  }

  useEffect(() => {
    loadPosts();
  }, []);

  return (
    <div className="board-container">
      <h2>사내 기술 Q&A 게시판 (v2.0)</h2>

      {/* 1. 검색 바 */}
      <div className="filter-bar">
        <select
          value={selectedCategory}
          onChange={(e) => setSelectedCategory(e.target.value)}
        >
          <option value="ALL">전체</option>
          <option value="DEV">개발</option>
          <option value="NOTICE">공지</option>
          <option value="FREE">자유</option>
        </select>
        <input
          value={inputKeyword}
          onChange={(e) => setInputKeyword(e.target.value)}
          placeholder="검색어"
          onKeyDown={(e) => {
            if (e.key === 'Enter') handleSearch();
          }}
        />
        <button type="button" onClick={handleSearch}>
          검색
        </button>
        <button type="button" onClick={handleReset}>
          초기화
        </button>
      </div>

      {/* 2. 등록 폼 */}
      <form className="form-box" onSubmit={handleSubmit}>
        <input
          value={form.title}
          onChange={(e) => setForm({ ...form, title: e.target.value })}
          placeholder="제목"
        />
        {errors.title && <span className="error">{errors.title}</span>}
        <input
          value={form.author}
          onChange={(e) => setForm({ ...form, author: e.target.value })}
          placeholder="작성자"
        />
        <textarea
          value={form.content}
          onChange={(e) => setForm({ ...form, content: e.target.value })}
          placeholder="내용"
        />
        {errors.content && <span className="error">{errors.content}</span>}
        <button type="submit" disabled={isSubmitting}>
          등록
        </button>
      </form>

      {/* 3. 게시글 목록 테이블 (제목 클릭 시 해당 글의 댓글 로드) */}
      <table className="board-table">
        <thead>
          <tr>
            <th>ID</th>
            <th>제목</th>
            <th>작성자</th>
            <th>작성일</th>
            <th>동작</th>
          </tr>
        </thead>
        <tbody>
          {posts.map((post) => (
            <tr key={post.id}>
              <td>{post.id}</td>
              <td
                className="title-cell clickable"
                onClick={() => setCurrentPostId(post.id)}
              >
                {post.title}
              </td>
              <td>{post.author}</td>
              <td>{post.createdAt}</td>
              <td>
                <button
                  className="btn-delete"
                  onClick={() => handleDelete(post.id)}
                >
                  삭제
                </button>
              </td>
            </tr>
          ))}
          {posts.length === 0 && !isLoading && (
            <tr>
              <td colSpan={5} className="empty-cell">
                게시글이 없습니다.
              </td>
            </tr>
          )}
        </tbody>
      </table>

      {/* 4. 페이지네이션 */}
      <div className="pagination">
        <button disabled={!hasPrevPage} onClick={prevPage}>
          이전
        </button>
        <span>
          {currentPage} / {totalPages} (총 {totalCount}건)
        </span>
        <button disabled={!hasNextPage} onClick={nextPage}>
          다음
        </button>
      </div>

      {/* 5. 계층형 댓글 섹션: React 19 <CommentContext value={...}>로 하위 트리 전체에 서비스 주입! */}
      <CommentContext value={commentService}>
        <section className="comment-section">
          <h4>💬 {currentPostId}번 글의 댓글</h4>
          {commentTree.length === 0 ? (
            <p className="empty-state">아직 댓글이 없습니다.</p>
          ) : (
            commentTree.map((comment) => (
              <CommentItem key={comment.id} comment={comment} />
            ))
          )}
        </section>
      </CommentContext>
    </div>
  );
}

💡 [확인하기]: 백엔드 DataInitializer는 게시글 7건을 저장할 때 3번 글에 "루트 댓글 → 대댓글 → 대대댓글" 3단 트리와 루트 댓글 1개를 미리 넣어 둡니다.
currentPostId 초기값이 3이므로 화면을 열면 하단에 들여쓰기된 3단 댓글 트리가 바로 표시됩니다!

  1. 가장 깊은 3단 대대댓글의 **[답글]**을 눌러 작성자와 내용을 입력하고 등록해 보십시오. 그 아래에 4단 대대대댓글이 한 단계 더 들여쓰기되어 즉시 나타납니다!
  2. 방금 등록한 4단 댓글의 **[삭제]**를 눌러보십시오. 중간의 1단·2단·3단 댓글 컴포넌트를 전혀 거치지 않고 BoardApp.tsx의 deleteComment가 바로 호출되어 깔끔하게 삭제됩니다.
  3. 위쪽 테이블에서 다른 게시글의 제목을 클릭하면 해당 글 번호로 바뀌며 "아직 댓글이 없습니다."가 표시됩니다.
🔬 심화(건너뛰어도 됨): 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.5 무엇이 개선되었는가? ​

  1. 이벤트 배달부 전락 방지:
    재귀적으로 수없이 호출되는 중간 CommentItem 컴포넌트들은 이제 부모와 자식 간의 이벤트 중계(onDelete, onReply) 책임을 완전히 벗어던지고, 오직 자신의 댓글 노드 렌더링에만 집중하게 되었습니다.
  2. 단일 진실 공급원(SSOT):
    댓글의 삭제 및 답글 등록 비즈니스 로직이 BoardApp.tsx의 commentService 한곳에 집중되어, 어느 깊이에서 버튼을 누르든 일관된 서버 동기화가 보장됩니다.

6.6 [직접 해보기] 실습 미션 💪 ​

미션: 재조회 없이 프론트엔드 메모리에서 특정 댓글 노드를 즉시 제거하는 재귀 함수 작성하기 ​

코드 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.7 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 3줄 요약

  1. 재귀 컴포넌트는 JSX 안에서 자기 이름(<CommentItem />)을 다시 호출해 깊이가 정해지지 않은 트리를 그리며, 자식이 없을 때 멈추는 종료 조건이 필수입니다.
  2. Props와 콜백만 쓰면 깊은 자식의 이벤트를 최상위 부모까지 올리기 위해 모든 중간 노드가 콜백을 중계해야 합니다(Props Drilling & 이벤트 릴레이).
  3. 상위에서 React 19의 **<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)

  • 재귀 컴포넌트 도입: 무한 Depth 계층형 대댓글을 스스로 렌더링하는 CommentItem.tsx 컴포넌트 추가
  • IoC/DI 전환: Props Drilling과 깊은 콜백 릴레이(onDelete)를 걷어내고, 부모는 React 19 <CommentContext value={commentService}>로 서비스 공급, 자식은 use(CommentContext) 기반 훅으로 단번에 호출

자주 하는 실수 (Troubleshooting)

증상원인해결
[삭제]나 [답글]을 눌러도 아무 반응이 없음BoardApp.tsx에서 <CommentItem> 바깥을 <CommentContext value={commentService}>로 감싸지 않음코드 6-4의 <CommentContext> 래퍼 확인
"아직 댓글이 없습니다."만 보임샘플 댓글이 없는 글을 보고 있음3번 글(또는 초기 설정 useState(3))의 제목을 클릭
대댓글을 달수록 들여쓰기가 20px → 60px → 120px로 너무 넓어짐중첩 구조인데 marginLeft: depth * 20으로 곱함marginLeft: depth > 0 ? '20px' : '0'으로 한 단계분만 지정
루트 댓글만 보이고 대댓글이 사라짐이미 트리인 백엔드 응답에 buildCommentTree를 또 호출함백엔드 응답 배열을 그대로 setCommentTree(res.data)에 전달

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. 코드 6-1(콜백 릴레이 방식)에서 Depth 2(3단) 댓글의 [삭제] 버튼 클릭이 BoardApp.tsx에 닿을 때까지 onDelete prop은 몇 단계를 거쳐 전달되어야 하나요?
  2. BoardApp.tsx에서 <CommentContext value={commentService}> 태그를 빼먹으면, 화면을 열고 [삭제]를 눌렀을 때 무슨 일이 일어나나요?
  3. 한 댓글에서 [답글]을 눌러 입력 폼을 열어도 다른 댓글의 답글 폼은 열리지 않습니다. 왜 그럴까요?
  4. React 19에서 Context를 제공할 때와 읽을 때 React 18 이하와 비교해 어떤 문법이 새로워졌나요?
셀프 체크 정답
  1. 3단계입니다. BoardApp이 Depth 0에게 넘기고(1), Depth 0이 Depth 1에게 넘기고(2), Depth 1이 Depth 2에게 넘겨야(3) 비로소 Depth 2가 호출할 수 있습니다.
  2. useCommentService()가 기본값인 null을 반환하므로, commentService?.deleteComment(...)가 옵셔널 체이닝(?.)에 의해 에러 없이 조용히 무시되어 아무 일도 일어나지 않습니다.
  3. isReplying 상태는 CommentItem 컴포넌트 내부에서 선언된 useState이므로, 각 댓글 컴포넌트 인스턴스마다 독립된 Fiber 슬롯에 따로 생성되기 때문입니다.
  4. 제공할 때는 <CommentContext.Provider> 대신 .Provider를 생략한 <CommentContext value={...}>를 바로 쓸 수 있고, 읽을 때는 useContext 대신 조건문(if) 안에서도 호출 가능한 use(CommentContext) API를 사용할 수 있습니다.

다음 챕터(Part 3)로 이어지는 질문 ​

축하합니다! Part 1과 Part 2를 거치며 BoardApp.tsx는 페이징, URL 동기화 검색, 폼 검증, 단위 테스트, 그리고 계층형 대댓글까지 갖춘 애플리케이션으로 성장했습니다.

하지만 소스 코드를 가만히 들여다보면, 엔터프라이즈 규모로 가기 전 반드시 메워야 할 구멍이 남아 있습니다:

  • 백엔드 Spring Boot는 엄격한 DTO 클래스로 계약을 맺고 있는데, 우리 프론트엔드는 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에 전면 적용해 보겠습니다!

Moon HomeLab Engineering Knowledge Hub