SCRUM-13369, 13749, 13750, 13751 | 커밋 수: 117개 | 기간: 2025.05 ~ 2025.06


문제상황

댓글 작성 컴포넌트가 레시피북, 체험단, 라이브, 스퀘어 등 4개 도메인에 분산되어 있어 일관성이 부족했습니다. 이미지 업로드 로직이 13개 파일에 중복되어 있었고, 페이징 인터페이스도 도메인마다 달라 유지보수가 어려운 상황이었습니다.

Before (도메인별 분산)

장점

  • 도메인 독립 개발 가능

고려할 점

  • 이미지 업로드 13파일 중복
  • 페이징 인터페이스 불일치
  • Vuex 레거시

After (CommentBox 통합)

장점

  • 단일 컴포넌트
  • 이미지 업로드 2파일
  • Pinia 마이그레이션
  • 일관된 UX

고려할 점

  • 4개 도메인 동시 마이그레이션 필요

주요 개선사항

CommentBox 하단 댓글 입력 컴포넌트를 통합했습니다. 이미지 업로드 로직을 useImageUtils와 useMediaStore로 중앙화했고, 댓글 페이징 인터페이스를 통일했습니다. 답글 기능을 레시피북, 체험단, 라이브, 스퀘어 전체 도메인에 적용했습니다.

이미지 업로드 코드 통합
항목비교값 (파일)
Before1313 파일
After22 파일

정량적 성과

댓글 관련 Vuex에서 Pinia로의 마이그레이션을 완료했습니다. 이미지 업로드 관련 중복 코드를 13개 파일에서 2개 파일로 제거했으며, 댓글 컴포넌트 테스트 코드를 18개 추가했습니다.

🔄

Vuex → Pinia

상태관리 마이그레이션

📷

이미지 중앙화

13파일 → 2파일

🧪

BDD 테스트

18개 테스트 추가

💬

4개 도메인 통합

일관된 댓글 UX

비즈니스 임팩트

모든 도메인에서 일관된 댓글 UX를 제공할 수 있게 되었습니다. 이미지 첨부 기능의 안정성이 향상되었고, 내 댓글만 보기, 댓글 가이드라인 등 신규 기능을 추가했습니다.

  1. 4개 도메인 분산 파악
  2. CommentBox 통합 설계
  3. Vuex → Pinia 전환
  4. 이미지 업로드 중앙화
  5. BDD 테스트 18개

기술적 구현 상세

AS-IS

기존에는 댓글 관련 로직이 여러 컴포넌트에 분산되어 있었습니다.

// 기존 구조 (더미 예시)
// 각 도메인마다 댓글 컴포넌트가 별도로 존재
// RecipeComment.vue, LiveComment.vue, SquareComment.vue 등

// 페이징 인터페이스도 불일치
interface RecipeCommentPage {
  pageNo: number;
  pageSize: number;
}

interface LiveCommentPage {
  page: number;
  perPage: number;
}

문제점:

댓글 작성 로직이 도메인마다 중복되어 있었습니다. 이미지 업로드 로직이 13개 파일에 분산되어 있었고, 페이징 인터페이스가 불일치하여 유지보수가 어려웠습니다.

TO-BE

CommentBox 컴포넌트로 통합하고, 페이징 인터페이스를 통일했습니다.

// 통합된 페이징 인터페이스
interface CommentPage {
  page: number;
  perPage: number;
}

type CommentPageKey = Pick<CommentPage, 'page' | 'perPage'>;

핵심 구현

1. CommentBox 하단 입력 컴포넌트

<!-- components/comment/CommentBox.vue -->
<script setup lang="ts">
import { useCommentStore } from '@/stores/modules/comment';
import { useMediaStore } from '@/stores/modules/media';

const props = defineProps<{
  contentType: CommentContentType;
  contentId: string;
}>();

const commentStore = useCommentStore();
const mediaStore = useMediaStore();

const commentText = ref('');
const isEditing = ref(false);

