From a06e4f46cf05769c8e04e145e08c3d3f6bf1ce1c Mon Sep 17 00:00:00 2001 From: jangwoong Date: Tue, 20 Oct 2026 15:39:49 +0900 Subject: [PATCH] =?UTF-8?q?[as=20fr]=20doc=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EB=B0=A9=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/docs/pm2/01.pm2-deploy-and-control.md | 201 ++++++++++++++++++ .../pm2/02.build-error-troubleshooting.md | 166 +++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 apps/as/docs/pm2/01.pm2-deploy-and-control.md create mode 100644 apps/as/docs/pm2/02.build-error-troubleshooting.md diff --git a/apps/as/docs/pm2/01.pm2-deploy-and-control.md b/apps/as/docs/pm2/01.pm2-deploy-and-control.md new file mode 100644 index 000000000..e2f8a68a4 --- /dev/null +++ b/apps/as/docs/pm2/01.pm2-deploy-and-control.md @@ -0,0 +1,201 @@ +# PM2 배포 및 프로세스 관리 가이드 + +본 문서는 ONA 프론트엔드 모노레포 프로젝트(`frontend_as`)에서 **PM2를 활용한 애플리케이션 배포, 실행, 중지 및 모니터링 명령**을 체계적으로 정리한 가이드입니다. + +--- + +## 1. 개요 및 아키텍처 + +- **설정 파일**: 프로젝트 루트의 `ecosystem.config.js` +- **배포 워크플로우**: `.gitea/workflows/deploy.yml` (Gitea Actions 자동 배포) +- **실행 모드**: `cluster` (2개 인스턴스로 로드밸런싱 및 무중단 롤링 배포 지원) +- **운영 대상**: + - `as2-app` (또는 `as-app`, `cs-app`, `st-app`, `cm-app` 등) + +--- + +## 2. CI/CD 자동 배포 흐름 + +Gitea 저장소의 `deploy` 브랜치로 푸시될 때 원격 서버(192.168.0.4)에서 다음 스크립트가 순차 실행됩니다: + +```bash +cd ~/frontend +git restore . # 변경된 로컬 파일 원복 +git clean -fd # 추적되지 않는 불필요한 파일/디렉터리 정리 +git pull origin deploy # 최신 deploy 브랜치 코드 반영 +pnpm install # 의존성 패키지 설치 +pnpm turbo build # 프로덕션 빌드 (.next 산출물 생성) +pm2 reload all # 무중단 서비스 리로드 (클러스터 롤링 재시작) +``` + +--- + +## 3. 수동 배포 (Manual Deploy) 명령어 + +서버 터미널에 직접 접속하여 수동으로 최신 코드를 배포할 때 사용하는 명령어입니다. + +### 3.1 전체 애플리케이션 수동 배포 +```bash +# 1. 프로젝트 루트 이동 +cd ~/frontend + +# 2. 최신 코드 가져오기 +git pull origin deploy + +# 3. 의존성 설치 및 전체 빌드 +pnpm install +pnpm turbo build + +# 4. PM2 전체 무중단 리로드 +pm2 reload all +``` + +### 3.2 `as` (행정지원) 앱만 단독 빌드 및 배포 +```bash +cd ~/frontend + +# as 앱만 타겟 빌드 +pnpm --filter as build + +# as2-app 프로세스만 무중단 리로드 +pm2 reload as2-app +``` + +--- + +## 4. PM2 실행 및 프로세스 등록 (Start & Save) + +최초 서버 환경 구성 시 또는 프로세스 목록에서 삭제된 후 새롭게 등록할 때 실행합니다. + +```bash +# 1. 로그 디렉터리 사전 생성 (필수) +mkdir -p apps/as/logs + +# 2. ecosystem.config.js 기반 전체 앱 최초 실행 +pm2 start ecosystem.config.js + +# 3. 특정 앱(as2-app)만 지정하여 최초 실행 +pm2 start ecosystem.config.js --only as2-app + +# 4. 서버 재부팅 시에도 PM2 프로세스가 자동 복구되도록 현재 상태 저장 (필수!) +pm2 save + +# 5. OS 부팅 시 PM2 데몬 자동 등록 (최초 1회 설정) +pm2 startup +``` + +--- + +## 5. 중지, 재시작 및 삭제 명령어 + +### 5.1 재시작 (Restart vs Reload) +> **TIP: 운영 환경에서는 항상 `reload`를 권장합니다.** +> - `reload`: 0-second downtime(무중단). 클러스터의 인스턴스를 하나씩 순차적으로 재시작하여 사용자 요청이 끊기지 않음. +> - `restart`: 모든 인스턴스를 일시에 강제 종료 후 다시 띄우므로 짧은 다운타임 발생. + +```bash +# [권장] 무중단 재로드 (운영 배포 시) +pm2 reload as2-app +pm2 reload all + +# 강제 재시작 (설정 변경이 반영되지 않거나 프로세스가 비정상일 때) +pm2 restart as2-app +pm2 restart all +``` + +### 5.2 프로세스 중지 (Stop) +프로세스를 메모리에서 내리고 실행을 일시 중단합니다 (PM2 목록에는 유지됨). + +```bash +# 특정 앱 중지 +pm2 stop as2-app + +# 전체 앱 중지 +pm2 stop all +``` + +### 5.3 프로세스 목록에서 제거 (Delete) +PM2 관리 대상 목록에서 프로세스를 완전히 삭제합니다. + +```bash +# 특정 앱 삭제 +pm2 delete as2-app + +# 전체 앱 삭제 +pm2 delete all + +# 삭제 상태를 영구 저장할 경우 +pm2 save +``` + +--- + +## 6. 상태 모니터링 및 로그 확인 + +### 6.1 프로세스 상태 확인 +```bash +# 간략한 프로세스 상태 표 확인 (online, stopped, cpu, memory 등) +pm2 status +# 또는 +pm2 list + +# 특정 앱 상세 정보 확인 (환경변수, 파일 경로, 재시작 횟수 등) +pm2 show as2-app +``` + +### 6.2 실시간 로그 스트리밍 (Logs) +```bash +# 특정 앱 실시간 로그 확인 (표준 출력 + 에러 출력) +pm2 logs as2-app + +# 최근 100줄 출력 후 실시간 스트리밍 +pm2 logs as2-app --lines 100 + +# 에러 로그만 확인 +pm2 logs as2-app --err + +# 전체 프로세스 로그 확인 +pm2 logs + +# 이전 로그 비우기(Flush) +pm2 flush +``` + +### 6.3 터미널 대시보드 모니터링 (Monit) +CPU 및 메모리 실시간 사용량을 터미널 대시보드로 확인합니다. +```bash +pm2 monit +``` + +--- + +## 7. 주요 트러블슈팅 (FAQ) + +### Q1. `EADDRINUSE: address already in use :::3004` 포트 충돌 발생 +- **원인**: 로컬 개발 서버(`pnpm --filter as dev`) 또는 이전 Node 프로세스가 3004번 포트를 계속 점유 중인 상태입니다. +- **해결**: + ```bash + # 3004 포트 사용 중인 PID 확인 + lsof -i :3004 + + # 해당 프로세스 종료 + kill -9 + + # PM2 리로드 + pm2 reload as2-app + ``` + +### Q2. `Could not find a production build in the '.next' directory` +- **원인**: Next.js 프로덕션 실행 전에 빌드 산출물(`.next`)이 생성되지 않았습니다. +- **해결**: + ```bash + pnpm --filter as build + pm2 reload as2-app + ``` + +### Q3. `logs` 디렉터리 관련 에러 발생 +- **원인**: `ecosystem.config.js`의 `cwd: './apps/as'` 설정에 따라 `./logs` 경로가 없을 경우 발생할 수 있습니다. +- **해결**: + ```bash + mkdir -p apps/as/logs + ``` diff --git a/apps/as/docs/pm2/02.build-error-troubleshooting.md b/apps/as/docs/pm2/02.build-error-troubleshooting.md new file mode 100644 index 000000000..03e156ef5 --- /dev/null +++ b/apps/as/docs/pm2/02.build-error-troubleshooting.md @@ -0,0 +1,166 @@ +# Next.js 프로덕션 빌드 에러 원인 분석 및 조치 보고서 + +본 문서는 `apps/as` 프로덕션 빌드(`pnpm build:as` / `next build`) 과정에서 발생한 ESLint Hook 규칙 위반 및 TypeScript 컴파일 에러의 원인과 이를 해결한 조치 내역을 정리한 문서입니다. + +--- + +## 1. 개요 및 장애 상황 + +- **대상 모듈**: `apps/as` (행정지원 프론트엔드 애플리케이션) +- **실행 명령**: `pnpm build:as` (또는 `pnpm --filter as build`) +- **발생 시점**: 2026-10-20 +- **장애 영향**: Next.js 프로덕션 최적화 빌드 실패로 인해 배포 산출물(`.next`) 생성이 중단됨 + +### 빌드 실패 로그 요약 +```text +as:build: Failed to compile. + +as:build: ./src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx +as:build: 159:35 Error: React Hook "useStore" is called conditionally. React Hooks must be called in the exact same order in every component render. react-hooks/rules-of-hooks + +./src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx:164:5 +Type error: Type 'boolean' has no properties in common with type '{ enabled?: boolean | undefined; structuralSharing?: boolean | undefined; }'. + +./src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx:215:29 +Type error: Property 'atrzSeq' does not exist on type 'ElaprDocPrgrsClientResponseDto'. Did you mean 'atrzSeqNo'? +``` + +--- + +## 2. 근본 원인 분석 (Root Cause Analysis) + +### 2.1 React Hook 규칙 위반 (`react-hooks/rules-of-hooks`) +- **위치**: `apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx` (158~159라인) +- **원인 코드**: + ```tsx + const storeContext = useContext(StoreContext); + const userInfo = storeContext ? useStore(storeContext.userStore, (state) => state.userInfo) : null; + ``` +- **문제점**: + - React Hooks는 조건문, 반복문, 삼항 연산자 내부에서 호출될 수 없으며, 모든 렌더링마다 항상 동일한 순서로 호출되어야 합니다. + - 삼항 연산자 내에서 `useStore`를 호출하면서 `react-hooks/rules-of-hooks` 에러가 발생하여 빌드가 즉시 차단되었습니다. + - 또한 프로젝트 전역에서 제공하는 표준 훅인 `useUserStore`(`@/shared/lib`)가 이미 존재함에도 불구하고 이를 사용하지 않고 수동으로 컨텍스트를 조회하여 분기 처리하고 있었습니다. + +### 2.2 `useElaprBranchesByDocNo` 훅 옵션 매개변수 타입 불일치 +- **원인 코드**: + ```tsx + const { data: elaprBranchList = [] } = useElaprBranchesByDocNo( + fnrsCnsltnDocNo, + open && Boolean(isForkJoin), // boolean 직접 전달 + ); + ``` +- **문제점**: + - `useElaprBranchesByDocNo`의 시그니처는 `(elaprDocNo, options?: { enabled?: boolean; structuralSharing?: boolean })`로 선언되어 있어, `boolean` 대신 객체 형태(`{ enabled: ... }`)로 전달해야 합니다. + +### 2.3 전자결재 결재선 DTO 필드명 불일치 +- **원인 코드**: + ```tsx + if (currentUserPrgrs?.atrzSeq && Number(currentUserPrgrs.atrzSeq) > 1) return true; + ``` +- **문제점**: + - `ElaprDocPrgrsClientResponseDto` 인터페이스(`src/shared/model/pbadms-sprt.ts`)에 선언된 정식 결재 순번 필드명은 `atrzSeqNo?: number`입니다. + - 기존 코드가 `atrzSeq`로 접근하여 TypeScript 컴파일 에러가 발생했습니다. + +--- + +## 3. 조치 내역 + +### 3.1 컴포넌트 코드 수정 (`pwrk-cnsltn-doc-mng-dialog.tsx`) +1. **표준 `useUserStore` 훅 적용**: + - `useStore`, `StoreContext`, `useContext`를 제거하고, `@/shared/lib`의 `useUserStore`를 무조건부로 호출하도록 변경. +2. **`useElaprBranchesByDocNo` 옵션 객체화**: + - `{ enabled: open && Boolean(isForkJoin) }` 형태로 수정. +3. **결재 순번 속성 방어적 매핑**: + - `atrzSeqNo`를 우선 참조하고 레거시/모의 데이터의 `atrzSeq`도 호환되도록 fallback 처리. + +#### 핵심 변경 사항 (Diff) +```diff +--- a/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx ++++ b/apps/as/src/pages/fnrs-cnsltn/ui/components/pwrk-cnsltn-doc-mng-dialog.tsx +@@ -1,14 +1,11 @@ + "use client"; + +-import { useContext, useEffect, useMemo, useState } from "react"; ++import { useEffect, useMemo, useState } from "react"; + import dynamic from "next/dynamic"; + + import { useQuery, useQueryClient } from "@tanstack/react-query"; + import { PanelRightClose, PanelRightOpen } from "lucide-react"; +-import { useStore } from "zustand"; +- +-import { getDefaultFileKey, useAlertStore, useTus } from "@/shared/lib"; +-import { StoreContext } from "@/shared/providers"; ++import { getDefaultFileKey, useAlertStore, useTus, useUserStore } from "@/shared/lib"; + +@@ -155,8 +152,7 @@ function PwrkCnsltnDocMngDialog({ + const queryClient = useQueryClient(); + const alert = useAlertStore((state) => state.setMessage); +- const storeContext = useContext(StoreContext); +- const userInfo = storeContext ? useStore(storeContext.userStore, (state) => state.userInfo) : null; ++ const userInfo = useUserStore((state) => state.userInfo); + +@@ -165,7 +161,7 @@ function PwrkCnsltnDocMngDialog({ + const { data: elaprBranchList = [] } = useElaprBranchesByDocNo( + fnrsCnsltnDocNo, +- open && Boolean(isForkJoin), ++ { enabled: open && Boolean(isForkJoin) }, + ); + +@@ -216,11 +212,19 @@ function PwrkCnsltnDocMngDialog({ + if (effectiveBranchSeCd === "TOTAL_AGG") { + if (effectiveAtrzSeList.includes("ATRZ04")) return false; + if (effectiveAtrzSeList.some((cd) => APPROVER_ATRZ_SE_COM_CDS.has(cd))) return true; +- if (currentUserPrgrs?.atrzSeq && Number(currentUserPrgrs.atrzSeq) > 1) return true; ++ const userSeq = currentUserPrgrs?.atrzSeqNo ?? (currentUserPrgrs as Record | undefined)?.atrzSeq; ++ if (userSeq && Number(userSeq) > 1) return true; + if (effectiveAtrzSeList.length > 0 && !effectiveAtrzSeList.includes("ATRZ04")) return true; + } + return false; +``` + +### 3.2 단위 테스트 모킹 보완 (`pwrk-cnsltn-doc-mng-dialog.test.tsx`) +- 단위 테스트 환경에서 `StoreProvider` 미감싸임 에러(`useUserStore must be used within StoreProvider`)가 발생하지 않도록 `@/shared/lib` 모킹 객체에 `useUserStore`를 추가. +```tsx +let mockUserInfo: any = null; + +vi.mock("@/shared/lib", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDefaultFileKey: () => "mock-key", + useAlertStore: () => vi.fn(), + useUserStore: (selector: (state: { userInfo: any }) => any) => + selector ? selector({ userInfo: mockUserInfo }) : { userInfo: mockUserInfo }, + useTus: () => ({ + uploadFileAsync: vi.fn(), + isUploading: {}, + uploads: {}, + }), + }; +}); +``` + +--- + +## 4. 검증 결과 + +| 검증 단계 | 명령어 | 결과 | 비고 | +|---|---|---|---| +| **1. 정적 타입 검사** | `pnpm --filter as exec tsc --noEmit` | **성공 (Exit 0)** | 컴파일 에러 0건 | +| **2. ESLint 린트 검사** | `pnpm --filter as lint` | **성공 (Exit 0)** | Hook 위반 에러 해결 | +| **3. 단위 테스트 (Vitest)** | `pnpm --filter as exec vitest run` | **전체 통과 (73/73 Tests)** | 14개 테스트 파일 100% Pass | +| **4. 프로덕션 빌드** | `pnpm build:as` | **성공 (Exit 0)** | 22/22 정적 페이지 최적화 완료 | + +--- + +## 5. 재발 방지 표준 가이드 + +1. **React Hook 호출 규칙 준수**: + - `useUserStore`, `useQuery`, `useStore` 등 모든 React Hook은 반드시 컴포넌트 함수의 최상단(Top-level)에서 무조건부로 호출해야 합니다. + - 사용자 정보가 필요할 경우 개별 컨텍스트 접근 대신 표준 훅인 `useUserStore((state) => state.userInfo)`를 사용합니다. + +2. **React Query 커스텀 훅 매개변수 규칙**: + - 조건부 쿼리 실행이 필요한 경우 boolean 값을 단독으로 넘기지 않고 `{ enabled: boolean }` 형태의 옵션 객체 인터페이스를 유지합니다. + +3. **엔티티 DTO 필드명 엄격 확인**: + - 전자결재 결재선 DTO는 백엔드 명세에 따라 `atrzSeqNo`를 표준으로 사용하므로 레거시 명칭(`atrzSeq`)을 사용하지 않도록 타입을 확인합니다.