GatsbyJS에서 l10n(지역화) 지원하기 (간단한 함수형을 곁들인)

김민수

데브시스터즈에서는 React 기반의 GatsbyJS를 이용해 웹 사이트를 관리하고 있으며 한국어 외에도 다양한 언어를 지원하고 있습니다. GatsbyJS를 이용한 React 웹 애플리케이션에서 Gatsby API와 React를 이용해 l10n(localization, 지역화)를 지원하도록 페이지를 생성하고 관리하는 방법을 소개합니다.

함수형 프로그래밍 라이브러리인 fp-ts를 같이 이용할 것이며 필요한 개념은 추가로 설명을 진행하겠습니다.

작성된 전체 코드는 레파지토리에서 확인할 수 있으며 실제 배포된 웹 애플리케이션은 여기에서 확인할 수 있습니다.

l10n 이란?

l10n은 localization의 약자로 지역화를 의미하며 간단하게는 다국어 지원을 의미하지만 그 외에도 지역의 문화, 형식, 요건들을 각각의 지역에 맞게 하는 작업을 의미합니다.

l과 n사이의 알파벳 개수가 10개이며, i18n(Internationalization, 국제화)와 m17n(multilingualization, 다국어화), g11n(Globalization, 세계화)와 같은 것들이 추가로 존재합니다.

이 포스트에서 진행할 지역화는 접속하는 브라우저의 언어 설정에 따른 페이지 라우팅과 간단한 번역 적용을 진행해 보겠습니다.

번역 데이터 준비하기

번역 데이터는 많은 방법으로 관리할 수 있습니다. 실제 업무를 진행할 때는 Lokalise와 같은 툴을 이용해 번역을 진행하기도 하며 API를 이용해 데이터를 가져오거나 json, yml과 같은 확장자로 파일을 export 해서 사용하기도 합니다.

이 포스트에서는 간단하게 ts 파일을 이용해 가짜 번역 데이터를 생성해 보겠습니다.

GatsbyJS의 설정은 생략하며 중간에 필요한 Gatsby의 Node API에 대해서는 따로 설명을 진행하겠습니다. Gatsby에 대해서 더 자세히 알고 싶으시면 여기를 확인해 주세요.

더미 데이터 생성을 위해 아래와 같이 TranslationData 타입을 선언하고 src/data 경로에 각각의 {language}.ts 파일을 생성했습니다.

번역 데이터 파일
  • typings/translation.d.ts
declare type Language = 'ko' | 'en' | 'ja';
declare type TranslationData = {
  language: Language,
  title: string,
  description: string,
};

실제로 프로젝트를 진행할 때에는 타입을 직접 설정하지 않고 gatsby-plugin-typegen과 같은 플러그인 이용해 타입을 동적으로 생성합니다.

  • src/data/ko.ts
const data: TranslationData = {
  language: 'ko',
  title: '한국어',
  description: '안녕 세상!',
};

export default data;
  • src/data/en.ts
const data: TranslationData = {
  language: 'en',
  title: 'English',
  description: 'Hello World!',
};

export default data;
  • src/data/ja.ts
const data: TranslationData = {
  language: 'ja',
  title: '日本語',
  description: 'ハロー・ワールド!',
};

export default data;

데이터들은 위의 데이터에서 title, description과 같은 번역키가 존재하며 번역 값을 가져오는 함수에서 번역키 자동완성을 지원하도록 구현을 진행해 보겠습니다.

번역 데이터 생성하기

번역 데이터 노드를 생성하기 위해서는 Gatsby의 Node API를 이용해야 합니다. 그냥 파일에 있는 데이터를 이용할 수 있지만, Gatsby의 장점인 Static Site Generation을 이용하기 위해 파일에 담겨있는 데이터를 Gatsby의 GraphQL Node로 생성해야 합니다.

Gatsby의 Node API는 여기에서 확인할 수 있습니다.

번역 데이터를 생성하기 위해서는 createSchemaCustomizationsourceNodes API를 사용해야 합니다.

노드 스키마 생성하기

함수 이름에서도 알 수 있듯이 createSchemaCustomization는 스키마를 생성하는 API입니다. root 디렉토리에 gatsby-node.ts 파일을 생성하고 아래와 같이 작성합니다.

import type { GatsbyNode } from 'gatsby';

const gql = String.raw;

export const createSchemaCustomization: GatsbyNode['createSchemaCustomization'] = ({
  actions: { createTypes },
}) => {
  const typeDef = gql`
    type TranslationData implements Node @dontInfer {
      language: String!,
      title: String!,
      description: String!,
    }
  `;
  createTypes(typeDef);
};

