diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index 738d486d..ec5fa949 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -763,12 +763,22 @@ export const getBatchTranslations = async ( ): Promise => { try { const { companyCode, menuCode, userLang } = req.query; - const { langKeys } = req.body; + 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, - menuCode, - userLang, + companyCode: finalCompanyCode, + menuCode: finalMenuCode, + userLang: finalUserLang, keyCount: langKeys?.length || 0, user: req.user, }); @@ -785,7 +795,7 @@ export const getBatchTranslations = async ( return; } - if (!companyCode || !userLang) { + if (!finalCompanyCode || !finalUserLang) { res.status(400).json({ success: false, message: "companyCode와 userLang은 필수입니다.", @@ -809,9 +819,9 @@ export const getBatchTranslations = async ( try { const multiLangService = new MultiLangService(client); const translations = await multiLangService.getBatchTranslations({ - companyCode: companyCode as string, - menuCode: menuCode as string, - userLang: userLang as string, + companyCode: finalCompanyCode as string, + menuCode: finalMenuCode as string, + userLang: finalUserLang as string, langKeys, }); diff --git a/docs/다국어_시스템_가이드.md b/docs/다국어_시스템_가이드.md new file mode 100644 index 00000000..e83096a9 --- /dev/null +++ b/docs/다국어_시스템_가이드.md @@ -0,0 +1,970 @@ +# 다국어 관리 시스템 가이드 + +## 📋 목차 + +1. [시스템 개요](#시스템-개요) +2. [아키텍처 구조](#아키텍처-구조) +3. [데이터베이스 구조](#데이터베이스-구조) +4. [핵심 컴포넌트](#핵심-컴포넌트) +5. [사용법 가이드](#사용법-가이드) +6. [페이지 적용 방법](#페이지-적용-방법) +7. [새로운 다국어 키 추가](#새로운-다국어-키-추가) +8. [문제 해결](#문제-해결) +9. [모범 사례](#모범-사례) + +## 🎯 시스템 개요 + +### 다국어 시스템이란? + +현재 ERP 시스템은 한국어와 영어를 지원하는 다국어 시스템을 구축하고 있습니다. 사용자가 설정한 언어에 따라 UI의 모든 텍스트가 자동으로 번역되어 표시됩니다. + +### 지원 언어 + +- **한국어 (KR)**: 기본 언어 +- **영어 (EN)**: 사용자 설정 가능 + +### 주요 특징 + +- **실시간 번역**: 페이지 로드 시 즉시 번역 적용 +- **배치 처리**: 여러 키를 한 번에 조회하여 성능 최적화 +- **캐싱 시스템**: 번역 결과를 메모리에 캐싱하여 중복 요청 방지 +- **폴백 시스템**: 번역 실패 시 기본 텍스트 표시 + +## 🏗️ 아키텍처 구조 + +### 전체 구조도 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Database │ +│ │ │ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ useMultiLang│ │ │ │ Multilang │ │ │ │ multilang │ │ +│ │ Hook │ │ │ │ Controller │ │ │ │ Table │ │ +│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ +│ │ │ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ Multilang │ │ │ │ Multilang │ │ │ │ lang_keys │ │ +│ │ Utils │ │ │ │ Service │ │ │ │ Table │ │ +│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ +│ │ │ │ │ │ +│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ +│ │ UI │ │ │ │ Batch API │ │ │ │ translations│ │ +│ │ Components │ │ │ │ Endpoint │ │ │ │ Table │ │ +│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 데이터 흐름 + +1. **사용자 언어 설정**: `useMultiLang` 훅에서 사용자 언어 확인 +2. **다국어 키 정의**: 컴포넌트에서 필요한 다국어 키 상수 정의 +3. **배치 요청**: 여러 키를 한 번에 백엔드로 전송 +4. **번역 조회**: 백엔드에서 데이터베이스에서 번역 데이터 조회 +5. **결과 반환**: 번역된 텍스트를 프론트엔드로 반환 +6. **UI 업데이트**: 번역된 텍스트로 컴포넌트 렌더링 + +## 🗄️ 데이터베이스 구조 + +### 실제 테이블 구조 + +#### 1. language_master (언어 마스터) + +```sql +CREATE TABLE public.language_master ( + lang_code varchar(10) NOT NULL PRIMARY KEY, -- 언어 코드 (KR, EN) + lang_name varchar(50) NOT NULL, -- 언어명 (Korean, English) + lang_native varchar(50) NOT NULL, -- 원어명 (한국어, English) + is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부 + sort_order int4 DEFAULT 0, -- 정렬 순서 + created_date timestamp DEFAULT CURRENT_TIMESTAMP, + created_by varchar(50), + updated_date timestamp DEFAULT CURRENT_TIMESTAMP, + updated_by varchar(50) +); +``` + +#### 2. multi_lang_key_master (다국어 키 마스터) + +```sql +CREATE TABLE public.multi_lang_key_master ( + key_id serial4 NOT NULL PRIMARY KEY, -- 키 ID (자동 증가) + company_code varchar(20) DEFAULT '*' NOT NULL, -- 회사 코드 (* = 공통) + lang_key varchar(100) NOT NULL, -- 다국어 키 (예: button.add) + description text, -- 키 설명 + is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부 + menu_name varchar(50), -- 메뉴명 (사용하지 않음) + created_date timestamp DEFAULT CURRENT_TIMESTAMP, + created_by varchar(50), + updated_date timestamp DEFAULT CURRENT_TIMESTAMP, + updated_by varchar(50), + CONSTRAINT uk_lang_key_company UNIQUE (company_code, lang_key) +); +``` + +#### 3. multi_lang_text (다국어 텍스트) + +```sql +CREATE TABLE public.multi_lang_text ( + text_id serial4 NOT NULL PRIMARY KEY, -- 텍스트 ID (자동 증가) + key_id int4 NOT NULL, -- 키 마스터의 key_id + lang_code varchar(10) NOT NULL, -- 언어 코드 (KR, EN) + lang_text text NOT NULL, -- 번역된 텍스트 + is_active bpchar(1) DEFAULT 'Y', -- 활성화 여부 + created_date timestamp DEFAULT CURRENT_TIMESTAMP, + created_by varchar(50), + updated_date timestamp DEFAULT CURRENT_TIMESTAMP, + updated_by varchar(50), + CONSTRAINT multi_lang_text_key_id_lang_code_key UNIQUE (key_id, lang_code), + CONSTRAINT multi_lang_text_key_id_fkey FOREIGN KEY (key_id) + REFERENCES multi_lang_key_master(key_id) ON DELETE CASCADE, + CONSTRAINT multi_lang_text_lang_code_fkey FOREIGN KEY (lang_code) + REFERENCES language_master(lang_code) +); +``` + +### 테이블 관계도 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ language_master │ │multi_lang_key_ │ │ multi_lang_text │ +│ │ │ master │ │ │ +│ - lang_code │ │ - key_id │ │ - text_id │ +│ - lang_name │ │ - company_code │ │ - lang_key │ +│ - lang_native │ │ - description │ │ - key_id │ +│ - is_active │ │ - is_active │ │ - lang_code │ +│ - sort_order │ │ - created_date │ │ - lang_text │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ 외래키 관계 │ + │ │ + │ lang_code │ + │ key_id │ + └─────────────────┘ +``` + +### 데이터 저장 예시 + +#### 1. 언어 마스터 데이터 + +```sql +INSERT INTO language_master (lang_code, lang_name, lang_native) VALUES +('KR', 'Korean', '한국어'), +('EN', 'English', 'English'); +``` + +#### 2. 다국어 키 등록 + +```sql +INSERT INTO multi_lang_key_master (lang_key, company_code, description) VALUES +('button.add', '*', '추가 버튼'), +('button.edit', '*', '수정 버튼'), +('menu.title', '*', '메뉴 제목'); +``` + +#### 3. 번역 텍스트 등록 + +```sql +-- 한국어 번역 +INSERT INTO multi_lang_text (key_id, lang_code, lang_text) +SELECT km.key_id, 'KR', + CASE km.lang_key + WHEN 'button.add' THEN '추가' + WHEN 'button.edit' THEN '수정' + WHEN 'menu.title' THEN '메뉴 관리' + END +FROM multi_lang_key_master km +WHERE km.lang_key IN ('button.add', 'button.edit', 'menu.title') + AND km.company_code = '*'; + +-- 영어 번역 +INSERT INTO multi_lang_text (key_id, lang_code, lang_text) +SELECT km.key_id, 'EN', + CASE km.lang_key + WHEN 'button.add' THEN 'Add' + WHEN 'button.edit' THEN 'Edit' + WHEN 'menu.title' THEN 'Menu Management' + END +FROM multi_lang_key_master km +WHERE km.lang_key IN ('button.add', 'button.edit', 'menu.title') + AND km.company_code = '*'; +``` + +## 🔧 핵심 컴포넌트 + +### 1. useMultiLang 훅 + +```typescript +// frontend/hooks/useMultiLang.ts +export const useMultiLang = (options: { companyCode?: string } = {}) => { + const [userLang, setUserLang] = useState(null); + + // 사용자 언어 조회 및 설정 + // 전역 언어 상태 동기화 + // 언어 변경 처리 + + return { + userLang, // 현재 사용자 언어 (KR, EN) + getText, // 다국어 텍스트 조회 함수 + changeLang, // 언어 변경 함수 + companyCode, // 회사 코드 + }; +}; +``` + +**주요 기능:** + +- 사용자 언어 설정 및 조회 +- 전역 언어 상태 관리 +- 언어 변경 시 자동 동기화 + +**사용 예시:** + +```typescript +const { userLang, getText, changeLang } = useMultiLang({ companyCode: "*" }); + +// 언어 변경 +await changeLang("EN"); + +// 다국어 텍스트 조회 +const text = await getText("menu.management", "button.add"); +``` + +### 2. Multilang Utils + +```typescript +// frontend/lib/utils/multilang.ts +export const MENU_MANAGEMENT_KEYS = { + TITLE: "menu.management.title", + BUTTON_ADD: "button.add", + BUTTON_EDIT: "button.edit", + // ... 더 많은 키들 +} as const; + +// 배치 조회 함수 +export async function getMultilangText( + key: string, + companyCode: string = "*", + menuCode: string = "MENU_MANAGEMENT", + userLang: string = "KR" +): Promise; + +// 동기 조회 함수 (캐시에서만) +export function getMultilangTextSync( + key: string, + companyCode: string = "*", + menuCode: string = "MENU_MANAGEMENT", + userLang: string = "KR" +): string; +``` + +**주요 기능:** + +- 다국어 키 상수 정의 +- 배치 처리로 성능 최적화 +- 캐싱 시스템으로 중복 요청 방지 +- 동기/비동기 조회 지원 + +### 3. Backend API + +```typescript +// backend/src/controllers/multilangController.ts +@Post("/batch") +async getBatchTranslations( + @Body() body: { langKeys: string[] }, + @Query() query: { companyCode: string; menuCode: string; userLang: string } +): Promise>> +``` + +**API 엔드포인트:** + +- **POST** `/multilang/batch` - 여러 키를 한 번에 조회 +- **GET** `/multilang/:key` - 개별 키 조회 (사용하지 않음) + +**요청 파라미터:** + +```typescript +{ + langKeys: ["button.add", "button.edit", "menu.title"], + companyCode: "*", // 회사 코드 (* = 전체) + menuCode: "MENU_MANAGEMENT", // 메뉴 코드 + userLang: "KR" // 사용자 언어 +} +``` + +**응답 형식:** + +```typescript +{ + success: true, + data: { + "button.add": "추가", + "button.edit": "수정", + "menu.title": "메뉴 관리" + } +} +``` + +## 📖 사용법 가이드 + +### 1. 기본 사용법 + +#### 단계별 가이드 + +1. **다국어 키 상수 정의** +2. **컴포넌트에서 다국어 텍스트 로드** +3. **UI에 번역된 텍스트 적용** + +#### 예시 코드 + +```typescript +// 1. 다국어 키 상수 정의 +const MENU_KEYS = ["menu.title", "button.add", "button.edit"]; + +// 2. 다국어 텍스트 로드 +const [uiTexts, setUiTexts] = useState>({}); + +useEffect(() => { + const loadTexts = async () => { + const response = await apiClient.post("/multilang/batch", { + langKeys: MENU_KEYS, + companyCode: "*", + menuCode: "MENU_MANAGEMENT", + userLang: userLang, + }); + + if (response.data.success) { + setUiTexts(response.data.data); + } + }; + + if (userLang) { + loadTexts(); + } +}, [userLang]); + +// 3. UI에 적용 +const getText = (key: string) => uiTexts[key] || key; + +return ( +
+

{getText("menu.title")}

+ +
+); +``` + +### 2. 고급 사용법 + +#### 배치 처리 최적화 + +```typescript +// 여러 키를 한 번에 조회하여 API 호출 최소화 +const LANG_KEYS = [ + "form.title", + "form.name.label", + "form.name.placeholder", + "form.submit.button", +]; + +// 한 번의 API 호출로 모든 키 조회 +const translations = await getMultilangTextBatch(LANG_KEYS); +``` + +#### 폴백 시스템 + +```typescript +const getText = (key: string, fallback?: string) => { + // 1. 번역된 텍스트 우선 + if (uiTexts[key]) return uiTexts[key]; + + // 2. 폴백 텍스트 사용 + if (fallback) return fallback; + + // 3. 키 자체 반환 (디버깅용) + return key; +}; +``` + +## 🚀 페이지 적용 방법 + +### 1. 새로운 페이지에 다국어 적용하기 + +#### Step 1: 다국어 키 정의 + +```typescript +// constants/pageKeys.ts +export const PAGE_KEYS = { + // 페이지 제목 + TITLE: "page.title", + DESCRIPTION: "page.description", + + // 폼 요소 + FORM_NAME: "form.name", + FORM_EMAIL: "form.email", + FORM_SUBMIT: "form.submit", + + // 메시지 + SUCCESS_MESSAGE: "message.success", + ERROR_MESSAGE: "message.error", +} as const; +``` + +#### Step 2: 컴포넌트에서 다국어 텍스트 로드 + +```typescript +// components/MyPage.tsx +import { PAGE_KEYS } from "@/constants/pageKeys"; + +export const MyPage: React.FC = () => { + const { userLang } = useMultiLang(); + const [uiTexts, setUiTexts] = useState>({}); + + // 다국어 텍스트 로드 + useEffect(() => { + const loadTexts = async () => { + if (!userLang) return; + + const response = await apiClient.post("/multilang/batch", { + langKeys: Object.values(PAGE_KEYS), + companyCode: "*", + menuCode: "MY_PAGE", + userLang: userLang, + }); + + if (response.data.success) { + setUiTexts(response.data.data); + } + }; + + loadTexts(); + }, [userLang]); + + // 텍스트 가져오기 함수 + const getText = (key: string) => uiTexts[key] || key; + + return ( +
+

{getText(PAGE_KEYS.TITLE)}

+

{getText(PAGE_KEYS.DESCRIPTION)}

+ +
+ + + + +
+
+ ); +}; +``` + +#### Step 3: 데이터베이스에 번역 데이터 등록 + +```sql +-- 1. 다국어 키 마스터에 키 등록 +INSERT INTO multi_lang_key_master ( + lang_key, + company_code, + description, + is_active, + created_date, + created_by +) VALUES +('page.title', '*', '페이지 제목', 'Y', now(), 'system'), +('page.description', '*', '페이지 설명', 'Y', now(), 'system'), +('form.name', '*', '이름', 'Y', now(), 'system'), +('form.email', '*', '이메일', 'Y', now(), 'system'), +('form.submit', '*', '제출', 'Y', now(), 'system'); + +-- 2. 한국어 번역 텍스트 등록 +INSERT INTO multi_lang_text ( + key_id, + lang_code, + lang_text, + is_active, + created_date, + created_by +) +SELECT + km.key_id, + 'KR', + CASE km.lang_key + WHEN 'page.title' THEN '내 페이지' + WHEN 'page.description' THEN '이것은 내 페이지입니다' + WHEN 'form.name' THEN '이름' + WHEN 'form.email' THEN '이메일' + WHEN 'form.submit' THEN '제출' + END, + 'Y', + now(), + 'system' +FROM multi_lang_key_master km +WHERE km.lang_key IN ('page.title', 'page.description', 'form.name', 'form.email', 'form.submit') + AND km.company_code = '*'; + +-- 3. 영어 번역 텍스트 등록 +INSERT INTO multi_lang_text ( + key_id, + lang_code, + lang_text, + is_active, + created_date, + created_by +) +SELECT + km.key_id, + 'EN', + CASE km.lang_key + WHEN 'page.title' THEN 'My Page' + WHEN 'page.description' THEN 'This is my page' + WHEN 'form.name' THEN 'Name' + WHEN 'form.email' THEN 'Email' + WHEN 'form.submit' THEN 'Submit' + END, + 'Y', + now(), + 'system' +FROM multi_lang_key_master km +WHERE km.lang_key IN ('page.title', 'page.description', 'form.name', 'form.email', 'form.submit') + AND km.company_code = '*'; +``` + +### 2. 기존 페이지에 다국어 적용하기 + +#### Step 1: 하드코딩된 텍스트 찾기 + +```typescript +// 변경 전 +return ( +
+

메뉴 관리

+ + +
+); + +// 변경 후 +return ( +
+

{getText("menu.management.title")}

+ + +
+); +``` + +#### Step 2: 다국어 키 상수 추가 + +```typescript +// 기존 키에 새로운 키 추가 +export const EXISTING_KEYS = [ + // 기존 키들... + "menu.management.title", + "button.add", + "button.edit", +]; +``` + +#### Step 3: 번역 데이터 등록 + +```sql +-- 1. 다국어 키 마스터에 키 등록 +INSERT INTO multi_lang_key_master ( + lang_key, + company_code, + description, + is_active, + created_date, + created_by +) VALUES +('menu.management.title', '*', '메뉴 관리 제목', 'Y', now(), 'system'), +('button.add', '*', '추가 버튼', 'Y', now(), 'system'), +('button.edit', '*', '수정 버튼', 'Y', now(), 'system'); + +-- 2. 한국어 번역 텍스트 등록 +INSERT INTO multi_lang_text ( + key_id, + lang_code, + lang_text, + is_active, + created_date, + created_by +) +SELECT + km.key_id, + 'KR', + CASE km.lang_key + WHEN 'menu.management.title' THEN '메뉴 관리' + WHEN 'button.add' THEN '추가' + WHEN 'button.edit' THEN '수정' + END, + 'Y', + now(), + 'system' +FROM multi_lang_key_master km +WHERE km.lang_key IN ('menu.management.title', 'button.add', 'button.edit') + AND km.company_code = '*'; + +-- 3. 영어 번역 텍스트 등록 +INSERT INTO multi_lang_text ( + key_id, + lang_code, + lang_text, + is_active, + created_date, + created_by +) +SELECT + km.key_id, + 'EN', + CASE km.lang_key + WHEN 'menu.management.title' THEN 'Menu Management' + WHEN 'button.add' THEN 'Add' + WHEN 'button.edit' THEN 'Edit' + END, + 'Y', + now(), + 'system' +FROM multi_lang_key_master km +WHERE km.lang_key IN ('menu.management.title', 'button.add', 'button.edit') + AND km.company_code = '*'; +``` + +## ➕ 새로운 다국어 키 추가 + +### 1. 프론트엔드에서 키 추가 + +#### Step 1: 키 상수 정의 + +```typescript +// constants/multilang.ts +export const NEW_FEATURE_KEYS = { + // 새로운 기능 관련 키들 + FEATURE_TITLE: "new.feature.title", + FEATURE_DESCRIPTION: "new.feature.description", + FEATURE_BUTTON: "new.feature.button", +} as const; +``` + +#### Step 2: 컴포넌트에서 사용 + +```typescript +import { NEW_FEATURE_KEYS } from "@/constants/multilang"; + +const MyComponent = () => { + const getText = (key: string) => uiTexts[key] || key; + + return ( +
+

{getText(NEW_FEATURE_KEYS.FEATURE_TITLE)}

+

{getText(NEW_FEATURE_KEYS.FEATURE_DESCRIPTION)}

+ +
+ ); +}; +``` + +### 2. 백엔드에서 번역 데이터 등록 + +#### Step 1: 다국어 키 등록 + +```sql +-- multi_lang_key_master 테이블에 새로운 키 등록 +INSERT INTO multi_lang_key_master ( + lang_key, + company_code, + description, + is_active, + created_date, + created_by +) VALUES +('new.feature.title', '*', '새로운 기능 제목', 'Y', now(), 'system'), +('new.feature.description', '*', '새로운 기능 설명', 'Y', now(), 'system'), +('new.feature.button', '*', '새로운 기능 버튼', 'Y', now(), 'system'); +``` + +#### Step 2: 번역 텍스트 등록 + +```sql +-- 한국어 번역 텍스트 등록 +INSERT INTO multi_lang_text ( + key_id, + lang_code, + lang_text, + is_active, + created_date, + created_by +) +SELECT + km.key_id, + 'KR', + CASE km.lang_key + WHEN 'new.feature.title' THEN '새로운 기능' + WHEN 'new.feature.description' THEN '이것은 새로운 기능입니다' + WHEN 'new.feature.button' THEN '시작하기' + END, + 'Y', + now(), + 'system' +FROM multi_lang_key_master km +WHERE km.lang_key IN ('new.feature.title', 'new.feature.description', 'new.feature.button') + AND km.company_code = '*'; + +-- 영어 번역 텍스트 등록 +INSERT INTO multi_lang_text ( + key_id, + lang_code, + lang_text, + is_active, + created_date, + created_by +) +SELECT + km.key_id, + 'EN', + CASE km.lang_key + WHEN 'new.feature.title' THEN 'New Feature' + WHEN 'new.feature.description' THEN 'This is a new feature' + WHEN 'new.feature.button' THEN 'Get Started' + END, + 'Y', + now(), + 'system' +FROM multi_lang_key_master km +WHERE km.lang_key IN ('new.feature.title', 'new.feature.description', 'new.feature.button') + AND km.company_code = '*'; +``` + +## 🔍 문제 해결 + +### 1. 일반적인 문제들 + +#### 문제: 키가 그대로 표시됨 + +```typescript +// 문제 상황 +

