Next.js Git Blog (7): 국제화(1) - 프로젝트 구조 설계 및 라우팅

목차

소개#

아무래도 개발의 영역은 한 국가에 국한되지 않고 세계적으로 통용되다 보니, 국제화 기능을 넣으면 좋을 것 같기는 한데... 으음 이 부분이 정말 고민이 많이 됐다.

실제로 나 역시 영어로 된 문서도 많이 참고하고 있으니, 내가 공유하는 내용도 언어에 국한되지 않았으면 하는 바람은 있는데 단순히 생각했을 때 아래 문제들이 좀 걸렸다.

  • 나는 작문을 잘 못한다. 한국말도 가끔 꼬이는데, 영어로 표현하는 건 더 어렵지 않을까 싶다.
  • 관리가 번거로워지는 것이 눈에 훤하다. 영문으로 작성된 같은 글이 물리적으로 또 필요하고 그만큼 관리 포인트가 늘어날 것이다.

그렇지만 보통 이렇게 생각을 가져버리면 결국 실행해보는 것 같다. 영어 공부를 글도 쓰면서 편하게 한다고 생각하면서, 한 번 진행해보고자 한다.

그리고 나중에 너무 피곤해지면 슬쩍 기능을 꺼버리자👍


구현 계획#

시작에 앞서 먼저 어떻게 구현할지 계획을 해보자.

  1. 사용자 언어 감지: 블로그에 처음 접근한 사용자의 브라우저 언어 설정을 파악해 초기 언어를 설정하자.
  2. 설정 제어 및 저장: 대부분의 사이트처럼 상단에 언어 토글 버튼을 구현하고 설정값은 localStorage에 저장해 재방문 시에도 유지되도록 구현해보자.
  3. URL 구조 설계: 언어별 마크다운 파일 이름은 동일하게 유지하되, URL에 /en/ 같은 prefix를 붙여 분기를 나누어 보여주자.
  4. 자동 리다이렉트: localStorage에 설정값이 있다면 해당 언어로, 없다면 브라우저 기본 언어를 토대로 적절하게 리다이렉트하자.
  5. 무결성 검증: 영문 마크다운 파일이 누락될 경우를 생각해볼 수 있는데, 빌드 시점에 언어별 파일 쌍이 존재하는지 비교하고 하나라도 빠졌다면 빌드 자체가 실패하도록 유도해서 이를 방지해보고자 한다.

URL로 분기를 하지 않고, 설정값에 따라 동적으로 마크다운 파일을 불러오는 방식도 고려해볼 수 있는데,
SEO를 고려한다면 URL로 직접 분기하는 것이 더 좋을 것 같다.


구현#

최종적으로 계획한 URL 구조는 다음과 같다.

  • 한국어: htbae.gitblog.io/ko/blog/recent
  • 영어: htbae.gitblog.io/en/blog/recent

이를 고려해서 진행해보자.

1. 프로젝트 구조 변경

기존의 Next.js Pages Router 구조에 언어 설정을 담당할 [lang] 디렉토리를 추가하여 계층을 한 단계 확장해야한다.

  • 기존 구조
    단일 언어를 기준으로 섹션과 상세 페이지가 구성되어 있다.
pages
├── [section]
│   ├── [slug].tsx
│   └── index.tsx
└── index.tsx
  • 새 구조
    최상위에 [lang] 동적 경로를 추가하여, 동일한 로직 내에서 콘텐츠를 분기 처리할 수 있도록 하자.
pages
├── [lang]
│   ├── [section]
│   │   ├── [slug].tsx
│   │   └── index.tsx
│   └── index.tsx
└── index.tsx

2. 동적 경로 로직 수정

폴더 구조가 깊어짐에 따라, 빌드 시점에 경로를 생성하는 getStaticPaths와 데이터를 가져오는 getStaticProps 로직에도 변화가 필요하다. 가장 하위 페이지인 [slug].tsx를 예로 들어보자

// pages/[lang]/[section]/[slug].tsx
 
const LANGS = ['ko', 'en'];
 
export const getStaticPaths: GetStaticPaths = async () => {
	const sections = getAllSections();
 
	// 언어(LANGS)와 섹션, 슬러그를 조합하여 3중 중첩 경로를 생성한다.
	const paths = LANGS.flatMap((lang) =>
		sections.flatMap((section) =>
			getAllPostIds({ section }).map(({ slug }) => ({
				params: { lang, section, slug }, // lang 추가
			})),
		),
	);
 
	return {
		paths,
		fallback: false, // 생성되지 않은 경로는 404 처리
	};
};

경로 생성뿐만 아니라, 컴포넌트 내부에서 네비게이션이나 링크 이동을 처리할 때도 현재 언어 정보가 필요해졌다. 따라서 getStaticProps에서 lang 값을 추출하고 props로 넘겨주도록 수정했다.

// pages/[lang]/[section]/[slug].tsx
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
	const lang = params?.lang as string;
	const section = params?.section as string;
	const slug = params?.slug as string;
 
	if (!lang || !section || !slug) return { notFound: true };
 
	try {
      ...
 
		return {
			props: { lang, section, slug },
		};
	} catch (err) {
		console.error(`[SlugPage] Error loading ${section}/${slug}:`, err);
		return { notFound: true };
	}
};
 

3. 루트 리다이렉트 처리

기존의 pages/index.tsx는 이제 pages/[lang]/index.tsx로 옮겨져 언어별 메인 페이지 역할을 하도록 변경했다. 이제 비어있는 루트 경로(/)에 접속했을 때, 설정이나 환경에 맞춰 적절한 언어 페이지로 리다이렉트하는 로직을 구현해보자.

// pages/index.tsx
 
const RootPage = () => {
	const router = useRouter();
 
	useEffect(() => {
		// 저장된 설정값 확인
		const savedLang = localStorage.getItem('lang') as LangType;
 
		// 브라우저 기본 언어 감지 (한국어면 ko, 그 외엔 en)
		const browserLang: LangType = navigator.language.startsWith('ko')
			? 'ko'
			: 'en';
 
		const targetLang = savedLang || browserLang;
 
		// 결정된 페이지로 이동
		router.replace(`/${targetLang}`);
	}, [router]);
 
	return null;
};

마무리#

여기까지 pages/[lang]/... 구조로 변경이 끝났다. 이제 사용자가 접속했을 때 언어별로 독립된 URL을 가지게 되었고, 동적 라우팅을 통해 각 언어에 맞는 글을 보여줄 준비를 마쳤다.

다음 글에서는 사용자가 직접 언어를 전환할 수 있는 '언어 설정 토글 버튼' 을 구현할 것이다. 그리고 localStoage에 저장된 설정값에 따라 적절한 마크다운 파일을 매칭하여 불러오는 로직을 추가하여 국제화 기능 구현을 마무리하도록 하겠다.