Lepisode Tech 로고Lepisode Tech

언어는 나중에 추가하면 되겠지 — 그 말이 얼마나 위험한지 알게 됐습니다

박지훈

다국어는 기능이 아니라 아키텍처였습니다

안녕하세요. IP 콘텐츠의 글로벌 유통 플랫폼을 개발하고 있는 개발자입니다. 한국 IP 홀더가 등록한 작품을 해외 바이어가 검색하고, 딜을 제안하는 B2B 서비스를 만들고 있어요.

처음 설계할 때 "다국어는 나중에 붙이면 된다"고 판단했고, MVP 개발에 집중했습니다. 한국어·영어·일본어 3개 국어를 지원해야 한다는 건 알고 있었지만, 기능 우선이었어요. 그 결과 수십 개 페이지의 UI 텍스트가 전부 한글 하드코딩으로 채워졌고, 이후에 다국어를 도입하면서 꽤 큰 대가를 치렀습니다.

이 글에서는 그 과정에서 내린 기술적 결정들과, 다시 한다면 무엇을 바꿀지를 공유하려고 합니다.


번역해야 할 텍스트가 두 종류라는 걸 뒤늦게 알았습니다

다국어 도입을 준비하면서 가장 먼저 마주한 문제는 라이브러리 선택이 아니었어요. **"번역해야 할 텍스트가 한 종류가 아니다"**라는 사실이었습니다.

첫 번째는 정적 텍스트입니다. 개발자가 작성한 UI 문구예요. 버튼 라벨, 페이지 제목, 토스트 메시지처럼 빌드 시점에 확정되어 있고, 번역 파일에 미리 키를 정의해둘 수 있습니다.

두 번째는 동적 텍스트입니다. 사용자가 입력한 콘텐츠예요. 작품 설명, 거래 제안서, 부트캠프 소개처럼 누가 무슨 내용을 등록할지 알 수 없기 때문에, 미리 번역 파일에 정의해둘 수가 없습니다.

이 두 가지를 같은 시스템으로 처리하려고 하면, 어느 쪽도 깔끔하게 동작하지 않더라고요. 정적 시스템에 동적 콘텐츠를 넣으면 키가 끝없이 늘어나고, 동적 시스템으로 UI 문구까지 처리하면 번역 API 비용이 불필요하게 커지고요.

결국 두 트랙을 완전히 분리하기로 했습니다. 기준은 단순했어요.

"이 텍스트가 개발자 손에서 나오는가, 사용자 손에서 나오는가."

전자면 정적, 후자면 동적. 이 기준을 초기에 세우지 않으면, 두 방식이 뒤섞인 채로 코드가 쌓이게 됩니다.


정적 번역 — 번역 JSON을 서버에 둔 이유

정적 번역은 보통 클라이언트에 JSON 파일을 두고 직접 로드하는 방식을 많이 사용합니다. 저희는 다르게 접근했어요.

번역 JSON 파일을 서버에 두고, 서버가 시작될 때 DB에 동기화합니다. 클라이언트는 JSON 파일을 직접 읽지 않고, API를 통해 DB에서 번역 데이터를 가져오는 구조예요.


[JSON 파일] → 서버 시작 시 DB 시드 → [i18n 테이블] → API → [클라이언트]

왜 이런 우회를 했을까요? 이유는 하나였습니다. 운영 중에 번역을 즉시 수정할 수 있어야 했어요.

클라이언트에 JSON을 두면 번역 하나 고치는 데 빌드 → 배포 사이클을 돌려야 합니다. 서비스가 라이브인 상태에서 "이 버튼의 일본어 표현이 어색하다"는 피드백이 들어오면, 어드민 화면에서 바로 수정하고 싶었거든요. DB를 번역의 Single Source of Truth로 만들면 이게 가능해집니다. JSON 파일은 초기 시드 역할만 하게 되고요.

물론 트레이드오프는 있었습니다. 클라이언트 초기화 시 API 호출이 하나 추가되니까요. 하지만 번역 데이터는 한 번 로드하면 세션 내내 재사용되기 때문에, 감수할 만한 비용이었습니다.


