[DiscordJS 봇 개발 튜토리얼] 12. 다국어 지원 시스템 만들기 (i18n): 전 세계와 소통하자!

2025. 6. 13. 17:15·DiscordJS 개발 튜토리얼

해당 글은, 제가 작성한 Discord.js 보일러플레이트를 기반으로 합니다. 해당 보일러픝레이트는 다음에라도 봇을 빠르게 만들고 싶으실 때 사용하실 수 있습니다. Discord.js TypeScript Boilerplate

안녕하세요! 지난 시간에는 반응 기반 기능들을 만들어서 사용자와의 상호작용을 더욱 직관적으로 만들어봤습니다. 이제 우리 봇이 점점 완성도가 높아지고 있죠?

오늘은 봇을 국제적으로 사용할 수 있도록 다국어 지원 시스템을 구축해보겠습니다. 한국어로만 동작하는 봇도 좋지만, 영어, 일본어, 중국어 등 다양한 언어를 지원한다면 훨씬 더 많은 사용자들이 편리하게 사용할 수 있겠죠?

실제로 Discord 자체도 Discord의 언어 설정에 따라 슬래시 명령어의 이름과 설명이 자동으로 번역되어 표시됩니다. 우리도 이런 기능을 활용해서 진정한 글로벌 봇을 만들어보겠습니다.

i18next 라이브러리 설치하기

다국어 지원을 위해 널리 사용되는 i18next 라이브러리를 사용하겠습니다. 이 라이브러리는 번역 파일 관리부터 언어 변환까지 모든 것을 편리하게 처리해줍니다.

npm install i18next i18next-fs-backend
npm install @types/i18next --save-dev

번역 파일 구조 설정하기

먼저 번역 파일들을 저장할 폴더 구조를 만들어보겠습니다.

locales 폴더 구조

src/
  locales/
    ko/
      common.json
      commands.json
      errors.json
    en/
      common.json
      commands.json
      errors.json
    ja/
      common.json
      commands.json
      errors.json

src/locales/ko/common.json

{
  "welcome": "환영합니다!",
  "goodbye": "안녕히 가세요!",
  "success": "성공적으로 완료되었습니다!",
  "error": "오류가 발생했습니다.",
  "loading": "처리 중입니다...",
  "yes": "예",
  "no": "아니오",
  "cancel": "취소",
  "confirm": "확인",
  "save": "저장",
  "delete": "삭제",
  "edit": "편집",
  "back": "뒤로",
  "next": "다음",
  "previous": "이전",
  "page": "페이지",
  "total": "총",
  "none": "없음",
  "unknown": "알 수 없음",
  "admin": "관리자",
  "moderator": "모더레이터",
  "member": "멤버",
  "bot": "봇"
}

src/locales/en/common.json

{
  "welcome": "Welcome!",
  "goodbye": "Goodbye!",
  "success": "Successfully completed!",
  "error": "An error occurred.",
  "loading": "Processing...",
  "yes": "Yes",
  "no": "No",
  "cancel": "Cancel",
  "confirm": "Confirm",
  "save": "Save",
  "delete": "Delete",
  "edit": "Edit",
  "back": "Back",
  "next": "Next",
  "previous": "Previous",
  "page": "Page",
  "total": "Total",
  "none": "None",
  "unknown": "Unknown",
  "admin": "Admin",
  "moderator": "Moderator",
  "member": "Member",
  "bot": "Bot"
}

src/locales/ko/commands.json

