Files
frontend/apps/as/docs/pm2/02.build-error-troubleshooting.md
T

8.8 KiB

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) 생성이 중단됨

빌드 실패 로그 요약

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라인)
  • 원인 코드:
    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 훅 옵션 매개변수 타입 불일치

  • 원인 코드:
    const { data: elaprBranchList = [] } = useElaprBranchesByDocNo(
      fnrsCnsltnDocNo,
      open && Boolean(isForkJoin), // boolean 직접 전달
    );
    
  • 문제점:
    • useElaprBranchesByDocNo의 시그니처는 (elaprDocNo, options?: { enabled?: boolean; structuralSharing?: boolean })로 선언되어 있어, boolean 대신 객체 형태({ enabled: ... })로 전달해야 합니다.

2.3 전자결재 결재선 DTO 필드명 불일치

  • 원인 코드:
    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 누락

  • 원인 코드:
    <PwrkCnsltnDocEvlDialog
      open={isEvlDialogOpen}
      pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
      onClose={() => 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: <PwrkCnsltnDocEvlDialog ... onSaved={() => setIsEvlDialogOpen(false)} /> 콜백 전달.

핵심 변경 사항 (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<string, unknown> | 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를 추가.
let mockUserInfo: any = null;

vi.mock("@/shared/lib", async (importOriginal) => {
  const actual = await importOriginal<typeof import("@/shared/lib")>();
  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)을 사용하지 않도록 타입을 확인합니다.