프론트엔드에서의 국제화와 현지화 (feat. 모노레포)

November 18, 2024

국제화, 현지화에 대한 개인적인 고민

프론트엔드에서의 국제화와 현지화 (feat. 모노레포)

들어가며

요근래 회사에서 다국어 어플리케이션 작업을 하게 되었다. 개인적으로 다국어 어플리케이션 개발은 평소에도 관심있던 주제였기에 꽤나 흥미롭고 재밌게 작업할 수 있었지만, 홈페이지와 서비스를 다국어로 개발하면서 기존에 단일 언어로 개발하던 방식과는 꽤나 다르게 접근해야하는 부분들이 많았던것 같다. 다국어 어플리케이션을 개발하면서 겪은 개인적인 고민을 정리해보려 한다.

국제화와 현지화의 차이

주로 프론트엔드 개발자에게 다국어라는 키워드를 물어봤을 때 대부분은 i18n 이라고도 줄여서 불리는 국제화(Internationalization) 을 우선적으로 떠올릿 것이라고 생각한다. 물론 나 또한 그랬다. 국제화의 경우에는 가장 큰 핵심은 말 그대로 다국어 지원에 존재한다. 그렇다보니 국제화가 적용된 서비스의 경우 주로, 언어별 글꼴, 다국어에 따른 레이아웃 설계, 번역 텍스트 관리가 주요 관심사이다. 개발할 때에는 react-18n이나 next-intl 같은 i18n을 쉽게 컨트롤 할 수 있는 라이브러리를 사용하면서 국제화를 도입한다.
이제는 필수가 되어버린 슬랙
이제는 필수가 되어버린 슬랙
국제화가 적용된 대표적인 서비스로는 Slack이 있다. Slack은 다국어 지원을 지원하여 사용자가 원하는 언어로 서비스를 이용할 수 있게 해준다. 또한, Slack은 서로 다른 국가에 위치하거나 각자 다른 언어를 사용하는 사용자가 함께 이용할 수도 있기 때문에 국제화가 필수적인 서비스이다. 물론 슬랙 또한 현지화가 적용된 부분들이 존재한다 하지만, 어떤 서비스들은 국제화만으로는 부족한 경우가 있다. 예를 들어, 서비스의 특성상 각 나라별로 다른 서비스를 제공해야하는 경우가 있다. 이런 경우에는 현지화(Localization) 가 필요하다. 현지화는 국제화의 확장판이라고 생각하면 된다. 현지화는 국제화를 넘어서 각 나라의 문화적, 지리적, 혹은 법적 특성에 따른 분리된 UI, 비즈니스 로직을 제공해야 한다.
아무도 몰랐겠지만 아마존은 사실 클라우드 컴퓨팅 회사가 아니라 이커머스 서비스입니다
아무도 몰랐겠지만 아마존은 사실 클라우드 컴퓨팅 회사가 아니라 이커머스 서비스입니다
현지화가 적용된 대표적인 서비스로는 Amazon이 있다. Amazon은 각 나라별로 다른 서비스를 제공하며 나라별로 다른 상품, 배송, 결제 방식을 제공하며, 지켜야하는 법규와 통화, 측량 단위를 사용한다. 이런 경우에는 국제화만으로는 부족하며, 현지화가 필수적인 서비스이다.

프론트엔드에서의 국제화와 현지화

국제화의 경우에는 비교적 구조를 설계하는데 크게 어려움은 존재하지 않는다. 국제화는 주로 다국어 지원에 초점을 맞추기 때문에, 언어별로 다른 글꼴, 레이아웃, 번역 텍스트를 관리하는 것이 주요 관심사이다. 물론 엑셀과 동기화하여 번역 텍스트를 동기화 하는 등 여러 추가 작업을 통해 번역 텍스트 매니징을 효율적으로 하는 등의 작업이 가능하지만, 프로젝트 구조적인 측면에서 다이나믹한 변화는 잘 일어나지 않는다. 하지만 현지화의 경우에는 다르다. 아무래도 현지화가 필요한 서비스가 국제화가 필요한 서비스에 비해 설계에 대한 고민이 늘어나는 이유는 결국 비즈니스 로직 의 분리가 필요하기 때문이다.

현지화 추상화에 대한 고민