menu.management.title

; + +// 원인: 번역 데이터가 로드되지 않음 +// 해결: 다국어 텍스트 로드 확인 +console.log("uiTexts:", uiTexts); +console.log("userLang:", userLang); +``` + +#### 문제: 일부만 번역됨 + +```typescript +// 원인: 일부 키만 번역 데이터에 등록됨 +// 해결: 모든 키에 대한 번역 데이터 확인 +SELECT km.lang_key, t.lang_code, t.lang_text +FROM multi_lang_key_master km +LEFT JOIN multi_lang_text t ON km.key_id = t.key_id +WHERE km.lang_key IN ('key1', 'key2', 'key3') + AND km.company_code = '*'; +``` + +#### 문제: 언어 변경이 반영되지 않음 + +```typescript +// 원인: useMultiLang 훅의 의존성 배열 문제 +// 해결: userLang 변경 시 다국어 텍스트 재로드 +useEffect(() => { + if (userLang) { + loadTexts(); + } +}, [userLang]); // userLang 의존성 추가 +``` + +### 2. 디버깅 방법 + +#### 콘솔 로그 확인 + +```typescript +// 다국어 텍스트 로드 과정 추적 +console.log("🌐 다국어 텍스트 로드 시작:", { + userLang, + keys: MENU_KEYS, + uiTextsCount: Object.keys(uiTexts).length, +}); + +// API 응답 확인 +console.log("📡 API 응답:", response.data); + +// 최종 상태 확인 +console.log("✅ 최종 uiTexts:", uiTexts); +``` + +#### 네트워크 탭 확인 + +- **API 호출**: `/multilang/batch` 엔드포인트 호출 확인 +- **요청 파라미터**: langKeys, companyCode, menuCode, userLang 확인 +- **응답 데이터**: 번역된 텍스트가 올바르게 반환되는지 확인 + +#### 데이터베이스 직접 확인 + +```sql +-- 특정 키의 번역 데이터 확인 +SELECT km.lang_key, t.lang_code, t.lang_text, km.description +FROM multi_lang_key_master km +LEFT JOIN multi_lang_text t ON km.key_id = t.key_id +WHERE km.lang_key = 'button.add' + AND km.company_code = '*' + AND t.lang_code = 'KR'; + +-- 특정 회사의 모든 번역 데이터 확인 +SELECT km.lang_key, t.lang_code, t.lang_text, km.description +FROM multi_lang_key_master km +LEFT JOIN multi_lang_text t ON km.key_id = t.key_id +WHERE km.company_code = '*' + AND t.lang_code = 'KR'; +``` + +## 📚 모범 사례 + +### 1. 키 명명 규칙 + +#### 계층 구조 사용 + +```typescript +// 좋은 예시 +export const KEYS = { + // 페이지 레벨 + PAGE_TITLE: "page.title", + PAGE_DESCRIPTION: "page.description", + + // 폼 레벨 + FORM_TITLE: "form.title", + FORM_NAME: "form.name", + FORM_EMAIL: "form.email", + + // 버튼 레벨 + BUTTON_SUBMIT: "button.submit", + BUTTON_CANCEL: "button.cancel", + + // 메시지 레벨 + MESSAGE_SUCCESS: "message.success", + MESSAGE_ERROR: "message.error", +}; +``` + +#### 일관된 접두사 사용 + +```typescript +// 일관된 접두사로 그룹화 +export const KEYS = { + // 공통 UI 요소 + COMMON_LOADING: "common.loading", + COMMON_ERROR: "common.error", + COMMON_SUCCESS: "common.success", + + // 특정 기능 + FEATURE_TITLE: "feature.title", + FEATURE_DESCRIPTION: "feature.description", +}; +``` + +### 2. 성능 최적화 + +#### 배치 처리 사용 + +```typescript +// ❌ 나쁜 예시: 개별 요청 +const text1 = await getText("key1"); +const text2 = await getText("key2"); +const text3 = await getText("key3"); + +// ✅ 좋은 예시: 배치 요청 +const keys = ["key1", "key2", "key3"]; +const translations = await getBatchTexts(keys); +``` + +#### 캐싱 활용 + +```typescript +// 번역 결과를 상태에 저장하여 재사용 +const [uiTexts, setUiTexts] = useState>({}); + +// 한 번 로드한 후 재사용 +useEffect(() => { + if (Object.keys(uiTexts).length === 0) { + loadTexts(); + } +}, []); +``` + +### 3. 에러 처리 + +#### 폴백 시스템 구현 + +```typescript +const getText = (key: string, fallback?: string) => { + // 1. 번역된 텍스트 우선 + if (uiTexts[key]) return uiTexts[key]; + + // 2. 폴백 텍스트 사용 + if (fallback) return fallback; + + // 3. 키 자체 반환 (디버깅용) + console.warn(`번역 키를 찾을 수 없음: ${key}`); + return key; +}; +``` + +#### 로딩 상태 관리 + +```typescript +const [loading, setLoading] = useState(false); +const [error, setError] = useState(null); + +const loadTexts = async () => { + try { + setLoading(true); + setError(null); + + const response = await apiClient.post("/multilang/batch", { ... }); + + if (response.data.success) { + setUiTexts(response.data.data); + } else { + setError(response.data.message); + } + } catch (err) { + setError('다국어 텍스트 로드 실패'); + } finally { + setLoading(false); + } +}; +``` + +## 📝 요약 + +### 핵심 포인트 + +1. **useMultiLang 훅**을 사용하여 사용자 언어 설정 관리 +2. **배치 API**를 사용하여 여러 키를 한 번에 조회 +3. **키 상수**를 정의하여 일관성 유지 +4. **폴백 시스템**을 구현하여 번역 실패 시에도 UI 표시 +5. **캐싱**을 활용하여 성능 최적화 + +### 개발 워크플로우 + +1. 다국어 키 상수 정의 +2. 컴포넌트에서 다국어 텍스트 로드 +3. UI에 번역된 텍스트 적용 +4. 데이터베이스에 번역 데이터 등록 +5. 테스트 및 디버깅 + +### 주의사항 + +- 키 명명 규칙을 일관되게 유지 +- 배치 처리를 사용하여 API 호출 최소화 +- 폴백 시스템을 구현하여 사용자 경험 보장 +- 성능을 고려한 캐싱 전략 수립 + +이 가이드를 따라하면 새로운 개발자도 쉽게 다국어 시스템을 이해하고 적용할 수 있을 것입니다. diff --git a/frontend/app/(main)/admin/layout.tsx b/frontend/app/(main)/admin/layout.tsx index 89738fb2..84882137 100644 --- a/frontend/app/(main)/admin/layout.tsx +++ b/frontend/app/(main)/admin/layout.tsx @@ -313,17 +313,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) } }, [userLang]); - // 컴포넌트 마운트 시 강제로 번역 로드 (userLang이 아직 설정되지 않았을 수 있음) + // 컴포넌트 마운트 시 userLang이 설정될 때까지 대기 useEffect(() => { - const timer = setTimeout(() => { - if (!userLang) { - console.log("🔄 Admin Layout 마운트 후 강제 번역 로드 (userLang 없음)"); - loadTranslations(); - } - }, 100); // 100ms 후 실행 - - return () => clearTimeout(timer); - }, []); // 컴포넌트 마운트 시 한 번만 실행 + if (userLang) { + console.log("🔄 userLang 설정됨, 번역 로드 시작:", userLang); + loadTranslations(); + } + }, [userLang]); // userLang이 설정될 때마다 실행 // 키보드 단축키로 사이드바 토글 useEffect(() => { @@ -359,11 +355,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const loadTranslations = async () => { try { - // 현재 사용자 언어 사용 - const currentUserLang = userLang || "en"; + // userLang이 설정되지 않았으면 번역 로드하지 않음 + if (!userLang) { + console.log("⏳ userLang이 설정되지 않음, 번역 로드 대기"); + return; + } + console.log("🌐 Admin Layout 번역 로드 시작", { userLang, - currentUserLang, }); // API 직접 호출로 현재 언어 사용 (배치 조회 방식) @@ -380,7 +379,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) params: { companyCode, menuCode: "MENU_MANAGEMENT", - userLang: currentUserLang, + userLang: userLang, }, }, ); @@ -392,24 +391,45 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) translations[MENU_MANAGEMENT_KEYS.DESCRIPTION] || "시스템의 메뉴 구조와 권한을 관리합니다."; // 번역 캐시에 저장 - setTranslationCache(currentUserLang, translations); + setTranslationCache(userLang, translations); // 상태 업데이트 setMenuTranslations({ title, description }); - console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang: currentUserLang }); + console.log("🌐 Admin Layout 번역 로드 완료 (배치)", { title, description, userLang }); } else { - // 기본값 사용 - const title = "메뉴 관리"; - const description = "시스템의 메뉴 구조와 권한을 관리합니다."; + // 전역 사용자 로케일 확인하여 기본값 설정 + const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR"; + console.log("🌐 전역 사용자 로케일 확인:", globalUserLang); + + // 사용자 로케일에 따른 기본값 설정 + let title, description; + if (globalUserLang === "US") { + title = "Menu Management"; + description = "Manage system menu structure and permissions"; + } else { + title = "메뉴 관리"; + description = "시스템의 메뉴 구조와 권한을 관리합니다."; + } + setMenuTranslations({ title, description }); - console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: currentUserLang }); + console.log("🌐 Admin Layout 기본값 사용", { title, description, userLang: globalUserLang }); } } catch (error) { console.error("❌ Admin Layout 배치 번역 로드 실패:", error); - // 오류 시 기본값 사용 - const title = "메뉴 관리"; - const description = "시스템의 메뉴 구조와 권한을 관리합니다."; + // 오류 시에도 전역 사용자 로케일 확인하여 기본값 설정 + const globalUserLang = (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR"; + console.log("🌐 오류 시 전역 사용자 로케일 확인:", globalUserLang); + + let title, description; + if (globalUserLang === "US") { + title = "Menu Management"; + description = "Manage system menu structure and permissions"; + } else { + title = "메뉴 관리"; + description = "시스템의 메뉴 구조와 권한을 관리합니다."; + } + setMenuTranslations({ title, description }); } } catch (error) { @@ -510,11 +530,6 @@ export default function AdminLayout({ children }: { children: React.ReactNode })

