Skip to content

Chapter 06. 계층형 댓글 트리와 계층적 인젝터 (Component DI) ​

"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 상태에서 시작합니다. 이 장에서 새로 만들거나 수정하는 파일:

  1. frontend/src/app/services/comment-tree.service.ts (신규 작성: 계층형 댓글 상태 및 API 전담 서비스)
  2. frontend/src/app/components/comment-item.component.ts (신규 작성: 재귀 렌더링 및 다이렉트 DI 주입 컴포넌트)
  3. frontend/src/app/board-app/board-app.component.ts & .html (CommentTreeService 제공 및 댓글 트리 연결)

6.1 계층형 대댓글 트리와 재귀 컴포넌트의 원리 ​

사내 기술 Q&A 게시판에서 질문 못지않게 중요한 것이 바로 댓글과 그 아래 달리는 **대댓글(답글의 답글)**입니다.

백엔드(GET /api/posts/{postId}/comments)가 내려주는 댓글 데이터는 children 배열 안에 자기 자신과 똑같은 모양의 CommentNode가 중첩된 트리(Tree) 구조입니다:

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

트리 구조는 깊이가 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] 전통적인 이벤트 릴레이 방식의 고통 (비교용) ​

html
<!-- ❌ 안티패턴 비교용: 모든 중간 댓글이 자식의 삭제/답글 이벤트를 부모에게 수동으로 중계해야 함! -->
<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)" 중계 코드를 끝없이 추가해야 합니다!


6.3 Angular의 킬러 무기: 계층적 의존성 주입 (Hierarchical Dependency Injection) ​

이 문제를 가장 우아하게 해결하는 도구가 바로 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로 직통 연결됩니다!

Angular 계층적 인젝터의 탐색 원리 (Resolution Rules) ​

말단 CommentItemComponent가 inject(CommentTreeService)를 호출하면 Angular는 다음 순서로 서비스를 찾습니다:

  1. 내 컴포넌트 자신의 providers에 있는가? → 없음
  2. 내 부모(CommentItemComponent Depth 1)에 있는가? → 없음
  3. 내 조부모(CommentItemComponent Depth 0)에 있는가? → 없음
  4. 최상위 컨테이너인 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 (전체 코드) ​

typescript
// frontend/src/app/services/comment-tree.service.ts
import { 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;
}

6.5 [실습 2] 재귀 컴포넌트 CommentItemComponent 구현 ​

이제 CommentItemComponent를 작성합니다.
놀랍게도 이 컴포넌트에는 output() 선언이 단 하나도 없습니다!
삭제든 답글 등록이든 위쪽 BoardAppComponent가 제공한 CommentTreeService를 inject()로 직접 꺼내 그 자리에서 호출하기 때문입니다.

[코드 6-3] frontend/src/app/components/comment-item.component.ts (전체 코드) ​

typescript
// frontend/src/app/components/comment-item.component.ts
import { Component, ChangeDetectionStrategy, input, signal, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CommentTreeService, CommentNode } from '../services/comment-tree.service';

