Files
frontend/docs/bbs/comment-archi.md
T

20 KiB

게시물 댓글(Comment) 관리 프로그램 아키텍처 문서

개요

이 문서는 게시물 댓글(Comment) 관리 프로그램의 아키텍처와 소스 흐름에 대한 상세 설명입니다. 댓글 관리 기능은 게시물에 대한 댓글을 등록, 조회, 수정, 삭제하는 기능을 제공하며, 로그인 및 비로그인 사용자 모두 이용할 수 있습니다.

기술 스택

  • 프레임워크: Next.js (App Router)
  • 상태 관리: React Query, Zustand(useUserStore, useAlertStore)
  • UI 컴포넌트: @common/components
  • 폼 관리: React Hook Form, Zod
  • API 통신: Axios
  • 알림 처리: useConfirm, usePrompt 커스텀 훅

디렉토리 구조

frontend/
├── apps/
│   └── cs/
│       ├── src/
│       │   ├── entities/
│       │   │   └── comment/
│       │   │       ├── api/
│       │   │       │   └── comment-service.ts       # 댓글 API 호출 함수
│       │   │       └── model/
│       │   │           └── comment-interface.ts     # 댓글 인터페이스 정의
│       │   └── widgets/
│       │       └── post/
│       │           └── ui/
│       │               └── post-comment.tsx         # 댓글 UI 컴포넌트

데이터 모델

댓글(Comment) 인터페이스

export interface Comment {
  cmntEvl: number;      // 댓글 평가 점수
  pstNo: number;        // 게시물 번호
  cmntNo: number;       // 댓글 번호
  cmntCn: string;       // 댓글 내용
  wrtrNm: string;       // 작성자명
  regDt: string;        // 등록일시
  rgtr: string;         // 등록자
  mdfcnDt: string;      // 수정일시
  mdfr: string;         // 수정자
  delYn: string;        // 삭제 여부
}

댓글 검색 파라미터(CommentParams) 인터페이스

export interface CommentParams extends PageableParams {
  keywordType?: string; // 검색 키워드 유형
  keyword?: string;     // 검색 키워드
  pstNo: number;        // 게시물 번호
  delYn?: string;       // 삭제 여부
}

댓글 폼 데이터(CommentFormData) 인터페이스

Zod 스키마를 사용하여 폼 데이터의 유효성 검증을 수행합니다.

export const commentFormSchema = z.object({
  pstNo: z.number(),                // 게시물 번호
  cmntCn: z.string(),               // 댓글 내용
  wrtrNm: z.string().nullish(),     // 작성자명 (선택적)
  cmntPswd: z.string(),             // 댓글 비밀번호
  cmntEvl: z.number().nullish(),    // 댓글 평가 점수 (선택적)
});

export type CommentFormData = z.infer<typeof commentFormSchema>;

댓글 수정 폼 데이터(CommentUpdateFormData) 인터페이스

export const commentUpdateFormSchema = commentFormSchema.omit({
  pstNo: true,  // 수정 시에는 게시물 번호가 필요 없음
});

export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>;

API 기능

댓글 관리 API는 다음과 같은 기능을 제공합니다:

1. 댓글 목록 조회

getComments: async (params: CommentParams): Promise<CommentList> => {
  const response = await client.get<CommentList>(
    `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
    { params },
  );
  return response.data;
}
  • 설명: 특정 게시물의 댓글 목록을 페이지네이션과 함께 조회합니다.
  • 파라미터: CommentParams (게시물 번호, 검색 조건)
  • 반환값: 페이지네이션된 댓글 목록 (CommentList)

2. 댓글 등록

createComment: async (data: CommentFormData): Promise<Comment> => {
  const validatedData = commentFormSchema.parse(data);
  const response = await client.post<Comment>(
    `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
    validatedData,
  );
  return response.data;
}
  • 설명: 새로운 댓글을 등록합니다.
  • 파라미터: CommentFormData (댓글 등록 데이터)
  • 반환값: 등록된 댓글 정보 (Comment)

