UX 해적단 다국어 지원: i18n과 DB 번역을 함께 사용하기

정혜인·2025년 10월 31일
0

기술적 고민과 도전

목록 보기
11/11

🌏 해외 사용자가 늘어나기 시작하면서,,,

서비스를 운영하다 보니 해외에서도 접속이 정말 많이 들어오더라고요. 특히 UX 관련 콘텐츠라서 영어권 사용자들도 관심을 가져주셨는데... 전부 한국어로만 되어 있으니 불편하셨을 것 같았습니다.

그래서 영어 지원을 추가해야겠다고 기획 단계에서 결정되었는데, 여기서 문제가 생겼습니다.

번역이 필요한 텍스트가 크게 두 종류였습니다.

  1. 정적 UI 텍스트: "로딩 중...", "다시 시도", "필터" 같은 고정된 텍스트
  2. 동적 콘텐츠: 아티클 제목, 내용, 키워드명 등 DB에 저장된 데이터

처음엔 "그냥 i18n 라이브러리 하나로 다 되겠지?" 싶었는데... 생각해보니 DB에서 가져오는 아티클 데이터는 자동 번역을 쓰지 않는 이상 DB에 영어 버전을 직접 저장해야겠더라고요.

왜냐면 자동 번역을 하면 어색한 부분이 분명 있기 때문에, 영어를 잘 하시는 디자이너분께 어드민 페이지를 통해 번역을 부탁한 뒤 수정받기로 했기에 DB의 데이터 사용이 필수적이었습니다.

🤔 고민: 두 가지 번역 방식을 어떻게 통합할까?

방법 1: 모든 텍스트를 i18n으로?

// ❌ 이렇게 하면 당연히 비효율적
t('article.title_001')
t('article.title_002')
t('article.title_003')
// ... 아티클이 100개면 100개를 다 추가해야 함

아티클이 추가될 때마다 번역 파일도 수정해야 하니까 유지보수가 너무 복잡해집니다.

그래서 데이터베이스에 영어 버전도 모두 추가하기로 했습니다. 혹시 모를 다른 외국어도 지원할 수 있기 때문에, 그거까지 고려해서 DB 구조를 개선했습니다.

결론: 두 가지를 분리해서

// ✅ 정적 UI 텍스트 → i18n
t('common.loading')  // "로딩 중..." / "Loading..."

// ✅ 동적 콘텐츠 → DB에서 가져오기
getLocalizedText(article.title) // DB의 titleKo / titleEn

아티클 내용은 DB에 따로 번역본을 저장해두고, 그걸 가져오기로 했고, 정적인 텍스트는 일반적인 방법대로 i18n을 사용하기로 결정했습니다.

🏗️ 아키텍처 설계

1단계: i18n 기본 설정

먼저 react-i18next로 정적 텍스트를 관리합니다.

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";

import koTranslations from "./locales/ko.json";
import enTranslations from "./locales/en.json";

i18n
  .use(LanguageDetector) // 브라우저 언어 자동 감지
  .use(initReactI18next)
  .init({
    resources: {
      ko: { translation: koTranslations },
      en: { translation: enTranslations },
    },
    fallbackLng: "ko", // 기본 언어

    detection: {
      // 언어 감지 우선순위
      order: ["localStorage", "navigator", "htmlTag"],
      caches: ["localStorage"], // localStorage에 저장
    },
  });

export default i18n;

번역 파일 예시

// ../ko.json
{
  "common": {
    "loading": "로딩 중...",
    "retry": "다시 시도",
    "search": "검색"
  },
  "filter": {
    "product": "프로덕트",
    "keyword": "키워드",
    "reset": "초기화"
  },
  "results": {
    "total": "총 {{count}}개의 결과"
  }
}
// ../en.json
{
  "common": {
    "loading": "Loading...",
    "retry": "Retry",
    "search": "Search"
  },
  "filter": {
    "product": "Product",
    "keyword": "Keyword",
    "reset": "Reset"
  },
  "results": {
    "total": "{{count}} results"
  }
}

i18n 사용은 이미 많이 해봤고, 세팅만 해주면 되는 거라 간단히 넘어가도록 하겠습니다.

