๐ Next.js ์ฌํ 13์ฅ: Advanced I18n โ ์ ์ธ๊ณ ์ ์ ๋ฅผ ์ฌ๋ก์ก๋ ๋ก์ปฌ๋ผ์ด์ ์ด์ ์ ๋ต
๐ ๊ฐ์
๊ณ ๊ธ ๊ตญ์ ํ ์ ๋ต โ ๋์ locale, ์๋ฒ/ํด๋ผ์ด์ธํธ i18n, RTL ์ง์๊น์ง ๋ค๋ฃน๋๋ค.
๐ ๋ชฉ์ฐจ
- ๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
- ๐ค ์ ์์์ผ ํ๋๊ฐ
- ๐๏ธ ๋น์ ๋ก ๋จผ์ ์ดํดํ๊ธฐ
- ๐บ๏ธ App Router์ I18n ๊ธฐ๋ณธ ์ฒ ํ ๐ข
- ๐๏ธ ๋์ ์ฌ์ (Dict) ๋ก๋ ์์คํ ์ค๊ณ ๐ก
- ๐ก๏ธ ํ์ ์์ ์ฑ ํ๋ณดํ๊ธฐ (i18next vs Next native) ๐ก
- ๐ ๋ฏธ๋ค์จ์ด์ ์ธ์ด ๊ฐ์ง(Detection) ์ต์ ํ ๐ด
- ๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
- ๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
- ๐ ๋ ์์๋ณด๊ธฐ
๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
โฑ๏ธ ์์ ์ฝ๊ธฐ ์๊ฐ: 18๋ถ (์ ์ฒด) / ํต์ฌ ํํธ๋ง: 8๋ถ
๐บ๏ธ ์ด ๋ฌธ์์ ๋ฐฐ๊ฒฝ ์ธ๊ณ๊ด: '์์๋ค ์ปค๋ฎค๋ํฐ'
- ์์(๋์์ด๋): "์์ ๋, ์ฐ๋ฆฌ ์ปค๋ฎค๋ํฐ์ ์ผ๋ณธ์ด๋ ๋ฏธ๊ตญ ์ ์ ๋ค์ด ์์ฒญ ์ ์ ๋๊ณ ์์ด์! ์์ด๋ ์ผ๋ณธ์ด ๋ฒ์ ๋ ๋ง๋ค์ด์ฃผ์ธ์."
- ์์(PM): "์ข์์! ์์ฒ ๋, ์ง๊ธ ๋ฐ๋ก ๋ค๊ตญ์ด ๊ธฐ๋ฅ ์ถ๊ฐํด์ค ์ ์์ฃ ?"
- ์์ฒ (์ฃผ๋์ด): "๋ค!
localStorage์language์ ์ฅํด์ ๋ฐ๊พธ๋ฉด ๋๊ฒ ์ฃ ?" - ์ํธ(๋ฆฌ๋): "์์ฒ ๋, ์ ๋ผ์! ๊ทธ๋ฌ๋ฉด SEO๊ฐ ๋ฐ์ด ๋์. ๊ตฌ๊ธ ๊ฒ์ ๋ก๋ด์ ํ๊ตญ์ด ๋ฌธ์๋ฐ์ ๋ชป ๋ณด๊ฒ ๋ ๊ฑฐ์์. App Router์ ๊ฒฝ๋ก ๊ธฐ๋ฐ I18n์ผ๋ก ๊ฐ์ผ ํฉ๋๋ค."
๐ฏ ์ด ๋ฌธ์๋ฅผ ๋ค ์ฝ์ผ๋ฉด ํ ์ ์๋ ๊ฒ
-
/ko,/en์ฒ๋ผ URL ๊ฒฝ๋ก๋ฅผ ํตํด ๊ฒ์ ์์ง ์ต์ ํ(SEO)๊ฐ ๋ณด์ฅ๋๋ ๋ค๊ตญ์ด๋ฅผ ๊ตฌํํ ์ ์๋ค - ์๋ฒ ์ปดํฌ๋ํธ์์ ํ์ํ ์ฌ์ ์ ๋์ ์ผ๋ก ๋ก๋ํ์ฌ ์ด๊ธฐ ์ ์ก๋์ ์ต์ํํ ์ ์๋ค
- ๋ฏธ๋ค์จ์ด๋ฅผ ์ด์ฉํด ์ ์ ์ ๋ธ๋ผ์ฐ์ ์ค์ ์ ๋ง๋ ์ธ์ด๋ก ์๋ ์ฐ๊ฒฐ(Redirect) ์๋น์ค๋ฅผ ๊ตฌ์ถํ ์ ์๋ค
๐ค ์ ์์์ผ ํ๋๊ฐ
๋ค๊ตญ์ด๋ ๋จ์ํ ๊ธ์๋ฅผ ๋ฐ๊พธ๋ ๊ฒ ์๋์ผ. ๋น์ฆ๋์ค์ ํ์ฅ์ฑ ๊ทธ ์์ฒด์ง.
Next.js App Router์์๋ ๋ค๊ตญ์ด ์ฒ๋ฆฌ๊ฐ ๋ผ์ฐํ
์์คํ
์ ํต์ฌ์ผ๋ก ๋ค์ด์์ด.
ํต์ฌ ํฌ์ธํธ:
- SEO: ๊ฐ ์ธ์ด๋ณ๋ก ๊ณ ์ ํ URL์ด ์์ด์ผ ๊ฒ์ ์์ง์ด ๋ชจ๋ ๋ฒ์ ์ ํ์ด์ง๋ฅผ ์ธ๋ฑ์ฑํ ์ ์์ด.
- Server-side First: ํด๋ผ์ด์ธํธ๊ฐ ๋ฒ์ญ ํ์ผ์ ๋ฐ๊ธฐ ์ ์, ์๋ฒ์์ ์ด๋ฏธ ๋ฒ์ญ๋ HTML์ ๋ด๋ ค์ค์ผ ํด.
- PPR & Layouts: ๋ ์ด์์ ๊ตฌ์กฐ๋ฅผ ์ ์งํ๋ฉด์ ์ธ์ด๋ง ๊ฐ์ ๋ผ์ฐ๋ ์ฐ์ํ ์ค๊ณ๊ฐ ํ์ํด.
๐๏ธ ๋น์ ๋ก ๋จผ์ ์ดํดํ๊ธฐ
๐ ๋์๊ด ์๋ด์ฑ ์๋ก ์ค๋ช ํ๋ค๋ฉด?
๋์๊ด ์ ๊ตฌ์ ์๋ด์ฑ ์๊ฐ ์์ฌ์์ด.
- Client-side I18n: ์ฑ ์๋ ๋ชจ๋ ํ๊ตญ์ด์ธ๋ฐ, ์์์ธ์ด ์ค๋ฉด ๋ง์ ์ ๋ถ๋ ค์ ๋์์์ ๊ธ์๋ฅผ ์์ด๋ก ๋ฐ๊พธ๋ ๊ฑฐ์ผ. ๋ง์ ์ฌ๊ฐ ์์ผ๋ฉด(JS ๋ก๋ ์ ) ๋ฌด์กฐ๊ฑด ํ๊ตญ์ด๋ง ๋ณด์ฌ.
- App Router I18n: ์ ๊ตฌ์ 'ํ๊ตญ์ด ์ ๊ตฌ', '์์ด ์ ๊ตฌ'๊ฐ ๋ฐ๋ก ์๋ ๊ฑฐ์ผ. ์์ด ์ ๊ตฌ๋ก ๋ค์ด๊ฐ๋ฉด ์์ ์ฒ์๋ถํฐ ๋๊น์ง ์์ด๋ก ๋ ์ฑ ์๋ค๋ง ๋น์น๋์ด ์์ง. ์๋์ด ๋ง์ ์ ๋ถ๋ฆด ํ์๊ฐ ์์ด.
๐บ๏ธ App Router์ I18n ๊ธฐ๋ณธ ์ฒ ํ ๐ข
Next.js๋ ๋ด์ฅ๋ I18n ์์คํ ์ ๊ฐ์ ํ์ง ์์. ๋์ ํด๋ ๊ตฌ์กฐ๋ฅผ ํตํด ์ ์ฐํ๊ฒ ๊ตฌํํ๋ผ๊ณ ๊ถ์ฅํ์ง.
๊ตฌ์กฐ ์์:
app/
[lang]/
layout.tsx
page.tsx
about/page.tsx
์ด๋ ๊ฒ ํ๋ฉด ์ ์ ๊ฐ /en/about์ผ๋ก ๋ค์ด์์ ๋ lang ํ๋ผ๋ฏธํฐ๊ฐ "en"์ผ๋ก ์ ๋ฌ๋ผ.
๐๏ธ ๋์ ์ฌ์ (Dict) ๋ก๋ ์์คํ ์ค๊ณ ๐ก
๋ชจ๋ ์ค๋ฌด์ฉ ๊ฐ์ด๋๋ ์๋ฒ ์ฌ์ด๋ ๋ก๋๋ฅผ ์งํฅํด์ผ ํด.
// lib/get-dictionary.ts
import 'server-only'
// โ
1. ๊ฐ ์ธ์ด๋ณ ์ฌ์ ํ์ผ์ ์ ์ํด (์ค์ ๋ก๋ public/locales ๋ฐ์ ๋๋ ๊ฒ ์ข์)
const dictionaries = {
en: () => import('@/dictionaries/en.json').then((module) => module.default),
ko: () => import('@/dictionaries/ko.json').then((module) => module.default),
}
export const getDictionary = async (lang: 'en' | 'ko') =>
dictionaries[lang]?.() ?? dictionaries.en() // ํด๋น ์ธ์ด ์์ผ๋ฉด ์์ด๊ฐ ๊ธฐ๋ณธ์๋ฒ ์ปดํฌ๋ํธ์์ ์ฌ์ฉํ๊ธฐ
// app/[lang]/page.tsx
import { getDictionary } from '@/lib/get-dictionary'
export default async function Page({ params }: { params: { lang: 'en' | 'ko' } }) {
const { lang } = await params
const dict = await getDictionary(lang) // โ
์๋ฒ์์ ํ์ํ ์ธ์ด๋ง ์ ๊ฐ์ ธ์ด
return (
<main>
<h1>{dict.home.title}</h1>
<p>{dict.home.description}</p>
</main>
)
}๐ ๋ฏธ๋ค์จ์ด์ ์ธ์ด ๊ฐ์ง(Detection) ์ต์ ํ ๐ด
์ ์ ๊ฐ ๊ทธ๋ฅ youngsu.com์ผ๋ก ๋ค์ด์์ ๋, ๋ธ๋ผ์ฐ์ ์ธ์ด ์ค์ ์ ์ฝ์ด์ /ko๋ /en์ผ๋ก ๋ณด๋ด์ค์ผ ํด.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const locales = ['ko', 'en']
const defaultLocale = 'ko'
function getLocale(request: NextRequest) {
const headers = { 'accept-language': request.headers.get('accept-language') ?? '' }
const languages = new Negotiator({ headers }).languages()
return match(languages, locales, defaultLocale)
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// โ
์ด๋ฏธ ์ธ์ด ๊ฒฝ๋ก๊ฐ ๋ถ์ด์๋์ง ํ์ธ
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameHasLocale) return
// โ
์ธ์ด๊ฐ ์๋ค๋ฉด ๊ฐ์งํด์ ๋ฆฌ๋ค์ด๋ ํธ
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
export const config = {
matcher: [
// โ ๏ธ ์ ์ ์์ฐ(images, favicon ๋ฑ)์ ์ ์ธํ๊ณ ๋ผ์ฐํธ๋ง ๊ฐ์ง!
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
๐ I18n ์ค๊ณ ํต์ฌ ์์น
| ํญ๋ชฉ | ์ ๋ต | ์ด์ |
|---|---|---|
| URL ๊ตฌ์กฐ | /lang/path (๋์ ์ธ๊ทธ๋จผํธ) | ์ธ์ด๋ณ ๊ณ ์ URL ํ๋ณด (SEO) |
| ๋ฐ์ดํฐ ๋ก๋ | import() (๋์ ์ํฌํธ) | ํ์ ์๋ ์ธ์ด ํฉ์ ์๋ฒ ๋ฉ๋ชจ๋ฆฌ์ ์ฌ๋ฆฌ์ง ์์ |
| ์ธ์ด ๊ฐ์ง | middleware.ts | ์ ์ ๊ฒฝํ(UX) ์ต์ ํ ๋ฐ ์๋ ์ ํ |
โ ๏ธ ์ ๋ ํ์ง ๋ง ๊ฒ
| ์ํฉ | โ ๋์ ์ | โ ์ข์ ์ |
|---|---|---|
| ์ธ์ด ์ ์ฅ | localStorage๋ง ์ฌ์ฉ | URL ๊ฒฝ๋ก([lang])๋ฅผ ์ง์ค์ ๊ทผ์์ผ๋ก ์ฌ์ฉ |
| ๋ฒ์ญ ๋ก๋ | ํด๋ผ์ด์ธํธ์์ ๊ฑฐ๋ํ JSON ํต์งธ๋ก fetch | ์๋ฒ ์ปดํฌ๋ํธ์์ ํ์ํ ๋งํผ๋ง ์ฌ์ ๋ก๋ |
| SEO | ?lang=en ์ฟผ๋ฆฌ ์คํธ๋ง ์ฌ์ฉ | /en ์๋ธ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ ์ฌ์ฉ |
๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
Q1. App Router์์ I18n์ ๊ตฌํํ ๋ URL์ ์ธ์ด ์ฝ๋(/ko, /en)๋ฅผ ํฌํจํ๋ ๊ฐ์ฅ ํฐ ์ด์ ๋?
- A) ์๋ฒ ์บ์๋ฅผ ๋ ๋ง์ด ์ฐ๊ธฐ ์ํด์
- B) ๋ธ๋ผ์ฐ์ ์ ๋ค๋ก ๊ฐ๊ธฐ ๊ธฐ๋ฅ์ ์ง์ํ๊ธฐ ์ํด
- C) ๊ฒ์ ์์ง(Google)์ด ์ธ์ด๋ณ ํ์ด์ง๋ฅผ ๊ฐ๊ฐ ์ธ์ํ๊ฒ ํ๊ธฐ ์ํด
- D) ๋งํฌ๋ค์ด ํ์์ ์งํค๊ธฐ ์ํด์
โ ์ ๋ต: C
ํด์ค: URL์ด ๋ค๋ฅด๋ฉด ๊ฒ์ ์์ง์ ์์ ๋ค๋ฅธ ํ์ด์ง๋ก ๋ด. ๊ทธ๋์ผ ์๋ฏธ๊ถ ์ ์ ๊ฐ ๊ตฌ๊ธ๋งํ์ ๋ ์ฐ๋ฆฌ ์ปค๋ฎค๋ํฐ์ ์์ด ๋ฒ์ ์ด ๊ฒฐ๊ณผ์ ๋จ๊ฒ ๋ผ.
Q2. getDictionary ํจ์์์ import('server-only')๋ฅผ ์ฌ์ฉํ๋ ์ด์ ๋?
- A) ๋น๋ ์๋๋ฅผ ๋น ๋ฅด๊ฒ ํ๋ ค๊ณ
- B) ๋ฒ์ญ ์ฌ์ (JSON) ํ์ผ์ด ํด๋ผ์ด์ธํธ ์๋ฐ์คํฌ๋ฆฝํธ ๋ฒ๋ค์ ํฌํจ๋์ง ์๊ฒ ํ๊ธฐ ์ํด
- C) JSON ํ์ผ์ XML๋ก ๋ณํํ๋ ค๊ณ
- D) ์ด๋ชจ์ง๋ฅผ ๋ ๋ง์ด ์ฐ๋ ค๊ณ
โ ์ ๋ต: B
ํด์ค: ๋ฒ์ญ ์ฌ์ ์ ์ฉ๋์ด ํด ์ ์์ด.server-only๋ฅผ ์ฐ๊ณ ์๋ฒ ์ปดํฌ๋ํธ์์๋ง ๋ก๋ํ๋ฉด, ๋ฒ์ญ ํ ์คํธ๋ ๊ฒฐ๊ณผ HTML์๋ง ํฌํจ๋๊ณ ๋ฌด๊ฑฐ์ด ์๋ณธ JSON ํ์ผ์ ๋ธ๋ผ์ฐ์ ๋ก ์ ์ก๋์ง ์์.
๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
์ค๋์ ์ ๋ง ์ฐ๋ฆฌ ์ปค๋ฎค๋ํฐ๊ฐ ์ ์ธ๊ณ๋ก ๋ป์ด๋๊ฐ ์ ์๊ฒ ํด์ฃผ๋ 'I18n & Localization' ์ ๋ฐฐ์ฐ๋ฉด์ ์ค๋ ๋ ๋ง์์ ๊ฐ์ถ ์ ์์์ด! ๊ทธ๋์ ๋จ์ํ "ํ ์คํธ๋ง ๋ฐ๊พธ๋ฉด ๋๊ฒ ์ง" ๋ผ๊ณ ์๊ฐํ๋๋ฐ, URL ์ค๊ณ๋ถํฐ ์๋ฒ ์ฌ์ด๋์์์ ํจ์จ์ ์ธ ๋ฒ์ญ ๋ก๋๊น์ง... ๊ธ๋ก๋ฒ ์๋น์ค๋ฅผ ๋ง๋๋ ๊ฒ ์ผ๋ง๋ ์ธ์ฌํ ์์ ์ธ์ง ๊นจ๋ฌ์์ด.
๐ก ์ค๋์ ๊ตํ: "์๋น์ค์ ํ์ฅ์ฑ์ ์ฝ๋๋ฟ๋ง ์๋๋ผ ์ธ์ด์ ๋ฌธํ์์๋ ์์๋๋ค. URL์ '์ง์ค์ ๊ทผ์' ์ผ๋ก ์ผ์ ๊ฒ์ ์์ง๊ณผ ์ ์ ๋ชจ๋์๊ฒ ์น์ ํ ๊ธ๋ก๋ฒ ์ปค๋ฎค๋ํฐ๋ฅผ ์ค๊ณํ์!"
์ํธ ๋ฆฌ๋ ๋์ด ๊ฒ์ ์์ง SEO ๋น์ ๋ฅผ ๋ค์ด ์ค๋ช ํด ์ฃผ์ค ๋, ์ ์ฟผ๋ฆฌ ์คํธ๋ง๋ณด๋ค ๊ฒฝ๋ก ๊ธฐ๋ฐ์ ์ธ์ด ๊ตฌ๋ถ์ด ์ค์ํ์ง ๋จ๋ฒ์ ์ดํด๊ฐ ๊ฐ๋๋ผ. ๋จ์ํ ๋ฒ์ญ์ ์ ๊ณตํ๋ ๊ฑธ ๋์ด, ์ ์ธ๊ณ ๋ชจ๋ ์ ์ ๊ฐ ์ฐ๋ฆฌ ์๋น์ค๋ฅผ ์๊ธฐ ์ง์ฒ๋ผ ํธ์ํ๊ฒ ๋จธ๋ฌผ ์ ์๋๋ก ๋ง๋๋ ๊ฒ ์ง์ง ์ค๋ ฅ์ด๋ผ๋ ๊ฑธ ๊นจ๋ฌ์์ด. ์ค๋ ๋๋ฌด ๊ธ๋ก๋ฒํ ์ฌ๊ณ ๋ฅผ ํ๋๋ ๋จธ๋ฆฌ๊ฐ ํ ๋์๊ฐ๋ค. ํด๊ทผ๊ธธ์ ๋ด๊ฐ ์ข์ํ๋ ์๋จ์ด ํด์ฆ ์ฑ์ด๋ผ๋ ์ข ํ๋ฉด์ ๋๋ฅผ ์ํ์ผ๊ฒ ์ด. ๋ด์ผ์ ๋ '์ธ๊ณ๋ฅผ ํ๋' ์ํคํ ์ฒ๋ฅผ ์ค๊ณํ๋ ๊ฐ๋ฐ์๊ฐ ๋ ๊ฑฐ์ผ! ๐ฃ