3. 댓글 수정

updateComment: async (
  bbsId: number,
  pstNo: number,
  cmntNo: number,
  data: CommentUpdateFormData,
): Promise<Comment> => {
  const validatedData = commentUpdateFormSchema.parse(data);
  const response = await client.put<Comment>(
    `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`,
    validatedData,
  );
  return response.data;
}
  • 설명: 기존 댓글 정보를 수정합니다.
  • 파라미터: bbsId (게시판 ID), pstNo (게시물 번호), cmntNo (댓글 번호), CommentUpdateFormData (수정할 댓글 데이터)
  • 반환값: 수정된 댓글 정보 (Comment)

4. 댓글 삭제

deleteComment: async (
  bbsId: number,
  pstNo: number,
  cmntNo: number,
  cmntPswd: string,
): Promise<Comment> => {
  const response = await client.delete<Comment>(
    `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}/${bbsId}/${pstNo}/${cmntNo}`,
    { params: { cmntPswd } },
  );
  return response.data;
}
  • 설명: 댓글을 삭제합니다.
  • 파라미터: bbsId (게시판 ID), pstNo (게시물 번호), cmntNo (댓글 번호), cmntPswd (댓글 비밀번호)
  • 반환값: 삭제된 댓글 정보 (Comment)

프로그램 흐름

1. 댓글 목록 조회 흐름

  1. 게시물 상세 페이지에서 PostComment 컴포넌트 로드
  2. React Query를 사용하여 getComments API 호출
    const { data: comments } = useQuery({
      queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
      queryFn: () => CommentApi.getComments({ pstNo }),
      enabled: !!pstNo,
    });
    
  3. 응답 데이터를 UI에 표시
    {comments?.content.map((comment) => {
      if (comment.delYn === "Y") return null;
      return (
        <div className="flex justify-between gap-2 border-t border-gray-200 py-8" key={comment.cmntNo}>
          {/* 댓글 내용 표시 */}
        </div>
      );
    })}
    

2. 댓글 등록 흐름

  1. 사용자가 댓글 내용 입력 및 등록 버튼 클릭
  2. React Hook Form을 통해 폼 데이터 유효성 검증
    const form = useForm<CommentFormData>({
      resolver: zodResolver(commentFormSchema),
      values: {
        pstNo,
        cmntCn: "",
        wrtrNm: userInfo?.userName ?? "",
        cmntPswd: userInfo?.userUnqId ?? "",
      },
    });
    
  3. 유효성 검증 통과 시 onSubmit 함수 호출
    const onSubmit = (data: CommentFormData) => {
      createComment(data);
    };
    
  4. React Query의 useMutation 훅을 통해 createComment API 호출
    const { mutate: createComment } = useMutation({
      mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
      onSuccess: () => {
        alert("등록되었습니다.");
        form.setValue("cmntCn", "");
        queryClient.invalidateQueries({
          queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
        });
      },
    });
    
  5. 성공 시 알림 표시 및 댓글 목록 새로고침

3. 댓글 수정 흐름

  1. 사용자가 댓글 수정 버튼 클릭
  2. 수정 모드 활성화 및 기존 댓글 내용 로드
    const handleEdit = (cmntNo: number, cmntCn: string) => {
      setEditingCmntNo(cmntNo);
      setEditValue(cmntCn);
    };
    
  3. 사용자가 댓글 내용 수정 후 저장 버튼 클릭
  4. 확인 다이얼로그 표시 및 비밀번호 확인
    const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => {
      if (!(await confirm("수정하시겠습니까?"))) return;
      const cmntPswd = await getCommentPassword();
      updateComment({
        cmntNo,
        data: {
          cmntCn: editValue,
          wrtrNm,
          cmntPswd,
        },
      });
    };
    
  5. React Query의 useMutation 훅을 통해 updateComment API 호출
  6. 성공 시 알림 표시 및 댓글 목록 새로고침