@Component({
  selector: 'app-comment-item',
  standalone: true,
  // 자기 자신의 태그(<app-comment-item>)는 별도 import 없이 템플릿에서 바로 재귀 호출 가능!
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- 자식 노드는 부모 .comment-node 안에 중첩되므로, 20px만 주어도 깊이만큼 자동 누적 들여쓰기됨! -->
    <div class="comment-node" [style.margin-left.px]="depth() > 0 ? 20 : 0">
      <div class="comment-header">
        <span class="author">
          @if (depth() > 0) {
            <span class="reply-arrow">↳</span>
          }
          {{ comment().author }}
        </span>
        <span class="date">{{ comment().createdAt }} (Depth {{ depth() }})</span>
      </div>

      <p class="content">{{ comment().content }}</p>

      <div class="actions">
        <button type="button" class="btn-reply" (click)="toggleReplyForm()">
          {{ isReplying() ? '취소' : '💬 답글 달기' }}
        </button>
        <!-- 🌟 이벤트 릴레이 없이 주입받은 서비스의 deleteComment를 직통 호출! -->
        <button type="button" class="btn-delete-sm" (click)="handleDelete()">
          삭제
        </button>
      </div>

      <!-- 답글 입력 폼: 각 댓글 컴포넌트 인스턴스마다 독립된 isReplying 신호를 가짐! -->
      @if (isReplying()) {
        <form class="reply-form" (ngSubmit)="handleReplySubmit()">
          <input
            type="text"
            [ngModel]="replyAuthor()"
            (ngModelChange)="replyAuthor.set($event)"
            name="replyAuthor"
            placeholder="작성자"
            class="reply-author"
            required
          />
          <input
            type="text"
            [ngModel]="replyContent()"
            (ngModelChange)="replyContent.set($event)"
            name="replyContent"
            placeholder="답글 내용을 입력하세요..."
            class="reply-content"
            required
          />
          <button type="submit" class="btn-reply-submit">답글 등록</button>
        </form>
      }

      <!-- 🌟 대댓글이 존재할 경우 자기 자신(<app-comment-item>)을 재귀 호출! -->
      <!-- @output 이벤트 중계 코드가 단 한 줄도 없다는 점에 주목하십시오! -->
      @if (comment().children?.length) {
        <div class="children">
          @for (child of comment().children!; track child.id) {
            <app-comment-item
              [comment]="child"
              [depth]="depth() + 1"
            />
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .comment-node { border-left: 2px solid #cbd5e1; padding: 10px 0 10px 14px; margin-top: 10px; background: #ffffff; border-radius: 0 6px 6px 0; }
    .comment-header { display: flex; justify-content: space-between; font-size: 12px; color: #64748b; margin-bottom: 4px; }
    .author { font-weight: 700; color: #1e293b; }
    .reply-arrow { color: #10b981; font-weight: 800; margin-right: 4px; }
    .content { margin: 6px 0; font-size: 14px; color: #334155; }
    .actions { display: flex; gap: 8px; }
    .btn-reply { background: #f1f5f9; color: #475569; border: 1px solid #cbd5e1; padding: 2px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; }
    .btn-delete-sm { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 2px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; }
    .reply-form { display: flex; gap: 6px; margin-top: 8px; padding: 8px; background: #f8fafc; border-radius: 6px; }
    .reply-author { width: 100px; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 12px; }
    .reply-content { flex: 1; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 4px; font-size: 12px; }
    .btn-reply-submit { background: #0f172a; color: #ffffff; border: none; padding: 6px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; }
  `]
})
export class CommentItemComponent {
  // 1. 부모 노드로부터 받는 읽기 전용 입력 신호
  comment = input.required<CommentNode>();
  depth = input<number>(0);

  // 2. 🌟 계층적 인젝터를 통해 조상(BoardAppComponent)이 제공한 CommentTreeService를 직접 획득!
  private readonly commentService = inject(CommentTreeService);

  // 3. 이 댓글 노드만의 로컬 UI 상태 (답글 폼 열림 여부 및 입력값)
  isReplying = signal<boolean>(false);
  replyAuthor = signal<string>('');
  replyContent = signal<string>('');

  toggleReplyForm(): void {
    this.isReplying.update((open) => !open);
  }

  handleDelete(): void {
    this.commentService.deleteComment(this.comment().id);
  }

  handleReplySubmit(): void {
    this.commentService.addComment(
      this.comment().id,
      this.replyAuthor(),
      this.replyContent(),
      () => {
        // 등록 성공 시 로컬 답글 입력창 초기화 및 닫기
        this.replyContent.set('');
        this.isReplying.set(false);
      }
    );
  }
}

🆕 처음 보는 문법: 왜 들여쓰기를 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) ​

typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { PaginationService } from '../services/pagination.service';
import { PostFilterService } from '../services/post-filter.service';
import { FormValidationService } from '../services/form-validation.service';
import { CommentTreeService } from '../services/comment-tree.service';
import { CommentItemComponent } from '../components/comment-item.component';

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

interface PageResponse {
  items: Post[];
  total: number;
}

@Component({
  selector: 'app-board-app',
  standalone: true,
  imports: [FormsModule, CommentItemComponent],
  // 🌟 계층적 DI: 4개의 도메인/상태 서비스를 이 컴포넌트 트리 스코프에 제공!
  providers: [
    PaginationService,
    PostFilterService,
    FormValidationService,
    CommentTreeService
  ],
  templateUrl: './board-app.component.html',
  styleUrl: './board-app.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
  private readonly http = inject(HttpClient);

  readonly pager = inject(PaginationService);
  readonly filter = inject(PostFilterService);
  readonly validator = inject(FormValidationService);
  // 🌟 댓글 트리 서비스 장착 (하위 모든 CommentItemComponent와 인스턴스 자동 공유!)
  readonly commentTree = inject(CommentTreeService);

  posts = signal<Post[]>([]);
  isLoading = signal<boolean>(false);
  isSubmitting = signal<boolean>(false);

  inputKeyword = signal<string>(this.filter.keyword());
  selectedCategory = signal<string>(this.filter.category());

  newTitle = signal<string>('');
  newAuthor = signal<string>('');
  newContent = signal<string>('');

  // 새 루트 댓글 입력용 신호
  rootCommentAuthor = signal<string>('');
  rootCommentContent = signal<string>('');

  constructor() {
    this.filter.registerOnFilterChange(() => {
      this.inputKeyword.set(this.filter.keyword());
      this.selectedCategory.set(this.filter.category());
      this.pager.resetFirstPage();
      this.loadPosts();
    });
  }

  ngOnInit(): void {
    this.loadPosts();
    // 초기 선택된 3번 게시글의 댓글 트리 로드
    this.commentTree.refreshComments();
  }

  handleSearch(): void {
    this.filter.applyFilter(this.inputKeyword(), this.selectedCategory());
  }

  handleReset(): void {
    this.inputKeyword.set('');
    this.selectedCategory.set('ALL');
    this.filter.resetFilter();
  }

  onPageChange(targetPage: number): void {
    this.pager.setPage(targetPage, () => this.loadPosts());
  }

  loadPosts(): void {
    this.isLoading.set(true);
    this.http
      .get<PageResponse>('/api/posts', {
        params: {
          page: this.pager.currentPage(),
          size: this.pager.pageSize(),
          category: this.filter.category(),
          keyword: this.filter.keyword()
        }
      })
      .subscribe({
        next: (res) => {
          this.posts.set(res.items ?? []);
          this.isLoading.set(false);
          this.pager.updateTotalCount(res.total ?? 0, () => {
            this.loadPosts();
          });
        },
        error: () => {
          alert('게시글 목록을 불러오지 못했습니다.');
          this.isLoading.set(false);
        }
      });
  }

  handleDelete(id: number): void {
    if (!confirm('정말 삭제하시겠습니까?')) return;

    this.http.delete<void>(`/api/posts/${id}`).subscribe({
      next: () => {
        this.loadPosts();
      },
      error: () => {
        alert('게시글 삭제에 실패했습니다.');
      }
    });
  }

  handleSubmit(): void {
    if (!this.validator.validate(this.newTitle(), this.newAuthor(), this.newContent())) {
      return;
    }

    this.isSubmitting.set(true);
    this.http
      .post<Post>('/api/posts', {
        title: this.newTitle().trim(),
        author: this.newAuthor().trim(),
        content: this.newContent().trim(),
        category: 'DEV'
      })
      .subscribe({
        next: () => {
          this.newTitle.set('');
          this.newAuthor.set('');
          this.newContent.set('');
          this.validator.clearErrors();
          this.isSubmitting.set(false);
          this.loadPosts();
        },
        error: () => {
          alert('게시글 등록에 실패했습니다.');
          this.isSubmitting.set(false);
        }
      });
  }

  handleRootCommentSubmit(): void {
    this.commentTree.addComment(
      null,
      this.rootCommentAuthor(),
      this.rootCommentContent(),
      () => {
        this.rootCommentContent.set('');
      }
    );
  }
}
html
<!-- frontend/src/app/board-app/board-app.component.html -->
<div class="board-card">
  <div class="card-header">
    <h2>사내 기술 Q&A 게시판 (v2.0 Clean Architecture)</h2>
    <span class="badge">총 {{ pager.totalCount() }}건</span>
  </div>

  <!-- 1. 검색 바 -->
  <div class="filter-bar">
    <select
      [ngModel]="selectedCategory()"
      (ngModelChange)="selectedCategory.set($event); handleSearch()"
    >
      <option value="ALL">전체 카테고리</option>
      <option value="DEV">개발 (DEV)</option>
      <option value="NOTICE">공지 (NOTICE)</option>
      <option value="FREE">자유 (FREE)</option>
    </select>
    <input
      type="text"
      [ngModel]="inputKeyword()"
      (ngModelChange)="inputKeyword.set($event)"
      (keyup.enter)="handleSearch()"
      placeholder="검색어 입력 후 Enter"
    />
    <button type="button" (click)="handleSearch()">검색</button>
    <button type="button" (click)="handleReset()">초기화</button>
  </div>

  <!-- 2. 게시글 등록 폼 -->
  <form class="form-container" (ngSubmit)="handleSubmit()">
    <div class="input-row">
      <input
        type="text"
        [ngModel]="newTitle()"
        (ngModelChange)="newTitle.set($event)"
        name="title"
        placeholder="제목 (2자 이상)"
      />
      <input
        type="text"
        [ngModel]="newAuthor()"
        (ngModelChange)="newAuthor.set($event)"
        name="author"
        placeholder="작성자"
        class="author-input"
      />
      <button type="submit" [disabled]="isSubmitting()" class="btn-submit">
        {{ isSubmitting() ? '등록 중...' : '등록' }}
      </button>
    </div>
    @if (validator.errors().title) {
      <p class="error-msg">⚠️ {{ validator.errors().title }}</p>
    }
    @if (validator.errors().author) {
      <p class="error-msg">⚠️ {{ validator.errors().author }}</p>
    }

    <textarea
      [ngModel]="newContent()"
      (ngModelChange)="newContent.set($event)"
      name="content"
      placeholder="내용 (10자 이상)"
      rows="3"
    ></textarea>
    @if (validator.errors().content) {
      <p class="error-msg">⚠️ {{ validator.errors().content }}</p>
    }
  </form>

  <!-- 3. 게시글 목록 테이블 (제목 클릭 시 해당 글의 댓글 트리 로드!) -->
  <table class="data-table">
    <thead>
      <tr>
        <th style="width: 60px;">ID</th>
        <th>제목 (클릭 시 댓글 보기)</th>
        <th style="width: 100px;">작성자</th>
        <th style="width: 120px;">작성일</th>
        <th style="width: 80px;">동작</th>
      </tr>
    </thead>
    <tbody>
      @for (post of posts(); track post.id) {
        <tr [class.row-selected]="post.id === commentTree.currentPostId()">
          <td>{{ post.id }}</td>
          <td
            class="clickable"
            (click)="commentTree.selectPostAndLoad(post.id)"
          >
            <span class="post-title">{{ post.title }}</span>
          </td>
          <td>{{ post.author }}</td>
          <td>{{ post.createdAt }}</td>
          <td>
            <button type="button" class="btn-delete" (click)="handleDelete(post.id)">
              삭제
            </button>
          </td>
        </tr>
      } @empty {
        <tr>
          <td colspan="5" class="empty-state">게시글이 없습니다.</td>
        </tr>
      }
    </tbody>
  </table>

  <!-- 4. 페이지네이션 -->
  <div class="pagination">
    <button
      type="button"
      [disabled]="!pager.hasPrevPage()"
      (click)="onPageChange(pager.currentPage() - 1)"
    >
      이전
    </button>
    <span>
      {{ pager.currentPage() }} / {{ pager.totalPages() }} 페이지
      (총 {{ pager.totalCount() }}건)
    </span>
    <button
      type="button"
      [disabled]="!pager.hasNextPage()"
      (click)="onPageChange(pager.currentPage() + 1)"
    >
      다음
    </button>
  </div>

  <!-- 5. 🌟 계층형 댓글 섹션 (재귀 컴포넌트 & 계층적 DI) -->
  <section class="comment-section">
    <div class="comment-section-header">
      <h4>
        💬 #{{ commentTree.currentPostId() }}번 게시글의 댓글 트리
        (총 {{ commentTree.totalCommentCount() }}개)
      </h4>
    </div>

    <!-- 루트 댓글 작성 폼 -->
    <form class="root-comment-form" (ngSubmit)="handleRootCommentSubmit()">
      <input
        type="text"
        [ngModel]="rootCommentAuthor()"
        (ngModelChange)="rootCommentAuthor.set($event)"
        name="rootAuthor"
        placeholder="작성자"
        class="root-author"
        required
      />
      <input
        type="text"
        [ngModel]="rootCommentContent()"
        (ngModelChange)="rootCommentContent.set($event)"
        name="rootContent"
        placeholder="새 댓글을 남겨보세요..."
        class="root-content"
        required
      />
      <button type="submit" class="btn-submit">댓글 등록</button>
    </form>

    <!-- 루트 댓글만 순회하면 하위 대댓글은 CommentItemComponent가 알아서 무한 재귀 렌더링! -->
    @if (commentTree.isLoading()) {
      <p class="empty-state">댓글을 불러오는 중입니다...</p>
    } @else {
      @for (comment of commentTree.comments(); track comment.id) {
        <app-comment-item [comment]="comment" [depth]="0" />
      } @empty {
        <p class="empty-state">아직 등록된 댓글이 없습니다. 첫 댓글을 남겨보세요!</p>
      }
    }
  </section>
</div>

추가 CSS 스타일(board-app.component.css 하단에 덧붙이기):

css
.comment-section { margin-top: 28px; padding-top: 20px; border-top: 2px solid #e2e8f0; }
.comment-section-header h4 { margin: 0 0 12px 0; color: #0f172a; }
.root-comment-form { display: flex; gap: 8px; margin-bottom: 16px; }
.root-author { width: 120px; padding: 8px 10px; border: 1px solid #cbd5e1; border-radius: 6px; }
.root-content { flex: 1; padding: 8px 10px; border: 1px solid #cbd5e1; border-radius: 6px; }

💡 [확인하기]: 브라우저를 열면 #3번 게시글의 3단 대댓글 트리가 들여쓰기와 함께 깔끔하게 렌더링됩니다. 가장 깊은 Depth 2 대대댓글에서 **[💬 답글 달기]**를 눌러 글을 등록하면 Depth 3에 즉시 대대대댓글이 달리며, 상단의 총 N개 댓글 수도 재귀적으로 정확히 올라갑니다!


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

현재 CommentItemComponent에는 각 댓글마다 자신의 isReplying = signal(false)가 있어서, 댓글 A의 답글 창을 열어둔 채 댓글 B의 답글 창을 열면 두 개의 답글 입력창이 동시에 열립니다.
이를 개선하여 "트리 전체에서 한 번에 딱 하나의 댓글에만 답글 입력창이 열리도록" CommentTreeService의 공유 신호를 활용해 리팩터링해 보십시오!

📝 미션 요구사항 ​

  1. CommentTreeService에 현재 답글 창이 열린 댓글의 ID를 기억하는 activeReplyCommentId = signal<number | null>(null) 신호와 toggleReply(commentId: number): void 메서드를 추가하십시오.
  2. CommentItemComponent에서 로컬 isReplying 신호를 제거하고, isReplying = computed(() => this.commentService.activeReplyCommentId() === this.comment().id) 파생 신호로 교체하십시오.
  3. 댓글 A의 [💬 답글 달기]를 누른 뒤 댓글 B의 [💬 답글 달기]를 누르면 댓글 A의 입력창이 자동으로 닫히고 댓글 B의 입력창만 열리는지 확인하십시오!
💡 미션 정답 코드 확인하기
typescript
// 1. comment-tree.service.ts에 공유 상태와 토글 메서드 추가
export class CommentTreeService {
  // ... 기존 코드 ...

  // 🌟 트리 전체에서 현재 답글 폼이 열린 단 하나의 댓글 ID!
  readonly activeReplyCommentId = signal<number | null>(null);

  toggleReply(commentId: number): void {
    this.activeReplyCommentId.update((current) =>
      current === commentId ? null : commentId
    );
  }
}
typescript
// 2. comment-item.component.ts에서 computed로 연결!
import { Component, ChangeDetectionStrategy, input, signal, computed, inject } from '@angular/core';

export class CommentItemComponent {
  comment = input.required<CommentNode>();
  depth = input<number>(0);

  private readonly commentService = inject(CommentTreeService);

  // 🌟 내 댓글 ID와 서비스의 activeReplyCommentId가 일치할 때만 true!
  isReplying = computed(
    () => this.commentService.activeReplyCommentId() === this.comment().id
  );

  replyAuthor = signal<string>('');
  replyContent = signal<string>('');

  toggleReplyForm(): void {
    this.commentService.toggleReply(this.comment().id);
  }

  handleReplySubmit(): void {
    this.commentService.addComment(
      this.comment().id,
      this.replyAuthor(),
      this.replyContent(),
      () => {
        this.replyContent.set('');
        this.commentService.activeReplyCommentId.set(null); // 닫기!
      }
    );
  }
}

6.8 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 요약 3줄

  1. 깊이가 정해지지 않은 트리 데이터는 템플릿 안에서 자기 자신의 셀렉터(<app-comment-item>)를 다시 호출하는 재귀 컴포넌트로 렌더링하며, @if (comment().children?.length)가 종료 조건이 됩니다.
  2. 전통적인 output() 방식은 모든 중간 노드가 이벤트를 중계(Relay)해야 하지만, Angular의 **계층적 인젝터(Hierarchical DI)**를 쓰면 말단 자식이 inject(CommentTreeService)로 상위 서비스를 직통 호출할 수 있습니다.
  3. 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%로 수렴시키는 도메인 계약 설계를 시작합니다!

Moon HomeLab Engineering Knowledge Hub