@dontInfer 지시어를 사용하면 모든 필드에 대한 타입 정의를 명시적으로 정의해야 합니다. [더 보기]

createSchemaCustomization 함수를 이용해 TranslationData 노드의 스키마 타입을 정의했습니다. 이제 sourceNodes 함수를 이용해 실제 노드를 생성해야 합니다.

번역 데이터 노드 생성하기
import * as A from 'fp-ts/lib/Array';
import { pipe } from 'fp-ts/lib/function';

import type { GatsbyNode, Actions } from 'gatsby';

import koTranslationData from './src/data/ko';
import enTranslationData from './src/data/en';
import jaTranslationData from './src/data/ja';

// ... createSchemaCustomization (생략)

const createTranslationDataNode = (
  createNode: Actions['createNode'],
  createNodeId: (input: string) => string,
  createContentDigest: (input: string | Record<string, unknown>) => string,
) => (
  translationData: TranslationData,
) => createNode({
  ...translationData,
  id: createNodeId(translationData.language),
  internal: {
    type: 'TranslationData',
    content: JSON.stringify(translationData),
    contentDigest: createContentDigest(translationData),
  },
});

export const sourceNodes: GatsbyNode['sourceNodes'] = ({
  actions: { createNode },
  createNodeId,
  createContentDigest,
}) => {
  const translationDatas = [
    koTranslationData,
    enTranslationData,
    jaTranslationData,
  ];
  pipe(
    translationDatas,
    A.map(
      createTranslationDataNode(
        createNode,
        createNodeId,
        createContentDigest,
      ),
    ),
  );
};

{language}.ts 형식으로 만들었던 파일에서 값을 가져와 createNode 함수를 이용해 노드를 생성해줍니다. 모든 번역 데이터값을 배열로 만들어 하나의 로직으로 처리할 수 있도록 createTranslationDataNode 함수를 추가로 구현했습니다. 순수 함수 원칙을 최대한 위반하지 않기 위해 필요한 Gatsby API들을 받고 그 이후에 번역 데이터값을 인자로 받습니다.

참고) 이미 createTranslationDataNode 함수는 순수하지 않을 수 있습니다. Gatsby API인 createNode, createNodeId, createContentDigest 함수는 동일한 입력을 받았을 때 동일한 출력을 반환할지 보장할 수 없습니다. 하지만 함수 내부에서 사용하는 값을 모두 인자로 받는 원칙을 지키기 위해 해당 형식으로 작성하였습니다.

pipe 함수는 값들을 순차적으로 조합(composition)하는 역할을 합니다. fp-tsArray.map은 기존 자바스크립트 배열의 map 함수와 동일한 동작을 합니다. 자세한 내용은 여기에서 확인할 수 있습니다.

  • fp-tsArray.map 함수 시그니처
export declare const map: <A, B>(f: (a: A) => B) => (fa: A[]) => B[]

참고) pipe 함수는 pipe operator(|>)로 Javascript 표준 스펙으로 제안되고 있습니다. [더 보기]

생성된 데이터 노드 확인하기

개발 서버를 실행하고 GrpahiQL(/__graphql)에 들어간 뒤, 아래와 같이 모든 TranslationData를 가져오기 위해 graphql 쿼리를 작성하고 Excute Query 버튼을 눌러 쿼리를 실행합니다.

query translationDatas {
  allTranslationData {
    nodes {
      language
      title
      description
    }
  }
}
translationDatas 쿼리 결과 0 query translation data

위의 이미지 결과와 같이 TranslationData 노드들이 정상적으로 잘 생성된 것을 확인할 수 있습니다. 이제 createPages API를 이용해 언어별 페이지를 생성해주어야 합니다.

언어별 페이지 생성하기

createPages API 안에서는 생성된 Gatsby Node를 query를 이용해 가져와 사용할 수 있습니다. query를 이용해 가져온 TranslationData 데이터를 이용해 아래와 같이 페이지를 생성할 수 있습니다. 코드가 복잡할 수 있으니 나누어 설명하겠습니다.

새로 변경된 모듈 선언부

Option, TaskEither 모듈이 추가되었으며 pipe 외에도 identity 함수를 가져왔습니다. path 모듈은 페이지를 생성할 때 페이지 컴포넌트의 경로를 가져오기 위해 추가했습니다.

import * as path from 'path';

import { identity, pipe } from 'fp-ts/lib/function';
import * as A from 'fp-ts/lib/Array';
import * as O from 'fp-ts/lib/Option';
import * as TE from 'fp-ts/lib/TaskEither';

import type { GatsbyNode, Actions } from 'gatsby';

import koTranslationData from './src/data/ko';
import enTranslationData from './src/data/en';
import jaTranslationData from './src/data/ja';