4. 댓글 삭제 흐름

  1. 사용자가 댓글 삭제 버튼 클릭
  2. 확인 다이얼로그 표시 및 비밀번호 확인
    const handleDeleteComment = async (cmntNo: number) => {
      if (!(await confirm("삭제하시겠습니까?"))) return;
      const cmntPswd = await getCommentPassword();
      deleteComment({ cmntNo, cmntPswd });
    };
    
  3. React Query의 useMutation 훅을 통해 deleteComment API 호출
  4. 성공 시 알림 표시 및 댓글 목록 새로고침

주요 컴포넌트

PostComment 컴포넌트

게시물 댓글을 표시하고 관리하는 컴포넌트입니다.

function PostComment({ bbsId, pstNo }: { bbsId: number; pstNo: number }) {
  // 상태 및 훅 설정
  const { prompt } = usePrompt();
  const { confirm } = useConfirm();
  const queryClient = useQueryClient();
  const { userInfo } = useUserStore((state) => state);
  const { setMessage: alert } = useAlertStore((state) => state);
  const [editingCmntNo, setEditingCmntNo] = useState<number | null>(null);
  const [editValue, setEditValue] = useState<string>("");

  // 댓글 목록 조회
  const { data: comments } = useQuery({
    queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
    queryFn: () => CommentApi.getComments({ pstNo }),
    enabled: !!pstNo,
  });

  // 댓글 등록 뮤테이션
  const { mutate: createComment } = useMutation({
    mutationFn: (data: CommentFormData) => CommentApi.createComment(data),
    onSuccess: () => {
      alert("등록되었습니다.");
      form.setValue("cmntCn", "");
      queryClient.invalidateQueries({
        queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
      });
    },
  });

  // 댓글 수정 뮤테이션
  const { mutate: updateComment } = useMutation({
    mutationFn: ({
      cmntNo,
      data,
    }: {
      cmntNo: number;
      data: CommentUpdateFormData;
    }) => CommentApi.updateComment(bbsId, pstNo, cmntNo, data),
    onSuccess: () => {
      alert("수정되었습니다.");
      setEditingCmntNo(null);
      setEditValue("");
      queryClient.invalidateQueries({
        queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
      });
    },
  });

  // 댓글 삭제 뮤테이션
  const { mutate: deleteComment } = useMutation({
    mutationFn: ({ cmntNo, cmntPswd }: { cmntNo: number; cmntPswd: string }) =>
      CommentApi.deleteComment(bbsId, pstNo, cmntNo, cmntPswd),
    onSuccess: () => {
      alert("삭제되었습니다.");
      queryClient.invalidateQueries({
        queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS],
      });
    },
  });

  // 폼 설정
  const form = useForm<CommentFormData>({
    resolver: zodResolver(commentFormSchema),
    values: {
      pstNo,
      cmntCn: "",
      wrtrNm: userInfo?.userName ?? "",
      cmntPswd: userInfo?.userUnqId ?? "",
    },
  });

  // 폼 제출 핸들러
  const onSubmit = (data: CommentFormData) => {
    createComment(data);
  };

  // 비밀번호 획득 함수
  const getCommentPassword = async () => {
    let cmntPswd = "";
    if (userInfo?.userUnqId) {
      cmntPswd = userInfo?.userUnqId;
    } else {
      const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
      if (resolved) cmntPswd = value ?? "";
    }
    return cmntPswd;
  };

  // 댓글 수정 핸들러
  const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => {
    if (!(await confirm("수정하시겠습니까?"))) return;
    const cmntPswd = await getCommentPassword();
    updateComment({
      cmntNo,
      data: {
        cmntCn: editValue,
        wrtrNm,
        cmntPswd,
      },
    });
  };

  // 댓글 삭제 핸들러
  const handleDeleteComment = async (cmntNo: number) => {
    if (!(await confirm("삭제하시겠습니까?"))) return;
    const cmntPswd = await getCommentPassword();
    deleteComment({ cmntNo, cmntPswd });
  };

  // UI 렌더링
  return (
    <div className="card card-border !p-8">
      {/* 댓글 등록 폼 */}
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          {/* 댓글 내용 입력 필드 */}
          <FormField
            control={form.control}
            name="cmntCn"
            render={({ field }) => (
              <FormItem>
                <FormControl>
                  <Textarea
                    maxLength={1000}
                    className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
                    placeholder="댓글을 입력해주세요."
                    {...field}
                  />
                </FormControl>
              </FormItem>
            )}
          />
          <div className="mt-4 flex items-center justify-between">
            <div className="flex gap-4">
              {/* 비로그인 사용자를 위한 작성자명 입력 필드 */}
              {!userInfo?.userName && (
                <FormField
                  control={form.control}
                  name="wrtrNm"
                  render={({ field }) => (
                    <FormItem>
                      <FormControl>
                        <Input
                          {...field}
                          value={field.value ?? ""}
                          className="w-[16rem] bg-background !text-[1.3rem]"
                          placeholder="작성자"
                        />
                      </FormControl>
                    </FormItem>
                  )}
                />
              )}
              {/* 비로그인 사용자를 위한 비밀번호 입력 필드 */}
              {!userInfo?.userUnqId && (
                <FormField
                  control={form.control}
                  name="cmntPswd"
                  render={({ field }) => (
                    <FormItem>
                      <FormControl>
                        <Input
                          {...field}
                          type="password"
                          value={field.value ?? ""}
                          className="w-[12rem] bg-background !text-[1.3rem]"
                          placeholder="비밀번호"
                        />
                      </FormControl>
                    </FormItem>
                  )}
                />
              )}
            </div>
            <Button type="submit">등록</Button>
          </div>
        </form>
      </Form>
      
      {/* 댓글 목록 */}
      <div className="mt-8 flex flex-col">
        {comments?.content.map((comment) => {
          if (comment.delYn === "Y") return null;
          return (
            <div
              className="flex justify-between gap-2 border-t border-gray-200 py-8"
              key={comment.cmntNo}
            >
              {/* 댓글 정보 표시 */}
              <div className="flex w-full gap-10">
                <div className="flex w-[10rem] shrink-0 flex-col">
                  <span className="text-[1.4rem] font-bold text-label">
                    {comment.wrtrNm}
                  </span>
                  <span className="text-[1.2rem] text-label">
                    {dayjs(comment.regDt).format("YYYY.MM.DD")}
                  </span>
                </div>
                
                {/* 수정 모드일 때 */}
                {editingCmntNo === comment.cmntNo ? (
                  <div className="flex flex-1 flex-col gap-2">
                    <Textarea
                      value={editValue}
                      onChange={(e) => setEditValue(e.target.value)}
                      className="h-[10rem] bg-background p-4 !text-[1.3rem] !leading-6"
                    />
                    <div className="flex justify-end gap-2">
                      <Button
                        onClick={() => {
                          setEditingCmntNo(null);
                          setEditValue("");
                        }}
                      >
                        취소
                      </Button>
                      <Button
                        onClick={() =>
                          handleUpdateComment(comment.cmntNo, comment.wrtrNm)
                        }
                      >
                        저장
                      </Button>
                    </div>
                  </div>
                ) : (
                  /* 일반 모드일 때 */
                  <div className="flex flex-1 flex-col">
                    <p className="whitespace-pre-wrap text-[1.4rem] text-label">
                      {comment.cmntCn}
                    </p>
                  </div>
                )}
              </div>
              
              {/* 수정/삭제 버튼 */}
              {editingCmntNo !== comment.cmntNo && (
                <div className="flex gap-2">
                  <Button
                    variant="outline"
                    onClick={() => handleEdit(comment.cmntNo, comment.cmntCn)}
                  >
                    수정
                  </Button>
                  <Button
                    variant="outline"
                    color="red"
                    onClick={() => handleDeleteComment(comment.cmntNo)}
                  >
                    삭제
                  </Button>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

로그인/비로그인 사용자 처리

로그인 사용자 처리

로그인 사용자의 경우 useUserStore에서 사용자 정보를 가져와 자동으로 설정합니다.

const { userInfo } = useUserStore((state) => state);

const form = useForm<CommentFormData>({
  resolver: zodResolver(commentFormSchema),
  values: {
    pstNo,
    cmntCn: "",
    wrtrNm: userInfo?.userName ?? "",
    cmntPswd: userInfo?.userUnqId ?? "",
  },
});

비로그인 사용자 처리

비로그인 사용자의 경우 작성자명과 비밀번호를 직접 입력받습니다.

{!userInfo?.userName && (
  <FormField
    control={form.control}
    name="wrtrNm"
    render={({ field }) => (
      <FormItem>
        <FormControl>
          <Input
            {...field}
            value={field.value ?? ""}
            className="w-[16rem] bg-background !text-[1.3rem]"
            placeholder="작성자"
          />
        </FormControl>
      </FormItem>
    )}
  />
)}

{!userInfo?.userUnqId && (
  <FormField
    control={form.control}
    name="cmntPswd"
    render={({ field }) => (
      <FormItem>
        <FormControl>
          <Input
            {...field}
            type="password"
            value={field.value ?? ""}
            className="w-[12rem] bg-background !text-[1.3rem]"
            placeholder="비밀번호"
          />
        </FormControl>
      </FormItem>
    )}
  />
)}

비밀번호 검증 로직

댓글 수정/삭제 시 비밀번호 검증을 위한 로직이 구현되어 있습니다.

const getCommentPassword = async () => {
  let cmntPswd = "";
  if (userInfo?.userUnqId) {
    // 로그인 사용자의 경우 사용자 ID를 비밀번호로 사용
    cmntPswd = userInfo?.userUnqId;
  } else {
    // 비로그인 사용자의 경우 프롬프트를 통해 비밀번호 입력 요청
    const { resolved, value } = await prompt("비밀번호를 입력해주세요.");
    if (resolved) cmntPswd = value ?? "";
  }
  return cmntPswd;
};

아키텍처 특징

  1. 계층 분리: API, 모델, UI 컴포넌트가 명확히 분리되어 있습니다.
  2. 타입 안전성: TypeScript 인터페이스와 Zod 스키마를 통한 타입 검증을 제공합니다.
  3. 상태 관리: React Query를 통한 서버 상태 관리와 Zustand를 통한 클라이언트 상태 관리를 제공합니다.
  4. 유효성 검증: Zod 스키마를 사용하여 폼 데이터의 유효성을 검증합니다.
  5. 사용자 경험: 로그인/비로그인 사용자에 대한 차별화된 UI 제공합니다.
  6. 비동기 처리: React Query의 useMutation을 통한 효율적인 비동기 작업 처리를 제공합니다.

확장 가능성

  1. 댓글 계층 구조: 대댓글 기능 추가
  2. 댓글 좋아요/싫어요: 댓글 평가 기능 강화
  3. 댓글 신고: 부적절한 댓글 신고 기능 추가
  4. 댓글 알림: 댓글 등록 시 알림 기능 추가
  5. 댓글 정렬: 최신순, 인기순 등 다양한 정렬 옵션 제공
  6. 댓글 검색: 댓글 내용 검색 기능 추가

주의사항

  1. 비로그인 사용자의 댓글 비밀번호는 클라이언트 측에서만 검증되므로 보안에 주의가 필요합니다.
  2. 댓글 삭제 시 실제로 데이터베이스에서 삭제하지 않고 delYn 필드를 'Y'로 설정하는 소프트 삭제 방식을 사용합니다.
  3. 댓글 내용에 HTML 태그나 스크립트가 포함될 경우 XSS 공격 가능성이 있으므로 서버 측에서 적절한 필터링이 필요합니다.