multilang #2

Merged
kjs merged 3 commits from multilang into dev 2025-08-25 18:32:55 +09:00
11 changed files with 1610 additions and 406 deletions

View File

@ -763,12 +763,22 @@ export const getBatchTranslations = async (
): Promise<void> => {
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,
});

View File

@ -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<string | null>(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<string>;
// 동기 조회 함수 (캐시에서만)
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<ApiResponse<Record<string, string>>>
```
**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<Record<string, string>>({});
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 (
<div>
<h1>{getText("menu.title")}</h1>
<button>{getText("button.add")}</button>
</div>
);
```
### 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<Record<string, string>>({});
// 다국어 텍스트 로드
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 (
<div>
<h1>{getText(PAGE_KEYS.TITLE)}</h1>
<p>{getText(PAGE_KEYS.DESCRIPTION)}</p>
<form>
<label>{getText(PAGE_KEYS.FORM_NAME)}</label>
<input placeholder={getText(PAGE_KEYS.FORM_NAME)} />
<button type="submit">{getText(PAGE_KEYS.FORM_SUBMIT)}</button>
</form>
</div>
);
};
```
#### 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 (
<div>
<h1>메뉴 관리</h1>
<button>추가</button>
<button>수정</button>
</div>
);
// 변경 후
return (
<div>
<h1>{getText("menu.management.title")}</h1>
<button>{getText("button.add")}</button>
<button>{getText("button.edit")}</button>
</div>
);
```
#### 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 (
<div>
<h2>{getText(NEW_FEATURE_KEYS.FEATURE_TITLE)}</h2>
<p>{getText(NEW_FEATURE_KEYS.FEATURE_DESCRIPTION)}</p>
<button>{getText(NEW_FEATURE_KEYS.FEATURE_BUTTON)}</button>
</div>
);
};
```
### 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
// 문제 상황
<h1>menu.management.title</h1>;
// 원인: 번역 데이터가 로드되지 않음
// 해결: 다국어 텍스트 로드 확인
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<Record<string, string>>({});
// 한 번 로드한 후 재사용
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<string | null>(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 호출 최소화
- 폴백 시스템을 구현하여 사용자 경험 보장
- 성능을 고려한 캐싱 전략 수립
이 가이드를 따라하면 새로운 개발자도 쉽게 다국어 시스템을 이해하고 적용할 수 있을 것입니다.

View File

@ -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 })
<div className="text-center">
<h1 className="mb-4 text-2xl font-bold text-red-600"> </h1>
<p className="mb-4"> . 3 .</p>
<div className="rounded bg-yellow-100 p-4">
<h2 className="mb-2 font-semibold"> </h2>
<p> : {pathname}</p>
<p>: {localStorage.getItem("authToken") ? "존재" : "없음"}</p>
</div>
</div>
</div>
)}

View File

@ -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<string, string>;
}
export const MenuFormModal: React.FC<MenuFormModalProps> = ({
@ -38,6 +40,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
menuType,
level,
parentCompanyCode,
uiTexts,
}) => {
console.log("🎯 MenuFormModal 렌더링 - Props:", {
isOpen,
@ -48,6 +51,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
parentCompanyCode,
});
// 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => {
return uiTexts[key] || fallback || key;
};
console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
const [formData, setFormData] = useState<MenuFormData>({
@ -149,7 +157,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
}
} 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<MenuFormModalProps> = ({
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<MenuFormModalProps> = ({
}
} 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<MenuFormModalProps> = ({
const selectedLangKeyInfo = getSelectedLangKeyInfo();
// 전역 사용자 로케일 가져오기
const getCurrentUserLang = () => {
return (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{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)}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="menuType">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE)}</Label>
<Label htmlFor="menuType">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE)}</Label>
<Select value={formData.menuType} onValueChange={(value) => handleInputChange("menuType", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN)}</SelectItem>
<SelectItem value="1">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER)}</SelectItem>
<SelectItem value="0">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN)}</SelectItem>
<SelectItem value="1">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER)}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS)}</Label>
<Label htmlFor="status">{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}</Label>
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_ACTIVE)}</SelectItem>
<SelectItem value="INACTIVE">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_STATUS_INACTIVE)}</SelectItem>
<SelectItem value="ACTIVE">{getText(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)}</SelectItem>
<SelectItem value="INACTIVE">{getText(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="companyCode">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY)} *</Label>
<Label htmlFor="companyCode">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY)} *</Label>
<Select
value={formData.companyCode}
onValueChange={(value) => handleInputChange("companyCode", value)}
disabled={!isEdit && level !== 1} // 수정 모드가 아니고 최상위 메뉴가 아니면 비활성화
>
<SelectTrigger>
<SelectValue placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT)} />
<SelectValue placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON)}</SelectItem>
<SelectItem value="none">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON)}</SelectItem>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
@ -405,12 +418,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</SelectContent>
</Select>
{!isEdit && level !== 1 && (
<p className="text-xs text-gray-500">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
<p className="text-xs text-gray-500">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="langKey">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY)}</Label>
<Label htmlFor="langKey">{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY)}</Label>
<div className="langkey-dropdown relative">
<button
type="button"
@ -419,7 +432,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
disabled={!formData.companyCode}
>
<span className={!formData.langKey ? "text-muted-foreground" : ""}>
{formData.langKey || getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT)}
{formData.langKey || getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT)}
</span>
<svg
className={`h-4 w-4 transition-transform ${isLangKeyDropdownOpen ? "rotate-180" : ""}`}
@ -436,7 +449,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
{/* 검색 입력 */}
<div className="border-b p-2">
<Input
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH)}
value={langKeySearchText}
onChange={(e) => setLangKeySearchText(e.target.value)}
className="h-8 text-sm"
@ -454,7 +467,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setLangKeySearchText("");
}}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE)}
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE)}
</div>
{langKeys
@ -483,48 +496,47 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
</div>
{selectedLangKeyInfo && (
<p className="text-xs text-gray-500">
{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)}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="menuNameKor">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME)} *</Label>
<Label htmlFor="menuNameKor">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME)} *</Label>
<Input
id="menuNameKor"
value={formData.menuNameKor}
onChange={(e) => handleInputChange("menuNameKor", e.target.value)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="menuUrl">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
<Input
id="menuUrl"
value={formData.menuUrl}
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="menuDesc">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION)}</Label>
<Label htmlFor="menuDesc">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION)}</Label>
<Textarea
id="menuDesc"
value={formData.menuDesc}
onChange={(e) => handleInputChange("menuDesc", e.target.value)}
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER)}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="seq">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE)}</Label>
<Label htmlFor="seq">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE)}</Label>
<Input
id="seq"
type="number"
@ -536,14 +548,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL)}
</Button>
<Button type="submit" disabled={loading}>
{loading
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING)
? getText(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING)
: isEdit
? getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY)
: getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER)}
? getText(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY)
: getText(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER)}
</Button>
</div>
</form>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { menuApi } from "@/lib/api/menu";
import type { MenuItem } from "@/lib/api/menu";
import { MenuTable } from "./MenuTable";
@ -24,12 +24,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useMenu } from "@/contexts/MenuContext";
import {
getMenuTextSync,
MENU_MANAGEMENT_KEYS,
useMenuManagementText,
setTranslationCache,
} from "@/lib/utils/multilang";
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client";
@ -46,7 +41,7 @@ export const MenuManagement: React.FC = () => {
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
// 다국어 텍스트 훅 사용
const { getMenuText } = useMenuManagementText();
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
const { userLang } = useMultiLang({ companyCode: "*" });
// 다국어 텍스트 상태
@ -68,29 +63,282 @@ export const MenuManagement: React.FC = () => {
parentCompanyCode: "",
});
// 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
// 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
const MENU_MANAGEMENT_LANG_KEYS = [
// 페이지 제목 및 설명
"menu.management.title",
"menu.management.description",
"menu.type.title",
"menu.type.admin",
"menu.type.user",
"menu.management.admin",
"menu.management.user",
"menu.management.admin.description",
"menu.management.user.description",
// 버튼
"button.add",
"button.add.top.level",
"button.add.sub",
"button.edit",
"button.delete",
"button.delete.selected",
"button.delete.selected.count",
"button.delete.processing",
"button.cancel",
"button.save",
"button.register",
"button.modify",
// 필터 및 검색
"filter.company",
"filter.company.all",
"filter.company.common",
"filter.company.search",
"filter.search",
"filter.search.placeholder",
"filter.reset",
// 테이블 헤더
"table.header.select",
"table.header.menu.name",
"table.header.menu.url",
"table.header.menu.type",
"table.header.status",
"table.header.company",
"table.header.sequence",
"table.header.actions",
// 상태
"status.active",
"status.inactive",
"status.unspecified",
// 폼
"form.menu.type",
"form.menu.type.admin",
"form.menu.type.user",
"form.company",
"form.company.select",
"form.company.common",
"form.company.submenu.note",
"form.lang.key",
"form.lang.key.select",
"form.lang.key.none",
"form.lang.key.search",
"form.lang.key.selected",
"form.menu.name",
"form.menu.name.placeholder",
"form.menu.url",
"form.menu.url.placeholder",
"form.menu.description",
"form.menu.description.placeholder",
"form.menu.sequence",
// 모달
"modal.menu.register.title",
"modal.menu.modify.title",
"modal.delete.title",
"modal.delete.description",
"modal.delete.batch.description",
// 메시지
"message.loading",
"message.menu.delete.processing",
"message.menu.save.success",
"message.menu.save.failed",
"message.menu.delete.success",
"message.menu.delete.failed",
"message.menu.delete.batch.success",
"message.menu.delete.batch.partial",
"message.menu.status.toggle.success",
"message.menu.status.toggle.failed",
"message.validation.menu.name.required",
"message.validation.company.required",
"message.validation.select.menu.delete",
"message.error.load.menu.list",
"message.error.load.menu.info",
"message.error.load.company.list",
"message.error.load.lang.key.list",
// 리스트 정보
"menu.list.title",
"menu.list.total",
"menu.list.search.result",
// UI
"ui.expand",
"ui.collapse",
"ui.menu.collapse",
"ui.language",
];
// 초기 로딩
useEffect(() => {
loadCompanies();
}, []); // 빈 의존성 배열로 한 번만 실행
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
if (!userLang) {
initializeDefaultTexts();
}
}, [userLang]); // userLang 변경 시마다 실행
// 초기 기본 텍스트 설정 함수
const initializeDefaultTexts = () => {
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
// 기본 한국어 텍스트 제공
const defaultText = getDefaultText(key);
defaultTexts[key] = defaultText;
});
setUiTexts(defaultTexts);
console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
};
// 기본 텍스트 반환 함수
const getDefaultText = (key: string): string => {
const defaultTexts: Record<string, string> = {
"menu.management.title": "메뉴 관리",
"menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
"menu.type.title": "메뉴 타입",
"menu.type.admin": "관리자",
"menu.type.user": "사용자",
"menu.management.admin": "관리자 메뉴",
"menu.management.user": "사용자 메뉴",
"menu.management.admin.description": "시스템 관리 및 설정 메뉴",
"menu.management.user.description": "일반 사용자 업무 메뉴",
"button.add": "추가",
"button.add.top.level": "최상위 메뉴 추가",
"button.add.sub": "하위 메뉴 추가",
"button.edit": "수정",
"button.delete": "삭제",
"button.delete.selected": "선택 삭제",
"button.delete.selected.count": "선택 삭제 ({count})",
"button.delete.processing": "삭제 중...",
"button.cancel": "취소",
"button.save": "저장",
"button.register": "등록",
"button.modify": "수정",
"filter.company": "회사",
"filter.company.all": "전체",
"filter.company.common": "공통",
"filter.company.search": "회사 검색",
"filter.search": "검색",
"filter.search.placeholder": "메뉴명 또는 URL로 검색...",
"filter.reset": "초기화",
"table.header.select": "선택",
"table.header.menu.name": "메뉴명",
"table.header.menu.url": "URL",
"table.header.menu.type": "메뉴 타입",
"table.header.status": "상태",
"table.header.company": "회사",
"table.header.sequence": "순서",
"table.header.actions": "작업",
"status.active": "활성화",
"status.inactive": "비활성화",
"status.unspecified": "미지정",
"form.menu.type": "메뉴 타입",
"form.menu.type.admin": "관리자",
"form.menu.type.user": "사용자",
"form.company": "회사",
"form.company.select": "회사를 선택하세요",
"form.company.common": "공통",
"form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
"form.lang.key": "다국어 키",
"form.lang.key.select": "다국어 키를 선택하세요",
"form.lang.key.none": "다국어 키 없음",
"form.lang.key.search": "다국어 키 검색...",
"form.lang.key.selected": "선택된 키: {key} - {description}",
"form.menu.name": "메뉴명",
"form.menu.name.placeholder": "메뉴명을 입력하세요",
"form.menu.url": "URL",
"form.menu.url.placeholder": "메뉴 URL을 입력하세요",
"form.menu.description": "설명",
"form.menu.description.placeholder": "메뉴 설명을 입력하세요",
"form.menu.sequence": "순서",
"modal.menu.register.title": "메뉴 등록",
"modal.menu.modify.title": "메뉴 수정",
"modal.delete.title": "메뉴 삭제",
"modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"modal.delete.batch.description":
"선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
"message.loading": "로딩 중...",
"message.menu.delete.processing": "메뉴 삭제 중...",
"message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
"message.menu.save.failed": "메뉴 저장에 실패했습니다.",
"message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
"message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
"message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
"message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
"message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
"message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
"message.validation.menu.name.required": "메뉴명을 입력해주세요.",
"message.validation.company.required": "회사를 선택해주세요.",
"message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
"message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
"message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
"message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
"message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
"menu.list.title": "메뉴 목록",
"menu.list.total": "총 {count}개",
"menu.list.search.result": "검색 결과: {count}개",
"ui.expand": "펼치기",
"ui.collapse": "접기",
"ui.menu.collapse": "메뉴 접기",
"ui.language": "언어",
};
return defaultTexts[key] || key;
};
// 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
useEffect(() => {
if (!uiTextsLoading) {
if (userLang && !uiTextsLoading) {
loadUITexts();
}
}, [userLang]); // userLang 변경 시마다 실행
// 컴포넌트 마운트 시 강제로 번역 로드 (userLang이 아직 설정되지 않았을 수 있음)
// uiTexts 상태 변경 감지
useEffect(() => {
console.log("🔄 uiTexts 상태 변경됨:", {
count: Object.keys(uiTexts).length,
sampleKeys: Object.keys(uiTexts).slice(0, 5),
sampleValues: Object.entries(uiTexts)
.slice(0, 3)
.map(([k, v]) => `${k}: ${v}`),
});
}, [uiTexts]);
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
useEffect(() => {
const timer = setTimeout(() => {
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
console.log("🔄 컴포넌트 마운트 후 강제 번역 로드");
if (userLang && !uiTextsLoading) {
console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
loadUITexts();
}
}, 100); // 100ms 후 실행
}, 300); // 300ms 후 실행
return () => clearTimeout(timer);
}, []); // 컴포넌트 마운트 시 한 번만 실행
}, [userLang]); // userLang이 설정된 후 실행
// 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
useEffect(() => {
const fallbackTimer = setTimeout(() => {
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
if (!userLang) {
initializeDefaultTexts();
} else {
// 사용자 언어가 설정된 경우 다국어 텍스트 로드
loadUITexts();
}
}
}, 1000); // 1초 후 실행
return () => clearTimeout(fallbackTimer);
}, [userLang]); // userLang 변경 시마다 실행
// 번역 로드 이벤트 감지
useEffect(() => {
@ -134,10 +382,10 @@ export const MenuManagement: React.FC = () => {
setLoading(true);
}
await refreshMenus();
console.log(`📋 메뉴 목록 조회 성공`);
console.log("📋 메뉴 목록 조회 성공");
} catch (error) {
console.error("❌ 메뉴 목록 조회 실패:", error);
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_LIST));
toast.error(getUITextSync("message.error.load.menu.list"));
} finally {
if (showLoading) {
setLoading(false);
@ -147,7 +395,7 @@ export const MenuManagement: React.FC = () => {
// 회사 목록 조회
const loadCompanies = async () => {
console.log(`🏢 회사 목록 조회 시작`);
console.log("🏢 회사 목록 조회 시작");
try {
const response = await apiClient.get("/admin/companies");
@ -165,228 +413,94 @@ export const MenuManagement: React.FC = () => {
}
};
// 다국어 텍스트 로드 함수
// 다국어 텍스트 로드 함수 - 배치 API 사용
const loadUITexts = async () => {
if (uiTextsLoading) return; // 이미 로딩 중이면 중단
// userLang이 없으면 기본값 사용
const currentUserLang = userLang || "KR";
console.log("🌐 UI 다국어 텍스트 로드 시작", { currentUserLang });
// userLang이 설정되지 않았으면 기본값 설정
if (!userLang) {
console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
});
setUiTexts(defaultTexts);
return;
}
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
if (Object.keys(uiTexts).length === 0) {
console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = getDefaultText(key);
});
setUiTexts(defaultTexts);
}
console.log("🌐 UI 다국어 텍스트 로드 시작", {
userLang,
apiParams: {
companyCode: "*",
menuCode: "menu.management",
userLang: userLang,
},
});
setUiTextsLoading(true);
const texts: Record<string, string> = {};
try {
const textPromises = [
getMenuText(MENU_MANAGEMENT_KEYS.TITLE),
getMenuText(MENU_MANAGEMENT_KEYS.DESCRIPTION),
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE),
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN),
getMenuText(MENU_MANAGEMENT_KEYS.MENU_TYPE_USER),
getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_MENU),
getMenuText(MENU_MANAGEMENT_KEYS.USER_MENU),
getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION),
getMenuText(MENU_MANAGEMENT_KEYS.USER_DESCRIPTION),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT),
getMenuText(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING),
getMenuText(MENU_MANAGEMENT_KEYS.FILTER_RESET),
getMenuText(MENU_MANAGEMENT_KEYS.LIST_TOTAL),
getMenuText(MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_TYPE),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE),
getMenuText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS),
// 추가 키들 - 실제 UI에서 사용되는 모든 키들
getMenuText("menu.list.title"),
getMenuText("filter.company"),
getMenuText("filter.company.all"),
getMenuText("filter.search"),
getMenuText("filter.search.placeholder"),
getMenuText("status.unspecified"),
getMenuText("status.active"),
getMenuText("filter.company.common"),
getMenuText("modal.menu.register.title"),
getMenuText("form.menu.type"),
getMenuText("form.menu.type.admin"),
getMenuText("form.menu.type.user"),
getMenuText("form.status"),
getMenuText("form.status.active"),
getMenuText("form.status.inactive"),
getMenuText("form.company"),
getMenuText("form.company.select"),
getMenuText("form.company.common"),
getMenuText("form.company.submenu.note"),
getMenuText("form.lang.key"),
getMenuText("form.lang.key.select"),
getMenuText("form.menu.name"),
getMenuText("form.menu.name.placeholder"),
getMenuText("form.menu.url"),
getMenuText("form.menu.url.placeholder"),
getMenuText("form.menu.description"),
getMenuText("form.menu.description.placeholder"),
getMenuText("form.menu.sequence"),
getMenuText("button.cancel"),
getMenuText("button.register"),
// 테이블 헤더 관련 추가 키들
getMenuText("table.header.menu.name"),
getMenuText("table.header.sequence"),
getMenuText("table.header.company"),
getMenuText("table.header.menu.url"),
getMenuText("table.header.status"),
getMenuText("table.header.actions"),
// 액션 버튼 관련 추가 키들
getMenuText("button.add"),
getMenuText("button.add.sub"),
getMenuText("button.edit"),
getMenuText("button.delete"),
// 페이지 제목 관련
getMenuText("page.title.menu.management"),
getMenuText("page.description.menu.management"),
getMenuText("section.title.menu.type"),
getMenuText("section.title.admin.menu.list"),
];
// 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
const response = await apiClient.post(
"/multilang/batch",
{
langKeys: MENU_MANAGEMENT_LANG_KEYS,
companyCode: "*", // 모든 회사
menuCode: "menu.management", // 메뉴관리 메뉴
userLang: userLang, // body에 포함
},
{
params: {}, // query params는 비움
},
);
const results = await Promise.all(textPromises);
if (response.data.success) {
const translations = response.data.data;
console.log("🌐 배치 다국어 텍스트 응답:", translations);
// 결과를 키와 매핑
const keys = [
MENU_MANAGEMENT_KEYS.TITLE,
MENU_MANAGEMENT_KEYS.DESCRIPTION,
MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE,
MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN,
MENU_MANAGEMENT_KEYS.MENU_TYPE_USER,
MENU_MANAGEMENT_KEYS.ADMIN_MENU,
MENU_MANAGEMENT_KEYS.USER_MENU,
MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION,
MENU_MANAGEMENT_KEYS.USER_DESCRIPTION,
MENU_MANAGEMENT_KEYS.BUTTON_ADD,
MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL,
MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB,
MENU_MANAGEMENT_KEYS.BUTTON_EDIT,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT,
MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING,
MENU_MANAGEMENT_KEYS.FILTER_RESET,
MENU_MANAGEMENT_KEYS.LIST_TOTAL,
MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_TYPE,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE,
MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS,
// 추가 키들 - 실제 UI에서 사용되는 모든 키들
"menu.list.title",
"filter.company",
"filter.company.all",
"filter.search",
"filter.search.placeholder",
"status.unspecified",
"status.active",
"filter.company.common",
"modal.menu.register.title",
"form.menu.type",
"form.menu.type.admin",
"form.menu.type.user",
"form.status",
"form.status.active",
"form.status.inactive",
"form.company",
"form.company.select",
"form.company.common",
"form.company.submenu.note",
"form.lang.key",
"form.lang.key.select",
"form.menu.name",
"form.menu.name.placeholder",
"form.menu.url",
"form.menu.url.placeholder",
"form.menu.description",
"form.menu.description.placeholder",
"form.menu.sequence",
"button.cancel",
"button.register",
// 테이블 헤더 관련 추가 키들
"table.header.menu.name",
"table.header.sequence",
"table.header.company",
"table.header.menu.url",
"table.header.status",
"table.header.actions",
// 액션 버튼 관련 추가 키들
"button.add",
"button.add.sub",
"button.edit",
"button.delete",
// 페이지 제목 관련
"page.title.menu.management",
"page.description.menu.management",
"section.title.menu.type",
"section.title.admin.menu.list",
];
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
const mergedTranslations = { ...uiTexts, ...translations };
console.log("🔧 setUiTexts 호출 전:", {
translationsCount: Object.keys(translations).length,
mergedCount: Object.keys(mergedTranslations).length,
});
setUiTexts(mergedTranslations);
console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
keys.forEach((key, index) => {
texts[key] = results[index];
});
setUiTexts(texts);
// 번역 텍스트를 캐시에 저장
setTranslationCache(currentUserLang, texts);
console.log("🌐 UI 다국어 텍스트 로드 완료:", texts);
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
setTranslationCache(userLang, mergedTranslations);
} else {
console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
// API 실패 시에도 기존 uiTexts는 유지
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
}
} catch (error) {
console.error("❌ UI 다국어 텍스트 로드 실패:", error);
// API 실패 시에도 기존 uiTexts는 유지
console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
} finally {
setUiTextsLoading(false);
}
};
// UI 텍스트 가져오기 함수
const getUIText = async (
key: string,
params?: Record<string, string | number>,
fallback?: string,
): Promise<string> => {
// uiTexts에서 먼저 찾기
let text = uiTexts[key];
// UI 텍스트 가져오기 함수 (동기 버전만 사용)
// getUIText 함수는 제거 - getUITextSync만 사용
// uiTexts에 없으면 비동기적으로 API 호출
if (!text) {
try {
text = await getMenuText(key);
// 새로운 텍스트를 uiTexts에 추가
setUiTexts((prev) => ({ ...prev, [key]: text }));
} catch (error) {
console.error(`❌ 키 "${key}" 번역 실패:`, error);
text = fallback || key;
}
}
// 파라미터 치환
if (params && text) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
text = text!.replace(`{${paramKey}}`, String(paramValue));
});
}
return text || key;
};
// 동기 버전 (기존 호환성을 위해)
// 동기 버전 (DB에서 가져온 번역 텍스트 사용)
const getUITextSync = (key: string, params?: Record<string, string | number>, fallback?: string): string => {
// uiTexts에서 번역 텍스트 찾기
let text = uiTexts[key];
// uiTexts에 없으면 fallback 또는 키 사용
if (!text) {
text = fallback || key;
}
@ -401,11 +515,11 @@ export const MenuManagement: React.FC = () => {
return text || key;
};
// 다국어 API 테스트 함수
// 다국어 API 테스트 함수 (getUITextSync 사용)
const testMultiLangAPI = async () => {
console.log("🧪 다국어 API 테스트 시작");
try {
const text = await getMenuText(MENU_MANAGEMENT_KEYS.ADMIN_MENU);
const text = getUITextSync("menu.management.admin");
console.log("🧪 다국어 API 테스트 결과:", text);
} catch (error) {
console.error("❌ 다국어 API 테스트 실패:", error);
@ -513,11 +627,11 @@ export const MenuManagement: React.FC = () => {
const handleDeleteSelectedMenus = async () => {
if (selectedMenus.size === 0) {
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_SELECT_MENU_DELETE));
toast.error(getUITextSync("message.validation.select.menu.delete"));
return;
}
if (!confirm(getMenuTextSync(MENU_MANAGEMENT_KEYS.MODAL_DELETE_BATCH_DESCRIPTION, { count: selectedMenus.size }))) {
if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
return;
}
@ -526,7 +640,7 @@ export const MenuManagement: React.FC = () => {
const menuIds = Array.from(selectedMenus);
console.log("삭제할 메뉴 IDs:", menuIds);
toast.info(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_PROCESSING));
toast.info(getUITextSync("message.menu.delete.processing"));
const response = await menuApi.deleteMenusBatch(menuIds);
console.log("삭제 API 응답:", response);
@ -552,12 +666,10 @@ export const MenuManagement: React.FC = () => {
// 삭제 결과 메시지
if (failedCount === 0) {
toast.success(
getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_SUCCESS, { count: deletedCount }),
);
toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
} else {
toast.success(
getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_BATCH_PARTIAL, {
getUITextSync("message.menu.delete.batch.partial", {
success: deletedCount,
failed: failedCount,
}),
@ -569,7 +681,7 @@ export const MenuManagement: React.FC = () => {
}
} catch (error) {
console.error("메뉴 삭제 중 오류:", error);
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_DELETE_FAILED));
toast.error(getUITextSync("message.menu.delete.failed"));
} finally {
setDeleting(false);
}
@ -605,7 +717,7 @@ export const MenuManagement: React.FC = () => {
}
} catch (error) {
console.error("메뉴 상태 토글 오류:", error);
toast.error(getMenuTextSync(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_STATUS_TOGGLE_FAILED));
toast.error(getUITextSync("message.menu.status.toggle.failed"));
}
};
@ -658,15 +770,29 @@ export const MenuManagement: React.FC = () => {
};
const getMenuTypeString = () => {
return selectedMenuType === "admin"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_ADMIN)
: getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_USER);
return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
};
const getMenuTypeValue = () => {
return selectedMenuType === "admin" ? "0" : "1";
};
// uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
const adminMenusCount = useMemo(() => adminMenus?.length || 0, [adminMenus]);
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
// 디버깅을 위한 간단한 상태 표시
console.log("🔍 MenuManagement 렌더링 상태:", {
loading,
uiTextsLoading,
uiTextsCount,
adminMenusCount,
userMenusCount,
selectedMenuType,
userLang,
});
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
@ -676,14 +802,14 @@ export const MenuManagement: React.FC = () => {
}
return (
<LoadingOverlay isLoading={deleting} text="메뉴 삭제 중...">
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
<div className="flex h-full flex-col">
{/* 메인 컨텐츠 - 2:8 비율 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r bg-gray-50">
<div className="p-6">
<h2 className="mb-4 text-lg font-semibold">{getMenuTextSync(MENU_MANAGEMENT_KEYS.MENU_TYPE_TITLE)}</h2>
<h2 className="mb-4 text-lg font-semibold">{getUITextSync("menu.type.title")}</h2>
<div className="space-y-3">
<Card
className={`cursor-pointer transition-all ${
@ -694,9 +820,9 @@ export const MenuManagement: React.FC = () => {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync(MENU_MANAGEMENT_KEYS.ADMIN_MENU)}</h3>
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
<p className="mt-1 text-sm text-gray-600">
{getUITextSync(MENU_MANAGEMENT_KEYS.ADMIN_DESCRIPTION)}
{getUITextSync("menu.management.admin.description")}
</p>
</div>
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
@ -715,9 +841,9 @@ export const MenuManagement: React.FC = () => {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync(MENU_MANAGEMENT_KEYS.USER_MENU)}</h3>
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
<p className="mt-1 text-sm text-gray-600">
{getUITextSync(MENU_MANAGEMENT_KEYS.USER_DESCRIPTION)}
{getUITextSync("menu.management.user.description")}
</p>
</div>
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>{userMenus.length}</Badge>
@ -733,7 +859,7 @@ export const MenuManagement: React.FC = () => {
<div className="flex h-full flex-col p-6">
<div className="mb-6 flex-shrink-0">
<h2 className="mb-2 text-xl font-semibold">
{getMenuTypeString()} {getMenuTextSync(MENU_MANAGEMENT_KEYS.LIST_TITLE)}
{getMenuTypeString()} {getUITextSync("menu.list.title")}
</h2>
</div>
@ -741,7 +867,7 @@ export const MenuManagement: React.FC = () => {
<div className="mb-4 flex-shrink-0">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<Label htmlFor="company">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY)}</Label>
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
<div className="company-dropdown relative">
<button
type="button"
@ -750,11 +876,11 @@ export const MenuManagement: React.FC = () => {
>
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)
? getUITextSync("filter.company.all")
: selectedCompany === "*"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
? getUITextSync("filter.company.common")
: companies.find((c) => c.code === selectedCompany)?.name ||
getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)}
getUITextSync("filter.company.all")}
</span>
<svg
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
@ -771,7 +897,7 @@ export const MenuManagement: React.FC = () => {
{/* 검색 입력 */}
<div className="border-b p-2">
<Input
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_SEARCH)}
placeholder={getUITextSync("filter.company.search")}
value={companySearchText}
onChange={(e) => setCompanySearchText(e.target.value)}
className="h-8 text-sm"
@ -789,7 +915,7 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText("");
}}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_ALL)}
{getUITextSync("filter.company.all")}
</div>
<div
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
@ -799,7 +925,7 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText("");
}}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)}
{getUITextSync("filter.company.common")}
</div>
{companies
@ -819,7 +945,7 @@ export const MenuManagement: React.FC = () => {
setCompanySearchText("");
}}
>
{company.code === "*" ? "공통" : company.name}
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
</div>
))}
</div>
@ -829,9 +955,9 @@ export const MenuManagement: React.FC = () => {
</div>
<div>
<Label htmlFor="search">{getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_SEARCH)}</Label>
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
<Input
placeholder={getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_SEARCH_PLACEHOLDER)}
placeholder={getUITextSync("filter.search.placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
@ -847,13 +973,13 @@ export const MenuManagement: React.FC = () => {
variant="outline"
className="w-full"
>
{getUITextSync(MENU_MANAGEMENT_KEYS.FILTER_RESET)}
{getUITextSync("filter.reset")}
</Button>
</div>
<div className="flex items-end">
<div className="text-sm text-gray-600">
{getUITextSync(MENU_MANAGEMENT_KEYS.LIST_SEARCH_RESULT, { count: getCurrentMenus().length })}
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
</div>
</div>
</div>
@ -862,11 +988,11 @@ export const MenuManagement: React.FC = () => {
<div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
{getUITextSync(MENU_MANAGEMENT_KEYS.LIST_TOTAL, { count: getCurrentMenus().length })}
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
{getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD_TOP_LEVEL)}
{getUITextSync("button.add.top.level")}
</Button>
{selectedMenus.size > 0 && (
<Button
@ -878,10 +1004,10 @@ export const MenuManagement: React.FC = () => {
{deleting ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_PROCESSING)}
{getUITextSync("button.delete.processing")}
</>
) : (
getUITextSync(MENU_MANAGEMENT_KEYS.BUTTON_DELETE_SELECTED_COUNT, {
getUITextSync("button.delete.selected.count", {
count: selectedMenus.size,
})
)}
@ -900,6 +1026,7 @@ export const MenuManagement: React.FC = () => {
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
/>
</div>
</div>
@ -915,6 +1042,7 @@ export const MenuManagement: React.FC = () => {
menuType={formData.menuType}
level={formData.level}
parentCompanyCode={formData.parentCompanyCode}
uiTexts={uiTexts}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>

View File

@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang";
import { getMenuTextSync, MENU_MANAGEMENT_KEYS, setTranslationCache } from "@/lib/utils/multilang";
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
interface MenuTableProps {
menus: MenuItem[];
@ -20,6 +20,8 @@ interface MenuTableProps {
onSelectAllMenus: (checked: boolean) => void;
expandedMenus: Set<string>;
onToggleExpand: (menuId: string) => void;
// 다국어 텍스트 props 추가
uiTexts: Record<string, string>;
}
export const MenuTable: React.FC<MenuTableProps> = ({
@ -33,8 +35,12 @@ export const MenuTable: React.FC<MenuTableProps> = ({
onSelectAllMenus,
expandedMenus,
onToggleExpand,
uiTexts,
}) => {
const { userLang } = useMultiLang();
// 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => {
return uiTexts[key] || fallback || key;
};
// 다국어 텍스트 표시 함수 (기본값 처리)
const getDisplayText = (menu: MenuItem) => {
@ -130,8 +136,8 @@ export const MenuTable: React.FC<MenuTableProps> = ({
}`}
>
{status === "active"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)
: getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}
? getText(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)
: getText(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}
</button>
);
};
@ -156,22 +162,22 @@ export const MenuTable: React.FC<MenuTableProps> = ({
/>
</TableHead>
<TableHead className="w-1/3 bg-gray-50 font-semibold text-gray-700">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME)}
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_NAME)}
</TableHead>
<TableHead className="w-16 bg-gray-50 font-semibold text-gray-700">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE)}
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_SEQUENCE)}
</TableHead>
<TableHead className="w-24 bg-gray-50 font-semibold text-gray-700">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY)}
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_COMPANY)}
</TableHead>
<TableHead className="w-48 bg-gray-50 font-semibold text-gray-700">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL)}
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_MENU_URL)}
</TableHead>
<TableHead className="w-20 bg-gray-50 font-semibold text-gray-700">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}
</TableHead>
<TableHead className="w-32 bg-gray-50 font-semibold text-gray-700">
{getMenuTextSync(MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS)}
{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_ACTIONS)}
</TableHead>
</TableRow>
</TableHeader>
@ -236,11 +242,11 @@ export const MenuTable: React.FC<MenuTableProps> = ({
<TableCell className="text-sm text-gray-600">
<div className="flex flex-col">
<span
className={`font-medium ${companyName && companyName !== getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED) ? "text-green-600" : "text-gray-500"}`}
className={`font-medium ${companyName && companyName !== getText(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED) ? "text-green-600" : "text-gray-500"}`}
>
{companyCode === "*"
? getMenuTextSync(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
: companyName || getMenuTextSync(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED)}
? getText(MENU_MANAGEMENT_KEYS.FILTER_COMPANY_COMMON)
: companyName || getText(MENU_MANAGEMENT_KEYS.STATUS_UNSPECIFIED)}
</span>
{companyCode && companyCode !== "" && (
<span className="font-mono text-xs text-gray-400">{companyCode}</span>
@ -285,7 +291,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onAddMenu(objid, menuType, lev)}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
</Button>
)}
{lev === 2 && (
@ -296,7 +302,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onAddMenu(objid, menuType, lev)}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD_SUB)}
</Button>
<Button
size="sm"
@ -304,7 +310,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onEditMenu(objid)}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
</Button>
</>
)}
@ -315,7 +321,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
className="min-w-[40px] px-1 py-1 text-xs"
onClick={() => onEditMenu(objid)}
>
{getMenuTextSync(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
</Button>
)}
</div>

View File

@ -110,6 +110,34 @@ export const useAuth = () => {
if (response.success && response.data) {
console.log("사용자 정보 조회 성공:", response.data);
// 사용자 로케일 정보도 함께 조회하여 전역 저장
try {
const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
if (localeResponse.success && localeResponse.data) {
const userLocale = localeResponse.data;
console.log("✅ 사용자 로케일 조회 성공:", userLocale);
// 전역 상태에 저장 (다른 컴포넌트에서 사용)
(window as any).__GLOBAL_USER_LANG = userLocale;
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
// localStorage에도 저장 (새 창에서 공유)
localStorage.setItem("userLocale", userLocale);
localStorage.setItem("userLocaleLoaded", "true");
console.log("🌐 전역 사용자 로케일 저장됨:", userLocale);
}
} catch (localeError) {
console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError);
(window as any).__GLOBAL_USER_LANG = "KR";
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
// localStorage에도 저장
localStorage.setItem("userLocale", "KR");
localStorage.setItem("userLocaleLoaded", "true");
}
return response.data;
}
@ -348,6 +376,12 @@ export const useAuth = () => {
// JWT 토큰 제거
TokenManager.removeToken();
// 로케일 정보도 제거
localStorage.removeItem("userLocale");
localStorage.removeItem("userLocaleLoaded");
(window as any).__GLOBAL_USER_LANG = undefined;
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
// 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화
setUser(null);
setAuthStatus({
@ -365,6 +399,13 @@ export const useAuth = () => {
// 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화
TokenManager.removeToken();
// 로케일 정보도 제거
localStorage.removeItem("userLocale");
localStorage.removeItem("userLocaleLoaded");
(window as any).__GLOBAL_USER_LANG = undefined;
(window as any).__GLOBAL_USER_LOCALE_LOADED = undefined;
setUser(null);
setAuthStatus({
isLoggedIn: false,

View File

@ -6,75 +6,87 @@ let globalUserLang = "KR";
let globalChangeLangCallback: ((lang: string) => void) | null = null;
export const useMultiLang = (options: { companyCode?: string } = {}) => {
const [userLang, setUserLang] = useState<string>("KR");
const [userLang, setUserLang] = useState<string | null>(null); // null로 시작
const companyCode = options.companyCode || "*";
// 전역 언어 상태 동기화
// 전역 언어 상태 동기화 (무한 루프 방지)
useEffect(() => {
if (globalUserLang !== userLang) {
// 초기 로딩 시에만 동기화
if (globalUserLang && globalUserLang !== userLang) {
setUserLang(globalUserLang);
}
}, [globalUserLang]);
}, []); // 의존성 배열을 비워서 한 번만 실행
// 언어 변경 시 전역 콜백 호출
// 언어 변경 시 전역 콜백 호출 (무한 루프 방지)
useEffect(() => {
if (globalChangeLangCallback) {
// 언어가 설정된 경우에만 콜백 호출
if (globalChangeLangCallback && userLang) {
globalChangeLangCallback(userLang);
}
}, [userLang]);
// 사용자 로케일 조회 (한 번만 실행)
useEffect(() => {
// localStorage에서 로케일 확인 (새 창에서도 공유)
const storedLocale = localStorage.getItem("userLocale");
const storedLocaleLoaded = localStorage.getItem("userLocaleLoaded");
if (storedLocaleLoaded === "true" && storedLocale) {
console.log("🌐 localStorage에서 사용자 로케일 사용:", storedLocale);
setUserLang(storedLocale);
globalUserLang = storedLocale;
// 전역 상태도 동기화
(window as any).__GLOBAL_USER_LANG = storedLocale;
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
return;
}
// 전역에서 이미 로케일이 로드되었는지 확인
if ((window as any).__GLOBAL_USER_LOCALE_LOADED) {
const globalLocale = (window as any).__GLOBAL_USER_LANG;
console.log("🌐 전역에서 사용자 로케일 사용:", globalLocale);
setUserLang(globalLocale);
globalUserLang = globalLocale;
return;
}
// 이미 로케일이 설정되어 있으면 중복 호출 방지
if (globalUserLang && globalUserLang !== "KR") {
if (globalUserLang) {
setUserLang(globalUserLang);
return;
}
const fetchUserLocale = async () => {
try {
console.log("🔍 사용자 로케일 조회 시작");
const response = await apiClient.get("/admin/user-locale");
// 전역 로케일이 아직 로드되지 않았으면 대기
console.log("⏳ 전역 로케일 로드 대기 중...");
if (response.data.success && response.data.data) {
const userLocale = response.data.data;
console.log("✅ 사용자 로케일 조회 성공:", userLocale);
// 데이터베이스의 locale 값을 그대로 사용 (매핑 없음)
setUserLang(userLocale);
globalUserLang = userLocale; // 전역 상태도 업데이트
return;
}
// API 호출 실패 시 브라우저 언어 사용
console.warn("⚠️ 사용자 로케일 조회 실패, 브라우저 언어 사용");
const browserLang = navigator.language.split("-")[0];
// 브라우저 언어를 그대로 사용 (매핑 없음)
if (["ko", "en", "ja", "zh"].includes(browserLang)) {
setUserLang(browserLang);
globalUserLang = browserLang;
}
} catch (error) {
console.error("❌ 사용자 로케일 조회 중 오류:", error);
// 오류 시 브라우저 언어 사용
const browserLang = navigator.language.split("-")[0];
// 브라우저 언어를 그대로 사용 (매핑 없음)
if (["ko", "en", "ja", "zh"].includes(browserLang)) {
setUserLang(browserLang);
globalUserLang = browserLang;
}
// 주기적으로 전역 로케일 확인 (더 빠른 간격으로)
const checkInterval = setInterval(() => {
if ((window as any).__GLOBAL_USER_LOCALE_LOADED) {
const globalLocale = (window as any).__GLOBAL_USER_LANG;
console.log("🌐 전역에서 사용자 로케일 확인됨:", globalLocale);
setUserLang(globalLocale);
globalUserLang = globalLocale;
clearInterval(checkInterval);
}
};
}, 50); // 50ms로 단축
fetchUserLocale();
// 3초 후 타임아웃 (더 빠른 타임아웃)
setTimeout(() => {
clearInterval(checkInterval);
if (!userLang) {
console.warn("⚠️ 전역 로케일 로드 타임아웃, 기본값 사용");
setUserLang("KR");
globalUserLang = "KR";
}
}, 3000); // 3초로 단축
return () => clearInterval(checkInterval);
}, []);
// 다국어 텍스트 가져오기 (배치 조회 방식)
const getText = async (menuCode: string, langKey: string, fallback?: string): Promise<string> => {
console.log(`🔍 다국어 텍스트 요청 (배치 방식):`, { menuCode, langKey, userLang, companyCode });
console.log("🔍 다국어 텍스트 요청 (배치 방식):", { menuCode, langKey, userLang, companyCode });
try {
// 배치 조회 API 사용
@ -92,7 +104,7 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
},
);
console.log(`📡 배치 API 응답 상태:`, response.status, response.statusText);
console.log("📡 배치 API 응답 상태:", response.status, response.statusText);
if (response.data.success && response.data.data && response.data.data[langKey]) {
// 번역 텍스트를 캐시에 저장
@ -105,26 +117,36 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
}
// 실패 시 fallback 또는 키 반환
console.log(`🔄 배치 API 성공했지만 데이터 없음, fallback 반환:`, fallback || langKey);
console.log("🔄 배치 API 성공했지만 데이터 없음, fallback 반환:", fallback || langKey);
return fallback || langKey;
} catch (error) {
console.error("❌ 다국어 텍스트 배치 조회 실패:", error);
console.log(`🔄 에러 시 fallback 반환:`, fallback || langKey);
console.log("🔄 에러 시 fallback 반환:", fallback || langKey);
return fallback || langKey;
}
};
// 언어 변경
// 언어 변경 (무한 루프 방지)
const changeLang = async (newLang: string) => {
// 같은 언어로 변경하려는 경우 무시
if (newLang === userLang) {
console.log("🔄 같은 언어로 변경 시도 무시:", newLang);
return;
}
try {
console.log("🔄 언어 변경 시작:", { from: userLang, to: newLang });
// 백엔드에 사용자 로케일 설정 요청
const response = await apiClient.post("/admin/user-locale", {
locale: newLang,
});
if (response.data.success) {
setUserLang(newLang);
// 전역 상태 먼저 업데이트
globalUserLang = newLang;
// 로컬 상태 업데이트
setUserLang(newLang);
console.log("✅ 사용자 로케일 변경 성공:", newLang);
} else {
console.error("❌ 사용자 로케일 변경 실패:", response.data.message);
@ -132,8 +154,8 @@ export const useMultiLang = (options: { companyCode?: string } = {}) => {
} catch (error) {
console.error("❌ 사용자 로케일 변경 중 오류:", error);
// 오류 시에도 로컬 상태는 변경
setUserLang(newLang);
globalUserLang = newLang;
setUserLang(newLang);
}
};

View File

@ -59,10 +59,10 @@ apiClient.interceptors.request.use(
console.warn("⚠️ 토큰이 없습니다.");
}
// 언어 정보를 쿼리 파라미터에 추가
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
if (config.method?.toUpperCase() === "GET") {
// 전역 언어 상태에서 현재 언어 가져오기
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "ko" : "ko";
// 전역 언어 상태에서 현재 언어 가져오기 (DB 값 그대로 사용)
const currentLang = typeof window !== "undefined" ? (window as any).__GLOBAL_USER_LANG || "KR" : "KR";
console.log("🌐 API 요청 시 언어 정보:", currentLang);
if (config.params) {

View File

@ -145,11 +145,11 @@ export const menuApi = {
menuCode?: string;
keyType?: string;
}): Promise<ApiResponse<LangKey[]>> => {
console.log("🔍 다국어 키 목록 조회 API 호출:", "/admin/multilang/keys", params);
console.log("🔍 다국어 키 목록 조회 API 호출:", "/multilang/keys", params);
try {
// Node.js 백엔드의 실제 라우팅과 일치하도록 수정
const response = await apiClient.get("/admin/multilang/keys", { params });
const response = await apiClient.get("/multilang/keys", { params });
console.log("✅ 다국어 키 목록 조회 성공:", response.data);
return response.data;
} catch (error) {

View File

@ -3,8 +3,8 @@ import { apiClient } from "../api/client";
// 메뉴 관리 화면 다국어 키 상수
export const MENU_MANAGEMENT_KEYS = {
// 기본 정보
TITLE: "title",
DESCRIPTION: "description",
TITLE: "menu.management.title",
DESCRIPTION: "menu.management.description",
MENU_TYPE_TITLE: "menu.type.title",
MENU_TYPE_ADMIN: "menu.type.admin",
MENU_TYPE_USER: "menu.type.user",