20 KiB
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. 댓글 목록 조회 흐름
- 게시물 상세 페이지에서
PostComment컴포넌트 로드 - React Query를 사용하여
getCommentsAPI 호출const { data: comments } = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo], queryFn: () => CommentApi.getComments({ pstNo }), enabled: !!pstNo, }); - 응답 데이터를 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. 댓글 등록 흐름
- 사용자가 댓글 내용 입력 및 등록 버튼 클릭
- React Hook Form을 통해 폼 데이터 유효성 검증
const form = useForm<CommentFormData>({ resolver: zodResolver(commentFormSchema), values: { pstNo, cmntCn: "", wrtrNm: userInfo?.userName ?? "", cmntPswd: userInfo?.userUnqId ?? "", }, }); - 유효성 검증 통과 시
onSubmit함수 호출const onSubmit = (data: CommentFormData) => { createComment(data); }; - React Query의
useMutation훅을 통해createCommentAPI 호출const { mutate: createComment } = useMutation({ mutationFn: (data: CommentFormData) => CommentApi.createComment(data), onSuccess: () => { alert("등록되었습니다."); form.setValue("cmntCn", ""); queryClient.invalidateQueries({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS], }); }, }); - 성공 시 알림 표시 및 댓글 목록 새로고침
3. 댓글 수정 흐름
- 사용자가 댓글 수정 버튼 클릭
- 수정 모드 활성화 및 기존 댓글 내용 로드
const handleEdit = (cmntNo: number, cmntCn: string) => { setEditingCmntNo(cmntNo); setEditValue(cmntCn); }; - 사용자가 댓글 내용 수정 후 저장 버튼 클릭
- 확인 다이얼로그 표시 및 비밀번호 확인
const handleUpdateComment = async (cmntNo: number, wrtrNm: string) => { if (!(await confirm("수정하시겠습니까?"))) return; const cmntPswd = await getCommentPassword(); updateComment({ cmntNo, data: { cmntCn: editValue, wrtrNm, cmntPswd, }, }); }; - React Query의
useMutation훅을 통해updateCommentAPI 호출 - 성공 시 알림 표시 및 댓글 목록 새로고침
4. 댓글 삭제 흐름
- 사용자가 댓글 삭제 버튼 클릭
- 확인 다이얼로그 표시 및 비밀번호 확인
const handleDeleteComment = async (cmntNo: number) => { if (!(await confirm("삭제하시겠습니까?"))) return; const cmntPswd = await getCommentPassword(); deleteComment({ cmntNo, cmntPswd }); }; - React Query의
useMutation훅을 통해deleteCommentAPI 호출 - 성공 시 알림 표시 및 댓글 목록 새로고침
주요 컴포넌트
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;
};
아키텍처 특징
- 계층 분리: API, 모델, UI 컴포넌트가 명확히 분리되어 있습니다.
- 타입 안전성: TypeScript 인터페이스와 Zod 스키마를 통한 타입 검증을 제공합니다.
- 상태 관리: React Query를 통한 서버 상태 관리와 Zustand를 통한 클라이언트 상태 관리를 제공합니다.
- 유효성 검증: Zod 스키마를 사용하여 폼 데이터의 유효성을 검증합니다.
- 사용자 경험: 로그인/비로그인 사용자에 대한 차별화된 UI 제공합니다.
- 비동기 처리: React Query의
useMutation을 통한 효율적인 비동기 작업 처리를 제공합니다.
확장 가능성
- 댓글 계층 구조: 대댓글 기능 추가
- 댓글 좋아요/싫어요: 댓글 평가 기능 강화
- 댓글 신고: 부적절한 댓글 신고 기능 추가
- 댓글 알림: 댓글 등록 시 알림 기능 추가
- 댓글 정렬: 최신순, 인기순 등 다양한 정렬 옵션 제공
- 댓글 검색: 댓글 내용 검색 기능 추가
주의사항
- 비로그인 사용자의 댓글 비밀번호는 클라이언트 측에서만 검증되므로 보안에 주의가 필요합니다.
- 댓글 삭제 시 실제로 데이터베이스에서 삭제하지 않고
delYn필드를 'Y'로 설정하는 소프트 삭제 방식을 사용합니다. - 댓글 내용에 HTML 태그나 스크립트가 포함될 경우 XSS 공격 가능성이 있으므로 서버 측에서 적절한 필터링이 필요합니다.