Takeoff. 서비스 다국어 지원하기 (feat. Next.js 15)
Takeoff. 서비스 다국어 지원하기 (feat. Next.js 15)
서비스 언어를 한국어만 지원하도록 설정하기에는 아쉬움이 남아서 영어를 추가로 지원하기로 했다.
현재 상황에서 다국어 지원을 하기 위해 필요한 것은 다음과 같다.
- 하드코딩된 한국어 문자열을 다국어 딕셔너리로 변환하는 작업
- AI가 생성한 포스트를 영어로 번역 후 데이터베이스에 저장
프론트엔드로 Next.js 15를 사용하고 있기 때문에 다국어 지원을 위해 몇가지 라이브러리를 찾아보았다.
Next.js 15에서 다국어 지원하기
라이브러리를 선택하기 위해 최신 Next.js와 완전히 호환되는지, 라이브러리가 가벼운지, 문서가 잘 정리되어 있는지를 고려했다.
next-18next, next-international, lingui 등 다양한 라이브러리가 있었지만 그 중 next-intl 가 앞서 언급한 조건을 모두 만족하는 라이브러리이기 때문에 선택했다.
국제화 기능을 사용하는 방식에는 라우팅 방식과 논라우팅 방식이 있는데 차이는 다음과 같다.
구분 | 라우팅 방식 | 논라우팅 방식 |
---|---|---|
구분 방식 | /en/... 접두사 기반 라우팅 또는 en.examples.com 도메인 기반 라우팅 | 사용자 설정에 따라 언어 지정 (헤더나 쿠키 등) |
Next.js의 dynamic routes를 사용 | 앱 구조를 변경할 필요 없음 |
이전에는 국제화를 할 때 논라우팅 방식을 많이 사용했기 때문에 이번 프로젝트에서는 접두사 기반 라우팅을 사용하기로 했다.
시작하기
설정은 공식문서를 따라 진행했다.
next-intl
는 useTranslations
훅을 제공하기 때문에 각 언어를 구분하여 문자열을 관리할 수 있다.
비동기 컴포넌트의 경우에는 getTranslations
함수를 사용하여 문자열을 가져올 수 있다.
국제화를 고려하지 않은 하드코딩 된 문자열 마이그레이션하기
처음에 서비스를 만들 때 국제화를 전혀 고려하지 않았기 때문에 모든 문자열이 하드코딩 되어 있는 상황이었다.
예시 코드
import Link from "next/link";
import Background from "./Background";
export default function BenchmarkBanner() {
return (
<Link
className="relative w-full h-24 overflow-hidden flex flex-col items-center justify-center bg-purple-800/20
rounded-3xl cursor-pointer border-2 border-purple-800/20 shadow-2xl
hover:border-purple-800/20 hover:shadow-purple-800/20 transition-all duration-150
"
href="/benchmarking"
>
<h1
className="text-3xl text-purple-200/90 z-10
text-shadow-[rgba(255, 255, 255, 0.5)]"
>
인공지능 벤치마크
</h1>
<p className="text-xs text-purple-100 z-10">
클릭하여 벤치마크 페이지로 이동
</p>
<Background className="absolute w-full h-128" />
</Link>
);
}
서비스 개발이 어느 정도 진행된 상태라 노가다로 바꾸기에는 조금 곤란했기에 AI 에이전트를 사용했다.
Cursor 에디터를 즐겨 사용하기 때문에 Cursor Agent 에서 모델은 claude-4-sonnet을 사용했다.
LLM 컨텍스트 윈도우 크기를 고려하여 페이지에 대해 각각 번역 작업을 요청하였다.
벤치마크 페이지 국제화 작업을 위해 메시지를 보낸 모습.
로딩 중...
따로 국제화에 필요한 번역 한-영 쌍을 만들지 않았기 때문에 LLM이 자율적으로 번역을 하도록 하였다.
타임라인 페이지 번역 결과.
한국어로 작성된 포스팅 번역하기
Takeoff. 서비스는 해외 AI 커뮤니티에서 특정 이슈를 다루는 게시글을 찾아 정리하는 서비스이다.
AI를 통해 한국어로 게시글을 작성한 후 저장하기 떄문에 국제화를 위한 번역 작업이 필요하다.
번역 작업을 위해 구글 gemini-2.5-flash-lite-preview-06-17
모델을 사용했고, 게시글 특성상 번역하기 까다로운 기술 용어들이 많기 때문에
이를 고려하여 프롬프트를 작성하였다.
프롬프트에는 기술적인 용어를 최대한 유지하고, 마크다운이 훼손되지 않도록 하는 문구를 작성하였다.
export const TRANSLATION_CHAT_TEMPLATE = ChatPromptTemplate.fromMessages([
[
'system',
`You are a professional AI content translator specializing in technical and AI-related content. Your task is to translate the given text accurately while maintaining:
1. Technical accuracy and terminology
2. Natural flow in the target language
3. Cultural appropriateness
4. Proper formatting and structure
## Guidelines:
- Preserve all technical terms and their meanings
- Maintain the original tone and style
- Keep proper nouns, brand names, and URLs unchanged
- Preserve markdown formatting if present
- Ensure the translation sounds natural to native speakers
- For AI/ML terms, use commonly accepted translations in the target language
- Translate the content exactly as written without adding any explanations, interpretations, or embellishments
- Do not insert flowery language, additional context, or editorial comments
- Keep the translation direct and faithful to the original meaning and style
## Language Codes:
- en: English
- ko: Korean (한국어)
## Output Format:
Please provide the translation in the following JSON format:
{{
"translatedTitle": "translated title here",
"translatedContent": "translated content here"
}}`,
],
[
'human',
`Please translate the following content to {language}:
Title: {title}
Content: {content}`,
],
]);
Langchain을 사용하여 AI 모델과 JSON 파서를 연결하여 체인을 생성 후 JSON 파싱을 하도록 하였다.
// 번역 프롬프트 생성 (ChatPromptTemplate 사용)
const promptValue = await getTranslationPrompt(post.title, post.content, language);
// AI 모델과 JSON 파서를 연결한 체인 생성
const chain = TranslateService.translator.pipe(new JsonOutputParser<TranslationResult>());
// 번역 실행 및 JSON 파싱
const translationResult = (await chain.invoke(promptValue)) as TranslationResult;
// 번역 결과 검증
if (!translationResult.translatedTitle || !translationResult.translatedContent) {
throw new Error('번역 결과가 완전하지 않습니다.');
}
return translationResult;
번역 결과 저장을 위한 데이터베이스 스키마를 작성하여 게시물을 참조하는 번역 결과를 저장할 수 있도록 한다. ORM은 drizzle-orm을 사용했다.
export const aiPostTranslations = sqliteTable('ai_post_translations', {
id: integer('id').primaryKey({ autoIncrement: true }),
aiPostId: integer('ai_post_id').references(() => aiPosts.id),
language: text('language').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
이제 기능을 묶어서 번역과 저장을 동시에 할 수 있도록 함수를 작성했다.
export class TranslateService {
//...
static async translateAndSaveAiPost(
aiPostId: number,
post: { title: string; content: string },
language: Language,
forceRetranslate = false
): Promise<TranslationResult> {
// 기존 번역 확인 (강제 재번역이 아닌 경우)
if (!forceRetranslate) {
const existingTranslation = await this.getExistingTranslation(aiPostId, language);
if (existingTranslation) {
return {
translatedTitle: existingTranslation.title,
translatedContent: existingTranslation.content,
};
}
}
console.log(`Translating post ${post.title}...`);
// 새로운 번역 수행
const translation = await this.translate(post, language);
console.log(`Translated post ${translation.translatedTitle}...`);
// 번역 결과 저장
await this.saveAiPostTranslation(aiPostId, language, translation);
console.log(`Saved translation for post ${post.title}...`);
return translation;
}
}
구현한 함수를 실제 서비스 코드에 적용했다. 이 작업은 게시글이 한국어로 작성된 후 진행되어야 하기 때문에 이를 고려하였다.
// ...
// 포스트 처리
const processedPost = await writer.processPost(result, similarPosts.map((post) => post.content));
logInfo(`Processed post: ${processedPost.title}`, SERVICE_TAG, OPERATION_TAG);
const savedPost = await postManager.savePost(processedPost);
if (savedPost) {
//.....
// 번역
if (config.translate) {
await TranslateService.translateAndSaveAiPost(savedPost.id,
{ title: savedPost.title, content: savedPost.content }, 'en', true);
logInfo(`Translated post`, SERVICE_TAG, OPERATION_TAG);
}
// ...
}
// ...
게시글이 저장된 후 값을 반환했을 때만 번역 작업을 진행하도록 하였다.
실제 서비스에서 번역 작업을 진행하는 모습.
로딩 중...
작업 결과를 비교해보면 다음과 같다.