Logo
Published on
·14 min read

Next.js - 블로그 다국어(i18n)로 만들기 Part 2

개요

Next.js - 블로그 다국어(i18n)로 만들기 Part 1 포스팅에서 블로그 글을 제외한 사이트에 대한 다국어 처리를 했습니다.

블로그 글의 경우 .md(x) 파일을 읽어서 글을 표시합니다. 앞선 내용의 {t('key')}처럼 관리할 수 없다는 것입니다.

선택된 언어에 맞게 등록된 글을 표시하고 글 목록, 태그 목록 등을 달리 표시해야 합니다.

내용이 길어져 Part 1 ~ 4 로 나누어서 작성합니다.

Part 2에서는 동적인 블로그 글들을 선택된 언어에 맞게 표시하는 방법을 알아봅니다.


gitHub, i18n-tailwind-nextjs-starter-blog를 참고하여 만들었습니다.

Tailwind CSS Blog Starter 템플릿 사용을 결정하셨고, 프로젝트를 처음 시작한다면 위 프로젝트로 시작하는 것도 좋을 것 같습니다.

제 경우 이미 프로젝트가 진행 중이라서, 위 프로젝트를 참고하여 진행했습니다.

파일명으로 언어 구분

  • 기본 언어(ko)의 경우 기존 파일명과 동일하게 만들면 됩니다.
    (예: nextjs-blog-multi-language-part2.mdx)
  • 다른 언어(en)의 경우 기본 언어 파일명 뒤에 .{locale}을 붙여서 사용합니다.
    (예: nextjs-blog-multi-language-part2.en.mdx)

공통 함수 수정

글 목록을 가져오거나, slug에 따라 글 내용을 가져오는 함수 등 공통으로 사용되는 함수들을 수정합니다.

lib/mdx.js

  • filterLocaleFiles 추가
    • 파일 목록 중 선택된 언어와 일치하는 파일만 필터링 합니다.
    • defaultLocale일 경우 .{locale}이 붙지 않은 파일만 필터링 합니다.
// 언어별 파일 필터링
function filterLocaleFiles(files, locale, defaultLocale, locales) {
  let filterFiles = files

  if (locales && defaultLocale && locales) {
    filterFiles =
      locale !== defaultLocale
        ? files.filter((path) => path.includes(`.${locale}.`))
        : files.filter((path) => !locales.some((locale) => path.includes(`.${locale}.`)))
  }

  return filterFiles
}
  • getFiles 수정
    • 위에서 추가한 filterLocaleFiles를 사용하여 현재 언어에 맞는 파일만 필터링 합니다.
export function getFiles(type, locale, defaultLocale, locales) { // locale, defaultLocale, locales 추가
  const prefixPaths = path.join(root, 'data', type)
  const files = getAllFilesRecursively(prefixPaths)

  const filterFiles = filterLocaleFiles(files, locale, defaultLocale, locales) // 추가

  return filterFiles.map((file) => file.slice(prefixPaths.length + 1).replace(/\\/g, '/')) // files -> filterFiles로 변경
}
  • formatSlug 수정
    • slug(파일명)에 포함된 locale(예:.en)을 제거합니다.
export function formatSlug(slug, locales = null) {
  let result = slug.replace(/\.(mdx|md)/, '')

  // 추가: slug에서 locale 제거
  if (locales && locales.length > 0) {
    locales.forEach((locale) => {
      result = result.replace(`.${locale}`, '')
    })
  }

  return result
}

  • getFileBySlug 수정
    • 선택된 언어와 일치하는 파일을 읽어올 수 있도록 slug에 locale을 추가합니다.

export async function getFileBySlug(type, slug, locale = null, defaultLocale = null) { //  locale = null, defaultLocale = null 추가
  // defaultLocale이 아닐경우 해당 언어의 파일을 가져오도록 수정
  const mdxPath =
    locale === defaultLocale
      ? path.join(root, 'data', type, `${slug}.mdx`)
      : path.join(root, 'data', type, `${slug}.${locale}.mdx`)
  const mdPath =
    locale === defaultLocale
      ? path.join(root, 'data', type, `${slug}.md`)
      : path.join(root, 'data', type, `${slug}.${locale}.md`)
  const source = fs.existsSync(mdxPath)
    ? fs.readFileSync(mdxPath, 'utf8')
    : fs.readFileSync(mdPath, 'utf8')

  // 그 외 변경사항 없음
}
  • getAllFilesFrontMatter 수정
    • filterLocaleFiles을 사용하여 현재 언어에 맞는 파일만 필터링 합니다.
