diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 00000000..e5c8d001 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,212 @@ +# Gitea Actions Workflow - vexplor 이미지 빌드 & Harbor Push +# +# 동작 방식: +# 1. main 브랜치에 push 시 자동 실행 +# 2. Docker 이미지 빌드 (Backend, Frontend) +# 3. Harbor 레지스트리에 Push +# 4. 공장 서버의 Watchtower가 새 이미지 감지 후 자동 업데이트 +# +# 필수 Secrets (Repository Settings > Secrets): +# - HARBOR_USERNAME: Harbor 사용자명 +# - HARBOR_PASSWORD: Harbor 비밀번호 + +name: Build and Push Images + +on: + push: + branches: + - main + - master + paths: + - "backend-node/**" + - "frontend/**" + - "docker/**" + - ".gitea/workflows/deploy.yml" + paths-ignore: + - "**.md" + - "deploy/**" + - "k8s/**" + workflow_dispatch: # 수동 실행도 가능 + +env: + GITEA_DOMAIN: g.wace.me + HARBOR_REGISTRY: localhost:5001 + HARBOR_REGISTRY_EXTERNAL: harbor.wace.me + HARBOR_PROJECT: speefox_vexplor + + # Frontend 빌드 환경 변수 + NEXT_PUBLIC_API_URL: "https://api.vexplor.com/api" + NEXT_PUBLIC_ENV: "production" + + # Frontend 설정 + FRONTEND_IMAGE_NAME: vexplor-frontend + FRONTEND_BUILD_CONTEXT: frontend + FRONTEND_DOCKERFILE_PATH: docker/deploy/frontend.Dockerfile + + # Backend 설정 + BACKEND_IMAGE_NAME: vexplor-backend + BACKEND_BUILD_CONTEXT: backend-node + BACKEND_DOCKERFILE_PATH: docker/deploy/backend.Dockerfile + +jobs: + build-and-push: + runs-on: ubuntu-24.04 + + steps: + # 작업 디렉토리 정리 + - name: Clean workspace + run: | + echo "작업 디렉토리 정리..." + cd /workspace + rm -rf source + mkdir -p source + echo "정리 완료" + + # 필수 도구 설치 + - name: Install required tools + run: | + echo "필수 도구 설치 중..." + apt-get update -qq + apt-get install -y git curl ca-certificates gnupg + + # Docker 클라이언트 설치 + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + + apt-get update -qq + apt-get install -y docker-ce-cli + + echo "설치 완료:" + git --version + docker --version + + export DOCKER_HOST=unix:///var/run/docker.sock + docker version || echo "소켓 연결 대기 중..." + + # 저장소 체크아웃 + - name: Checkout code + run: | + echo "저장소 체크아웃..." + cd /workspace/source + + git clone --depth 1 --branch ${GITHUB_REF_NAME} \ + https://oauth2:${{ github.token }}@${GITEA_DOMAIN}/${GITHUB_REPOSITORY}.git . + + echo "체크아웃 완료" + git log -1 --oneline + + # 빌드 환경 설정 + - name: Set up build environment + run: | + IMAGE_TAG="v$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::7}" + echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV + + # Frontend 이미지 + echo "FRONTEND_FULL_IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${FRONTEND_IMAGE_NAME}" >> $GITHUB_ENV + + # Backend 이미지 + echo "BACKEND_FULL_IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${BACKEND_IMAGE_NAME}" >> $GITHUB_ENV + + echo "==========================================" + echo "빌드 태그: ${IMAGE_TAG}" + echo "==========================================" + + # Harbor 로그인 + - name: Login to Harbor + env: + HARBOR_USER: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASS: ${{ secrets.HARBOR_PASSWORD }} + run: | + echo "Harbor 로그인..." + export DOCKER_HOST=unix:///var/run/docker.sock + echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} \ + --username ${HARBOR_USER} \ + --password-stdin + echo "Harbor 로그인 완료" + + # Backend 빌드 및 푸시 + - name: Build and Push Backend image + run: | + echo "==========================================" + echo "Backend 이미지 빌드 시작..." + echo "==========================================" + export DOCKER_HOST=unix:///var/run/docker.sock + cd /workspace/source + + docker build \ + -t ${BACKEND_FULL_IMAGE}:${IMAGE_TAG} \ + -t ${BACKEND_FULL_IMAGE}:latest \ + -f ${BACKEND_DOCKERFILE_PATH} \ + ${BACKEND_BUILD_CONTEXT} + + echo "Backend 이미지 푸시..." + docker push ${BACKEND_FULL_IMAGE}:${IMAGE_TAG} + docker push ${BACKEND_FULL_IMAGE}:latest + + echo "==========================================" + echo "Backend 푸시 완료!" + echo " - ${BACKEND_FULL_IMAGE}:${IMAGE_TAG}" + echo " - ${BACKEND_FULL_IMAGE}:latest" + echo "==========================================" + + # Frontend 빌드 및 푸시 + - name: Build and Push Frontend image + run: | + echo "==========================================" + echo "Frontend 이미지 빌드 시작..." + echo "==========================================" + export DOCKER_HOST=unix:///var/run/docker.sock + cd /workspace/source + + echo "빌드 환경 변수:" + echo " NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}" + echo " NEXT_PUBLIC_ENV=${NEXT_PUBLIC_ENV}" + + docker build \ + -t ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG} \ + -t ${FRONTEND_FULL_IMAGE}:latest \ + -f ${FRONTEND_DOCKERFILE_PATH} \ + --build-arg NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}" \ + ${FRONTEND_BUILD_CONTEXT} + + echo "Frontend 이미지 푸시..." + docker push ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG} + docker push ${FRONTEND_FULL_IMAGE}:latest + + echo "==========================================" + echo "Frontend 푸시 완료!" + echo " - ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG}" + echo " - ${FRONTEND_FULL_IMAGE}:latest" + echo "==========================================" + + # 빌드 완료 요약 + - name: Build summary + if: success() + run: | + echo "" + echo "==========================================" + echo " 이미지 빌드 & Push 완료!" + echo "==========================================" + echo "" + echo "빌드 버전: ${IMAGE_TAG}" + echo "" + echo "푸시된 이미지:" + echo " - Backend: ${HARBOR_REGISTRY_EXTERNAL}/${HARBOR_PROJECT}/${BACKEND_IMAGE_NAME}:latest" + echo " - Frontend: ${HARBOR_REGISTRY_EXTERNAL}/${HARBOR_PROJECT}/${FRONTEND_IMAGE_NAME}:latest" + echo "" + echo "다음 단계:" + echo " - 공장 서버의 Watchtower가 자동으로 새 이미지를 감지합니다" + echo " - 또는 수동 업데이트: docker compose pull && docker compose up -d" + echo "" + echo "==========================================" + + # Harbor 로그아웃 + - name: Logout from Harbor + if: always() + run: | + export DOCKER_HOST=unix:///var/run/docker.sock + docker logout ${HARBOR_REGISTRY} || true diff --git a/.gitignore b/.gitignore index 972957ba..4605a219 100644 --- a/.gitignore +++ b/.gitignore @@ -113,6 +113,16 @@ secrets.yml api-keys.json tokens.json +# Kubernetes Secrets (절대 커밋하지 않음!) +k8s/vexplor-secret.yaml +k8s/*-secret.yaml +!k8s/*-secret.yaml.template + +# Kubernetes Secrets (절대 커밋하지 않음!) +k8s/vexplor-secret.yaml +k8s/*-secret.yaml +!k8s/*-secret.yaml.template + # 데이터베이스 덤프 *.sql *.dump @@ -168,6 +178,8 @@ uploads/ # ===== 기타 ===== claude.md +.cursor/mcp.json + # Agent Pipeline 로컬 파일 _local/ .agent-pipeline/ diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 00000000..9a424223 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,328 @@ +{ + "version": "1.0.0", + "lastScanned": 1772609393905, + "projectRoot": "/Users/johngreen/Dev/vexplor", + "techStack": { + "languages": [ + { + "name": "JavaScript/TypeScript", + "version": null, + "confidence": "high", + "markers": [ + "package.json" + ] + } + ], + "frameworks": [], + "packageManager": "npm", + "runtime": null + }, + "build": { + "buildCommand": null, + "testCommand": null, + "lintCommand": null, + "devCommand": null, + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": "type-based" + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [ + "docs", + "lib", + "scripts", + "src" + ], + "gitBranches": { + "defaultBranch": "main", + "branchingStrategy": null + } + }, + "customNotes": [], + "directoryMap": { + "WebContent": { + "path": "WebContent", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1772609393856, + "keyFiles": [ + "init.jsp", + "init_jqGrid.jsp", + "init_no_login.jsp", + "init_toastGrid.jsp", + "viewImage.jsp" + ] + }, + "backend": { + "path": "backend", + "purpose": null, + "fileCount": 6, + "lastAccessed": 1772609393857, + "keyFiles": [ + "Dockerfile", + "Dockerfile.mac", + "build.gradle", + "gradlew", + "gradlew.bat" + ] + }, + "backend-node": { + "path": "backend-node", + "purpose": null, + "fileCount": 14, + "lastAccessed": 1772609393872, + "keyFiles": [ + "API_연동_가이드.md", + "API_키_정리.md", + "Dockerfile.win", + "PHASE1_USAGE_GUIDE.md", + "README.md" + ] + }, + "db": { + "path": "db", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1772609393873, + "keyFiles": [ + "00-create-roles.sh", + "migrate_company13_export.sh" + ] + }, + "deploy": { + "path": "deploy", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1772609393873, + "keyFiles": [] + }, + "docker": { + "path": "docker", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1772609393873, + "keyFiles": [] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 23, + "lastAccessed": 1772609393873, + "keyFiles": [ + "AI_화면생성_시스템_설계서.md", + "DB_ARCHITECTURE_ANALYSIS.md", + "DB_STRUCTURE_DIAGRAM.html", + "DB_WORKFLOW_ANALYSIS.md", + "KUBERNETES_DEPLOYMENT_GUIDE.md" + ] + }, + "frontend": { + "path": "frontend", + "purpose": null, + "fileCount": 14, + "lastAccessed": 1772609393873, + "keyFiles": [ + "MODAL_REPEATER_TABLE_DEBUG.md", + "README.md", + "components.json", + "eslint.config.mjs", + "middleware.ts" + ] + }, + "hooks": { + "path": "hooks", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1772609393879, + "keyFiles": [ + "useScreenStandards.ts" + ] + }, + "k8s": { + "path": "k8s", + "purpose": null, + "fileCount": 7, + "lastAccessed": 1772609393882, + "keyFiles": [ + "local-path-provisioner.yaml", + "namespace.yaml", + "vexplor-backend-deployment.yaml", + "vexplor-config.yaml", + "vexplor-frontend-deployment.yaml" + ] + }, + "lib": { + "path": "lib", + "purpose": "Library code", + "fileCount": 0, + "lastAccessed": 1772609393883, + "keyFiles": [] + }, + "mcp-agent-orchestrator": { + "path": "mcp-agent-orchestrator", + "purpose": null, + "fileCount": 4, + "lastAccessed": 1772609393883, + "keyFiles": [ + "README.md", + "package-lock.json", + "package.json", + "tsconfig.json" + ] + }, + "popdocs": { + "path": "popdocs", + "purpose": null, + "fileCount": 12, + "lastAccessed": 1772609393884, + "keyFiles": [ + "ARCHITECTURE.md", + "CHANGELOG.md", + "FILES.md", + "INDEX.md", + "PLAN.md" + ] + }, + "scripts": { + "path": "scripts", + "purpose": "Build/utility scripts", + "fileCount": 2, + "lastAccessed": 1772609393884, + "keyFiles": [ + "add-modal-ids.py", + "remove-logs.js" + ] + }, + "src": { + "path": "src", + "purpose": "Source code", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "tomcat-conf": { + "path": "tomcat-conf", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1772609393884, + "keyFiles": [ + "context.xml" + ] + }, + "backend/build": { + "path": "backend/build", + "purpose": "Build output", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "backend/src": { + "path": "backend/src", + "purpose": "Source code", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "backend-node/data": { + "path": "backend-node/data", + "purpose": "Data files", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "db/migrations": { + "path": "db/migrations", + "purpose": "Database migrations", + "fileCount": 16, + "lastAccessed": 1772609393884, + "keyFiles": [ + "046_MIGRATION_FIX.md", + "046_QUICK_FIX.md", + "README_1003.md" + ] + }, + "db/scripts": { + "path": "db/scripts", + "purpose": "Build/utility scripts", + "fileCount": 1, + "lastAccessed": 1772609393884, + "keyFiles": [ + "README_cleanup.md" + ] + }, + "frontend/app": { + "path": "frontend/app", + "purpose": "Application code", + "fileCount": 5, + "lastAccessed": 1772609393885, + "keyFiles": [ + "favicon.ico", + "globals.css", + "layout.tsx" + ] + }, + "frontend/components": { + "path": "frontend/components", + "purpose": "UI components", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "GlobalFileViewer.tsx" + ] + }, + "mcp-agent-orchestrator/src": { + "path": "mcp-agent-orchestrator/src", + "purpose": "Source code", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "index.ts" + ] + }, + "src/controllers": { + "path": "src/controllers", + "purpose": "Controllers", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "dataflowDiagramController.ts" + ] + }, + "src/routes": { + "path": "src/routes", + "purpose": "Route handlers", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "dataflowDiagramRoutes.ts" + ] + }, + "src/services": { + "path": "src/services", + "purpose": "Business logic services", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "dataflowDiagramService.ts" + ] + }, + "src/utils": { + "path": "src/utils", + "purpose": "Utility functions", + "fileCount": 2, + "lastAccessed": 1772609393885, + "keyFiles": [ + "databaseValidator.ts", + "queryBuilder.ts" + ] + } + }, + "hotPaths": [], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json b/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json new file mode 100644 index 00000000..ec93e466 --- /dev/null +++ b/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json @@ -0,0 +1,8 @@ +{ + "session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23", + "ended_at": "2026-03-04T08:10:16.810Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json new file mode 100644 index 00000000..5fbc9b8f --- /dev/null +++ b/.omc/state/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-03-04T07:29:57.315Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-03-04T07:29:53.176Z", + "sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23" +} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 00000000..d5a8e668 --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json new file mode 100644 index 00000000..84ff7ebe --- /dev/null +++ b/.omc/state/idle-notif-cooldown.json @@ -0,0 +1,3 @@ +{ + "lastSentAt": "2026-03-04T07:30:30.883Z" +} \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ae2424a0..ee964175 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -378,12 +378,20 @@ app.use(errorHandler); const PORT = config.port; const HOST = config.host; -app.listen(PORT, HOST, async () => { +const server = app.listen(PORT, HOST, async () => { logger.info(`🚀 Server is running on ${HOST}:${PORT}`); logger.info(`📊 Environment: ${config.nodeEnv}`); logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + // 비동기 초기화 작업 (에러가 발생해도 서버는 유지) + initializeServices().catch(err => { + logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err); + }); +}); + +// 서비스 초기화 함수 분리 +async function initializeServices() { // 데이터베이스 마이그레이션 실행 try { const { @@ -451,6 +459,6 @@ app.listen(PORT, HOST, async () => { } catch (error) { logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error); } -}); +} export default app; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index c8ba23d8..36e8bd62 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -4,6 +4,7 @@ import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; import { auditLogService } from "../services/auditLogService"; import { TableManagementService } from "../services/tableManagementService"; import { formatPgError } from "../utils/pgErrorUtil"; @@ -35,7 +36,7 @@ router.get( console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`); const relation = await masterDetailExcelService.getMasterDetailRelation( - parseInt(screenId) + parseInt(screenId), ); if (!relation) { @@ -64,7 +65,7 @@ router.get( error: error.message, }); } - } + }, ); /** @@ -90,7 +91,7 @@ router.post( // 1. 마스터-디테일 관계 조회 const relation = await masterDetailExcelService.getMasterDetailRelation( - parseInt(screenId) + parseInt(screenId), ); if (!relation) { @@ -104,7 +105,7 @@ router.post( const data = await masterDetailExcelService.getJoinedData( relation, companyCode, - filters + filters, ); console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`); @@ -121,7 +122,7 @@ router.post( error: error.message, }); } - } + }, ); /** @@ -144,11 +145,13 @@ router.post( }); } - console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`); + console.log( + `📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`, + ); // 1. 마스터-디테일 관계 조회 const relation = await masterDetailExcelService.getMasterDetailRelation( - parseInt(screenId) + parseInt(screenId), ); if (!relation) { @@ -163,7 +166,7 @@ router.post( relation, data, companyCode, - userId + userId, ); console.log(`✅ 마스터-디테일 업로드 완료:`, { @@ -194,7 +197,7 @@ router.post( error: error.message, }); } - } + }, ); /** @@ -202,7 +205,7 @@ router.post( * - 마스터 정보는 UI에서 선택 * - 디테일 정보만 엑셀에서 업로드 * - 채번 규칙을 통해 마스터 키 자동 생성 - * + * * POST /api/data/master-detail/upload-simple */ router.post( @@ -210,7 +213,14 @@ router.post( authenticateToken, async (req: AuthenticatedRequest, res) => { try { - const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body; + const { + screenId, + detailData, + masterFieldValues, + numberingRuleId, + afterUploadFlowId, + afterUploadFlows, + } = req.body; const companyCode = req.user?.companyCode || "*"; const userId = req.user?.userId || "system"; @@ -221,10 +231,17 @@ router.post( }); } - console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`); + console.log( + `📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`, + ); console.log(` 마스터 필드 값:`, masterFieldValues); console.log(` 채번 규칙 ID:`, numberingRuleId); - console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음"); + console.log( + ` 업로드 후 제어:`, + afterUploadFlows?.length > 0 + ? `${afterUploadFlows.length}개` + : afterUploadFlowId || "없음", + ); // 업로드 실행 const result = await masterDetailExcelService.uploadSimple( @@ -235,7 +252,7 @@ router.post( companyCode, userId, afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성) - afterUploadFlows // 업로드 후 제어 실행 (다중) + afterUploadFlows, // 업로드 후 제어 실행 (다중) ); console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, { @@ -260,7 +277,7 @@ router.post( error: error.message, }); } - } + }, ); // ================================ @@ -504,7 +521,7 @@ router.get( parsedDataFilter, enableEntityJoinFlag, parsedDisplayColumns, // 🆕 표시 컬럼 전달 - parsedDeduplication // 🆕 중복 제거 설정 전달 + parsedDeduplication, // 🆕 중복 제거 설정 전달 ); if (!result.success) { @@ -512,7 +529,7 @@ router.get( } console.log( - `✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목` + `✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`, ); return res.json({ @@ -527,7 +544,7 @@ router.get( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); /** @@ -616,7 +633,7 @@ router.get( } console.log( - `✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목` + `✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`, ); // 페이징 정보 포함하여 반환 @@ -642,7 +659,7 @@ router.get( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); /** @@ -684,7 +701,7 @@ router.get( } console.log( - `✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼` + `✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`, ); return res.json(result); @@ -696,7 +713,7 @@ router.get( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); /** @@ -748,7 +765,8 @@ router.get( } // 🆕 primaryKeyColumn 파싱 - const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined; + const primaryKeyColumnStr = + typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined; console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { enableEntityJoin: enableEntityJoinFlag, @@ -762,7 +780,7 @@ router.get( id, enableEntityJoinFlag, groupByColumnsArray, - primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달 + primaryKeyColumnStr, // 🆕 Primary Key 컬럼명 전달 ); if (!result.success) { @@ -790,7 +808,7 @@ router.get( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); /** @@ -844,7 +862,7 @@ router.post( records, req.user?.companyCode, req.user?.userId, - deleteOrphans + deleteOrphans, ); if (!result.success) { @@ -856,7 +874,9 @@ router.post( const deleted = result.data?.deleted || 0; console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { - inserted, updated, deleted, + inserted, + updated, + deleted, }); const parts: string[] = []; @@ -869,7 +889,10 @@ router.post( companyCode: req.user?.companyCode || "", userId: req.user?.userId || "", userName: req.user?.userName || "", - action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE", + action: + inserted > 0 && updated === 0 && deleted === 0 + ? "BATCH_CREATE" + : "UPDATE", resourceType: "DATA", tableName, summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`, @@ -895,7 +918,81 @@ router.post( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, +); + +/** + * 설비 ID 일괄 검증 API + * POST /api/data/equipment_mng/validate + * + * 요청: { "equipmentIds": ["EQ_001", "EQ_002", "EQ_003"] } + * 응답: { "success": true, "data": [{ "equipment_id": "EQ_001", "equipment_name": "프레스 1호기" }, ...] } + * + * - 존재하는 설비만 반환 (존재하지 않는 ID는 응답에서 제외) + * - 멀티테넌시: company_code 필터링 적용 + */ +router.post( + "/equipment_mng/validate", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { equipmentIds } = req.body; + + if (!Array.isArray(equipmentIds) || equipmentIds.length === 0) { + return res.status(400).json({ + success: false, + message: "equipmentIds 배열이 필요합니다.", + }); + } + + // 최대 500개 제한 + if (equipmentIds.length > 500) { + return res.status(400).json({ + success: false, + message: "한 번에 최대 500개까지 검증할 수 있습니다.", + }); + } + + const companyCode = req.user?.companyCode; + const pool = getPool(); + + // Parameterized query로 SQL Injection 방지 + const placeholders = equipmentIds.map((_, i) => `$${i + 1}`).join(", "); + const params: any[] = [...equipmentIds]; + + let whereClause = `WHERE equipment_id IN (${placeholders})`; + + // 멀티테넌시 필터링 (company_code가 '*'이 아닌 경우) + if (companyCode && companyCode !== "*") { + params.push(companyCode); + whereClause += ` AND company_code = $${params.length}`; + } + + const query = ` + SELECT equipment_id, equipment_name + FROM equipment_mng + ${whereClause} + `; + + const result = await pool.query(query, params); + + console.log( + `✅ 설비 일괄 검증: ${result.rowCount}/${equipmentIds.length}개 확인`, + ); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + console.error("설비 일괄 검증 오류:", error); + return res.status(500).json({ + success: false, + message: "설비 검증 중 오류가 발생했습니다.", + error: error.message, + }); + } + }, ); /** @@ -935,7 +1032,7 @@ router.post( // 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가 const hasCompanyCode = await dataService.checkColumnExists( tableName, - "company_code" + "company_code", ); if (hasCompanyCode && req.user?.companyCode) { enrichedData.company_code = req.user.companyCode; @@ -945,7 +1042,7 @@ router.post( // 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가 const hasCompanyName = await dataService.checkColumnExists( tableName, - "company_name" + "company_name", ); if (hasCompanyName && req.user?.companyName) { enrichedData.company_name = req.user.companyName; @@ -1001,7 +1098,7 @@ router.post( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); /** @@ -1086,7 +1183,7 @@ router.put( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); /** @@ -1156,7 +1253,7 @@ router.post( error: error.message, }); } - } + }, ); /** @@ -1179,12 +1276,16 @@ router.post( }); } - console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany }); + console.log(`🗑️ 그룹 삭제:`, { + tableName, + filterConditions, + userCompany, + }); const result = await dataService.deleteGroupRecords( tableName, filterConditions, - userCompany // 회사 코드 전달 + userCompany, // 회사 코드 전달 ); if (!result.success) { @@ -1201,7 +1302,7 @@ router.post( error: error.message, }); } - } + }, ); router.delete( @@ -1264,7 +1365,7 @@ router.delete( error: error instanceof Error ? error.message : "Unknown error", }); } - } + }, ); export default router; diff --git a/deploy/customers/README.md b/deploy/customers/README.md new file mode 100644 index 00000000..8b55214b --- /dev/null +++ b/deploy/customers/README.md @@ -0,0 +1,115 @@ +# 고객사별 환경 변수 관리 + +## 개요 + +이 폴더는 각 고객사(업체)별 환경 변수 설정을 **참고용**으로 관리합니다. + +**중요:** 실제 비밀번호는 이 파일에 저장하지 마세요. 템플릿으로만 사용합니다. + +--- + +## 고객사 목록 + +| 고객사 | 파일 | 배포 형태 | 상태 | +| :--- | :--- | :--- | :--- | +| 스피폭스 | `spifox.env` | 온프레미스 (공장 서버) | 진행 중 | +| 엔키드 | `enkid.env` | 온프레미스 (공장 서버) | 예정 | + +--- + +## 신규 고객사 추가 절차 + +### 1단계: 환경 변수 파일 생성 + +```bash +# 기존 파일 복사 +cp spifox.env newcustomer.env + +# 수정 +nano newcustomer.env +``` + +필수 수정 항목: +- `COMPANY_CODE`: 고유한 회사 코드 (예: NEWCO) +- `SERVER_IP`: 고객사 서버 IP +- `DB_PASSWORD`: 고유한 비밀번호 +- `JWT_SECRET`: 고유한 시크릿 키 + +### 2단계: 데이터베이스에 회사 등록 + +```sql +-- company_info 테이블에 추가 +INSERT INTO company_info (company_code, company_name, status) +VALUES ('NEWCO', '신규고객사', 'ACTIVE'); +``` + +### 3단계: 관리자 계정 생성 + +```sql +-- user_info 테이블에 관리자 추가 +INSERT INTO user_info (user_id, user_name, company_code, role) +VALUES ('newco_admin', '신규고객사 관리자', 'NEWCO', 'COMPANY_ADMIN'); +``` + +### 4단계: 고객사 서버에 배포 + +```bash +# 고객사 서버에 SSH 접속 +ssh user@customer-server + +# 설치 폴더 생성 +sudo mkdir -p /opt/vexplor +cd /opt/vexplor + +# docker-compose.yml 복사 (deploy/onpremise/에서) +# .env 파일 복사 및 수정 + +# 서비스 시작 +docker compose up -d +``` + +--- + +## 환경 변수 설명 + +| 변수 | 설명 | 예시 | +| :--- | :--- | :--- | +| `COMPANY_CODE` | 회사 고유 코드 (멀티테넌시) | `SPIFOX`, `ENKID` | +| `SERVER_IP` | 서버의 실제 IP | `192.168.0.100` | +| `DB_PASSWORD` | DB 비밀번호 | (고객사별 고유) | +| `JWT_SECRET` | JWT 토큰 시크릿 | (고객사별 고유) | +| `IMAGE_TAG` | Docker 이미지 버전 | `latest`, `v1.0.0` | + +--- + +## 보안 주의사항 + +1. **비밀번호**: 이 폴더의 파일에는 실제 비밀번호를 저장하지 마세요 +2. **Git**: `.env` 파일이 커밋되지 않도록 `.gitignore` 확인 +3. **고객사별 격리**: 각 고객사는 별도 서버, 별도 DB로 완전 격리 +4. **키 관리**: JWT_SECRET은 고객사별로 반드시 다르게 설정 + +--- + +## 구조 다이어그램 + +``` +[Harbor (이미지 저장소)] + │ + │ docker pull + ↓ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 스피폭스 공장 │ │ 엔키드 공장 │ │ 신규 고객사 │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ Vexplor │ │ │ │ Vexplor │ │ │ │ Vexplor │ │ +│ │ SPIFOX │ │ │ │ ENKID │ │ │ │ NEWCO │ │ +│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ +│ │ │ │ │ │ +│ [독립 DB] │ │ [독립 DB] │ │ [독립 DB] │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + +* 각 공장은 완전히 독립적으로 운영 +* 같은 Docker 이미지 사용, .env만 다름 +* 데이터는 절대 섞이지 않음 (물리적 격리) +``` + diff --git a/deploy/customers/enkid.env b/deploy/customers/enkid.env new file mode 100644 index 00000000..3a9e84df --- /dev/null +++ b/deploy/customers/enkid.env @@ -0,0 +1,36 @@ +# ============================================ +# 엔키드(ENKID) 공장 서버 환경 변수 +# ============================================ +# 이 파일을 엔키드 공장 서버의 /opt/vexplor/.env로 복사 + +# 회사 정보 +COMPANY_CODE=ENKID + +# 서버 정보 (실제 서버 IP로 변경 필요) +SERVER_IP=10.0.0.50 + +# 데이터베이스 +DB_USER=vexplor +DB_PASSWORD=enkid_secure_password_here +DB_NAME=vexplor +DB_PORT=5432 + +# 백엔드 +BACKEND_PORT=3001 +JWT_SECRET=enkid_jwt_secret_minimum_32_characters +JWT_EXPIRES_IN=24h +LOG_LEVEL=info + +# 프론트엔드 +FRONTEND_PORT=80 + +# Harbor 레지스트리 +HARBOR_USER=enkid_harbor_user +HARBOR_PASSWORD=enkid_harbor_password + +# 이미지 태그 +IMAGE_TAG=latest + +# Watchtower (1시간마다 업데이트 확인) +UPDATE_INTERVAL=3600 + diff --git a/deploy/customers/spifox.env b/deploy/customers/spifox.env new file mode 100644 index 00000000..ab7d6004 --- /dev/null +++ b/deploy/customers/spifox.env @@ -0,0 +1,36 @@ +# ============================================ +# 스피폭스(SPIFOX) 공장 서버 환경 변수 +# ============================================ +# 이 파일을 스피폭스 공장 서버의 /opt/vexplor/.env로 복사 + +# 회사 정보 +COMPANY_CODE=SPIFOX + +# 서버 정보 (실제 서버 IP로 변경 필요) +SERVER_IP=192.168.0.100 + +# 데이터베이스 +DB_USER=vexplor +DB_PASSWORD=spifox_secure_password_here +DB_NAME=vexplor +DB_PORT=5432 + +# 백엔드 +BACKEND_PORT=3001 +JWT_SECRET=spifox_jwt_secret_minimum_32_characters +JWT_EXPIRES_IN=24h +LOG_LEVEL=info + +# 프론트엔드 +FRONTEND_PORT=80 + +# Harbor 레지스트리 +HARBOR_USER=spifox_harbor_user +HARBOR_PASSWORD=spifox_harbor_password + +# 이미지 태그 +IMAGE_TAG=latest + +# Watchtower (1시간마다 업데이트 확인) +UPDATE_INTERVAL=3600 + diff --git a/deploy/onpremise/README.md b/deploy/onpremise/README.md new file mode 100644 index 00000000..76cad490 --- /dev/null +++ b/deploy/onpremise/README.md @@ -0,0 +1,321 @@ +# Vexplor 온프레미스(공장) 배포 가이드 + +## 개요 + +이 가이드는 Vexplor를 **공장 내부 서버(온프레미스)**에 배포하는 방법을 설명합니다. + +**Watchtower**를 사용하여 Harbor에 새 이미지가 푸시되면 **자동으로 업데이트**됩니다. + +--- + +## 사전 요구사항 + +### 서버 요구사항 + +| 항목 | 최소 사양 | 권장 사양 | +| :--- | :--- | :--- | +| OS | Ubuntu 20.04+ | Ubuntu 22.04 LTS | +| CPU | 4 Core | 8 Core | +| RAM | 8 GB | 16 GB | +| Disk | 50 GB | 100 GB SSD | +| Network | Harbor 접근 가능 | - | + +### 필수 소프트웨어 + +```bash +# Docker 설치 확인 +docker --version # 20.10 이상 + +# Docker Compose 설치 확인 +docker compose version # v2.0 이상 +``` + +--- + +## 1단계: 초기 설정 + +### 1.1 배포 폴더 생성 + +```bash +# 배포 폴더 생성 +sudo mkdir -p /opt/vexplor +cd /opt/vexplor + +# 파일 복사 (또는 git clone) +# deploy/onpremise/ 폴더의 내용을 복사 +``` + +### 1.2 환경 변수 설정 + +```bash +# 예제 파일 복사 +cp env.example .env + +# 편집 +nano .env +``` + +**필수 수정 항목:** + +```bash +# 서버 IP (이 서버의 실제 IP) +SERVER_IP=192.168.0.100 + +# 회사 코드 +COMPANY_CODE=SPIFOX + +# DB 비밀번호 (강력한 비밀번호 설정) +DB_PASSWORD=MySecurePassword123! + +# JWT 시크릿 (32자 이상) +JWT_SECRET=your-super-secret-jwt-key-minimum-32-chars + +# Harbor 인증 정보 +HARBOR_USER=your_username +HARBOR_PASSWORD=your_password +``` + +### 1.3 Harbor 레지스트리 로그인 + +Watchtower가 이미지를 당겨올 수 있도록 Docker 로그인이 필요합니다. + +```bash +# Harbor 로그인 +docker login harbor.wace.me + +# Username: (입력) +# Password: (입력) + +# 로그인 성공 확인 +cat ~/.docker/config.json +``` + +--- + +## 2단계: 서비스 실행 + +### 2.1 서비스 시작 + +```bash +cd /opt/vexplor + +# 이미지 다운로드 & 실행 +docker compose up -d + +# 상태 확인 +docker compose ps +``` + +### 2.2 정상 동작 확인 + +```bash +# 모든 컨테이너 Running 상태 확인 +docker compose ps + +# 로그 확인 +docker compose logs -f + +# 개별 서비스 로그 +docker compose logs -f backend +docker compose logs -f frontend +docker compose logs -f watchtower +``` + +### 2.3 웹 접속 테스트 + +``` +프론트엔드: http://SERVER_IP:80 +백엔드 API: http://SERVER_IP:3001/health +``` + +--- + +## 3단계: 자동 업데이트 확인 + +### Watchtower 동작 확인 + +```bash +# Watchtower 로그 확인 +docker compose logs -f watchtower +``` + +**정상 로그 예시:** + +``` +watchtower | time="2024-12-28T10:00:00+09:00" level=info msg="Checking for updates..." +watchtower | time="2024-12-28T10:00:05+09:00" level=info msg="Found new image harbor.wace.me/vexplor/vexplor-backend:latest" +watchtower | time="2024-12-28T10:00:10+09:00" level=info msg="Stopping container vexplor-backend" +watchtower | time="2024-12-28T10:00:15+09:00" level=info msg="Starting container vexplor-backend" +``` + +### 업데이트 주기 변경 + +```bash +# .env 파일에서 변경 +UPDATE_INTERVAL=3600 # 1시간마다 확인 + +# 변경 후 watchtower 재시작 +docker compose restart watchtower +``` + +--- + +## 운영 가이드 + +### 서비스 관리 명령어 + +```bash +# 모든 서비스 상태 확인 +docker compose ps + +# 모든 서비스 중지 +docker compose stop + +# 모든 서비스 시작 +docker compose start + +# 모든 서비스 재시작 +docker compose restart + +# 모든 서비스 삭제 (데이터 유지) +docker compose down + +# 모든 서비스 삭제 + 볼륨 삭제 (주의: 데이터 삭제됨!) +docker compose down -v +``` + +### 로그 확인 + +```bash +# 전체 로그 (실시간) +docker compose logs -f + +# 특정 서비스 로그 +docker compose logs -f backend +docker compose logs -f frontend +docker compose logs -f database + +# 최근 100줄만 +docker compose logs --tail=100 backend +``` + +### 수동 업데이트 (긴급 시) + +자동 업데이트를 기다리지 않고 즉시 업데이트하려면: + +```bash +# 최신 이미지 다운로드 +docker compose pull + +# 재시작 +docker compose up -d +``` + +### 특정 버전으로 롤백 + +```bash +# .env 파일에서 버전 지정 +IMAGE_TAG=v1.0.0 + +# 재시작 +docker compose up -d +``` + +--- + +## 백업 가이드 + +### DB 백업 + +```bash +# 백업 디렉토리 생성 +mkdir -p /opt/vexplor/backups + +# PostgreSQL 백업 +docker compose exec database pg_dump -U vexplor vexplor > /opt/vexplor/backups/backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### 업로드 파일 백업 + +```bash +# 볼륨 위치 확인 +docker volume inspect vexplor_backend_uploads + +# 또는 직접 복사 +docker cp vexplor-backend:/app/uploads /opt/vexplor/backups/uploads_$(date +%Y%m%d) +``` + +### 자동 백업 스크립트 (Cron) + +```bash +# crontab 편집 +crontab -e + +# 매일 새벽 3시 DB 백업 +0 3 * * * docker compose -f /opt/vexplor/docker-compose.yml exec -T database pg_dump -U vexplor vexplor > /opt/vexplor/backups/backup_$(date +\%Y\%m\%d).sql +``` + +--- + +## 문제 해결 + +### 컨테이너가 시작되지 않음 + +```bash +# 로그 확인 +docker compose logs backend + +# 일반적인 원인: +# 1. 환경 변수 누락 → .env 파일 확인 +# 2. 포트 충돌 → netstat -tlnp | grep 3001 +# 3. 메모리 부족 → free -h +``` + +### DB 연결 실패 + +```bash +# DB 컨테이너 상태 확인 +docker compose logs database + +# DB 직접 접속 테스트 +docker compose exec database psql -U vexplor -d vexplor -c "SELECT 1" +``` + +### Watchtower가 업데이트하지 않음 + +```bash +# Watchtower 로그 확인 +docker compose logs watchtower + +# Harbor 인증 확인 +docker pull harbor.wace.me/vexplor/vexplor-backend:latest + +# 라벨 확인 (라벨이 있는 컨테이너만 업데이트) +docker inspect vexplor-backend | grep watchtower +``` + +### 디스크 공간 부족 + +```bash +# 사용하지 않는 이미지/컨테이너 정리 +docker system prune -a + +# 오래된 로그 정리 +docker compose logs --tail=0 backend # 로그 초기화 +``` + +--- + +## 보안 권장사항 + +1. **방화벽 설정**: 필요한 포트(80, 3001)만 개방 +2. **SSL/TLS**: Nginx 리버스 프록시 + Let's Encrypt 적용 권장 +3. **정기 백업**: 최소 주 1회 DB 백업 +4. **로그 모니터링**: 비정상 접근 감시 + +--- + +## 연락처 + +배포 관련 문의: [담당자 이메일] + diff --git a/deploy/onpremise/docker-compose.yml b/deploy/onpremise/docker-compose.yml new file mode 100644 index 00000000..a779cad7 --- /dev/null +++ b/deploy/onpremise/docker-compose.yml @@ -0,0 +1,155 @@ +# Vexplor 온프레미스(공장) 배포용 Docker Compose +# 사용법: docker compose up -d + +services: + # ============================================ + # 1. 데이터베이스 (PostgreSQL) + # ============================================ + database: + image: postgres:15-alpine + container_name: vexplor-db + environment: + POSTGRES_USER: ${DB_USER:-vexplor} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + POSTGRES_DB: ${DB_NAME:-vexplor} + TZ: Asia/Seoul + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db:/docker-entrypoint-initdb.d # 초기화 스크립트 (선택) + ports: + - "${DB_PORT:-5432}:5432" + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-vexplor}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - vexplor-network + + # ============================================ + # 2. 백엔드 API (Node.js) + # ============================================ + backend: + image: harbor.wace.me/speefox_vexplor/vexplor-backend:${IMAGE_TAG:-latest} + container_name: vexplor-backend + environment: + NODE_ENV: production + PORT: 3001 + HOST: 0.0.0.0 + TZ: Asia/Seoul + # DB 연결 + DB_HOST: database + DB_PORT: 5432 + DB_USER: ${DB_USER:-vexplor} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME:-vexplor} + # JWT + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h} + # 암호화 키 (메일 등 민감정보 암호화용) + ENCRYPTION_KEY: ${ENCRYPTION_KEY:-vexplor-encryption-key-32characters-secure} + # 회사 코드 (온프레미스는 단일 회사) + DEFAULT_COMPANY_CODE: ${COMPANY_CODE:-SPIFOX} + # 로깅 + LOG_LEVEL: ${LOG_LEVEL:-info} + volumes: + - backend_uploads:/app/uploads + - backend_data:/app/data + - backend_logs:/app/logs + ports: + - "${BACKEND_PORT:-3001}:3001" + depends_on: + database: + condition: service_healthy + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - vexplor-network + labels: + - "com.centurylinklabs.watchtower.enable=true" + + # ============================================ + # 3. 프론트엔드 (Next.js) + # ============================================ + frontend: + image: harbor.wace.me/speefox_vexplor/vexplor-frontend:${IMAGE_TAG:-latest} + container_name: vexplor-frontend + environment: + NODE_ENV: production + PORT: 3000 + HOSTNAME: 0.0.0.0 + TZ: Asia/Seoul + # 백엔드 API URL (내부 통신) + NEXT_PUBLIC_API_URL: http://${SERVER_IP:-localhost}:${BACKEND_PORT:-3001}/api + ports: + - "${FRONTEND_PORT:-80}:3000" + depends_on: + - backend + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - vexplor-network + labels: + - "com.centurylinklabs.watchtower.enable=true" + + # ============================================ + # 4. Watchtower (자동 업데이트) + # ============================================ + watchtower: + image: containrrr/watchtower:latest + container_name: vexplor-watchtower + environment: + TZ: Asia/Seoul + DOCKER_API_VERSION: "1.44" + # Harbor 레지스트리 인증 + REPO_USER: ${HARBOR_USER} + REPO_PASS: ${HARBOR_PASSWORD} + # 업데이트 설정 + # WATCHTOWER_POLL_INTERVAL: ${UPDATE_INTERVAL:-300} # 간격 기반 (비활성화) + WATCHTOWER_SCHEDULE: "0 0 * * * *" # 매시 정각에 실행 (cron 형식) + WATCHTOWER_CLEANUP: "true" # 이전 이미지 자동 삭제 + WATCHTOWER_INCLUDE_STOPPED: "true" # 중지된 컨테이너도 업데이트 + WATCHTOWER_ROLLING_RESTART: "true" # 순차 재시작 (다운타임 최소화) + WATCHTOWER_LABEL_ENABLE: "true" # 라벨이 있는 컨테이너만 업데이트 + # 알림 설정 (선택) + # WATCHTOWER_NOTIFICATIONS: slack + # WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: ${SLACK_WEBHOOK_URL} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Harbor 인증 정보 (docker login 후 생성됨) + - ~/.docker/config.json:/config.json:ro + restart: always + networks: + - vexplor-network + +# ============================================ +# 볼륨 정의 +# ============================================ +volumes: + postgres_data: + driver: local + backend_uploads: + driver: local + backend_data: + driver: local + backend_logs: + driver: local + +# ============================================ +# 네트워크 정의 +# ============================================ +networks: + vexplor-network: + driver: bridge + diff --git a/deploy/onpremise/env.example b/deploy/onpremise/env.example new file mode 100644 index 00000000..7ffc0d5b --- /dev/null +++ b/deploy/onpremise/env.example @@ -0,0 +1,65 @@ +# ============================================ +# Vexplor 온프레미스(공장) 환경 변수 +# ============================================ +# 사용법: 이 파일을 .env로 복사 후 값 수정 +# cp env.example .env + +# ============================================ +# 서버 정보 +# ============================================ +# 이 서버의 IP 주소 (프론트엔드가 백엔드 API 호출할 때 사용) +SERVER_IP=192.168.0.100 + +# ============================================ +# 회사 정보 +# ============================================ +# 이 공장의 회사 코드 (멀티테넌시용) +COMPANY_CODE=SPIFOX + +# ============================================ +# 데이터베이스 설정 +# ============================================ +DB_USER=vexplor +DB_PASSWORD=your_secure_password_here +DB_NAME=vexplor +DB_PORT=5432 + +# ============================================ +# 백엔드 설정 +# ============================================ +BACKEND_PORT=3001 +JWT_SECRET=your_jwt_secret_key_minimum_32_characters +JWT_EXPIRES_IN=24h +LOG_LEVEL=info + +# ============================================ +# 프론트엔드 설정 +# ============================================ +FRONTEND_PORT=80 + +# ============================================ +# Harbor 레지스트리 인증 +# ============================================ +# Watchtower가 이미지를 당겨올 때 사용 +HARBOR_USER=your_harbor_username +HARBOR_PASSWORD=your_harbor_password + +# ============================================ +# 이미지 태그 +# ============================================ +# latest 또는 특정 버전 (v1.0.0 등) +IMAGE_TAG=latest + +# ============================================ +# Watchtower 설정 +# ============================================ +# 업데이트 확인 주기 (초 단위) +# 300 = 5분, 3600 = 1시간, 86400 = 24시간 +UPDATE_INTERVAL=3600 + +# ============================================ +# 알림 설정 (선택) +# ============================================ +# Slack 웹훅 URL (업데이트 알림 받기) +# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/xxx/xxx + diff --git a/deploy/onpremise/scripts/backup.sh b/deploy/onpremise/scripts/backup.sh new file mode 100644 index 00000000..1e3a65fd --- /dev/null +++ b/deploy/onpremise/scripts/backup.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# ============================================ +# Vexplor 백업 스크립트 +# Cron에 등록하여 정기 백업 가능 +# ============================================ + +set -e + +INSTALL_DIR="/opt/vexplor" +BACKUP_DIR="/opt/vexplor/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# 백업 디렉토리 생성 +mkdir -p $BACKUP_DIR + +echo "==========================================" +echo " Vexplor 백업 시작 - $DATE" +echo "==========================================" + +cd $INSTALL_DIR + +# 1. PostgreSQL 데이터베이스 백업 +echo "[1/3] 데이터베이스 백업..." +docker compose exec -T database pg_dump -U vexplor vexplor > "$BACKUP_DIR/db_$DATE.sql" +gzip "$BACKUP_DIR/db_$DATE.sql" +echo " → $BACKUP_DIR/db_$DATE.sql.gz" + +# 2. 업로드 파일 백업 +echo "[2/3] 업로드 파일 백업..." +docker cp vexplor-backend:/app/uploads "$BACKUP_DIR/uploads_$DATE" 2>/dev/null || echo " → 업로드 폴더 없음 (스킵)" +if [ -d "$BACKUP_DIR/uploads_$DATE" ]; then + tar -czf "$BACKUP_DIR/uploads_$DATE.tar.gz" -C "$BACKUP_DIR" "uploads_$DATE" + rm -rf "$BACKUP_DIR/uploads_$DATE" + echo " → $BACKUP_DIR/uploads_$DATE.tar.gz" +fi + +# 3. 환경 설정 백업 +echo "[3/3] 환경 설정 백업..." +cp "$INSTALL_DIR/.env" "$BACKUP_DIR/env_$DATE" +cp "$INSTALL_DIR/docker-compose.yml" "$BACKUP_DIR/docker-compose_$DATE.yml" +echo " → $BACKUP_DIR/env_$DATE" +echo " → $BACKUP_DIR/docker-compose_$DATE.yml" + +# 4. 오래된 백업 정리 (30일 이상) +echo "" +echo "[정리] 30일 이상 된 백업 삭제..." +find $BACKUP_DIR -type f -mtime +30 -delete 2>/dev/null || true + +# 완료 +echo "" +echo "==========================================" +echo " 백업 완료!" +echo "==========================================" +echo "" +echo "백업 위치: $BACKUP_DIR" +ls -lh $BACKUP_DIR | tail -10 + diff --git a/deploy/onpremise/scripts/install.sh b/deploy/onpremise/scripts/install.sh new file mode 100644 index 00000000..880dcbcc --- /dev/null +++ b/deploy/onpremise/scripts/install.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# ============================================ +# Vexplor 온프레미스 초기 설치 스크립트 +# ============================================ + +set -e + +echo "==========================================" +echo " Vexplor 온프레미스 설치 스크립트" +echo "==========================================" + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 설치 경로 +INSTALL_DIR="/opt/vexplor" + +# 1. Docker 설치 확인 +echo -e "\n${YELLOW}[1/5] Docker 설치 확인...${NC}" +if ! command -v docker &> /dev/null; then + echo -e "${RED}Docker가 설치되어 있지 않습니다.${NC}" + echo "다음 명령어로 설치하세요:" + echo " curl -fsSL https://get.docker.com | sh" + echo " sudo usermod -aG docker \$USER" + exit 1 +fi +echo -e "${GREEN}Docker $(docker --version | cut -d' ' -f3)${NC}" + +# 2. Docker Compose 확인 +echo -e "\n${YELLOW}[2/5] Docker Compose 확인...${NC}" +if ! docker compose version &> /dev/null; then + echo -e "${RED}Docker Compose v2가 설치되어 있지 않습니다.${NC}" + exit 1 +fi +echo -e "${GREEN}$(docker compose version)${NC}" + +# 3. 설치 디렉토리 생성 +echo -e "\n${YELLOW}[3/5] 설치 디렉토리 생성...${NC}" +sudo mkdir -p $INSTALL_DIR +sudo chown $USER:$USER $INSTALL_DIR +echo -e "${GREEN}$INSTALL_DIR 생성 완료${NC}" + +# 4. 파일 복사 +echo -e "\n${YELLOW}[4/5] 설정 파일 복사...${NC}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cp "$SCRIPT_DIR/docker-compose.yml" "$INSTALL_DIR/" +cp "$SCRIPT_DIR/env.example" "$INSTALL_DIR/" + +if [ ! -f "$INSTALL_DIR/.env" ]; then + cp "$SCRIPT_DIR/env.example" "$INSTALL_DIR/.env" + echo -e "${YELLOW}[주의] .env 파일을 생성했습니다. 반드시 수정하세요!${NC}" +fi + +echo -e "${GREEN}파일 복사 완료${NC}" + +# 5. Harbor 로그인 안내 +echo -e "\n${YELLOW}[5/5] Harbor 레지스트리 로그인...${NC}" +if [ ! -f ~/.docker/config.json ] || ! grep -q "harbor.wace.me" ~/.docker/config.json 2>/dev/null; then + echo -e "${YELLOW}Harbor 로그인이 필요합니다:${NC}" + echo " docker login harbor.wace.me" +else + echo -e "${GREEN}Harbor 로그인 확인됨${NC}" +fi + +# 완료 메시지 +echo -e "\n==========================================" +echo -e "${GREEN} 설치 준비 완료!${NC}" +echo "==========================================" +echo "" +echo "다음 단계:" +echo " 1. 환경 변수 설정: nano $INSTALL_DIR/.env" +echo " 2. Harbor 로그인: docker login harbor.wace.me" +echo " 3. 서비스 시작: cd $INSTALL_DIR && docker compose up -d" +echo "" + diff --git a/deploy/onpremise/scripts/server-setup.sh b/deploy/onpremise/scripts/server-setup.sh new file mode 100644 index 00000000..fa20a85f --- /dev/null +++ b/deploy/onpremise/scripts/server-setup.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# ============================================ +# Vexplor 온프레미스 서버 초기 설정 스크립트 +# 스피폭스 공장 서버용 +# ============================================ +# 사용법: sudo bash server-setup.sh + +set -e + +# 색상 정의 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "" +echo "==========================================" +echo " Vexplor 서버 초기 설정" +echo "==========================================" +echo "" + +# root 권한 확인 +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}이 스크립트는 root 권한이 필요합니다.${NC}" + echo "다음 명령어로 실행하세요: sudo bash server-setup.sh" + exit 1 +fi + +# ============================================ +# 1. Docker 설치 +# ============================================ +echo -e "${YELLOW}[1/5] Docker 설치 중...${NC}" + +# 기존 Docker 제거 +apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true + +# 필수 패키지 설치 +apt-get update +apt-get install -y ca-certificates curl gnupg + +# Docker GPG 키 추가 +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg + +# Docker 저장소 추가 +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Docker 설치 +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +echo -e "${GREEN}Docker 설치 완료!${NC}" +docker --version +docker compose version + +# ============================================ +# 2. 사용자를 docker 그룹에 추가 +# ============================================ +echo "" +echo -e "${YELLOW}[2/5] 사용자 권한 설정...${NC}" + +# wace 사용자를 docker 그룹에 추가 +usermod -aG docker wace + +echo -e "${GREEN}wace 사용자를 docker 그룹에 추가했습니다.${NC}" + +# ============================================ +# 3. Vexplor 디렉토리 생성 +# ============================================ +echo "" +echo -e "${YELLOW}[3/5] Vexplor 디렉토리 생성...${NC}" + +mkdir -p /opt/vexplor +chown wace:wace /opt/vexplor + +echo -e "${GREEN}/opt/vexplor 디렉토리 생성 완료!${NC}" + +# ============================================ +# 4. Docker 서비스 시작 및 자동 시작 설정 +# ============================================ +echo "" +echo -e "${YELLOW}[4/5] Docker 서비스 설정...${NC}" + +systemctl start docker +systemctl enable docker + +echo -e "${GREEN}Docker 서비스 활성화 완료!${NC}" + +# ============================================ +# 5. 방화벽 설정 (필요시) +# ============================================ +echo "" +echo -e "${YELLOW}[5/5] 방화벽 설정 확인...${NC}" + +if command -v ufw &> /dev/null; then + ufw status + echo "" + echo "필요시 다음 포트를 개방하세요:" + echo " sudo ufw allow 80/tcp # 웹 서비스" + echo " sudo ufw allow 3001/tcp # 백엔드 API" +else + echo "ufw가 설치되어 있지 않습니다. (방화벽 설정 스킵)" +fi + +# ============================================ +# 완료 +# ============================================ +echo "" +echo "==========================================" +echo -e "${GREEN} 서버 초기 설정 완료!${NC}" +echo "==========================================" +echo "" +echo "다음 단계:" +echo " 1. 로그아웃 후 다시 로그인 (docker 그룹 적용)" +echo " exit" +echo " ssh -p 22 wace@112.168.212.142" +echo "" +echo " 2. Docker 동작 확인" +echo " docker ps" +echo "" +echo " 3. Vexplor 배포 진행" +echo " cd /opt/vexplor" +echo " # docker-compose.yml 및 .env 파일 복사 후" +echo " docker compose up -d" +echo "" + diff --git a/deploy/onpremise/scripts/update.sh b/deploy/onpremise/scripts/update.sh new file mode 100644 index 00000000..77e7678b --- /dev/null +++ b/deploy/onpremise/scripts/update.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# ============================================ +# Vexplor 수동 업데이트 스크립트 +# Watchtower를 기다리지 않고 즉시 업데이트할 때 사용 +# ============================================ + +set -e + +INSTALL_DIR="/opt/vexplor" +cd $INSTALL_DIR + +echo "==========================================" +echo " Vexplor 수동 업데이트" +echo "==========================================" + +# 1. 현재 상태 백업 +echo "[1/4] 현재 설정 백업..." +docker compose config > "backup-config-$(date +%Y%m%d-%H%M%S).yml" + +# 2. 최신 이미지 다운로드 +echo "[2/4] 최신 이미지 다운로드..." +docker compose pull backend frontend + +# 3. 서비스 재시작 (롤링 업데이트) +echo "[3/4] 서비스 재시작..." +docker compose up -d --no-deps backend +sleep 10 # 백엔드가 완전히 뜰 때까지 대기 +docker compose up -d --no-deps frontend + +# 4. 상태 확인 +echo "[4/4] 상태 확인..." +sleep 5 +docker compose ps + +echo "" +echo "==========================================" +echo " 업데이트 완료!" +echo "==========================================" +echo "" +echo "로그 확인: docker compose logs -f" + diff --git a/digitalTwin/architecture-v4.md b/digitalTwin/architecture-v4.md new file mode 100644 index 00000000..96e32ef1 --- /dev/null +++ b/digitalTwin/architecture-v4.md @@ -0,0 +1,209 @@ +# 디지털트윈 아키텍처 v4 + +## 변경사항 (v3 → v4) + +| 구분 | v3 | v4 | +| :--- | :--- | :--- | +| OTA 업데이트 | 개념만 존재 | Fleet Manager + MQTT 구현 | +| 디바이스 관리 | 없음 | Device Registry 추가 | +| 상태 모니터링 | 없음 | Heartbeat + Metrics 추가 | +| 원격 제어 | 없음 | MQTT 기반 명령 추가 | +| Agent | 없음 | Fleet Agent 추가 | + +--- + +## Mermaid 다이어그램 + +```mermaid +--- +config: + layout: dagre +--- +flowchart BT + subgraph Global_Platform["☁️ Vexplor 글로벌 플랫폼"] + direction TB + AAS_Dashboard["💻 AAS 통합 대시보드
(React/Next.js)
• 중앙 모니터링
• Fleet 관리 UI"] + Global_API["🌐 글로벌 API 게이트웨이
• 사용자 인증 (Auth)
• 고객사 라우팅
• Fleet API"] + + subgraph Fleet_System["🎛️ Fleet Management"] + Fleet_Manager["📊 Fleet Manager
• Device Registry
• 배포 오케스트레이션
• 상태 모니터링"] + MQTT_Broker["📡 MQTT Broker
(Mosquitto/EMQX)
• 실시간 통신
• 10,000+ 연결"] + Monitoring["📈 Monitoring
(Prometheus/Grafana)
• 메트릭 수집
• 알림"] + end + + Update_Server["🚀 배포/업데이트 매니저
• Docker 이미지 레지스트리 (Harbor)
• 버전 관리
• Canary 배포"] + end + + subgraph Local_Server["스피폭스 사내 서버 (Local Server)"] + Fleet_Agent_A["🤖 Fleet Agent
• MQTT 연결
• Heartbeat (30초)
• 원격 명령 실행
• Docker 관리"] + VEX_Engine["VEX Flow 엔진
데이터 수집/처리"] + Customer_DB[("사내 통합 DB
(모든 데이터 보유)")] + Watchtower_A["🐋 Watchtower
이미지 자동 업데이트"] + end + + subgraph Edge_Internals["🖥️ 엣지 디바이스 (Store & Forward)"] + Edge_Collector["수집/가공
(Python)"] + Edge_Buffer[("💾 로컬 버퍼
(TimescaleDB)
단절 시 임시 저장")] + Edge_Sender["📤 전송 매니저
(Priority Queue)"] + Edge_Retry_Queue[("🕒 재전송 큐
(SQLite/File)")] + end + + subgraph Factory_A["🏭 스피폭스 공장 현장 (Factory Floor)"] + Edge_Internals + PLC_A["PLC / 센서"] + end + + subgraph Customer_A["🏢 고객사 A: 스피폭스 (사내망)"] + Local_Server + Factory_A + end + + subgraph Local_Server_B["고객사 B 사내 서버"] + Fleet_Agent_B["🤖 Fleet Agent"] + Watchtower_B["🐋 Watchtower"] + end + + subgraph Customer_B["🏭 고객사 B (확장 예정)"] + Local_Server_B + end + + subgraph Local_Server_N["고객사 N 사내 서버"] + Fleet_Agent_N["🤖 Fleet Agent"] + end + + subgraph Customer_N["🏭 고객사 N (10,000개)"] + Local_Server_N + end + + %% 대시보드 연결 + AAS_Dashboard <--> Global_API + AAS_Dashboard <--> Fleet_Manager + + %% Fleet System 내부 연결 + Fleet_Manager <--> MQTT_Broker + Fleet_Manager <--> Monitoring + Fleet_Manager <--> Update_Server + + %% 공장 내부 연결 + PLC_A <--> Edge_Collector + Edge_Collector --> Edge_Buffer + Edge_Buffer --> Edge_Sender + Edge_Sender -- ① 정상 전송 --> VEX_Engine + Edge_Sender -- ② 전송 실패 시 --> Edge_Retry_Queue + Edge_Retry_Queue -. ③ 네트워크 복구 시 재전송 .-> Edge_Sender + VEX_Engine <--> Customer_DB + + %% Fleet Agent 연결 (MQTT - Outbound Only) + Fleet_Agent_A == 📡 MQTT (Heartbeat/명령) ==> MQTT_Broker + Fleet_Agent_B == 📡 MQTT ==> MQTT_Broker + Fleet_Agent_N == 📡 MQTT ==> MQTT_Broker + + %% Agent ↔ 로컬 컴포넌트 + Fleet_Agent_A <--> VEX_Engine + Fleet_Agent_A <--> Watchtower_A + Fleet_Agent_A <--> Customer_DB + + %% OTA 업데이트 (Pull 방식) + Update_Server -. 이미지 배포 .-> Watchtower_A + Update_Server -. 이미지 배포 .-> Watchtower_B + Watchtower_A -. 컨테이너 업데이트 .-> VEX_Engine + + %% 엣지 업데이트 + VEX_Engine -. 엣지 업데이트 .-> Edge_Collector + + %% 스타일 + AAS_Dashboard:::user + Global_API:::global + Update_Server:::global + Fleet_Manager:::fleet + MQTT_Broker:::fleet + Monitoring:::fleet + VEX_Engine:::localServer + Customer_DB:::localServer + Fleet_Agent_A:::agent + Fleet_Agent_B:::agent + Fleet_Agent_N:::agent + Watchtower_A:::agent + Watchtower_B:::agent + Edge_Collector:::edge + Edge_Buffer:::edgedb + Edge_Sender:::edge + Edge_Retry_Queue:::fail + PLC_A:::factory + + classDef factory fill:#e1f5fe,stroke:#01579b,stroke-width:2px + classDef edge fill:#fff9c4,stroke:#fbc02d,stroke-width:2px + classDef edgedb fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,stroke-dasharray: 5 5 + classDef localServer fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px + classDef global fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + classDef user fill:#ffebee,stroke:#c62828,stroke-width:2px + classDef fleet fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + classDef agent fill:#fff3e0,stroke:#ef6c00,stroke-width:2px + classDef fail fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5 + + linkStyle 8 stroke:#2e7d32,stroke-width:2px,fill:none + linkStyle 9 stroke:#c62828,stroke-width:2px,fill:none + linkStyle 10 stroke:#fbc02d,stroke-width:2px,stroke-dasharray: 5 5,fill:none +``` + +--- + +## 추가된 컴포넌트 설명 + +### 1. Fleet Management (신규) + +| 컴포넌트 | 역할 | +| :--- | :--- | +| **Fleet Manager** | 10,000개 디바이스 등록/관리, 배포 오케스트레이션 | +| **MQTT Broker** | 실시간 양방향 통신 (Outbound Only 유지) | +| **Monitoring** | Prometheus + Grafana, 메트릭 수집 & 알림 | + +### 2. Fleet Agent (각 공장 서버에 설치) + +| 기능 | 설명 | +| :--- | :--- | +| **MQTT 연결** | 글로벌 플랫폼과 상시 연결 (Outbound) | +| **Heartbeat** | 30초마다 상태 보고 | +| **원격 명령** | 업데이트, 재시작, 설정 변경 수신 | +| **Docker 관리** | 컨테이너 상태 모니터링 & 제어 | + +### 3. Watchtower (기존 유지) + +- Harbor에서 새 이미지 자동 Pull +- Fleet Agent의 명령으로 즉시 업데이트 가능 + +--- + +## 통신 흐름 비교 + +### v3 (기존) +``` +보안 커넥터 ←→ 글로벌 API (양방향 터널) +``` + +### v4 (신규) +``` +Fleet Agent ──→ MQTT Broker (Outbound Only) + ←── 명령 수신 (Subscribe) + ──→ 상태 보고 (Publish) + +Watchtower ──→ Harbor (Pull Only) +``` + +**장점:** +- 방화벽 Inbound 규칙 불필요 +- 10,000개 동시 연결 가능 +- 실시간 명령 전달 + +--- + +## 데이터 흐름 + +``` +[공장 → 글로벌] +PLC → 엣지 → VEX Flow → Fleet Agent → MQTT → Fleet Manager → Dashboard + +[글로벌 → 공장] +Dashboard → Fleet Manager → MQTT → Fleet Agent → Docker/VEX Flow +``` + diff --git a/digitalTwin/fleet-management-plan.md b/digitalTwin/fleet-management-plan.md new file mode 100644 index 00000000..e80aaab9 --- /dev/null +++ b/digitalTwin/fleet-management-plan.md @@ -0,0 +1,725 @@ +# Fleet Management 시스템 구축 계획서 + +## 개요 + +**목표:** 10,000개 이상의 온프레미스 공장 서버를 중앙에서 효율적으로 관리 + +**현재 상태:** 1개 업체 (스피폭스), Watchtower 기반 자동 업데이트 + +**목표 상태:** 10,000개 업체, 실시간 모니터링 & 원격 제어 가능 + +--- + +## 목차 + +1. [아키텍처 설계](#1-아키텍처-설계) +2. [Phase별 구현 계획](#2-phase별-구현-계획) +3. [핵심 컴포넌트 상세](#3-핵심-컴포넌트-상세) +4. [데이터베이스 스키마](#4-데이터베이스-스키마) +5. [API 설계](#5-api-설계) +6. [기술 스택](#6-기술-스택) +7. [일정 및 마일스톤](#7-일정-및-마일스톤) +8. [리스크 및 대응](#8-리스크-및-대응) + +--- + +## 1. 아키텍처 설계 + +### 1.1 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Vexplor 글로벌 플랫폼 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Web UI │ │ Fleet API │ │ Config │ │ Monitoring │ │ +│ │ (Dashboard) │ │ Gateway │ │ Server │ │ & Alerts │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ │ +│ └────────────────┼────────────────┼────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ Message │ │ Device │ │ +│ │ Broker │ │ Registry │ │ +│ │ (MQTT) │ │ (Redis) │ │ +│ └──────┬──────┘ └─────────────┘ │ +│ │ │ +└──────────────────────────┼────────────────────────────────────────────┘ + │ + │ MQTT (TLS) + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ Agent │ │ Agent │ │ Agent │ + │ 스피폭스 │ │ 엔키드 │ │ 고객 N │ + └─────────┘ └─────────┘ └─────────┘ + │ │ │ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ Vexplor │ │ Vexplor │ │ Vexplor │ + │ Backend │ │ Backend │ │ Backend │ + │Frontend │ │Frontend │ │Frontend │ + │ DB │ │ DB │ │ DB │ + └─────────┘ └─────────┘ └─────────┘ +``` + +### 1.2 통신 흐름 + +``` +[공장 서버 → 글로벌] +1. Agent 시작 시 MQTT 연결 (Outbound Only) +2. 주기적 Heartbeat 전송 (30초) +3. 상태/메트릭 보고 (5분) +4. 로그 전송 (선택적) + +[글로벌 → 공장 서버] +1. 업데이트 명령 +2. 설정 변경 +3. 재시작 명령 +4. 데이터 요청 +``` + +--- + +## 2. Phase별 구현 계획 + +### Phase 1: 기반 구축 (1~10개 업체) +**기간:** 2주 + +| 구현 항목 | 설명 | 우선순위 | +| :--- | :--- | :--- | +| Device Registry API | 디바이스 등록/조회 | P0 | +| Heartbeat API | 상태 보고 수신 | P0 | +| 기본 대시보드 | 디바이스 목록/상태 표시 | P1 | +| Agent 기본 버전 | Heartbeat 전송 기능 | P0 | + +**산출물:** +- `POST /api/fleet/devices/register` +- `POST /api/fleet/devices/heartbeat` +- `GET /api/fleet/devices` +- Agent Docker 이미지 + +--- + +### Phase 2: 실시간 통신 (10~100개 업체) +**기간:** 4주 + +| 구현 항목 | 설명 | 우선순위 | +| :--- | :--- | :--- | +| MQTT 브로커 설치 | Eclipse Mosquitto | P0 | +| Agent MQTT 연결 | 상시 연결 유지 | P0 | +| 원격 명령 기능 | 업데이트/재시작 명령 | P1 | +| 실시간 상태 업데이트 | WebSocket → 대시보드 | P1 | + +**산출물:** +- MQTT 브로커 (Docker) +- Agent v2 (MQTT 지원) +- 원격 명령 UI + +--- + +### Phase 3: 배포 관리 (100~500개 업체) +**기간:** 6주 + +| 구현 항목 | 설명 | 우선순위 | +| :--- | :--- | :--- | +| 버전 관리 시스템 | 릴리즈 버전 관리 | P0 | +| 단계적 롤아웃 | Canary 배포 | P0 | +| 롤백 기능 | 이전 버전 복구 | P0 | +| 그룹 관리 | 지역/업종별 그룹핑 | P1 | +| 배포 스케줄링 | 시간대별 배포 | P2 | + +**산출물:** +- Release Management UI +- Deployment Pipeline +- Rollback 자동화 + +--- + +### Phase 4: 모니터링 강화 (500~2,000개 업체) +**기간:** 6주 + +| 구현 항목 | 설명 | 우선순위 | +| :--- | :--- | :--- | +| 메트릭 수집 | CPU/Memory/Disk | P0 | +| 알림 시스템 | Slack/Email/SMS | P0 | +| 로그 중앙화 | 원격 로그 수집 | P1 | +| 이상 탐지 | 자동 장애 감지 | P1 | +| SLA 대시보드 | 가용성 리포트 | P2 | + +**산출물:** +- Prometheus + Grafana +- Alert Manager +- Log Aggregator (Loki) + +--- + +### Phase 5: 대규모 확장 (2,000~10,000개 업체) +**기간:** 8주 + +| 구현 항목 | 설명 | 우선순위 | +| :--- | :--- | :--- | +| MQTT 클러스터링 | 고가용성 브로커 | P0 | +| 샤딩 | 지역별 분산 | P0 | +| 자동 프로비저닝 | 신규 업체 자동 설정 | P1 | +| API Rate Limiting | 과부하 방지 | P1 | +| 멀티 리전 | 글로벌 분산 | P2 | + +**산출물:** +- MQTT Cluster (EMQX) +- Regional Gateway +- Auto-provisioning System + +--- + +## 3. 핵심 컴포넌트 상세 + +### 3.1 Fleet Agent (공장 서버에 설치) + +``` +┌─────────────────────────────────────────┐ +│ Fleet Agent │ +├─────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ MQTT │ │ Command │ │ +│ │ Client │ │ Executor │ │ +│ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ Core Controller │ │ +│ └─────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ Metrics │ │ Docker │ │ +│ │ Collector │ │ Manager │ │ +│ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────┘ +``` + +**주요 기능:** +- MQTT 연결 유지 (자동 재연결) +- Heartbeat 전송 (30초) +- 시스템 메트릭 수집 +- Docker 컨테이너 관리 +- 원격 명령 실행 + +### 3.2 Fleet Manager (글로벌 서버) + +**주요 기능:** +- 디바이스 등록/인증 +- 상태 모니터링 +- 배포 오케스트레이션 +- 설정 관리 +- 알림 발송 + +### 3.3 Message Broker (MQTT) + +**선택지:** +| 옵션 | 장점 | 단점 | 추천 규모 | +| :--- | :--- | :--- | :--- | +| Mosquitto | 가볍고 간단 | 클러스터링 어려움 | ~1,000 | +| EMQX | 클러스터링, 고성능 | 복잡함 | 1,000~100,000 | +| HiveMQ | 엔터프라이즈급 | 비용 | 100,000+ | + +**권장:** Phase 1~3은 Mosquitto, Phase 4~5는 EMQX + +--- + +## 4. 데이터베이스 스키마 + +### 4.1 디바이스 테이블 + +```sql +-- 디바이스 (공장 서버) 정보 +CREATE TABLE fleet_devices ( + id SERIAL PRIMARY KEY, + device_id VARCHAR(50) UNIQUE NOT NULL, -- 고유 식별자 + company_code VARCHAR(20) NOT NULL, -- 회사 코드 + device_name VARCHAR(100), -- 표시 이름 + + -- 연결 정보 + ip_address VARCHAR(45), + last_seen_at TIMESTAMPTZ, + is_online BOOLEAN DEFAULT false, + + -- 버전 정보 + agent_version VARCHAR(20), + app_version VARCHAR(20), + + -- 시스템 정보 + os_info JSONB, + hardware_info JSONB, + + -- 그룹/태그 + device_group VARCHAR(50), + tags JSONB DEFAULT '[]', + + -- 메타 + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + FOREIGN KEY (company_code) REFERENCES company_info(company_code) +); + +CREATE INDEX idx_fleet_devices_company ON fleet_devices(company_code); +CREATE INDEX idx_fleet_devices_online ON fleet_devices(is_online); +CREATE INDEX idx_fleet_devices_group ON fleet_devices(device_group); +``` + +### 4.2 Heartbeat 로그 테이블 + +```sql +-- Heartbeat 기록 (TimescaleDB 권장) +CREATE TABLE fleet_heartbeats ( + id BIGSERIAL, + device_id VARCHAR(50) NOT NULL, + received_at TIMESTAMPTZ DEFAULT NOW(), + + -- 상태 + status VARCHAR(20), -- OK, WARNING, ERROR + uptime_seconds BIGINT, + + -- 메트릭 + cpu_percent DECIMAL(5,2), + memory_percent DECIMAL(5,2), + disk_percent DECIMAL(5,2), + + -- 컨테이너 상태 + containers JSONB, + + PRIMARY KEY (device_id, received_at) +); + +-- TimescaleDB 하이퍼테이블 변환 (선택) +-- SELECT create_hypertable('fleet_heartbeats', 'received_at'); +``` + +### 4.3 배포 테이블 + +```sql +-- 릴리즈 버전 관리 +CREATE TABLE fleet_releases ( + id SERIAL PRIMARY KEY, + version VARCHAR(20) NOT NULL, + release_type VARCHAR(20), -- stable, beta, hotfix + + -- 이미지 정보 + backend_image VARCHAR(200), + frontend_image VARCHAR(200), + agent_image VARCHAR(200), + + -- 변경사항 + changelog TEXT, + + -- 상태 + status VARCHAR(20) DEFAULT 'draft', -- draft, testing, released, deprecated + released_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 배포 작업 +CREATE TABLE fleet_deployments ( + id SERIAL PRIMARY KEY, + release_id INTEGER REFERENCES fleet_releases(id), + + -- 배포 대상 + target_type VARCHAR(20), -- all, group, specific + target_value VARCHAR(100), -- 그룹명 또는 device_id + + -- 롤아웃 설정 + rollout_strategy VARCHAR(20), -- immediate, canary, scheduled + rollout_percentage INTEGER, + scheduled_at TIMESTAMPTZ, + + -- 상태 + status VARCHAR(20) DEFAULT 'pending', + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- 결과 + total_devices INTEGER, + success_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 개별 디바이스 배포 상태 +CREATE TABLE fleet_deployment_status ( + id SERIAL PRIMARY KEY, + deployment_id INTEGER REFERENCES fleet_deployments(id), + device_id VARCHAR(50), + + status VARCHAR(20) DEFAULT 'pending', -- pending, downloading, installing, completed, failed + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + error_message TEXT, + + UNIQUE(deployment_id, device_id) +); +``` + +### 4.4 알림 규칙 테이블 + +```sql +-- 알림 규칙 +CREATE TABLE fleet_alert_rules ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + + -- 조건 + condition_type VARCHAR(50), -- offline, version_mismatch, high_cpu, etc. + condition_value JSONB, + threshold_minutes INTEGER, -- 조건 지속 시간 + + -- 알림 채널 + notify_channels JSONB, -- ["slack", "email"] + notify_targets JSONB, -- 수신자 목록 + + -- 상태 + is_enabled BOOLEAN DEFAULT true, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 알림 기록 +CREATE TABLE fleet_alerts ( + id SERIAL PRIMARY KEY, + rule_id INTEGER REFERENCES fleet_alert_rules(id), + device_id VARCHAR(50), + + alert_type VARCHAR(50), + message TEXT, + severity VARCHAR(20), -- info, warning, critical + + -- 해결 상태 + status VARCHAR(20) DEFAULT 'open', -- open, acknowledged, resolved + resolved_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 5. API 설계 + +### 5.1 Device Management API + +```yaml +# 디바이스 등록 +POST /api/fleet/devices/register +Request: + device_id: string (required) + company_code: string (required) + device_name: string + agent_version: string + os_info: object +Response: + success: boolean + data: + device_id: string + mqtt_credentials: + broker_url: string + username: string + password: string + +# 디바이스 목록 조회 +GET /api/fleet/devices +Query: + company_code: string + is_online: boolean + device_group: string + page: number + limit: number +Response: + success: boolean + data: Device[] + pagination: { total, page, limit } + +# 디바이스 상세 조회 +GET /api/fleet/devices/:deviceId +Response: + success: boolean + data: + device: Device + recent_heartbeats: Heartbeat[] + recent_alerts: Alert[] +``` + +### 5.2 Heartbeat API + +```yaml +# Heartbeat 전송 +POST /api/fleet/devices/:deviceId/heartbeat +Request: + status: string + uptime_seconds: number + metrics: + cpu_percent: number + memory_percent: number + disk_percent: number + containers: + - name: string + status: string + version: string +Response: + success: boolean + data: + commands: Command[] # 대기 중인 명령 반환 +``` + +### 5.3 Deployment API + +```yaml +# 배포 생성 +POST /api/fleet/deployments +Request: + release_id: number + target_type: "all" | "group" | "specific" + target_value: string + rollout_strategy: "immediate" | "canary" | "scheduled" + rollout_percentage: number + scheduled_at: datetime +Response: + success: boolean + data: + deployment_id: number + estimated_devices: number + +# 배포 상태 조회 +GET /api/fleet/deployments/:deploymentId +Response: + success: boolean + data: + deployment: Deployment + status_summary: + pending: number + in_progress: number + completed: number + failed: number + device_statuses: DeploymentStatus[] + +# 배포 롤백 +POST /api/fleet/deployments/:deploymentId/rollback +Response: + success: boolean + data: + rollback_deployment_id: number +``` + +### 5.4 Command API + +```yaml +# 원격 명령 전송 +POST /api/fleet/devices/:deviceId/commands +Request: + command_type: "update" | "restart" | "config" | "logs" + payload: object +Response: + success: boolean + data: + command_id: string + status: "queued" + +# 명령 결과 조회 +GET /api/fleet/commands/:commandId +Response: + success: boolean + data: + command_id: string + status: "queued" | "sent" | "executing" | "completed" | "failed" + result: object +``` + +--- + +## 6. 기술 스택 + +### 6.1 글로벌 플랫폼 + +| 컴포넌트 | 기술 | 비고 | +| :--- | :--- | :--- | +| Fleet API | Node.js (기존 backend-node 확장) | 기존 코드 재사용 | +| Message Broker | Mosquitto → EMQX | 단계적 전환 | +| Device Registry | Redis | 빠른 조회 | +| Database | PostgreSQL | 기존 DB 확장 | +| Time-series DB | TimescaleDB | Heartbeat 저장 | +| Monitoring | Prometheus + Grafana | 메트릭 시각화 | +| Log | Loki | 로그 중앙화 | +| Alert | AlertManager | 알림 관리 | + +### 6.2 Fleet Agent + +| 컴포넌트 | 기술 | 비고 | +| :--- | :--- | :--- | +| Runtime | Go 또는 Node.js | 가볍고 안정적 | +| MQTT Client | Paho MQTT | 표준 라이브러리 | +| Docker SDK | Docker API | 컨테이너 관리 | +| Metrics | gopsutil | 시스템 메트릭 | + +### 6.3 대시보드 + +| 컴포넌트 | 기술 | 비고 | +| :--- | :--- | :--- | +| UI Framework | Next.js (기존) | 기존 코드 확장 | +| Real-time | Socket.io | 실시간 상태 | +| Charts | Recharts | 메트릭 시각화 | +| Map | Leaflet | 지역별 표시 | + +--- + +## 7. 일정 및 마일스톤 + +### 7.1 전체 일정 + +``` +2025 Q1 2025 Q2 2025 Q3 +│ │ │ +├── Phase 1 (2주) ─────────┤ │ +│ Device Registry │ │ +│ Heartbeat API │ │ +│ 기본 대시보드 │ │ +│ │ │ +│ ├── Phase 2 (4주) ──────────┤ │ +│ │ MQTT 브로커 │ │ +│ │ Agent v2 │ │ +│ │ 원격 명령 │ │ +│ │ │ │ +│ │ ├── Phase 3 (6주) ──────┤ +│ │ │ 버전 관리 │ +│ │ │ Canary 배포 │ +│ │ │ 롤백 │ +│ │ │ │ +``` + +### 7.2 상세 마일스톤 + +| 마일스톤 | 목표 | 완료 기준 | 예상 일정 | +| :--- | :--- | :--- | :--- | +| M1 | Device Registry | 디바이스 등록/조회 API 완료 | 1주차 | +| M2 | Heartbeat | 상태 보고 & 저장 완료 | 2주차 | +| M3 | Basic Dashboard | 디바이스 목록 UI 완료 | 2주차 | +| M4 | MQTT Setup | 브로커 설치 & 연결 테스트 | 4주차 | +| M5 | Agent v2 | MQTT 기반 Agent 완료 | 6주차 | +| M6 | Remote Command | 업데이트/재시작 명령 완료 | 8주차 | +| M7 | Release Mgmt | 버전 관리 UI 완료 | 10주차 | +| M8 | Canary Deploy | 단계적 배포 완료 | 14주차 | + +--- + +## 8. 리스크 및 대응 + +### 8.1 기술적 리스크 + +| 리스크 | 영향 | 확률 | 대응 | +| :--- | :--- | :--- | :--- | +| MQTT 연결 불안정 | 높음 | 중간 | 자동 재연결, 오프라인 큐 | +| 대량 동시 접속 | 높음 | 높음 | 클러스터링, 로드밸런싱 | +| 보안 취약점 | 높음 | 낮음 | TLS 필수, 인증 강화 | +| 네트워크 단절 | 중간 | 높음 | 로컬 캐시, 재전송 로직 | + +### 8.2 운영 리스크 + +| 리스크 | 영향 | 확률 | 대응 | +| :--- | :--- | :--- | :--- | +| 잘못된 배포 | 높음 | 중간 | Canary 배포, 자동 롤백 | +| 모니터링 누락 | 중간 | 중간 | 다중 알림 채널 | +| 버전 파편화 | 중간 | 높음 | 강제 업데이트 정책 | + +--- + +## 9. 다음 단계 + +### 즉시 시작할 작업 (Phase 1) + +1. **Device Registry 테이블 생성** + - `fleet_devices` 테이블 마이그레이션 + +2. **Fleet API 엔드포인트 개발** + - `POST /api/fleet/devices/register` + - `POST /api/fleet/devices/:deviceId/heartbeat` + - `GET /api/fleet/devices` + +3. **Agent 기본 버전 개발** + - Docker 이미지로 배포 + - 주기적 Heartbeat 전송 + +4. **대시보드 기본 화면** + - 디바이스 목록 + - 온라인/오프라인 상태 표시 + +--- + +## 부록 + +### A. MQTT 토픽 설계 + +``` +vexplor/ +├── devices/ +│ ├── {device_id}/ +│ │ ├── status # 상태 보고 (Agent → Server) +│ │ ├── metrics # 메트릭 보고 (Agent → Server) +│ │ ├── commands # 명령 수신 (Server → Agent) +│ │ └── responses # 명령 응답 (Agent → Server) +│ │ +├── broadcasts/ +│ ├── all # 전체 공지 +│ └── groups/{group} # 그룹별 공지 +│ +└── system/ + ├── announcements # 시스템 공지 + └── maintenance # 점검 알림 +``` + +### B. Agent 설정 파일 + +```yaml +# /opt/vexplor/agent/config.yaml +device: + id: "SPIFOX-001" + company_code: "SPIFOX" + name: "스피폭스 메인 서버" + +mqtt: + broker: "mqtts://mqtt.vexplor.com:8883" + username: "${MQTT_USERNAME}" + password: "${MQTT_PASSWORD}" + keepalive: 60 + reconnect_interval: 5 + +heartbeat: + interval: 30 # seconds + +metrics: + enabled: true + interval: 300 # 5 minutes + collect: + - cpu + - memory + - disk + - network + +docker: + socket: "/var/run/docker.sock" + managed_containers: + - vexplor-backend + - vexplor-frontend + - vexplor-db +``` + +### C. 참고 자료 + +- [EMQX Documentation](https://docs.emqx.com/) +- [Eclipse Mosquitto](https://mosquitto.org/) +- [AWS IoT Device Management](https://aws.amazon.com/iot-device-management/) +- [Google Cloud IoT Core](https://cloud.google.com/iot-core) +- [HashiCorp Nomad](https://www.nomadproject.io/) + diff --git a/digitalTwin/디지털트윈 아키텍쳐_v3.png b/digitalTwin/디지털트윈 아키텍쳐_v3.png new file mode 100644 index 00000000..b72e7549 Binary files /dev/null and b/digitalTwin/디지털트윈 아키텍쳐_v3.png differ diff --git a/digitalTwin/디지털트윈 아키텍쳐_v4.png b/digitalTwin/디지털트윈 아키텍쳐_v4.png new file mode 100644 index 00000000..62d72b47 Binary files /dev/null and b/digitalTwin/디지털트윈 아키텍쳐_v4.png differ diff --git a/docker/prod/backend.Dockerfile b/docker/prod/backend.Dockerfile index ec3a5c74..cba88e5c 100644 --- a/docker/prod/backend.Dockerfile +++ b/docker/prod/backend.Dockerfile @@ -39,6 +39,6 @@ RUN mkdir -p logs uploads data && \ chown -R appuser:appgroup /app && \ chmod -R 755 /app -EXPOSE 8080 +EXPOSE 3001 USER appuser CMD ["node", "dist/app.js"] diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 425fdbb6..a3327ea1 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -1,6 +1,6 @@ services: - # Node.js 백엔드 (운영용) - backend: + # Node.js 백엔드 + plm-backend: build: context: ../../backend-node dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile @@ -25,7 +25,7 @@ services: - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker/prod/docker-compose.frontend.prod.yml b/docker/prod/docker-compose.frontend.prod.yml index de07bec4..227cd76e 100644 --- a/docker/prod/docker-compose.frontend.prod.yml +++ b/docker/prod/docker-compose.frontend.prod.yml @@ -1,22 +1,30 @@ services: - # Next.js 프론트엔드만 - frontend: + # Next.js 프론트엔드 + plm-frontend: build: context: ../../frontend dockerfile: ../docker/prod/frontend.Dockerfile args: - - NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api - container_name: pms-frontend-linux - ports: - - "5555:5555" + - NEXT_PUBLIC_API_URL=https://api.vexplor.com + container_name: plm-frontend + restart: always environment: - - NODE_ENV=production - - NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api - networks: - - pms-network - restart: unless-stopped + NODE_ENV: production + NEXT_PUBLIC_API_URL: https://api.vexplor.com + NEXT_TELEMETRY_DISABLED: "1" + PORT: "3000" + HOSTNAME: 0.0.0.0 + volumes: + - /home/vexplor/frontend_data:/app/data + labels: + - traefik.enable=true + - traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`) + - traefik.http.routers.frontend.entrypoints=websecure,web + - traefik.http.routers.frontend.tls=true + - traefik.http.routers.frontend.tls.certresolver=le + - traefik.http.services.frontend.loadbalancer.server.port=3000 networks: - pms-network: - driver: bridge + default: + name: toktork_server_default external: true diff --git a/docker/prod/frontend.Dockerfile b/docker/prod/frontend.Dockerfile index 38e7cff5..e4741ad5 100644 --- a/docker/prod/frontend.Dockerfile +++ b/docker/prod/frontend.Dockerfile @@ -22,8 +22,8 @@ COPY . . # Disable telemetry during the build ENV NEXT_TELEMETRY_DISABLED 1 -# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정) -ARG NEXT_PUBLIC_API_URL=http://192.168.0.70:8080/api +# 빌드 시 환경변수 설정 +ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL # Build the application @@ -55,9 +55,9 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules USER nextjs -EXPOSE 5555 +EXPOSE 3000 -ENV PORT 5555 +ENV PORT 3000 ENV HOSTNAME "0.0.0.0" # Next.js start 명령어 사용 diff --git a/docs/AI_화면생성_시스템_설계서.md b/docs/AI_화면생성_시스템_설계서.md new file mode 100644 index 00000000..b7873615 --- /dev/null +++ b/docs/AI_화면생성_시스템_설계서.md @@ -0,0 +1,451 @@ +# AI 기반 화면 자동 생성 시스템 설계서 + +## 1. 개요 + +### 1.1 목표 + +사용자가 자연어로 "자재입고페이지 만들어줘"라고 요청하면, AI가 자동으로 완성된 화면과 워크플로우를 생성 + +### 1.2 핵심 가치 + +- **제로 코딩**: 대화만으로 화면 생성 +- **워크플로우 포함**: 저장 시 재고 업데이트 등 자동 설정 +- **기존 시스템 활용**: 새 인프라 최소화, 기존 API 재사용 + +### 1.3 예상 시나리오 + +``` +사용자: "자재입고페이지 만들어줘" + +AI: "입고관리 화면 41개를 분석했습니다. + + ✅ 자동 적용 (90%+ 사용) + - 레이아웃: 분할화면 (좌: 목록, 우: 상세) + - 필수 필드: 입고번호, 입고일자, 품목코드, 수량 + + 💡 추천 기능 (60% 사용) + - 저장 시 재고 자동 증가 + [추가하기] + + ➕ 선택 필드 + [ ] 창고 (68%) [ ] 공급업체 (61%) + + [화면 생성하기]" + +사용자: "재고 연동 추가하고, 창고 필드도 넣어줘" + +AI: "화면을 생성했습니다. + - 화면명: 자재 입고관리 + - 레이아웃: 분할화면 + - 필드: 입고번호, 입고일자, 품목, 수량, 창고 + - 워크플로우: 저장 시 inventory_stock +qty + + [미리보기] [메뉴에 추가]" +``` + +--- + +## 2. 시스템 아키텍처 + +``` + ┌───────────────────────────────────────────────────────────────┬───────────────────────────────┐ +│ 신규 개발 (AI 담당) │ 기존 드래그앤드랍 시스템 │ +│ ┌─────────────┐ ┌──────────────────────────────┐ │ ┌─────────────────────┐ │ +│ │ Chat UI │────▶│ AI 서비스 │ │ │ D&D UI Builder │ │ +│ └─────────────┘ │ • LLM 호출 (Claude/GPT) │ │ └─────────┬──────────┘ │ +│ │ • 패턴 분석 + RAG 검색 │ │ │ │ +│ │ • JSON 생성 │ │ ▼ │ +│ └──────────────┬──────────────┘ │ ┌─────────────────────┐ │ +└─────────────────────────────────────┼─────────────────────---──┴─┼─────────────────────┬─────┘ + │ 기존 API 호출 │ 기존 API 호출 │ + ▼ ┼─────────────────────┘ + ▼ +┌-------------------------------------------------------------------------------┐ +│ 기존 시스템 (vexplor) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Screen API │ │ Flow API │ │ Dataflow API│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │ PostgreSQL │ │ +│ └─────────────┘ │ +└-------------------------------------------------------------------------------┘ +``` + +### 설계 원칙 + +| 원칙 | 설명 | +| --------------------- | --------------------------------- | +| 기존 코드 변경 최소화 | 기존 백엔드/프론트 수정 없음 | +| 기존 API 재사용 | AI가 기존 API를 "사용자처럼" 호출 | +| RAG 기반 지식 주입 | 필요한 패턴만 동적으로 LLM에 주입 | +| **Assistive AI** | AI는 결정하지 않고, 사용자의 결정을 돕는다 | + +--- + +## 3. AI가 화면을 "알아서" 만드는 방법 + +### 3.1 지식 소스 + +| 데이터 | 테이블 | 활용 | +| --------------- | ---------------------------------- | ------------------- | +| 기존 화면 | screen_definitions, screen_layouts | 패턴 학습 | +| 테이블 라벨 | table_labels | 테이블 검색 | +| 컬럼 라벨 | column_labels | 필드→컴포넌트 매핑 | +| 워크플로우 패턴 | workflow_patterns (신규) | 비즈니스 로직 | + +### 3.2 통계 기반 패턴 분석 + +> **핵심 아이디어**: 사용자들이 이미 만든 화면들을 분석해서 "입고 화면은 보통 이렇게 생겼더라"를 알아내는 것 + +#### 예시: "입고페이지 만들어줘" 요청 시 + +**Step 1. 테이블 찾기** + +```sql +-- "입고"라는 단어로 table_labels 검색 +SELECT table_name, table_label FROM table_labels +WHERE table_label LIKE '%입고%'; + +-- 결과: inbound_mng (입고관리) +``` + +**Step 2. 이 테이블을 쓰는 기존 화면 찾기** + +```sql +-- inbound_mng 테이블을 사용하는 화면들 조회 +SELECT * FROM screen_definitions +WHERE table_name = 'inbound_mng'; + +-- 결과: 41개 화면 발견! +``` + +**Step 3. 41개 화면의 "공통점" 분석** + +레이아웃 통계: + +| 레이아웃 | 개수 | 비율 | +| -------------------------------- | ----- | -------- | +| split-panel (좌: 목록, 우: 상세) | 32개 | **78%** | +| table-list (목록만) | 6개 | 15% | +| form (폼만) | 3개 | 7% | + +필드 사용 통계: + +| 필드 | 사용 화면 수 | 비율 | +| ---------------------- | ------------ | -------- | +| inbound_no (입고번호) | 41개 | **100%** | +| inbound_date (입고일자)| 41개 | **100%** | +| item_code (품목코드) | 40개 | **98%** | +| qty (수량) | 39개 | **95%** | +| warehouse_code (창고) | 28개 | 68% | +| supplier_code (공급업체)| 25개 | 61% | + +**Step 4. 확신도(Confidence)에 따라 다르게 처리** + +AI는 통계 결과의 확신도에 따라 행동을 다르게 합니다: + +| 확신도 | 기준 | AI 행동 | 예시 | +| ------ | ---- | ------- | ---- | +| **높음** | 90%+ | 자동 적용 | 필수 필드(입고번호, 일자) 자동 추가 | +| **중간** | 60~90% | 추천하며 확인 | "분할화면으로 만들까요? (78% 사용)" | +| **낮음** | 60% 미만 | 옵션 나열 | "창고 필드 추가할까요? (68%)" | + +``` +AI 판단 예시: +- 레이아웃: split-panel (78%) → "분할화면으로 생성합니다" +- 필수 필드: 입고번호, 입고일자 (100%) → 자동 추가 +- 워크플로우: 입고→재고 (60%) → "💡 재고 자동 연동 추가할까요?" +- 선택 필드: 창고 (68%) → "추가 필드: [ ] 창고 [ ] 공급업체" +``` + +**핵심**: 확실한 것은 빠르게 처리하고, 애매한 것은 사용자에게 물어본다. + +#### 비유: "맛집 추천 AI"와 같은 원리 + +| 맛집 추천 AI | 화면 생성 AI | +| ------------ | ------------ | +| "강남에서 점심 뭐 먹지?" | "입고페이지 만들어줘" | +| 강남 식당 1000개 리뷰 분석 | 기존 입고 화면 41개 분석 | +| "70%가 파스타집, 평균 1.5만원" | "78%가 분할화면, 100%가 입고번호 사용" | +| "파스타집 추천드릴까요?" | "분할화면으로 만들까요?" | + +### 3.3 RAG 기반 동적 지식 주입 (핵심) + +> **문제**: 모든 도메인 지식을 프롬프트에 넣으면 Context Window 초과 + +> **해결**: 필요한 지식만 검색해서 동적 주입 + +``` +사용자: "입고페이지 만들어줘" + ↓ +1. 키워드 추출: "입고" + ↓ +2. workflow_patterns 검색 → "입고→재고 증가" 패턴 발견 + ↓ +3. LLM 프롬프트에 해당 패턴만 주입 + ↓ +4. 재고 증가 로직 포함된 화면 생성 +``` + +#### 장점 + +| 장점 | 설명 | +| ------------- | ---------------------------------- | +| 토큰 절약 | 관련 패턴 1-2개만 주입 | +| 확장성 | 패턴 1000개여도 프롬프트 길이 동일 | +| 회사별 커스텀 | company_code로 회사별 패턴 적용 | + +### 3.4 멀티테넌트 & Fallback 전략 + +회사마다 테이블명이 다르거나, 신규 회사라 기존 화면이 없을 수 있습니다. + +**데이터 검색 우선순위:** + +``` +1순위: 해당 회사의 기존 화면 (가장 정확) + ↓ 없거나 부족하면 +2순위: 전체 회사의 익명화된 통계 (company_code 제외, 패턴만) + ↓ 그래도 부족하면 +3순위: vexplor 표준 템플릿 (기본 레이아웃 + 필수 필드) +``` + +**여러 테이블이 검색될 때:** + +``` +사용자: "입고페이지 만들어줘" + +AI: "입고 관련 테이블이 3개 있습니다: + 1. 자재입고관리 (material_inbound) + 2. 제품입고관리 (product_inbound) + 3. 반품입고관리 (return_inbound) + + 어떤 테이블로 만들까요?" +``` + +### 3.5 기존 시스템 분석 결과 + +**발견된 학습 가능 데이터:** + +- `transferData` 액션: 14개 (발주→입고, 수주→출고 등) +- 제어관리 프레임워크: `dataflowControlService.ts` 존재 +- 입고→재고 규칙: 아직 정의되지 않음 → AI가 생성하면 됨 + +--- + +## 4. 개발 범위 + +### 4.1 AI 담당 (신규) + +| 작업 | 파일 | 우선순위 | +| ------------------------------------ | ----------------------------- | ------------ | +| AI 채팅 API | `aiRoutes.ts` | P0 | +| 화면 패턴 분석 | `screenAnalyzer.ts` | P0 | +| LLM 호출 | `llmService.ts` | P0 | +| **워크플로우 패턴 검색 (RAG)** | `workflowPatternService.ts` | **P0** | +| 채팅 UI | `AIChatPanel.tsx` | P0 | +| **workflow_patterns 테이블** | DB | **P0** | + +```typescript +// 패턴 검색 핵심 로직 +export async function searchWorkflowPatterns(userIntent: string, companyCode: string) { + const keywords = extractKeywords(userIntent); + + return await query(` + SELECT * FROM workflow_patterns + WHERE intent_keywords && $1::text[] + AND (company_code = $2 OR company_code = '*') + ORDER BY priority DESC + LIMIT 3 + `, [keywords, companyCode]); +} +``` + +### 4.2 vexplor 담당 (기존 보완) + +| 작업 | 현재 상태 | 필요 작업 | +| -------------- | --------- | ------------------- | +| 화면 생성 API | ✅ 존재 | 문서화 | +| 워크플로우 API | ✅ 존재 | 문서화 | +| 제어관리 API | ✅ 존재 | AI 활용 가능 | +| table_labels | 부분 존재 | 주요 테이블 한글 라벨 | +| column_labels | 부분 존재 | web_type 보완 | + +#### 4.2.1 table_labels가 필요한 이유 + +AI가 "입고"라는 단어로 테이블을 찾으려면 한글 라벨이 있어야 합니다. + +**현재 상태:** + +| table_name | table_label | AI 검색 | +| ---------- | ----------- | ------- | +| inbound_mng | **입고관리** | ✅ "입고" 검색 가능 | +| outbound_mng | **출고관리** | ✅ "출고" 검색 가능 | +| production_record | production_record | ❌ "생산" 검색 불가 | +| purchase_order_master | purchase_order_master | ❌ "발주" 검색 불가 | + +**필요 작업**: 주요 업무 테이블에 한글 라벨 추가 + +```sql +UPDATE table_labels SET table_label = '생산실적' WHERE table_name = 'production_record'; +UPDATE table_labels SET table_label = '발주관리' WHERE table_name = 'purchase_order_master'; +``` + +#### 4.2.2 column_labels의 web_type이 필요한 이유 + +AI가 컬럼을 보고 **어떤 컴포넌트를 생성할지** 결정해야 합니다. + +**현재 상태** (inbound_mng): + +| column_name | column_label | web_type | +| ----------- | ------------ | -------- | +| inbound_date | 입고일 | **null** | +| inbound_qty | 입고수량 | **null** | +| item_code | 품목코드 | **null** | + +**web_type이 null이면?** → AI가 모든 필드를 text-input으로 만들어버림 + +**web_type이 있으면:** + +| column_name | web_type | AI가 생성할 컴포넌트 | +| ----------- | -------- | ------------------- | +| inbound_date | **date** | 📅 날짜 선택기 | +| inbound_qty | **number** | 🔢 숫자 입력 | +| item_code | **entity** | 🔍 품목 검색 팝업 | +| memo | **textarea** | 📝 여러 줄 텍스트 | + +**필요 작업**: 주요 테이블 컬럼에 web_type 추가 + +```sql +UPDATE column_labels SET web_type = 'date' WHERE column_name LIKE '%_date'; +UPDATE column_labels SET web_type = 'number' WHERE column_name LIKE '%_qty'; +UPDATE column_labels SET web_type = 'entity' WHERE column_name LIKE '%_code' AND column_name != 'company_code'; +UPDATE column_labels SET web_type = 'textarea' WHERE column_name = 'memo'; +``` + +--- + +## 5. 데이터베이스 스키마 (AI 전용) + +```sql +-- 핵심: 워크플로우 패턴 (RAG 지식 베이스) +CREATE TABLE workflow_patterns ( + pattern_id SERIAL PRIMARY KEY, + category VARCHAR(50) NOT NULL, -- 'inventory', 'sales' + pattern_name VARCHAR(200) NOT NULL, -- '입고→재고 증가' + intent_keywords TEXT[] NOT NULL, -- ['입고', '자재입고', 'inbound'] + description TEXT, + source_table_hint VARCHAR(100), -- 'inbound_mng' + target_table_hint VARCHAR(100), -- 'inventory_stock' + logic_template JSONB NOT NULL, -- 제어관리 생성 템플릿 + company_code VARCHAR(20) DEFAULT '*', + priority INTEGER DEFAULT 100, + is_active BOOLEAN DEFAULT true +); + +CREATE INDEX idx_workflow_patterns_keywords ON workflow_patterns USING GIN(intent_keywords); + +-- 초기 데이터 +INSERT INTO workflow_patterns (category, pattern_name, intent_keywords, description, source_table_hint, target_table_hint, logic_template) VALUES +('inventory', '입고→재고 증가', + ARRAY['입고', '구매입고', '자재입고', 'inbound'], + '입고 저장 시 재고 수량 증가', + 'inbound_mng', 'inventory_stock', + '{"actionType": "upsert", "operation": "increment", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "inbound_qty", "target": "qty", "type": "increment"}]}'::jsonb +), +('inventory', '출고→재고 감소', + ARRAY['출고', '판매출고', 'outbound'], + '출고 저장 시 재고 수량 감소', + 'outbound_mng', 'inventory_stock', + '{"actionType": "update", "operation": "decrement", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "outbound_qty", "target": "qty", "type": "decrement"}]}'::jsonb +); + +-- 동의어 매핑 (P1) +CREATE TABLE keyword_mapping ( + id SERIAL PRIMARY KEY, + keyword VARCHAR(100) NOT NULL, + table_name VARCHAR(100) NOT NULL, + company_code VARCHAR(20) DEFAULT '*' +); + +-- AI 대화 이력 (P2) +CREATE TABLE ai_conversations ( + id SERIAL PRIMARY KEY, + session_id VARCHAR(100) NOT NULL, + company_code VARCHAR(20) NOT NULL, + user_id VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE ai_messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES ai_conversations(id), + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 6. 개발 로드맵 + +### Phase 1: MVP + +**AI:** + +- 채팅 UI + LLM 연동 +- 화면 패턴 분석 +- workflow_patterns 테이블 + RAG 검색 + +**vexplor:** + +- API 스펙 문서화 (screen, flow) +- 주요 테이블 한글 라벨 20개 + +### Phase 2: 워크플로우 + +**AI:** + +- 자연어 → 워크플로우 변환 +- dataflow_diagrams 자동 생성 + +**vexplor:** + +- 워크플로우 API 문서화 + +### Phase 3: 고도화 + +- 대화형 수정 ("왼쪽 패널 넓혀줘") +- 멀티턴 컨텍스트 +- 사용자 피드백 학습 + +--- + +## 7. vexplor 체크리스트 + +### 즉시 (P0) + +- [ ] POST /api/screen/create 스펙 +- [ ] POST /api/flow/definitions 스펙 +- [ ] 화면 레이아웃 JSON 예시 + +### 1주 내 (P1) + +- [ ] 주요 테이블 20개 한글 라벨 + - inbound_mng, outbound_mng, inventory_stock + - item_info, customer_mng, supplier_mng + - sales_order_mng, purchase_order_mng +- [ ] column_labels web_type 보완 + +--- + +## 8. 성공 지표 + +| 지표 | 목표 | +| -------------------- | --------- | +| 화면 생성 성공률 | 90%+ | +| 평균 생성 시간 | 10초 이내 | +| 수정 없이 사용 | 70%+ | +| 워크플로우 자동 연결 | 80%+ | diff --git a/docs/KUBERNETES_DEPLOYMENT_GUIDE.md b/docs/KUBERNETES_DEPLOYMENT_GUIDE.md new file mode 100644 index 00000000..f5c99cbf --- /dev/null +++ b/docs/KUBERNETES_DEPLOYMENT_GUIDE.md @@ -0,0 +1,375 @@ +# vexplor 쿠버네티스 자동 배포 가이드 + +## 개요 + +이 문서는 vexplor 프로젝트를 Gitea Actions를 통해 쿠버네티스 클러스터에 자동 배포하는 방법을 설명합니다. + +**작성일**: 2024년 12월 22일 + +--- + +## 아키텍처 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Gitea Repository │ +│ g.wace.me/chpark/vexplor │ +└─────────────────────┬───────────────────────────────────────────┘ + │ push to main + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Gitea Actions Runner │ +│ 1. Checkout code │ +│ 2. Build Docker images (frontend, backend) │ +│ 3. Push to Harbor Registry │ +│ 4. Deploy to Kubernetes │ +└─────────────────────┬───────────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ Harbor Registry │ │ Kubernetes (K8s) │ +│ harbor.wace.me │ │ 112.168.212.142 │ +└──────────────────┘ └──────────────────┘ + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Frontend │ │ Backend │ │ Ingress │ + │ :3000 │ │ :3001 │ │ Nginx │ + └──────────┘ └──────────┘ └──────────┘ + │ │ │ + └────────────────┴────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ External Access │ + │ v1.vexplor.com │ + │ api.vexplor.com │ + └─────────────────────┘ +``` + +--- + +## 사전 요구사항 + +### 1. 쿠버네티스 클러스터 + +```bash +# 서버 정보 +IP: 112.168.212.142 +SSH: ssh -p 22 wace@112.168.212.142 +K8s 버전: v1.28.15 +``` + +### 2. Harbor 레지스트리 접근 권한 + +Harbor에 `vexplor` 프로젝트가 생성되어 있어야 합니다. + +### 3. Gitea Repository Secrets + +Gitea 저장소에 다음 Secrets를 설정해야 합니다: + +| Secret 이름 | 설명 | +|------------|------| +| `HARBOR_USERNAME` | Harbor 사용자명 | +| `HARBOR_PASSWORD` | Harbor 비밀번호 | +| `KUBECONFIG` | base64 인코딩된 Kubernetes config | + +--- + +## 초기 설정 + +### 1단계: 쿠버네티스 클러스터 접속 + +```bash +ssh -p 22 wace@112.168.212.142 +``` + +### 2단계: Nginx Ingress Controller 설치 + +```bash +# Nginx Ingress Controller 설치 (baremetal용) +kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.5/deploy/static/provider/baremetal/deploy.yaml + +# 설치 확인 +kubectl get pods -n ingress-nginx +kubectl get svc -n ingress-nginx +``` + +### 3단계: Local Path Provisioner 설치 (PVC용) + +```bash +# Local Path Provisioner 설치 +kubectl apply -f k8s/local-path-provisioner.yaml + +# 설치 확인 +kubectl get pods -n local-path-storage +kubectl get storageclass +``` + +### 4단계: Cert-Manager 설치 (SSL 인증서용) + +```bash +# Cert-Manager 설치 +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml + +# 설치 확인 +kubectl get pods -n cert-manager + +# ClusterIssuer 생성 (Let's Encrypt) +cat < Secrets > Actions 메뉴로 이동 +3. 다음 Secrets 추가: + +#### HARBOR_USERNAME +Harbor 로그인 사용자명 + +#### HARBOR_PASSWORD +Harbor 로그인 비밀번호 + +#### KUBECONFIG +```bash +# 쿠버네티스 서버에서 실행 +cat ~/.kube/config | base64 -w 0 +``` +출력된 값을 KUBECONFIG secret으로 등록 + +--- + +## 배포 트리거 + +### 자동 배포 (Push) + +다음 경로의 파일이 변경되어 `main` 브랜치에 push되면 자동으로 배포됩니다: + +- `backend-node/**` +- `frontend/**` +- `docker/**` +- `k8s/**` +- `.gitea/workflows/deploy.yml` + +### 수동 배포 + +1. Gitea 저장소 > Actions 탭으로 이동 +2. "Deploy vexplor" 워크플로우 선택 +3. "Run workflow" 버튼 클릭 + +--- + +## 파일 구조 + +``` +vexplor/ +├── .gitea/ +│ └── workflows/ +│ └── deploy.yml # Gitea Actions 워크플로우 +├── docker/ +│ └── deploy/ +│ ├── backend.Dockerfile # 백엔드 배포용 Dockerfile +│ └── frontend.Dockerfile # 프론트엔드 배포용 Dockerfile +├── k8s/ +│ ├── namespace.yaml # 네임스페이스 정의 +│ ├── vexplor-config.yaml # ConfigMap +│ ├── vexplor-secret.yaml.template # Secret 템플릿 +│ ├── vexplor-backend-deployment.yaml # 백엔드 Deployment/Service/PVC +│ ├── vexplor-frontend-deployment.yaml # 프론트엔드 Deployment/Service +│ ├── vexplor-ingress.yaml # Ingress 설정 +│ ├── local-path-provisioner.yaml # 스토리지 프로비저너 +│ └── ingress-nginx.yaml # Ingress 컨트롤러 패치 +└── docs/ + └── KUBERNETES_DEPLOYMENT_GUIDE.md # 이 문서 +``` + +--- + +## 운영 명령어 + +### 상태 확인 + +```bash +# 전체 리소스 확인 +kubectl get all -n vexplor + +# Pod 상태 확인 +kubectl get pods -n vexplor -o wide + +# 로그 확인 +kubectl logs -f deployment/vexplor-backend -n vexplor +kubectl logs -f deployment/vexplor-frontend -n vexplor + +# Pod 상세 정보 +kubectl describe pod -n vexplor +``` + +### 수동 배포/롤백 + +```bash +# 이미지 업데이트 +kubectl set image deployment/vexplor-backend \ + vexplor-backend=harbor.wace.me/vexplor/vexplor-backend:v20241222-120000-abc1234 \ + -n vexplor + +# 롤아웃 상태 확인 +kubectl rollout status deployment/vexplor-backend -n vexplor + +# 롤백 +kubectl rollout undo deployment/vexplor-backend -n vexplor +kubectl rollout undo deployment/vexplor-frontend -n vexplor + +# 히스토리 확인 +kubectl rollout history deployment/vexplor-backend -n vexplor +``` + +### 스케일링 + +```bash +# 레플리카 수 조정 +kubectl scale deployment/vexplor-backend --replicas=3 -n vexplor +kubectl scale deployment/vexplor-frontend --replicas=3 -n vexplor +``` + +### Pod 재시작 + +```bash +# Deployment 재시작 (롤링 업데이트) +kubectl rollout restart deployment/vexplor-backend -n vexplor +kubectl rollout restart deployment/vexplor-frontend -n vexplor +``` + +--- + +## 문제 해결 + +### Pod이 Pending 상태일 때 + +```bash +# Pod 이벤트 확인 +kubectl describe pod -n vexplor + +# 노드 리소스 확인 +kubectl describe node +kubectl top nodes +``` + +### ImagePullBackOff 오류 + +```bash +# Harbor Secret 확인 +kubectl get secret harbor-registry -n vexplor -o yaml + +# Secret 재생성 +kubectl delete secret harbor-registry -n vexplor +kubectl create secret docker-registry harbor-registry \ + --docker-server=192.168.1.100:5001 \ + --docker-username= \ + --docker-password= \ + -n vexplor +``` + +### Ingress가 작동하지 않을 때 + +```bash +# Ingress 상태 확인 +kubectl get ingress -n vexplor +kubectl describe ingress vexplor-ingress -n vexplor + +# Ingress Controller 로그 +kubectl logs -f deployment/ingress-nginx-controller -n ingress-nginx +``` + +### SSL 인증서 문제 + +```bash +# Certificate 상태 확인 +kubectl get certificate -n vexplor +kubectl describe certificate vexplor-tls -n vexplor + +# Cert-Manager 로그 +kubectl logs -f deployment/cert-manager -n cert-manager +``` + +--- + +## 네트워크 설정 + +### 방화벽 포트 개방 + +쿠버네티스 서버에서 다음 포트가 개방되어야 합니다: + +| 포트 | 용도 | +|-----|------| +| 30080 | HTTP (Ingress NodePort) | +| 30443 | HTTPS (Ingress NodePort) | +| 6443 | Kubernetes API | + +### DNS 설정 + +다음 도메인이 쿠버네티스 서버 IP를 가리키도록 설정: + +- `v1.vexplor.com` → 112.168.212.142 +- `api.vexplor.com` → 112.168.212.142 + +--- + +## 환경 변수 + +### Backend 환경 변수 + +| 변수 | 설명 | 소스 | +|-----|------|-----| +| `NODE_ENV` | 환경 (production) | ConfigMap | +| `PORT` | 서버 포트 (3001) | ConfigMap | +| `DATABASE_URL` | PostgreSQL 연결 문자열 | Secret | +| `JWT_SECRET` | JWT 서명 키 | Secret | +| `JWT_EXPIRES_IN` | JWT 만료 시간 | ConfigMap | +| `CORS_ORIGIN` | CORS 허용 도메인 | ConfigMap | + +### Frontend 환경 변수 + +| 변수 | 설명 | 소스 | +|-----|------|-----| +| `NODE_ENV` | 환경 (production) | ConfigMap | +| `NEXT_PUBLIC_API_URL` | 클라이언트 API URL | ConfigMap | +| `SERVER_API_URL` | SSR용 내부 API URL | Deployment | + +--- + +## 참고 자료 + +- [Kubernetes 공식 문서](https://kubernetes.io/docs/) +- [Gitea Actions 문서](https://docs.gitea.com/usage/actions/overview) +- [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/) +- [Cert-Manager](https://cert-manager.io/docs/) +- [Harbor Registry](https://goharbor.io/docs/) + diff --git a/frontend/app/api/fleet-sso/token/route.ts b/frontend/app/api/fleet-sso/token/route.ts new file mode 100644 index 00000000..0dbfd656 --- /dev/null +++ b/frontend/app/api/fleet-sso/token/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { cookies } from "next/headers"; + +const FLEET_API_URL = process.env.FLEET_API_URL || "https://fleet-api.vexplor.com"; +const SSO_SHARED_SECRET = process.env.SSO_SHARED_SECRET || "change_this_sso_secret"; + +export async function POST(request: NextRequest) { + try { + // V1 로그인 세션에서 사용자 정보 추출 + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("session_token")?.value + || cookieStore.get("token")?.value; + + if (!sessionToken) { + return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + } + + // 세션에서 사용자 정보 파싱 (JWT 디코딩) + let userId = "unknown"; + let userName = "unknown"; + let companyId = ""; + try { + const payload = JSON.parse(atob(sessionToken.split(".")[1])); + userId = payload.userId || payload.user_id || payload.sub || payload.id || "unknown"; + userName = payload.userName || payload.user_name || payload.name || "unknown"; + companyId = payload.companyId || payload.company_id || payload.companyCode || ""; + } catch { + return NextResponse.json({ error: "세션이 유효하지 않습니다." }, { status: 401 }); + } + + // Fleet API로 SSO 토큰 요청 (HMAC 서명) + const timestamp = Math.floor(Date.now() / 1000); + const signPayload = `${userId}|${userName}|${companyId}|${timestamp}`; + const signature = crypto + .createHmac("sha256", SSO_SHARED_SECRET) + .update(signPayload) + .digest("hex"); + + const response = await fetch(`${FLEET_API_URL}/api/auth/sso`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user_id: userId, + user_name: userName, + company_id: companyId, + timestamp, + signature, + }), + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + return NextResponse.json( + { error: data.message || "SSO 토큰 발급 실패" }, + { status: response.status }, + ); + } + + return NextResponse.json({ token: data.data.token }); + } catch (error) { + console.error("[fleet-sso] 토큰 발급 에러:", error); + return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + } +} diff --git a/frontend/app/api/system/raw-token/route.ts b/frontend/app/api/system/raw-token/route.ts new file mode 100644 index 00000000..225d50d4 --- /dev/null +++ b/frontend/app/api/system/raw-token/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +export async function GET() { + try { + const cookieStore = await cookies(); + const token = + cookieStore.get("authToken")?.value || cookieStore.get("session_token")?.value || cookieStore.get("token")?.value; + + if (!token) { + return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + } + + return NextResponse.json({ token }); + } catch { + return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + } +} diff --git a/frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx b/frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx new file mode 100644 index 00000000..a193b139 --- /dev/null +++ b/frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx @@ -0,0 +1,236 @@ +"use client"; + +import React, { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Globe, Shield, Settings, ChevronDown, Info, Copy, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { V2WebViewConfig } from "@/lib/registry/components/v2-web-view/types"; + +interface V2WebViewConfigPanelProps { + config: V2WebViewConfig; + onChange: (config: Partial) => void; +} + +const SSO_GUIDE_SNIPPET = `// URL에서 sso_token 파라미터를 읽어 JWT를 디코딩하세요 +const token = url.searchParams.get("sso_token"); +const payload = JSON.parse(atob(token.split(".")[1])); +// payload.userId, payload.userName, payload.companyCode`; + +export const V2WebViewConfigPanel: React.FC = ({ config, onChange }) => { + const [advancedOpen, setAdvancedOpen] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopySnippet = () => { + navigator.clipboard.writeText(SSO_GUIDE_SNIPPET).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const updateConfig = (field: keyof V2WebViewConfig, value: any) => { + const newConfig = { ...config, [field]: value }; + onChange({ [field]: value }); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("componentConfigChanged", { + detail: { config: newConfig }, + }), + ); + } + }; + + return ( +
+ {/* ─── 1단계: URL 입력 ─── */} +
+
+ +