{
  "ping": {
    "name": "핑",
    "description": "봇의 응답 시간을 확인합니다",
    "response": "🏓 퐁! 지연시간: {{latency}}ms"
  },
  "help": {
    "name": "도움말",
    "description": "사용 가능한 명령어 목록을 표시합니다",
    "title": "📚 도움말",
    "noCommands": "사용 가능한 명령어가 없습니다."
  },
  "userinfo": {
    "name": "유저정보",
    "description": "사용자 정보를 표시합니다",
    "options": {
      "user": "정보를 확인할 사용자"
    },
    "embed": {
      "title": "👤 사용자 정보",
      "username": "사용자명",
      "id": "ID",
      "createdAt": "계정 생성일",
      "joinedAt": "서버 가입일",
      "roles": "역할",
      "isBot": "봇 여부"
    }
  },
  "language": {
    "name": "언어",
    "description": "봇의 언어를 변경합니다",
    "options": {
      "lang": "사용할 언어"
    },
    "changed": "언어가 {{language}}로 변경되었습니다!",
    "invalid": "올바르지 않은 언어입니다. 사용 가능한 언어: {{languages}}"
  }
}

src/locales/en/commands.json

{
  "ping": {
    "name": "ping",
    "description": "Check the bot's response time",
    "response": "🏓 Pong! Latency: {{latency}}ms"
  },
  "help": {
    "name": "help",
    "description": "Show available commands",
    "title": "📚 Help",
    "noCommands": "No commands available."
  },
  "userinfo": {
    "name": "userinfo",
    "description": "Show user information",
    "options": {
      "user": "User to get information about"
    },
    "embed": {
      "title": "👤 User Information",
      "username": "Username",
      "id": "ID",
      "createdAt": "Account Created",
      "joinedAt": "Joined Server",
      "roles": "Roles",
      "isBot": "Is Bot"
    }
  },
  "language": {
    "name": "language",
    "description": "Change the bot's language",
    "options": {
      "lang": "Language to use"
    },
    "changed": "Language changed to {{language}}!",
    "invalid": "Invalid language. Available languages: {{languages}}"
  }
}

src/locales/ko/errors.json

{
  "generic": "예기치 않은 오류가 발생했습니다.",
  "permission": "이 명령어를 사용할 권한이 없습니다.",
  "cooldown": "이 명령어는 {{time}}초 후에 다시 사용할 수 있습니다.",
  "userNotFound": "사용자를 찾을 수 없습니다.",
  "roleNotFound": "역할을 찾을 수 없습니다.",
  "channelNotFound": "채널을 찾을 수 없습니다.",
  "invalidArgument": "올바르지 않은 인수입니다.",
  "missingArgument": "필수 인수가 누락되었습니다.",
  "databaseError": "데이터베이스 오류가 발생했습니다.",
  "networkError": "네트워크 오류가 발생했습니다."
}

src/locales/en/errors.json

{
  "generic": "An unexpected error occurred.",
  "permission": "You don't have permission to use this command.",
  "cooldown": "You can use this command again in {{time}} seconds.",
  "userNotFound": "User not found.",
  "roleNotFound": "Role not found.",
  "channelNotFound": "Channel not found.",
  "invalidArgument": "Invalid argument.",
  "missingArgument": "Missing required argument.",
  "databaseError": "Database error occurred.",
  "networkError": "Network error occurred."
}

i18n 시스템 초기화하기

src/utils/i18n.ts

import i18next from "i18next";
import Backend from "i18next-fs-backend";
import path from "path";

export const supportedLanguages = {
  ko: "한국어",
  en: "English",
  ja: "日本語",
} as const;

export type SupportedLanguage = keyof typeof supportedLanguages;

export async function initializeI18n(): Promise<void> {
  await i18next.use(Backend).init({
    lng: "ko", // 기본 언어
    fallbackLng: "en", // 번역이 없을 때 사용할 언어
    debug: process.env.NODE_ENV === "development",

    backend: {
      loadPath: path.join(__dirname, "../locales/{{lng}}/{{ns}}.json"),
    },

    ns: ["common", "commands", "errors"], // 네임스페이스
    defaultNS: "common",

    interpolation: {
      escapeValue: false, // React가 아니므로 XSS 걱정 없음
    },

    returnObjects: true, // 객체 반환 허용
  });
}

export function createI18nFunction(language: SupportedLanguage = "ko") {
  return (key: string, options?: any) => {
    return i18next.t(key, { lng: language, ...options });
  };
}