현지화 과정에서 발생하는 고민으로는 어떤게 있을까? 통화 관리, 국가별 라우팅? 사실 내가 가장 절망을 느낀 부분은 다음과 같았다.
아니 국가별로 분리하기엔 겹치는 로직이 너무 많은데...?
차라리 완전히 다른 비즈니스 로직을 가지거나 동일한 로직을 가지면 고민할 거리가 비교적 적겠지만, 현실은 원하는대로 흘러가지 않는다. 사실 현지화를 적용한다고 하더라도 대부분의 비즈니스 로직은 동일하며 약간의 포인트들이 국가별로 다른 경우가 훨씬 많았다. 예를 들어 상품 등록이 존재하는 이커머스 서비스를 가정해보자. 이 서비스는 다음과 같은 프로세스를 가지고 있다.
  1. 상품의 이름을 입력한다.
  2. 상품의 가격을 입력한다.
  3. 상품의 카테고리를 선택한다.
  4. 상품의 설명을 입력한다.
  5. 상품의 이미지를 업로드한다.
  6. 상품의 재고 위치를 입력한다.
  7. 상품의 결제 방식을 선택한다.
이 일곱 가지의 주문 프로세스 스텝에서 과연 현지화가 필요한 부분은 무엇일까? 도메인별로 다르겠지만 아마 다음과 같을 것이라고 추론할 수 있다.
  1. 상품의 이름을 입력한다.
  2. 상품의 가격을 입력한다.
  3. 상품의 카테고리를 선택한다.
  4. 상품의 설명을 입력한다.
  5. 상품의 이미지를 업로드한다.
  6. 상품의 재고 위치를 입력한다.
  7. 상품의 결제 방식을 선택한다.
상품의 경우 이름, 설명, 이미지는 단순히 국가별로 다른 언어로 제공하는 것이므로 국제화로 해결할 수 있지만, 가격, 재고 위치, 결제 방식 등록에 대한 비즈니스 로직은 국가별로 다른 비즈니스 로직을 가지게 된다. 예를 들어 위치 정보의 경우 한국은 카카오 주소 검색 API를 사용하지만 미국의 경우는 미국 우편번호 검색 API를 사용하는 등의 차이가 발생할 수 있다. 이렇다보니 컴포넌트나 비즈니스 로직을 설계하는 과정에서 추상화의 경계를 어디까지 둘지에 대한 고민이 필요하다.

무한 원숭이 정리

무한 원숭이 정리는 무한성에 기초한 정리로, 타자기 앞에 앉아서 마음대로 쳐대는 원숭이가 프랑스 국립 박물관의 모든 책을 언젠가는 쳐 낼 가능성이 거의 확실하다는 정리이다. - 위키백과
하지만 그렇다고 너무 단순하게 생각할 수도 없다. 사실 로컬라이징의 경우에도 i18n을 통해 어느정도 해결할 수 있다. i18n 관련 라이브러리에서 제공하는 현재 국가를 반환하는 API를 통해 단일 어플리케이션에서 분기 처리를 할 수 있기 때문이다. 하지만 다음 같은 요구사항이 주어진다고 생각해보자.
우리의 어플리케이션은 한국/미국/일본/중국/베트남 총 5개의 국가를 지원해야 합니다.
각 국가의 경우 국가에 맞는 배송 API를 통해 요청이 이루어지며, 각 국가의 오후 6시마다 배송에 대한 분기처리가 존재합니다. 분기 처리의 루트 또한 국가별로 상이합니다.
이외에도 유저의 등급과 관련된 부분들이 존재합니다.
이때 개발자가 생각해야할 분기의 플래그는 한국/미국/일본/중국/베트남 총 5개의 국가와 유저의 등급, 그리고 배송 분기 처리 총 7개의 플래그를 고려해야 한다. 이 경우, 다음과 같은 케이스들이 발생 한다.
  • 한국 배송 API / 오후 6시 이전 / VIP 유저
  • 한국 배송 API / 오후 6시 이후 / VIP 유저
  • 한국 배송 API / 오후 6시 이전 / 일반 유저
  • 한국 배송 API / 오후 6시 이후 / 일반 유저
  • 미국 배송 API / 오후 6시 이전 / VIP 유저
  • 미국 배송 API / 오후 6시 이후 / VIP 유저
  • 미국 배송 API / 오후 6시 이전 / 일반 유저
  • 미국 배송 API / 오후 6시 이후 / 일반 유저
  • 일본 배송 API / 오후 6시 이전 / VIP 유저
  • 그 외 무한한 케이스...