기존 fp-ts/lib/Array 외에도 OptionTaskEither 타입을 사용하게 되었습니다. 관련 타입은 아래에 간단하게 설명하고 넘어가겠습니다.

Option 타입 설명

Option 타입은 아래와 같이 구현되어 있습니다.

type Option<A> = None | Some<A>

Option<A>는 선택적인 타입의 값에 대한 컨테이너 입니다. 만약에 A가 존재한다면 Option<A>Some<A> 인스턴스가 될 것이고, A가 존재하지 않는다면 None이 될 것입니다.

Option은 0개나 1개의 원소를 가진 집합 또는 접을 수 있는 foldable한 구조로 볼 수 있습니다.

여기서 foldreduce로도 알려져 있으며 자료 구조를 분석해 전달받은 명령을 통해 반환값을 만들어내는 함수입니다.

Optionfold 함수의 시그니처는 아래와 같습니다. match 함수는 fold 함수와 동일하며 대체할 수 있습니다.

export declare const fold: <A, B>(
  onNone: Lazy<B>, onSome: (a: A) => B
) => (ma: Option<A>) => B;

아래 코드는 Option 타입에 대한 간단한 예시 코드입니다.

import * as O from 'fp-ts/lib/Option';

const isAdult = (age: number) => age > 19;
const getOptionAge = O.fromPredicate(isAdult);

console.log(getOptionAge(20)); // { _tag: 'Some', value: 20 }
console.log(getOptionAge(18)); // { _tag: 'None' }

boolean을 반환하는 isAdult 함수를 fromPredicate에 넘겨주어 Option<number>를 반환하는 getOptionAge 함수를 만들 수 있습니다. 위의 결과와 같이 조건에 따라 _tag 속성으로 구분되는 Some 타입과 None 타입을 확인할 수 있습니다.

const getAgeLabelText = (age: number) => pipe(
  age,
  getOptionAge,
  O.fold(
    () => 'Not adult',
    (age) => `Adult (age: ${age})`,
  ),
);
console.log(getAgeLabelText(20)); // Adult (age: 20)
console.log(getAgeLabelText(18)); // Not Adult

getOptionAge에서 반환된 Option<number> 타입의 값을 fold 함수를 이용해 콘솔에 출력했습니다. fold 함수로 전달된 Option 타입의 값이 Some일 경우 두 번째 인자로 전달된 함수가 실행되며 None일 경우 첫 번째 인자로 전달된 함수가 실행됩니다.

Option 타입에 대하여 더 알아보시고 싶으시면 fp-ts 공식 문서를 참고해 주세요.

TaskEither 타입 설명

TaskEither 타입은 TaskEither 타입이 결합된 타입입니다.

Task<A> 타입은 A 타입 값을 반환하는 절대 실패하지 않는 비동기 연산을 의미합니다.

interface Task<A> {
  (): Promise<A>
}

Either 타입은 두개의 가능한 타입 중 하나의 값(분리 합집합)을 나타내는 타입입니다.

분리 합집합은 서로소 합집합이라고도 하며 교집합을 허락하지 않습니다. [더 보기]

type Either<E, A> = Left<E> | Right<A>;

Either 인스턴스는 Left 또는 Right 인스턴스 둘 중 하나가 됩니다. Either 타입을 일반적으로 사용하는 것은 Option 타입의 대안으로 결측값을 처리할 때 사용되며 None은 유용한 값을 포함할 수 있는 Left로 대체됩니다. 관습적으로는 Left는 실패를 Right는 성공을 위해 사용됩니다.

interface TaskEither<E, A> extends Task<Either<E, A>> {}

TaskEither<E, A>는 타입 A의 값을 반환하거나 E 타입의 오류가 발생할 수 있는 비동기 연산을 표현합니다. 예외 처리를 하기위해서는 tryCatch를 같이 사용해야 합니다.

createPages 함수를 구현하기 위한 함수

graphql을 이용해서 TranslationData 노드들을 가져오는 query에 대한 쿼리의 결과 타입입니다.

type QueryResult = { allTranslationData: { nodes: TranslationData[]  } };

graphql을 이용해 query를 하는중 예외가 발생했을 때 프로세스를 종료하기 위해 사용하는 함수입니다. TaskEithertryCatch는 예외를 잡아주지만, 프로세스를 종료시키지 않습니다. 따라서 NodeJSprocess.exit 함수를 이용해 프로세스를 종료시킵니다.

const exitProcess = <E>(e: E) => (console.error(e), process.exit(1));

graphql을 이용해서 가져온 데이터가 예외 없이 성공했다면 match 함수를 통해 getTranslationDataNode 함수가 실행됩니다. OptionfromNullable 함수는 null, undefined와 같은 값이 들어오면 None을 반환하고 그렇지 않으면 Some을 반환합니다.

