
next-intl로 다국어 지원: 한국어 서비스에 영어를 붙인 이유
한국어로만 서비스하던 블로그에 영어를 추가하면서 next-intl을 도입했다. App Router 환경에서 다국어를 구현하며 겪은 시행착오와 패턴.

한국어로만 서비스하던 블로그에 영어를 추가하면서 next-intl을 도입했다. App Router 환경에서 다국어를 구현하며 겪은 시행착오와 패턴.
게시판에 달린 댓글 하나 때문에 관리자 계정이 탈취당했습니다. XSS(Cross-Site Scripting)의 3가지 유형(Stored, Reflected, DOM)과 React/Next.js 환경에서의 구체적인 방어법(HTML 이스케이프, CSP, 쿠키 보안)을 예제와 함께 깊이 있게 다룹니다.

Next.js 빌드 로그에 나오는 동그라미(○)와 람다(λ) 기호의 의미를 아시나요? 실수로 모든 페이지를 동적으로 만들어버리지 않는 방법을 확인하세요.

서비스를 만들었는데 아무도 오지 않았다. 마케팅 예산 없이 검색 유입을 만드는 기술적 SEO를 Next.js에서 직접 구현한 경험.

Vercel 대신 AWS S3에 정적 배포(Static Export)를 시도했다가 겪은 세 가지 악몽(이미지 최적화, API 라우트, 동적 라우팅)과 그 해결책을 공유합니다. '서버 없는 Next.js'가 어떤 제약이 있는지 확실히 이해하게 될 것입니다.

