node 업그레이드-->25 이전 서버설정 cash문제 해결.

This commit is contained in:
2026-04-08 01:10:59 +09:00
parent 9890b906b9
commit db8df83b31
7 changed files with 123 additions and 57 deletions
+46 -15
View File
@@ -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 가 처리하도록 위임.
## 코드 컨벤션
### 네이밍 규칙
@@ -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
+5 -3
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
22
+43 -17
View File
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div key={menu.id} style={{ position: "relative" }}>
<div
key={menu.id}
style={{ position: "relative" }}
onMouseEnter={(e) => handleCollapsedMenuHover(menu, e)}
onMouseLeave={() => {
if (sidebarCollapsed && menu.hasChildren) scheduleFlyoutClose();
}}
>
<div
draggable={isLeaf && !sidebarCollapsed}
onDragStart={(e) => handleMenuDragStart(e, menu)}
@@ -607,6 +629,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
<div
className="v5-side-flyout open"
style={{ top: 0 }}
onMouseEnter={cancelFlyoutClose}
onMouseLeave={scheduleFlyoutClose}
>
<div className="fly-title">{menu.name}</div>
{menu.children?.map((child: any) => (
@@ -622,9 +646,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
)}
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어 */}
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어. inner div 로 감싸야 grid 0fr trick 이 동작함 */}
{!sidebarCollapsed && menu.hasChildren && (
<div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}>
<div className="v5-submenu-inner">
{menu.children?.map((child: any, idx: number) => (
<div
key={child.id}
@@ -639,6 +664,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
))}
</div>
</div>
)}
</div>
);
+4 -1
View File
@@ -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",
+5 -2
View File
@@ -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;