동적 번역 — "일단 보여주고, 캐싱하자"

동적 콘텐츠에는 Google Translate API를 사용합니다. 여기서 핵심 결정은 **"언제 번역을 만들 것인가"**였어요.

이상적으로는 데이터가 생성되는 시점에 즉시 번역을 만들어두는 게 맞습니다. 그런데 현실적인 문제가 있었어요. 다국어 기능 없이 축적된 기존 데이터에는 번역이 하나도 없었거든요. 전체 데이터를 일괄 번역하는 마이그레이션을 돌릴 수도 있지만, 데이터가 계속 늘어나는 상황에서 일회성 마이그레이션만으로는 부족합니다.

그래서 Lazy Translation 패턴을 선택했습니다.

핵심 아이디어는 간단합니다. 조회 시점에 캐시된 번역이 없으면, 그 자리에서 번역을 생성하고 DB에 저장한 뒤 응답하는 거예요.


1. 해외 바이어가 작품 상세를 조회
2. 해당 작품의 영어 번역이 DB에 있는지 확인
3-a. 있으면 → 캐시된 번역을 적용해서 응답
3-b. 없으면 → Google Translate API 호출 → DB에 저장 → 적용해서 응답

첫 조회만 번역 생성 비용이 추가되고, 이후 요청은 캐시된 결과를 사용합니다. 원본 콘텐츠가 수정되면 캐시를 지우고, 다음 조회 시 재생성하고요.

  • *"완벽한 사전 번역"보다 "마이그레이션 없이 즉시 동작하는 구조"**를 선택한 셈인데, 중간 도입이라는 제약 조건에서는 합리적인 타협이었다고 생각합니다.

두 시스템을 연결한 브릿지 — 그리고 예상 못한 부작용

정적 번역과 동적 번역을 분리하면, 클라이언트 코드에서 "이건 정적이고 저건 동적"이라고 신경 써야 할까요? 저희는 그러고 싶지 않았습니다.

@ngx-translate에는 번역 키를 찾지 못했을 때 호출되는 핸들러가 있는데요. 이를 커스터마이징해서, 정적 번역에 없는 키가 들어오면 동적 번역 API로 폴백시켰습니다.


번역 키 요청 → 정적 번역에서 검색
  ├── 찾으면 → 바로 반환
  └── 못 찾으면 → 동적 번역 API 호출 → 반환

이 브릿지 덕분에 클라이언트 코드에서는 번역의 출처를 신경 쓰지 않아도 됩니다. {{ key | translate }} 하나로 정적이든 동적이든 알아서 가져오는 거죠.

그런데 예상 못한 부작용이 있었어요. 번역 키 오타를 잡을 수 없게 됐습니다.

정적 번역 키를 common.action.confrim(오타)으로 잘못 적어도, 핸들러가 동적 번역으로 폴백을 시도하면서 에러 없이 조용히 넘어갑니다. 디버깅할 때 "이 번역이 어디서 오는 거지?"를 추적하기가 꽤 까다로워졌어요. 편리함의 대가가 디버깅 난이도 상승으로 돌아온 케이스였습니다.


가장 비싸게 배운 교훈 — 키 네이밍 컨벤션

정적 번역에서 가장 큰 비용을 만들어낸 건 라이브러리 선택도, 아키텍처 설계도 아니었어요. 번역 키 이름을 어떻게 지을지 합의하지 않은 것이었습니다.

초기에는 각자 편한 대로 키를 만들었어요. loginTitlelogin_page_titleauth.login.title 같은 키들이 뒤섞이기 시작했고, 번역 파일이 수백 개 키로 불어나자 중복과 불일치가 빈번해졌습니다.

결국 전체 키를 재구조화하는 리팩토링을 해야 했어요. 5단계 마이그레이션 계획을 세워서, 코드베이스 전체의 번역 키를 페이지.섹션.항목 형식의 dot notation으로 통일하는 작업이었습니다.

