런타임에 변하는 한글 - i18next에서 조사를 자동으로 선택하기

sumi-0011·2026년 2월 28일

들어가며

i18next 기반 프로젝트에서 국제화 작업을 진행하고 있었습니다. 한국어 텍스트를 하나씩 번역 키로 옮기다 보니, 이런 게 눈에 들어왔습니다.

"deleteModalDescription": "{{name}}을/를 삭제하시겠습니까?"

{{name}}에 들어올 단어에 따라 이 맞을 수도, 이 맞을 수도 있습니다. 그러면 이걸 쓰는 곳마다 사용부에서 직접 분기해야 하나? 싶었습니다.

모든 페이지를 국제화해야 하는 상황에서 이렇게 DX가 안 좋을 리 없다고 생각했고, 이 문제를 좀 파보게 되었습니다. 한국어의 조사 문제였습니다.


왜 이게 문제가 되는가

한국어는 앞 글자의 받침(종성) 유무에 따라 조사가 달라집니다.

받침 있음받침 없음예시
모델 / 도구
모델 / 도구
모델 / 도구
모델 / 도구
으로부산으로 / 서울

보통은 문제가 되지 않습니다. 글을 쓰는 시점에 앞 단어를 알고 있으니까요.

그런데 i18n에서는 상황이 다릅니다. {{name}}에 "모델"이 올지 "도구"가 올지는 런타임에 결정됩니다. 번역 키를 작성하는 시점에는 어떤 조사가 맞는지 알 수가 없습니다.

영어에는 이 문제 자체가 없습니다. "Delete {{name}}"은 name에 뭐가 오든 문법이 동일하거든요. 한국어-영어 다국어 프로젝트에서 한국어 쪽만 이 짐을 안게 되는 셈이죠.

그렇다면 프로젝트에서는 이 문제를 어떻게 다루고 있었을까요?


프로젝트에서 발견한 세 가지 패턴

ko.json을 더 살펴보니, 이 문제를 나름대로 회피하려는 세 가지 패턴이 섞여 있었습니다.

패턴 1. 슬래시 표기: "을/를"을 그대로 노출

"deleteModalDescription": "{{agentName}}을/를 삭제하시겠습니까?"

가장 흔한 패턴이었습니다. 양쪽 다 써놓으면 틀릴 일은 없으니까요. 하지만 "을/를"이 화면에 그대로 나옵니다. UI가 비전문적으로 느껴지는 게 가장 큰 문제였습니다.

패턴 2. 하드코딩된 단일 조사: 특정 단어에서만 맞음

"fieldPlaceholder": "{{name}}를 입력하세요"

"를"로 고정해뒀는데, name에 "설명"이 들어오면 "설명를"이 됩니다. 받침 있는 단어에서 무조건 틀리죠. 아마 처음 작성할 때 테스트한 단어가 받침 없는 단어였을 겁니다.

패턴 3. 키 2개로 분리: 유지보수 부담

"selectFieldPlaceholder": "{{name}}을 선택하세요",
"selectFieldPlaceholder2": "{{name}}를 선택하세요"

가장 정직한 접근이지만, 호출하는 쪽에서 받침 판단을 직접 해야 합니다. 컴포넌트마다 "이 단어는 받침이 있으니 1번 키, 저 단어는 없으니 2번 키"라는 분기 코드가 들어가고, 조사가 필요한 키가 늘수록 키도, 판단 로직도 같이 불어납니다.

세 패턴 모두 근본적인 해결은 아니었습니다. 제가 원한 건 번역 키 하나로, 런타임에 올바른 조사를 자동 선택하는 것이었습니다. 거기에 영어 번역에는 아무 영향을 주지 않아야 했고요.


세 가지 접근법을 비교하다

i18next 생태계에서 이 문제를 해결하는 방법은 크게 세 가지가 있었습니다.

접근법 1. Formatter 커스텀

i18next의 내장 format 함수를 오버라이드하는 방식입니다.

"selectPlaceholder": "{{name, 을}} 선택하세요"

보간 구문 안에 조사 힌트를 넣고, format 함수에서 받침을 판별해 올바른 조사를 반환합니다. 조사를 하나만 쓰면 되니 구문은 간결합니다. 다만 i18next의 기존 formatter와 충돌할 수 있고, 날짜 포맷 같은 다른 format 기능과 조사 처리가 한 함수에 얽히게 되는 점이 걸렸습니다.

접근법 2. Post-Processor 직접 구현

