From db8df83b31732a16a6111579dbec659d98dcbfeb Mon Sep 17 00:00:00 2001 From: gbpark Date: Wed, 8 Apr 2026 01:10:59 +0900 Subject: [PATCH] =?UTF-8?q?node=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EB=93=9C-->25=20=20=EC=9D=B4=EC=A0=84=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20cash=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 61 +++++++++---- .../src/main/resources/mapper/admin.xml | 12 +-- docker/dev/frontend.Dockerfile | 8 +- frontend/.nvmrc | 1 + frontend/components/layout/AppLayout.tsx | 86 ++++++++++++------- frontend/package.json | 5 +- frontend/styles/v5-layout.css | 7 +- 7 files changed, 123 insertions(+), 57 deletions(-) create mode 100644 frontend/.nvmrc diff --git a/README.md b/README.md index 2abd0f24..ea6563d9 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ INVION/ ### 1. 필수 요구사항 - **Java**: 21 -- **Node.js**: 20.10+ (프론트엔드 빌드용) +- **Node.js**: **22 LTS+** (`frontend/package.json` 의 `engines.node` 에 `>=22.0.0` 강제. 프로젝트 루트에 `.nvmrc` 박혀있어 `nvm use` 로 자동 전환됨) - **PostgreSQL**: 데이터베이스 서버 - **npm**: 10.0+ @@ -148,25 +148,33 @@ cd frontend && npm install && npm run dev cd backend-spring && ./gradlew bootRun ``` -### 3. Docker 환경 실행 +### 3. Docker 환경 실행 (dev) + +frontend + backend 한 컴포즈 파일로 띄움. 코드 변경은 volume mount 로 컨테이너 안에 즉시 반영됨 (turbopack 자동 리로드). ```bash -# 개발 (백엔드 + 프론트엔드) -docker-compose -f docker-compose.backend.win.yml up -d -docker-compose -f docker-compose.frontend.win.yml up -d +# 개발 환경 한 번에 띄우기 +docker compose -f docker/dev/docker-compose.invyone.yml up -d -# 프로덕션 배포 -docker-compose -f docker/deploy/docker-compose.yml up -d +# 내리기 / 재시작 / 로그 +docker compose -f docker/dev/docker-compose.invyone.yml down +docker compose -f docker/dev/docker-compose.invyone.yml restart +docker compose -f docker/dev/docker-compose.invyone.yml logs -f + +# 프로덕션 배포 (별도) +docker compose -f docker/deploy/docker-compose.yml up -d ``` ### 4. 서비스 접속 -| 서비스 | URL | 설명 | -|--------|-----|------| -| **프론트엔드** | http://localhost:9771 | Next.js UI | -| **백엔드 API** | http://localhost:8081 | Spring Boot REST API | +| 환경 | 서비스 | URL | 설명 | +|------|--------|-----|------| +| **로컬 dev** (`npm run dev` / `gradlew bootRun`) | 프론트엔드 | http://localhost:9771 | Next.js (turbopack) | +| | 백엔드 API | http://localhost:8081 | Spring Boot | +| **도커 dev** (위 컴포즈) | 프론트엔드 | http://localhost:9772 | 컨테이너 내부 3000 → 호스트 9772 | +| | 백엔드 API | http://localhost:8083 | 컨테이너 내부 8081 → 호스트 8083 | -> 프론트엔드는 `next.config.mjs`의 rewrite 설정으로 `/api/*` 요청을 백엔드(8081)로 프록시합니다. +> 프론트엔드는 `next.config.mjs` 의 rewrites 설정으로 `/api/*` 요청을 백엔드로 프록시합니다 (도커 컴포즈에서는 컨테이너 네트워크 내부 이름 `invyone-backend-spring:8081` 로 프록시). ## 주요 기능 @@ -244,8 +252,11 @@ file: ``` ```bash -# frontend/.env.local -NEXT_PUBLIC_API_URL=http://localhost:8081/api +# frontend/.env.local — 클라이언트(브라우저) 가 사용할 API base URL +# 반드시 상대경로 "/api" 로 둘 것. 절대 URL (예: http://localhost:8083/api) 을 박으면 +# 다른 머신/도메인에서 접속할 때 클라이언트가 자기 자신을 찌르며 connection refused 발생함. +# next dev server 의 rewrites 가 "/api/*" 를 컨테이너 내부 backend 로 프록시한다. +NEXT_PUBLIC_API_URL=/api ``` ## 배포 @@ -259,7 +270,7 @@ docker build -t invion . 멀티스테이지 빌드 과정: 1. **Stage 1** — Spring Boot 빌드 (`eclipse-temurin:21-jdk-alpine`, Gradle → bootJar) -2. **Stage 2** — Next.js 빌드 (`node:20.10-alpine`, npm → standalone) +2. **Stage 2** — Next.js 빌드 (`node:22-alpine`, npm → standalone) 3. **Stage 3** — 런타임 (`eclipse-temurin:21-jre-alpine` + Node.js, 두 서비스 병렬 실행) ### CI/CD 파이프라인 @@ -274,6 +285,26 @@ Git Push → Jenkins → Kaniko 이미지 빌드 → 프라이빗 레지스트 - **배포**: Helm 차트 + GitOps (이미지 태그 자동 업데이트) - **프로덕션 도메인**: Traefik 리버스 프록시 + Let's Encrypt HTTPS +## 알려진 설정 정책 + +후임자/협업자가 "이거 왜 이렇게 짜놨지?" 헷갈리지 않도록 의도가 있는 비명시적 결정만 정리. + +### `frontend/next.config.mjs` — `isDev` 분기 + +`output: "standalone"` 과 `experimental.webpackMemoryOptimizations` 는 **prod build 에서만 켜진다** (`NODE_ENV !== "production"` 이면 비활성화). dev 모드에서 같이 켜면 다음 두 가지 부작용이 동시에 발생함: +- `output: standalone` 이 dev 청크 manifest 경로 처리를 깨트림 → 라우트 그룹 `(main)/(auth)` 의 layout/page 청크가 `_next/static/chunks/...` 에 못 만들어짐 +- `webpackMemoryOptimizations` 가 컴파일된 SSR 청크를 GC 해버려서 첫 visit 후 ENOENT 발생 → ChunkLoadError 영구화 + +→ 두 옵션은 절대 dev 에서 켜지 말 것. prod build 에서만 의미 있음. + +### `docker/dev/frontend.Dockerfile` — turbopack 강제 + +`dev:docker` 스크립트에 `--turbopack` 플래그가 박혀있다. 도커에서 webpack 으로 돌리면 next 15 + app router + 라우트 그룹 `(main)/(auth)` 조합에서 layout/page 청크가 disk 에 flush 되지 않는 케이스가 발생함. 로컬 dev (`npm run dev`) 도 동일하게 turbopack 사용. 베이스 이미지는 `node:22-alpine`. + +### Frontend → Backend API 호출 — 반드시 상대경로 + +위 "환경 변수" 섹션 참고. `NEXT_PUBLIC_API_URL` 에 절대 URL 을 박으면 안 된다. `/api` 로 두고 `next.config.mjs` 의 rewrites 가 처리하도록 위임. + ## 코드 컨벤션 ### 네이밍 규칙 diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index afa9695e..a58d7ca5 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -20,7 +20,7 @@ , MENU.MENU_DESC , MENU.SEQ , MENU.WRITER - , MENU.REGDATE + , MENU.CREATED_DATE , MENU.STATUS , MENU.COMPANY_CODE , MENU.LANG_KEY @@ -71,7 +71,7 @@ , S.MENU_DESC , S.SEQ , S.WRITER - , S.REGDATE + , S.CREATED_DATE , S.STATUS , S.COMPANY_CODE , S.LANG_KEY @@ -117,7 +117,7 @@ , COALESCE(V.MENU_DESC, '') AS MENU_DESC , CAST(V.SEQ AS TEXT) AS SEQ , V.WRITER - , TO_CHAR(V.REGDATE, 'YYYY-MM-DD') AS REGDATE + , TO_CHAR(V.CREATED_DATE, 'YYYY-MM-DD') AS REGDATE , V.STATUS , V.COMPANY_CODE , COALESCE(V.LANG_KEY, '') AS LANG_KEY @@ -168,7 +168,7 @@ , MENU.MENU_DESC , MENU.SEQ , MENU.WRITER - , MENU.REGDATE + , MENU.CREATED_DATE , MENU.STATUS , MENU.COMPANY_CODE , MENU.LANG_KEY @@ -200,7 +200,7 @@ , S.MENU_DESC , S.SEQ , S.WRITER - , S.REGDATE + , S.CREATED_DATE , S.STATUS , S.COMPANY_CODE , S.LANG_KEY @@ -224,7 +224,7 @@ , COALESCE(V.MENU_DESC, '') AS MENU_DESC , CAST(V.SEQ AS TEXT) AS SEQ , V.WRITER - , TO_CHAR(V.REGDATE, 'YYYY-MM-DD') AS REGDATE + , TO_CHAR(V.CREATED_DATE, 'YYYY-MM-DD') AS REGDATE , V.STATUS , V.COMPANY_CODE , COALESCE(V.LANG_KEY, '') AS LANG_KEY diff --git a/docker/dev/frontend.Dockerfile b/docker/dev/frontend.Dockerfile index 4520d6f1..3158776c 100644 --- a/docker/dev/frontend.Dockerfile +++ b/docker/dev/frontend.Dockerfile @@ -1,5 +1,5 @@ -# Node.js 20 기반 이미지 사용 -FROM node:20-alpine +# Node.js 22 기반 (active LTS, 2027-04 까지 — Next 15.4 turbopack 권장 환경) +FROM node:22-alpine # 작업 디렉토리 설정 WORKDIR /app @@ -16,5 +16,7 @@ COPY . . # 포트 노출 EXPOSE 3000 -# 개발 서버 시작 (Docker에서는 Turbopack 비활성화로 CPU 폭주 방지) +# 개발 서버 시작 (Next 15.4 turbopack stable. webpack 으로 돌리면 +# app router + 라우트 그룹 (main)/(auth) 의 layout/page 청크가 disk 에 +# 안 flush 되어 ChunkLoadError 가 영구 발생함. 로컬과 동일하게 turbopack 사용) CMD ["npm", "run", "dev:docker"] \ No newline at end of file diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 157455a4..e77a7a54 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, Suspense, useEffect, useCallback } from "react"; +import { useState, Suspense, useEffect, useCallback, useRef } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { @@ -549,32 +549,47 @@ function AppLayoutInner({ children }: AppLayoutProps) { [pathname, activeTab], ); - // 접힌 사이드바에서 부모 메뉴 클릭 → 플라이아웃 + // 플라이아웃 닫기 타이머 — hover out 후 작은 딜레이로 닫아 마우스가 버튼 ↔ 플라이아웃 이동 시 끊기지 않게 함 + const flyoutCloseTimerRef = useRef | null>(null); + + const cancelFlyoutClose = useCallback(() => { + if (flyoutCloseTimerRef.current) { + clearTimeout(flyoutCloseTimerRef.current); + flyoutCloseTimerRef.current = null; + } + }, []); + + const scheduleFlyoutClose = useCallback(() => { + cancelFlyoutClose(); + flyoutCloseTimerRef.current = setTimeout(() => setFlyoutMenu(null), 150); + }, [cancelFlyoutClose]); + + // 접힌 사이드바에서 부모 메뉴 hover → 플라이아웃 오픈 + const handleCollapsedMenuHover = useCallback((menu: any, e: React.MouseEvent) => { + if (!sidebarCollapsed || !menu.hasChildren) return; + cancelFlyoutClose(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setFlyoutMenu({ menu, rect }); + }, [sidebarCollapsed, cancelFlyoutClose]); + + // 접힌 사이드바에서 부모 메뉴 클릭 → 이미 hover 로 열려있으므로 swallow (leaf 만 동작) const handleCollapsedMenuClick = useCallback((menu: any, e: React.MouseEvent) => { if (!sidebarCollapsed || !menu.hasChildren) return false; e.stopPropagation(); - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setFlyoutMenu((prev) => prev?.menu.id === menu.id ? null : { menu, rect }); return true; }, [sidebarCollapsed]); // 플라이아웃에서 메뉴 선택 const handleFlyoutSelect = useCallback((child: any) => { + cancelFlyoutClose(); setFlyoutMenu(null); handleMenuClick(child); - }, []); + }, [cancelFlyoutClose]); - // 바깥 클릭 시 플라이아웃 닫기 + // 언마운트 시 타이머 정리 useEffect(() => { - if (!flyoutMenu) return; - const close = (e: MouseEvent) => { - if (!(e.target as HTMLElement).closest(".v5-side-flyout") && !(e.target as HTMLElement).closest(".v5-si")) { - setFlyoutMenu(null); - } - }; - document.addEventListener("click", close); - return () => document.removeEventListener("click", close); - }, [flyoutMenu]); + return () => cancelFlyoutClose(); + }, [cancelFlyoutClose]); // 메뉴 트리 렌더링 (v5 glassmorphism) const renderMenu = (menu: any, level: number = 0) => { @@ -582,7 +597,14 @@ function AppLayoutInner({ children }: AppLayoutProps) { const isLeaf = !menu.hasChildren; return ( -
+
handleCollapsedMenuHover(menu, e)} + onMouseLeave={() => { + if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose(); + }} + >
handleMenuDragStart(e, menu)} @@ -607,6 +629,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{menu.name}
{menu.children?.map((child: any) => ( @@ -622,22 +646,24 @@ function AppLayoutInner({ children }: AppLayoutProps) {
)} - {/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어 */} + {/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어. inner div 로 감싸야 grid 0fr trick 이 동작함 */} {!sidebarCollapsed && menu.hasChildren && (
- {menu.children?.map((child: any, idx: number) => ( -
handleMenuDragStart(e, child)} - className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`} - style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }} - onClick={() => handleMenuClick(child)} - > - {child.icon} - {child.name} -
- ))} +
+ {menu.children?.map((child: any, idx: number) => ( +
handleMenuDragStart(e, child)} + className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`} + style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }} + onClick={() => handleMenuClick(child)} + > + {child.icon} + {child.name} +
+ ))} +
)}
diff --git a/frontend/package.json b/frontend/package.json index b4041213..584682cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,9 +2,12 @@ "name": "frontend", "version": "0.1.0", "private": true, + "engines": { + "node": ">=22.0.0" + }, "scripts": { "dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -p 9771", - "dev:docker": "next dev -p 3000", + "dev:docker": "next dev --turbopack -p 3000", "build": "next build", "build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build", "start": "next start", diff --git a/frontend/styles/v5-layout.css b/frontend/styles/v5-layout.css index 760ea120..02083ba6 100644 --- a/frontend/styles/v5-layout.css +++ b/frontend/styles/v5-layout.css @@ -397,12 +397,15 @@ html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bo .v5-side:not(.collapsed) .v5-side-group .v5-si{ transition:height .35s .12s cubic-bezier(.16,1,.3,1),padding .35s .12s,opacity .35s .15s,margin .35s .12s;} -/* ===== SUBMENU EXPAND/COLLAPSE ===== */ +/* ===== SUBMENU EXPAND/COLLAPSE ===== + grid-template-rows:0fr↔1fr 트릭은 직속 child 가 단 하나일 때만 동작 (여러 개면 implicit row 가 auto 로 펼쳐짐). + 그래서 .v5-submenu 안을 .v5-submenu-inner 로 감싸 항상 child 가 1개가 되도록 보장한다. */ .v5-submenu{display:grid;grid-template-rows:0fr;overflow:hidden;padding-left:1.5rem; transition:grid-template-rows .35s cubic-bezier(.4,0,.2,1),opacity .25s;} -.v5-submenu>*{overflow:hidden;} +.v5-submenu>*{overflow:hidden;min-height:0;} .v5-submenu.expanded{grid-template-rows:1fr;opacity:1;} .v5-submenu:not(.expanded){opacity:0;} +.v5-submenu-inner{display:flex;flex-direction:column;gap:1px;} /* Sub items stagger slide in */ .v5-sub-item{transform:translateX(-10px);opacity:0;