From f1fe697b9c8c0969771179062f202f7b39b5fa14 Mon Sep 17 00:00:00 2001 From: changwng Date: Thu, 26 Jun 2025 18:02:28 +0900 Subject: [PATCH] =?UTF-8?q?[docker=20build]=20=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=90=EA=B2=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/approute.md | 232 ++++++++++++++ docs/bbs/comment-archi.md | 650 ++++++++++++++++++++++++++++++++++++++ docs/bbs/post-archi.md | 480 ++++++++++++++++++++++++++++ docs/debugfrontend.md | 96 ++++++ docs/plan.md | 214 +++++++++++++ docs/requirements.md | 78 +++++ 6 files changed, 1750 insertions(+) create mode 100644 docs/approute.md create mode 100644 docs/bbs/comment-archi.md create mode 100644 docs/bbs/post-archi.md create mode 100644 docs/debugfrontend.md create mode 100644 docs/plan.md create mode 100644 docs/requirements.md diff --git a/docs/approute.md b/docs/approute.md new file mode 100644 index 000000000..c327c0c04 --- /dev/null +++ b/docs/approute.md @@ -0,0 +1,232 @@ +# Next.js App Router의 라우트 그룹(Route Groups) 구현 분석 + +## 개요 + +Next.js의 App Router에서는 괄호로 묶인 디렉토리(`(with-base-layout)`, `(without-base-layout)`)를 라우트 그룹으로 처리하여 URL 경로에 영향을 주지 않으면서 서로 다른 레이아웃을 적용할 수 있게 합니다. 이 문서에서는 프로젝트에서 라우트 그룹이 어떻게 구현되고 사용되는지 분석합니다. + +## 라우트 그룹 구조 분석 + +### 1. 디렉토리 구조 + +프로젝트에서는 두 가지 주요 라우트 그룹이 사용되고 있습니다: + +``` +/apps/cs/app/ +├── (with-base-layout)/ +│ ├── banner/ +│ ├── bbs/ +│ ├── code/ +│ ├── contents/ +│ ├── dashboard/ +│ ├── ... +│ ├── layout.tsx +│ └── loading.tsx +├── (without-base-layout)/ +│ ├── user-survey-article/ +│ └── user-survey-result/ +└── layout.tsx +``` + +### 2. 라우트 그룹별 레이아웃 구현 + +#### 루트 레이아웃 (`/apps/cs/app/layout.tsx`) + +```tsx +import type { Metadata } from "next"; +import { App } from "@/app"; + +export const metadata: Metadata = { + title: "유역하수도 통합 정보플랫폼", +}; + +export default App; +``` + +#### App 컴포넌트 (`/apps/cs/src/app/index.tsx`) + +```tsx +import "./styles/base.css"; +import "./styles/components.css"; +import "@fontsource-variable/noto-sans-kr"; + +import { AppProvider } from "./providers"; +import { cookies } from "next/headers"; + +const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +}; + +export { App }; +``` + +#### (with-base-layout) 레이아웃 (`/apps/cs/app/(with-base-layout)/layout.tsx`) + +```tsx +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { BaseLayout } from "@common/components"; +import type { MenuItem } from "@/entities/menu"; +import { useMenuStore } from "@/shared/lib"; +import { PageLayout } from "@/shared/ui/page-layout"; + +export default function WithBaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + + const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); + + const handleMenuClick = (item: MenuItem) => { + setCurrentMenuInfo(item); + router.push(item.urlPath); + }; + + return ( + + {children} + + ); +} +``` + +#### PageLayout 컴포넌트 (`/apps/cs/src/shared/ui/page-layout.tsx`) + +```tsx +import { PageTitle } from "@common/components"; +import { useMenuStore } from "../lib/use-menu-store"; + +function PageLayout({ children }: { children: React.ReactNode }) { + const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo); + + const menuPaths = currentMenuInfo?.menuPath + ?.split(">") + .map((path) => path.trim()); + + return ( +
+ + {children} +
+ ); +} +``` + +## 라우트 그룹의 작동 원리 + +### 1. URL 경로에 영향을 주지 않는 그룹화 + +Next.js App Router에서 괄호로 묶인 디렉토리는 URL 경로에 포함되지 않습니다. 예를 들어: + +- `/bbs/register` 경로는 `(with-base-layout)/bbs/register` 디렉토리에 매핑됩니다. +- `/user-survey-article/1` 경로는 `(without-base-layout)/user-survey-article/[srvySn]` 디렉토리에 매핑됩니다. + +### 2. 레이아웃 분리 및 재사용 + +라우트 그룹을 사용하면 서로 다른 레이아웃을 가진 페이지들을 효율적으로 구성할 수 있습니다: + +- `(with-base-layout)` 그룹: 메뉴, 헤더, 사이드바 등을 포함하는 `BaseLayout`과 `PageLayout`을 사용합니다. +- `(without-base-layout)` 그룹: 기본 레이아웃 없이 페이지 컴포넌트만 렌더링합니다. + +### 3. 중첩 라우트 그룹 + +프로젝트에서는 중첩된 라우트 그룹도 사용되고 있습니다: + +``` +(with-base-layout)/bbs/(post)/bbsnormal/ +``` + +여기서 `(post)`는 게시물 관련 페이지들을 그룹화하는 중첩된 라우트 그룹입니다. + +## 라우트 그룹 사용 사례 + +### 1. 관리자 인터페이스 (`with-base-layout`) + +관리자 인터페이스는 일관된 레이아웃과 네비게이션이 필요합니다: + +```tsx +// (with-base-layout)/layout.tsx +return ( + + {children} + +); +``` + +이 레이아웃은 다음과 같은 기능을 제공합니다: +- 메뉴 시스템을 통한 페이지 간 이동 +- 현재 페이지 경로 표시 +- 일관된 페이지 타이틀 및 구조 + +### 2. 독립적인 페이지 (`without-base-layout`) + +설문 기사나 결과 페이지와 같은 독립적인 페이지는 기본 레이아웃 없이 콘텐츠에 집중합니다: + +```tsx +// (without-base-layout)/user-survey-article/[srvySn]/page.tsx +export default async function UserSurveyArticlePage({ + params, +}: { + params: Promise<{ srvySn: string }>; +}) { + const { srvySn } = await params; + return ; +} +``` + +이러한 페이지는: +- 외부 링크나 팝업으로 열리는 경우가 많음 +- 독립적인 기능을 제공(예: 설문 제출 후 창 닫기) +- 메인 애플리케이션 UI와 분리된 경험 제공 + +## 두 라우트 그룹의 주요 차이점 + +### (with-base-layout) +- `BaseLayout` 컴포넌트를 포함하는 레이아웃을 사용합니다. +- 이 레이아웃은 메뉴, 헤더, 사이드바 등 기본 UI 요소를 포함합니다. +- `PageLayout` 컴포넌트로 감싸져 있어 일관된 페이지 구조를 제공합니다. +- 주로 관리자 페이지나 로그인 후 사용하는 내부 페이지에 사용됩니다. +- 다양한 관리 기능을 포함하는 여러 하위 디렉토리가 있습니다. + +### (without-base-layout) +- 기본 레이아웃 없이 페이지 컴포넌트만 렌더링합니다. +- 메뉴, 헤더, 사이드바 등의 UI 요소가 없는 독립적인 페이지입니다. +- 주로 외부 사용자용 페이지나 팝업, 임베드 페이지에 사용됩니다. +- 현재 두 가지 특수 목적 페이지만 포함하고 있습니다. + +## 기술적 이점 + +1. **코드 구성 개선**: 관련 페이지와 레이아웃을 논리적으로 그룹화할 수 있습니다. +2. **레이아웃 재사용**: 공통 레이아웃을 여러 페이지에서 재사용할 수 있습니다. +3. **URL 구조 단순화**: 그룹 이름이 URL에 포함되지 않아 깔끔한 URL 구조를 유지할 수 있습니다. +4. **유지보수성 향상**: 관련 기능을 함께 그룹화하여 코드 탐색과 유지보수가 용이합니다. + +## 결론 + +Next.js App Router의 라우트 그룹 기능은 프로젝트에서 다양한 레이아웃 요구사항을 효율적으로 관리하는 데 중요한 역할을 합니다. `(with-base-layout)`과 `(without-base-layout)` 그룹을 통해 관리자 인터페이스와 독립적인 페이지를 명확하게 분리하면서도 코드 재사용성과 유지보수성을 높일 수 있습니다. + +이 구조는 확장성이 뛰어나 새로운 페이지나 기능을 추가할 때 기존 패턴을 따르기 쉽고, 애플리케이션이 성장함에 따라 필요한 경우 추가 라우트 그룹을 도입할 수 있는 유연성을 제공합니다. diff --git a/docs/bbs/comment-archi.md b/docs/bbs/comment-archi.md new file mode 100644 index 000000000..852e77568 --- /dev/null +++ b/docs/bbs/comment-archi.md @@ -0,0 +1,650 @@ +# 게시물 댓글(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; +``` + +### 댓글 수정 폼 데이터(CommentUpdateFormData) 인터페이스 + +```typescript +export const commentUpdateFormSchema = commentFormSchema.omit({ + pstNo: true, // 수정 시에는 게시물 번호가 필요 없음 +}); + +export type CommentUpdateFormData = z.infer; +``` + +## API 기능 + +댓글 관리 API는 다음과 같은 기능을 제공합니다: + +### 1. 댓글 목록 조회 + +```typescript +getComments: async (params: CommentParams): Promise => { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`, + { params }, + ); + return response.data; +} +``` + +- **설명**: 특정 게시물의 댓글 목록을 페이지네이션과 함께 조회합니다. +- **파라미터**: `CommentParams` (게시물 번호, 검색 조건) +- **반환값**: 페이지네이션된 댓글 목록 (`CommentList`) + +### 2. 댓글 등록 + +```typescript +createComment: async (data: CommentFormData): Promise => { + const validatedData = commentFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.BOARD_SERVICE.POSTS_COMMENTS}`, + validatedData, + ); + return response.data; +} +``` + +- **설명**: 새로운 댓글을 등록합니다. +- **파라미터**: `CommentFormData` (댓글 등록 데이터) +- **반환값**: 등록된 댓글 정보 (`Comment`) + +### 3. 댓글 수정 + +```typescript +updateComment: async ( + bbsId: number, + pstNo: number, + cmntNo: number, + data: CommentUpdateFormData, +): Promise => { + const validatedData = commentUpdateFormSchema.parse(data); + const response = await client.put( + `${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 => { + const response = await client.delete( + `${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 ( +
+ {/* 댓글 내용 표시 */} +
+ ); + })} + ``` + +### 2. 댓글 등록 흐름 + +1. 사용자가 댓글 내용 입력 및 등록 버튼 클릭 +2. React Hook Form을 통해 폼 데이터 유효성 검증 + ```typescript + const form = useForm({ + 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(null); + const [editValue, setEditValue] = useState(""); + + // 댓글 목록 조회 + 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({ + 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 ( +
+ {/* 댓글 등록 폼 */} +
+ + {/* 댓글 내용 입력 필드 */} + ( + + +