identity는 항등함수로 받은 값을 그대로 반환하는 (a) => a의 시그니처를 갖는 함수입니다.

const getTranslationDataNode = (response: { data?: QueryResult }) => pipe(
  response.data?.allTranslationData.nodes,
  O.fromNullable,
  O.match(
    () => [],
    identity,
  ),
);

TranslationData 노드들을 가져왔을 경우 노드를 순회하며 createLocalizedPage 함수가 실행되며 페이지가 생성됩니다.

const createLocalizedPage = (createPage: Actions['createPage'], component: string) =>
  (translationData: TranslationData) =>
    createPage({
      path: `/${translationData.language}/`,
      component,
      context: {
        language: translationData.language,
      },
    });

createPages 구현하기

위에서 만든 함수들을 조합해 createPages 함수를 구현합니다. TaskEithertryCatch를 이용해 graphql 함수를 실행하고 Promisereject되어도 그대로 TaskEither를 반환하도록 identity를 사용합니다.

그 후 match 함수를 이용해 Left가 반환될 경우 exitProcessRight가 반환될 경우 getTranslationDataNode 함수가 실행됩니다.

TranslationData 노드들을 순회하며 createLocalizedPage 함수가 실행되며 페이지가 생성됩니다.

export const createPages: GatsbyNode['createPages'] = async ({
  actions: { createPage },
  graphql,
}) => {
  const translationDataQuery = gql`
    query translationDatas {
      allTranslationData {
        nodes {
          language
        }
      }
    }
  `;

  const translationDataNodes = await pipe(
    TE.tryCatch(
      () => graphql<QueryResult>(translationDataQuery),
      identity,
    ),
    TE.match(
      exitProcess,
      getTranslationDataNode,
    ),
  )();

  pipe(
    translationDataNodes,
    A.map(
      createLocalizedPage(
        createPage,
        path.resolve('./src/templates/index.tsx'),
      ),
    ),
  );
};

생성된 페이지는 dev-404 페이지나 /__graphql에서 SitePage 타입을 쿼리해 확인할 수 있습니다.

404 페이지에서 확인한 생성된 페이지 1 page routing

생성된 언어별 페이지로 이동하면 오류가 발생하는 것을 확인할 수 있습니다. 이제 createPage 함수에 component로 넘겨준 페이지 컴포넌트를 구현해야 합니다.

templates 페이지 컴포넌트 구현하기

앞에서 우리는 createPage 함수로 페이지를 만들 때 인자로 context 객체를 넘겨주었습니다. 이 context 객체는 GatsbyJS의 페이지 컴포넌트에서 Page Query를 하는 데 사용할 수 있으며 또한 props로 전달됩니다.

  • templates/index.tsx
import * as React from 'react';

import type { PageProps } from 'gatsby';

type PageContext = { language: string };
type Props = PageProps<Record<string, unknown>, PageContext, unknown>;

const LocalizedIndexPage: React.FC<Props> = ({
  pageContext,
}) => {
  return (
    <div>{pageContext.language}</div>
  );
};

export default LocalizedIndexPage;

위와 같이 템플릿 페이지 컴포넌트를 작성하고 다시 각 언어의 페이지로 이동해 결과를 확인해보면 오류가 발생하지 않고 context로 전달된 language 값이 화면에 표시되는 것을 확인할 수 있습니다.

정상적으로 그려지는 언어별 페이지
  • localhost:8000/ko/
2 ko page
  • localhost:8000/en/
2 en page
  • localhost:8000/ja/
2 ja page

루트(/) 경로로 접근시 언어별 페이지로 Redirect 시키기

실제 웹 애플리케이션을 서비스할 때에는 https://www.devsisters.com/, https://www.cookierun-kingdom.com/과 같은 도메인에서 대부분 루트(/) 경로로 접근하게 됩니다. 이때 각 브라우저의 언어 설정에 따라 사용자들을 각각의 언어 페이지로 이동시켜줘야 합니다.

GatsbyJS는 기본적으로 폴더 기반 라우팅을 지원합니다. src/pages 폴더 내부에 원하는 경로의 이름을 갖는 폴더를 생성해 페이지를 생성할 수 있습니다. 여기서 우리는 루트 경로를 이용할 것이기 때문에 src/pages 경로에 index.tsx 파일을 생성하고 아래와 같이 작성합니다.

  • src/pages/index.tsx
import * as React from 'react';
import * as A from 'fp-ts/lib/Array';
import { graphql } from 'gatsby';
import { pipe } from 'fp-ts/lib/function';

