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% 안전**: 어떤 형태의 데이터도 수용 가능