
서비스를 운영하다 보니 해외에서도 접속이 정말 많이 들어오더라고요. 특히 UX 관련 콘텐츠라서 영어권 사용자들도 관심을 가져주셨는데... 전부 한국어로만 되어 있으니 불편하셨을 것 같았습니다.
그래서 영어 지원을 추가해야겠다고 기획 단계에서 결정되었는데, 여기서 문제가 생겼습니다.
번역이 필요한 텍스트가 크게 두 종류였습니다.
처음엔 "그냥 i18n 라이브러리 하나로 다 되겠지?" 싶었는데... 생각해보니 DB에서 가져오는 아티클 데이터는 자동 번역을 쓰지 않는 이상 DB에 영어 버전을 직접 저장해야겠더라고요.
왜냐면 자동 번역을 하면 어색한 부분이 분명 있기 때문에, 영어를 잘 하시는 디자이너분께 어드민 페이지를 통해 번역을 부탁한 뒤 수정받기로 했기에 DB의 데이터 사용이 필수적이었습니다.
// ❌ 이렇게 하면 당연히 비효율적
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을 사용하기로 결정했습니다.
먼저 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 사용은 이미 많이 해봤고, 세팅만 해주면 되는 거라 간단히 넘어가도록 하겠습니다.
그리고 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 "";
}
이렇게 하면 영어가 없어도 한국어로 대체되니까 안전하게 사용이 가능합니다.
그리고 전역에서 언어 설정을 공유하기 위해 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에 저장해주기 때문에 새로고침하거나 재접속하더라도 언어가 유지될 수 있게 해주었습니다.
여기에서 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);
결론적으로 위의 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 데이터그리고 사이트의 헤더 부분에 사용자가 언어를 쉽게 바꿀 수 있게 언어 선택 버튼을 만들었습니다. (디자인 반영)
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>
);
}
결론적으로 언어를 바꾸게 되면,
currentLanguage 상태 변경이 순서대로 적용이 됩니다.
결론적으로, 이번 리팩토링에서 다국어 지원을 위해 한 작업을 정리해보자면,
(정적 텍스트와 동적 테스트에 대한 분리)
| 종류 | 사용 방법 | 예시 |
|---|---|---|
| 정적 UI 텍스트 | t('key') | 버튼, 라벨, 에러 메시지 |
| 동적 DB 콘텐츠 | getLocalizedText() | 아티클 제목, 키워드명 |
const multilingualArticle = createMultilingualArticle(article);
const title = getLocalizedText(multilingualArticle.title);
(나중에 일본어, 중국어를 추가해도 컴포넌트 코드는 수정할 필요 없음)
export type LanguageCode = "ko" | "en";
// ✅ 타입 안전성
setLanguage("ko"); // OK
setLanguage("en"); // OK
setLanguage("jp"); // ❌ 컴파일 에러
(오타나 잘못된 언어 코드 사용시 에러 발생)
useEffect(() => {
const savedLanguage = localStorage.getItem('lang');
if (savedLanguage) {
setCurrentLanguage(savedLanguage);
}
}, []);
(새로고침해도 유지)
const setLanguage = (language: LanguageCode) => {
setCurrentLanguage(language); // Context 상태 변경
localStorage.setItem('lang', language); // 저장
i18n.changeLanguage(language); // i18n도 변경!
};
(t()로 가져오는 텍스트와 getLocalizedText()로 가져오는 텍스트 항상 같은 언어로 표시)
번역 기능을 추가하면서 두 가지 번역 시스템에 대한 역할을 제대로 분리하고,
이 과정에서 확장 가능하도록 설계하려고 노력하였습니다.
덕분에 해외 사용자들도 편하게 UX 해적단을 이용할 수 있는 첫 기반이 마련된 것 같습니다.

📌 참고
관련 글