무한한 요구사항을 마주한 무력한 개발자
무한한 요구사항을 마주한 무력한 개발자
위와 같이 수많은 분기 처리에 대한 고통은 모두 개발자가 안고 있어야 한다. 이러한 성향은 당연히 국가별로 비즈니스가 이루어지는 과정이 상이할 때 더욱 부각된다. 컴포넌트를 나누어 각 컴포넌트 내부에서 분기 처리를 하는 것 또한 가능하지만, 결국 플래그가 많아지면서 생기는 근본적인 문제는 해결할 수 없다. 그리고 모두 알다시피 요구사항은 절대 초기의 모습을 유지하지 않는다. 플래그가 추가되면 추가될수록 분기 처리의 수도 늘어나며 개발자가 흘리는 눈물 또한 기하급수적으로 늘어난다. 저 수많은 플래그를 감당하는 테스트 Suite를 작성한다고 상상해보자. 울지 않을 수 없다.

모노레포를 통한 해결법

개인적으로 위와 같은 상황은 마주하고 싶지 않았기에 해결법에 대해 고민의 시간을 가졌다. 결국 내가 선택한 방법은 모노레포 였다. 모노레포를 통해 각 국가별로 어플리케이션을 분리하고, 어플리케이션마다 비즈니스 로직을 분리하는 방법을 선택하였다. 국가별 비즈니스 로직 이외에 공통으로 사용되는 컴포넌트나 코드는 패키지로 분리하여 개발하는 방법을 선택하였다. 이러한 방법의 장점은 명확하다. 국가별로 비즈니스 로직을 분리하기 때문에 어플리케이션 레벨에서의 분기 처리에 대한 고민이 줄어든다. 반대로 단점으로는 만약 모든 국가에서의 변경사항이 발생한다면 모든 어플리케이션을 수정해야 한다는 것이다. 이러한 상황을 예방하기 위해서는 국가별 비즈니스 로직과 공통 비즈니스 로직을 확실히 구분하여 어플리케이션 레벨에서 코드를 직접 수정하지 않도록 하는 것이 중요하다. 그러나, 개발하면서 패키지가 많아지다보니 예상치 못한 문제들이 발생하였다. 그 중 가장 큰 문제는 패키지 순환참조 였다. 모노레포의 패키지 순환참조는 서로 다른 두개의 패키지가 서로 참조하는 경우에 발생한다. 패키지가 적은 경우에는 이러한 상황이 발생하지 않았지만, 패키지가 많아지면서 이러한 문제가 발생하였다. 예를들어, 각종 유틸리티 함수들이 존재하는 utils 패키지와, 국가별로 시간대와 측량을 현지화해주는 localization 패키지가 존재한다고 가정해보자. utils에 두 시각의 차이를 계산하는 getDiffTime 함수가 존재하고, 해당 함수가 localization 패키지의 현지 시간을 가져오는 getLocalDate 함수를 의존한다. 또한 localization 패키지의 현지 getLocalDate 함수가 utils 패키지의 날짜를 포매팅하는 dateFormatter 함수를 의존한다. 이러한 경우에는 빌드 과정에서 순환참조 에러를 마주하게 된다. 패키지가 많으면 많아질수록 개발자가 예상하지 못하는 순환참조 에러가 발생할 가능성이 높아진다.

패키지 레이어

패키지의 순환 참조로 인해 골머리를 앓던 와중에 떠올린 해결법은 흔히 접할 수 있는 레이어 아키텍처 였다. 레이어 아키텍쳐를 선택한 이유는 패키지의 순환 참조를 해결하기 위함이 아니라, 패키지 간의 의존성을 명확하게 분리하기 위함이었다. 레이어 아키텍쳐의 절대법칙은 하위 레이어는 상위 레이어에 의존할 수 있지만, 상위 레이어는 하위 레이어에 의존해서는 안된다. 이다. 아러한 핵심을 패키지 레벨 내에서 적용시킴으로써 패키지 간의 의존성을 명확하게 분리할 수 있었다.
대략적인 패키지 레이어의 구조
대략적인 패키지 레이어의 구조
패키지의 레이어 계층은 대략 위 사진과 같다. 패키지별 의존성 규칙은 다음과 같이 적용된다.
  • Case 1. Utility Layerutils 패키지는 하위 레이어인 Configuration Layereslint-config 패키지를 의존할 수 있다.
  • Case 2. UI Component Layerui 패키지는 하위 레이어인 Utility Layerlocale 패키지를 의존할 수 있다.
  • Case 3. React Based Utility Layerhooks 패키지는 상위 레이어인 UI Component Layercomponents 패키지를 의존할 수 없다.
  • Case 4. Service Layerqueries 패키지는 상위 레이어인 Domain Business Layerdomain 패키지를 의존할 수 없다.
즉, 패키지 레이어 내에서 상위 레이어는 하위 레이어에 의존할 수 있지만, 하위 레이어는 상위 레이어에 의존할 수 없다. 각 레이어의 패키지에 대한 간략한 설명은 다음과 같다.

