"5장에서 가벼워진 BoardAppComponent에 '계층형 대댓글(답글의 답글)'을 붙여보자. 그런데 저 4단계 깊숙한 자식 컴포넌트에서 [삭제]나 [답글 등록] 버튼을 눌렀을 때, 왜 모든 중간 컴포넌트가 이벤트 배달부로 고통받아야 하는가? Angular 고유의 계층적 컴포넌트 DI(providers: [CommentTreeService])로 트리 스코프를 우아하게 격리하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
개념
한 줄 요약
설명 위치
재귀 컴포넌트 (Recursive Component)
템플릿 안에서 자기 자신의 셀렉터(<app-comment-item>)를 다시 호출해 무한 깊이의 트리를 그리는 컴포넌트
6.1
Props Drilling & 이벤트 릴레이
깊은 자식의 이벤트를 최상위 부모에게 올리기 위해 중간 노드가 output()을 계속 중계하는 안티패턴
6.2
계층적 인젝터 (Hierarchical DI)
컴포넌트 트리를 따라 위쪽 조상이 등록한 providers 서비스를 자손이 inject()로 직접 찾아 쓰는 메커니즘
6.3
CommentTreeService 트리 스코프 격리
댓글 섹션(또는 게시글)마다 독립된 서비스 인스턴스를 제공하여 여러 댓글 트리가 서로 섞이지 않게 격리하는 기법
6.4 ~ 6.5
TIP
🏁 이 장의 시작 파일 5장을 마친 frontend/src/app/board-app/board-app.component.ts 상태에서 시작합니다. 이 장에서 새로 만들거나 수정하는 파일:
frontend/src/app/services/comment-tree.service.ts (신규 작성: 계층형 댓글 상태 및 API 전담 서비스)
frontend/src/app/components/comment-item.component.ts (신규 작성: 재귀 렌더링 및 다이렉트 DI 주입 컴포넌트)
frontend/src/app/board-app/board-app.component.ts & .html (CommentTreeService 제공 및 댓글 트리 연결)
트리 구조는 깊이가 2단일지 5단일지 미리 알 수 없으므로, HTML을 고정된 횟수만큼 중첩해서는 그릴 수 없습니다. 해결책은 **컴포넌트 템플릿 안에서 자기 자신의 태그(<app-comment-item>)를 다시 호출하는 재귀 컴포넌트(Recursive Component)**를 만드는 것입니다.
🆕 처음 보는 문법: Standalone 재귀 컴포넌트는 어떻게 자기 자신을 부를까요?
Standalone 컴포넌트는 자신의 @Component({ selector: 'app-comment-item', ... })에 정의된 셀렉터를 자기 자신의 템플릿 안에서 별도 imports 추가 없이 바로 사용할 수 있습니다! 단, 재귀 함수에 종료 조건(return)이 필수이듯, 재귀 컴포넌트도 @if (comment().children?.length)가 참일 때만 자식 <app-comment-item>을 렌더링해야 무한 루프에 빠지지 않습니다.
<!-- ❌ 안티패턴 비교용: 모든 중간 댓글이 자식의 삭제/답글 이벤트를 부모에게 수동으로 중계해야 함! --><div class="comment-node" [style.margin-left.px]="depth() > 0 ? 20 : 0"> <p>{{ comment().author }}: {{ comment().content }}</p> <button (click)="deleteComment.emit(comment().id)">삭제</button> <button (click)="replyComment.emit({ parentId: comment().id, ... })">답글</button> @if (comment().children?.length) { <div class="children"> @for (child of comment().children!; track child.id) { <!-- 🚨 고통의 시작: 자식이 보낸 이벤트를 받아서 내 부모에게 똑같이 다시 토스(Relay)해야 함! --> <app-comment-item [comment]="child" [depth]="depth() + 1" (deleteComment)="deleteComment.emit($event)" (replyComment)="replyComment.emit($event)" (editComment)="editComment.emit($event)" (likeComment)="likeComment.emit($event)" /> } </div> }</div>
만약 댓글 기능에 [수정], [좋아요], [신고], [블라인드] 버튼이 추가될 때마다 무슨 일이 벌어질까요? 실제로는 그 버튼을 처리하지도 않는 모든 중간 단계 컴포넌트와 래퍼 컴포넌트에 output() 선언과 (event)="event.emit($event)" 중계 코드를 끝없이 추가해야 합니다!
이 문제를 가장 우아하게 해결하는 도구가 바로 Angular의 심장인 **계층적 인젝터(Hierarchical Injector)**입니다.
🏢 비유: 지하실 인턴의 무전기 릴레이(Event Relay) vs 1층 로비의 공용 직통 전화기(계층적 DI)
이벤트 릴레이 (output 중계): 지하 4층 말단 인턴(대대댓글 1-1-1)이 본부(BoardAppComponent)에 삭제 요청을 보고하려고 지하 3층 대리, 지하 2층 과장, 지하 1층 부장에게 차례대로 무전기를 넘기는 꼴입니다. 중간 선배들은 자기가 왜 무전을 전달하는지도 모른 채 종일 무전기만 토스합니다.
Angular 계층적 DI (providers + inject): 본부(BoardAppComponent)가 자기 건물 로비에 직통 내선 전화망(providers: [CommentTreeService])을 설치해 둡니다. 지하 4층이든 지하 10층이든 누구든 수화기(inject(CommentTreeService))만 들면 중간 선배들을 전혀 거치지 않고 본부와 1:1로 직통 연결됩니다!
말단 CommentItemComponent가 inject(CommentTreeService)를 호출하면 Angular는 다음 순서로 서비스를 찾습니다:
내 컴포넌트 자신의 providers에 있는가? → 없음
내 부모(CommentItemComponent Depth 1)에 있는가? → 없음
내 조부모(CommentItemComponent Depth 0)에 있는가? → 없음
최상위 컨테이너인 BoardAppComponent의 providers: [CommentTreeService]에 있는가? → 찾았다! 그 인스턴스를 즉시 반환합니다.
즉, BoardAppComponent 아래에 매달린 모든 CommentItemComponent 노드들은 깊이가 1단이든 100단이든 정확히 동일한 CommentTreeService 인스턴스 하나를 공유하면서도, 다른 화면이나 다른 게시글 카드의 댓글 트리와는 완벽히 격리(Scope Isolation)됩니다!
// frontend/src/app/services/comment-tree.service.tsimport { Injectable, signal, computed, inject } from '@angular/core';import { HttpClient } from '@angular/common/http';export interface CommentNode { id: number; parentId?: number | null; author: string; content: string; createdAt: string; children?: CommentNode[];}// 트리 전체를 재귀 순회하며 총 댓글(대댓글 포함) 개수를 세는 순수 함수function countCommentsRecursive(nodes: CommentNode[]): number { let count = 0; for (const node of nodes) { count += 1; if (node.children?.length) { count += countCommentsRecursive(node.children); } } return count;}@Injectable()export class CommentTreeService { private readonly http = inject(HttpClient); // 1. 현재 댓글을 보고 있는 게시글 ID (기본값: 샘플 3단 대댓글이 달린 3번 게시글!) readonly currentPostId = signal<number>(3); readonly comments = signal<CommentNode[]>([]); readonly isLoading = signal<boolean>(false); // 2. 대댓글까지 모두 합산한 총 댓글 수 파생 신호 readonly totalCommentCount = computed(() => countCommentsRecursive(this.comments())); /** * 특정 게시글을 선택하고 해당 게시글의 댓글 트리를 서버에서 불러옵니다. */ selectPostAndLoad(postId: number): void { this.currentPostId.set(postId); this.refreshComments(); } /** * 현재 선택된 게시글의 댓글 트리를 새로고침합니다. * 백엔드 GET /api/posts/{postId}/comments 는 이미 children이 조립된 트리를 반환합니다. */ refreshComments(): void { const postId = this.currentPostId(); if (!postId) return; this.isLoading.set(true); this.http.get<CommentNode[]>(`/api/posts/${postId}/comments`).subscribe({ next: (tree) => { this.comments.set(Array.isArray(tree) ? tree : []); this.isLoading.set(false); }, error: () => { this.comments.set([]); this.isLoading.set(false); } }); } /** * 루트 댓글(parentId = null) 또는 대댓글(parentId = 부모 댓글 ID)을 등록합니다. */ addComment(parentId: number | null, author: string, content: string, onSuccess?: () => void): void { const postId = this.currentPostId(); const trimmedAuthor = author.trim(); const trimmedContent = content.trim(); if (!trimmedAuthor || !trimmedContent) { alert('작성자와 댓글 내용을 모두 입력해 주세요.'); return; } this.http .post<CommentNode>(`/api/posts/${postId}/comments`, { parentId, author: trimmedAuthor, content: trimmedContent }) .subscribe({ next: () => { onSuccess?.(); this.refreshComments(); }, error: () => { alert('댓글 등록에 실패했습니다.'); } }); } /** * 특정 댓글(및 그 하위 대댓글)을 삭제합니다. */ deleteComment(commentId: number): void { if (!confirm('이 댓글을 정말 삭제하시겠습니까?')) return; const postId = this.currentPostId(); this.http.delete<void>(`/api/posts/${postId}/comments/${commentId}`).subscribe({ next: () => { this.refreshComments(); }, error: () => { alert('댓글 삭제에 실패했습니다.'); } }); }}
🔬 심화(건너뛰어도 됨): 만약 백엔드가 트리가 아닌 평면 배열(parentId만 있는 리스트)을 내려준다면?
본 교재의 Spring Boot 백엔드는 서버에서 children 트리를 만들어 내려주지만, 실무에서 SELECT * FROM comment WHERE post_id = ? 결과를 평면 배열로 그대로 내려주는 API를 만난다면 아래 $O(N)$ Map 알고리즘으로 프론트엔드에서 트리를 조립할 수 있습니다:
typescript
export function buildCommentTree(flatList: CommentNode[]): CommentNode[] { const map = new Map<number, CommentNode>(); const roots: CommentNode[] = []; for (const item of flatList) { map.set(item.id, { ...item, children: [] }); } for (const item of flatList) { 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;}
이제 CommentItemComponent를 작성합니다. 놀랍게도 이 컴포넌트에는 output() 선언이 단 하나도 없습니다! 삭제든 답글 등록이든 위쪽 BoardAppComponent가 제공한 CommentTreeService를 inject()로 직접 꺼내 그 자리에서 호출하기 때문입니다.
🆕 처음 보는 문법: 왜 들여쓰기를 depth() * 20이 아니라 depth() > 0 ? 20 : 0으로 주었나요?
재귀 컴포넌트에서 자식 <app-comment-item>은 부모 .comment-node 요소의 **안쪽(DOM 자식)**에 렌더링됩니다. 즉, 부모가 이미 왼쪽에서 20px 들어간 상태라면 그 안의 자식이 추가로 20px만 더 들어가도 화면에서는 자연스럽게 20 + 20 = 40px이 됩니다! 만약 depth() * 20을 주면 Depth 2에서 20 + 40 = 60px, Depth 3에서 120px로 기하급수적으로 벌어지게 됩니다(반면 13장 가상 스크롤처럼 트리를 1차원 평면으로 펼쳐서 렌더링할 때는 중첩이 없으므로 depth * 20을 씁니다).
6.6 [실습 3] BoardAppComponent에 CommentTreeService 제공 및 댓글 섹션 통합
이제 5장의 BoardAppComponent에 CommentTreeService와 CommentItemComponent를 연결합니다. 게시글 제목을 클릭하면 해당 글의 댓글 트리가 하단에 로드되며, 처음 화면을 열었을 때는 3단 대댓글 샘플이 들어 있는 3번 게시글의 댓글이 즉시 표시됩니다.
💡 [확인하기]: 브라우저를 열면 #3번 게시글의 3단 대댓글 트리가 들여쓰기와 함께 깔끔하게 렌더링됩니다. 가장 깊은 Depth 2 대대댓글에서 **[💬 답글 달기]**를 눌러 글을 등록하면 Depth 3에 즉시 대대대댓글이 달리며, 상단의 총 N개 댓글 수도 재귀적으로 정확히 올라갑니다!
현재 CommentItemComponent에는 각 댓글마다 자신의 isReplying = signal(false)가 있어서, 댓글 A의 답글 창을 열어둔 채 댓글 B의 답글 창을 열면 두 개의 답글 입력창이 동시에 열립니다. 이를 개선하여 "트리 전체에서 한 번에 딱 하나의 댓글에만 답글 입력창이 열리도록"CommentTreeService의 공유 신호를 활용해 리팩터링해 보십시오!
깊이가 정해지지 않은 트리 데이터는 템플릿 안에서 자기 자신의 셀렉터(<app-comment-item>)를 다시 호출하는 재귀 컴포넌트로 렌더링하며, @if (comment().children?.length)가 종료 조건이 됩니다.
전통적인 output() 방식은 모든 중간 노드가 이벤트를 중계(Relay)해야 하지만, Angular의 **계층적 인젝터(Hierarchical DI)**를 쓰면 말단 자식이 inject(CommentTreeService)로 상위 서비스를 직통 호출할 수 있습니다.
providers: [CommentTreeService]를 컴포넌트 레벨에 등록하면 해당 컴포넌트 서브트리 안에서만 인스턴스를 공유하므로, 전역 상태를 오염시키지 않고 완벽한 트리 스코프 격리를 달성합니다.
다음 단계 (Part 2 → Part 3 예고): 축하합니다! Part 1과 Part 2를 통해 jQuery 레거시를 걷어내고 Signals 반응형 엔진, 라우터, @Injectable() 서비스 분리, 계층적 DI까지 완성했습니다. 하지만 아직 우리 코드의 Post 인터페이스는 category: string처럼 아무 문자열이나 허용하는 느슨한 타입이며, 공지글(필독 만료일 포함)·비밀글(비밀번호 포함)·일반글처럼 카테고리에 따라 필수 필드가 달라지는 엔터프라이즈 도메인 규칙을 표현하지 못합니다. 이어지는 Chapter 07에서는 TypeScript 6.0의 꽃인 **판별된 유니온(Discriminated Unions)과 타입 가드(Type Guard)**로 런타임 에러 확률을 0%로 수렴시키는 도메인 계약 설계를 시작합니다!
Chapter 06. 계층형 댓글 트리와 계층적 인젝터 (Component DI)
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
<app-comment-item>)를 다시 호출해 무한 깊이의 트리를 그리는 컴포넌트output()을 계속 중계하는 안티패턴providers서비스를 자손이inject()로 직접 찾아 쓰는 메커니즘CommentTreeService트리 스코프 격리TIP
🏁 이 장의 시작 파일
5장을 마친
frontend/src/app/board-app/board-app.component.ts상태에서 시작합니다. 이 장에서 새로 만들거나 수정하는 파일:frontend/src/app/services/comment-tree.service.ts(신규 작성: 계층형 댓글 상태 및 API 전담 서비스)frontend/src/app/components/comment-item.component.ts(신규 작성: 재귀 렌더링 및 다이렉트 DI 주입 컴포넌트)frontend/src/app/board-app/board-app.component.ts&.html(CommentTreeService제공 및 댓글 트리 연결)6.1 계층형 대댓글 트리와 재귀 컴포넌트의 원리
사내 기술 Q&A 게시판에서 질문 못지않게 중요한 것이 바로 댓글과 그 아래 달리는 **대댓글(답글의 답글)**입니다.
백엔드(
GET /api/posts/{postId}/comments)가 내려주는 댓글 데이터는children배열 안에 자기 자신과 똑같은 모양의CommentNode가 중첩된 트리(Tree) 구조입니다:트리 구조는 깊이가 2단일지 5단일지 미리 알 수 없으므로, HTML을 고정된 횟수만큼 중첩해서는 그릴 수 없습니다.
해결책은 **컴포넌트 템플릿 안에서 자기 자신의 태그(
<app-comment-item>)를 다시 호출하는 재귀 컴포넌트(Recursive Component)**를 만드는 것입니다.🆕 처음 보는 문법: Standalone 재귀 컴포넌트는 어떻게 자기 자신을 부를까요?
Standalone 컴포넌트는 자신의
@Component({ selector: 'app-comment-item', ... })에 정의된 셀렉터를 자기 자신의 템플릿 안에서 별도imports추가 없이 바로 사용할 수 있습니다!단, 재귀 함수에 종료 조건(
return)이 필수이듯, 재귀 컴포넌트도@if (comment().children?.length)가 참일 때만 자식<app-comment-item>을 렌더링해야 무한 루프에 빠지지 않습니다.6.2 Props Drilling과 이벤트 릴레이(Event Relay)의 비극
먼저 DI(의존성 주입)를 쓰지 않고 전통적인
input()과output()만으로 재귀 댓글 컴포넌트를 짜면 어떤 문제가 생기는지 비교용 코드로 살펴봅시다. (아래 [코드 6-1]은 문제점을 눈으로 확인하기 위한 예시이므로 타이핑하지 않고 읽기만 하십시오.)[코드 6-1] 전통적인 이벤트 릴레이 방식의 고통 (비교용)
만약 댓글 기능에
[수정],[좋아요],[신고],[블라인드]버튼이 추가될 때마다 무슨 일이 벌어질까요?실제로는 그 버튼을 처리하지도 않는 모든 중간 단계 컴포넌트와 래퍼 컴포넌트에
output()선언과(event)="event.emit($event)"중계 코드를 끝없이 추가해야 합니다!6.3 Angular의 킬러 무기: 계층적 의존성 주입 (Hierarchical Dependency Injection)
이 문제를 가장 우아하게 해결하는 도구가 바로 Angular의 심장인 **계층적 인젝터(Hierarchical Injector)**입니다.
Angular 계층적 인젝터의 탐색 원리 (Resolution Rules)
말단
CommentItemComponent가inject(CommentTreeService)를 호출하면 Angular는 다음 순서로 서비스를 찾습니다:providers에 있는가? → 없음CommentItemComponentDepth 1)에 있는가? → 없음CommentItemComponentDepth 0)에 있는가? → 없음BoardAppComponent의providers: [CommentTreeService]에 있는가? → 찾았다! 그 인스턴스를 즉시 반환합니다.즉,
BoardAppComponent아래에 매달린 모든CommentItemComponent노드들은 깊이가 1단이든 100단이든 정확히 동일한CommentTreeService인스턴스 하나를 공유하면서도, 다른 화면이나 다른 게시글 카드의 댓글 트리와는 완벽히 격리(Scope Isolation)됩니다!6.4 [실습 1] 계층형 댓글 상태와 API를 전담하는
CommentTreeService구현 먼저 선택된 게시글 ID(
currentPostId), 댓글 트리 배열(comments), 총 댓글 수 재귀 계산(totalCommentCount), 그리고 댓글 조회·등록·답글·삭제를 모두 책임지는CommentTreeService를 작성합니다.[코드 6-2]
frontend/src/app/services/comment-tree.service.ts(전체 코드) 🔬 심화(건너뛰어도 됨): 만약 백엔드가 트리가 아닌 평면 배열(
parentId만 있는 리스트)을 내려준다면?본 교재의 Spring Boot 백엔드는 서버에서
children트리를 만들어 내려주지만, 실무에서SELECT * FROM comment WHERE post_id = ?결과를 평면 배열로 그대로 내려주는 API를 만난다면 아래 $O(N)$Map알고리즘으로 프론트엔드에서 트리를 조립할 수 있습니다:6.5 [실습 2] 재귀 컴포넌트
CommentItemComponent구현 이제
CommentItemComponent를 작성합니다.놀랍게도 이 컴포넌트에는
output()선언이 단 하나도 없습니다!삭제든 답글 등록이든 위쪽
BoardAppComponent가 제공한CommentTreeService를inject()로 직접 꺼내 그 자리에서 호출하기 때문입니다.[코드 6-3]
frontend/src/app/components/comment-item.component.ts(전체 코드) 🆕 처음 보는 문법: 왜 들여쓰기를
depth() * 20이 아니라depth() > 0 ? 20 : 0으로 주었나요?재귀 컴포넌트에서 자식
<app-comment-item>은 부모.comment-node요소의 **안쪽(DOM 자식)**에 렌더링됩니다. 즉, 부모가 이미 왼쪽에서20px들어간 상태라면 그 안의 자식이 추가로20px만 더 들어가도 화면에서는 자연스럽게20 + 20 = 40px이 됩니다! 만약depth() * 20을 주면 Depth 2에서20 + 40 = 60px, Depth 3에서120px로 기하급수적으로 벌어지게 됩니다(반면 13장 가상 스크롤처럼 트리를 1차원 평면으로 펼쳐서 렌더링할 때는 중첩이 없으므로depth * 20을 씁니다).6.6 [실습 3]
BoardAppComponent에CommentTreeService제공 및 댓글 섹션 통합 이제 5장의
BoardAppComponent에CommentTreeService와CommentItemComponent를 연결합니다.게시글 제목을 클릭하면 해당 글의 댓글 트리가 하단에 로드되며, 처음 화면을 열었을 때는 3단 대댓글 샘플이 들어 있는 3번 게시글의 댓글이 즉시 표시됩니다.
[코드 6-4] 6장 완성본
BoardAppComponent(board-app.component.ts&.html) 추가 CSS 스타일(
board-app.component.css하단에 덧붙이기):6.7 [직접 해보기] 실습 미션 💪
현재
CommentItemComponent에는 각 댓글마다 자신의isReplying = signal(false)가 있어서, 댓글 A의 답글 창을 열어둔 채 댓글 B의 답글 창을 열면 두 개의 답글 입력창이 동시에 열립니다.이를 개선하여 "트리 전체에서 한 번에 딱 하나의 댓글에만 답글 입력창이 열리도록"
CommentTreeService의 공유 신호를 활용해 리팩터링해 보십시오!📝 미션 요구사항
CommentTreeService에 현재 답글 창이 열린 댓글의 ID를 기억하는activeReplyCommentId = signal<number | null>(null)신호와toggleReply(commentId: number): void메서드를 추가하십시오.CommentItemComponent에서 로컬isReplying신호를 제거하고,isReplying = computed(() => this.commentService.activeReplyCommentId() === this.comment().id)파생 신호로 교체하십시오.[💬 답글 달기]를 누른 뒤 댓글 B의[💬 답글 달기]를 누르면 댓글 A의 입력창이 자동으로 닫히고 댓글 B의 입력창만 열리는지 확인하십시오!💡 미션 정답 코드 확인하기
6.8 이번 장 핵심 요약 및 다음 단계 🚀
핵심 요약 3줄
<app-comment-item>)를 다시 호출하는 재귀 컴포넌트로 렌더링하며,@if (comment().children?.length)가 종료 조건이 됩니다.output()방식은 모든 중간 노드가 이벤트를 중계(Relay)해야 하지만, Angular의 **계층적 인젝터(Hierarchical DI)**를 쓰면 말단 자식이inject(CommentTreeService)로 상위 서비스를 직통 호출할 수 있습니다.providers: [CommentTreeService]를 컴포넌트 레벨에 등록하면 해당 컴포넌트 서브트리 안에서만 인스턴스를 공유하므로, 전역 상태를 오염시키지 않고 완벽한 트리 스코프 격리를 달성합니다.다음 단계 (Part 2 → Part 3 예고): 축하합니다! Part 1과 Part 2를 통해 jQuery 레거시를 걷어내고 Signals 반응형 엔진, 라우터,
@Injectable()서비스 분리, 계층적 DI까지 완성했습니다.하지만 아직 우리 코드의
Post인터페이스는category: string처럼 아무 문자열이나 허용하는 느슨한 타입이며, 공지글(필독 만료일 포함)·비밀글(비밀번호 포함)·일반글처럼 카테고리에 따라 필수 필드가 달라지는 엔터프라이즈 도메인 규칙을 표현하지 못합니다. 이어지는 Chapter 07에서는 TypeScript 6.0의 꽃인 **판별된 유니온(Discriminated Unions)과 타입 가드(Type Guard)**로 런타임 에러 확률을 0%로 수렴시키는 도메인 계약 설계를 시작합니다!