SSR 환경에서도 다국어 지원하는 서비스 만들기

이수빈·2024년 6월 1일
post-thumbnail

회사에서 진행 중인 신규 프로젝트에는 기존에 다른 서비스에도 적용 되어 있는 다국어 기능을 동일하게 적용 시키는 요구사항이 있었다. 신규 프로젝트의 기술 스택은 React만 다루던 기존 서비스와는 다르게, Next.js를 사용해 개발 중이다. 어쨌든 같은 React를 사용하니 다국어 기능도 기존과 동일하게 react-i18next를 사용해 구현하면 되겠지 하고 기능을 그대로 가져다가 실행시켜 보았는데 화면상으로 볼 때 번역은 되는것 처럼 보였으나 페이지의 html 파일에는 번역된 언어가 아닌 기본 언어인 한글 그대로 내려오고 있었다.
이번 신규 프로젝트는 SSR로 구현했기에 서버에서 내려오는 html 파일에서 부터 다국어가 적용되어 내려와야 하지 않을까 라는 생각에 react-i18next의 공식 문서를 찾아보니 SSR에 관련해선 비슷한 라이브러리인 next-i18next 사용을 추천하고 있었다. (링크)
이러한 이유들로 react-i18next에서 next-i18next로의 라이브러리 교체를 진행했다. 참고로 프로젝트에서 사용하는 Next.js의 버전은 13이며 page router 방식을 사용중이다.

사전작업

우선 라이브러리를 교체하기 전에 선행되어야 할 작업이 있었다.
모든 회사 서비스에는 공통적으로 적용되는 필수 정책이 있는데 처음 로딩 화면에서 인증 과정을 거치고 인증이 정상적으로 이루어지면 레이아웃을 그리는데 필요한 데이터를 서버에서 내려준다. 이 과정이 실패한다면 메인 화면으로 보내줘야 한다.
프로젝트 초창기에 Next.js의 getInitialProps 함수가 이러한 과정들을 실행시키는데 적합하다고 생각해 _app.tsxgetInitialProps 함수를 선언하고 해당 로직을 실행시키게 했다.
하지만 getInitialProps는 next-i18next와는 잘 맞지 않는 것 같았다. 프로젝트 전체에 다국어를 적용 시키려면 serverSideTranslations라는 함수를 전달하는 작업이 필요한데 작동이 잘 되지 않았다. 공식 github의 Issue를 살펴보니 나와 비슷한 사람이 제법 있었다. getInitialProps의 지원을 하지 않는 것은 아닌데 serverSideTranslations 함수가 서버 사이드 모듈인 fs에 의존하기 때문에 클라이언트 사이드와 서버 사이드 모두에서 동작하는 getInitialProps를 사용하면 에러가 나는 것 같았다.
당장 getInitialProps 키워드로 검색을 해보아도 성능 등의 이유로 사용해야한다는 글보다 사용하지 말아야 한다는 글이 많기에 getServerSideProps를 hoc로 만들어서 SSR을 지원해야 하는 페이지에 적용 시키기로 했다.

