# 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 컴파일 에러가 발생했습니다. ### 2.4 `PwrkCnsltnDocEvlDialog`의 필수 prop `onSaved` 누락 - **원인 코드**: ```tsx setIsEvlDialogOpen(false)} /> ``` - **문제점**: - `PwrkCnsltnDocEvlDialogProps`에 `onSaved: () => void;`가 필수로 선언되어 있었으나, 호출부에서 `onSaved` 콜백을 넘기지 않아 `Property 'onSaved' is missing` 컴파일 에러가 발생했습니다. --- ## 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 처리. 4. **`PwrkCnsltnDocEvlDialog`의 `onSaved` prop 연결 및 선택적(Optional) 처리**: - `pwrk-cnsltn-doc-evl-dialog.tsx`: `onSaved?: () => void;`로 변경하고 `onSaved?.()` 호출로 방어. - `pwrk-cnsltn-doc-mng-dialog.tsx`: ` setIsEvlDialogOpen(false)} />` 콜백 전달. #### 핵심 변경 사항 (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`)을 사용하지 않도록 타입을 확인합니다.