2단계: 다국어 타입 시스템 구축

그리고 DB에 저장된 콘텐츠를 처리하기 위한 타입 시스템을 만들었습니다.

// ../config/languages.ts
export type LanguageCode = "ko" | "en";

export interface LanguageInfo {
  code: LanguageCode;
  name: string;
  nativeName: string;
  flag: string;
}

export const SUPPORTED_LANGUAGES: Record<LanguageCode, LanguageInfo> = {
  ko: {
    code: "ko",
    name: "Korean",
    nativeName: "한국어",
  },
  en: {
    code: "en",
    name: "English",
    nativeName: "English",
  },
};

export const DEFAULT_LANGUAGE: LanguageCode = "ko";
// frontend/src/shared/types/multilingual.ts
import { LanguageCode } from "../config/languages";

/**
 * 다국어 문자열 타입
 * { ko: "안녕하세요", en: "Hello" } 형태
 */
export type MultilingualString = Partial<Record<LanguageCode, string>>;

/**
 * 다국어 문자열에서 현재 언어의 값을 가져오는 헬퍼 함수
 */
export function getLocalizedString(
  multilingual: MultilingualString,
  currentLanguage: LanguageCode,
  fallbackLanguage: LanguageCode = "ko",
): string {
  // 현재 언어의 값이 있으면 반환
  if (multilingual[currentLanguage]) {
    return multilingual[currentLanguage]!;
  }

  // 대체 언어의 값이 있으면 반환
  if (multilingual[fallbackLanguage]) {
    return multilingual[fallbackLanguage]!;
  }

  // 아무것도 없으면 빈 문자열 반환
  return "";
}

이렇게 하면 영어가 없어도 한국어로 대체되니까 안전하게 사용이 가능합니다.

3단계: Context로 언어 상태 관리

그리고 전역에서 언어 설정을 공유하기 위해 Context를 만들어 공유하도록 하였습니다.

// ../useLanguageContext.tsx
import { createContext, useContext, useState, useEffect } from "react";
import { LanguageCode, DEFAULT_LANGUAGE } from "../config/languages";
import { MultilingualString, getLocalizedString } from "../types/multilingual";
import i18n from "../config/i18n";

interface LanguageContextType {
  currentLanguage: LanguageCode;
  setLanguage: (language: LanguageCode) => void;
  getLocalizedText: (multilingual: MultilingualString) => string;
}

const LanguageContext = createContext<LanguageContextType | undefined>(undefined);

export function LanguageProvider({ children }: { children: ReactNode }) {
  const [currentLanguage, setCurrentLanguage] = useState<LanguageCode>(DEFAULT_LANGUAGE);

  // 로컬 스토리지에서 언어 설정 불러오기
  useEffect(() => {
    const savedLanguage = localStorage.getItem('lang') as LanguageCode;
    if (savedLanguage && SUPPORTED_LANGUAGES[savedLanguage]) {
      setCurrentLanguage(savedLanguage);
    }
  }, []);

  // 언어 변경 시 로컬 스토리지에 저장하고 i18n도 변경
  const setLanguage = (language: LanguageCode) => {
    setCurrentLanguage(language);
    localStorage.setItem('lang', language);
    i18n.changeLanguage(language); // i18n도 함께 변경!
  };

  // 다국어 문자열에서 현재 언어의 텍스트 가져오기
  const getLocalizedText = (multilingual: MultilingualString): string => {
    return getLocalizedString(multilingual, currentLanguage);
  };

  return (
    <LanguageContext.Provider value={{
      currentLanguage,
      setLanguage,
      getLocalizedText
    }}>
      {children}
    </LanguageContext.Provider>
  );
}

// 언어 컨텍스트 사용을 위한 훅
export function useLanguage(): LanguageContextType {
  const context = useContext(LanguageContext);
  if (!context) {
    throw new Error("useLanguage must be used within a LanguageProvider");
  }
  return context;
}

setLanguage 호출 시 i18n도 함께 변경이 되기 때문에 두 시스템이 항상 동기화되고,
getLocalizedText로 DB 콘텐츠를 현재 언어로 변환해줍니다.