웹페이지 URL

+
+ updateConfig("url", e.target.value)} + placeholder="https://example.com" + className="h-8 text-sm" + /> +

임베드할 외부 웹페이지 주소를 입력하세요

+
+ + {/* ─── 2단계: SSO 연동 ─── */} +
+
+
+ +
+

SSO 연동

+

현재 로그인 토큰을 URL에 자동 전달해요

+
+
+ updateConfig("useSSO", checked)} /> +
+ + {config.useSSO && ( + + + + + +
+
+

전달 방식

+

URL 쿼리 파라미터로 JWT가 전달됩니다.

+ ?sso_token=eyJhbGciOi... +
+ +
+

JWT Payload 구조

+
+
+ userId + 사용자 ID +
+
+ userName + 사용자 이름 +
+
+ companyCode + 회사 코드 +
+
+ role + 권한 +
+
+
+ +
+
+

수신측 예시 코드

+ +
+
+                    {`const token = url.searchParams
+  .get("sso_token");
+const payload = JSON.parse(
+  atob(token.split(".")[1])
+);
+// payload.userId
+// payload.companyCode`}
+                  
+
+
+
+
+ )} +
+ + {/* ─── 3단계: 표시 옵션 ─── */} +
+
+
+

테두리 표시

+

웹 뷰 주변에 테두리를 표시해요