// withServerSideProps.tsx
if (!context.req.url?.includes('_next')) {
...
  if (health.data.status === 'UP') {
    ...
      return await getServerSideProps(context).then((res: { [key: string]: any }) => ({
		...res,
			props: {
				...res.props,
				type: 'success',
				token: accessToken,
				config: config.data,
			},
		}));
    }
return await getServerSideProps(context);

인증 과정을 수행하는 로직은 보안상의 이유로 생략했고, type과 token이 인증 정보, config가 레이아웃을 그리기 위한 데이터라고 생각하면 되겠다.
인증은 첫 로딩 시점이나 새로고침 시점에만 수행하면 되기 때문에 context.req.url?.includes('_next')로 분기 처리를 해주었다. context.req.url_next 문자열이 포함되어 있다면 첫 로딩 혹은 새로고침으로 화면을 요청하는 것이고, 오직 라우팅 경로로 되어 있다면 page route를 통해 화면을 요청하는 것이기 때문이다.
인증이 필요한 시점에서는 인증에 필요한 데이터들(type, token)과 각 페이지의 getServerSideProps에서 리턴하는 데이터(res, res.props)를 함께 리턴 시켜주어야 한다.
인증이 필요하지 않는 시점, 즉 라우팅 시점에는 기존처럼 serverSideProps에서 리턴하는 데이터만 넘겨주면 된다.
이 hoc로 SSR을 지원하는 페이지의 getServerSideProps 함수를 감싸면 getInitialProps를 이용해 구현했던 동작을 그대로 구현 가능하다.

// _app.tsx
const App = ({ Component, pageProps }: AppProps & AppOwnProps) => {
  const { type, token, config } = pageProps;

위에서 리턴한 데이터는 withServerSideProps가 실행 될 때마다 _app.tsxpageProps로 들어가게 되며, 인증이 필요한 시점에 리턴한 type ,token, config 등의 데이터들을 사용할 수 있게 된다.

설정

이제 next-i18next의 설정법을 프로젝트에 적용시켜주면 된다.

// _app.tsx
export default appWithTranslation(App);

우선적으로 app을 appWithTranslation로 감싸주어야 한다. 해당 함수는 next-i18next에서 제공하는 i18nextProvider를 hoc로 구현한 함수이다.

// next-i18next.config.js 
module.exports = {
	i18n: {
		defaultLocale: 'kr',
		locales: ['kr', 'en', 'jp', 'cnb', 'cng', 'idn', 'th', 'vn'],
		defaultNS: 'all',
		localeDetection: false,
	},
};
// next.config.js
const { i18n } = require('./next-i18next.config');

그 다음으로는 config 파일인데 next-i18next.config.js 키워드로 파일을 생성해 next.config.js에서 import 후 사용하도록 했다.
next-i18next.config.js의 구성 내용은 차례대로 기본 설정 언어, 지원할 다국어 언어 목록, 기본 json 파일 이름, 브라우저 언어 감지 유무 이다. 프로젝트의 요구사항 중 사용자의 브라우저 언어에 맞게 다국어를 변경해주거나 감지하는 기능은 없기 때문에 false로 설정했다.

// withServerSideProps.tsx
const { lang } = nookies.get(context);
  const nextI18Next = await serverSideTranslations(lang || 'kr');
  ...
  props: {
		...res.props,
		...nextI18Next,
		type: 'success',
		token: accessToken,
		config: config.data,
  ...

그리고 가장 중요한 SSR 환경에서 다국어 지원을 위해 pages에 정의된 getServerSideProps serverSideTranslations(lang); 함수를 실행 시켜 주어야 하지만, 모든 pages가 getServerSideProps로 구현된 프로젝트의 특성에 따라 withServerSideProps에서 공통으로 리턴 주는 방식으로 구현했다.
모든 서비스에서 설정한 언어 값을 공유해야 하기에 lang 값은 쿠키에 설정된 값을 보고 설정하도록 구현했고, 다른 서비스에도 쿠키에 설정된 값으로 언어를 설정하게 했다.

중간에 Next.js를 사용하면서 다국어를 지원하는 다른 회사의 서비스를 참고하기 위해 페이지 요청 페이로드를 분석해 보았는데, op.gg에서도 모든 페이지 요청 시 _nexti18Next를 내려주는 것으로 보아 비슷하게 구현한듯 하다.

// helper.tsx
function langs(key: string, ns?: string) {
	const opts = ns ? { ns } : { ns: 'all' };
	return i18n?.t(key, key, opts) ?? key;
}

다국어 적용은 함수로 만들어 langs('다국어 변수') 이런 식으로 공통으로 사용하도록 했다.

언어 변경

// Layout.tsx
const [cookies, setCookie] = useCookies(['lang']);
...
useEffect(() => {
		i18n?.changeLanguage(cookies.lang);
	}, [cookies]);
...
<Header
	language={cookies.lang}
	onChangeLanguage={lang => {
		setCookie('lang', lang, { path: '/', domain: Paths.COOKIE_DOMAIN });
		router.push(router.pathname, router.asPath);
	}}
...

언어 변경은 SSR 설정이 false인 클라이언트 컴포넌트와 서버 컴포넌트 서로 차이점이 존재한다.
클라이언트 컴포넌트는 useEffect의 의존성 배열인 쿠키의 lang값을 보고 변경을 감지해 i18n.changeLanguage 함수를 호출하게 되고, 서버 컴포넌트는 i18n.changeLanguage 함수를 호출한다고 html 파일을 다시 그리는 것이 아니기 때문에, router.push로 현재 경로의 html 파일을 다시 그려주도록 구현했다.
두 로직을 하나의 함수로 만들어 사용하지 않은 이유는 동시성 때문이다. 한 함수에 작성 후 실행하게 되면 클라이언트 컴포넌트와 서버 컴포넌트의 언어 변경 시점이 차이가 나서 클라이언트 컴포넌트가 먼저 바뀌고 서버 컴포넌트가 나중에 바뀌는 현상이 생기더라.

에러

const nextConfig = {
	i18n,
	webpack: config => {
		// https://github.com/i18next/next-i18next/issues/935
		config.resolve.fallback = { fs: false };
		// https://github.com/i18next/next-i18next/issues/1545
		config.module.exprContextCritical = false;

위와 같이 구현한 뒤, 빌드 과정에서 다음과 같은 에러를 만났다.

warn - ./node_modules/next-i18next/dist/commonjs/serverSideTranslations.js
Critical dependency: the request of a dependency is an expression
error - ./node_modules/next-i18next/dist/commonjs/serverSideTranslations.js:28:0
Module not found: Can't resolve 'fs'

대충봐도 serverSideTranslations 함수에서 문제가 생긴 것으로 보인다. 다시 공식 github를 찾아보니 serverSideTranslations 함수를 pages 폴더가 아닌 다른 폴더에서 사용한게 그 이유였다. serverSideTranslations 함수를 사용하는 withServerSideProps.tsx 파일은 util 폴더에 생성 해 두었고 이게 이유인 것 같았다. 팀에서 정한 파일 구조 룰이 존재하는데 이 오류 때문에 룰을 깨고 싶진 않았다. 다행이도 webpack 설정을 바꿔주면 임시적으로 해결 가능했다. 이건 리팩토링 할 때 해결 방법을 찾아봐야 할 것 같다.

후기

만든 곳이 같은 라이브러리들이라 생각보다 어렵지 않게 다국어 라이브러리를 교체 할 수 있었다. 앞으로 Next.js를 사용하던, React만 사용하던 다국어를 적용 시켜야 한다면 문제 없이 구현 할 수 있을 것 같다.

profile
내가 나중에 보려고

0개의 댓글