export async function getAllFilesFrontMatter(folder, locale, defaultLocale, locales) { // locale, defaultLocale, locales 추가
  const prefixPaths = path.join(root, 'data', folder)
  const files = getAllFilesRecursively(prefixPaths)

  const filterFiles = filterLocaleFiles(files, locale, defaultLocale, locales) // 추가

  const allFrontMatter = []

  filterFiles.forEach((file) => { // filterFiles로 변경
    // ...
    if (frontmatter.draft !== true) {
      allFrontMatter.push({
        ...frontmatter,
        slug: formatSlug(fileName, locales), // formatSlug에 locales 추가
        date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
      })
    }
  })

  return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date))
}

lib/tags.js

  • getAllTags 수정
    • 필터가 적용되도록 locale, defaultLocale, locales 파라미터를 추가합니다.
export async function getAllTags(type, locale, defaultLocale, locales) { // locale, defaultLocale, locales 추가
  const files = await getFiles(type, locale, defaultLocale, locales) // locale, defaultLocale, locales 추가

  // ...
}

pages 폴더 내 파일 수정

pages/index.js

  • 필터가 적용되도록 locale, defaultLocale, locales 파라미터를 추가합니다.
export async function getStaticProps({ locale, defaultLocale, locales }) { // locale, defaultLocale, locales 추가
  const posts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // locale, defaultLocale, locales 추가

  return { props: { posts } }
}

메인 페이지 목록이 언어별로 필터링된 것을 확인할 수 있습니다.

pages/blog.js

  • 필터가 적용되도록 locale, defaultLocale, locales 파라미터를 추가합니다.
export async function getStaticProps({ locale, defaultLocale, locales }) { // defaultLocale, locales 추가
  const posts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // locale, defaultLocale, locales 추가

  // ...
}

전체 글 목록이 언어별로 필터링된 것을 확인할 수 있습니다.

pages/blog/page/[page].js

  • 동적 라우팅의 경우 getStaticPaths 부분을 모든 언어별 경로를 생성하도록 수정합니다. (path가 없으면 404 에러가 발생합니다.)
import { getAllFilesFrontMatter, getFiles } from '@/lib/mdx' // getFiles 추가

export async function getStaticPaths({ defaultLocale, locales }) { // defaultLocale, locales 추가
  // 모든 언어별 경로 생성
  const localePaths = (
    await Promise.all(
      locales.map(async (locale) => {
        // getAllFilesFrontMatter -> getFiles 로 변경 및 locale, defaultLocale, locales 추가
        const posts = await getFiles('blog', locale, defaultLocale, locales)
        const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE)
        return Array.from({ length: totalPages }, (_, i) => ({
          params: { page: (i + 1).toString() },
          locale,
        }))
      })
    )
  ).flat()

  // paths 수정
  return {
    paths: localePaths.map(({ params, locale }) => ({
      params,
      locale,
    })),
    fallback: false,
  }
}

export async function getStaticProps(context) {
  // locale, defaultLocale, locales 추가
  const {
    params: { page },
    locale,
    defaultLocale,
    locales,
  } = context
  const posts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // locale, defaultLocale, locales 추가
  // ...
}

페이징이 언어별로 생성된 것을 확인할 수 있습니다.

pages/tags.js

  • 필터가 적용되도록 locale, defaultLocale, locales 파라미터를 추가합니다.
export async function getStaticProps({ locale, defaultLocale, locales }) { // locale, defaultLocale, locales 추가
  const tags = await getAllTags('blog', locale, defaultLocale, locales) // locale, defaultLocale, locales 추가

  return { props: { tags } }
}

태그가 언어별로 생성된 것을 확인할 수 있습니다.

pages/tags/[tag].js

  • 동적 라우팅의 경우 getStaticPaths 부분을 모든 언어별 경로를 생성하도록 수정합니다.
