From 26ede5830c102d9e4d3b39cb29fa8df443f1927f Mon Sep 17 00:00:00 2001 From: chpark Date: Mon, 22 Sep 2025 17:46:23 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=EC=9C=88=EB=8F=84=EC=9A=B0=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=ED=8C=8C=EC=9D=BC=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/Dockerfile.win | 28 ++++++ .../src/controllers/multilangController.ts | 2 +- docker-compose.backend.win.yml | 12 ++- docker-compose.frontend.win.yml | 9 +- docker-compose.win.yml | 3 +- run-windows.bat | 11 ++- start-all-separated.bat | 13 ++- start-windows-simple.bat | 97 +++++++++++++++++++ test-backend-build.bat | 47 +++++++++ 9 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 backend-node/Dockerfile.win create mode 100644 start-windows-simple.bat create mode 100644 test-backend-build.bat diff --git a/backend-node/Dockerfile.win b/backend-node/Dockerfile.win new file mode 100644 index 00000000..c1ab5ec8 --- /dev/null +++ b/backend-node/Dockerfile.win @@ -0,0 +1,28 @@ +# Windows 개발 환경 전용 Dockerfile (단순 개발 모드) +FROM node:20-bookworm-slim + +WORKDIR /app + +# 필요한 패키지 설치 (wget 포함) +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates wget \ + && rm -rf /var/lib/apt/lists/* + +# package.json 복사 및 의존성 설치 +COPY package*.json ./ +RUN npm ci + +# 소스 코드 복사 +COPY . . + +# Prisma 클라이언트 생성 +RUN npx prisma generate + +# 개발 환경 설정 +ENV NODE_ENV=development + +# 포트 노출 +EXPOSE 8080 + +# 개발 서버 시작 (nodemon 사용) +CMD ["npm", "run", "dev"] diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 65600672..25cfb1b3 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -103,7 +103,7 @@ export const getBatchTranslations = async ( } // 2. 모든 key_id를 추출 - const keyIds = langKeyMasters.map((master) => master.key_id); + const keyIds = langKeyMasters.map((master: any) => master.key_id); // 3. 요청된 언어와 한국어 번역을 한번에 조회 const translations = await prisma.$queryRaw` diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index 67557614..bef844dc 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -5,7 +5,7 @@ services: backend: build: context: ./backend-node - dockerfile: Dockerfile + dockerfile: Dockerfile.win container_name: pms-backend-win ports: - "8080:8080" @@ -21,16 +21,18 @@ services: volumes: - ./backend-node:/app - /app/node_modules + - /app/dist networks: - pms-network restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health", "||", "exit", "1"] interval: 30s - timeout: 10s - retries: 3 - start_period: 60s + timeout: 15s + retries: 5 + start_period: 90s networks: pms-network: driver: bridge + external: false diff --git a/docker-compose.frontend.win.yml b/docker-compose.frontend.win.yml index d8865567..db9722d8 100644 --- a/docker-compose.frontend.win.yml +++ b/docker-compose.frontend.win.yml @@ -11,6 +11,7 @@ services: - "9771:3000" environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api + - WATCHPACK_POLLING=true volumes: - ./frontend:/app - /app/node_modules @@ -18,8 +19,14 @@ services: networks: - pms-network restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000", "||", "exit", "1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s networks: pms-network: driver: bridge - external: true + external: false diff --git a/docker-compose.win.yml b/docker-compose.win.yml index 67d83bc6..844266ea 100644 --- a/docker-compose.win.yml +++ b/docker-compose.win.yml @@ -59,4 +59,5 @@ volumes: networks: plm-network: - driver: bridge \ No newline at end of file + driver: bridge + external: false \ No newline at end of file diff --git a/run-windows.bat b/run-windows.bat index 39c84ab3..b5490e27 100644 --- a/run-windows.bat +++ b/run-windows.bat @@ -1,14 +1,19 @@ @echo off +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + echo ===================================== echo PLM 솔루션 - Windows 시작 echo ===================================== -echo 기존 컨테이너 정리 중... -docker-compose -f docker-compose.win.yml down 2>nul +echo 기존 컨테이너 및 네트워크 정리 중... +docker-compose -f docker-compose.win.yml down -v 2>nul +docker network rm plm-network 2>nul echo PLM 서비스 시작 중... -docker-compose -f docker-compose.win.yml up --build --force-recreate -d +docker-compose -f docker-compose.win.yml build --no-cache +docker-compose -f docker-compose.win.yml up -d if %errorlevel% equ 0 ( echo. diff --git a/start-all-separated.bat b/start-all-separated.bat index b7bb3725..7c580aca 100644 --- a/start-all-separated.bat +++ b/start-all-separated.bat @@ -1,6 +1,9 @@ @echo off chcp 65001 >nul +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + echo ============================================ echo PLM 솔루션 - 전체 서비스 시작 (분리형) echo ============================================ @@ -14,9 +17,13 @@ echo ============================================ echo 1. 백엔드 서비스 시작 중... echo ============================================ +REM 기존 컨테이너 및 네트워크 정리 +docker-compose -f docker-compose.backend.win.yml down -v 2>nul +docker-compose -f docker-compose.frontend.win.yml down -v 2>nul +docker network rm pms-network 2>nul + +REM 백엔드 빌드 및 시작 docker-compose -f docker-compose.backend.win.yml build --no-cache -docker-compose -f docker-compose.backend.win.yml down -v -docker network create pms-network 2>nul || echo 네트워크가 이미 존재합니다. docker-compose -f docker-compose.backend.win.yml up -d echo. @@ -29,8 +36,8 @@ echo ============================================ echo 2. 프론트엔드 서비스 시작 중... echo ============================================ +REM 프론트엔드 빌드 및 시작 docker-compose -f docker-compose.frontend.win.yml build --no-cache -docker-compose -f docker-compose.frontend.win.yml down -v docker-compose -f docker-compose.frontend.win.yml up -d echo. diff --git a/start-windows-simple.bat b/start-windows-simple.bat new file mode 100644 index 00000000..a5c96fa7 --- /dev/null +++ b/start-windows-simple.bat @@ -0,0 +1,97 @@ +@echo off +chcp 65001 >nul + +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + +echo ============================================ +echo PLM 솔루션 - 윈도우 간편 시작 +echo ============================================ +echo. + +REM Docker Desktop 실행 확인 +echo 🔍 Docker Desktop 상태 확인 중... +docker --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ❌ Docker Desktop이 실행되지 않았습니다! + echo Docker Desktop을 먼저 실행해주세요. + echo. + pause + exit /b 1 +) + +echo ✅ Docker Desktop이 실행 중입니다. +echo. + +REM 기존 컨테이너 정리 +echo 🧹 기존 컨테이너 정리 중... +docker-compose -f docker-compose.backend.win.yml down -v 2>nul +docker-compose -f docker-compose.frontend.win.yml down -v 2>nul +docker network rm pms-network 2>nul +echo. + +REM 백엔드 시작 +echo ============================================ +echo 🚀 1단계: 백엔드 서비스 시작 중... +echo ============================================ +docker-compose -f docker-compose.backend.win.yml up -d --build + +if %errorlevel% neq 0 ( + echo ❌ 백엔드 시작 실패! + echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs + pause + exit /b 1 +) + +echo ✅ 백엔드 서비스 시작 완료 +echo ⏳ 백엔드 안정화 대기 중... (30초) +timeout /t 30 /nobreak >nul + +REM 프론트엔드 시작 +echo. +echo ============================================ +echo 🎨 2단계: 프론트엔드 서비스 시작 중... +echo ============================================ +docker-compose -f docker-compose.frontend.win.yml up -d --build + +if %errorlevel% neq 0 ( + echo ❌ 프론트엔드 시작 실패! + echo 로그를 확인하세요: docker-compose -f docker-compose.frontend.win.yml logs + pause + exit /b 1 +) + +echo ✅ 프론트엔드 서비스 시작 완료 +echo ⏳ 프론트엔드 안정화 대기 중... (15초) +timeout /t 15 /nobreak >nul + +echo. +echo ============================================ +echo 🎉 PLM 솔루션이 성공적으로 시작되었습니다! +echo ============================================ +echo. +echo 📱 접속 정보: +echo • 프론트엔드: http://localhost:9771 +echo • 백엔드 API: http://localhost:8080/api +echo • 데이터베이스: 39.117.244.52:11132 +echo. +echo 📊 서비스 상태 확인: +echo docker-compose -f docker-compose.backend.win.yml ps +echo docker-compose -f docker-compose.frontend.win.yml ps +echo. +echo 📋 로그 확인: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f +echo. +echo 🛑 서비스 중지: +echo stop-all-separated.bat 실행 +echo. + +REM 브라우저 자동 열기 +echo 5초 후 브라우저에서 애플리케이션을 엽니다... +timeout /t 5 /nobreak >nul +start http://localhost:9771 + +echo. +echo 애플리케이션이 준비되었습니다! +pause diff --git a/test-backend-build.bat b/test-backend-build.bat new file mode 100644 index 00000000..dad4aaee --- /dev/null +++ b/test-backend-build.bat @@ -0,0 +1,47 @@ +@echo off +chcp 65001 >nul + +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + +echo ============================================ +echo 백엔드 빌드 테스트 (Windows 전용) +echo ============================================ +echo. + +echo 🔍 기존 컨테이너 정리 중... +docker-compose -f docker-compose.backend.win.yml down -v 2>nul + +echo. +echo 🚀 백엔드 빌드 시작... +docker-compose -f docker-compose.backend.win.yml build --no-cache + +if %errorlevel% equ 0 ( + echo. + echo ✅ 백엔드 빌드 성공! + echo. + echo 🚀 백엔드 시작 중... + docker-compose -f docker-compose.backend.win.yml up -d + + if %errorlevel% equ 0 ( + echo ✅ 백엔드 시작 완료! + echo. + echo 📊 컨테이너 상태: + docker-compose -f docker-compose.backend.win.yml ps + echo. + echo 📋 로그 확인: + echo docker-compose -f docker-compose.backend.win.yml logs -f + echo. + echo 🌐 헬스체크: + echo http://localhost:8080/health + ) else ( + echo ❌ 백엔드 시작 실패! + echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs + ) +) else ( + echo ❌ 백엔드 빌드 실패! + echo 위의 오류 메시지를 확인하세요. +) + +echo. +pause From c78f152f6808ee461ceaf79b401d86bfc73c3a0c Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Sep 2025 18:16:24 +0900 Subject: [PATCH 2/8] Resolve merge conflicts: Use main branch versions for conflicting files - Update multilangController.ts to main branch version - Add Windows development environment files from main - Include batch files for Windows development support --- .../src/controllers/multilangController.ts | 881 +++++------------- docker-compose.backend.win.yml | 36 + docker-compose.frontend.win.yml | 25 + docker-compose.win.yml | 62 ++ run-windows.bat | 40 + start-all-separated.bat | 64 ++ 테이블_타입_관리_개선_계획서.md | 472 ++++++++++ 7 files changed, 946 insertions(+), 634 deletions(-) create mode 100644 docker-compose.backend.win.yml create mode 100644 docker-compose.frontend.win.yml create mode 100644 docker-compose.win.yml create mode 100644 run-windows.bat create mode 100644 start-all-separated.bat create mode 100644 테이블_타입_관리_개선_계획서.md diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 14155f86..65600672 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1,496 +1,202 @@ -import { Request, Response } from "express"; -import { logger } from "../utils/logger"; +import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; -import { MultiLangService } from "../services/multilangService"; -import { - CreateLanguageRequest, - UpdateLanguageRequest, - CreateLangKeyRequest, - UpdateLangKeyRequest, - SaveLangTextsRequest, - GetUserTextParams, - BatchTranslationRequest, - ApiResponse, -} from "../types/multilang"; +import { logger } from "../utils/logger"; +import prisma from "../config/database"; + +// 메모리 캐시 (개발 환경용, 운영에서는 Redis 사용 권장) +const translationCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5분 + +interface CacheEntry { + data: any; + timestamp: number; +} /** - * GET /api/multilang/languages - * 언어 목록 조회 API + * GET /api/multilang/batch + * 다국어 텍스트 배치 조회 API - 여러 키를 한번에 조회 */ -export const getLanguages = async ( +export const getBatchTranslations = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { - logger.info("언어 목록 조회 요청", { user: req.user }); + const { companyCode, menuCode, userLang } = req.query; + const { langKeys } = req.body; // 배열로 여러 키 전달 - const multiLangService = new MultiLangService(); - const languages = await multiLangService.getLanguages(); - - const response: ApiResponse = { - success: true, - message: "언어 목록 조회 성공", - data: languages, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("언어 목록 조회 실패:", error); - res.status(500).json({ - success: false, - message: "언어 목록 조회 중 오류가 발생했습니다.", - error: { - code: "LANGUAGE_LIST_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * POST /api/multilang/languages - * 언어 생성 API - */ -export const createLanguage = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const languageData: CreateLanguageRequest = req.body; - logger.info("언어 생성 요청", { languageData, user: req.user }); - - // 필수 입력값 검증 - if ( - !languageData.langCode || - !languageData.langName || - !languageData.langNative - ) { - res.status(400).json({ - success: false, - message: "언어 코드, 언어명, 원어명은 필수입니다.", - error: { - code: "MISSING_REQUIRED_FIELDS", - details: "langCode, langName, langNative are required", - }, - }); - return; - } - - const multiLangService = new MultiLangService(); - const createdLanguage = await multiLangService.createLanguage({ - ...languageData, - createdBy: req.user?.userId || "system", - updatedBy: req.user?.userId || "system", - }); - - const response: ApiResponse = { - success: true, - message: "언어가 성공적으로 생성되었습니다.", - data: createdLanguage, - }; - - res.status(201).json(response); - } catch (error) { - logger.error("언어 생성 실패:", error); - res.status(500).json({ - success: false, - message: "언어 생성 중 오류가 발생했습니다.", - error: { - code: "LANGUAGE_CREATE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * PUT /api/multilang/languages/:langCode - * 언어 수정 API - */ -export const updateLanguage = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { langCode } = req.params; - const languageData: UpdateLanguageRequest = req.body; - - logger.info("언어 수정 요청", { langCode, languageData, user: req.user }); - - const multiLangService = new MultiLangService(); - const updatedLanguage = await multiLangService.updateLanguage(langCode, { - ...languageData, - updatedBy: req.user?.userId || "system", - }); - - const response: ApiResponse = { - success: true, - message: "언어가 성공적으로 수정되었습니다.", - data: updatedLanguage, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("언어 수정 실패:", error); - res.status(500).json({ - success: false, - message: "언어 수정 중 오류가 발생했습니다.", - error: { - code: "LANGUAGE_UPDATE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * PUT /api/multilang/languages/:langCode/toggle - * 언어 상태 토글 API - */ -export const toggleLanguage = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { langCode } = req.params; - logger.info("언어 상태 토글 요청", { langCode, user: req.user }); - - const multiLangService = new MultiLangService(); - const result = await multiLangService.toggleLanguage(langCode); - - const response: ApiResponse = { - success: true, - message: `언어가 ${result}되었습니다.`, - data: result, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("언어 상태 토글 실패:", error); - res.status(500).json({ - success: false, - message: "언어 상태 변경 중 오류가 발생했습니다.", - error: { - code: "LANGUAGE_TOGGLE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * GET /api/multilang/keys - * 다국어 키 목록 조회 API - */ -export const getLangKeys = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { companyCode, menuCode, keyType, searchText } = req.query; - logger.info("다국어 키 목록 조회 요청", { - query: req.query, + logger.info("다국어 텍스트 배치 조회 요청", { + companyCode, + menuCode, + userLang, + keyCount: langKeys?.length || 0, user: req.user, }); - const multiLangService = new MultiLangService(); - const langKeys = await multiLangService.getLangKeys({ - companyCode: companyCode as string, - menuCode: menuCode as string, - keyType: keyType as string, - searchText: searchText as string, - }); - - const response: ApiResponse = { - success: true, - message: "다국어 키 목록 조회 성공", - data: langKeys, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("다국어 키 목록 조회 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 키 목록 조회 중 오류가 발생했습니다.", - error: { - code: "LANG_KEYS_LIST_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * GET /api/multilang/keys/:keyId/texts - * 특정 키의 다국어 텍스트 조회 API - */ -export const getLangTexts = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { keyId } = req.params; - logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); - - const multiLangService = new MultiLangService(); - const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); - - const response: ApiResponse = { - success: true, - message: "다국어 텍스트 조회 성공", - data: langTexts, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("다국어 텍스트 조회 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 텍스트 조회 중 오류가 발생했습니다.", - error: { - code: "LANG_TEXTS_LIST_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * POST /api/multilang/keys - * 다국어 키 생성 API - */ -export const createLangKey = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const keyData: CreateLangKeyRequest = req.body; - logger.info("다국어 키 생성 요청", { keyData, user: req.user }); - - // 필수 입력값 검증 - if (!keyData.companyCode || !keyData.langKey) { + if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { res.status(400).json({ success: false, - message: "회사 코드와 언어 키는 필수입니다.", - error: { - code: "MISSING_REQUIRED_FIELDS", - details: "companyCode and langKey are required", - }, + message: "langKeys 배열이 필요합니다.", }); return; } - const multiLangService = new MultiLangService(); - const keyId = await multiLangService.createLangKey({ - ...keyData, - createdBy: req.user?.userId || "system", - updatedBy: req.user?.userId || "system", + // 캐시 키 생성 + const cacheKey = `${companyCode}_${userLang}_${langKeys.sort().join("_")}`; + + // 캐시 확인 + const cached = translationCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + logger.info("캐시된 번역 데이터 사용", { + cacheKey, + keyCount: langKeys.length, + }); + res.status(200).json({ + success: true, + data: cached.data, + message: "캐시된 다국어 텍스트 조회 성공", + fromCache: true, + }); + return; + } + + // 1. 모든 키에 대한 마스터 정보를 한번에 조회 + logger.info("다국어 키 마스터 배치 조회 시작", { + keyCount: langKeys.length, }); - const response: ApiResponse = { - success: true, - message: "다국어 키가 성공적으로 생성되었습니다.", - data: keyId, - }; + const langKeyMasters = await prisma.$queryRaw` + SELECT key_id, lang_key, company_code + FROM multi_lang_key_master + WHERE lang_key = ANY(${langKeys}::varchar[]) + AND (company_code = ${companyCode}::varchar OR company_code = '*') + ORDER BY + CASE WHEN company_code = ${companyCode}::varchar THEN 1 ELSE 2 END, + lang_key, + company_code + `; - res.status(201).json(response); - } catch (error) { - logger.error("다국어 키 생성 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 키 생성 중 오류가 발생했습니다.", - error: { - code: "LANG_KEY_CREATE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * PUT /api/multilang/keys/:keyId - * 다국어 키 수정 API - */ -export const updateLangKey = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { keyId } = req.params; - const keyData: UpdateLangKeyRequest = req.body; - - logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); - - const multiLangService = new MultiLangService(); - await multiLangService.updateLangKey(parseInt(keyId), { - ...keyData, - updatedBy: req.user?.userId || "system", + logger.info("다국어 키 마스터 배치 조회 결과", { + requestedKeys: langKeys.length, + foundKeys: langKeyMasters.length, }); - const response: ApiResponse = { - success: true, - message: "다국어 키가 성공적으로 수정되었습니다.", - data: "수정 완료", - }; + if (langKeyMasters.length === 0) { + // 마스터 데이터가 없으면 기본값 반환 + const defaultTranslations = getDefaultTranslations( + langKeys, + userLang as string + ); - res.status(200).json(response); - } catch (error) { - logger.error("다국어 키 수정 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 키 수정 중 오류가 발생했습니다.", - error: { - code: "LANG_KEY_UPDATE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, + // 캐시에 저장 + translationCache.set(cacheKey, { + data: defaultTranslations, + timestamp: Date.now(), + }); + + res.status(200).json({ + success: true, + data: defaultTranslations, + message: "기본값으로 다국어 텍스트 조회 성공", + fromCache: false, + }); + return; + } + + // 2. 모든 key_id를 추출 + const keyIds = langKeyMasters.map((master) => master.key_id); + + // 3. 요청된 언어와 한국어 번역을 한번에 조회 + const translations = await prisma.$queryRaw` + SELECT + mlt.key_id, + mlt.lang_code, + mlt.lang_text, + mlkm.lang_key + FROM multi_lang_text mlt + JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id + WHERE mlt.key_id = ANY(${keyIds}::numeric[]) + AND mlt.lang_code IN (${userLang}::varchar, 'KR') + ORDER BY + mlt.key_id, + CASE WHEN mlt.lang_code = ${userLang}::varchar THEN 1 ELSE 2 END + `; + + logger.info("번역 텍스트 배치 조회 결과", { + keyIds: keyIds.length, + translations: translations.length, }); - } -}; -/** - * DELETE /api/multilang/keys/:keyId - * 다국어 키 삭제 API - */ -export const deleteLangKey = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { keyId } = req.params; - logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); + // 4. 결과를 키별로 정리 + const result: Record = {}; - const multiLangService = new MultiLangService(); - await multiLangService.deleteLangKey(parseInt(keyId)); + for (const langKey of langKeys) { + const master = langKeyMasters.find((m) => m.lang_key === langKey); - const response: ApiResponse = { - success: true, - message: "다국어 키가 성공적으로 삭제되었습니다.", - data: "삭제 완료", - }; + if (master) { + const keyId = master.key_id; - res.status(200).json(response); - } catch (error) { - logger.error("다국어 키 삭제 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 키 삭제 중 오류가 발생했습니다.", - error: { - code: "LANG_KEY_DELETE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; + // 요청된 언어 번역 찾기 + let translation = translations.find( + (t) => t.key_id === keyId && t.lang_code === userLang + ); -/** - * PUT /api/multilang/keys/:keyId/toggle - * 다국어 키 상태 토글 API - */ -export const toggleLangKey = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { keyId } = req.params; - logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); + // 요청된 언어가 없으면 한국어 번역 찾기 + if (!translation) { + translation = translations.find( + (t) => t.key_id === keyId && t.lang_code === "KR" + ); + } - const multiLangService = new MultiLangService(); - const result = await multiLangService.toggleLangKey(parseInt(keyId)); + // 번역이 있으면 사용, 없으면 기본값 + if (translation) { + result[langKey] = translation.lang_text; + } else { + result[langKey] = getDefaultTranslation(langKey, userLang as string); + } + } else { + // 마스터 데이터가 없으면 기본값 + result[langKey] = getDefaultTranslation(langKey, userLang as string); + } + } - const response: ApiResponse = { - success: true, - message: `다국어 키가 ${result}되었습니다.`, + // 5. 캐시에 저장 + translationCache.set(cacheKey, { data: result, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("다국어 키 상태 토글 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 키 상태 변경 중 오류가 발생했습니다.", - error: { - code: "LANG_KEY_TOGGLE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * POST /api/multilang/keys/:keyId/texts - * 다국어 텍스트 저장/수정 API - */ -export const saveLangTexts = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { keyId } = req.params; - const textData: SaveLangTextsRequest = req.body; - - logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); - - // 필수 입력값 검증 - if ( - !textData.texts || - !Array.isArray(textData.texts) || - textData.texts.length === 0 - ) { - res.status(400).json({ - success: false, - message: "텍스트 데이터는 필수입니다.", - error: { - code: "MISSING_REQUIRED_FIELDS", - details: "texts array is required", - }, - }); - return; - } - - const multiLangService = new MultiLangService(); - await multiLangService.saveLangTexts(parseInt(keyId), { - texts: textData.texts.map((text) => ({ - ...text, - createdBy: req.user?.userId || "system", - updatedBy: req.user?.userId || "system", - })), + timestamp: Date.now(), }); - const response: ApiResponse = { + logger.info("다국어 텍스트 배치 조회 완료", { + requestedKeys: langKeys.length, + resultKeys: Object.keys(result).length, + cacheKey, + }); + + res.status(200).json({ success: true, - message: "다국어 텍스트가 성공적으로 저장되었습니다.", - data: "저장 완료", - }; - - res.status(200).json(response); + data: result, + message: "다국어 텍스트 배치 조회 성공", + fromCache: false, + }); } catch (error) { - logger.error("다국어 텍스트 저장 실패:", error); + logger.error("다국어 텍스트 배치 조회 실패", { error }); res.status(500).json({ success: false, - message: "다국어 텍스트 저장 중 오류가 발생했습니다.", - error: { - code: "LANG_TEXTS_SAVE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, + message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", }); } }; /** * GET /api/multilang/user-text/:companyCode/:menuCode/:langKey - * 사용자별 다국어 텍스트 조회 API + * 단일 다국어 텍스트 조회 API (하위 호환성 유지) */ -export const getUserText = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { +export const getUserText = async (req: AuthenticatedRequest, res: Response) => { try { const { companyCode, menuCode, langKey } = req.params; const { userLang } = req.query; - logger.info("사용자별 다국어 텍스트 조회 요청", { + logger.info("단일 다국어 텍스트 조회 요청", { companyCode, menuCode, langKey, @@ -498,215 +204,122 @@ export const getUserText = async ( user: req.user, }); - if (!userLang) { - res.status(400).json({ - success: false, - message: "사용자 언어는 필수입니다.", - error: { - code: "MISSING_USER_LANG", - details: "userLang query parameter is required", - }, - }); - return; - } + // 배치 API를 사용하여 단일 키 조회 + const batchResult = await getBatchTranslations( + { + ...req, + body: { langKeys: [langKey] }, + query: { companyCode, menuCode, userLang }, + } as any, + res + ); - const multiLangService = new MultiLangService(); - const langText = await multiLangService.getUserText({ - companyCode, - menuCode, - langKey, - userLang: userLang as string, - }); - - const response: ApiResponse = { - success: true, - message: "사용자별 다국어 텍스트 조회 성공", - data: langText, - }; - - res.status(200).json(response); + // 배치 API에서 이미 응답을 보냈으므로 여기서는 아무것도 하지 않음 + return; } catch (error) { - logger.error("사용자별 다국어 텍스트 조회 실패:", error); + logger.error("단일 다국어 텍스트 조회 실패", { error }); res.status(500).json({ success: false, - message: "사용자별 다국어 텍스트 조회 중 오류가 발생했습니다.", - error: { - code: "USER_TEXT_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, + message: "다국어 텍스트 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", }); } }; /** - * GET /api/multilang/text/:companyCode/:langKey/:langCode - * 특정 키의 다국어 텍스트 조회 API + * 기본 번역 텍스트 반환 (개별 키) */ -export const getLangText = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { companyCode, langKey, langCode } = req.params; +function getDefaultTranslation(langKey: string, userLang: string): string { + const defaultKoreanTexts: Record = { + "button.add": "추가", + "button.add.top.level": "최상위 메뉴 추가", + "button.add.sub": "하위 메뉴 추가", + "button.edit": "수정", + "button.delete": "삭제", + "button.cancel": "취소", + "button.save": "저장", + "button.register": "등록", + "form.menu.name": "메뉴명", + "form.menu.url": "URL", + "form.menu.description": "설명", + "form.menu.type": "메뉴 타입", + "form.status": "상태", + "form.company": "회사", + "table.header.menu.name": "메뉴명", + "table.header.menu.url": "URL", + "table.header.status": "상태", + "table.header.company": "회사", + "table.header.actions": "작업", + "filter.company": "회사", + "filter.search": "검색", + "filter.reset": "초기화", + "menu.type.title": "메뉴 타입", + "menu.type.admin": "관리자", + "menu.type.user": "사용자", + "status.active": "활성화", + "status.inactive": "비활성화", + "form.lang.key": "언어 키", + "form.lang.key.select": "언어 키 선택", + "form.menu.name.placeholder": "메뉴명을 입력하세요", + "form.menu.url.placeholder": "URL을 입력하세요", + "form.menu.description.placeholder": "설명을 입력하세요", + "form.menu.sequence": "순서", + "form.menu.sequence.placeholder": "순서를 입력하세요", + "form.status.active": "활성", + "form.status.inactive": "비활성", + "form.company.select": "회사 선택", + "form.company.common": "공통", + "form.company.submenu.note": "하위메뉴는 회사별로 관리됩니다", + "filter.company.common": "공통", + "filter.search.placeholder": "검색어를 입력하세요", + "modal.menu.register.title": "메뉴 등록", + }; - logger.info("특정 키의 다국어 텍스트 조회 요청", { - companyCode, - langKey, - langCode, + return defaultKoreanTexts[langKey] || langKey; +} + +/** + * 기본 번역 텍스트 반환 (배치) + */ +function getDefaultTranslations( + langKeys: string[], + userLang: string +): Record { + const result: Record = {}; + + for (const langKey of langKeys) { + result[langKey] = getDefaultTranslation(langKey, userLang); + } + + return result; +} + +/** + * 캐시 초기화 (개발/테스트용) + */ +export const clearCache = async (req: AuthenticatedRequest, res: Response) => { + try { + const beforeSize = translationCache.size; + translationCache.clear(); + + logger.info("다국어 캐시 초기화 완료", { + beforeSize, + afterSize: 0, user: req.user, }); - const multiLangService = new MultiLangService(); - const langText = await multiLangService.getLangText( - companyCode, - langKey, - langCode - ); - - const response: ApiResponse = { + res.status(200).json({ success: true, - message: "특정 키의 다국어 텍스트 조회 성공", - data: langText, - }; - - res.status(200).json(response); + message: "캐시가 초기화되었습니다.", + beforeSize, + afterSize: 0, + }); } catch (error) { - logger.error("특정 키의 다국어 텍스트 조회 실패:", error); + logger.error("캐시 초기화 실패", { error }); res.status(500).json({ success: false, - message: "특정 키의 다국어 텍스트 조회 중 오류가 발생했습니다.", - error: { - code: "LANG_TEXT_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * DELETE /api/multilang/languages/:langCode - * 언어 삭제 API - */ -export const deleteLanguage = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const { langCode } = req.params; - logger.info("언어 삭제 요청", { langCode, user: req.user }); - - if (!langCode) { - res.status(400).json({ - success: false, - message: "언어 코드가 필요합니다.", - error: { - code: "MISSING_LANG_CODE", - details: "langCode parameter is required", - }, - }); - return; - } - - const multiLangService = new MultiLangService(); - await multiLangService.deleteLanguage(langCode); - - const response: ApiResponse = { - success: true, - message: "언어가 성공적으로 삭제되었습니다.", - data: "삭제 완료", - }; - - res.status(200).json(response); - } catch (error) { - logger.error("언어 삭제 실패:", error); - res.status(500).json({ - success: false, - message: "언어 삭제 중 오류가 발생했습니다.", - error: { - code: "LANGUAGE_DELETE_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }); - } -}; - -/** - * POST /api/multilang/batch - * 다국어 텍스트 배치 조회 API - */ -export const getBatchTranslations = async ( - req: Request, - res: Response -): Promise => { - try { - const { companyCode, menuCode, userLang } = req.query; - const { - langKeys, - companyCode: bodyCompanyCode, - menuCode: bodyMenuCode, - userLang: bodyUserLang, - } = req.body; - - // query params에서 읽지 못한 경우 body에서 읽기 - const finalCompanyCode = companyCode || bodyCompanyCode; - const finalMenuCode = menuCode || bodyMenuCode; - const finalUserLang = userLang || bodyUserLang; - - logger.info("다국어 텍스트 배치 조회 요청", { - companyCode: finalCompanyCode, - menuCode: finalMenuCode, - userLang: finalUserLang, - keyCount: langKeys?.length || 0, - }); - - if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { - res.status(400).json({ - success: false, - message: "langKeys 배열이 필요합니다.", - error: { - code: "MISSING_LANG_KEYS", - details: "langKeys array is required", - }, - }); - return; - } - - if (!finalCompanyCode || !finalUserLang) { - res.status(400).json({ - success: false, - message: "companyCode와 userLang은 필수입니다.", - error: { - code: "MISSING_REQUIRED_PARAMS", - details: "companyCode and userLang are required", - }, - }); - return; - } - - const multiLangService = new MultiLangService(); - const translations = await multiLangService.getBatchTranslations({ - companyCode: finalCompanyCode as string, - menuCode: finalMenuCode as string, - userLang: finalUserLang as string, - langKeys, - }); - - const response: ApiResponse> = { - success: true, - message: "다국어 텍스트 배치 조회 성공", - data: translations, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("다국어 텍스트 배치 조회 실패:", error); - res.status(500).json({ - success: false, - message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", - error: { - code: "BATCH_TRANSLATION_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, + message: "캐시 초기화 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", }); } }; diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml new file mode 100644 index 00000000..67557614 --- /dev/null +++ b/docker-compose.backend.win.yml @@ -0,0 +1,36 @@ +version: "3.8" + +services: + # Node.js 백엔드 + backend: + build: + context: ./backend-node + dockerfile: Dockerfile + container_name: pms-backend-win + ports: + - "8080:8080" + environment: + - NODE_ENV=development + - PORT=8080 + - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm + - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 + - JWT_EXPIRES_IN=24h + - CORS_ORIGIN=http://localhost:9771 + - CORS_CREDENTIALS=true + - LOG_LEVEL=debug + volumes: + - ./backend-node:/app + - /app/node_modules + networks: + - pms-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +networks: + pms-network: + driver: bridge diff --git a/docker-compose.frontend.win.yml b/docker-compose.frontend.win.yml new file mode 100644 index 00000000..d8865567 --- /dev/null +++ b/docker-compose.frontend.win.yml @@ -0,0 +1,25 @@ +version: "3.8" + +services: + # Next.js 프론트엔드만 + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: pms-frontend-win + ports: + - "9771:3000" + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8080/api + volumes: + - ./frontend:/app + - /app/node_modules + - /app/.next + networks: + - pms-network + restart: unless-stopped + +networks: + pms-network: + driver: bridge + external: true diff --git a/docker-compose.win.yml b/docker-compose.win.yml new file mode 100644 index 00000000..67d83bc6 --- /dev/null +++ b/docker-compose.win.yml @@ -0,0 +1,62 @@ +services: + # 백엔드 서비스 (기존) + plm-app: + build: + context: . + dockerfile: Dockerfile.win + platforms: + - linux/amd64 + container_name: plm-windows + ports: + - "9090:8080" + environment: + - CATALINA_OPTS=-DDB_URL=jdbc:postgresql://39.117.244.52:11132/plm -DDB_USERNAME=postgres -DDB_PASSWORD=ph0909!! -Xms512m -Xmx1024m + - TZ=Asia/Seoul + - APP_ENV=development + - LOG_LEVEL=INFO + - DB_HOST=39.117.244.52 + - DB_PORT=11132 + - DB_NAME=plm + - DB_USERNAME=postgres + - DB_PASSWORD=ph0909!! + volumes: + - plm-win-project:/data_storage + - plm-win-app:/app_data + - ./logs:/usr/local/tomcat/logs + - ./WebContent:/usr/local/tomcat/webapps/ROOT + restart: unless-stopped + networks: + - plm-network + + # 프론트엔드 서비스 (새로 추가) + plm-frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: plm-frontend + ports: + - "3000:3000" + environment: + - NODE_ENV=development + - TZ=Asia/Seoul + - NEXT_PUBLIC_API_URL=http://localhost:9090 + - WATCHPACK_POLLING=true + volumes: + - ./frontend:/app + - /app/node_modules + - /app/.next + depends_on: + - plm-app + restart: unless-stopped + networks: + - plm-network + +volumes: + plm-win-project: + driver: local + plm-win-app: + driver: local + +networks: + plm-network: + driver: bridge \ No newline at end of file diff --git a/run-windows.bat b/run-windows.bat new file mode 100644 index 00000000..39c84ab3 --- /dev/null +++ b/run-windows.bat @@ -0,0 +1,40 @@ +@echo off + +echo ===================================== +echo PLM 솔루션 - Windows 시작 +echo ===================================== + +echo 기존 컨테이너 정리 중... +docker-compose -f docker-compose.win.yml down 2>nul + +echo PLM 서비스 시작 중... +docker-compose -f docker-compose.win.yml up --build --force-recreate -d + +if %errorlevel% equ 0 ( + echo. + echo ✅ PLM 서비스가 성공적으로 시작되었습니다! + echo. + echo 🌐 접속 URL: + echo • 프론트엔드 (Next.js): http://localhost:3000 + echo • 백엔드 (Spring/JSP): http://localhost:9090 + echo. + echo 📋 서비스 상태 확인: + echo docker-compose -f docker-compose.win.yml ps + echo. + echo 📊 로그 확인: + echo docker-compose -f docker-compose.win.yml logs + echo. + echo 5초 후 프론트엔드 페이지를 자동으로 엽니다... + timeout /t 5 /nobreak >nul + start http://localhost:3000 +) else ( + echo. + echo ❌ PLM 서비스 시작에 실패했습니다! + echo. + echo 🔍 문제 해결 방법: + echo 1. Docker Desktop이 실행 중인지 확인 + echo 2. 포트가 사용 중인지 확인 (3000, 9090) + echo 3. 로그 확인: docker-compose -f docker-compose.win.yml logs + echo. + pause +) \ No newline at end of file diff --git a/start-all-separated.bat b/start-all-separated.bat new file mode 100644 index 00000000..b7bb3725 --- /dev/null +++ b/start-all-separated.bat @@ -0,0 +1,64 @@ +@echo off +chcp 65001 >nul + +echo ============================================ +echo PLM 솔루션 - 전체 서비스 시작 (분리형) +echo ============================================ + +echo. +echo 🚀 백엔드와 프론트엔드를 순차적으로 시작합니다... +echo. + +REM 백엔드 먼저 시작 +echo ============================================ +echo 1. 백엔드 서비스 시작 중... +echo ============================================ + +docker-compose -f docker-compose.backend.win.yml build --no-cache +docker-compose -f docker-compose.backend.win.yml down -v +docker network create pms-network 2>nul || echo 네트워크가 이미 존재합니다. +docker-compose -f docker-compose.backend.win.yml up -d + +echo. +echo ⏳ 백엔드 서비스 안정화 대기 중... (20초) +timeout /t 20 /nobreak >nul + +REM 프론트엔드 시작 +echo. +echo ============================================ +echo 2. 프론트엔드 서비스 시작 중... +echo ============================================ + +docker-compose -f docker-compose.frontend.win.yml build --no-cache +docker-compose -f docker-compose.frontend.win.yml down -v +docker-compose -f docker-compose.frontend.win.yml up -d + +echo. +echo ⏳ 프론트엔드 서비스 안정화 대기 중... (10초) +timeout /t 10 /nobreak >nul + +echo. +echo ============================================ +echo 🎉 모든 서비스가 시작되었습니다! +echo ============================================ +echo. +echo [DATABASE] PostgreSQL: http://39.117.244.52:11132 +echo [BACKEND] Spring Boot: http://localhost:8080/api +echo [FRONTEND] Next.js: http://localhost:9771 +echo. +echo 서비스 상태 확인: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml ps +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml ps +echo. +echo 로그 확인: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f +echo. +echo 서비스 중지: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml down +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml down +echo 전체: stop-all-separated.bat +echo. +echo ============================================ + +pause diff --git a/테이블_타입_관리_개선_계획서.md b/테이블_타입_관리_개선_계획서.md new file mode 100644 index 00000000..fd8ab7f1 --- /dev/null +++ b/테이블_타입_관리_개선_계획서.md @@ -0,0 +1,472 @@ +# 테이블 타입 관리 개선 계획서 + +## 🎯 개선 목표 + +현재 테이블 타입 관리 시스템의 용어 통일과 타입 단순화를 통해 사용자 친화적이고 유연한 시스템으로 개선합니다. + +## 📋 주요 변경사항 + +### 1. 용어 변경 + +- **웹 타입(Web Type)** → **입력 타입(Input Type)** +- 사용자에게 더 직관적인 명칭으로 변경 + +### 2. 입력 타입 단순화 + +기존 20개 타입에서 **8개 핵심 타입**으로 단순화: + +| 번호 | 입력 타입 | 설명 | 예시 | +| ---- | ---------- | ---------- | -------------------- | +| 1 | `text` | 텍스트 | 이름, 제목, 설명 | +| 2 | `number` | 숫자 | 수량, 건수, 순번 | +| 3 | `date` | 날짜 | 생성일, 완료일, 기한 | +| 4 | `code` | 코드 | 상태코드, 유형코드 | +| 5 | `entity` | 엔티티 | 고객선택, 제품선택 | +| 6 | `select` | 선택박스 | 드롭다운 목록 | +| 7 | `checkbox` | 체크박스 | Y/N, 사용여부 | +| 8 | `radio` | 라디오버튼 | 단일선택 옵션 | + +### 3. DB 타입 제거 + +- 컬럼 정보에서 `dbType` 필드 제거 +- 입력 타입만으로 데이터 처리 방식 결정 + +## 🛠️ 기술적 구현 방안 + +### 전체 VARCHAR 통일 방식 (확정) + +**모든 신규 테이블의 사용자 정의 컬럼을 VARCHAR(500)로 생성하고 애플리케이션 레벨에서 형변환 처리** + +#### 핵심 장점 + +- ✅ **최대 유연성**: 어떤 데이터든 타입 에러 없이 저장 가능 +- ✅ **에러 제로**: 타입 불일치로 인한 DB 삽입/수정 에러 완전 차단 +- ✅ **개발 속도**: 복잡한 타입 고민 없이 빠른 개발 가능 +- ✅ **요구사항 대응**: 필드 성격 변경 시에도 스키마 수정 불필요 +- ✅ **데이터 마이그레이션**: 기존 시스템 데이터 이관 시 100% 안전 + +#### 실제 예시 + +```sql +-- 기존 방식 (타입별 생성) +CREATE TABLE products ( + id serial PRIMARY KEY, + name varchar(255), -- 텍스트 + price numeric(10,2), -- 숫자 + launch_date date, -- 날짜 + is_active boolean -- 체크박스 +); + +-- 새로운 방식 (VARCHAR 통일) +CREATE TABLE products ( + id serial PRIMARY KEY, + created_date timestamp DEFAULT now(), + updated_date timestamp DEFAULT now(), + company_code varchar(50) DEFAULT '*', + writer varchar(100), + -- 사용자 정의 컬럼들은 모두 VARCHAR(500) + name varchar(500), -- 입력타입: text + price varchar(500), -- 입력타입: number → "15000.50" + launch_date varchar(500), -- 입력타입: date → "2024-03-15" + is_active varchar(500) -- 입력타입: checkbox → "Y"/"N" +); +``` + +#### 애플리케이션 레벨 처리 + +````typescript +// 입력 타입별 형변환 및 검증 +export class InputTypeProcessor { + + // 저장 전 변환 (화면 → DB) + static convertForStorage(value: any, inputType: string): string { + if (value === null || value === undefined) return ""; + + switch (inputType) { + case "text": + return String(value); + + case "number": + const num = parseFloat(String(value)); + return isNaN(num) ? "0" : String(num); + + case "date": + if (!value) return ""; + const date = new Date(value); + return isNaN(date.getTime()) ? "" : date.toISOString().split('T')[0]; + + case "checkbox": + return ["true", "1", "Y", "yes"].includes(String(value).toLowerCase()) ? "Y" : "N"; + + default: + return String(value); + } + } + + // 표시용 변환 (DB → 화면) + static convertForDisplay(value: string, inputType: string): any { + if (!value) return inputType === "number" ? 0 : ""; + + switch (inputType) { + case "number": + const num = parseFloat(value); + return isNaN(num) ? 0 : num; + + case "date": + return value; // YYYY-MM-DD 형식 그대로 + + case "checkbox": + return value === "Y"; + + default: + return value; + } + } +} + +## 🏗️ 구현 단계 + +### Phase 1: 타입 정의 수정 (1-2일) + +#### 1.1 입력 타입 enum 업데이트 + +```typescript +// frontend/types/unified-web-types.ts +export type InputType = + | "text" // 텍스트 + | "number" // 숫자 + | "date" // 날짜 + | "code" // 코드 + | "entity" // 엔티티 + | "select" // 선택박스 + | "checkbox" // 체크박스 + | "radio"; // 라디오버튼 +```` + +#### 1.2 UI 표시명 업데이트 + +```typescript +export const INPUT_TYPE_OPTIONS = [ + { value: "text", label: "텍스트", description: "일반 텍스트 입력" }, + { value: "number", label: "숫자", description: "숫자 입력 (정수/소수)" }, + { value: "date", label: "날짜", description: "날짜 선택" }, + { value: "code", label: "코드", description: "공통코드 참조" }, + { value: "entity", label: "엔티티", description: "다른 테이블 참조" }, + { value: "select", label: "선택박스", description: "드롭다운 선택" }, + { value: "checkbox", label: "체크박스", description: "체크박스 입력" }, + { value: "radio", label: "라디오버튼", description: "단일 선택" }, +]; +``` + +### Phase 2: 데이터베이스 스키마 수정 (1일) + +#### 2.1 테이블 스키마 수정 + +```sql +-- table_type_columns 테이블 수정 +ALTER TABLE table_type_columns +DROP COLUMN IF EXISTS db_type; + +-- 컬럼명 변경 +ALTER TABLE table_type_columns +RENAME COLUMN web_type TO input_type; + +-- 기존 데이터 마이그레이션 +UPDATE table_type_columns +SET input_type = CASE + WHEN input_type IN ('textarea') THEN 'text' + WHEN input_type IN ('decimal') THEN 'number' + WHEN input_type IN ('datetime') THEN 'date' + WHEN input_type IN ('dropdown') THEN 'select' + WHEN input_type IN ('boolean') THEN 'checkbox' + WHEN input_type NOT IN ('text', 'number', 'date', 'code', 'entity', 'select', 'checkbox', 'radio') + THEN 'text' + ELSE input_type +END; +``` + +### Phase 3: 백엔드 서비스 수정 (2-3일) + +#### 3.1 DDL 생성 로직 수정 + +```typescript +// 전체 VARCHAR 통일 방식으로 수정 +private mapInputTypeToPostgresType(inputType: string): string { + // 기본 컬럼들은 기존 타입 유지 (시스템 컬럼) + // 사용자 정의 컬럼은 입력 타입과 관계없이 모두 VARCHAR(500)로 통일 + return "varchar(500)"; +} + +private generateCreateTableQuery( + tableName: string, + columns: CreateColumnDefinition[] +): string { + // 사용자 정의 컬럼들 - 모두 VARCHAR(500)로 생성 + const columnDefinitions = columns + .map((col) => { + let definition = `"${col.name}" varchar(500)`; // 타입 통일 + + if (!col.nullable) { + definition += " NOT NULL"; + } + + if (col.defaultValue) { + definition += ` DEFAULT '${col.defaultValue}'`; + } + + return definition; + }) + .join(",\n "); + + // 기본 컬럼들 (시스템 필수 컬럼 - 기존 타입 유지) + const baseColumns = ` + "id" serial PRIMARY KEY, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(100), + "company_code" varchar(50) DEFAULT '*'`; + + return ` +CREATE TABLE "${tableName}" (${baseColumns}, + ${columnDefinitions} +);`.trim(); +} +``` + +#### 3.2 입력 타입 처리 서비스 구현 + +```typescript +// 통합 입력 타입 처리 서비스 +export class InputTypeService { + // 데이터 저장 전 형변환 (화면 입력값 → DB 저장값) + static convertForStorage(value: any, inputType: string): string { + if (value === null || value === undefined) return ""; + + switch (inputType) { + case "text": + case "select": + case "radio": + return String(value); + + case "number": + const num = parseFloat(String(value)); + return isNaN(num) ? "0" : String(num); + + case "date": + if (!value) return ""; + const date = new Date(value); + return isNaN(date.getTime()) ? "" : date.toISOString().split("T")[0]; + + case "checkbox": + return ["true", "1", "Y", "yes", true].includes(value) ? "Y" : "N"; + + case "code": + case "entity": + return String(value || ""); + + default: + return String(value); + } + } + + // 화면 표시용 형변환 (DB 저장값 → 화면 표시값) + static convertForDisplay(value: string, inputType: string): any { + if (!value && value !== "0") { + return inputType === "number" ? 0 : inputType === "checkbox" ? false : ""; + } + + switch (inputType) { + case "number": + const num = parseFloat(value); + return isNaN(num) ? 0 : num; + + case "checkbox": + return value === "Y" || value === "true"; + + case "date": + return value; // YYYY-MM-DD 형식 그대로 사용 + + default: + return value; + } + } + + // 입력값 검증 + static validate( + value: any, + inputType: string + ): { isValid: boolean; message?: string } { + if (!value && value !== 0) { + return { isValid: true }; // 빈 값은 허용 + } + + switch (inputType) { + case "number": + const num = parseFloat(String(value)); + return { + isValid: !isNaN(num), + message: isNaN(num) ? "숫자 형식이 올바르지 않습니다." : undefined, + }; + + case "date": + const date = new Date(value); + return { + isValid: !isNaN(date.getTime()), + message: isNaN(date.getTime()) + ? "날짜 형식이 올바르지 않습니다." + : undefined, + }; + + default: + return { isValid: true }; + } + } +} +``` + +### Phase 4: 프론트엔드 UI 수정 (2일) + +#### 4.1 테이블 관리 화면 수정 + +- "웹 타입" → "입력 타입" 라벨 변경 +- DB 타입 컬럼 제거 +- 8개 타입만 선택 가능하도록 UI 수정 + +#### 4.2 화면 관리 시스템 연동 + +- 웹타입 → 입력타입 용어 통일 +- 기존 화면관리 컴포넌트와 호환성 유지 + +### Phase 5: 기본 컬럼 유지 로직 보강 (1일) + +#### 5.1 테이블 생성 시 기본 컬럼 자동 추가 + +```typescript +const DEFAULT_COLUMNS = [ + { + name: "id", + type: "serial PRIMARY KEY", + description: "기본키 (자동증가)", + }, + { + name: "created_date", + type: "timestamp DEFAULT now()", + description: "생성일시", + }, + { + name: "updated_date", + type: "timestamp DEFAULT now()", + description: "수정일시", + }, + { + name: "writer", + type: "varchar(100)", + description: "작성자", + }, + { + name: "company_code", + type: "varchar(50) DEFAULT '*'", + description: "회사코드", + }, +]; +``` + +## 🧪 테스트 계획 + +### 1. 단위 테스트 + +- [ ] 입력 타입별 형변환 함수 테스트 +- [ ] 데이터 검증 로직 테스트 +- [ ] DDL 생성 로직 테스트 + +### 2. 통합 테스트 + +- [ ] 테이블 생성 → 데이터 입력 → 조회 전체 플로우 +- [ ] 기존 테이블과의 호환성 테스트 +- [ ] 화면관리 시스템 연동 테스트 + +### 3. 성능 테스트 + +- [ ] VARCHAR vs 전용 타입 성능 비교 +- [ ] 대용량 데이터 입력 테스트 +- [ ] 형변환 오버헤드 측정 + +## 📊 마이그레이션 전략 + +### 기존 데이터 호환성 + +1. **기존 테이블**: 현재 타입 구조 유지 +2. **신규 테이블**: 새로운 입력 타입 체계 적용 +3. **점진적 전환**: 필요에 따라 기존 테이블도 단계적 전환 + +### 데이터 무결성 보장 + +- 형변환 실패 시 기본값 사용 +- 검증 로직을 통한 데이터 품질 관리 +- 에러 로깅 및 알림 시스템 + +## 🎯 예상 효과 + +### 사용자 경험 개선 + +- ✅ 직관적인 용어로 학습 비용 감소 +- ✅ 8개 타입으로 선택 복잡도 감소 +- ✅ 일관된 인터페이스 제공 + +### 개발 생산성 향상 + +- ✅ 타입 관리 복잡도 감소 +- ✅ 에러 발생률 감소 +- ✅ 빠른 프로토타이핑 가능 + +### 시스템 유연성 확보 + +- ✅ 요구사항 변경에 빠른 대응 +- ✅ 다양한 데이터 형식 수용 +- ✅ 확장성 있는 구조 + +## 🚨 주의사항 + +### 데이터 검증 강화 필요 + +VARCHAR 통일 방식 적용 시 애플리케이션 레벨에서 철저한 데이터 검증이 필요합니다. + +### 성능 모니터링 + +초기 적용 후 성능 영향도를 지속적으로 모니터링하여 필요 시 최적화 방안을 강구합니다. + +### 문서화 업데이트 + +새로운 입력 타입 체계에 대한 사용자 가이드 및 개발 문서를 업데이트합니다. + +## 📅 일정 + +| 단계 | 소요시간 | 담당 | +| ---------------------------- | --------- | ---------- | +| Phase 1: 타입 정의 수정 | 1-2일 | 프론트엔드 | +| Phase 2: DB 스키마 수정 | 1일 | 백엔드 | +| Phase 3: 백엔드 서비스 수정 | 2-3일 | 백엔드 | +| Phase 4: 프론트엔드 UI 수정 | 2일 | 프론트엔드 | +| Phase 5: 기본 컬럼 로직 보강 | 1일 | 백엔드 | +| **총 소요시간** | **7-9일** | | + +--- + +**결론**: 전체 VARCHAR 통일 방식을 확정하여 최대한의 유연성과 안정성을 확보합니다. + +## 🎯 핵심 결정사항 요약 + +### ✅ 확정된 방향성 + +1. **용어 통일**: 웹 타입 → 입력 타입 +2. **타입 단순화**: 20개 → 8개 핵심 타입 +3. **DB 타입 제거**: dbType 필드 완전 삭제 +4. **저장 방식**: 모든 사용자 정의 컬럼을 VARCHAR(500)로 통일 +5. **형변환**: 애플리케이션 레벨에서 입력 타입별 처리 + +### 🚀 예상 효과 + +- **개발 속도 3배 향상**: 타입 고민 시간 제거 +- **에러율 90% 감소**: DB 타입 불일치 에러 완전 차단 +- **요구사항 대응력 극대화**: 스키마 수정 없이 필드 성격 변경 가능 +- **데이터 마이그레이션 100% 안전**: 어떤 형태의 데이터도 수용 가능 From 0257254036ee80fe905c0680fbd33cf127f00713 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Sep 2025 18:24:00 +0900 Subject: [PATCH 3/8] Fix: Restore multilangController.ts with all required exports - Revert to dev branch version to fix TypeScript compilation errors - Main branch version was missing required export functions - Routes depend on these exported functions for proper API functionality --- .../src/controllers/multilangController.ts | 921 +++++++++++++----- 1 file changed, 654 insertions(+), 267 deletions(-) diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 25cfb1b3..14155f86 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1,202 +1,496 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; +import { Request, Response } from "express"; import { logger } from "../utils/logger"; -import prisma from "../config/database"; - -// 메모리 캐시 (개발 환경용, 운영에서는 Redis 사용 권장) -const translationCache = new Map(); -const CACHE_TTL = 5 * 60 * 1000; // 5분 - -interface CacheEntry { - data: any; - timestamp: number; -} +import { AuthenticatedRequest } from "../types/auth"; +import { MultiLangService } from "../services/multilangService"; +import { + CreateLanguageRequest, + UpdateLanguageRequest, + CreateLangKeyRequest, + UpdateLangKeyRequest, + SaveLangTextsRequest, + GetUserTextParams, + BatchTranslationRequest, + ApiResponse, +} from "../types/multilang"; /** - * GET /api/multilang/batch - * 다국어 텍스트 배치 조회 API - 여러 키를 한번에 조회 + * GET /api/multilang/languages + * 언어 목록 조회 API */ -export const getBatchTranslations = async ( +export const getLanguages = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { - const { companyCode, menuCode, userLang } = req.query; - const { langKeys } = req.body; // 배열로 여러 키 전달 + logger.info("언어 목록 조회 요청", { user: req.user }); - logger.info("다국어 텍스트 배치 조회 요청", { - companyCode, - menuCode, - userLang, - keyCount: langKeys?.length || 0, + const multiLangService = new MultiLangService(); + const languages = await multiLangService.getLanguages(); + + const response: ApiResponse = { + success: true, + message: "언어 목록 조회 성공", + data: languages, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "언어 목록 조회 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/languages + * 언어 생성 API + */ +export const createLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const languageData: CreateLanguageRequest = req.body; + logger.info("언어 생성 요청", { languageData, user: req.user }); + + // 필수 입력값 검증 + if ( + !languageData.langCode || + !languageData.langName || + !languageData.langNative + ) { + res.status(400).json({ + success: false, + message: "언어 코드, 언어명, 원어명은 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "langCode, langName, langNative are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const createdLanguage = await multiLangService.createLanguage({ + ...languageData, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 생성되었습니다.", + data: createdLanguage, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("언어 생성 실패:", error); + res.status(500).json({ + success: false, + message: "언어 생성 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/languages/:langCode + * 언어 수정 API + */ +export const updateLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + const languageData: UpdateLanguageRequest = req.body; + + logger.info("언어 수정 요청", { langCode, languageData, user: req.user }); + + const multiLangService = new MultiLangService(); + const updatedLanguage = await multiLangService.updateLanguage(langCode, { + ...languageData, + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 수정되었습니다.", + data: updatedLanguage, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 수정 실패:", error); + res.status(500).json({ + success: false, + message: "언어 수정 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/languages/:langCode/toggle + * 언어 상태 토글 API + */ +export const toggleLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + logger.info("언어 상태 토글 요청", { langCode, user: req.user }); + + const multiLangService = new MultiLangService(); + const result = await multiLangService.toggleLanguage(langCode); + + const response: ApiResponse = { + success: true, + message: `언어가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "언어 상태 변경 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys + * 다국어 키 목록 조회 API + */ +export const getLangKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, menuCode, keyType, searchText } = req.query; + logger.info("다국어 키 목록 조회 요청", { + query: req.query, user: req.user, }); - if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { - res.status(400).json({ - success: false, - message: "langKeys 배열이 필요합니다.", - }); - return; - } - - // 캐시 키 생성 - const cacheKey = `${companyCode}_${userLang}_${langKeys.sort().join("_")}`; - - // 캐시 확인 - const cached = translationCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - logger.info("캐시된 번역 데이터 사용", { - cacheKey, - keyCount: langKeys.length, - }); - res.status(200).json({ - success: true, - data: cached.data, - message: "캐시된 다국어 텍스트 조회 성공", - fromCache: true, - }); - return; - } - - // 1. 모든 키에 대한 마스터 정보를 한번에 조회 - logger.info("다국어 키 마스터 배치 조회 시작", { - keyCount: langKeys.length, + const multiLangService = new MultiLangService(); + const langKeys = await multiLangService.getLangKeys({ + companyCode: companyCode as string, + menuCode: menuCode as string, + keyType: keyType as string, + searchText: searchText as string, }); - const langKeyMasters = await prisma.$queryRaw` - SELECT key_id, lang_key, company_code - FROM multi_lang_key_master - WHERE lang_key = ANY(${langKeys}::varchar[]) - AND (company_code = ${companyCode}::varchar OR company_code = '*') - ORDER BY - CASE WHEN company_code = ${companyCode}::varchar THEN 1 ELSE 2 END, - lang_key, - company_code - `; - - logger.info("다국어 키 마스터 배치 조회 결과", { - requestedKeys: langKeys.length, - foundKeys: langKeyMasters.length, - }); - - if (langKeyMasters.length === 0) { - // 마스터 데이터가 없으면 기본값 반환 - const defaultTranslations = getDefaultTranslations( - langKeys, - userLang as string - ); - - // 캐시에 저장 - translationCache.set(cacheKey, { - data: defaultTranslations, - timestamp: Date.now(), - }); - - res.status(200).json({ - success: true, - data: defaultTranslations, - message: "기본값으로 다국어 텍스트 조회 성공", - fromCache: false, - }); - return; - } - - // 2. 모든 key_id를 추출 - const keyIds = langKeyMasters.map((master: any) => master.key_id); - - // 3. 요청된 언어와 한국어 번역을 한번에 조회 - const translations = await prisma.$queryRaw` - SELECT - mlt.key_id, - mlt.lang_code, - mlt.lang_text, - mlkm.lang_key - FROM multi_lang_text mlt - JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id - WHERE mlt.key_id = ANY(${keyIds}::numeric[]) - AND mlt.lang_code IN (${userLang}::varchar, 'KR') - ORDER BY - mlt.key_id, - CASE WHEN mlt.lang_code = ${userLang}::varchar THEN 1 ELSE 2 END - `; - - logger.info("번역 텍스트 배치 조회 결과", { - keyIds: keyIds.length, - translations: translations.length, - }); - - // 4. 결과를 키별로 정리 - const result: Record = {}; - - for (const langKey of langKeys) { - const master = langKeyMasters.find((m) => m.lang_key === langKey); - - if (master) { - const keyId = master.key_id; - - // 요청된 언어 번역 찾기 - let translation = translations.find( - (t) => t.key_id === keyId && t.lang_code === userLang - ); - - // 요청된 언어가 없으면 한국어 번역 찾기 - if (!translation) { - translation = translations.find( - (t) => t.key_id === keyId && t.lang_code === "KR" - ); - } - - // 번역이 있으면 사용, 없으면 기본값 - if (translation) { - result[langKey] = translation.lang_text; - } else { - result[langKey] = getDefaultTranslation(langKey, userLang as string); - } - } else { - // 마스터 데이터가 없으면 기본값 - result[langKey] = getDefaultTranslation(langKey, userLang as string); - } - } - - // 5. 캐시에 저장 - translationCache.set(cacheKey, { - data: result, - timestamp: Date.now(), - }); - - logger.info("다국어 텍스트 배치 조회 완료", { - requestedKeys: langKeys.length, - resultKeys: Object.keys(result).length, - cacheKey, - }); - - res.status(200).json({ + const response: ApiResponse = { success: true, - data: result, - message: "다국어 텍스트 배치 조회 성공", - fromCache: false, - }); + message: "다국어 키 목록 조회 성공", + data: langKeys, + }; + + res.status(200).json(response); } catch (error) { - logger.error("다국어 텍스트 배치 조회 실패", { error }); + logger.error("다국어 키 목록 조회 실패:", error); res.status(500).json({ success: false, - message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "다국어 키 목록 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_KEYS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys/:keyId/texts + * 특정 키의 다국어 텍스트 조회 API + */ +export const getLangTexts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); + + const multiLangService = new MultiLangService(); + const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: "다국어 텍스트 조회 성공", + data: langTexts, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 텍스트 조회 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXTS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys + * 다국어 키 생성 API + */ +export const createLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const keyData: CreateLangKeyRequest = req.body; + logger.info("다국어 키 생성 요청", { keyData, user: req.user }); + + // 필수 입력값 검증 + if (!keyData.companyCode || !keyData.langKey) { + res.status(400).json({ + success: false, + message: "회사 코드와 언어 키는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode and langKey are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keyId = await multiLangService.createLangKey({ + ...keyData, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("다국어 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 생성 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/keys/:keyId + * 다국어 키 수정 API + */ +export const updateLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + const keyData: UpdateLangKeyRequest = req.body; + + logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); + + const multiLangService = new MultiLangService(); + await multiLangService.updateLangKey(parseInt(keyId), { + ...keyData, + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 수정되었습니다.", + data: "수정 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 키 수정 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 수정 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * DELETE /api/multilang/keys/:keyId + * 다국어 키 삭제 API + */ +export const deleteLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); + + const multiLangService = new MultiLangService(); + await multiLangService.deleteLangKey(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 삭제되었습니다.", + data: "삭제 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 키 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 삭제 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/keys/:keyId/toggle + * 다국어 키 상태 토글 API + */ +export const toggleLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); + + const multiLangService = new MultiLangService(); + const result = await multiLangService.toggleLangKey(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: `다국어 키가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 키 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 상태 변경 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/:keyId/texts + * 다국어 텍스트 저장/수정 API + */ +export const saveLangTexts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + const textData: SaveLangTextsRequest = req.body; + + logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); + + // 필수 입력값 검증 + if ( + !textData.texts || + !Array.isArray(textData.texts) || + textData.texts.length === 0 + ) { + res.status(400).json({ + success: false, + message: "텍스트 데이터는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "texts array is required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + await multiLangService.saveLangTexts(parseInt(keyId), { + texts: textData.texts.map((text) => ({ + ...text, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + })), + }); + + const response: ApiResponse = { + success: true, + message: "다국어 텍스트가 성공적으로 저장되었습니다.", + data: "저장 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 텍스트 저장 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 저장 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXTS_SAVE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; /** * GET /api/multilang/user-text/:companyCode/:menuCode/:langKey - * 단일 다국어 텍스트 조회 API (하위 호환성 유지) + * 사용자별 다국어 텍스트 조회 API */ -export const getUserText = async (req: AuthenticatedRequest, res: Response) => { +export const getUserText = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { try { const { companyCode, menuCode, langKey } = req.params; const { userLang } = req.query; - logger.info("단일 다국어 텍스트 조회 요청", { + logger.info("사용자별 다국어 텍스트 조회 요청", { companyCode, menuCode, langKey, @@ -204,122 +498,215 @@ export const getUserText = async (req: AuthenticatedRequest, res: Response) => { user: req.user, }); - // 배치 API를 사용하여 단일 키 조회 - const batchResult = await getBatchTranslations( - { - ...req, - body: { langKeys: [langKey] }, - query: { companyCode, menuCode, userLang }, - } as any, - res - ); + if (!userLang) { + res.status(400).json({ + success: false, + message: "사용자 언어는 필수입니다.", + error: { + code: "MISSING_USER_LANG", + details: "userLang query parameter is required", + }, + }); + return; + } - // 배치 API에서 이미 응답을 보냈으므로 여기서는 아무것도 하지 않음 - return; + const multiLangService = new MultiLangService(); + const langText = await multiLangService.getUserText({ + companyCode, + menuCode, + langKey, + userLang: userLang as string, + }); + + const response: ApiResponse = { + success: true, + message: "사용자별 다국어 텍스트 조회 성공", + data: langText, + }; + + res.status(200).json(response); } catch (error) { - logger.error("단일 다국어 텍스트 조회 실패", { error }); + logger.error("사용자별 다국어 텍스트 조회 실패:", error); res.status(500).json({ success: false, - message: "다국어 텍스트 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "사용자별 다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "USER_TEXT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; /** - * 기본 번역 텍스트 반환 (개별 키) + * GET /api/multilang/text/:companyCode/:langKey/:langCode + * 특정 키의 다국어 텍스트 조회 API */ -function getDefaultTranslation(langKey: string, userLang: string): string { - const defaultKoreanTexts: Record = { - "button.add": "추가", - "button.add.top.level": "최상위 메뉴 추가", - "button.add.sub": "하위 메뉴 추가", - "button.edit": "수정", - "button.delete": "삭제", - "button.cancel": "취소", - "button.save": "저장", - "button.register": "등록", - "form.menu.name": "메뉴명", - "form.menu.url": "URL", - "form.menu.description": "설명", - "form.menu.type": "메뉴 타입", - "form.status": "상태", - "form.company": "회사", - "table.header.menu.name": "메뉴명", - "table.header.menu.url": "URL", - "table.header.status": "상태", - "table.header.company": "회사", - "table.header.actions": "작업", - "filter.company": "회사", - "filter.search": "검색", - "filter.reset": "초기화", - "menu.type.title": "메뉴 타입", - "menu.type.admin": "관리자", - "menu.type.user": "사용자", - "status.active": "활성화", - "status.inactive": "비활성화", - "form.lang.key": "언어 키", - "form.lang.key.select": "언어 키 선택", - "form.menu.name.placeholder": "메뉴명을 입력하세요", - "form.menu.url.placeholder": "URL을 입력하세요", - "form.menu.description.placeholder": "설명을 입력하세요", - "form.menu.sequence": "순서", - "form.menu.sequence.placeholder": "순서를 입력하세요", - "form.status.active": "활성", - "form.status.inactive": "비활성", - "form.company.select": "회사 선택", - "form.company.common": "공통", - "form.company.submenu.note": "하위메뉴는 회사별로 관리됩니다", - "filter.company.common": "공통", - "filter.search.placeholder": "검색어를 입력하세요", - "modal.menu.register.title": "메뉴 등록", - }; - - return defaultKoreanTexts[langKey] || langKey; -} - -/** - * 기본 번역 텍스트 반환 (배치) - */ -function getDefaultTranslations( - langKeys: string[], - userLang: string -): Record { - const result: Record = {}; - - for (const langKey of langKeys) { - result[langKey] = getDefaultTranslation(langKey, userLang); - } - - return result; -} - -/** - * 캐시 초기화 (개발/테스트용) - */ -export const clearCache = async (req: AuthenticatedRequest, res: Response) => { +export const getLangText = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { try { - const beforeSize = translationCache.size; - translationCache.clear(); + const { companyCode, langKey, langCode } = req.params; - logger.info("다국어 캐시 초기화 완료", { - beforeSize, - afterSize: 0, + logger.info("특정 키의 다국어 텍스트 조회 요청", { + companyCode, + langKey, + langCode, user: req.user, }); - res.status(200).json({ + const multiLangService = new MultiLangService(); + const langText = await multiLangService.getLangText( + companyCode, + langKey, + langCode + ); + + const response: ApiResponse = { success: true, - message: "캐시가 초기화되었습니다.", - beforeSize, - afterSize: 0, - }); + message: "특정 키의 다국어 텍스트 조회 성공", + data: langText, + }; + + res.status(200).json(response); } catch (error) { - logger.error("캐시 초기화 실패", { error }); + logger.error("특정 키의 다국어 텍스트 조회 실패:", error); res.status(500).json({ success: false, - message: "캐시 초기화 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "특정 키의 다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * DELETE /api/multilang/languages/:langCode + * 언어 삭제 API + */ +export const deleteLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + logger.info("언어 삭제 요청", { langCode, user: req.user }); + + if (!langCode) { + res.status(400).json({ + success: false, + message: "언어 코드가 필요합니다.", + error: { + code: "MISSING_LANG_CODE", + details: "langCode parameter is required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + await multiLangService.deleteLanguage(langCode); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 삭제되었습니다.", + data: "삭제 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "언어 삭제 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/batch + * 다국어 텍스트 배치 조회 API + */ +export const getBatchTranslations = async ( + req: Request, + res: Response +): Promise => { + try { + const { companyCode, menuCode, userLang } = req.query; + const { + langKeys, + companyCode: bodyCompanyCode, + menuCode: bodyMenuCode, + userLang: bodyUserLang, + } = req.body; + + // query params에서 읽지 못한 경우 body에서 읽기 + const finalCompanyCode = companyCode || bodyCompanyCode; + const finalMenuCode = menuCode || bodyMenuCode; + const finalUserLang = userLang || bodyUserLang; + + logger.info("다국어 텍스트 배치 조회 요청", { + companyCode: finalCompanyCode, + menuCode: finalMenuCode, + userLang: finalUserLang, + keyCount: langKeys?.length || 0, + }); + + if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { + res.status(400).json({ + success: false, + message: "langKeys 배열이 필요합니다.", + error: { + code: "MISSING_LANG_KEYS", + details: "langKeys array is required", + }, + }); + return; + } + + if (!finalCompanyCode || !finalUserLang) { + res.status(400).json({ + success: false, + message: "companyCode와 userLang은 필수입니다.", + error: { + code: "MISSING_REQUIRED_PARAMS", + details: "companyCode and userLang are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const translations = await multiLangService.getBatchTranslations({ + companyCode: finalCompanyCode as string, + menuCode: finalMenuCode as string, + userLang: finalUserLang as string, + langKeys, + }); + + const response: ApiResponse> = { + success: true, + message: "다국어 텍스트 배치 조회 성공", + data: translations, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 텍스트 배치 조회 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", + error: { + code: "BATCH_TRANSLATION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; From 7fa6fd60930a3b33f4fb3cc8c20ab4bc7363edaf Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Sep 2025 18:24:21 +0900 Subject: [PATCH 4/8] Fix: Restore multilangController.ts with all required exports - Revert to working dev branch version to fix TypeScript compilation errors - Main branch version was missing required export functions - Routes depend on these exported functions for proper API functionality --- .../src/controllers/multilangController.ts | 921 +++++++++++++----- 1 file changed, 654 insertions(+), 267 deletions(-) diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 65600672..14155f86 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -1,202 +1,496 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; +import { Request, Response } from "express"; import { logger } from "../utils/logger"; -import prisma from "../config/database"; - -// 메모리 캐시 (개발 환경용, 운영에서는 Redis 사용 권장) -const translationCache = new Map(); -const CACHE_TTL = 5 * 60 * 1000; // 5분 - -interface CacheEntry { - data: any; - timestamp: number; -} +import { AuthenticatedRequest } from "../types/auth"; +import { MultiLangService } from "../services/multilangService"; +import { + CreateLanguageRequest, + UpdateLanguageRequest, + CreateLangKeyRequest, + UpdateLangKeyRequest, + SaveLangTextsRequest, + GetUserTextParams, + BatchTranslationRequest, + ApiResponse, +} from "../types/multilang"; /** - * GET /api/multilang/batch - * 다국어 텍스트 배치 조회 API - 여러 키를 한번에 조회 + * GET /api/multilang/languages + * 언어 목록 조회 API */ -export const getBatchTranslations = async ( +export const getLanguages = async ( req: AuthenticatedRequest, res: Response ): Promise => { try { - const { companyCode, menuCode, userLang } = req.query; - const { langKeys } = req.body; // 배열로 여러 키 전달 + logger.info("언어 목록 조회 요청", { user: req.user }); - logger.info("다국어 텍스트 배치 조회 요청", { - companyCode, - menuCode, - userLang, - keyCount: langKeys?.length || 0, + const multiLangService = new MultiLangService(); + const languages = await multiLangService.getLanguages(); + + const response: ApiResponse = { + success: true, + message: "언어 목록 조회 성공", + data: languages, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "언어 목록 조회 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/languages + * 언어 생성 API + */ +export const createLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const languageData: CreateLanguageRequest = req.body; + logger.info("언어 생성 요청", { languageData, user: req.user }); + + // 필수 입력값 검증 + if ( + !languageData.langCode || + !languageData.langName || + !languageData.langNative + ) { + res.status(400).json({ + success: false, + message: "언어 코드, 언어명, 원어명은 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "langCode, langName, langNative are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const createdLanguage = await multiLangService.createLanguage({ + ...languageData, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 생성되었습니다.", + data: createdLanguage, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("언어 생성 실패:", error); + res.status(500).json({ + success: false, + message: "언어 생성 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/languages/:langCode + * 언어 수정 API + */ +export const updateLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + const languageData: UpdateLanguageRequest = req.body; + + logger.info("언어 수정 요청", { langCode, languageData, user: req.user }); + + const multiLangService = new MultiLangService(); + const updatedLanguage = await multiLangService.updateLanguage(langCode, { + ...languageData, + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 수정되었습니다.", + data: updatedLanguage, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 수정 실패:", error); + res.status(500).json({ + success: false, + message: "언어 수정 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/languages/:langCode/toggle + * 언어 상태 토글 API + */ +export const toggleLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + logger.info("언어 상태 토글 요청", { langCode, user: req.user }); + + const multiLangService = new MultiLangService(); + const result = await multiLangService.toggleLanguage(langCode); + + const response: ApiResponse = { + success: true, + message: `언어가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "언어 상태 변경 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys + * 다국어 키 목록 조회 API + */ +export const getLangKeys = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { companyCode, menuCode, keyType, searchText } = req.query; + logger.info("다국어 키 목록 조회 요청", { + query: req.query, user: req.user, }); - if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { - res.status(400).json({ - success: false, - message: "langKeys 배열이 필요합니다.", - }); - return; - } - - // 캐시 키 생성 - const cacheKey = `${companyCode}_${userLang}_${langKeys.sort().join("_")}`; - - // 캐시 확인 - const cached = translationCache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - logger.info("캐시된 번역 데이터 사용", { - cacheKey, - keyCount: langKeys.length, - }); - res.status(200).json({ - success: true, - data: cached.data, - message: "캐시된 다국어 텍스트 조회 성공", - fromCache: true, - }); - return; - } - - // 1. 모든 키에 대한 마스터 정보를 한번에 조회 - logger.info("다국어 키 마스터 배치 조회 시작", { - keyCount: langKeys.length, + const multiLangService = new MultiLangService(); + const langKeys = await multiLangService.getLangKeys({ + companyCode: companyCode as string, + menuCode: menuCode as string, + keyType: keyType as string, + searchText: searchText as string, }); - const langKeyMasters = await prisma.$queryRaw` - SELECT key_id, lang_key, company_code - FROM multi_lang_key_master - WHERE lang_key = ANY(${langKeys}::varchar[]) - AND (company_code = ${companyCode}::varchar OR company_code = '*') - ORDER BY - CASE WHEN company_code = ${companyCode}::varchar THEN 1 ELSE 2 END, - lang_key, - company_code - `; - - logger.info("다국어 키 마스터 배치 조회 결과", { - requestedKeys: langKeys.length, - foundKeys: langKeyMasters.length, - }); - - if (langKeyMasters.length === 0) { - // 마스터 데이터가 없으면 기본값 반환 - const defaultTranslations = getDefaultTranslations( - langKeys, - userLang as string - ); - - // 캐시에 저장 - translationCache.set(cacheKey, { - data: defaultTranslations, - timestamp: Date.now(), - }); - - res.status(200).json({ - success: true, - data: defaultTranslations, - message: "기본값으로 다국어 텍스트 조회 성공", - fromCache: false, - }); - return; - } - - // 2. 모든 key_id를 추출 - const keyIds = langKeyMasters.map((master) => master.key_id); - - // 3. 요청된 언어와 한국어 번역을 한번에 조회 - const translations = await prisma.$queryRaw` - SELECT - mlt.key_id, - mlt.lang_code, - mlt.lang_text, - mlkm.lang_key - FROM multi_lang_text mlt - JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id - WHERE mlt.key_id = ANY(${keyIds}::numeric[]) - AND mlt.lang_code IN (${userLang}::varchar, 'KR') - ORDER BY - mlt.key_id, - CASE WHEN mlt.lang_code = ${userLang}::varchar THEN 1 ELSE 2 END - `; - - logger.info("번역 텍스트 배치 조회 결과", { - keyIds: keyIds.length, - translations: translations.length, - }); - - // 4. 결과를 키별로 정리 - const result: Record = {}; - - for (const langKey of langKeys) { - const master = langKeyMasters.find((m) => m.lang_key === langKey); - - if (master) { - const keyId = master.key_id; - - // 요청된 언어 번역 찾기 - let translation = translations.find( - (t) => t.key_id === keyId && t.lang_code === userLang - ); - - // 요청된 언어가 없으면 한국어 번역 찾기 - if (!translation) { - translation = translations.find( - (t) => t.key_id === keyId && t.lang_code === "KR" - ); - } - - // 번역이 있으면 사용, 없으면 기본값 - if (translation) { - result[langKey] = translation.lang_text; - } else { - result[langKey] = getDefaultTranslation(langKey, userLang as string); - } - } else { - // 마스터 데이터가 없으면 기본값 - result[langKey] = getDefaultTranslation(langKey, userLang as string); - } - } - - // 5. 캐시에 저장 - translationCache.set(cacheKey, { - data: result, - timestamp: Date.now(), - }); - - logger.info("다국어 텍스트 배치 조회 완료", { - requestedKeys: langKeys.length, - resultKeys: Object.keys(result).length, - cacheKey, - }); - - res.status(200).json({ + const response: ApiResponse = { success: true, - data: result, - message: "다국어 텍스트 배치 조회 성공", - fromCache: false, - }); + message: "다국어 키 목록 조회 성공", + data: langKeys, + }; + + res.status(200).json(response); } catch (error) { - logger.error("다국어 텍스트 배치 조회 실패", { error }); + logger.error("다국어 키 목록 조회 실패:", error); res.status(500).json({ success: false, - message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "다국어 키 목록 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_KEYS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * GET /api/multilang/keys/:keyId/texts + * 특정 키의 다국어 텍스트 조회 API + */ +export const getLangTexts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); + + const multiLangService = new MultiLangService(); + const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: "다국어 텍스트 조회 성공", + data: langTexts, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 텍스트 조회 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXTS_LIST_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys + * 다국어 키 생성 API + */ +export const createLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const keyData: CreateLangKeyRequest = req.body; + logger.info("다국어 키 생성 요청", { keyData, user: req.user }); + + // 필수 입력값 검증 + if (!keyData.companyCode || !keyData.langKey) { + res.status(400).json({ + success: false, + message: "회사 코드와 언어 키는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "companyCode and langKey are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const keyId = await multiLangService.createLangKey({ + ...keyData, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 생성되었습니다.", + data: keyId, + }; + + res.status(201).json(response); + } catch (error) { + logger.error("다국어 키 생성 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 생성 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_CREATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/keys/:keyId + * 다국어 키 수정 API + */ +export const updateLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + const keyData: UpdateLangKeyRequest = req.body; + + logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); + + const multiLangService = new MultiLangService(); + await multiLangService.updateLangKey(parseInt(keyId), { + ...keyData, + updatedBy: req.user?.userId || "system", + }); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 수정되었습니다.", + data: "수정 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 키 수정 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 수정 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_UPDATE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * DELETE /api/multilang/keys/:keyId + * 다국어 키 삭제 API + */ +export const deleteLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); + + const multiLangService = new MultiLangService(); + await multiLangService.deleteLangKey(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: "다국어 키가 성공적으로 삭제되었습니다.", + data: "삭제 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 키 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 삭제 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * PUT /api/multilang/keys/:keyId/toggle + * 다국어 키 상태 토글 API + */ +export const toggleLangKey = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); + + const multiLangService = new MultiLangService(); + const result = await multiLangService.toggleLangKey(parseInt(keyId)); + + const response: ApiResponse = { + success: true, + message: `다국어 키가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 키 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 키 상태 변경 중 오류가 발생했습니다.", + error: { + code: "LANG_KEY_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/keys/:keyId/texts + * 다국어 텍스트 저장/수정 API + */ +export const saveLangTexts = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { keyId } = req.params; + const textData: SaveLangTextsRequest = req.body; + + logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); + + // 필수 입력값 검증 + if ( + !textData.texts || + !Array.isArray(textData.texts) || + textData.texts.length === 0 + ) { + res.status(400).json({ + success: false, + message: "텍스트 데이터는 필수입니다.", + error: { + code: "MISSING_REQUIRED_FIELDS", + details: "texts array is required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + await multiLangService.saveLangTexts(parseInt(keyId), { + texts: textData.texts.map((text) => ({ + ...text, + createdBy: req.user?.userId || "system", + updatedBy: req.user?.userId || "system", + })), + }); + + const response: ApiResponse = { + success: true, + message: "다국어 텍스트가 성공적으로 저장되었습니다.", + data: "저장 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 텍스트 저장 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 저장 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXTS_SAVE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; /** * GET /api/multilang/user-text/:companyCode/:menuCode/:langKey - * 단일 다국어 텍스트 조회 API (하위 호환성 유지) + * 사용자별 다국어 텍스트 조회 API */ -export const getUserText = async (req: AuthenticatedRequest, res: Response) => { +export const getUserText = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { try { const { companyCode, menuCode, langKey } = req.params; const { userLang } = req.query; - logger.info("단일 다국어 텍스트 조회 요청", { + logger.info("사용자별 다국어 텍스트 조회 요청", { companyCode, menuCode, langKey, @@ -204,122 +498,215 @@ export const getUserText = async (req: AuthenticatedRequest, res: Response) => { user: req.user, }); - // 배치 API를 사용하여 단일 키 조회 - const batchResult = await getBatchTranslations( - { - ...req, - body: { langKeys: [langKey] }, - query: { companyCode, menuCode, userLang }, - } as any, - res - ); + if (!userLang) { + res.status(400).json({ + success: false, + message: "사용자 언어는 필수입니다.", + error: { + code: "MISSING_USER_LANG", + details: "userLang query parameter is required", + }, + }); + return; + } - // 배치 API에서 이미 응답을 보냈으므로 여기서는 아무것도 하지 않음 - return; + const multiLangService = new MultiLangService(); + const langText = await multiLangService.getUserText({ + companyCode, + menuCode, + langKey, + userLang: userLang as string, + }); + + const response: ApiResponse = { + success: true, + message: "사용자별 다국어 텍스트 조회 성공", + data: langText, + }; + + res.status(200).json(response); } catch (error) { - logger.error("단일 다국어 텍스트 조회 실패", { error }); + logger.error("사용자별 다국어 텍스트 조회 실패:", error); res.status(500).json({ success: false, - message: "다국어 텍스트 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "사용자별 다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "USER_TEXT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; /** - * 기본 번역 텍스트 반환 (개별 키) + * GET /api/multilang/text/:companyCode/:langKey/:langCode + * 특정 키의 다국어 텍스트 조회 API */ -function getDefaultTranslation(langKey: string, userLang: string): string { - const defaultKoreanTexts: Record = { - "button.add": "추가", - "button.add.top.level": "최상위 메뉴 추가", - "button.add.sub": "하위 메뉴 추가", - "button.edit": "수정", - "button.delete": "삭제", - "button.cancel": "취소", - "button.save": "저장", - "button.register": "등록", - "form.menu.name": "메뉴명", - "form.menu.url": "URL", - "form.menu.description": "설명", - "form.menu.type": "메뉴 타입", - "form.status": "상태", - "form.company": "회사", - "table.header.menu.name": "메뉴명", - "table.header.menu.url": "URL", - "table.header.status": "상태", - "table.header.company": "회사", - "table.header.actions": "작업", - "filter.company": "회사", - "filter.search": "검색", - "filter.reset": "초기화", - "menu.type.title": "메뉴 타입", - "menu.type.admin": "관리자", - "menu.type.user": "사용자", - "status.active": "활성화", - "status.inactive": "비활성화", - "form.lang.key": "언어 키", - "form.lang.key.select": "언어 키 선택", - "form.menu.name.placeholder": "메뉴명을 입력하세요", - "form.menu.url.placeholder": "URL을 입력하세요", - "form.menu.description.placeholder": "설명을 입력하세요", - "form.menu.sequence": "순서", - "form.menu.sequence.placeholder": "순서를 입력하세요", - "form.status.active": "활성", - "form.status.inactive": "비활성", - "form.company.select": "회사 선택", - "form.company.common": "공통", - "form.company.submenu.note": "하위메뉴는 회사별로 관리됩니다", - "filter.company.common": "공통", - "filter.search.placeholder": "검색어를 입력하세요", - "modal.menu.register.title": "메뉴 등록", - }; - - return defaultKoreanTexts[langKey] || langKey; -} - -/** - * 기본 번역 텍스트 반환 (배치) - */ -function getDefaultTranslations( - langKeys: string[], - userLang: string -): Record { - const result: Record = {}; - - for (const langKey of langKeys) { - result[langKey] = getDefaultTranslation(langKey, userLang); - } - - return result; -} - -/** - * 캐시 초기화 (개발/테스트용) - */ -export const clearCache = async (req: AuthenticatedRequest, res: Response) => { +export const getLangText = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { try { - const beforeSize = translationCache.size; - translationCache.clear(); + const { companyCode, langKey, langCode } = req.params; - logger.info("다국어 캐시 초기화 완료", { - beforeSize, - afterSize: 0, + logger.info("특정 키의 다국어 텍스트 조회 요청", { + companyCode, + langKey, + langCode, user: req.user, }); - res.status(200).json({ + const multiLangService = new MultiLangService(); + const langText = await multiLangService.getLangText( + companyCode, + langKey, + langCode + ); + + const response: ApiResponse = { success: true, - message: "캐시가 초기화되었습니다.", - beforeSize, - afterSize: 0, - }); + message: "특정 키의 다국어 텍스트 조회 성공", + data: langText, + }; + + res.status(200).json(response); } catch (error) { - logger.error("캐시 초기화 실패", { error }); + logger.error("특정 키의 다국어 텍스트 조회 실패:", error); res.status(500).json({ success: false, - message: "캐시 초기화 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", + message: "특정 키의 다국어 텍스트 조회 중 오류가 발생했습니다.", + error: { + code: "LANG_TEXT_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * DELETE /api/multilang/languages/:langCode + * 언어 삭제 API + */ +export const deleteLanguage = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { langCode } = req.params; + logger.info("언어 삭제 요청", { langCode, user: req.user }); + + if (!langCode) { + res.status(400).json({ + success: false, + message: "언어 코드가 필요합니다.", + error: { + code: "MISSING_LANG_CODE", + details: "langCode parameter is required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + await multiLangService.deleteLanguage(langCode); + + const response: ApiResponse = { + success: true, + message: "언어가 성공적으로 삭제되었습니다.", + data: "삭제 완료", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("언어 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "언어 삭제 중 오류가 발생했습니다.", + error: { + code: "LANGUAGE_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +}; + +/** + * POST /api/multilang/batch + * 다국어 텍스트 배치 조회 API + */ +export const getBatchTranslations = async ( + req: Request, + res: Response +): Promise => { + try { + const { companyCode, menuCode, userLang } = req.query; + const { + langKeys, + companyCode: bodyCompanyCode, + menuCode: bodyMenuCode, + userLang: bodyUserLang, + } = req.body; + + // query params에서 읽지 못한 경우 body에서 읽기 + const finalCompanyCode = companyCode || bodyCompanyCode; + const finalMenuCode = menuCode || bodyMenuCode; + const finalUserLang = userLang || bodyUserLang; + + logger.info("다국어 텍스트 배치 조회 요청", { + companyCode: finalCompanyCode, + menuCode: finalMenuCode, + userLang: finalUserLang, + keyCount: langKeys?.length || 0, + }); + + if (!langKeys || !Array.isArray(langKeys) || langKeys.length === 0) { + res.status(400).json({ + success: false, + message: "langKeys 배열이 필요합니다.", + error: { + code: "MISSING_LANG_KEYS", + details: "langKeys array is required", + }, + }); + return; + } + + if (!finalCompanyCode || !finalUserLang) { + res.status(400).json({ + success: false, + message: "companyCode와 userLang은 필수입니다.", + error: { + code: "MISSING_REQUIRED_PARAMS", + details: "companyCode and userLang are required", + }, + }); + return; + } + + const multiLangService = new MultiLangService(); + const translations = await multiLangService.getBatchTranslations({ + companyCode: finalCompanyCode as string, + menuCode: finalMenuCode as string, + userLang: finalUserLang as string, + langKeys, + }); + + const response: ApiResponse> = { + success: true, + message: "다국어 텍스트 배치 조회 성공", + data: translations, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("다국어 텍스트 배치 조회 실패:", error); + res.status(500).json({ + success: false, + message: "다국어 텍스트 배치 조회 중 오류가 발생했습니다.", + error: { + code: "BATCH_TRANSLATION_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, }); } }; From d7c512772e7332d969e869d8c8a2ffeb390bbb19 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Sep 2025 18:27:50 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=EB=8F=84=EC=BB=A4=20db=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/dev/docker-compose.backend.mac.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 0cb5cc57..3862a74f 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,7 +12,7 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:postgres@postgres-erp:5432/ilshin + - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h - CORS_ORIGIN=http://localhost:9771 From 37ded5a543b1568c845200a8d9eae1c648d288e0 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 23 Sep 2025 09:27:15 +0900 Subject: [PATCH 6/8] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node From f160a33b94d3281d2c2a6d02e232a98a7f7e656b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 23 Sep 2025 10:45:53 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package-lock.json | 126 +++++++++ backend-node/package.json | 1 + .../src/database/DatabaseConnectorFactory.ts | 52 ++++ backend-node/src/database/MySQLConnector.ts | 175 +++++++++++++ .../src/database/PostgreSQLConnector.ts | 145 +++++++++++ .../src/interfaces/DatabaseConnector.ts | 27 ++ .../src/services/dbConnectionManager.ts | 59 +++++ .../services/externalDbConnectionService.ts | 246 ++++-------------- frontend/components/admin/SqlQueryModal.tsx | 128 ++++----- 9 files changed, 703 insertions(+), 256 deletions(-) create mode 100644 backend-node/src/database/DatabaseConnectorFactory.ts create mode 100644 backend-node/src/database/MySQLConnector.ts create mode 100644 backend-node/src/database/PostgreSQLConnector.ts create mode 100644 backend-node/src/interfaces/DatabaseConnector.ts create mode 100644 backend-node/src/services/dbConnectionManager.ts diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index ee373fa8..06920113 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -21,6 +21,7 @@ "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "mysql2": "^3.15.0", "nodemailer": "^6.9.7", "pg": "^8.16.3", "prisma": "^5.7.1", @@ -3612,6 +3613,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", @@ -4457,6 +4467,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5413,6 +5432,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -5959,6 +5987,12 @@ "node": ">=8" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6914,6 +6948,12 @@ "node": ">= 12.0.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6924,6 +6964,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7138,6 +7193,63 @@ "node": ">= 6.0.0" } }, + "node_modules/mysql2": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.0.tgz", + "integrity": "sha512-tT6pomf5Z/I7Jzxu8sScgrYBMK9bUFWd7Kbo6Fs1L0M13OOIJ/ZobGKS3Z7tQ8Re4lj+LnLXIQVZZxa3fhYKzA==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8226,6 +8338,11 @@ "node": ">= 0.8" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -8431,6 +8548,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 258475e9..7c7e9fb8 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -39,6 +39,7 @@ "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", + "mysql2": "^3.15.0", "nodemailer": "^6.9.7", "pg": "^8.16.3", "prisma": "^5.7.1", diff --git a/backend-node/src/database/DatabaseConnectorFactory.ts b/backend-node/src/database/DatabaseConnectorFactory.ts new file mode 100644 index 00000000..6bb55d88 --- /dev/null +++ b/backend-node/src/database/DatabaseConnectorFactory.ts @@ -0,0 +1,52 @@ +import { DatabaseConnector, ConnectionConfig } from '../interfaces/DatabaseConnector'; +import { PostgreSQLConnector } from './PostgreSQLConnector'; + +export class DatabaseConnectorFactory { + private static connectors = new Map(); + + static async createConnector( + type: string, + config: ConnectionConfig, + connectionId: number // Added connectionId for unique key + ): Promise { + const key = `${type}-${connectionId}`; // Use connectionId for unique key + if (this.connectors.has(key)) { + return this.connectors.get(key)!; + } + + let connector: DatabaseConnector; + + switch (type.toLowerCase()) { + case 'postgresql': + connector = new PostgreSQLConnector(config); + break; + // Add other database types here + default: + throw new Error(`지원하지 않는 데이터베이스 타입: ${type}`); + } + + this.connectors.set(key, connector); + return connector; + } + + static async getConnector(connectionId: number, type: string): Promise { + const key = `${type}-${connectionId}`; + return this.connectors.get(key); + } + + static async closeConnector(connectionId: number, type: string): Promise { + const key = `${type}-${connectionId}`; + const connector = this.connectors.get(key); + if (connector) { + await connector.disconnect(); + this.connectors.delete(key); + } + } + + static async closeAll(): Promise { + for (const connector of this.connectors.values()) { + await connector.disconnect(); + } + this.connectors.clear(); + } +} \ No newline at end of file diff --git a/backend-node/src/database/MySQLConnector.ts b/backend-node/src/database/MySQLConnector.ts new file mode 100644 index 00000000..6d27bad6 --- /dev/null +++ b/backend-node/src/database/MySQLConnector.ts @@ -0,0 +1,175 @@ +import * as mysql from 'mysql2/promise'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class MySQLConnector implements DatabaseConnector { + private connection: mysql.Connection | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (this.connection) { + throw new Error('이미 연결되어 있습니다.'); + } + + this.connection = await mysql.createConnection({ + host: this.config.host, + port: this.config.port, + database: this.config.database, + user: this.config.username, + password: this.config.password, + connectTimeout: this.config.connectionTimeout || 30000, + ssl: this.config.sslEnabled ? { rejectUnauthorized: false } : undefined, + }); + } + + async disconnect(): Promise { + if (this.connection) { + await this.connection.end(); + this.connection = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + + const [versionResult] = await this.connection!.query('SELECT VERSION() as version'); + const [sizeResult] = await this.connection!.query( + 'SELECT SUM(data_length + index_length) as size FROM information_schema.tables WHERE table_schema = DATABASE()' + ); + + const responseTime = Date.now() - startTime; + + return { + success: true, + message: 'MySQL 연결이 성공했습니다.', + details: { + response_time: responseTime, + server_version: (versionResult as any)[0]?.version || '알 수 없음', + database_size: this.formatBytes(parseInt((sizeResult as any)[0]?.size || '0')), + }, + }; + } catch (error) { + return { + success: false, + message: 'MySQL 연결에 실패했습니다.', + error: { + code: 'CONNECTION_FAILED', + details: error instanceof Error ? error.message : '알 수 없는 오류', + }, + }; + } finally { + await this.disconnect(); + } + } + + async executeQuery(query: string): Promise { + if (!this.connection) { + await this.connect(); + } + + try { + const [rows, fields] = await this.connection!.query(query); + return { + rows: rows as any[], + rowCount: Array.isArray(rows) ? rows.length : 0, + fields: (fields as mysql.FieldPacket[]).map(field => ({ + name: field.name, + dataType: field.type.toString(), + })), + }; + } finally { + await this.disconnect(); + } + } + + async getTables(): Promise { + if (!this.connection) { + await this.connect(); + } + + try { + const [tables] = await this.connection!.query(` + SELECT + t.TABLE_NAME as table_name, + t.TABLE_COMMENT as table_description + FROM information_schema.TABLES t + WHERE t.TABLE_SCHEMA = DATABASE() + ORDER BY t.TABLE_NAME + `); + + const result: TableInfo[] = []; + + for (const table of tables as any[]) { + const [columns] = await this.connection!.query(` + SELECT + COLUMN_NAME as column_name, + DATA_TYPE as data_type, + IS_NULLABLE as is_nullable, + COLUMN_DEFAULT as column_default + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + `, [table.table_name]); + + result.push({ + table_name: table.table_name, + description: table.table_description || null, + columns: columns as any[], + }); + } + + return result; + } finally { + await this.disconnect(); + } + } + + async getColumns(tableName: string): Promise> { + if (!this.connection) { + await this.connect(); + } + + try { + const [columns] = await this.connection!.query(` + SELECT + COLUMN_NAME as name, + DATA_TYPE as dataType, + IS_NULLABLE = 'YES' as isNullable, + COLUMN_DEFAULT as defaultValue + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + `, [tableName]); + + return (columns as any[]).map(col => ({ + name: col.name, + dataType: col.dataType, + isNullable: col.isNullable, + defaultValue: col.defaultValue, + })); + } finally { + await this.disconnect(); + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } +} diff --git a/backend-node/src/database/PostgreSQLConnector.ts b/backend-node/src/database/PostgreSQLConnector.ts new file mode 100644 index 00000000..1a6065a4 --- /dev/null +++ b/backend-node/src/database/PostgreSQLConnector.ts @@ -0,0 +1,145 @@ +import { Client } from 'pg'; +import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class PostgreSQLConnector implements DatabaseConnector { + private client: Client | null = null; + private config: ConnectionConfig; + + constructor(config: ConnectionConfig) { + this.config = config; + } + + async connect(): Promise { + if (this.client) { + await this.disconnect(); + } + const clientConfig: any = { + host: this.config.host, + port: this.config.port, + database: this.config.database, + user: this.config.user, + password: this.config.password, + }; + + if (this.config.connectionTimeoutMillis != null) { + clientConfig.connectionTimeoutMillis = this.config.connectionTimeoutMillis; + } + + if (this.config.queryTimeoutMillis != null) { + clientConfig.query_timeout = this.config.queryTimeoutMillis; + } + + if (this.config.ssl != null) { + clientConfig.ssl = this.config.ssl; + } + + this.client = new Client(clientConfig); + await this.client.connect(); + } + + async disconnect(): Promise { + if (this.client) { + await this.client.end(); + this.client = null; + } + } + + async testConnection(): Promise { + const startTime = Date.now(); + try { + await this.connect(); + const result = await this.client!.query("SELECT version(), pg_database_size(current_database()) as size"); + const responseTime = Date.now() - startTime; + await this.disconnect(); + return { + success: true, + message: "PostgreSQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: result.rows[0]?.version || "알 수 없음", + database_size: this.formatBytes(parseInt(result.rows[0]?.size || "0")), + }, + }; + } catch (error: any) { + await this.disconnect(); + return { + success: false, + message: "PostgreSQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error.message || "알 수 없는 오류", + }, + }; + } + } + + async executeQuery(query: string): Promise { + try { + await this.connect(); + const result = await this.client!.query(query); + await this.disconnect(); + return { + rows: result.rows, + rowCount: result.rowCount ?? undefined, + fields: result.fields ?? undefined, + }; + } catch (error: any) { + await this.disconnect(); + throw new Error(`PostgreSQL 쿼리 실행 실패: ${error.message}`); + } + } + + async getTables(): Promise { + try { + await this.connect(); + const result = await this.client!.query(` + SELECT + t.table_name, + obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description + FROM information_schema.tables t + WHERE t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY t.table_name; + `); + await this.disconnect(); + return result.rows.map((row) => ({ + table_name: row.table_name, + description: row.table_description, + columns: [], // Columns will be fetched by getColumns + })); + } catch (error: any) { + await this.disconnect(); + throw new Error(`PostgreSQL 테이블 목록 조회 실패: ${error.message}`); + } + } + + async getColumns(tableName: string): Promise { + try { + await this.connect(); + const result = await this.client!.query(` + SELECT + column_name, + data_type, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; + `, [tableName]); + await this.disconnect(); + return result.rows; + } catch (error: any) { + await this.disconnect(); + throw new Error(`PostgreSQL 컬럼 정보 조회 실패: ${error.message}`); + } + } + + private formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } +} \ No newline at end of file diff --git a/backend-node/src/interfaces/DatabaseConnector.ts b/backend-node/src/interfaces/DatabaseConnector.ts new file mode 100644 index 00000000..c8980eef --- /dev/null +++ b/backend-node/src/interfaces/DatabaseConnector.ts @@ -0,0 +1,27 @@ +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export interface ConnectionConfig { + host: string; + port: number; + database: string; + user: string; + password: string; + connectionTimeoutMillis?: number; + queryTimeoutMillis?: number; + ssl?: boolean | { rejectUnauthorized: boolean }; +} + +export interface QueryResult { + rows: any[]; + rowCount?: number; + fields?: any[]; +} + +export interface DatabaseConnector { + connect(): Promise; + disconnect(): Promise; + testConnection(): Promise; + executeQuery(query: string): Promise; + getTables(): Promise; + getColumns(tableName: string): Promise; // 특정 테이블의 컬럼 정보 조회 +} \ No newline at end of file diff --git a/backend-node/src/services/dbConnectionManager.ts b/backend-node/src/services/dbConnectionManager.ts new file mode 100644 index 00000000..78500c48 --- /dev/null +++ b/backend-node/src/services/dbConnectionManager.ts @@ -0,0 +1,59 @@ +import { DatabaseConnectorFactory } from '../database/DatabaseConnectorFactory'; +import { ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; +import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; + +export class DbConnectionManager { + static async testConnection( + connectionId: number, + dbType: string, + config: ConnectionConfig + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.testConnection(); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); // Close after test + } + } + + static async executeQuery( + connectionId: number, + dbType: string, + config: ConnectionConfig, + query: string + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.executeQuery(query); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); + } + } + + static async getTables( + connectionId: number, + dbType: string, + config: ConnectionConfig + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.getTables(); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); + } + } + + static async getColumns( + connectionId: number, + dbType: string, + config: ConnectionConfig, + tableName: string + ): Promise { + const connector = await DatabaseConnectorFactory.createConnector(dbType, config, connectionId); + try { + return await connector.getColumns(tableName); + } finally { + await DatabaseConnectorFactory.closeConnector(connectionId, dbType); + } + } +} \ No newline at end of file diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index 8039ebb0..bd9f8e86 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -9,6 +9,7 @@ import { TableInfo, } from "../types/externalDbTypes"; import { PasswordEncryption } from "../utils/passwordEncryption"; +import { DbConnectionManager } from "./dbConnectionManager"; const prisma = new PrismaClient(); @@ -321,8 +322,6 @@ export class ExternalDbConnectionService { static async testConnectionById( id: number ): Promise { - const startTime = Date.now(); - try { // 저장된 연결 정보 조회 const connection = await prisma.external_db_connections.findUnique({ @@ -353,40 +352,20 @@ export class ExternalDbConnectionService { }; } - // 테스트용 데이터 준비 - const testData = { - db_type: connection.db_type, + // 연결 설정 준비 + const config = { host: connection.host, port: connection.port, - database_name: connection.database_name, - username: connection.username, + database: connection.database_name, + user: connection.username, password: decryptedPassword, - connection_timeout: connection.connection_timeout || undefined, - ssl_enabled: connection.ssl_enabled || undefined + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false }; - // 실제 연결 테스트 수행 - switch (connection.db_type.toLowerCase()) { - case "postgresql": - return await this.testPostgreSQLConnection(testData, startTime); - case "mysql": - return await this.testMySQLConnection(testData, startTime); - case "oracle": - return await this.testOracleConnection(testData, startTime); - case "mssql": - return await this.testMSSQLConnection(testData, startTime); - case "sqlite": - return await this.testSQLiteConnection(testData, startTime); - default: - return { - success: false, - message: `지원하지 않는 데이터베이스 타입입니다: ${testData.db_type}`, - error: { - code: "UNSUPPORTED_DB_TYPE", - details: `${testData.db_type} 타입은 현재 지원하지 않습니다.`, - }, - }; - } + // DbConnectionManager를 통한 연결 테스트 + return await DbConnectionManager.testConnection(id, connection.db_type, config); } catch (error) { return { success: false, @@ -399,132 +378,6 @@ export class ExternalDbConnectionService { } } - /** - * PostgreSQL 연결 테스트 - */ - private static async testPostgreSQLConnection( - testData: any, - startTime: number - ): Promise { - const { Client } = await import("pg"); - const client = new Client({ - host: testData.host, - port: testData.port, - database: testData.database_name, - user: testData.username, - password: testData.password, - connectionTimeoutMillis: (testData.connection_timeout || 30) * 1000, - ssl: testData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false, - }); - - try { - await client.connect(); - const result = await client.query( - "SELECT version(), pg_database_size(current_database()) as size" - ); - const responseTime = Date.now() - startTime; - - await client.end(); - - return { - success: true, - message: "PostgreSQL 연결이 성공했습니다.", - details: { - response_time: responseTime, - server_version: result.rows[0]?.version || "알 수 없음", - database_size: this.formatBytes( - parseInt(result.rows[0]?.size || "0") - ), - }, - }; - } catch (error) { - try { - await client.end(); - } catch (endError) { - // 연결 종료 오류는 무시 - } - - return { - success: false, - message: "PostgreSQL 연결에 실패했습니다.", - error: { - code: "CONNECTION_FAILED", - details: error instanceof Error ? error.message : "알 수 없는 오류", - }, - }; - } - } - - /** - * MySQL 연결 테스트 (모의 구현) - */ - private static async testMySQLConnection( - testData: any, - startTime: number - ): Promise { - // MySQL 라이브러리가 없으므로 모의 구현 - return { - success: false, - message: "MySQL 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: "MySQL 라이브러리가 설치되지 않았습니다.", - }, - }; - } - - /** - * Oracle 연결 테스트 (모의 구현) - */ - private static async testOracleConnection( - testData: any, - startTime: number - ): Promise { - return { - success: false, - message: "Oracle 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: "Oracle 라이브러리가 설치되지 않았습니다.", - }, - }; - } - - /** - * SQL Server 연결 테스트 (모의 구현) - */ - private static async testMSSQLConnection( - testData: any, - startTime: number - ): Promise { - return { - success: false, - message: "SQL Server 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: "SQL Server 라이브러리가 설치되지 않았습니다.", - }, - }; - } - - /** - * SQLite 연결 테스트 (모의 구현) - */ - private static async testSQLiteConnection( - testData: any, - startTime: number - ): Promise { - return { - success: false, - message: "SQLite 연결 테스트는 현재 지원하지 않습니다.", - error: { - code: "NOT_IMPLEMENTED", - details: - "SQLite는 파일 기반이므로 네트워크 연결 테스트가 불가능합니다.", - }, - }; - } - /** * 바이트 크기를 읽기 쉬운 형태로 변환 */ @@ -622,36 +475,26 @@ export class ExternalDbConnectionService { }; } - // DB 타입에 따른 쿼리 실행 - switch (connection.db_type.toLowerCase()) { - case "postgresql": - return await this.executePostgreSQLQuery(connection, decryptedPassword, query); - case "mysql": - return { - success: false, - message: "MySQL 쿼리 실행은 현재 지원하지 않습니다." - }; - case "oracle": - return { - success: false, - message: "Oracle 쿼리 실행은 현재 지원하지 않습니다." - }; - case "mssql": - return { - success: false, - message: "SQL Server 쿼리 실행은 현재 지원하지 않습니다." - }; - case "sqlite": - return { - success: false, - message: "SQLite 쿼리 실행은 현재 지원하지 않습니다." - }; - default: - return { - success: false, - message: `지원하지 않는 데이터베이스 타입입니다: ${connection.db_type}` - }; - } + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DbConnectionManager를 통한 쿼리 실행 + const result = await DbConnectionManager.executeQuery(id, connection.db_type, config, query); + + return { + success: true, + message: "쿼리가 성공적으로 실행되었습니다.", + data: result.rows + }; } catch (error) { console.error("쿼리 실행 오류:", error); return { @@ -740,15 +583,26 @@ export class ExternalDbConnectionService { }; } - switch (connection.db_type.toLowerCase()) { - case "postgresql": - return await this.getPostgreSQLTables(connection, decryptedPassword); - default: - return { - success: false, - message: `지원하지 않는 데이터베이스 타입입니다: ${connection.db_type}` - }; - } + // 연결 설정 준비 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: decryptedPassword, + connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, + queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, + ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false + }; + + // DbConnectionManager를 통한 테이블 목록 조회 + const tables = await DbConnectionManager.getTables(id, connection.db_type, config); + + return { + success: true, + message: "테이블 목록을 조회했습니다.", + data: tables + }; } catch (error) { console.error("테이블 목록 조회 오류:", error); return { diff --git a/frontend/components/admin/SqlQueryModal.tsx b/frontend/components/admin/SqlQueryModal.tsx index cdc85786..de508db7 100644 --- a/frontend/components/admin/SqlQueryModal.tsx +++ b/frontend/components/admin/SqlQueryModal.tsx @@ -122,7 +122,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c return ( - + {connectionName} - SQL 쿼리 실행 @@ -131,7 +131,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c {/* 쿼리 입력 영역 */} -
+
{/* 테이블 선택 */}
@@ -168,28 +168,32 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c {/* 테이블 정보 */}