import { useLanguageNavigateEffect } from '../utils/useLanguageNavigationEffect';

import type { PageProps } from 'gatsby';

type PageQueryData = { allTranslationData: { nodes: TranslationData[] } };
type IndexPageProps = PageProps<PageQueryData>;

const IndexPage: React.FC<IndexPageProps> = ({ data }) => {
  const languages = pipe(
    data.allTranslationData.nodes,
    A.map((node) => node.language),
  );

  useLanguageNavigateEffect(languages);

  return null;
};

export default IndexPage;

export const query = graphql`
  query IndexPage {
    allTranslationData {
      nodes {
        language
      }
    }
  }
`;

페이지 컴포넌트에서 페이지 쿼리를 이용해 모든 TranslationData 노드들의 language 필드 값들을 가져옵니다. 그 후 노드들에서 language 값만 뽑아 문자열 배열을 만들고 구현하게 될 useLanguageNavigationEffect 훅에 전달합니다. 실제 컴포넌트는 렌더링할 필요가 없어 null을 반환했지만, 상황에 따라 SEO 컴포넌트와 같은 컴포넌트들도 렌더링할 수 있습니다.

  • src/utils/useLanguageNavigationEffect.ts

필요한 모듈들을 가져와 선언합니다. 경로를 이동시키기 위한 navigate 함수와 React 라이프사이클 훅인 useEffect를 가져왔으며, fp-ts에서 이용하는 타입은 Option이 존재하며 배열에서 값을 찾는 것을 도와주는 유틸 함수인 findFirst 함수를 추가로 사용합니다.

import { navigate } from 'gatsby';
import { useEffect } from 'react';
import { findFirst } from 'fp-ts/lib/Array';
import { identity, pipe } from 'fp-ts/lib/function';
import { match, fromNullable } from 'fp-ts/lib/Option';

getDefaultLanguageOrElse 함수는 사용자의 브라우저의 언어를 지원하는 페이지가 없을 경우 실행되는 함수입니다. 선택적으로 defaultLanguage인자를 받아 있을 경우 그대로 반환하며 없다면 'ko'라는 문자열을 반환합니다.

const getDefaultLanguageOrElse = (defaultLanguage?: string) => pipe(
  defaultLanguage,
  fromNullable,
  match(
    () => 'ko',
    identity,
  ),
);

getTargetLanguages 함수는 지원하는 언어 배열인 languages와 사용자 브라우저의 언어를 의미하는 navigatorLanguage를 전달받으며 선택적으로 getDefaultLanguageOrElse 함수에서 사용하는 defaultLanguage 값을 전달받습니다. languages 배열에서 navigatorLanguage 값으로 시작하는 값이 있는지 찾습니다. findFirst 함수는 Option 타입을 반환하며 값이 없을 경우 None을 값이 있을 경우 Some을 반환합니다.

값이 존재하지 않아 None이 반환되었을 경우 getDefaultLanguageOrElse 함수로 기본 언어 값을 반환하며 값이 존재한다면 identity 함수로 그대로 값을 반환합니다.

const getTargetLanguages = (
  languages: string[],
  navigatorLanguage: string,
  defaultLanguage?: string,
) => pipe(
  languages,
  findFirst((lang: string) => lang.startsWith(navigatorLanguage)),
  match(
    () => getDefaultLanguageOrElse(defaultLanguage),
    identity,
  ),
);

window.navigatorlanguage 값을 가져와 -로 분리합니다. 한국어의 경우에는 window.navigator의 값이 ko-KR이며 IE와 같은 구형 브라우저에서는 language가 아닌 userLanguage로 값을 가져와야 할 수 있습니다.

MDN web docs : Navigator.language

export function useLanguageNavigateEffect(languages: string[]) {
  useEffect(() => {
    const [navigatorLanguage] = window.navigator.language.split('-');
    const targetLanguage = getTargetLanguages(languages, navigatorLanguage);

    void navigate(`/${targetLanguage}/`, { replace: true });
  }, [languages]);
}

가져온 navigatorLanguage 값을 지원 언어를 가진 languages 배열과 함께 getTargetLanguages 함수를 호출해 경로를 이동시킬 언어 문자열을 얻습니다. 그 후 GatsbyJS의 navigate 함수를 이용해 해당 경로로 이동시킵니다.

이제 / 경로로 접근하면 정상적으로 브라우저 설정 언어로 이동하는지 확인해봅시다.

/로 접근시 /ko/로 이동하는 결과

/로 접근했을 때 정상적으로 /ko/ 경로로 이동하는 것을 확인할 수 있습니다. 브라우저의 언어 설정을 영어로 변경하게 된다면 /en/으로 이동하게 될 것이고 일본어로 변경하게 된다면 /ja/로 이동하게 됩니다.

