데브시스터즈에서는 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 AdultgetOptionAge에서 반환된 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,
},
});