그리고 뭐,, localStorage에 저장해주기 때문에 새로고침하거나 재접속하더라도 언어가 유지될 수 있게 해주었습니다.

4단계: 확장성을 고려한 헬퍼 함수들

여기에서 DB에서 가져온 데이터를 편하게 MultilingualString 형태로 변환하는 헬퍼 함수를 만들었습니다.

// ../multilingual-helpers.ts
import { MultilingualString } from "../types/multilingual";

/**
 * 기존 titleKo, titleEn 형태를 MultilingualString으로 변환
 */
export function convertToMultilingualString(
  koValue: string,
  enValue?: string,
): MultilingualString {
  const result: MultilingualString = {
    ko: koValue || "",
  };

  if (enValue) {
    result.en = enValue;
  }

  return result;
}

/**
 * 아티클 데이터를 다국어 지원 형태로 변환
 */
export function createMultilingualArticle(article: {
  titleKo: string;
  titleEn?: string;
  contentKo: string;
  contentEn?: string;
}) {
  return {
    title: convertToMultilingualString(article.titleKo, article.titleEn),
    content: convertToMultilingualString(article.contentKo, article.contentEn),
  };
}

/**
 * 제품 데이터를 다국어 지원 형태로 변환
 */
export function createMultilingualProduct(product: {
  nameKo: string;
  nameEn?: string;
}) {
  return {
    name: convertToMultilingualString(product.nameKo, product.nameEn),
  };
}

/**
 * 키워드 데이터를 다국어 지원 형태로 변환
 */
export function createMultilingualKeyword(keyword: {
  nameKo: string;
  nameEn?: string;
  descKo: string;
  descEn?: string;
}) {
  return {
    name: convertToMultilingualString(keyword.nameKo, keyword.nameEn),
    description: convertToMultilingualString(keyword.descKo, keyword.descEn),
  };
}

매번 아래와 같이 사용하면 너무 불편하기도 하고, en, ko 등 다른 언어가 추가되었을 때에 대한 관리가 어렵기 때문에 공통 함수로 분리시켜주었습니다.

const title = article.titleEn && currentLanguage === 'en'
  ? article.titleEn
  : article.titleKo;
const multilingualArticle = createMultilingualArticle(article);
const title = getLocalizedText(multilingualArticle.title);

5단계: 두 가지 훅으로 편하게 사용

결론적으로 위의 convertToMultilingualString와 아래의 useTranslation 훅을 사용하여 정적 텍스트와 동적 텍스트를 모두 관리할 수 있게 구축하였습니다.

// ../useTranslation.tsx
import { useTranslation as useI18nTranslation } from "react-i18next";

/**
 * i18n 번역을 위한 훅 (정적 UI 텍스트용)
 */
export function useTranslation() {
  const { t, i18n } = useI18nTranslation();

  return {
    t, // 번역 함수
    i18n,
    currentLanguage: i18n.language as "ko" | "en",
  };
}

실제 코드에서 사용할 때에는 아래와 같이 사용됩니다.

// 컴포넌트에서 사용
import { useLanguage } from "@/shared/hooks/useLanguageContext";
import { useTranslation } from "@/shared/hooks/useTranslation";
import { createMultilingualArticle } from "@/shared/utils/multilingual-helpers";

function ArticleCard({ article }) {
  // i18n 번역 (정적 UI 텍스트)
  const { t } = useTranslation();

  // DB 콘텐츠 번역
  const { getLocalizedText } = useLanguage();

  // 아티클 데이터를 다국어 형태로 변환
  const multilingualArticle = createMultilingualArticle({
    titleKo: article.titleKo,
    titleEn: article.titleEn,
    contentKo: article.contentKo,
    contentEn: article.contentEn,
  });

  return (
    <div className="article-card">
      {/* i18n 사용: 정적 UI 텍스트 */}
      <span className="label">{t('article.title')}</span>

      {/* DB 콘텐츠: 동적 데이터 */}
      <h2>{getLocalizedText(multilingualArticle.title)}</h2>
      <p>{getLocalizedText(multilingualArticle.content)}</p>

      {/* i18n 사용: 버튼 텍스트 */}
      <button>{t('common.more')}</button>
    </div>
  );
}
  • t() → UI 라벨, 버튼 텍스트 등 정적인 것
  • getLocalizedText() → 아티클 제목, 내용 등 DB 데이터