인증 실패

토큰이 없습니다. 3초 후 로그인 페이지로 이동합니다.

-
-

디버깅 정보

-

현재 경로: {pathname}

-

토큰: {localStorage.getItem("authToken") ? "존재" : "없음"}

-
)} diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index 3770a095..be3c2132 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -10,7 +10,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; -import { getMenuTextSync, MENU_MANAGEMENT_KEYS, setTranslationCache } from "@/lib/utils/multilang"; +import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang"; interface Company { company_code: string; @@ -27,6 +27,8 @@ interface MenuFormModalProps { menuType?: string; level?: number; parentCompanyCode?: string; + // 다국어 텍스트 props 추가 + uiTexts: Record; } export const MenuFormModal: React.FC = ({ @@ -38,6 +40,7 @@ export const MenuFormModal: React.FC = ({ menuType, level, parentCompanyCode, + uiTexts, }) => { console.log("🎯 MenuFormModal 렌더링 - Props:", { isOpen, @@ -48,6 +51,11 @@ export const MenuFormModal: React.FC = ({ parentCompanyCode, }); + // 다국어 텍스트 가져오기 함수 + const getText = (key: string, fallback?: string): string => { + return uiTexts[key] || fallback || key; + }; + console.log("🔍 MenuFormModal 컴포넌트 마운트됨"); const [formData, setFormData] = useState({ @@ -149,7 +157,7 @@ export const MenuFormModal: React.FC = ({ stack: error?.stack, response: error?.response, }); - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO)); + toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO)); } finally { setLoading(false); } @@ -254,7 +262,7 @@ export const MenuFormModal: React.FC = ({ setCompanies(companyList); } catch (error) { console.error("회사 목록 로딩 오류:", error); - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST)); + toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST)); } }; @@ -273,7 +281,7 @@ export const MenuFormModal: React.FC = ({ } } catch (error) { console.error("❌ 다국어 키 목록 로딩 오류:", error); - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST)); + toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST)); setLangKeys([]); } }; @@ -282,12 +290,12 @@ export const MenuFormModal: React.FC = ({ e.preventDefault(); if (!formData.menuNameKor.trim()) { - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED)); + toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED)); return; } if (!formData.companyCode) { - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED)); + toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED)); return; } @@ -324,7 +332,7 @@ export const MenuFormModal: React.FC = ({ } } catch (error) { console.error("메뉴 저장/수정 실패:", error); - toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED)); + toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED)); } finally { setLoading(false); } @@ -345,58 +353,63 @@ export const MenuFormModal: React.FC = ({ const selectedLangKeyInfo = getSelectedLangKeyInfo(); + // 전역 사용자 로케일 가져오기 + const getCurrentUserLang = () => { + return (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR"; + }; + return ( {isEdit - ? getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE) - : getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)} + ? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE) + : getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
- +
- +
- + {!isEdit && level !== 1 && ( -

{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}

+

{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}

)}
- +
{selectedLangKeyInfo && (

- {getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED, { - key: selectedLangKeyInfo.langKey, - description: selectedLangKeyInfo.description, - })} + {getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED) + .replace("{key}", selectedLangKeyInfo.langKey) + .replace("{description}", selectedLangKeyInfo.description)}

)}
- + handleInputChange("menuNameKor", e.target.value)} - placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)} + placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)} required />
- + handleInputChange("menuUrl", e.target.value)} - placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)} + placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)} />
- +