export function isValidLanguage(lang: string): lang is SupportedLanguage {
  return Object.keys(supportedLanguages).includes(lang);
}

export function getLanguageDisplay(lang: SupportedLanguage): string {
  return supportedLanguages[lang];
}

사용자별 언어 설정 저장하기

사용자가 선택한 언어를 데이터베이스에 저장해야 합니다.

prisma/schema.prisma에 추가

model UserSettings {
  id       String @id @default(cuid())
  userId   String @unique
  guildId  String
  language String @default("ko")
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("user_settings")
}

마이그레이션 실행:

npx prisma migrate dev --name add-user-settings

src/utils/userSettings.ts

import { PrismaClient } from "@prisma/client";
import { SupportedLanguage } from "./i18n";

const prisma = new PrismaClient();

export async function getUserLanguage(
  userId: string,
  guildId: string
): Promise<SupportedLanguage> {
  try {
    const settings = await prisma.userSettings.findUnique({
      where: { userId },
    });

    return (settings?.language as SupportedLanguage) || "ko";
  } catch (error) {
    console.error("언어 설정 조회 오류:", error);
    return "ko";
  }
}

export async function setUserLanguage(
  userId: string,
  guildId: string,
  language: SupportedLanguage
): Promise<void> {
  try {
    await prisma.userSettings.upsert({
      where: { userId },
      update: { language },
      create: {
        userId,
        guildId,
        language,
      },
    });
  } catch (error) {
    console.error("언어 설정 저장 오류:", error);
    throw error;
  }
}

다국어 지원 명령어 만들기

src/commands/language.ts

import { SlashCommandBuilder, EmbedBuilder, LocalizationMap } from "discord.js";
import { Command } from "../types";
import {
  createI18nFunction,
  supportedLanguages,
  isValidLanguage,
  SupportedLanguage,
  getLanguageDisplay,
} from "../utils/i18n";
import { getUserLanguage, setUserLanguage } from "../utils/userSettings";

export const language: Command = {
  data: new SlashCommandBuilder()
    .setName("language")
    .setDescription("Change the bot's language")
    .setNameLocalizations({
      ko: "언어",
      ja: "言語",
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: "봇의 언어를 변경합니다",
      ja: "ボットの言語を変更します",
    } as LocalizationMap)
    .addStringOption((option) =>
      option
        .setName("lang")
        .setDescription("Language to use")
        .setNameLocalizations({
          ko: "언어",
          ja: "言語",
        } as LocalizationMap)
        .setDescriptionLocalizations({
          ko: "사용할 언어",
          ja: "使用する言語",
        } as LocalizationMap)
        .setRequired(true)
        .addChoices(
          { name: "한국어", value: "ko" },
          { name: "English", value: "en" },
          { name: "日本語", value: "ja" }
        )
    ),

  async execute(interaction) {
    const selectedLang = interaction.options.getString("lang", true);
    const currentLang = await getUserLanguage(
      interaction.user.id,
      interaction.guildId!
    );
    const t = createI18nFunction(currentLang);

    if (!isValidLanguage(selectedLang)) {
      const availableLanguages = Object.entries(supportedLanguages)
        .map(([code, name]) => `${name} (${code})`)
        .join(", ");

      return interaction.reply({
        content: t("commands:language.invalid", {
          languages: availableLanguages,
        }),
        ephemeral: true,
      });
    }

    try {
      await setUserLanguage(
        interaction.user.id,
        interaction.guildId!,
        selectedLang
      );

      // 새로운 언어로 t 함수 생성
      const newT = createI18nFunction(selectedLang);

      const embed = new EmbedBuilder()
        .setTitle("🌍 " + newT("commands:language.name"))
        .setDescription(
          newT("commands:language.changed", {
            language: getLanguageDisplay(selectedLang),
          })
        )
        .setColor(0x00ae86)
        .setTimestamp();

      await interaction.reply({
        embeds: [embed],
        ephemeral: true,
      });
    } catch (error) {
      console.error("언어 변경 오류:", error);
      await interaction.reply({
        content: t("errors:generic"),
        ephemeral: true,
      });
    }
  },
};