사용 가능한 테이블

-
- {tables.map((table) => ( -
-
-

{table.table_name}

- -
- {table.description && ( -

{table.description}

- )} -
- {table.columns.map((column: TableColumn) => ( -
- {column.column_name} - ({column.data_type}) +
+
+ {tables.map((table) => ( +
+
+
+

{table.table_name}

+
- ))} + {table.description && ( +

{table.description}

+ )} +
+ {table.columns.map((column: TableColumn) => ( +
+ {column.column_name} + ({column.data_type}) +
+ ))} +
+
-
- ))} + ))} +
@@ -218,7 +222,7 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c
{/* 결과 섹션 */} -
+
{loading ? "쿼리 실행 중..." : results.length > 0 ? `${results.length}개의 결과가 있습니다.` : "실행된 쿼리가 없습니다."} @@ -227,45 +231,49 @@ export const SqlQueryModal: React.FC = ({ isOpen, onClose, c {/* 결과 그리드 */}
-
- - {results.length > 0 ? ( - <> - - - {Object.keys(results[0]).map((key) => ( - - {key} - - ))} - - - - {results.map((row: QueryResult, i: number) => ( - - {Object.values(row).map((value, j) => ( - - {value === null ? ( - NULL - ) : ( - String(value) - )} - +
+
+
+
+ {results.length > 0 ? ( + <> + + + {Object.keys(results[0]).map((key) => ( + + {key} + + ))} + + + + {results.map((row: QueryResult, i: number) => ( + + {Object.values(row).map((value, j) => ( + + {value === null ? ( + NULL + ) : ( + String(value) + )} + + ))} + ))} + + + ) : ( + + + + {loading ? "쿼리 실행 중..." : "쿼리를 실행하면 결과가 여기에 표시됩니다."} + - ))} - - - ) : ( - - - - {loading ? "쿼리 실행 중..." : "쿼리를 실행하면 결과가 여기에 표시됩니다."} - - - - )} -
+ + )} + +
+
From 3083ffc0a3b49f4a97fdeb9ce060b0cbaeea8b3b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 23 Sep 2025 12:34:34 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=EC=99=B8=EB=B6=80=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=EA=B4=80=EB=A6=AC=20=EC=88=98=EC=A0=95=EC=8B=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0=EA=B3=BC=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EC=95=BD=EA=B0=84=20=EC=88=98=EC=A0=95=ED=95=9C=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/page.tsx | 38 +++++++++---------- .../admin/ExternalDbConnectionModal.tsx | 21 +++++----- frontend/components/layout/AppLayout.tsx | 12 +++++- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index e4bec481..b320ab45 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -5,13 +5,13 @@ import Link from "next/link"; */ export default function AdminPage() { return ( -
+
{/* 관리자 기능 카드들 */} -
+
-
+
@@ -24,8 +24,8 @@ export default function AdminPage() {
-
- +
+

권한 관리

@@ -36,8 +36,8 @@ export default function AdminPage() {
-
- +
+

시스템 설정

@@ -48,8 +48,8 @@ export default function AdminPage() {
-
- +
+

통계 및 리포트

@@ -61,7 +61,7 @@ export default function AdminPage() {
-
+
@@ -74,14 +74,14 @@ export default function AdminPage() {
{/* 표준 관리 섹션 */} -
+

표준 관리

-
- +
+

웹타입 관리

@@ -94,8 +94,8 @@ export default function AdminPage() {
-
- +
+

템플릿 관리

@@ -108,8 +108,8 @@ export default function AdminPage() {
-
- +
+

테이블 관리

@@ -122,8 +122,8 @@ export default function AdminPage() {
-
- +
+

컴포넌트 관리

diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index b86050f8..4959f6fe 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -211,18 +211,17 @@ export const ExternalDbConnectionModal: React.FC setTestingConnection(true); setTestResult(null); - const testData: ConnectionTestRequest = { - db_type: formData.db_type, - host: formData.host, - port: formData.port, - database_name: formData.database_name, - username: formData.username, - password: formData.password, - connection_timeout: formData.connection_timeout, - ssl_enabled: formData.ssl_enabled, - }; + // 편집 모드일 때만 연결 테스트 실행 + if (!isEditMode || !connection?.id) { + toast({ + title: "연결 테스트 불가", + description: "연결을 먼저 저장한 후 테스트할 수 있습니다.", + variant: "destructive", + }); + return; + } - const result = await ExternalDbConnectionAPI.testConnection(testData); + const result = await ExternalDbConnectionAPI.testConnection(connection.id); setTestResult(result); if (result.success) { diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index f99c72f5..22cd259b 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -307,7 +307,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
0 ? "ml-6" : ""}`} onClick={() => handleMenuClick(menu)} > @@ -328,7 +332,11 @@ function AppLayoutInner({ children }: AppLayoutProps) { {menu.children?.map((child: any) => (
handleMenuClick(child)} > {child.icon}