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

651 lines
20 KiB
Markdown

# 게시물 댓글(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) 인터페이스
```typescript
export interface Comment {
cmntEvl: number; // 댓글 평가 점수
pstNo: number; // 게시물 번호
cmntNo: number; // 댓글 번호
cmntCn: string; // 댓글 내용
wrtrNm: string; // 작성자명
regDt: string; // 등록일시
rgtr: string; // 등록자
mdfcnDt: string; // 수정일시
mdfr: string; // 수정자
delYn: string; // 삭제 여부
}
```
### 댓글 검색 파라미터(CommentParams) 인터페이스
```typescript
export interface CommentParams extends PageableParams {
keywordType?: string; // 검색 키워드 유형
keyword?: string; // 검색 키워드
pstNo: number; // 게시물 번호
delYn?: string; // 삭제 여부
}
```
### 댓글 폼 데이터(CommentFormData) 인터페이스
Zod 스키마를 사용하여 폼 데이터의 유효성 검증을 수행합니다.
```typescript
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) 인터페이스
```typescript
export const commentUpdateFormSchema = commentFormSchema.omit({
pstNo: true, // 수정 시에는 게시물 번호가 필요 없음
});
export type CommentUpdateFormData = z.infer<typeof commentUpdateFormSchema>;
```
## API 기능
댓글 관리 API는 다음과 같은 기능을 제공합니다:
### 1. 댓글 목록 조회
```typescript
getComments: async (params: CommentParams): Promise<CommentList> => {
const response = await client.get<CommentList>(
`${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`,
{ params },
);
return response.data;
}
```
- **설명**: 특정 게시물의 댓글 목록을 페이지네이션과 함께 조회합니다.
- **파라미터**: `CommentParams` (게시물 번호, 검색 조건)
- **반환값**: 페이지네이션된 댓글 목록 (`CommentList`)
### 2. 댓글 등록
```typescript
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. 댓글 수정
```typescript
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. 댓글 삭제
```typescript
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 호출
```typescript
const { data: comments } = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS, pstNo],
queryFn: () => CommentApi.getComments({ pstNo }),
enabled: !!pstNo,
});
```
3. 응답 데이터를 UI에 표시
```tsx
{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을 통해 폼 데이터 유효성 검증
```typescript
const form = useForm<CommentFormData>({
resolver: zodResolver(commentFormSchema),
values: {
pstNo,
cmntCn: "",
wrtrNm: userInfo?.userName ?? "",
cmntPswd: userInfo?.userUnqId ?? "",
},
});
```
3. 유효성 검증 통과 시 `onSubmit` 함수 호출
```typescript
const onSubmit = (data: CommentFormData) => {
createComment(data);
};
```
4. React Query의 `useMutation` 훅을 통해 `createComment` API 호출
```typescript
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. 수정 모드 활성화 및 기존 댓글 내용 로드
```typescript
const handleEdit = (cmntNo: number, cmntCn: string) => {
setEditingCmntNo(cmntNo);
setEditValue(cmntCn);
};
```
3. 사용자가 댓글 내용 수정 후 저장 버튼 클릭
4. 확인 다이얼로그 표시 및 비밀번호 확인
```typescript
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. 확인 다이얼로그 표시 및 비밀번호 확인
```typescript
const handleDeleteComment = async (cmntNo: number) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
const cmntPswd = await getCommentPassword();
deleteComment({ cmntNo, cmntPswd });
};
```
3. React Query의 `useMutation` 훅을 통해 `deleteComment` API 호출
4. 성공 시 알림 표시 및 댓글 목록 새로고침
## 주요 컴포넌트
### PostComment 컴포넌트
게시물 댓글을 표시하고 관리하는 컴포넌트입니다.
```tsx
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`에서 사용자 정보를 가져와 자동으로 설정합니다.
```typescript
const { userInfo } = useUserStore((state) => state);
const form = useForm<CommentFormData>({
resolver: zodResolver(commentFormSchema),
values: {
pstNo,
cmntCn: "",
wrtrNm: userInfo?.userName ?? "",
cmntPswd: userInfo?.userUnqId ?? "",
},
});
```
### 비로그인 사용자 처리
비로그인 사용자의 경우 작성자명과 비밀번호를 직접 입력받습니다.
```tsx
{!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>
)}
/>
)}
```
## 비밀번호 검증 로직
댓글 수정/삭제 시 비밀번호 검증을 위한 로직이 구현되어 있습니다.
```typescript
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 공격 가능성이 있으므로 서버 측에서 적절한 필터링이 필요합니다.