기존 명령어에 다국어 지원 추가하기

src/commands/ping.ts (수정)

import { SlashCommandBuilder, LocalizationMap } from "discord.js";
import { Command } from "../types";
import { createI18nFunction } from "../utils/i18n";
import { getUserLanguage } from "../utils/userSettings";

export const ping: Command = {
  data: new SlashCommandBuilder()
    .setName("ping")
    .setDescription("Check the bot's response time")
    .setNameLocalizations({
      ko: "핑",
      ja: "ピン",
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: "봇의 응답 시간을 확인합니다",
      ja: "ボットの応答時間を確認します",
    } as LocalizationMap),

  async execute(interaction) {
    const userLang = await getUserLanguage(
      interaction.user.id,
      interaction.guildId!
    );
    const t = createI18nFunction(userLang);

    const sent = await interaction.reply({
      content: t("common:loading"),
      fetchReply: true,
    });

    const latency = sent.createdTimestamp - interaction.createdTimestamp;

    await interaction.editReply({
      content: t("commands:ping.response", { latency }),
    });
  },
};

src/commands/userinfo.ts (수정)

import { SlashCommandBuilder, EmbedBuilder, LocalizationMap } from "discord.js";
import { Command } from "../types";
import { createI18nFunction } from "../utils/i18n";
import { getUserLanguage } from "../utils/userSettings";

export const userinfo: Command = {
  data: new SlashCommandBuilder()
    .setName("userinfo")
    .setDescription("Show user information")
    .setNameLocalizations({
      ko: "유저정보",
      ja: "ユーザー情報",
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: "사용자 정보를 표시합니다",
      ja: "ユーザー情報を表示します",
    } as LocalizationMap)
    .addUserOption((option) =>
      option
        .setName("user")
        .setDescription("User to get information about")
        .setNameLocalizations({
          ko: "사용자",
          ja: "ユーザー",
        } as LocalizationMap)
        .setDescriptionLocalizations({
          ko: "정보를 확인할 사용자",
          ja: "情報を確認するユーザー",
        } as LocalizationMap)
        .setRequired(false)
    ),

  async execute(interaction) {
    const userLang = await getUserLanguage(
      interaction.user.id,
      interaction.guildId!
    );
    const t = createI18nFunction(userLang);

    const targetUser = interaction.options.getUser("user") || interaction.user;
    const member = await interaction.guild?.members.fetch(targetUser.id);

    if (!member) {
      return interaction.reply({
        content: t("errors:userNotFound"),
        ephemeral: true,
      });
    }

    const roles =
      member.roles.cache
        .filter((role) => role.name !== "@everyone")
        .map((role) => role.toString())
        .join(", ") || t("common:none");

    const embed = new EmbedBuilder()
      .setTitle(t("commands:userinfo.embed.title"))
      .setThumbnail(targetUser.displayAvatarURL())
      .addFields([
        {
          name: t("commands:userinfo.embed.username"),
          value: targetUser.tag,
          inline: true,
        },
        {
          name: t("commands:userinfo.embed.id"),
          value: targetUser.id,
          inline: true,
        },
        {
          name: t("commands:userinfo.embed.isBot"),
          value: targetUser.bot ? t("common:yes") : t("common:no"),
          inline: true,
        },
        {
          name: t("commands:userinfo.embed.createdAt"),
          value: `<t:${Math.floor(targetUser.createdTimestamp / 1000)}:F>`,
          inline: false,
        },
        {
          name: t("commands:userinfo.embed.joinedAt"),
          value: member.joinedAt
            ? `<t:${Math.floor(member.joinedAt.getTime() / 1000)}:F>`
            : t("common:unknown"),
          inline: false,
        },
        {
          name: t("commands:userinfo.embed.roles"),
          value: roles,
          inline: false,
        },
      ])
      .setColor(member.displayColor || 0x00ae86)
      .setTimestamp();

    await interaction.reply({ embeds: [embed] });
  },
};

