node 업그레이드-->25 이전 서버설정 cash문제 해결.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -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,22 +646,24 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어 */}
|
||||
{/* 하위 메뉴 — 항상 렌더링, CSS로 높이 제어. inner div 로 감싸야 grid 0fr trick 이 동작함 */}
|
||||
{!sidebarCollapsed && menu.hasChildren && (
|
||||
<div className={`v5-submenu ${isExpanded ? "expanded" : ""}`}>
|
||||
{menu.children?.map((child: any, idx: number) => (
|
||||
<div
|
||||
key={child.id}
|
||||
draggable={!child.hasChildren}
|
||||
onDragStart={(e) => handleMenuDragStart(e, child)}
|
||||
className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`}
|
||||
style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }}
|
||||
onClick={() => handleMenuClick(child)}
|
||||
>
|
||||
<span className="ic">{child.icon}</span>
|
||||
<span className="truncate" title={child.name}>{child.name}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="v5-submenu-inner">
|
||||
{menu.children?.map((child: any, idx: number) => (
|
||||
<div
|
||||
key={child.id}
|
||||
draggable={!child.hasChildren}
|
||||
onDragStart={(e) => handleMenuDragStart(e, child)}
|
||||
className={`v5-si v5-sub-item ${isMenuActive(child) ? "on" : ""}`}
|
||||
style={{ transitionDelay: isExpanded ? `${idx * 30}ms` : "0ms" }}
|
||||
onClick={() => handleMenuClick(child)}
|
||||
>
|
||||
<span className="ic">{child.icon}</span>
|
||||
<span className="truncate" title={child.name}>{child.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user