데브시스터즈에서는 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는 여기에서 확인할 수 있습니다.
번역 데이터를 생성하기 위해서는 createSchemaCustomization 과 sourceNodes 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-ts
의 Array.map
은 기존 자바스크립트 배열의 map
함수와 동일한 동작을 합니다. 자세한 내용은 여기에서 확인할 수 있습니다.
fp-ts
의Array.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 쿼리 결과
위의 이미지 결과와 같이 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
외에도 Option
과 TaskEither
타입을 사용하게 되었습니다. 관련 타입은 아래에 간단하게 설명하고 넘어가겠습니다.
Option 타입 설명
Option
타입은 아래와 같이 구현되어 있습니다.
type Option<A> = None | Some<A>
Option<A>
는 선택적인 타입의 값에 대한 컨테이너 입니다. 만약에 A
가 존재한다면 Option<A>
는 Some<A>
인스턴스가 될 것이고, A
가 존재하지 않는다면 None
이 될 것입니다.
Option
은 0개나 1개의 원소를 가진 집합 또는 접을 수 있는 foldable한 구조로 볼 수 있습니다.
여기서
fold
는reduce
로도 알려져 있으며 자료 구조를 분석해 전달받은 명령을 통해 반환값을 만들어내는 함수입니다.
Option
의 fold
함수의 시그니처는 아래와 같습니다. 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
타입은 Task
와 Either
타입이 결합된 타입입니다.
Task
타입 시그니처 [fp-ts 공식문서 - Task]
Task<A>
타입은 A
타입 값을 반환하는 절대 실패하지 않는 비동기 연산을 의미합니다.
interface Task<A> {
(): Promise<A>
}
Either
타입 시그니처 [fp-ts 공식문서 - Either]
Either
타입은 **두개의 가능한 타입 중 하나의 값(분리 합집합)**을 나타내는 타입입니다.
분리 합집합은 서로소 합집합이라고도 하며 교집합을 허락하지 않습니다. [더 보기]
type Either<E, A> = Left<E> | Right<A>;
Either
인스턴스는 Left
또는 Right
인스턴스 둘 중 하나가 됩니다. Either
타입을 일반적으로 사용하는 것은 Option
타입의 대안으로 결측값을 처리할 때 사용되며 None
은 유용한 값을 포함할 수 있는 Left
로 대체됩니다. 관습적으로는 Left
는 실패를 Right
는 성공을 위해 사용됩니다.
TaskEither
타입 시그니처 [fp-ts 공식문서 - TaskEither]
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
를 하는중 예외가 발생했을 때 프로세스를 종료하기 위해 사용하는 함수입니다. TaskEither
의 tryCatch
는 예외를 잡아주지만, 프로세스를 종료시키지 않습니다. 따라서 NodeJS
인 process.exit
함수를 이용해 프로세스를 종료시킵니다.
const exitProcess = <E>(e: E) => (console.error(e), process.exit(1));
graphql
을 이용해서 가져온 데이터가 예외 없이 성공했다면 match
함수를 통해 getTranslationDataNode
함수가 실행됩니다. Option
의 fromNullable
함수는 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
함수를 구현합니다. TaskEither
의 tryCatch
를 이용해 graphql
함수를 실행하고 Promise
가 reject
되어도 그대로 TaskEither
를 반환하도록 identity
를 사용합니다.
그 후 match
함수를 이용해 Left
가 반환될 경우 exitProcess
를 Right
가 반환될 경우 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 페이지에서 확인한 생성된 페이지
생성된 언어별 페이지로 이동하면 오류가 발생하는 것을 확인할 수 있습니다. 이제 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/
localhost:8000/en/
localhost:8000/ja/
루트(/
) 경로로 접근시 언어별 페이지로 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.navigator
의 language
값을 가져와 -
로 분리합니다. 한국어의 경우에는 window.navigator
의 값이 ko-KR
이며 IE와 같은 구형 브라우저에서는 language
가 아닌 userLanguage
로 값을 가져와야 할 수 있습니다.
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-ts
의 string
모듈을 추가로 가져왔습니다.
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
함수를 이용해 문자열을 하나씩 합치며 문자열 Semigroup
의 concat
을 이용해 닫는 괄호를 추가해줍니다.
Semigroup
은concat
매서드를 포함하고 있으며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 버튼을 눌러 쿼리를 실행합니다.
이제 우리는 이 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
를 받아 번역 데이터값을 반환하는 함수입니다. 여기에서 사용된 GatsbyTypes
는 gatsby-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
값을 이용해 L10nContext
의 Provider
에 전달할 값을 반환하는 함수를 생성해야 합니다. 아래와 같이 데이터가 없을 경우 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;
위의 Title
과 Description
컴포넌트와 같이 useTranslation
훅을 이용해 반환된 함수를 사용하면 손쉽게 번역데이터를 적용할 수 있습니다. 앞서 구현하기로 했던 번역키 자동완성을 지원도 확인해 보겠습니다.
위의 이미지와 같이 t
함수에 인자를 입력하려고 할 때 번역키가 자동완성이 되도록 지원되는 것을 확인할 수 있습니다. 실제 번역키의 경우 길고 복잡한 경우가 많기 때문에 생산성에 있어 큰 도움이 될 것입니다.
마지막으로 언어별 페이지에 접근해 실제 번역 데이터가 정상적으로 적용되었는지 확인해 보겠습니다.
번역 데이터가 적용된 언어별 페이지
localhost:8000/ko/
localhost:8000/en/
localhost:8000/ja/
마무리
모든 작업이 완료되었습니다. 이 포스트에서 어떤 작업을 진행했는지 살펴보겠습니다.
- Gatsby Node API를 이용해 스키마와 노드를 생성했습니다.
- 생성된 노드를 이용해 페이지를 생성했습니다.
/
경로로 접근 시 브라우저 설정 언어로 이동되게 했습니다.- 전체 번역 데이터를 컴포넌트 전체적으로 사용할 수 있게 했습니다.
- 번역 데이터를 가져오는 함수에서 번역키 자동완성을 지원하도록 했습니다.
위와 같은 작업을 진행하며 대부분의 로직을 fp-ts
를 이용한 함수형 프로그래밍 기반으로 작업했습니다. 기존 명령형 코드에 비하여 조금 더 눈에 보기 편하셨나요? 아니면 이해하기 어려우셨나요?
프론트엔드 개발을 진행하며 자주 겪어보지 못했던 파일 입출력을 이용한 개발이나 익숙하지 않은 패러다임인 함수형 프로그래밍을 데브시스터즈에서 웹 애플리케이션 지역화를 지원하는 방법과 함께 소개해드릴 기회가 된 것 같아 즐겁게 글을 작성할 수 있었습니다.
긴 글 읽어주셔서 감사드리며 데브시스터즈에서 여러 가지 문제들을 같이 즐겁게 해결해 나갈 개발자들을 모집합니다.