중앙집중식 번역 미들웨어 만들기

모든 명령어에서 매번 언어를 가져오는 것은 비효율적입니다. 미들웨어를 만들어서 자동으로 처리하도록 하겠습니다.

src/utils/interactionHelper.ts

import {
  CommandInteraction,
  ButtonInteraction,
  SelectMenuInteraction,
} from "discord.js";
import { createI18nFunction, SupportedLanguage } from "./i18n";
import { getUserLanguage } from "./userSettings";

export interface LocalizedInteraction extends CommandInteraction {
  t: (key: string, options?: any) => string;
  userLanguage: SupportedLanguage;
}

export async function addLocalization<T extends CommandInteraction>(
  interaction: T
): Promise<
  T & {
    t: (key: string, options?: any) => string;
    userLanguage: SupportedLanguage;
  }
> {
  const userLanguage = await getUserLanguage(
    interaction.user.id,
    interaction.guildId!
  );
  const t = createI18nFunction(userLanguage);

  return Object.assign(interaction, { t, userLanguage });
}

이제 명령어에서 더 간단하게 사용할 수 있습니다:

개선된 ping 명령어

import { SlashCommandBuilder, LocalizationMap } from "discord.js";
import { Command } from "../types";
import { addLocalization } from "../utils/interactionHelper";

export const ping: Command = {
  data: new SlashCommandBuilder()
    .setName("ping")
    .setDescription("Check the bot's response time")
    .setNameLocalizations({
      ko: "핑",
      ja: "ピン",
    } as LocalizationMap)
    .setDescriptionLocalizations({
      ko: "봇의 응답 시간을 확인합니다",
      ja: "ボットの応답時間を確認します",
    } as LocalizationMap),

  async execute(interaction) {
    const localizedInteraction = await addLocalization(interaction);
    const { t } = localizedInteraction;

    const sent = await interaction.reply({
      content: t("common:loading"),
      fetchReply: true,
    });

    const latency = sent.createdTimestamp - interaction.createdTimestamp;

    await interaction.editReply({
      content: t("commands:ping.response", { latency }),
    });
  },
};

일본어 번역 파일 추가하기

src/locales/ja/common.json

{
  "welcome": "ようこそ!",
  "goodbye": "さようなら!",
  "success": "正常に完了しました!",
  "error": "エラーが発生しました。",
  "loading": "処理中です...",
  "yes": "はい",
  "no": "いいえ",
  "cancel": "キャンセル",
  "confirm": "確認",
  "save": "保存",
  "delete": "削除",
  "edit": "編集",
  "back": "戻る",
  "next": "次へ",
  "previous": "前へ",
  "page": "ページ",
  "total": "合計",
  "none": "なし",
  "unknown": "不明",
  "admin": "管理者",
  "moderator": "モデレーター",
  "member": "メンバー",
  "bot": "ボット"
}

src/locales/ja/commands.json

{
  "ping": {
    "name": "ピン",
    "description": "ボットの応答時間を確認します",
    "response": "🏓 ポン!レイテンシ: {{latency}}ms"
  },
  "userinfo": {
    "name": "ユーザー情報",
    "description": "ユーザー情報を表示します",
    "options": {
      "user": "情報を確認するユーザー"
    },
    "embed": {
      "title": "👤 ユーザー情報",
      "username": "ユーザー名",
      "id": "ID",
      "createdAt": "アカウント作成日",
      "joinedAt": "サーバー参加日",
      "roles": "ロール",
      "isBot": "ボットかどうか"
    }
  },
  "language": {
    "name": "言語",
    "description": "ボットの言語を変更します",
    "options": {
      "lang": "使用する言語"
    },
    "changed": "言語が{{language}}に変更されました!",
    "invalid": "無効な言語です。使用可能な言語: {{languages}}"
  }
}

