갑작스럽게 회사에서 제작중인 사이트의 영문 번역버전이 필요하다는 요청을 받았다.
이전 react-native에서 한번 i18next를 해봤기 때문에 쉽게 할 수 있으리라 생각했던 부분이었으나, nextjs의 app router로 변경된 이후
SEO등등을 위해서라도 app router 기반의 Internationalization을 공식 사이트에서도 권장하는 분위기로 보여서 조금 다르게 접근할 필요가 있었다.
물론 CSR을 기반으로 하여, 브라우저의 web API인 navigator.language로 사용자의 선호 언어를 확인한 후 locale을 변경하는 것도 충분히 가능하다.
function useLocaleState() {
const language = navigator.language as LocaleType;
const [locale, setLocale] = useState<LocaleType>(language === 'ko-KR' ? 'ko' : language);
const handleLocale = (locale: LocaleType) => setLocale(locale);
return { locale, handleLocale };
}
처음에는 그런식으로 구현을 하였지만 몇가지의 이유로 인해 app routing방식을 사용하기로 마음먹었다.
Nextjs가 서버사이드 랜더링이 가능하고, 사용자의 요청이 들어왔을 때 미리 서버에서부터 locale을 정의한 후 필요한 정적 랜더링 텍스트를 보내주는 것이 더 효율적이라 판단하였다. nextjs는 기본적으로 서버사이드 랜더링을 먼저 진행하기 때문에, hydration 이후에 web API 를 이용하여 로케일을 변경하는 것은 효율적이지 못하다.
새로고침을 할 경우 로케일에 대한 정보를 어딘가에서 "저장" 하고, 이것을 추출하여 사용할 수 있는 구조가 필요로해지게 되는데 굳이 브라우저 기반의 스토리지들을 이용하기 보다는 url path 그 자체에 로케일 정보를 담아두게 하는 것이 불필요한 스토리지 사용을 막고, 더 직관적인 라우팅이 가능해진다고 판단하였다.
이런 이유로 app router을 이용해 국제화를 진행하기로 마음먹었다.
생각보다 app router 기반으로 작성된 글이 많이 없기도 했고, 이미 존재하는 프로젝트에 마이그레이션하느라 우여곡절이 조금 있었기 때문에 필자와 같은 고통을 다음 사람은 겪지 않기를 바라는 마음으로 글을 남기려고 한다. ( 그리고 nextjs 야 공식문서 업데이트좀 해줘... )
현재 Nextjs가 밀고 있는 app routing 기반 국제화는 Sub-path Routing로 보인다.
Sub-path Routing의 구조는 간단하게 말해서, 언어 코드를 url의 path에 정의하는 형식을 뜻한다.
https://test.develop.com/ko 에서 /ko 의 부분같이 말이다.
하는 방법은 아주 친절하게 공식문서에 설명하고 있으니, 순서대로 따라가보기로 한다.
참고로, 구글링으로 바로 나오는 nextjs internalization에서 사용하는 nextjs config 기반 i18n 프로퍼티는 page route 기반으로 동작하는 친구다. app route 기반으로도 되면 좋겠다 싶어서 시도해봤는데 뭔가 잘 작동하지 않았으므로 참고하면 좋을 것 같다.
Sub-path routing의 기본적인 컨셉은 middleware을 통해 모든 페이지 요청에 대하여 중간에 locale path를 붙여주고, Dynamic routes에서 이를 받아서 처리하는 구조로 되어있다.
미들웨어의 기본적인 구조 예시는 아래와 같다.
일단 서버사이드에서 로케일을 미리 정의해주기 위한 미들웨어를 설정한다.
Nextjs의 예약파일인 middleware.ts는 루트에 위치해있고, 모든 서버 요청에 대해서 중간에 가로채서 특정한 작업을 할 수 있게 해준다.
만약 미들웨어에 대한 이해가 필요하다면 미들웨어 대한 공식문서 정의를 참조하면 좋다.
현재 이미 middleware를 사용중이어서 마이그레이션이 필요한 상황이었다. 결론적인 구조는 아래와 같다.
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-session';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import { i18n } from '@/locales/i18n';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const PUBLIC_FILE = /\.(png|jpg|jpeg|gif|svg|css|js)$/i;
if (PUBLIC_FILE.test(pathname)) {
return NextResponse.next(); // 정적 자원의 요청에 대해서는 그냥 그대로 요청을 처리하게 한다.
}
//1. 만약 로케일이 없는 주소로 왔을 경우, 유저 선호 로케일로 리다이렉트
const pathnameHasLocale = i18n.locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
if (!pathnameHasLocale) return localeRedirect(request);
//2. session에 있는 권한에 따른 리다이렉트
const session = await getServerSession();
const isPathAuthOnly = checkPathInclusion(pathname, 'AUTH');
const isPathAdminOnly = checkPathInclusion(pathname, 'ADMIN_ONLY');
if (!session) {
if (isPathAuthOnly || isPathAdminOnly) {
return NextResponse.redirect(new URL('/signin', request.url));
}
}
if (session) {
const isNotAdmin = session.authorityId !== 1;
if (isPathAdminOnly && isNotAdmin) {
return NextResponse.redirect(new URL('/', request.url));
}
}
return NextResponse.next();
}
/****************************************************************************************
*@Constants
****************************************************************************************/
export const config = {
matcher: ['/((?!api|_next/static|_next/image|\\.png|favicon\\.ico).*)'],
};
const LOCALE_PREFIX = '^/[^/]+';
const localePath = (path: string) => new RegExp(`${LOCALE_PREFIX}/${path}`);
const MATCH_ROUTES = {
/** 누구나 접근 가능
* 하지만 로그인 한 상태라면 "/" 페이지로 라우팅
*/
OPEN: [localePath('signin$'), localePath('signup$'), localePath('find-password$')],
/** 로그인된 사용자만 접근 가능 */
AUTH: [
localePath('authority(/.+)?$'),
localePath('projects(/.+)?$'),
localePath('residential(/.+)?$'),
localePath('commercial(/.+)?$'),
localePath('delete-account$'),
],
/** 관리자만 접근 가능 */
ADMIN_ONLY: [localePath('admin/member$'), localePath('admin/expired-member$')],
};
/****************************************************************************************
*@helpers
****************************************************************************************/
function checkPathInclusion(pathname: string, matchRoutesKey: keyof typeof MATCH_ROUTES) {
return MATCH_ROUTES[matchRoutesKey].some(pattern => pattern.test(pathname));
}
function getUserPreferenceLocale(request: NextRequest): string | undefined {
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
const locales = i18n.locales;
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
const userPreferenceLocale = match(languages, locales, i18n.defaultLocale);
return userPreferenceLocale;
}
function localeRedirect(request: NextRequest) {
const userPreferenceLocale = getUserPreferenceLocale(request);
const currentPathname = request.nextUrl.pathname;
request.nextUrl.pathname = `/${userPreferenceLocale}${currentPathname}`;
return NextResponse.redirect(request.nextUrl);
}
미들웨어 구조는 간단하게 path 부분에 locale 부분이 들어가 있지 않은 경우, getUserPreferenceLocale 함수를 통해 현재 페이지 요청을 한 유저의 langauge와 우리 어플리케이션이 지원하려고 하는 langauge를 비교해서 가장 최적의 locale을 판단한 후, 이것을 path에 prefix로 붙여서 리다이렉트 시키게 되어있다.
앱에서 지원하게 될 로케일과 관련한 정보는 어디에다가 저장해놓고 재활용해도 상관없지만 필자는 편의상, 그리고 대체적인 international library에서 보여주는 폴더 구조를 따라 src/locales/i18n.ts 에 저장하여 사용하였다.
그리고 번역 데이터는 추후 "google-spreadsheet에서 연동하여 사용할 때에, 각 탭에 따른 json 데이터를 모아서 하나의 "언어.json" 형태로 사용할 예정이라 아래와 같은 구조를 띄게 해두었다.
import en from './en/en.json';
import ko from './ko/ko.json';
export type I18n = typeof i18n;
export type Locales = I18n['locales'];
export type Locale = Locales extends readonly (infer L)[] ? L : never;
export type Messages = I18n['messages'];
export type MessageMap = I18n['messages'][Locale];
export type SheetTab = keyof MessageMap;
export type MessageId<S extends SheetTab> = keyof MessageMap[S];
export const i18n = {
locales: ['ko', 'en'],
defaultLocale: 'ko',
messages: {
en,
ko,
},
} as const;
이 때에, langauge에 대한 Dynamic Routing을 지원하기 위해 대괄호([]) 로 이루어진 폴더를 만들도록 한다.
이제 우리의 모든 요청은 Dynamic Routing을 통해서 layout의 params에 해당 값이 들어오게 된다.
참고로 user preference language를 변경해본 후 테스트하고싶다면, 크롬 브라우저 기준
설정 => 언어 => 기본언어를 원하는 언어로 우선순위를 설정하면 된다.
⚠️만약 nextjs에서 제공하는 기본 예약어 파일들 (not-found) 등을 사용하고 싶다면, 무조건 루트 레벨에 해당 파일이 있어야 하는데
해당 파일들은 기본으로 page.tsx, layout.tsx를 요구하는 경우가 많아서 큰 이유가 없다면 해당 파일을 같이 두어야 한다.
단, layout 예약어 파일은 기본적으로 nested 구조를 띄기 때문에, 불필요하게 [locale] 폴더 내의 layout에 "html" 및 "body" 태그를 삽입하지 않도록 한다.
이제 로케일을 확인하였으니, 다음 남은 일은 해당 locale을 기반으로 필요한 번역 데이터를 내려주는 작업을 해야 한다.
즉, context provider을 기반으로 해당 데이터셋을 자식들에게 전달해줄 수 있는 방법이 필요하다.
직접 구현을 해도 좋겠지만, 굳이 바퀴를 처음부터 만들 필요는 없을 것이다.
공식적으로 추천해주는 라이브러리들이 많은데, 이 중에서 react-intl 을 이용하기로 마음먹었다.
사실 라이브러리들을 분석해보니 다 비슷비슷한 기능을 가지고 있는데, 아무래도 colaborater들 수도 많고, git star을 받은 수가 제일 많아서 (믿음직스러워서) 선택하게 되었다. 요즘 공부하고 있는 Rust를 사용하고 있다는 점도 마음에 들었다.
쉽게 사용하게 만들어졌다. 오로지 해야하는 일은 provider을 통해서 locale과 번역 데이터를 전달하고,
자식들에서는 라이브러리에서 제공하는 컴포넌트를 활용해서 필요한 text를 랜더링하는 것으로 international 적용이 완료된다.
(필자는 결론적으로 번역 데이터 부분은 내려줄 필요가 없었기에 그냥 locale 정보만 context Value로 적용하였다)
import { PropsWithChildren } from 'react';
import { Locale } from '@/locales';
import { IntlProvider } from '@/providers/provider-group';
interface RootLayoutProps {
params: {
locale: Locale;
};
}
export default function RootLayout({
children,
params,
}: Readonly<PropsWithChildren<RootLayoutProps>>) {
return <IntlProvider locale={params?.locale}>{children}</IntlProvider>;
}
contextAPI 로 생각하면 쉽게 이해가 될 것이다. 특정 locale과, 번역 json 데이터인 messages를 자식에게 보내는 구조이다.
사실, 이렇게 provider을 작성한 후, 자식 컴포넌트에서 공식문서에 요구하는 대로 FormattedMessage 컴포넌트를 이용하면 손쉽게 텍스트 번역이 적용 가능하다.
// 해당 컴포넌트 내부에는 useContext를 활용하고 있다.
<FormattedMessage
id="myMessage"
defaultMessage="Today is {ts, date, ::yyyyMMdd}"
values={{ts: Date.now()}}
/>
그런데, 개인적으로 공식문서 그대로 사용하는 것은 조금 여러모로 문제가 있다고 생각했는데 그 이유는
1. 굳이 텍스트 element 교체를 위해 선언형 컴포넌트를 사용하는 것은 과하게 이름이 길다. (일반적인 i18n처럼 "t(...) 형태로 쓰고싶음)
2. id에 대해서 타입 정의가 되지 않는다. (json key가 어떤것인지 확인이 안된다)
3. 제일 큰 문제는 react-intl이 nested json을 지원하지 않는다.
공식문서에 따르면 nested한 json 데이터를 쓰고싶으면 flatten이 되어있는 json 번역 데이터를 사용하라고 하는데, 이는 이미 존재하는 데이터를 다시한번 flatten 하기 위한 불필요한 절차가 한번 더 들어가는 것이며, 심지어 사용하는 장소에서조차 가독성이 몹시 좋지 않았다.
function flattenMessages(nestedMessages, prefix = '') {
return Object.keys(nestedMessages).reduce((messages, key) => {
let value = nestedMessages[key]
let prefixedKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'string') {
messages[prefixedKey] = value
} else {
Object.assign(messages, flattenMessages(value, prefixedKey))
}
return messages
}, {})
}
let messages = flattenMessages(nestedMessages) // 이렇게 해당 재귀함수로 json 모듈을 평탄화한다.
...
<p>
<FormattedMessage
id='propertyId.targetId' // 사용하는 쪽에서는 (.) 으로 평탄화된 아이디를 사용한다.
/>
</p>
버전이 올라갔어도 여전히 nested object의 평탄화 관련된 업데이트는 찾아볼 수가 없어서, 그냥 직접 필요한 형태를 구현하기로 했다.
다행히, react-intl의 github 소스코드들을 살펴보면 그다지 어렵지 않고 대부분의 내용이 기본적인 react의 contextAPI를 활용하고 있으므로,
안심하고 필요한 내용을 구현하면 되었다.
필자가 원하는 사용방식은 아래와 같았다.
const { t } = useTranslations('sheetTab')
...
<div>
{t('messageId')}
<div>
내가 사용하고 싶은 custom hook인 "useTranslations" 은 아래와 같이 정의하였다.
import {
MessageDescriptor,
ResolvedIntlConfig,
createIntlCache,
createIntl,
useIntl,
} from 'react-intl';
import { MessageId, SheetTab } from '@/locales';
const intlCache = createIntlCache();
export function useTranslations<S extends SheetTab>(sheetTab: S) {
const currentIntl = useIntl(); // 현재 전역 provider로 제공되는 intl instance를 가져온다.
const totalMessages = currentIntl.messages;
const targetMessages = totalMessages[sheetTab] as unknown as ResolvedIntlConfig['messages']; // sheet tab 기반으로 객체가 nested 되어있기 때문에 타겟이 되는 tab의 messages를 조회한다.
const newIntl = createIntl({ ...currentIntl, messages: targetMessages }, intlCache); // targetMessages를 기반으로 하는 새 intl instance를 생성한다.
// t 함수는 message id에 대한 타입이 정의된 $t의 래핑 함수이다.
// 내부적으로는 target message 기반으로 생성된 intl instance 기반으로 호출이 되게 된다.
const t = (id: MessageId<S>, ...tail: Tail<Parameters<typeof newIntl.$t>>) => {
const descriptor = { id, defaultMessage: '' } as MessageDescriptor;
return newIntl.$t(descriptor, ...tail);
};
return { t };
}
이렇게 정의할 경우, 의도했던 대로 json의 타입도 자동완성이 되면서 번역이 되는 것을 확인할 수 있었다.
자, 여기까지 오면 이제 번역이 잘 되니까 "유후 국제화 완전 쉬워버린 것인가?" 하면서 좋아할 수 있었을텐데,
실제로 이것을 브랜치에 업데이트 한 후 개발단계에 들어가려고 하니까 심각한 문제가 하나 발생했고, 앞으로도 발생할 것이라는 사실을 깨닫게 되었다.
현재 구조는 app router의 sub-path에 로케일 정보를 저장하는 형식으로 국제화를 진행하였다.
여기까지는 문제가 없는데 문제는 개발자가 routing 관련 함수를 작성할 때이다.
nextjs에 익숙해진 개발자라면 너무나 당연스럽게 라우팅을 "useRouter" 훅을 사용해서 하는 것이 일반적일 것이다.
혹은 link를 통해서 서버에 페이지를 요청하여 새로운 페이지를 랜더링하는 경우도 있을 수 있다.
문제는 이동하려고 하는 경로를 작성할 때 보통 locale path가 없는 상황에서 개발을 하기 때문에 해당 부분을 누락한 path를 입력하는 것이 당연하다는 것이었다.
const router = useRouter();
...
const handleClickTab = (key: string) => {
router.push(`/admin`);
}; // 이렇게 될 경우, request는 "/admin" 으로 이동하는 것이 된다.
이를 막기 위해서 미들웨어에서 해당 locale을 붙여서 이동시키는 방식을 생각해보긴 했지만
middleware의 인자로 전달되는 request의 url에는 오로지 "/admin" 에 대한 요청만 있지, locale에 대한 path는 누락된 채로 인자에 전달받기 때문에 어떤 언어로 dynamic routing을 해야하는지에 대한 재료가 존재하지 않는다.
결론적으로 말하자면, 개발자가 라우팅 관련한 함수를 작성하려고 할 때에는 필연적으로 locale path를 안에다가 삽입하는 형식으로 개발을 진행해야 한다는 소리가 된다.
실제로도 vercel i18n example 내용을 살펴보면 경로 이동에 직접적으로 locale을 설정하는 방식을 통해 이동하라는 식으로 작성이 되어있다.
이외에 다른 많은 블로그들도 전부 라우팅을 할 때에는 경로에 locale을 붙여서 이동하는 방식으로 작성되어 있었다.
하지만, 이런 방식은 100%, 아니 200% 개발자에 의한 휴먼 에러를 야기하는 위험성을 내포하고 있다.
아무리 컨벤션으로 정해놓는다 하더라도 어느 순간 정신없이 개발을 하다 보면 locale string을 넣는다는 생각을 잊어버리게 된다.
그러므로 인해서 당연히 locale의 정합성이 무너지게 되는 상황이 발생하게 된다.
<ul className='flex gap-x-8'>
<li>
<Link href={`/${lang}`}>{navigation.home}</Link>
</li>
<li>
// 매번 이런식으로 locale 작성을 강요하는 것은 언제든지 정합성이 무너지는 위험성을 내포한다.
<Link href={`/${lang}/about`}>{navigation.about}</Link>
</li>
</ul>
그렇다면, locale을 삽입해주는 커스텀 라우팅 함수를 작성하고 이것을 늘 사용하라고 컨벤션으로 정하면 해결될까?
1차적인 문제는 해결될지 몰라도 기존에 사용하고 있던 라우팅을 모두 다 커스텀 라우팅 함수로 변경하는 작업은
이런 문제를 해결하기 위해 고민해본 결과, 가장 좋고 효과적인 마이그레이션은 path alias를 활용해 nextjs 의 라우팅 모듈을 재정의하고 타입으로 지정하는 것 이라는 판단을 하게 되었다.
하는 방법은 몹시 간단하다.
next.config.mjs에는 nextjs의 webpack 설정을 추가적으로 할 수 있는 방법을 지원해준다.
기본적인 속성은 webpack에 있는 내용과 동일하기 때문에 우리는 여기서 resolve 속성을 사용할 예정이다.
nextjs 내부에 내장되어 있는 webpack 번들러에게 어떤 특정한 "alias" 가 특정 "module"을 가리킨다는 것을 정의한다.
필자는 아래와 같은 방식으로 설정하였다.
// next.config.mjs
const nextConfig = {
...
webpack: config => {
config.resolve.alias = {
...config.resolve.alias,
'next/navigationOrigin': path.resolve(process.cwd(), 'node_modules/next/navigation'),
'next/navigation': path.resolve(process.cwd(), 'src/libs/next/navigation'),
'next/LinkOrigin': path.resolve(process.cwd(), 'node_modules/next/link'),
'next/link': path.resolve(process.cwd(), 'src/libs/next/link'),
};
return config;
},
};
원래 "next/navigation" 모듈 alias를 "next/navigationOrigin"으로, "next/link" 모듈 alisa를 "next/LinkOrigin" 로 설정해주고, 내가 커스터마이징하여 작성한 모듈이 있는 장소를 해당 원래 경로의 alias로 설정해주었다.
현재 프로젝트에서는 타입스크립트를 사용하고 있으므로, 해당 경로가 타입스크립트가 알고 있는 경로라는 것을 정의해주기 위해서 "tsconfig.json"에 해당 경로들을 path로 등록해주도록 한다.
{
"compilerOptions": {
"paths": {
"next/navigationOrigin": ["./node_modules/next/navigation"],
"next/navigation": ["./src/libs/next/navigation"],
"next/LinkOrigin": ["./node_modules/next/link"],
"next/link": ["./src/libs/next/link"]
}
},
}
이렇게 하면 이제 기존에 nextjs 모듈을 import하던 코드들은 전부 수정할 필요 없이, 모든 경로를 내 커스터마이징한 모듈의 경로로 연동시킬 수 있다.
이후, 해당 경로에 커스터마이징한 라우팅 모듈 코드를 작성하여주기만 하면 된다.
1) useLocale (로케일을 효율적으로 처리하기 위한 훅)
import { usePathname } from 'next/navigation';
import { i18n, Locale } from '@/locales';
export default function useLocale() {
const pathname = usePathname();
const localeMatch = pathname.match(/^\/([^/]+)/); // pathname의 시작점에에 있는 locale 부분을 추출한다.
const locale = (localeMatch?.[1] as Locale) ?? i18n.defaultLocale;
const addLocaleToHref = (href: string) => { // 현재 인자로 들어온 경로에 locale이 없으면 붙이고 있으면 그대로 리턴.
if (!href.startsWith(`/${locale}`)) {
return `/${locale}${href}`;
}
return href;
};
return { pathname, localeMatch, locale, addLocaleToHref };
}
2) next/link 커스터마이징
// libs/next/link/index.ts
import Link from './Link';
export * from 'next/LinkOrigin';
export default Link;
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// libs/next/link/Link.tsx
import { forwardRef } from 'react';
import NextLink from 'next/LinkOrigin';
import useLocale from '@/hooks/use-locale';
const Link = forwardRef<HTMLAnchorElement, React.ComponentProps<typeof NextLink>>((props, ref) => {
const { addLocaleToHref } = useLocale();
const { href, ...rest } = props;
const _href = href instanceof URL ? href : addLocaleToHref(href as string);
return <NextLink href={_href} {...rest} ref={ref} />;
});
export default Link;
3) next/navigation 커스터마이징
// libs/next/navigation/index.ts
export * from 'next/navigationOrigin';
export { useRouter } from './navigation'; // 모듈시스템에서 나중에 export되는 맴버가 기존 맴버를 덮어쓰게 된다.
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// libs/next/navigation/navigation.ts
import {
NavigateOptions,
PrefetchOptions,
} from 'next/dist/shared/lib/app-router-context.shared-runtime';
import { useRouter as nextUseRouter } from 'next/navigationOrigin';
import useLocale from '@/hooks/use-locale';
const useRouter = () => {
const router = nextUseRouter();
const { addLocaleToHref } = useLocale();
const customPush = (href: string, options?: NavigateOptions) => {
router.push(addLocaleToHref(href), options);
};
const customReplace = (href: string, options?: NavigateOptions) => {
router.replace(addLocaleToHref(href), options);
};
const customPrefetch = (href: string, options?: PrefetchOptions) => {
router.prefetch(addLocaleToHref(href), options);
};
return {
...router,
push: customPush,
replace: customReplace,
prefetch: customPrefetch,
};
};
export { useRouter };
위의 코드도 몹시 간단하다. 기존에 있던 nextjs 모듈을 위에서 정의했던 모듈 alias로 들고온 후, 정의할 필요가 없는 함수들은 그대로 내보내고 로케일 설정이 필요한 함수들은 재정의하여 내보내주기만 하면 되었다.
위와 같은 설정을 통해서 기존 개발자들은 자신이 익숙했던 라우팅 처리 방식을 그대로 이용하면서도 locale에 대한 정합성이 유지되는 형태로 휴먼 에러가 제거된 개발을 진행할 수 있게 되었다.
생각보다 app router을 기반으로 하는 i18n 설정의 내용이 없고, 이미 진행이 되고있는 프로젝트에 마이그레이션하는 방법은 더더욱 없어서 하나하나 깨져가며 설정하느라 시간이 조금 걸렸었다.
그래도 이 기회를 통해서 DX 친화적인 개발을 하기 위한 사고가 더 늘어난 것 같아서 조금 뿌듯하고
혹시 추후에 어떤 사람이 app router 기반으로 국제화를 했을 때에 필자가 겪었던 고민은 겪지 않기를 바라는 마음으로 글을 남긴다.
다음 글에는 google spreadsheet를 활용하여 번역 데이터를 자동화하는 내용에 대해서 작성해야겠다.