export async function getStaticPaths({ locales, defaultLocale }) { // locales, defaultLocale 추가
  // 모든 언어별 경로 생성
  const localeTags = (
    await Promise.all(
      locales.map(async (locale) => {
        const tags = await getAllTags('blog', locale, defaultLocale, locales)
        return Object.keys(tags).map((tag) => ({
          params: { tag },
          locale,
        }))
      })
    )
  ).flat()

  // paths 수정
  return {
    paths: localeTags.map(({ params, locale }) => ({
      params,
      locale,
    })),
    fallback: false,
  }
}

export async function getStaticProps({ params, locale, defaultLocale, locales }) { // locale, defaultLocale, locales 추가
  const allPosts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // locale, defaultLocale, locales 추가
  // ...
}

태그 선택시 해당 태그가 언어별로 생성된 것을 확인할 수 있습니다.

pages/blog/[...slug].js

  • 동적 라우팅의 경우 getStaticPaths 부분을 모든 언어별 경로를 생성하도록 수정합니다.
  • formatSlug 호출에도 locales 파라미터가 추가된 것을 잊지마세요.
export async function getStaticPaths({ locales, defaultLocale }) { // locales, defaultLocale 추가
  // 모든 언어별 경로 생성
  const localePosts = (
    await Promise.all(
      locales.map(async (locale) => {
        const posts = getFiles('blog', locale, defaultLocale, locales)
        return posts.map((post) => ({
          params: {
            slug: formatSlug(post, locales).split('/'), // locales 추가
          },
          locale,
        }))
      })
    )
  ).flat()

  // paths 수정
  return {
    paths: localePosts.map(({ params, locale }) => ({
      params,
      locale,
    })),
    fallback: false,
  }
}

export async function getStaticProps({ params, locale, defaultLocale, locales }) { // locale, defaultLocale, locales 추가
  const allPosts = await getAllFilesFrontMatter('blog', locale, defaultLocale, locales) // locale, defaultLocale, locales 추가
  const postIndex = allPosts.findIndex(
    (post) => formatSlug(post.slug, locales) === params.slug.join('/') // locales 추가
  )
  // ...
  const post = await getFileBySlug('blog', params.slug.join('/'), locale, defaultLocale) // locale, defaultLocale 추가

  // ...
}

글이 선택된 언어에 맞게 표시되는 것을 확인할 수 있습니다.
(선택 언어로 작성된 글이 없는 경우 404 에러가 발생합니다.)

localhost 확인 시 속도

localhost에서 확인하는데 느립니다. (아직 배포전이라 production에서 확인은 못했습니다.)

이에 대한 원인은 Next.js 공식 문서: getStaticPaths 에서 확인할 수 있습니다.

위 내용을 발췌하면 아래와 같습니다.

Runs on every request in development

In development (next dev), getStaticPaths will be called on every request.

해석하면 아래와 같습니다.

개발 모드에서는 모든 요청마다 실행됩니다.

개발 모드(next dev)에서는 getStaticPaths가 모든 요청마다 호출됩니다.

정리

목록, 글 보기 등 제 눈에 보이는 부분은 정상 동작하는 것을 확인했습니다.
(누락된 부분과 오류가 있는 부분이 있을 지도 모르겠습니다. 😥)

이제 남은 건 눈에 보이지 않는 부분인 SEO, Sitemap, RSS 등을 추가하는 것입니다.

Next.js - 블로그 다국어(i18n)로 만들기 Part 3 에서 SiteMap, RSS를 다국어로 만드는 방법을 알아보겠습니다.

코드

변경된 코드는 commit history 를 참고해주세요.

모두의 구글 애널리틱스4:GA4로 하는 디지털 마케팅 데이터 분석, 길벗  아무나 쉽게 따라하는 블로그 마케팅:검색 상위노출을 위한 블로그 마케팅의 모든 것, 페이스메이커  트래픽을 쓸어 담는 검색엔진 최적화:검색엔진이 가장 좋아하는 사이트 만들기, e비즈북스  실전에서 바로 쓰는 Next.js:SSR부터 SEO 배포까지 확장성 높은 풀스택 서비스 구축 가이드, 한빛미디어
(위 링크는 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.)