이제 TranslationData 노드의 다른 필드들인 실제 번역 값을 각 페이지에 Context API를 이용해 보여주도록 해보겠습니다.

파일 입출력으로 GraphQL Fragment 생성하기

예시와 같은 적은 양의 데이터의 경우에는 context를 이용해 값을 넘겨주어 사용해도 문제 될 것이 없지만, 실제 프로젝트에서는 상당히 많은 양의 번역 데이터들이 사용하게 됩니다.

따라서 GatsbyJS의 sourceNodes API 함수가 실행되면서 같이 GraphQL의 Fragment와 타입들을 생성할 수 있도록 개선해 보겠습니다.

수정된 gatsby-node.ts 파일

파일 입출력을 위한 fs 모듈을 추가로 가져왔으며 문자열을 합치기 위해 fp-tsstring 모듈을 추가로 가져왔습니다.

fp-ts/lib/string 모듈을 사용하지 않고 + 연산자를 사용해도 충분합니다.

import * as path from 'path';
import * as fs from 'fs';

import { identity, pipe } from 'fp-ts/lib/function';
import * as A from 'fp-ts/lib/Array';
import * as O from 'fp-ts/lib/Option';
import * as TE from 'fp-ts/lib/TaskEither';
import * as S from 'fp-ts/lib/string';

import koTranslationData from './src/data/ko';
import enTranslationData from './src/data/en';
import jaTranslationData from './src/data/ja';

import type { GatsbyNode, Actions } from 'gatsby';

createTranslationDataNode 함수가 인자로 전달받은 translationData를 그대로 반환하도록 변경했습니다. 반환된 translationData 값은 파일 IO를 통해 GraphQL Fragment를 생성하게 됩니다.

const createTranslationDataNode = (
  createNode: Actions['createNode'],
  createNodeId: (input: string) => string,
  createContentDigest: (input: string | Record<string, unknown>) => string,
) => (
  translationData: TranslationData,
) => {
  createNode({
    ...translationData,
    id: createNodeId(translationData.language),
    internal: {
      type: 'TranslationData',
      content: JSON.stringify(translationData),
      contentDigest: createContentDigest(translationData),
    },
  });
  return translationData;
};

renderTextFragments 함수는 TranslationData 노드의 키값을 받아 GraphQL Fragment 문자열을 생성합니다. reduce 함수를 이용해 문자열을 하나씩 합치며 문자열 Semigroupconcat을 이용해 닫는 괄호를 추가해줍니다.

Semigroupconcat 매서드를 포함하고 있으며 concat 매서드는 항상 결합 법칙을 만족해야 합니다.

const renderTextFragments = (fields: string[]) =>
  pipe(
    fields,
    A.reduce<string, string>(
      'fragment TranslationDatas_allDatas on TranslationData {',
      (a, b) => `${a}\n${b}`,
    ),
    (fragment) => S.Semigroup.concat(fragment, '\n}'),
  );

실제 파일 입출력을 실행하는 함수입니다. src/__generated__/allTranslationsFragment.js 의 경로에 fragments 상수를 생성합니다. fragments를 생성하기 위해 위에서 구현한 renderTextFragments 함수를 사용합니다. 동일하게 Semigroup을 사용할 수 있지만, 가독성을 위해 + 연산자를 사용하였습니다.

const writeTranslationsFragmentFile = (
  textFields: string[],
) => {
  void fs.writeFileSync(
    path.resolve('src/__generated__', 'allTranslationsFragment.js'),
    '/* eslint-disable */' +
    '\n' +
    'import { graphql } from \'gatsby\';' +
    '\n' +
    'export const fragments = graphql`' +
    '\n' +
    `${textFields.length > 0 ? renderTextFragments(textFields) : ''}` +
    '`;',
    'utf8',
  );
};

기존의 sourceNodes에서 변경된 점은 그대로 반환된 translationDatas에서 마지막 하나의 TranslationData 값을 이용해 fragments를 생성하는 파일 입출력을 진행합니다.

last 함수는 Option을 반환하며 None이 반환될 경우 빈 배열을 Some이 반환될 경우에는 getTranslationDataKey 함수를 호출해 TranslationData 객체의 키값을 반환합니다.

반환된 키값은 writeTranslationsFragmentFile 함수를 거쳐 파일 입출력을 통해 allTranslationsFragment.js 파일이 생성되게 됩니다.

const getTranslationDataKey = (translationData: TranslationData) =>
    Object.keys(translationData);