i18next가 보간을 완료한 뒤, 후처리 단계에서 조사를 교체하는 방식입니다.

"selectPlaceholder": "{{name}}(을/를) 선택하세요"

보간이 끝난 문자열을 정규식으로 순회하면서, (을/를) 앞 글자의 받침을 보고 올바른 쪽을 고릅니다. 보간 시스템과 완전히 분리되니 충돌 걱정은 없습니다. 대신 양쪽 조사를 다 써야 하고, 받침 판별 로직과 엣지 케이스(숫자, 영어, 괄호 등)를 직접 챙겨야 합니다.

접근법 3. 검증된 라이브러리 도입

찾아보니 i18next-korean-postposition-processor라는 npm 패키지가 이미 있었습니다. Post-Processor 방식인데, 직접 구현 대신 검증된 코드를 가져다 쓰는 겁니다.

"selectPlaceholder": "{{name}}[[를]] 선택하세요"

조사를 하나만 쓰면 됩니다. [[를]]이라고 쓰면 앞 글자의 받침에 따라 "을" 또는 "를"로 자동 변환됩니다. 숫자나 괄호로 감싸진 텍스트 같은 엣지 케이스도 이미 처리되어 있었습니다. 이 라이브러리가 내부적으로 받침을 어떻게 판별하는지는 뒤에서 따로 살펴봅니다.

어떤 걸 선택했는가

비교 기준Formatter직접 구현라이브러리
구문 간결함{{name, 을}}(을/를) 양쪽[[를]] 하나
구현 비용중간높음거의 없음
테스트 신뢰도직접 작성직접 작성이미 검증됨
엣지 케이스직접 처리직접 처리내장 (숫자, 괄호 등)
유지보수직접직접커뮤니티

최종적으로 라이브러리를 도입하기로 했습니다. 받침 판별이라는 문제는 이미 잘 정의되어 있고, 검증된 구현체가 있는데 굳이 직접 만들 이유가 없었습니다.

Formatter 방식도 구문이 간결해서 끌렸지만, 결정적으로 처리 시점이 달랐습니다. Formatter는 보간 값 자체를 변환하는 단계에서 동작합니다. {{name, 을}}에서 name 값을 받아 조사를 붙인 결과를 돌려주는 방식인데, 이러면 format 함수 하나가 날짜 포맷, 숫자 포맷, 조사 처리를 전부 분기해야 합니다.

반면 Post-Processor는 보간이 끝난 완성된 문자열을 받습니다. "모델[[를]] 선택하세요"처럼 이미 보간이 끝난 상태에서, [[를]] 앞 글자만 보고 조사를 결정하면 됩니다. 기존 format 로직에 영향을 주지 않고, 조사 처리는 어차피 "앞 글자가 확정된 뒤"에야 할 수 있는 작업이니까 Post-Processor가 더 자연스러운 시점이라고 판단했습니다.


구현 과정

바꿔야 할 파일은 4개(package.json, pnpm-lock.yaml, i18n.ts, ko.json), 세 단계로 끝났습니다.

1단계. 설치

pnpm add i18next-korean-postposition-processor

2단계. 플러그인 등록

기존 i18n 설정에 두 줄만 추가하면 됩니다.

// src/lib/i18n/i18n.ts
import koreanPostpositionProcessor from 'i18next-korean-postposition-processor';

i18n
  .use(initReactI18next)
  .use(koreanPostpositionProcessor) // 추가
  .init({
    // ... 기존 설정 유지
    postProcess: ['korean-postposition'], // 추가
  });

여기서 한 가지 주의할 게 있습니다. postProcess 배열에 넣는 이름 'korean-postposition'은 라이브러리 소스의 get name()에서 정의된 값입니다. 이 이름이 틀리면 아무 동작도 하지 않으니, 라이브러리의 실제 name 속성을 꼭 확인해야 합니다.

3단계. 번역 키 마이그레이션

ko.json에서 기존 조사 관련 키들을 [[조사]] 구문으로 바꿨습니다. 규칙은 단순합니다. 기존 조사 자리를 [[조사]]로 감싸면 됩니다.

// 하드코딩 조사 → [[조사]]
"{{name}}를 입력하세요"       →  "{{name}}[[를]] 입력하세요"
"새로운 {{item}}가 생성되었습니다."  →  "새로운 {{item}}[[가]] 생성되었습니다."

// 슬래시 표기 → [[조사]]
"{{agentName}}을/를 삭제하시겠습니까?"  →  "{{agentName}}[[를]] 삭제하시겠습니까?"