Configuration Layer

  • eslint-config : 각 패키지에 사용되는 eslint의 설정 파일인 eslint.config.js 파일을 정의한다.
  • typescript-config : 각 패키지에 사용되는 typescript의 설정 파일인 tsconfig.json 파일을 정의한다.
패키지의 전반적인 설정을 위한 Configuration Layer 패키지는 각 패키지에 사용되는 기본적인 컴파일러나 포맷팅 설정을 정의한다. 상위 레이어에 위치하는 패키지나 어플리케이션은 각 설정을 기반으로 extend하여 추가적인 설정을 추가한다.
ex) ui 패키지에서의 storybook 관련 eslint 설정 추가

Utility Layer

  • utils : 각 패키지에 사용되는 유틸리티 함수를 정의한다. 해당 유틸리티 함수는 도메인에 종속되는 비즈니스 로직을 포함하지 않는다.
  • locale : 각 패키지에 사용되는 측량, 시간 등 현지화 및 번역 함수를 정의한다. 해당 현지화 함수는 도메인에 종속되는 비즈니스 로직을 포함하지 않는다.
Utility Layer에 속하는 패키지는 각 패키지에 사용되는 기본적인 유틸리티 함수와 현지화 함수를 정의한다. 해당 레이어의 핵심은 도메인에 종속되는 비즈니스 로직을 포함하지 않는다 이다. 예를 들어 구매에 대한 비즈니스 로직의 순서가 주소 입력 => 결제 수단 선택 => 전화번호 입력 => 전화번호 양식 포매팅 => PG사 결제라고 가정한다면, 전화번호 양식 포매팅 같은 비즈니스 로직에 종속되지 않는 모듈들만을 관리한다. 다만 locale 패키지는 단순 번역 텍스트를 관리하는 패키지이므로 일부 비즈니스에 대한 명명이 존재할 수 있다.

React Based Utility Layer

  • hooks : 각 패키지에 사용되는 커스텀 훅을 정의한다.
  • store : 각 패키지에 사용되는 전역 상태 관리 스토어를 정의한다.
React Based Utility에 속화는 패키지 또한 비즈니스 로직을 포함하지 않는다. 하위 레이어인 Utility Layer와의 차이점은 메인 어플리케이션에서 사용하는 react 관련 모듈들을 정의한다는 것이다.

UI Component Layer

  • ui : 각 패키지에 사용되는 컴포넌트를 정의한다.
어플리케이션에 사용하는 공통 컴포넌트가 존재하는 레이어이다.

Service Layer

  • queries : 각 패키지에 사용되는 네트워크 요청 관련 함수 및 커스텀 훅을 정의한다.
queries 패키지에서는 기본적인 네트워크 요청 axios 인스턴스나 API 요청 서비스 레이어 및 모델, tanstack/react-query 라이브러리를 의존하는 쿼리 옵션 및 커스텀 훅을 정의한다.

Domain Business Layer

  • domain : 각 패키지에 사용되는 도메인 비즈니스 로직 및 컴포넌트를 정의한다.
해당 레이어의 패키지에서는 하위 레이어의 패키지들을 사용하여 도메인에 종속되는 비즈니스 로직이 포함된 모듈을 정의한다. 다만, 모든 국가가 공통적으로 공유하는 비즈니스 로직에만 국한하여 관리한다. 예를 들어 ProductEditForm 컴포넌트는 queries 패키지에서 네트워크 요청을 통해 선택 목록을 가져오고 locale 패키지에서 번역 텍스트를 가져와 렌더링한 후 store 패키지에서 가져온 유저 데이터를 통해 등록자의 정보를 기본적으로 입력한다. 만약 컴포넌트나 비즈니스 로직에 국가별로 분리되는 로직이 존재한다면, 외부에서 변경되는 부분에 대해 의존성을 주입받아 확장하는 형식으로 구현한다. 확장된 국가별 컴포넌트는 각 국가별 어플리케이션 레이어에 위치한다.

탑다운 방식으로 이해해보기

어플리케이션을 처음부터 구현한다는 가정 하에 다시한번 생각해보자. 우리가 구축할 어플리케이션은 아주 간단하다. 국가별로 금액을 입력하면 나머지 국가에 대한 환율을 계산하는 어플리케이션이다. 모노레포에서 어플리케이션은 다음과 같이 구성된다.
  • app-kr : 한국 어플리케이션
  • app-us : 미국 어플리케이션, 센트 표시 여부가 결정되어야한다.
  • app-jp : 일본 어플리케이션
  • app-cn : 중국 어플리케이션
  • app-vn : 베트남 어플리케이션