+
+ updateConfig("showBorder", checked)} + /> +
+ +
+
+

전체 화면 허용

+

임베드된 페이지에서 전체 화면 전환이 가능해요

+
+ updateConfig("allowFullscreen", checked)} + /> +
+
+ + {/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */} + + + + + +
+
+
+

샌드박스 모드

+

보안을 위해 iframe 실행 환경을 제한해요

+
+ updateConfig("sandbox", checked)} + /> +
+ +
+ 모서리 둥글기 + updateConfig("borderRadius", e.target.value)} + placeholder="8px" + className="h-7 w-[100px] text-xs" + /> +
+ +
+ 로딩 텍스트 + updateConfig("loadingText", e.target.value)} + placeholder="로딩 중..." + className="h-7 w-[140px] text-xs" + /> +
+
+
+
+
+ ); +}; + +V2WebViewConfigPanel.displayName = "V2WebViewConfigPanel"; + +export default V2WebViewConfigPanel; diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index f3f4e552..650c0be8 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -120,6 +120,7 @@ import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록 +import "./v2-web-view/V2WebViewRenderer"; // 외부 웹페이지 임베딩 (SSO 지원) /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx b/frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx new file mode 100644 index 00000000..c7118beb --- /dev/null +++ b/frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { ComponentRendererProps } from "../../types"; +import { V2WebViewConfig } from "./types"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; + +export interface V2WebViewComponentProps extends ComponentRendererProps {} + +export const V2WebViewComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + ...props +}) => { + const config = (component.componentConfig || {}) as V2WebViewConfig; + const [iframeSrc, setIframeSrc] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const iframeRef = useRef(null); + + const baseUrl = config.url ?? ""; + + useEffect(() => { + if (!baseUrl) { + setIframeSrc(null); + return; + } + + if (!config.useSSO) { + setIframeSrc(baseUrl); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + const paramName = "sso_token"; + + fetch("/api/system/raw-token") + .then((r) => r.json()) + .then((data) => { + if (cancelled) return; + if (data.token) { + const separator = baseUrl.includes("?") ? "&" : "?"; + setIframeSrc(`${baseUrl}${separator}${encodeURIComponent(paramName)}=${encodeURIComponent(data.token)}`); + } else { + setError(data.error ?? "토큰을 가져올 수 없습니다"); + } + }) + .catch(() => { + if (!cancelled) setError("토큰 조회 실패"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [baseUrl, config.useSSO]); + + const containerStyle: React.CSSProperties = { + position: "absolute", + left: `${component.style?.positionX || 0}px`, + top: `${component.style?.positionY || 0}px`, + width: `${component.style?.width || 400}px`, + height: `${component.style?.height || 300}px`, + zIndex: component.style?.positionZ || 1, + cursor: isDesignMode ? "pointer" : "default", + border: isSelected ? "2px solid #3b82f6" : config.showBorder ? "1px solid #e0e0e0" : "none", + borderRadius: config.borderRadius || "8px", + overflow: "hidden", + background: "#fafafa", + }; + + const handleClick = (e: React.MouseEvent) => { + if (isDesignMode) { + e.stopPropagation(); + onClick?.(e); + } + }; + + const domProps = filterDOMProps(props); + + // 디자인 모드: URL 미리보기 표시 + if (isDesignMode) { + return ( +
+
+ + + + + 웹 뷰 + {baseUrl ? ( + + {baseUrl} + + ) : ( + URL을 설정하세요 + )} + {config.useSSO && SSO: ?sso_token=JWT} +
+
+ ); + } + + // 런타임 모드 + return ( +
+ {loading && ( +
+ {config.loadingText || "로딩 중..."} +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && iframeSrc && ( +