[[를]]이든 [[을]]이든 상관없습니다. 앞 글자의 받침에 따라 알아서 올바른 형태로 바뀝니다.

이 작업을 하면서 하나 더 정리한 게 있습니다. 기존에는 "~를 입력하세요", "~을 선택하세요" 같은 placeholder가 컴포넌트마다 따로 있었는데, 공통 키 두 개로 통합했습니다.

"selectPlaceholder": "{{name}}[[을]] 선택하세요",
"inputPlaceholder": "{{name}}[[을]] 입력하세요"

사용하는 쪽에서는 이렇게 쓰면 됩니다.

commonT('selectPlaceholder', { name: t('model') })
// → "모델을 선택하세요"

en.json은 손대지 않았습니다. [[]] 패턴이 없는 문자열은 post-processor가 그냥 통과시키거든요.


라이브러리는 어떻게 동작하는가

앞에서 "엣지 케이스가 이미 처리되어 있다"고 했는데, 실제로 어떤 과정인지 궁금해서 라이브러리 내부를 들여다봤습니다.

  1. i18next가 {{name}}을 보간해서 "모델[[를]] 선택하세요"라는 문자열을 만듭니다.
  2. post-processor가 정규식으로 [[를]]을 찾습니다.
  3. [[를]] 바로 앞 글자 '델'의 유니코드를 분석합니다.

한글 유니코드에서 받침을 판별하는 공식은 (charCode - 0xAC00) % 28입니다. 결과가 0이면 받침 없음, 0이 아니면 받침 있음. 생각보다 단순한 산술 연산 하나로 끝나는 게 인상적이었습니다.

'델'의 코드포인트는 0xB378이고, (0xB378 - 0xAC00) % 28 = 8이니까 받침이 있다고 판별됩니다. 받침이 있으니 대신 이 선택되고, 최종 출력은 "모델을 선택하세요"가 됩니다.

숫자는 한국어 발음 규칙을 따릅니다. "3"은 "삼"으로 읽히니 받침이 있고, "2"는 "이"로 읽히니 받침이 없는 식이죠. "모델"처럼 따옴표나 괄호로 감싸진 텍스트는 괄호 안의 마지막 문자를 기준으로 판별합니다. 직접 구현했다면 이런 케이스를 하나씩 테스트하고 처리해야 했을 텐데, 라이브러리를 선택한 이유가 여기에 있었습니다.


마치며

처음에 ko.json에서 발견한 "을/를"이 화면에 그대로 찍히던 문제(슬래시 표기, 하드코딩 조사, 키 분리)는 결국 같은 원인이었습니다. 런타임에 결정되는 보간 값의 받침을 번역 키 작성 시점에 알 수 없다는 것이죠.

세 가지 접근법을 비교하고, 구현 비용과 검증도를 기준으로 라이브러리를 도입했습니다. 하드코딩 텍스트를 i18n으로 옮기는 작업과 함께 조사 처리까지 한 PR로 정리할 수 있었습니다. "사용부마다 조사를 직접 분기해야 하나?"라는 처음의 DX 고민은 번역 키에 [[조사]]를 쓰는 것만으로 해소되었고, 이제 "을/를"이 화면에 나오는 일도 없어졌습니다.

앞으로 새 번역 키를 작성할 때는 이렇게 쓰면 됩니다.

// ko.json
"message": "{{name}}[[를]] 삭제하시겠습니까?"

// en.json - 변경 없음
"message": "Do you want to delete {{name}}?"

지원되는 조사는 [[을]] [[를]] [[은]] [[는]] [[이]] [[가]] [[과]] [[와]] [[으로]] [[로]] [[이랑]] [[랑]]이고, 어느 쪽을 써도 알아서 올바른 형태로 변환됩니다.

다만 이 방식은 i18next의 post-processor로 동작하기 때문에 i18next 없이는 쓸 수 없습니다. 순수 유틸리티 함수가 필요하다면 Toss의 slash josahangul-postposition 같은 독립 라이브러리도 있습니다.

그리고 [[]] 구문은 번역자에게 익숙하지 않을 수 있습니다. 팀 내에서 번역 키 작성 규칙으로 공유하고, 이 구문의 의미를 문서화해두는 게 좋겠다고 느꼈습니다.


참고 자료

profile
안녕하세요 😚 썸네일을 쉽게 만들 수 있는 서비스를 운영중입니다. 많은 관심 부탁드립니다. https://thumbnail.ssumi.space/

0개의 댓글