블로그를 운영한 지 꽤 됐는데, 어느 날 영어로 된 피드백이 들어왔다.
"Your posts are really useful, but I can't read Korean. Is there an English version?"
한 번이면 무시할 수 있었다. 두 번, 세 번 비슷한 메시지가 오니까 진지하게 생각하게 됐다. 내가 쓰는 글이 기술 주제다 보니 한국어를 모르는 독자도 충분히 유용하게 읽을 수 있겠다 싶었다. 그리고 솔직히 말하면 포트폴리오로도 영어 버전이 있는 편이 훨씬 낫다.
문제는 방법이었다.
가장 단순한 방법은 같은 글을 영어로 번역해서 별도 URL로 올리는 것이다. /blog/some-post에 한국어 버전, /en/blog/some-post에 영어 버전을 따로 만드는 식이다. 하지만 이 방식이면 내가 관리해야 할 파일이 두 배가 된다. 글 하나 고치면 두 파일을 고쳐야 한다. 게다가 UI 텍스트, 날짜 형식, 카테고리 이름 같은 것들도 전부 따로 관리해야 한다. 직접 다 만들면 금방 엉망이 된다.
국제화(i18n)를 제대로 구조화해야 했다.
Next.js App Router용 i18n 라이브러리 선택지를 살펴봤을 때 크게 세 가지가 보였다.
next-i18next, next-intl, 그리고 직접 구현.
next-i18next는 Pages Router 시절에 널리 쓰이던 라이브러리다. App Router와 함께 쓸 수는 있는데, 원래 Pages Router를 위해 설계된 구조라서 Server Components 환경에서 어색한 부분이 생긴다. 특히 서버 컴포넌트에서 번역을 쓰려면 꽤 우회를 해야 한다.
직접 구현은... 해봤다. useContext로 locale을 전달하고, JSON 파일을 불러와서 dot notation으로 접근하는 방식. 처음엔 간단해 보이는데 예외 처리, 타입 추론, 복수형 처리 같은 것들이 하나씩 쌓이면서 결국 라이브러리가 된다. 라이브러리를 만들기 위해 라이브러리를 공부하는 셈이다.
next-intl을 선택한 이유는 두 가지였다.
첫째, App Router와 Server Components를 정식으로 지원한다. getTranslations()는 서버 컴포넌트에서, useTranslations()는 클라이언트 컴포넌트에서 쓰도록 명확히 나뉘어 있다. 구조가 Next.js 15/16의 설계와 정렬된다.
둘째, TypeScript 자동 완성이 된다. 메시지 파일의 키를 잘못 쓰면 즉시 에러가 난다. 번역 키를 문자열로 쓰다 보면 오타가 자주 생기는데, 이걸 컴파일 타임에 잡을 수 있다는 게 실용적이다.
next-intl의 핵심 아이디어는 모든 페이지를 [locale] 폴더 안에 넣는 것이다. URL 구조가 달라진다.
기존: /blog/some-post
변경: /ko/blog/some-post
/en/blog/some-post
src/app/[locale]/ 아래에 기존 페이지를 전부 옮긴다. page.tsx, layout.tsx가 [locale] 세그먼트를 받아서 현재 로케일을 알 수 있게 된다.
미들웨어가 핵심이다. src/middleware.ts에서 next-intl이 제공하는 createMiddleware()를 쓴다.
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
routing.ts에서 기본 로케일과 지원 로케일 목록을 정의한다.
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ko', 'en'],
defaultLocale: 'ko',
localePrefix: 'always',
});
localePrefix: 'always'로 설정하면 기본 로케일도 URL에 /ko/가 붙는다. 'as-needed'로 하면 기본 로케일은 prefix 없이 /blog/...로 접근 가능하다. 나는 SEO 일관성을 위해 'always'를 택했다.
미들웨어가 하는 일은 단순하다. URL에 로케일이 없으면 브라우저 설정을 보고 가장 적합한 로케일로 리다이렉트한다. /blog/some-post로 들어오면 Accept-Language 헤더를 보고 /ko/blog/some-post 또는 /en/blog/some-post로 보낸다. 이 과정이 자동이다.
번역 텍스트는 JSON 파일로 관리한다. messages/ko.json과 messages/en.json. 이걸 비유하면 여행용 구문집과 같다.
일본어를 모르는 여행자가 식당에서 "이거 주세요"라고 말하고 싶을 때, 구문집에서 "これをください"를 찾아서 쓴다. 앱에서 "최근 포스트"라는 텍스트가 필요할 때, 한국어 구문집에서는 "최근 포스트"를, 영어 구문집에서는 "Recent Posts"를 찾아서 쓰는 것과 같다.
구문집에서 중요한 건 네임스페이스 구조다. 모든 키를 flat하게 쌓으면 관리가 안 된다. 페이지 단위로 나누는 것이 실용적이었다.
// messages/ko.json
{
"Home": {
"recentPosts": "최근 포스트",
"viewAll": "전체 보기",
"hero": {
"title": "개발자의 기록",
"subtitle": "배운 것들을 정리하는 공간"
}
},
"Blog": {
"title": "블로그",
"allPosts": "전체 글",
"noResults": "검색 결과가 없습니다.",
"readMore": "더 읽기",
"categories": {
"all": "전체",
"frontend": "프론트엔드",
"backend": "백엔드"
}
},
"Navigation": {
"home": "홈",
"blog": "블로그",
"projects": "프로젝트",
"about": "소개"
}
}
// messages/en.json
{
"Home": {
"recentPosts": "Recent Posts",
"viewAll": "View All",
"hero": {
"title": "A Developer's Notes",
"subtitle": "A space to organize what I've learned"
}
},
"Blog": {
"title": "Blog",
"allPosts": "All Posts",
"noResults": "No results found.",
"readMore": "Read more",
"categories": {
"all": "All",
"frontend": "Frontend",
"backend": "Backend"
}
},
"Navigation": {
"home": "Home",
"blog": "Blog",
"projects": "Projects",
"about": "About"
}
}
네임스페이스를 페이지별로 나누는 게 핵심이다. Home.recentPosts가 뭔지 바로 알 수 있다. Blog.categories.frontend처럼 중첩도 된다. 나중에 키가 수백 개가 돼도 어느 파일에서 어떤 키를 쓰는지 추적할 수 있다.
next-intl을 쓸 때 가장 처음 헷갈린 부분이 이거였다. getTranslations()와 useTranslations()의 차이.
규칙은 단순하다.
| 컴포넌트 | 사용 함수 | 이유 |
|---|---|---|
| Server Component | getTranslations() (async) | await 가능, RSC에서 훅 불가 |
| Client Component | useTranslations() (hook) | 브라우저 환경, React 훅 사용 |
서버 컴포넌트에서 사용하는 방식이다.
// src/app/[locale]/blog/page.tsx (Server Component)
import { getTranslations } from 'next-intl/server';
export default async function BlogPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Blog' });
return (
<main>
<h1>{t('title')}</h1>
<p>{t('allPosts')}</p>
</main>
);
}
클라이언트 컴포넌트에서는 훅을 쓴다.
// src/components/layout/Header.tsx
'use client';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
export function Header() {
const t = useTranslations('Navigation');
return (
<nav>
<Link href="/">{t('home')}</Link>
<Link href="/blog">{t('blog')}</Link>
<Link href="/projects">{t('projects')}</Link>
</nav>
);
}
처음에 서버 컴포넌트에서 useTranslations()를 쓰려다가 에러를 만났다. useTranslations is only available in Client Components. 당연한 에러인데, 서버 컴포넌트에서는 React 훅이 동작하지 않으니까. 이 차이를 명확히 구분한 뒤로는 헷갈리지 않았다.
블로그 포스트 자체는 한국어/영어 버전이 모두 MDX 파일에 들어 있다. title, title_en, description, description_en 같은 식으로 frontmatter에 양쪽 필드를 다 둔다. 그리고 localized(post, 'title', locale) 헬퍼로 현재 로케일에 맞는 필드를 꺼낸다.
UI 텍스트는 메시지 파일에서, 콘텐츠 자체는 MDX frontmatter에서 관리하는 두 레이어 구조다.
SEO 처리도 로케일별로 해야 했다. generateMetadata()에서 각 로케일에 맞는 제목과 설명을 만들고, alternates에 hreflang 링크를 넣는다.
// src/app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
const title = localized(post, 'title', locale);
const description = localized(post, 'description', locale);
return {
title,
description,
alternates: {
canonical: `/${locale}/blog/${slug}`,
languages: {
'ko': `/ko/blog/${slug}`,
'en': `/en/blog/${slug}`,
},
},
};
}
alternates.languages에 hreflang 링크를 넣으면 Google이 각 URL이 어느 언어를 위한 건지 파악한다. 같은 콘텐츠의 언어별 버전이 있을 때 이게 없으면 중복 콘텐츠로 처리될 수 있다. i18n 구현에서 SEO까지 챙기려면 이 부분을 빠뜨리면 안 된다.
i18n을 이해하는 데 도움이 됐던 비유가 있다. 건물에 두 개의 입구를 만드는 것이다.
건물 자체(콘텐츠, 기능, 로직)는 하나다. 그런데 입구가 둘이다. 한국어 입구(/ko/)로 들어오면 안내 표지판이 한국어로 써 있고, 영어 입구(/en/)로 들어오면 표지판이 영어다. 건물 내부 구조는 동일하고, 표지판 텍스트만 다른 것이다.
이 비유가 설계 결정을 명확하게 만들어줬다.
[locale] 폴더로 분리ko.json, en.json)_ko/_en 필드처음에 실수했던 게 이 세 레이어를 구분하지 않고 메시지 파일에 블로그 포스트 제목까지 넣으려 했던 것이다. 포스트가 수십 개인데 그걸 다 ko.json에 넣으면 메시지 파일이 수백 줄짜리 괴물이 된다. 콘텐츠는 MDX frontmatter에, UI 텍스트만 메시지 파일에 넣는 것이 맞다.
또 다른 시행착오는 날짜 형식이었다. 한국어에서는 "2026년 3월 3일", 영어에서는 "March 3, 2026". 처음에는 직접 포맷팅 함수를 만들었는데, next-intl에 이미 useFormatter()가 있었다.
// 클라이언트 컴포넌트에서
'use client';
import { useFormatter, useLocale } from 'next-intl';
function PostDate({ dateString }: { dateString: string }) {
const format = useFormatter();
const date = new Date(dateString);
return (
<time dateTime={dateString}>
{format.dateTime(date, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
);
}
useFormatter()는 현재 로케일을 자동으로 반영한다. 한국어면 "2026년 3월 3일", 영어면 "March 3, 2026"이 나온다. Intl.DateTimeFormat을 래핑한 것이라 locale 인자를 따로 넘길 필요가 없다. 숫자 포맷팅(format.number(1234567))도 마찬가지로 로케일에 맞게 자동 처리된다.
메시지 파일에서 키가 빠지면 어떻게 될까. 기본적으로 next-intl은 키 이름을 그대로 표시한다. Blog.noResults가 없으면 화면에 "Blog.noResults"가 뜬다. 보기 흉하다.
개발 환경에서는 콘솔 경고가 나오기 때문에 눈치채기 쉽다. 문제는 초기 설정 때 en.json에 키를 추가하는 걸 빠뜨리는 경우다. 한국어로 테스트하다 보면 영어 버전에 키가 없어도 한국어 테스트에서는 에러가 안 난다.
해결책 두 가지를 썼다.
첫째, next-intl의 TypeScript 타입 지원을 활용했다. src/i18n/request.ts에 메시지 타입을 연결하면 IDE에서 키 자동 완성과 누락 경고가 나온다.
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as 'ko' | 'en')) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});
둘째, en.json과 ko.json을 항상 같이 편집하는 습관을 들였다. 어떤 한쪽에 키를 추가하면 즉시 반대쪽도 추가한다. 단순하지만 효과적이다.
next-intl은 App Router와 Server Components를 정식 지원한다. getTranslations()(서버)와 useTranslations()(클라이언트)의 구분이 명확하다.
메시지 파일은 UI 텍스트만 담아야 한다. 블로그 포스트 같은 콘텐츠는 MDX frontmatter의 _ko/_en 필드로 별도 관리한다.
네임스페이스를 페이지 단위로 나누면 유지보수가 쉽다. Home, Blog, Navigation 같은 단위로 나누면 키가 수백 개가 돼도 추적할 수 있다.
날짜, 숫자 포맷팅은 useFormatter()를 쓴다. 직접 구현할 필요 없다. 로케일에 맞는 형식을 자동으로 처리한다.
SEO는 hreflang을 챙겨야 한다. generateMetadata()의 alternates.languages에 로케일별 URL을 명시한다.
영어 독자를 위해 시작한 작업이었는데, 결과적으로 코드 구조가 더 명확해졌다. UI 텍스트와 콘텐츠가 레이어로 분리되고, 각 페이지가 어떤 텍스트를 쓰는지 추적 가능해졌다. 처음엔 번거로운 작업처럼 보였는데, 해놓고 나니 오히려 유지보수가 쉬워진 부분이 생겼다.