[Next.js] Next.js 어플리케이션에 i18n 구현하기

2025. 12. 15. 22:12·개발

이번 일주일 동안 저번 포스팅에서 만든 UI 라이브러리를 가지고 압축 사이트를 만들었습니다.

https://www.runzipper.app/

 

Runzipper

ZIP, TAR, TAR.GZ 형식을 지원하는 브라우저 기반 파일 압축 및 아카이브 도구입니다. 서버 업로드 없이, 설치 불필요하며, 완벽한 개인정보 보호를 제공합니다. 모든 압축 처리는 사용자의 기기에서

www.runzipper.app

 

제가 이 프로젝트를 시작한 계기는 간단한 툴을 웹서비스 형태로 제공하여 방문자수를 모니터링하고 싶어서였습니다.

이 서비스를 사용하는 사람이 충분하다면 광고를 추가하여 수익을 창출하는 것도 고려하고 있습니다.

 

❓ 다양한 툴 중에 왜 압축 사이트를 만들었나요?

일반 컴퓨터 사용자는 보통 압축, 압축해제를 하려면 반디집 같은 압축 프로그램을 설치하여 사용합니다.

압축 프로그램을 브라우저에서 구현하면 설치도 필요 없고, 서버로 정보가 넘어가지도 않으니 프라이버시도 지킬 수 있습니다.
특히, 설치가 필요 없다는 점에서 상용 소프트웨어에 비해 경쟁력을 가질 수 있지 않을까 하여 브라우저에서 실행되는 압축 프로그램을 만들었습니다.

 

방문자수를 높일 수 있는 방법이 뭐가 있을까 고민했을 때 첫 번째로 떠오르는 것은 전 세계 사람들을 대상으로 서비스하는 것이었습니다.

 

그래서 runzipper에 국제화를 적용하기로 했습니다.

 

dictionary 만들기

사실 저는 i18n을 구현하는 게 처음입니다.

그래서 next.js 공식문서의 internationalization 섹션을 참고하였고, 많은 부분을 따라서 만들었습니다.

 

제가 가장 먼저 한 것은 하드 코딩된 텍스트를 json 형태의 dictionary로 정리하는 것입니다.

 

만약 영어, 한국어, 일본어, 중국어를 지원한다면

  • en.json
  • ko.json
  • ja.json
  • zh.json

등의 dictionary를 만들면 됩니다.

 

하지만 무장적 json 파일을 작성하면 실수할 가능성이 높겠죠?

 

그래서 저는 안정성을 위해 json schema 파일을 먼저 작성했습니다.

 

json schema 작성하기

json schema는 타입스크립트에 비유할 수 있습니다.

타입스크립트는 타입이나 인터페이스를 정의하여 코더에게 형식을 제한할 수 있잖아요?

json schema는 json 버전의 인터페이스라고 이해하시면 됩니다.

 

json schema를 작성하는 방법은 이 문서에 나와있습니다.

 

저는 dictionary.schema.json 파일을 만들어서 스키마를 정의했습니다.