export const sourceNodes: GatsbyNode['sourceNodes'] = ({
  actions: { createNode },
  createNodeId,
  createContentDigest,
}) => {
  const translationDatas = [
    koTranslationData,
    enTranslationData,
    jaTranslationData,
  ];
  pipe(
    translationDatas,
    A.map(
      createTranslationDataNode(
        createNode,
        createNodeId,
        createContentDigest,
      ),
    ),
    A.last,
    O.match(
      () => [],
      getTranslationDataKey,
    ),
    writeTranslationsFragmentFile,
  );
};

개발 서버를 실행하고 GrpahiQL(/__graphql)에 들어간 뒤, 아래와 같이 모든 TranslationData를 가져오기 위해 graphql 쿼리를 작성하고 Excute Query 버튼을 눌러 쿼리를 실행합니다.

4 create graphql fragments

이제 우리는 이 fragment를 이용해 각각의 언어 페이지에서 페이지 쿼리를 한 후 Context API를 이용해 번역 데이터와 번역 데이터를 반환해주는 함수를 구현해야 합니다.

Context API로 번역 데이터 설정하기

가져온 데이터를 페이지에 전체적으로 전달해주기 위해서 React의 Context API를 사용합니다. 만들게 될 L10nContext의 타입은 아래와 같습니다. createContext 함수를 이용해 기본값을 null로 갖는 새로운 Context를 생성했습니다.

  • src/context/L10nContext.ts
import { createContext } from 'react';

export type Nullable<T> = T | null;
export type L10NContextType = Nullable<{
  messages: GatsbyTypes.TranslationDatas_allDatasFragment,
  t: (key: keyof GatsbyTypes.TranslationDatas_allDatasFragment) => string,
}>;

export const L10nContext = createContext<L10NContextType>(null);

messages는 번역데이터 객체에 해당하며 t 함수는 번역 데이터 key를 받아 번역 데이터값을 반환하는 함수입니다. 여기에서 사용된 GatsbyTypesgatsby-plugin-typegen에 의해 생성되었으며 templates/index.tsx의 템플릿 페이지 컴포넌트에 페이지 쿼리가 실행되며 타입이 생성됩니다.

우리는 위에서 만들어진 fragments를 이용해 템플릿 페이지 컴포넌트에서 사용할 새로운 fragments를 추가로 생성할 것입니다.

  • src/context/L10nContext.ts
import { graphql } from 'gatsby';

export const fragments = graphql`
  fragment TranslationDatas on Query {
    translationData(language: { eq: $language }) {
      ...TranslationDatas_allDatas
    }
  }
`;
  • src/templates/index.tsx

위에서 만들어진 fragments는 아래의 템플릿 페이지 컴포넌트에서 사용됩니다. 페이지 쿼리로 가져와진 데이터는 페이지 컴포넌트의 data로 전달되며 GatsbyTypes에서 해당 페이지 쿼리의 타입을 가져올 수 있습니다.

import * as React from 'react';
import { graphql } from 'gatsby';

import type { PageProps } from 'gatsby';

type PageContext = { language: string };
type Props = PageProps<GatsbyTypes.LocalizedIndexPageQuery, PageContext, unknown>;

// ... 생략

export const query = graphql`
  query LocalizedIndexPage($language: String!) {
    ...TranslationDatas
  }
`;

data 하위에 있는 translationData 값을 이용해 L10nContextProvider에 전달할 값을 반환하는 함수를 생성해야 합니다. 아래와 같이 데이터가 없을 경우 null을 반환하고 그렇지 않을 경우 L10NContextType에 전달될 값을 반환하도록 합니다.

import * as React from 'react';
import * as O from 'fp-ts/lib/Option';
import { graphql } from 'gatsby';
import { pipe, constNull } from 'fp-ts/lib/function';

import type { L10NContextType } from '../context/L10nContext';

// ... 생략

const getL10nValue = (
  data: GatsbyTypes.LocalizedIndexPageQuery,
  t: typeof getTranslationText,
) => pipe(
  data?.translationData,
  O.fromNullable,
  O.match(
    constNull,
    (data) => ({ messages: data, t: (key) => t(data, key) }) as L10NContextType,
  ),
);

// ... 생략

이제 다시 L10nContext.ts 파일로 이동해 getTranslationText와 같이 사용할 유틸 함수와 훅들을 구현해 보겠습니다.

  • src/context/L10nContext.ts

아래와 같이 간단하게 구현할 수 있습니다. getTranslationText 함수는 번역 데이터 객체와 키를 받아 값이 존재할 경우 번역 데이터를 그렇지 않은 경우 오류임을 표시할 수 있는 이모지를 반환합니다.

여기서 messages 인자는 페이지 쿼리로 가져와진 번역 데이터로 실제 L10nContext에 있는 t 함수를 호출할 때는 key 인자만 전달받을 수 있도록 구현되어 있습니다.

