Skip to content

Chapter 06. 계층형 댓글 트리와 Provide / Inject (DI) ​

"5장에서 가벼워진 BoardApp.vue에 '계층형 대댓글(답글의 답글)'을 붙여보자. 그런데 저 4단계 깊숙한 자식 컴포넌트에서 [삭제] 버튼을 눌렀을 때, 왜 모든 중간 컴포넌트가 고통받아야 하는가?"


6.1 BoardApp.vue에 계층형 댓글 기능 추가하기 ​

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

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

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

새로운 컴포넌트 파일을 생성합니다: frontend/src/components/CommentItem.vue

[코드 6-1] 재귀 컴포넌트의 뼈대 (frontend/src/components/CommentItem.vue) ​

html
<template>
  <div class="comment-node" :style="{ marginLeft: `${depth * 20}px` }">
    <div class="comment-header">
      <span class="author">{{ comment.author }}</span>
      <span class="date">{{ comment.createdAt }}</span>
    </div>
    <p class="content">{{ comment.content }}</p>

    <div class="actions">
      <button @click="handleDelete">삭제</button>
    </div>

    <!-- 대댓글이 존재할 경우 자기 자신을 재귀 렌더링 -->
    <div v-if="comment.children?.length" class="children">
      <CommentItem
        v-for="child in comment.children"
        :key="child.id"
        :comment="child"
        :depth="depth + 1"
        @delete="$emit('delete', $event)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface CommentNode {
  id: number;
  author: string;
  content: string;
  createdAt: string;
  children?: CommentNode[];
}

const { comment, depth = 0 } = defineProps<{
  comment: CommentNode;
  depth?: number;
}>();

const emit = defineEmits<{
  (e: 'delete', id: number): void;
}>();

function handleDelete() {
  emit('delete', props.comment.id);
}

6.2 Props Drilling과 이벤트 릴레이 지옥 ​

여기서 엄청난 설계적 고통이 찾아옵니다.
저 4단계 깊숙한 곳에 있는 대대댓글 1-1-1에서 사용자가 [삭제] 버튼을 눌렀다고 가정해 봅시다.

실제 서버 DELETE API를 호출하고 전체 댓글 트리를 갱신해야 하는 주체는 최상위 BoardApp.vue입니다.

전통적인 props와 emit 방식을 고집하면:

  • 중간에 낀 Depth 0, Depth 1 컴포넌트들은 댓글 삭제에 아무런 관심도 없으면서, 오직 자식의 이벤트를 부모에게 전달하기 위해 불필요한 @delete="$emit('delete', $event)" 보일러플레이트를 모든 재귀 호출마다 도배해야 합니다.
  • 중간 전달자 중 단 한 곳이라도 이벤트를 누락하면 시스템은 조용히 멈춥니다.
  • 이를 프론트엔드에서는 이벤트 릴레이 지옥(Event Bubbling Hell) 또는 Props Drilling이라고 부릅니다.

6.3 엔터프라이즈 IoC/DI를 프론트엔드에 투영하다 ​

엔터프라이즈 백엔드 아키텍처에서 IoC(제어의 역전)와 DI(의존성 주입)가 도입된 가장 큰 이유가 무엇이었습니까?
**"하위 계층의 컴포넌트가 상위의 서비스를 일일이 파라미터로 넘겨받지 않고, DI 컨테이너에 등록된 의존성을 필요할 때 직접 주입(Injection)받아 결합도를 끊어내는 것"**이었습니다.

Vue 3는 이 의존성 주입 메커니즘을 프레임워크 코어 레벨에서 완벽하게 지원합니다.
바로 **provide와 inject**입니다.

  • provide: 최상위 BoardApp.vue가 하위 트리 전체를 향해 댓글 비즈니스 서비스를 방송(제공)합니다.
  • inject: 아무리 깊은 곳에 있는 대대댓글이라도 중간 단계의 관여 없이 상위 서비스를 다이렉트로 주입받아 호출합니다.

6.4 InjectionKey<T>를 통한 100% 타입 안전한 DI ​

문자열 키(provide('service', ...))를 쓰면 오타가 날 수 있으므로, TypeScript의 InjectionKey<T> 심볼을 사용합니다.

[코드 6-2] DI 계약서 정의 (src/types/comment-di.ts) ​

typescript
import type { InjectionKey } from 'vue';

export interface CommentService {
  deleteComment: (commentId: number) => Promise<void>;
  replyComment: (parentId: number, content: string) => Promise<void>;
}

export const CommentServiceKey: InjectionKey<CommentService> = Symbol('CommentServiceKey');

[코드 6-3] BoardApp.vue에서 서비스 Provide ​

html
<script setup lang="ts">
import { provide } from 'vue';
import axios from 'axios';
import { CommentServiceKey, type CommentService } from '@/types/comment-di';

// 댓글 삭제 및 답글 비즈니스 로직 정의
const commentService: CommentService = {
  async deleteComment(commentId: number) {
    if (!confirm('정말 삭제하시겠습니까?')) return;
    await axios.delete(`/api/posts/${currentPostId.value}/comments/${commentId}`);
    await refreshComments(); // 새로고침
  },
  async replyComment(parentId: number, content: string) {
    await axios.post(`/api/posts/${currentPostId.value}/comments`, { parentId, content });
    await refreshComments();
  }
};

// 🌟 트리 하위의 모든 컴포넌트에게 서비스 주입!
provide(CommentServiceKey, commentService);

[코드 6-4] 깊은 자식 CommentItem.vue에서 다이렉트 Inject ​

html
<template>
  <div class="comment-node">
    <p>{{ comment.content }}</p>
    <div class="actions">
      <!-- 🌟 중간 전달자 없이 최상위 서비스 메서드를 즉시 호출! -->
      <button @click="commentService.deleteComment(comment.id)">삭제</button>
    </div>

    <!-- 재귀 호출 시 더 이상 @delete 보일러플레이트가 전혀 필요 없다! -->
    <div v-if="comment.children?.length" class="children">
      <CommentItem
        v-for="child in comment.children"
        :key="child.id"
        :comment="child"
        :depth="depth + 1"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue';
import { CommentServiceKey } from '@/types/comment-di';

const { comment, depth = 0 } = defineProps<{ comment: any; depth?: number }>();

// DI 컨테이너에서 CommentService를 주입받음 (타입 추론 100%)
const commentService = inject(CommentServiceKey);
if (!commentService) {
  throw new Error('CommentItem은 반드시 CommentService가 Provide된 트리 내부에서 사용되어야 합니다.');
}

6.5 무엇이 개선되었는가? ​

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

6.6 다음 챕터로 이어지는 질문 ​

이제 BoardApp.vue는 페이징, 검색, 폼 검증, 그리고 계층형 대댓글까지 갖춘 제법 훌륭한 애플리케이션이 되었습니다.
하지만 소스 코드를 가만히 들여다보면, 아직도 치명적인 구멍이 남아있습니다:

  • 백엔드 Spring Boot는 PostDto 클래스로 엄격한 데이터 타입을 정의하고 있는데, 우리 프론트엔드 코드 곳곳에는 여전히 any나 느슨한 필드명이 섞여 있습니다.
  • 공지글, 비밀글, 일반글에 따라 필드가 달라지는데 옵셔널(?)로 처리되어 있어 런타임 에러의 불씨가 살아있습니다.

이어지는 Chapter 07에서는 Spring Boot DTO와 1:1로 맞물리는 **엄격한 TypeScript 도메인 계약(Contract)과 판별된 유니온(Discriminated Unions)**을 BoardApp.vue에 전면 적용해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub