492 lines
16 KiB
Markdown
492 lines
16 KiB
Markdown
# Kord - 다국어 지원 기획 (i18n / Internationalization)
|
|
|
|
## 체인지로그 (Changelog)
|
|
- **2026-03-27**: 최초 기획서 작성
|
|
|
|
---
|
|
|
|
## 1. 개요
|
|
|
|
Kord 봇의 모든 **사용자 표시 텍스트**(명령어 응답, 에러 메시지, UI 라벨, 컨트롤 패널 등)를 다국어로 제공하는 시스템입니다.
|
|
|
|
### 현재 문제 (As-Is)
|
|
|
|
| 문제 | 위치 |
|
|
|------|------|
|
|
| 모든 사용자 메시지가 영어로 하드코딩 | `ErrorCodes.ts`, `interactionCreate.ts`, `VoiceService.ts`, `voiceSetup.ts` |
|
|
| Discord `interaction.locale` 미활용 | discord.js가 제공하는 사용자/서버 locale 정보를 무시 |
|
|
| 에러 메시지 키-값 구조 미비 | `ErrorCodes.ts`에 문자열 직접 포함 |
|
|
| 서버/사용자별 언어 설정 불가 | DB에 locale 필드 없음 |
|
|
|
|
### 목표 (To-Be)
|
|
|
|
- **Locale 키-값 구조** 기반 모든 사용자 문자열 관리
|
|
- **서버(Guild) → 사용자(User)** 우선순위 기반 언어 결정
|
|
- **discord.js `interaction.locale` / `guildLocale`** 활용한 자동 감지
|
|
- **에러 시스템(ErrorCodes)** 과 자연스러운 통합
|
|
- 영어(en) fallback + **한국어(ko)** 우선 지원
|
|
|
|
---
|
|
|
|
## 2. 언어 결정 우선순위 (Locale Resolution)
|
|
|
|
사용자에게 표시할 언어는 다음 우선순위로 결정합니다:
|
|
|
|
```
|
|
1. 사용자 개인 설정 (DB: UserLocale.locale) ← 최우선
|
|
2. 서버 설정 (DB: GuildConfig.locale) ← 2순위
|
|
3. Discord interaction.locale ← 3순위 (자동 감지)
|
|
4. 기본 언어: 'en' (English) ← fallback
|
|
```
|
|
|
|
> [!NOTE]
|
|
> Discord의 `interaction.locale`은 사용자의 Discord 클라이언트 언어를 자동으로 감지하므로,
|
|
> DB에 별도 설정이 없어도 적절한 언어가 자동 선택됩니다.
|
|
|
|
### 지원 언어 (초기)
|
|
|
|
| 코드 | 언어 |
|
|
|------|------|
|
|
| `en` | English (기본, fallback) |
|
|
| `ko` | 한국어 |
|
|
|
|
향후 `ja`, `zh-CN` 등 추가 가능. 번역 파일만 추가하면 자동 인식.
|
|
|
|
---
|
|
|
|
## 3. 번역 파일 구조 (Translation Files)
|
|
|
|
### 3-1. 디렉터리 구조
|
|
|
|
```
|
|
src/
|
|
├── i18n/
|
|
│ ├── index.ts ← t() 함수, resolveLocale() 등 핵심 모듈
|
|
│ ├── types.ts ← 타입 정의 (TranslationKeys, Locale 등)
|
|
│ └── locales/
|
|
│ ├── en.ts ← 영어 번역 (기본, 전체 키 포함)
|
|
│ └── ko.ts ← 한국어 번역
|
|
```
|
|
|
|
### 3-2. 번역 파일 형태 (TypeScript Object)
|
|
|
|
JSON/YAML 대신 **TypeScript 객체**를 사용합니다.
|
|
이유: 타입 안전성, IDE 자동완성, 빌드 타임 검증 가능.
|
|
|
|
```typescript
|
|
// src/i18n/locales/en.ts
|
|
export const en = {
|
|
// ── 에러 메시지 (Error Messages) ──
|
|
errors: {
|
|
E1001: {
|
|
userMessage: 'The user limit value is invalid.',
|
|
resolution: 'Please enter a number between 0 and 99. (0 = unlimited)',
|
|
},
|
|
E2001: {
|
|
userMessage: 'The bot does not have sufficient permissions to manage channels.',
|
|
resolution: 'Please ask a server administrator to grant the bot "Manage Channels" permission.',
|
|
},
|
|
// ... 모든 ErrorCodes의 userMessage/resolution
|
|
},
|
|
|
|
// ── 에러 카테고리 타이틀 ──
|
|
errorTitles: {
|
|
USER_INPUT: 'Please check your input',
|
|
PERMISSION: 'Insufficient permissions',
|
|
BOT_INTERNAL: 'Something went wrong',
|
|
DISCORD_API: 'Temporary issue',
|
|
},
|
|
|
|
// ── 에러 Embed 필드명 ──
|
|
errorFields: {
|
|
resolution: '💡 How to resolve',
|
|
},
|
|
|
|
// ── 음성 채널 (Voice Channel) ──
|
|
voice: {
|
|
channelReady: '{{owner}}, your temporary channel is ready! Use the dropdown menu below to manage it.',
|
|
defaultRoomName: "{{username}}'s Room",
|
|
controlPanel: {
|
|
placeholder: '⚙️ Manage Channel Settings',
|
|
rename: 'Rename Channel',
|
|
limit: 'Set User Limit',
|
|
lock: 'Lock / Unlock',
|
|
kick: 'Kick User',
|
|
ban: 'Ban / Hide User',
|
|
transfer: 'Transfer Ownership',
|
|
},
|
|
// 인터랙션 응답
|
|
responses: {
|
|
channelLocked: 'Channel Locked! Only you and invited members can join.',
|
|
channelUnlocked: 'Channel Unlocked! Anyone can join now.',
|
|
channelRenamed: 'Channel renamed to **{{name}}**!',
|
|
limitSet: 'Channel limit set to **{{limit}}**!',
|
|
limitUnlimited: 'Unlimited',
|
|
kicked: 'Kicked {{user}} from the channel.',
|
|
banned: 'Banned and hidden channel from {{user}}.',
|
|
transferPrompt: 'Select who will become the new owner of this channel.',
|
|
transferDone: 'Ownership successfully transferred to {{user}}.',
|
|
banPrompt: 'Banning will make the channel invisible to them.',
|
|
},
|
|
},
|
|
|
|
// ── 명령어 (Commands) ──
|
|
commands: {
|
|
voiceSetup: {
|
|
setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!',
|
|
createSuccess: 'Successfully created and set up {{channel}} as a Voice Generator Channel!',
|
|
},
|
|
},
|
|
|
|
// ── 모달 (Modals) ──
|
|
modals: {
|
|
renameTitle: 'Rename Voice Channel',
|
|
renameLabel: 'New Channel Name',
|
|
limitTitle: 'Set User Limit',
|
|
limitLabel: 'User Limit (0 for unlimited, 1-99)',
|
|
},
|
|
|
|
// ── 셀렉트 메뉴 Placeholder ──
|
|
selects: {
|
|
kickUser: 'Select a user to kick',
|
|
banUser: 'Select a user to ban/hide',
|
|
transferOwner: 'Select a user to transfer ownership to',
|
|
},
|
|
} as const;
|
|
```
|
|
|
|
```typescript
|
|
// src/i18n/locales/ko.ts
|
|
export const ko = {
|
|
errors: {
|
|
E1001: {
|
|
userMessage: '사용자 제한 값이 올바르지 않습니다.',
|
|
resolution: '0에서 99 사이의 숫자를 입력해주세요. (0 = 무제한)',
|
|
},
|
|
E2001: {
|
|
userMessage: '봇에 채널을 관리할 권한이 부족합니다.',
|
|
resolution: '서버 관리자에게 봇의 "채널 관리" 권한을 확인해달라고 요청하세요.',
|
|
},
|
|
// ...
|
|
},
|
|
errorTitles: {
|
|
USER_INPUT: '입력을 확인해주세요',
|
|
PERMISSION: '권한이 부족합니다',
|
|
BOT_INTERNAL: '문제가 발생했습니다',
|
|
DISCORD_API: '일시적인 문제입니다',
|
|
},
|
|
errorFields: {
|
|
resolution: '💡 해결 방법',
|
|
},
|
|
voice: {
|
|
channelReady: '{{owner}}, 임시 채널이 준비되었습니다! 아래 드롭다운 메뉴로 관리하세요.',
|
|
defaultRoomName: '{{username}}의 방',
|
|
controlPanel: {
|
|
placeholder: '⚙️ 채널 설정 관리',
|
|
rename: '채널 이름 변경',
|
|
limit: '인원 제한 설정',
|
|
lock: '채널 잠금 / 해제',
|
|
kick: '유저 추방',
|
|
ban: '유저 차단 / 숨기기',
|
|
transfer: '소유권 이전',
|
|
},
|
|
responses: {
|
|
channelLocked: '채널이 잠겼습니다! 초대된 멤버만 참여할 수 있습니다.',
|
|
channelUnlocked: '채널이 해제되었습니다! 누구나 참여할 수 있습니다.',
|
|
channelRenamed: '채널 이름이 **{{name}}**(으)로 변경되었습니다!',
|
|
limitSet: '인원 제한이 **{{limit}}**명으로 설정되었습니다!',
|
|
limitUnlimited: '무제한',
|
|
kicked: '{{user}}을(를) 채널에서 추방했습니다.',
|
|
banned: '{{user}}에게 채널을 숨기고 차단했습니다.',
|
|
transferPrompt: '채널의 새 소유자를 선택하세요.',
|
|
transferDone: '소유권이 {{user}}에게 이전되었습니다.',
|
|
banPrompt: '차단하면 해당 유저에게 채널이 보이지 않게 됩니다.',
|
|
},
|
|
},
|
|
commands: {
|
|
voiceSetup: {
|
|
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
|
|
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
|
|
},
|
|
},
|
|
modals: {
|
|
renameTitle: '음성 채널 이름 변경',
|
|
renameLabel: '새 채널 이름',
|
|
limitTitle: '인원 제한 설정',
|
|
limitLabel: '인원 제한 (0 = 무제한, 1-99)',
|
|
},
|
|
selects: {
|
|
kickUser: '추방할 유저를 선택하세요',
|
|
banUser: '차단할 유저를 선택하세요',
|
|
transferOwner: '소유권을 이전할 유저를 선택하세요',
|
|
},
|
|
} as const;
|
|
```
|
|
|
|
### 3-3. 핵심 모듈: `t()` 함수
|
|
|
|
```typescript
|
|
// src/i18n/index.ts — 개념 설계
|
|
|
|
import { en } from './locales/en';
|
|
import { ko } from './locales/ko';
|
|
|
|
const locales = { en, ko };
|
|
type SupportedLocale = keyof typeof locales;
|
|
const DEFAULT_LOCALE: SupportedLocale = 'en';
|
|
|
|
/**
|
|
* Resolve locale priority:
|
|
* 1. 사용자 DB 설정
|
|
* 2. 서버 DB 설정
|
|
* 3. Discord interaction.locale
|
|
* 4. fallback: 'en'
|
|
*/
|
|
function resolveLocale(options?: {
|
|
userLocale?: string | null;
|
|
guildLocale?: string | null;
|
|
discordLocale?: string;
|
|
}): SupportedLocale {
|
|
const candidates = [
|
|
options?.userLocale,
|
|
options?.guildLocale,
|
|
options?.discordLocale?.split('-')[0], // 'ko-KR' → 'ko'
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (candidate && candidate in locales) {
|
|
return candidate as SupportedLocale;
|
|
}
|
|
}
|
|
return DEFAULT_LOCALE;
|
|
}
|
|
|
|
/**
|
|
* 번역 키로 현지화된 문자열을 가져옵니다.
|
|
*
|
|
* @param locale - 대상 locale
|
|
* @param key - 점 표기법 키 (예: 'voice.responses.channelLocked')
|
|
* @param vars - 템플릿 변수 (예: { user: '<@123>' })
|
|
*/
|
|
function t(
|
|
locale: SupportedLocale,
|
|
key: string,
|
|
vars?: Record<string, string>
|
|
): string {
|
|
// locale 번역에서 키 검색, 없으면 en fallback
|
|
let value = getNestedValue(locales[locale], key)
|
|
?? getNestedValue(locales[DEFAULT_LOCALE], key)
|
|
?? key; // 키 자체를 반환 (최후 fallback)
|
|
|
|
// 템플릿 변수 치환: {{name}} → 실제 값
|
|
if (vars) {
|
|
for (const [k, v] of Object.entries(vars)) {
|
|
value = value.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. DB 스키마 확장
|
|
|
|
### 4-1. `GuildConfig`에 `locale` 필드 추가
|
|
|
|
```prisma
|
|
model GuildConfig {
|
|
guildId String @id
|
|
prefix String @default("!")
|
|
mimicEnabled Boolean @default(true)
|
|
locale String? // 서버 언어 설정 (e.g. 'ko', 'en')
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
```
|
|
|
|
### 4-2. `UserLocale` 모델 신규 생성
|
|
|
|
```prisma
|
|
model UserLocale {
|
|
userId String @id
|
|
locale String // 사용자 개인 언어 설정 (e.g. 'ko', 'en')
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
```
|
|
|
|
> [!NOTE]
|
|
> `UserLocale`을 별도 모델로 분리하는 이유: `UserVoiceProfile`은 음성 채널 전용이므로,
|
|
> 언어 설정은 봇 전체에 걸친 범용 설정으로 독립 관리합니다.
|
|
|
|
---
|
|
|
|
## 5. 기존 시스템 통합
|
|
|
|
### 5-1. `ErrorCodes.ts` → i18n 전환
|
|
|
|
**Before**
|
|
```typescript
|
|
INVALID_USER_LIMIT: new BotError({
|
|
code: 'E1001',
|
|
category: ErrorCategory.USER_INPUT,
|
|
userMessage: 'The user limit value is invalid.',
|
|
resolution: 'Please enter a number between 0 and 99.',
|
|
}),
|
|
```
|
|
|
|
**After**
|
|
```typescript
|
|
INVALID_USER_LIMIT: {
|
|
code: 'E1001',
|
|
category: ErrorCategory.USER_INPUT,
|
|
messageKey: 'errors.E1001', // i18n 키로 대체
|
|
},
|
|
```
|
|
|
|
`BotError` 클래스를 수정하여 `userMessage`/`resolution` 대신 `messageKey`를 보유하고,
|
|
`ErrorReporter`가 보고 시점에 locale을 결정하여 `t()` 함수로 메시지를 해석합니다.
|
|
|
|
### 5-2. `ErrorReporter.ts` 변경
|
|
|
|
```typescript
|
|
// buildEmbed 메서드 변경 (개념)
|
|
private static buildEmbed(error: BotError, locale: SupportedLocale): EmbedBuilder {
|
|
const msg = t(locale, `${error.messageKey}.userMessage`);
|
|
const res = t(locale, `${error.messageKey}.resolution`);
|
|
const title = t(locale, `errorTitles.${error.category}`);
|
|
const fieldName = t(locale, 'errorFields.resolution');
|
|
// ... Embed 빌드
|
|
}
|
|
```
|
|
|
|
### 5-3. `interactionCreate.ts` / `VoiceService.ts` 변경
|
|
|
|
하드코딩된 모든 사용자 문자열을 `t(locale, key, vars)` 호출로 교체합니다.
|
|
|
|
```typescript
|
|
// Before
|
|
await interaction.reply({ content: 'Channel Locked!', ephemeral: true });
|
|
|
|
// After
|
|
await interaction.reply({
|
|
content: t(locale, 'voice.responses.channelLocked'),
|
|
ephemeral: true,
|
|
});
|
|
```
|
|
|
|
### 5-4. 슬래시 명령어 설명 현지화 (discord.js `setDescriptionLocalizations`)
|
|
|
|
discord.js는 슬래시 명령어의 이름/설명에 대한 네이티브 다국어를 지원합니다:
|
|
|
|
```typescript
|
|
new SlashCommandBuilder()
|
|
.setName('voice-setup')
|
|
.setDescription('Setup a generator voice channel for temporary channels.')
|
|
.setDescriptionLocalizations({
|
|
ko: '임시 음성 채널을 위한 생성기 채널을 설정합니다.',
|
|
})
|
|
```
|
|
|
|
> [!IMPORTANT]
|
|
> 명령어 이름(`setName`)은 Discord 정책상 영어만 허용됩니다.
|
|
> 설명(`setDescription`)과 옵션 설명만 현지화 가능합니다.
|
|
|
|
---
|
|
|
|
## 6. 언어 변경 명령어
|
|
|
|
### `/language` 슬래시 명령어
|
|
|
|
```
|
|
/language [scope] [locale]
|
|
scope: 'user' | 'server'
|
|
locale: 'en' | 'ko'
|
|
```
|
|
|
|
- `scope: user` → `UserLocale` 테이블에 개인 설정 저장
|
|
- `scope: server` → `GuildConfig.locale` 업데이트 (관리자 전용)
|
|
|
|
---
|
|
|
|
## 7. 구현 Phase
|
|
|
|
### Phase 1 — i18n 인프라 구축
|
|
- `src/i18n/` 디렉터리 생성 (index.ts, types.ts)
|
|
- `src/i18n/locales/en.ts`, `ko.ts` 번역 파일 작성
|
|
- `t()` 함수, `resolveLocale()` 함수 구현
|
|
- DB 스키마 변경: `GuildConfig.locale` 추가, `UserLocale` 모델 생성
|
|
- Prisma migrate 실행
|
|
|
|
### Phase 2 — 에러 시스템 i18n 통합
|
|
- `BotError`, `ErrorCodes` 리팩터링: `userMessage`/`resolution` → `messageKey`
|
|
- `ErrorReporter.report()` / `buildEmbed()` 에 locale 파라미터 추가
|
|
- `withErrorHandler`에 locale 전달 경로 추가
|
|
- 기존 테스트 업데이트
|
|
|
|
### Phase 3 — UI/명령어 문자열 전환
|
|
- `interactionCreate.ts` 모든 응답 메시지 `t()` 전환
|
|
- `VoiceService.ts` 컨트롤 패널 및 응답 메시지 `t()` 전환
|
|
- `voiceSetup.ts` 응답 메시지 `t()` 전환
|
|
- 슬래시 명령어 설명 `setDescriptionLocalizations()` 적용
|
|
|
|
### Phase 4 — 언어 변경 명령어
|
|
- `/language` 슬래시 명령어 구현
|
|
- `UserLocale` / `GuildConfig.locale` CRUD
|
|
|
|
### Phase 5 — 테스트
|
|
- `t()` 함수 유닛 테스트
|
|
- `resolveLocale()` 우선순위 유닛 테스트
|
|
- 에러 시스템 통합 테스트 업데이트
|
|
- 수동 테스트: Discord에서 `/language` 변경 후 응답 언어 확인
|
|
|
|
---
|
|
|
|
## 8. 검증 계획 (Verification Plan)
|
|
|
|
### 자동 테스트 (Automated Tests)
|
|
|
|
1. **i18n 핵심 모듈 유닛 테스트** — `tests/i18n/i18n.test.ts`
|
|
- `t()`: 정상 키 → 번역 값 반환
|
|
- `t()`: 미존재 키 → en fallback
|
|
- `t()`: 미존재 키(en에도 없음) → 키 자체 반환
|
|
- `t()`: 템플릿 변수 `{{var}}` 치환 검증
|
|
- `resolveLocale()`: 우선순위 순서 검증 (user > guild > discord > default)
|
|
- 실행: `yarn test -- --testPathPattern='tests/i18n'`
|
|
|
|
2. **에러 시스템 통합 테스트 업데이트** — `tests/errors/`
|
|
- `ErrorReporter.report()`에 locale 전달 시 해당 언어 Embed 생성 검증
|
|
- 기존 `BotError.test.ts`, `ErrorReporter.test.ts` 수정
|
|
- 실행: `yarn test -- --testPathPattern='tests/errors'`
|
|
|
|
### 수동 테스트 (Manual Tests)
|
|
|
|
1. 봇 개발 서버에서 `/language user ko` 실행
|
|
2. `/voice-setup create test` 실행 → 한국어 응답 확인
|
|
3. 음성 채널 참여 → 한국어 컨트롤 패널 확인
|
|
4. 에러 유발 (잘못된 제한 입력 등) → 한국어 에러 Embed 확인
|
|
5. `/language user en`으로 복원 → 영어 응답 복원 확인
|
|
|
|
---
|
|
|
|
## 9. 열린 질문 / 결정 필요 사항
|
|
|
|
> [!IMPORTANT]
|
|
> 구현 전 확정이 필요한 항목입니다.
|
|
|
|
1. **번역 파일 형식**: TypeScript 객체(위 제안) vs JSON 파일
|
|
- TS 객체: 타입 안전, IDE 지원 우수 / JSON: 비개발자 편집 용이
|
|
- **제안**: TypeScript 객체 (현재 프로젝트 규모에 최적)
|
|
|
|
2. **Discord `interaction.locale` 활용 범위**
|
|
- 3순위 fallback으로 사용 (사용자가 별도 설정하지 않은 경우 자동 감지)
|
|
- DB 설정이 있으면 Discord locale을 무시 → 맞습니까?
|
|
|
|
3. **`/language` 명령어 권한**
|
|
- `user` scope: 모든 사용자 사용 가능
|
|
- `server` scope: 관리자(Administrator) 전용
|
|
- 이 정책이 맞습니까?
|
|
|
|
4. **VoiceService 내부 로그 메시지**는 i18n 대상에서 **제외** (영어 고정)
|
|
- 서버 로그는 개발자/운영자 전용이므로 영어 유지 → 맞습니까?
|