// 댓글 작성
const submitComment = async () => {
  const attachedImages = await mediaStore.getUploadedImagePaths();

  await commentStore.createComment({
    contentType: props.contentType,
    contentId: props.contentId,
    text: commentText.value,
    images: attachedImages,
  });

  // 초기화
  commentText.value = '';
  mediaStore.clearAttachedImages();
};

// 수정 모드 진입 시 초기 텍스트 길이 반영
watch(isEditing, (editing) => {
  if (editing) {
    // 초기 텍스트 길이 설정
  }
});
</script>

<template>
  <div class="comment-box">
    <ImageUpload
      :max-count="3"
      @upload="handleImageUpload"
    />
    <textarea
      v-model="commentText"
      :placeholder="isFocused ? '댓글을 입력하세요' : '댓글 작성'"
    />
    <button @click="submitComment">등록</button>
  </div>
</template>

2. 이미지 업로드 로직 중앙화

// stores/modules/media.ts
export const useMediaStore = defineStore('media', {
  state: () => ({
    attachedImages: [] as AttachedImage[],
    maxImageCount: 3,
  }),

  actions: {
    async uploadImage(file: File): Promise<AttachedImage> {
      // 이미지 업로드 API 호출
      const response = await mediaApi.upload(file);
      const image: AttachedImage = {
        id: response.id,
        url: response.url,
        thumbnailUrl: response.thumbnailUrl,
      };
      this.attachedImages.push(image);
      return image;
    },

    async getUploadedImagePaths(): Promise<string[]> {
      return this.attachedImages.map(img => img.url);
    },

    clearAttachedImages() {
      this.attachedImages = [];
    },
  },
});

3. 댓글 테스트 코드 (BDD 스타일)

// test/tests/comment.test.ts
describe('CommentListItem', () => {
  describe('블라인드/삭제/신고 처리된 댓글', () => {
    it('본인이 작성한 댓글이 블라인드 처리되었을 때 내용이 표시된다', async () => {
      // Given: 블라인드 처리된 본인 댓글
      const comment = createMockComment({
        isBlind: true,
        isOwner: true,
      });

      // When: 컴포넌트 렌더링
      const { getByText } = render(CommentListItem, {
        props: { comment },
      });

      // Then: 댓글 내용이 표시됨
      expect(getByText(comment.content)).toBeInTheDocument();
    });

    it('타인이 작성한 블라인드 댓글은 안내 메시지가 표시된다', async () => {
      // Given: 블라인드 처리된 타인 댓글
      const comment = createMockComment({
        isBlind: true,
        isOwner: false,
      });

      // When: 컴포넌트 렌더링
      const { getByText } = render(CommentListItem, {
        props: { comment },
      });

      // Then: 블라인드 안내 메시지 표시
      expect(getByText('블라인드 처리된 댓글입니다')).toBeInTheDocument();
    });
  });
});

4. 페이징 인터페이스 통일

// types/comment.d.ts
interface CommentPage {
  page: number;
  perPage: number;
}

type CommentPageKey = Pick<CommentPage, 'page' | 'perPage'>;

// stores/modules/comment.ts
export const useCommentStore = defineStore('comment', {
  actions: {
    async fetchComments(params: CommentPageKey) {
      const response = await commentApi.getComments({
        page: params.page,
        perPage: params.perPage,
      });
      this.comments = response.items;
      this.totalCount = response.totalCount;
    },
  },
});


회고

잘한 점

이미지 업로드 로직을 Pinia Store로 중앙화하여 재사용성을 확보했습니다. BDD 스타일 테스트를 도입하여 요구사항을 명확화했고, jest-dom을 설치하여 DOM 존재 여부 검증을 개선했습니다.

개선할 점

초기 설계 시 페이징 인터페이스를 통일했다면 마이그레이션 비용을 절감할 수 있었을 것입니다. 또한 답글 기능 구현 시 재귀 구조에 대한 고려가 필요합니다.

배운 점

댓글 시스템의 복잡성을 체감했습니다. 블라인드, 신고, 삭제 상태 관리가 얼마나 다양한 분기를 만드는지 확인했고, 이미지 업로드 로직의 공통화 패턴과 BDD 스타일 테스트 작성 방법을 익혔습니다.