Kord/Docs/Plans/i18n_Plan.md

16 KiB

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 자동완성, 빌드 타임 검증 가능.

// 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;
// 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() 함수

// 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. GuildConfiglocale 필드 추가

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 모델 신규 생성

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

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

INVALID_USER_LIMIT: {
  code: 'E1001',
  category: ErrorCategory.USER_INPUT,
  messageKey: 'errors.E1001',  // i18n 키로 대체
},

BotError 클래스를 수정하여 userMessage/resolution 대신 messageKey를 보유하고, ErrorReporter가 보고 시점에 locale을 결정하여 t() 함수로 메시지를 해석합니다.

5-2. ErrorReporter.ts 변경

// 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) 호출로 교체합니다.

// 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는 슬래시 명령어의 이름/설명에 대한 네이티브 다국어를 지원합니다:

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: userUserLocale 테이블에 개인 설정 저장
  • scope: serverGuildConfig.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/resolutionmessageKey
  • 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 대상에서 제외 (영어 고정)

    • 서버 로그는 개발자/운영자 전용이므로 영어 유지 → 맞습니까?