// dictionary.schema.json
{
	"$schema": "https://json-schema.org/draft/2020-12/schema",
	"title": "Dictionary Schema",
	"description": "Schema for application dictionary files",
	"type": "object",
	"properties": {
		"meta": {
			//...
		},
		"compress": {
			//...
		},
		"header": {
			//...
		},
		"footer": {
			//...
	},
	"required": [
		//...
	]
}

 

위와 같이 레이아웃, 메타 데이터, 페이지 단위로 데이터를 정의하였습니다.

 

필수로 넣어야 하는 필드인 경우 required 배열에 key를 추가합니다.

 

json dictionary 작성하기

json schema를 정의했으니 이를 사용하여 dictionary를 작성하겠습니다.

 

딕셔너리를 작성할 때 먼저 $schema 키에 값으로 앞서 작성한 json schema의 url을 넣어줍니다.

// ko.json
{
	"$schema": "./dictionary.schema.json",
	//...
}

 

이렇게 하면 에디터에서 자동완성이 지원되고, 필수 값이 누락됐을 경우 경고 표시를 해줍니다.

 

dictionary 유틸 만들기

dictionary를 정의했으니 이를 사용하기 위한 유틸 함수들을 만들겠습니다.

 

이 코드 또한 next.js 공식문서에서 가져왔습니다.

// utils/dictionary.ts

// 앞서 정의한 dictionary를 로딩하는 함수
export const dictionaries = {
	ko: () =>
		import('@/docs/dictionanaries/kr.json').then((module) => module.default),
	en: () =>
		import('@/docs/dictionanaries/en.json').then((module) => module.default),
	ja: () =>
		import('@/docs/dictionanaries/ja.json').then((module) => module.default),
	zh: () =>
		import('@/docs/dictionanaries/zh.json').then((module) => module.default),
	de: () =>
		import('@/docs/dictionanaries/de.json').then((module) => module.default),
	pl: () =>
		import('@/docs/dictionanaries/pl.json').then((module) => module.default),
	es: () =>
		import('@/docs/dictionanaries/es.json').then((module) => module.default),
	fr: () =>
		import('@/docs/dictionanaries/fr.json').then((module) => module.default),
	it: () =>
		import('@/docs/dictionanaries/it.json').then((module) => module.default),
} as const;

export type Locale = keyof typeof dictionaries;

// string 타입을 좁히는 함수
export const hasLocale = (locale: string): locale is Locale => {
	return Object.keys(dictionaries).includes(locale);
};

// locale에 따른 dictionary를 get하는 함수
export const getDictionary = async (locale: Locale) => dictionaries[locale]();

 

Route 수정하기

다중 언어를 지원하려면 언어별로 route가 필요합니다.

 

예를 들어 /compress이라는 경로가 있고

  • 영어
  • 일본어
  • 한국어

를 지원한다면

 

  • /en/compression
  • /ja/compression
  • /ko/compression

3개의 route가 필요합니다.

 

이를 위해 폴더 구조를 /compress에서 /[slug]/compress 형태로 변경했습니다.

 

Proxy.ts에서 언어 감지하기

사용자가 지원하지 않는 언어로 접근하거나 url에 언어(slug)가 포함이 안 돼있을 시를 처리하기 위해 next.js의 proxy를 사용했습니다.

 

사용자가 /[lang]/compress가 아닌 /compress로 접속했을 때는 요청 헤더의 accept-language를 파싱 하여

해당 언어를 지원하면 /[해당 언어]/compress로 리다이렉트 시키고,

지원하지 않는다면 /en/compress로 리다이렉트 시키도록 만들었습니다.

// proxy.ts

import { dictionaries } from '@/utils/dictionary';
import { type NextRequest, NextResponse } from 'next/server';

// 지원하지 않는 언어로 접속시 기본 언어
const DEFAULT_LANGUAGE = 'en';

// 지원하는 언어 목록
const localList = Object.keys(dictionaries);

export function proxy(request: NextRequest) {
	const { pathname } = request.nextUrl;
	const pathnameHasLocale = localList.some(
		(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
	);

	// 사용자의 요청이 /lang/~ 일 때
	if (pathnameHasLocale) return;

	// 사용자의 요청이 /lang/~ 형식이 아닐 때
	const acceptLanguage = request.headers.get('accept-language') || '';
	const primaryLanguage = acceptLanguage
		.split(',')[0]
		.split(';')[0]
		.trim()
		.split('-')[0];

	const locale =
		localList.find((locale) => locale === primaryLanguage) || DEFAULT_LANGUAGE;

	request.nextUrl.pathname = `/${locale}${pathname}`;

	return NextResponse.redirect(request.nextUrl);
}

 

언어별 route에 static site generation 적용하기

Dictionary에 저희가 페이지에서 사용할 텍스트가 모두 정의돼 있으니 동적 렌더링을 할 이유가 전혀 없습니다.

 

SSG를 적용하기 위해 [lang] 폴더의 layout.tsx에 generateStaticparams를 선언하였습니다.

// src/app/[lang]/layout.tsx

export async function generateStaticParams() {
	return Object.keys(dictionaries).map((lang) => ({ lang }));
}

export default async function Layout({
	children,
	params,
}: LayoutProps<'/[lang]'>) {
	const locale = (await params).lang;

	if (!hasLocale(locale)) notFound();

	return (
		<html lang={(await params).lang}>
			<body className={bodyStyle}>
				<DictionaryProvider dictionary={await dictionaries[locale]()}>
					<Header lang={locale} />
					{children}
					<Footer lang={locale} />
				</DictionaryProvider>
			</body>
		</html>
	);
}

다음과 같이 지원하는 언어들에 대한 정적페이지를 생성하였습니다.

 

추가적으로 layout 컴포넌트에서 알아낸 딕셔너리 정보를 자식 컴포넌트에게 전달해야 하는데 저 같은 경우 Provider를 사용하여 전달했습니다.

 

언어에 따른 메타데이터 제공하기

저는 눈에 보이는 정보뿐만이 아니라 메타데이터도 국제화를 적용하고 싶어서 dictionary를 정의할 때 메타데이터도 각 국가의 언어로 작성했습니다.

 

generateMetadata 함수를 선언하여 언어에 따른 메타데이터를 생성하였습니다.

// src/app/[lang]/layout.tsx

type MetadataProps = {
	params: Promise<{ lang: string }>;
};

export async function generateMetadata({
	params,
}: MetadataProps): Promise<Metadata> {
	const locale = (await params).lang;

	if (!hasLocale(locale)) {
		return {};
	}

	const dictionary = await dictionaries[locale]();

	return {
		title: 'Runzipper',
		description: dictionary.meta.description,
		manifest: '/site.webmanifest',
		appleWebApp: {
			title: 'Runzipper',
		},
		icons: {
			icon: [
				{ url: '/favicon.ico' },
				{ url: '/favicon.svg', type: 'image/svg+xml' },
				{ url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' },
			],
			apple: { url: '/apple-touch-icon.png', sizes: '180x180' },
			shortcut: '/favicon.ico',
		},
	};
}

 

SEO를 위해 언어별 sitemap 정의하기

프로젝트 목표는 방문자수 확보이므로 SEO는 필수입니다.

 

이 사이트가 다양한 언어를 지원한다는 것을 검색엔진에게 알리기 위해서는 hreflang 태그를 html head에 설정해야하는데,

next.js에서는 sitemap 함수를 사용하여 설정할 수 있습니다.

// sitemap.ts

import type { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
	return [
		{
			url: 'https://runzipper.app/compress',
			lastModified: new Date(),
			changeFrequency: 'monthly',
			priority: 1,
			alternates: {
				languages: {
					ko: 'https://runzipper.app/ko/compress',
					de: 'https://runzipper.app/de/compress',
					pl: 'https://runzipper.app/pl/compress',
					fr: 'https://runzipper.app/fr/compress',
					it: 'https://runzipper.app/it/compress',
					ja: 'https://runzipper.app/ja/compress',
					zh: 'https://runzipper.app/zh/compress',
					es: 'https://runzipper.app/es/compress',
					en: 'https://runzipper.app/en/compress',
				},
			},
		},
	];
}

 

이렇게 할 경우 빌드 결과물로 다음과 같은 sitemap.xml이 생성됩니다.

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://acme.com</loc>
    <xhtml:link
      rel="alternate"
      hreflang="es"
      href="https://acme.com/es"/>
    <xhtml:link
      rel="alternate"
      hreflang="de"
      href="https://acme.com/de"/>
    <lastmod>2023-04-06T15:02:24.021Z</lastmod>
  </url>
  <url>
    <loc>https://acme.com/about</loc>
    <xhtml:link
      rel="alternate"
      hreflang="es"
      href="https://acme.com/es/about"/>
    <xhtml:link
      rel="alternate"
      hreflang="de"
      href="https://acme.com/de/about"/>
    <lastmod>2023-04-06T15:02:24.021Z</lastmod>
  </url>
  <url>
    <loc>https://acme.com/blog</loc>
    <xhtml:link
      rel="alternate"
      hreflang="es"
      href="https://acme.com/es/blog"/>
    <xhtml:link
      rel="alternate"
      hreflang="de"
      href="https://acme.com/de/blog"/>
    <lastmod>2023-04-06T15:02:24.021Z</lastmod>
  </url>
</urlset>

 

 

i18n 적용 후기

 

처음 다국어 지원을 구현했는데 next.js 공식문서에 설명이 잘 되어있어서 어려움 없이 구현할 수 있었습니다.

 

제가 만든 컴포넌트 라이브러리가 국제화를 고려하지 않고 만들어서, 텍스트가 하드코딩 되있어서 이를 수정하는데 시간이 걸렸습니다.

 

다음부터 컴포넌트 라이브러리를 만들때는 사용자가 국제화를 할 수 있다는 것도 고려해야할 것 같습니다. 😅

'개발' 카테고리의 다른 글

[vite] vite로 라이브러리 빌드하기  (0) 2025.12.02
[압축 알고리즘] LZ77 구현  (0) 2025.11.13
[압축 알고리즘] LZ77 알고리즘 이란?  (0) 2025.11.13
'개발' 카테고리의 다른 글
  • [vite] vite로 라이브러리 빌드하기
  • [압축 알고리즘] LZ77 구현
  • [압축 알고리즘] LZ77 알고리즘 이란?
월월월월
월월월월
  • 월월월월
    Bobostown
    월월월월
  • 전체
    오늘
    어제
    • 분류 전체보기 (43)
      • 개발 (4)
      • 사이드 프로젝트 (1)
        • interview-lab (1)
        • Loft (0)
      • Claude (1)
      • React (5)
        • React Router (1)
        • Interactive (2)
      • Javascript (1)
      • node.js (3)
      • npm (3)
      • Nest.js (0)
      • Web (5)
        • Web API (4)
      • TDD (2)
        • Jest (0)
      • TroubleShooting (1)
      • Rust (1)
      • Bash (1)
      • 보안 (1)
      • 일상 (4)
      • 여행 (5)
      • 우아한 테크코스 8기 프리코스 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    peer dependency
    json-schema
    LZ77
    오픈미션
    package.json
    motion
    Media Capture and Streams API
    오실리에이터
    npm
    webbase line
    보홀
    framer motion
    react
    runzipper
    private package
    nofitication
    node.js
    devfest 2025
    VITE
    Web API
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
월월월월
[Next.js] Next.js 어플리케이션에 i18n 구현하기
상단으로

티스토리툴바