export function getTranslationText(
  messages: GatsbyTypes.TranslationDatas_allDatasFragment,
  key: keyof GatsbyTypes.TranslationDatas_allDatasFragment,
) {
  return messages[key] ?? '⚠️⚠️⚠️';
}

export const useL10N = () => {
  const l10n = useContext(L10nContext);
  if (!l10n) {
    throw new Error('번역 데이터 리소스가 존재하지 않습니다.');
  }
  return l10n;
};

export const useTranslation = () => {
  const l10n = useL10N();
  return l10n.t;
};

이제 기본적인 함수 구현은 끝났습니다. 템플릿 페이지 컴포넌트에서 L10nContext를 채워 넣기만 하면 번역 데이터를 애플리케이션 전체에서 이용할 수 있습니다.

  • src/templates/index.tsx
// ... 생략
import { getTranslationText, L10nContext } from '../context/L10nContext';
// ... 생략

const LocalizedIndexPage: React.FC<Props> = ({
  data,
  pageContext,
}) => {
  const l10n = React.useMemo<L10NContextType>(
    () => getL10nValue(data, getTranslationText),
    [data],
  );
  return (
    <L10nContext.Provider value={l10n}>
      {pageContext.language}
    </L10nContext.Provider>
  );
};

useTranslation 훅으로 번역 적용하기

실제 번역 데이터를 적용할 수 있도록 도와주는 함수를 반환하는 useTranslation 훅을 사용하기 위해서 간단한 컴포넌트들을 구현하겠습니다.

  • src/components/Title.tsx
import * as React from 'react';

import { useTranslation } from '../context/L10nContext';

const Title = () => {
  const t = useTranslation();
  return (
    <h1>{t('title')}</h1>
  );
};

export default Title;
  • src/components/Description.tsx
import * as React from 'react';

import { useTranslation } from '../context/L10nContext';

const Description = () => {
  const t = useTranslation();
  return (
    <p>{t('description')}</p>
  );
};

export default Description;

위의 TitleDescription 컴포넌트와 같이 useTranslation 훅을 이용해 반환된 함수를 사용하면 손쉽게 번역데이터를 적용할 수 있습니다. 앞서 구현하기로 했던 번역키 자동완성을 지원도 확인해 보겠습니다.

5 translation key auto complete

위의 이미지와 같이 t 함수에 인자를 입력하려고 할 때 번역키가 자동완성이 되도록 지원되는 것을 확인할 수 있습니다. 실제 번역키의 경우 길고 복잡한 경우가 많기 때문에 생산성에 있어 큰 도움이 될 것입니다.

마지막으로 언어별 페이지에 접근해 실제 번역 데이터가 정상적으로 적용되었는지 확인해 보겠습니다.

번역 데이터가 적용된 언어별 페이지
  • localhost:8000/ko/
6 ko page
  • localhost:8000/en/
6 en page
  • localhost:8000/ja/
6 ja page

마무리

모든 작업이 완료되었습니다. 이 포스트에서 어떤 작업을 진행했는지 살펴보겠습니다.

  1. Gatsby Node API를 이용해 스키마와 노드를 생성했습니다.
  2. 생성된 노드를 이용해 페이지를 생성했습니다.
  3. / 경로로 접근 시 브라우저 설정 언어로 이동되게 했습니다.
  4. 전체 번역 데이터를 컴포넌트 전체적으로 사용할 수 있게 했습니다.
  5. 번역 데이터를 가져오는 함수에서 번역키 자동완성을 지원하도록 했습니다.

위와 같은 작업을 진행하며 대부분의 로직을 fp-ts를 이용한 함수형 프로그래밍 기반으로 작업했습니다. 기존 명령형 코드에 비하여 조금 더 눈에 보기 편하셨나요? 아니면 이해하기 어려우셨나요?

프론트엔드 개발을 진행하며 자주 겪어보지 못했던 파일 입출력을 이용한 개발이나 익숙하지 않은 패러다임인 함수형 프로그래밍을 데브시스터즈에서 웹 애플리케이션 지역화를 지원하는 방법과 함께 소개해드릴 기회가 된 것 같아 즐겁게 글을 작성할 수 있었습니다.

긴 글 읽어주셔서 감사드리며 데브시스터즈에서 여러 가지 문제들을 같이 즐겁게 해결해 나갈 개발자들을 모집합니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

데브시스터즈에서는 능력있는 웹 프론트엔드 엔지니어웹 백엔드 엔지니어를 찾고 있습니다.
자세한 내용은 채용 사이트를 확인해주세요!
프론트엔드백엔드사내시스템채용

© 2024 Devsisters Corp. All Rights Reserved.