🎨 언어 전환 UI 구현

그리고 사이트의 헤더 부분에 사용자가 언어를 쉽게 바꿀 수 있게 언어 선택 버튼을 만들었습니다. (디자인 반영)

import { useLanguage } from "../../hooks/useLanguageContext";
import { SUPPORTED_LANGUAGES, LANGUAGE_ORDER } from "../../config/languages";

export default function LanguageSwitcher() {
  const { currentLanguage, setLanguage } = useLanguage();
  const [isOpen, setIsOpen] = useState(false);

  const handleLanguageChange = (languageCode: LanguageCode) => {
    setLanguage(languageCode); 
    setIsOpen(false);
  };

  const currentLanguageInfo = SUPPORTED_LANGUAGES[currentLanguage];

  return (
    <div>
      <button
        onClick={() => setIsOpen(!isOpen)}
      >
        <img src="/assets/icons/Globe.svg" alt="language" />
        <span>{currentLanguageInfo.code.toUpperCase()}</span>
        <img
          src="/assets/icons/CaretDown.svg"
          className={isOpen ? "open" : ""}
        />
      </button>

      {isOpen && (
        <div>
          {LANGUAGE_ORDER.map((languageCode) => {
            const languageInfo = SUPPORTED_LANGUAGES[languageCode];
            const isSelected = languageCode === currentLanguage;

            return (
              <button
                key={languageCode}
                className={`${isSelected ? "selected" : ""}`}
                onClick={() => handleLanguageChange(languageCode)}
                disabled={isSelected}
              >
                {languageInfo.code.toUpperCase()}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

결론적으로 언어를 바꾸게 되면,

  1. Context의 currentLanguage 상태 변경
  2. localStorage에 저장
  3. i18n 언어도 변경
  4. 모든 컴포넌트가 자동으로 리렌더링되면서 언어 반영

이 순서대로 적용이 됩니다.

안정성을 위한 작업들

결론적으로, 이번 리팩토링에서 다국어 지원을 위해 한 작업을 정리해보자면,

1. 역할 분리

(정적 텍스트와 동적 테스트에 대한 분리)

종류사용 방법예시
정적 UI 텍스트t('key')버튼, 라벨, 에러 메시지
동적 DB 콘텐츠getLocalizedText()아티클 제목, 키워드명

2. 헬퍼 함수로 확장성 확보

const multilingualArticle = createMultilingualArticle(article);
const title = getLocalizedText(multilingualArticle.title);

(나중에 일본어, 중국어를 추가해도 컴포넌트 코드는 수정할 필요 없음)

3. 타입 시스템으로 안전하게

export type LanguageCode = "ko" | "en";

// ✅ 타입 안전성
setLanguage("ko"); // OK
setLanguage("en"); // OK
setLanguage("jp"); // ❌ 컴파일 에러

(오타나 잘못된 언어 코드 사용시 에러 발생)

4. Context + localStorage로 상태 유지

useEffect(() => {
  const savedLanguage = localStorage.getItem('lang');
  if (savedLanguage) {
    setCurrentLanguage(savedLanguage);
  }
}, []);

(새로고침해도 유지)

5. i18n과 Context를 동기화

const setLanguage = (language: LanguageCode) => {
  setCurrentLanguage(language);     // Context 상태 변경
  localStorage.setItem('lang', language);  // 저장
  i18n.changeLanguage(language);     // i18n도 변경!
};

(t()로 가져오는 텍스트와 getLocalizedText()로 가져오는 텍스트 항상 같은 언어로 표시)


번역 기능을 추가하면서 두 가지 번역 시스템에 대한 역할을 제대로 분리하고,
이 과정에서 확장 가능하도록 설계하려고 노력하였습니다.

덕분에 해외 사용자들도 편하게 UX 해적단을 이용할 수 있는 첫 기반이 마련된 것 같습니다.


📌 참고


관련 글

0개의 댓글