번역 키 하나를 바꾸면, JSON 파일 3개(ko, en, ja)와 해당 키를 참조하는 모든 HTML 템플릿·TypeScript 파일을 수정해야 합니다. 수백 개의 키를 바꾸는 건 며칠이 걸리는 작업이었어요.

처음부터 common.action.confirmtoast.saveddealroom.detail.back_to_list 같은 규칙을 정했다면, 이 마이그레이션 전체가 불필요했습니다.

번역 키 네이밍 컨벤션은 코드 네이밍 컨벤션만큼 중요합니다. 프로젝트 시작 시 10분만 투자해서 합의하면, 나중에 며칠짜리 리팩토링을 피할 수 있어요.


번역 로직이 모든 서비스를 오염시킨 문제

동적 번역에서 아직 해결하지 못한 아쉬운 점도 있어요.

번역을 적용하려면 각 도메인 서비스의 조회·생성·수정 메서드마다 번역 로직을 수동으로 삽입해야 합니다. 이 패턴이 프로젝트, 부트캠프, 배너, 과제, 거래 제안 등 10개 이상의 서비스에서 반복되고 있어요.


// 이 패턴이 10개 이상의 서비스에서 반복됩니다
asyncfindById(id:string,locale?:string) {
    let project=awaitdb.project.findUnique({where: {id } });

    if (locale && locale!=='ko-KR') {
        project = await translate(project,'Project', ['name','description'],locale);
        // 관계 데이터도 각각 번역해야 합니다
        if (project.genre) {
            project.genre=await translate(project.genre,'Genre', ['name'],locale);
        }
    }
    return project;
}

문제는 단순한 코드 중복이 아닙니다. 번역이 비즈니스 로직과 결합되면서, "이 API에 번역이 적용되어 있는가?"를 매번 확인해야 해요. 새 서비스를 추가할 때 번역 적용을 깜빡하면, 그 모듈의 해외 사용자에게는 한국어가 그대로 노출됩니다.

본질적으로 번역은 로깅이나 인증처럼 Cross-Cutting Concern인데, 서비스 레이어에서 처리하고 있는 것이 문제예요. 인터셉터나 미들웨어에서 선언적으로 처리하는 구조가 더 적합했을 거라고 생각합니다. 이 부분은 아직 개선 과제로 남아 있어요.


돌아보며 — 초기에 30분이면 충분했을 결정들

다국어 도입 전체를 돌아보면, 가장 비싼 비용을 만들어낸 원인은 기술적 난이도가 아니었어요. 프로젝트 초기에 30분이면 끝낼 수 있는 결정들을 미뤄둔 것이 원인이었습니다.

1) "번역 대상이 한 종류인가, 두 종류인가"를 구분하는 것 정적/동적 분리는 코드를 한 줄도 쓰기 전에 할 수 있는 판단입니다. 이 구분 없이 코드가 쌓이면, 나중에 두 시스템을 분리하는 작업이 리팩토링 수준이 됩니다.

2) 번역 키 네이밍 규칙을 정하는 것 page.section.item 형식, snake_case 사용. 이 정도 합의만으로 키 구조 전면 재작업을 예방할 수 있습니다.

3) 번역 적용 레이어를 정하는 것 서비스마다 수동으로 넣을 것인지, 인터셉터에서 자동으로 처리할 것인지. 이 결정이 늦어질수록 보일러플레이트가 쌓입니다.


"다국어는 나중에 붙이면 됩니다"라는 말은 틀리지 않아요. 실제로 나중에 붙일 수 있습니다. 다만 그 "나중"이 예상보다 훨씬 비싸다는 것을 알고 하는 판단이어야 합니다.

구조를 나중에 바꾸는 비용은, 처음에 구조를 잡는 비용과 비교할 수 없을 만큼 큽니다. 다국어를 지원할 가능성이 조금이라도 있다면, 위의 세 가지만이라도 프로젝트 시작 시점에 정해두시길 권합니다.