금액을 입력하는 Form 컴포넌트를 각 어플레케이션에 위치시킨다.
  • app-kr : 한국 어플리케이션
    • /components/KoreanExchangeRateForm.tsx
  • app-us : 미국 어플리케이션
    • /components/USExchangeRateForm.tsx
  • app-jp : 일본 어플리케이션
    • /components/JapaneseExchangeRateForm.tsx
  • app-cn : 중국 어플리케이션
    • /components/ChineseExchangeRateForm.tsx
  • app-vn : 베트남 어플리케이션
    • /components/VietnameseExchangeRateForm.tsx
각 어플리케이션에서 환전 컴포넌트는 공통적인 비즈니스 로직의 구현체인 ExchangeRateForm를 확장하여 구현한다. -packages/domain/components/ExchangeRateForm.tsx
1// packages/domain 2function ExchangeRateForm( 3 // 확장을 결정하는 props를 주입받는다. 4) { 5 return <form> 6 //... 7 </form>; 8} 9 10// app-en 11function USExchangeRateForm() { 12 return <ExchangeRateForm someProp={utilsForUSA} />; 13} 14
ExchangeRateForm는 모든 국가의 환율을 가져오는 쿼리 커스텀 훅을 queries 패키지에서 가져온다.
1// packages/queries 2function useGetExchangeRate() { 3 return useQuery({ 4 queryKey: ['exchangeRate'], 5 queryFn: () => axios.get('/api/exchange-rate'), 6 }); 7} 8 9// packages/domain 10function ExchangeRateForm() { 11 const { data } = useGetExchangeRate(); 12 13 return <form> 14 //... 15 </form>; 16} 17
입력을 위한 컴포넌트는 ui 패키지에서 정의한다.
1import { Input, Button } from '@repo/ui'; 2 3// packages/domain 4function ExchangeRateForm() { 5 const { data } = useGetExchangeRate(); 6 7 return <form> 8 //... 9 <Input /> 10 <Button> 11 //... 12 </Button> 13 </form>; 14} 15
입력을 처리하기 위한 커스텀 훅을 hooks 패키지에서 정의한다.
1import { useInput } from '@repo/hooks'; 2 3// packages/domain 4function ExchangeRateForm() { 5 const { value, handleChange } = useInput(); 6 const { data } = useGetExchangeRate(); 7 8 return <form> 9 //... 10 <Input value={value} onChange={handleChange} /> 11 <Button> 12 //... 13 </Button> 14 </form>; 15} 16
번역을 위해 locale 패키지에서 번역 텍스트를 가져온다.
1import { translate } from '@repo/locale'; 2 3// packages/domain 4function ExchangeRateForm() { 5 const { value, handleChange } = useInput(); 6 const { data } = useGetExchangeRate(); 7 8 return <form> 9 //... 10 <Input value={value} onChange={handleChange} /> 11 <Button> 12 {translate('계산')} 13 </Button> 14 </form>; 15} 16
위 코드에서 볼 수 있듯이 상위 레이어는 항상 하위 레이어만을 의존한다.

마치며

사실 단순히 생각해보면 i18n 라이브러리를 사용하는 것이 더 간단할 것이다. 하지만 이러한 방식을 굳이 채택한 이유는 현지화 과정에서 생기는 과도한 플래그 처리에 대한 피로를 어느정도 줄이기 위해서였다. 사실 프로젝트를 구현하면서 아쉬운 부분 또한 존재하였는데, 환경 변수를 통한 국가 플래그 주입이나 국가별 비즈니스 로직의 위치라던지 생각보다 딱딱 구분하고 추상화하는게 쉽지만은 않았다. 그래도 단일 언어 어플리케이션에서는 겪어볼 수 없었던 고민들을 해결하는 것 또한 꽤나 흥미로운 경험이었고, 도메인의 비즈니스 로직에 대해 추상화의 경계를 설정하는 것 또한 자극을 주는 도전과제 였던 것 같다. 이 설계 또한 아직 문제점이 많고, 고쳐야할 부분들이 넘치도록 존재한다. 현지화 과정에서 생기는 문제점들을 해결하는 방법은 여러가지이며, 이 글에서 제시한 방법이 정답은 아니다. 하지만 다국어 어플리케이션의 현지화 과정에서 생기는 고민들을 어떤 시각으로 접근하려 했는지 스스로 돌아보고 공유하는 것이 이 글의 목적이기도 하다. 혹시라도 현지화 과정에서 설계에 대한 고민을 겪고 있는 누군가에게 조금이나마 도움이 될까 싶어 이 글을 작성한다.