봇 시작 시 i18n 초기화하기

src/index.ts (수정)

import { Client, GatewayIntentBits } from "discord.js";
import { initializeI18n } from "./utils/i18n";
// ...기존 import들...

async function startBot() {
  try {
    // i18n 초기화
    await initializeI18n();
    console.log("🌍 다국어 시스템이 초기화되었습니다.");

    // 봇 클라이언트 생성
    const client = new Client({
      intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.GuildMessageReactions,
      ],
    });

    // ...기존 코드...

    await client.login(process.env.DISCORD_TOKEN);
  } catch (error) {
    console.error("봇 시작 오류:", error);
    process.exit(1);
  }
}

startBot();

마무리하며

오늘은 i18next를 활용해서 완전한 다국어 지원 시스템을 구축해봤습니다. 이제 우리 봇은 한국어, 영어, 일본어를 지원하며, 필요에 따라 더 많은 언어를 쉽게 추가할 수 있습니다.

다국어 지원의 핵심은 다음과 같습니다:

  1. 번역 파일 체계적 관리 - 네임스페이스별로 분리하여 관리의 편의성 증대
  2. 사용자별 언어 설정 - 개인의 선호에 따라 언어 선택 가능
  3. Discord 로컬라이제이션 - Discord 자체 언어 설정과 연동
  4. 중앙집중식 관리 - 미들웨어를 통한 효율적인 번역 처리

다음 시간에는 봇의 성능을 최적화하고 캐시를 효율적으로 관리하는 방법에 대해 알아보겠습니다. 사용자가 많아질수록 중요해지는 부분이니까 기대해주세요!

글로벌 봇을 만드는 것은 정말 뿌듯한 일입니다. 여러분의 봇이 전 세계 사용자들에게 사랑받기를 바랍니다!

'DiscordJS 개발 튜토리얼' 카테고리의 다른 글

[DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작  (0) 2025.06.12
[DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기  (6) 2025.06.11
[DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자!  (2) 2025.06.11
[DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기  (2) 2025.06.10
[DiscordJS 봇 개발 튜토리얼] 7. 역할과 권한 체크 구현하기: 봇에게 질서를 부여하자!  (1) 2025.06.09
'DiscordJS 개발 튜토리얼' 카테고리의 다른 글
  • [DiscordJS 봇 개발 튜토리얼] 11. 사용자 반응(이모지) 기반 기능 만들기: 손쉬운 상호작용의 시작
  • [DiscordJS 봇 개발 튜토리얼] 10. 대화형 UI: 셀렉트 메뉴와 모달 활용하기
  • [DiscordJS 봇 개발 튜토리얼] 9. 봇 배포 및 호스팅하기: 내 봇을 세상에 내보내자!
  • [DiscordJS 봇 개발 튜토리얼] 8. Prisma로 SQLite, MySQL 연동하기
디스호스트
디스호스트
쉽고 안정적인 디스코드 봇 호스팅 서비스, 디스호스트의 기술 블로그입니다. 디스호스트는 24시간 구동되는 서버를 통해 디스코드 봇을 대신 구동시켜 드리는 서비스를 제공하고 있습니다.
  • 디스호스트
    디스호스트 기술 블로그
    디스호스트
  • 블로그 메뉴

    • 홈
    • 디스호스트 사용 가이드
    • 디스코드 봇 호스팅, 24시간 서버 구동
    • 분류 전체보기 (40) N
      • 디스코드 (6)
      • 디스호스트 가이드 (11)
      • 봇 개발 팁 (10)
        • Discord.js (8)
        • Discord.py (1)
      • DiscordJS 개발 튜토리얼 (13) N
  • 링크

    • 디스호스트
    • 디스호스트 패널
  • hELLO· Designed By정상우.v4.10.3
디스호스트
[DiscordJS 봇 개발 튜토리얼] 12. 다국어 지원 시스템 만들기 (i18n): 전 세계와 소통하자!
상단으로

티스토리툴바