배경
한 번쯤 서비스를 사용할 때 문구가 거슬렸던 경험이 있을 것이다. 나는 이런 경험이 몇 번 있었는데 영어 서비스를 어색한 한국어로 번역한 경우가 대표적이다. 이 외에 ‘~이(가) 연결을 거부했습니다’와 같이 조사를 제대로 처리하지 못하는 경우도 조금 거슬렸다.
이번에 프로젝트 진행 중 이 상황을 마주쳤다.
해빗버디에는 별자리 페이지가 있다. 버튼을 누르면 별자리에 별을 하나씩 채우고, 다 채운 경우 보상으로 캐릭터를 받을 수 있는 곳이다. 캐릭터를 얻으면 축하하는 모달(=해금 모달)이 등장하는데, 여기에 적힌 문구에서 조사 처리를 제대로 하지 못한다.
조사 처리가 필요한 곳은 전체 서비스를 기준으로 보면 작은 부분이다. 하지만 사용자가 캐릭터를 해금하고 성취감이 최고조에 달했을 때 보는 거라 사소한 거슬림이 없는 게 좋을 것 같았다. (그리고 디테일을 챙길수록 완성도가 높아지니까)
아무튼 어색한 부분과 내가 생각한 방법을 기획 쪽과 상의했고 프론트에서 적절한 조사를 붙이기로 했다. 알고 보니 기획할 때 프로그래밍으로 적절한 조사를 붙일 수 있는지 몰랐다고 했다.
적절한 조사 붙이기
프론트에서 조사 처리를 하기로 결정한 뒤 여러 가지 고민을 했다.
라이브러리가 있는가?
우선 라이브러리를 쓸지, 아니면 직접 구현할지를 먼저 고민했다. 조사 처리하는 라이브러리가 있다는 건 알고 있어서 우선 어떤 라이브러리가 있는지 먼저 검색했다. 여러 개가 있었는데 내가 본 것 중 최근까지 업데이트 되고 타입 지원도 되고 문서도 깔끔한 건 es-hangul이었다. (최근에 알게 된 건데 엄청 가벼운 라이브러리인 k-popo도 있다)
구현 난이도는?
그다음 어떻게 구현하는지 찾아봤다. 생각보다 그렇게 복잡하지 않았다. 자료도 꽤 있었고, 무엇보다 한글 조사 문법에 대해 이해하고 있으면 구현 자체는 어렵지 않다고 판단했다.
구현해야 할게 많은가?
처리해야 할 조사는 ‘이/가’가 전부였다. 사용하는 곳도 해금 모달 한 곳밖에 없었다. 그래서 함수 하나 정도만 있으면 충분하다고 판단했다.
시간이 충분한가?
너무 충분했다..! 밀린 일도 없었고 병목 지점이 있는 것도 아니고.. 정말 널널한 상황이었다.
그 외…
어떻게 구현하는지 궁금했다.
딱 한 곳에만 필요한 기능이라 결국 직접 구현하는 방향을 선택했다. 그럼, 이제 구현해 보자.
구현방법
👑 한글 조사 문법
우선 어떨 때 어떤 조사가 붙는지 이해해야 한다.
한 음절은 첫머리 자음 초성, 가운데 모음 중성, 끝에 오는 자음 종성으로 구성된다. 종성 즉 받침은 있을 때도 있고 없을 때도 있다.
조사 ‘이’ 또는 ‘가’는 종성 유무에 의해 결정된다. 종성이 있으면 ‘이’가 붙어야 하고 없으면 ‘가’가 붙어야 한다. 예를 들어 ‘사과’의 마지막 글자인 ‘과’에는 종성이 없다. 그래서 ‘가’가 붙어야 한다.
👑 한글 음절 코드 포인트 구하기
여기부터는 이 글을 많이 참고했다.
시작 값 ‘가(AC00)’에 ‘((초성 값 x 21) + 중성 값) x 28 + 종성 값’ 더하기
음절 코드 포인트를 구하는 방법이다. AC00은 음절 시작 부분인 ‘가’의 유니코드고 21은 중성 개수, 28은 종성 개수다(받침 없는 경우 포함).
이를 역 이용해 초성, 중성, 종성을 구분할 수 있다. 종성을 구하는 공식은 아래와 같다.
(한글 음절 코드 포인트 값 - U+AC00) % 28
구현하기
먼저 간단하게 수도코드를 작성했다.
단어를 파라미터로 받는다.
마지막 음절을 가져온다.
마지막 음절에서 종성 유무를 판단한다.
종성이 있으면 '이', 없으면 '가'를 반환한다.
수도코드의 흐름을 참고해서 코드를 작성하면 아래와 같다.
const HANGUL_START_CHARCODE = "가".charCodeAt(0);
const JONGSUNG = 28;
function josaIga(letter: string) {
if (!letter) return;
const lastCharCode = letter.charCodeAt(letter.length - 1);
const jongsungIndex = (lastCharCode - HANGUL_START_CHARCODE) % JONGSUNG;
return jongsungIndex ? "이" : "가";
}
먼저 letter라는 이름으로 문자열을 받고, 빈 문자일 경우 함수를 종료한다.
음절의 코드 포인트를 알아야 하므로 charCodeAt 메서드로 마지막 음절의 코드 포인트를 계산한다.
종성 구하는 공식으로 종성 값을 구한다. 0부터 27까지가 종성 값인데, 0은 없는 경우고 1~27은 있는 경우다. (음절 초성/중성/종성 순서 값 표 참고)
종성이 있으면 ‘이’를 반환하고 없으면 ‘가’를 반환한다.
고민한 부분
1️⃣ ’조사’를 어떻게 표기할까?
이 기능을 구현하면서 이름을 가장 많이 고민했다.
조사는 영어로 postposition이라고 하는데 이걸 그대로 쓰자니 딱 와닿지 않았다. 평소에 ‘조사’를 영어로 쓸 일이 거의 없어서 익숙하지 않았음.
그래서 로마자로 표기하기로 했다. ‘종성’도 마찬가지.
2️⃣ 이름에 처리하고 있는 조사를 드러낼까?
조사를 ‘josa’로 표기하기로 한 뒤, 그대로 사용할지 처리 중인 조사를 드러낼지 고민했다.
만약 josa라고 지으면 사용하는 곳에서는 josa(’단어’)
형태로 사용하게 된다. 함수 구현을 모르는 상태에서 이 부분만 보면 구체적으로 어떤 조사를 처리하는지 알 수 없고, 모든 조사를 처리하는 함수라고 오해할 수 있을 것 같았다.
그래서 ‘이/가’ 조사만 처리하고 있다는 걸 표시하기 위해 ‘josaIga’라고 지었다.
3️⃣ 빈 문자열이 온다면?
타입스크립트로 파라미터를 string으로 제한하고 있지만 빈 문자열인 경우 통과된다. 이 경우 종성 유무를 제대로 판단할 수 없다. 그럼 굳이 다음 코드를 실행시킬 필요가 없으니, 함수를 종료하는 게 맥락상(?) 맞겠다는 생각이 들었다. 또 만약 런타임에 undefined나 null이 온다면 타입 에러가 발생한다.
그래서 letter가 falsy 값일 때 함수를 종료하게 했다.
적용 결과
josaIga 함수를 적용했고 캐릭터 이름에 따라 적절한 조사가 등장한다.
이번엔 '을/를'
josaIga 함수 구현 후 몇 주 뒤 ‘을/를’도 처리해야 할 일이 생겼다. 이번에는 온보딩 채팅 파트에서 사용자가 선택한 습관에 맞는 조사를 붙여야 한다. 총 3곳에서 등장한다.
‘을/를’도 ‘이/가’처럼 종성 유무로 ‘을’과 ‘를’이 정해진다. 구현 방법은 josaIga와 비슷하다.
function josaEulReul(letter: string) {
if (!letter) return;
const lastCharCode = letter.charCodeAt(letter.length - 1);
const jongsungIndex = (lastCharCode - HANGUL_START_CHARCODE) % JONGSUNG;
return jongsungIndex ? "을" : "를";
}
리팩토링
공통 로직을 함수로 분리하기
‘이/가’와 ‘을/를’은 종성의 유무에 따라 달라진다. 그래서 종성 유무를 파악하는 로직이 겹친다.
어차피 똑같은 코드니, 사용하기 편하게 한 곳에서 관리할 수 있게 함수로 분리했다.
function hasJongsung(letter: string) {
if (!letter) return;
const lastCharCode = letter.charCodeAt(letter.length - 1);
const isHangul = HANGUL_START_CHARCODE <= lastCharCode && lastCharCode <= HANGUL_END_CHARCODE;
if (!isHangul) return;
const jongsungIndex = (lastCharCode - HANGUL_START_CHARCODE) % JONGSUNG;
return jongsungIndex ? true : false;
}
hasJongsung
이라는 이름의 함수로 분리했다.
이제 이 함수를 사용해 을/를/이/가를 판단한다.
function josaIga(letter: string) {
return hasJongsung(letter) ? "이" : "가";
}
function josaEulReul(letter: string) {
return hasJongsung(letter) ? "을" : "를";
}
예외 처리
원래는 문자열이 아닐 때 그냥 함수를 종료했다. 하지만 hasJongsung 함수로 분리 후 좀 더 맥락에 맞는 예외 처리가 필요할 것 같다는 생각이 들었다.
hasJongsung
은 종성 유무를 판단하는데, 그럼 한글이 올바르게 들어와야 한다.
만약 영어와 같이 한글이 아닌 문자가 들어온다면 종성이 없으니 제대로 판단을 못 한다. 그럼, 종성을 판단하는 로직을 실행할 필요가 없고, 판단 자체를 못 하니 undefined를 리턴하는게 함수 의미와 기능을 명확하게 한다고 생각했다.
그래서 단어의 마지막 글자가 한글인지 아닌지를 파악하고 한글이 아니라면 함수를 종료하는 코드를 추가했다.
function hasJongsung(letter: string) {
if (!letter) return;
const lastCharCode = letter.charCodeAt(letter.length - 1);
// 한글인지 아닌지 판단
const isHangul = HANGUL_START_CHARCODE <= lastCharCode && lastCharCode <= HANGUL_END_CHARCODE;
// 한글이 아니라면 종료
if (!isHangul) return;
const jongsungIndex = (lastCharCode - HANGUL_START_CHARCODE) % JONGSUNG;
return jongsungIndex ? true : false;
}
마지막 단어가 음절 시작 코드와 마지막 코드 사이에 없다면 한글이 아니라고 판단하고 그대로 함수를 종료한다.
고민한 부분
1️⃣ 함수 형태에 대한 고민
josaIga(’단어’) vs josa.Iga(’단어’)
‘을/를’ 조사도 추가되고 나서 함수 형태에 대해 고민했다.
기존에는 josaIga(’단어’)
형태였는데 여기에 josaEulReul(’단어’)
함수도 추가되었다. 그런데 함수 이름이 한눈에 들어오지 않았다.
그래서 객체에 메서드로 넣어두고 사용하는 방식을 떠올렸다.
그럼 josa.iga(’단어’)
, josa.eulReul(’단어’)
이런 형태로 사용할 수 있는데, 미묘하지만 읽기는 좀 더 편하지 않나 싶었다.
하지만 만약 객체 구조 분해 할당 방식으로 사용한다면 iga(’단어’)
로 사용할 수 있다. 아무 맥락 없이 이 함수만 보인다면 어떤 기능인지 알아채기 힘들 것 같다. 또 을/를/이/가 외에 다른 조사도 있다고 착각할 수 있다.
그래서 그냥 원래 형태로 사용하기로 했다.
2️⃣ 사용 형태에 대한 고민
josaIga(’단어’)
의 경우 단어${josaIga(’단어’)}
이런 식으로 사용해야 한다. '단어'를 두 번 써야 해서 번잡한 느낌이다.
그래서 ‘단어 + 조사’ 형태를 반환하게 하는 방법을 떠올렸다. 만약 ‘사과’라는 단어를 전달하면 ‘사과가’를 반환한다.
하지만 생각해 보니 해금 모달에서는 캐릭터 이름에 노란색 폰트 컬러를 적용해야 한다. 그럼 이 경우는 처리가 힘들어진다.
이를 해결하기 위해 다른 방법을 쓸 수도 있겠다. ‘단어 + 조사’와 ‘조사’를 리턴하는 방법을 사용하거나, 아니면 기본 기능은 ‘단어 + 조사’를 리턴하지만 옵션 전달 시 ‘조사’만 리턴하기, 또는 ‘조사’만 리턴하는 메서드 만들기 등등..
하지만 을/를/이/가만 처리하고 함수도 두 개밖에 없고 전체 페이지 기준 처리가 필요한 곳도 얼마 없어서 이렇게까지 구성할 필요가 없을 것 같았다.
그래서 이것도 원래 형태로 사용하기로 했다.
결과
앞 글자에 따라 적절한 조사가 붙어서 문장이 더 자연스러워졌다.
느낀점
👉 평소 가볍게 읽어둔 개발 글의 도움을 받다
평소 시간 나면 개발 뉴스레터와 글을 읽는데 ‘이런 게 있구나’ 정도로 가볍게 읽는 편이다. 그러다 관심 가는 게 있으면 그제야 자세히 읽는다.
예전에 개발 관련 글을 읽다가 한글 처리를 쉽게 도와주는 es-hangul 라이브러리를 알게 되었고, 이를 통해 적절한 조사를 붙여주는 기능을 구현할 수 있다는 사실을 알게 되었다.
언젠가 한글 처리가 필요한 날이 오면 유용할 것 같아서 기억해 두고 있었는데 마침 프로젝트에서 조사 처리가 필요했다. 그래서 이 라이브러리도 구경했고 사용할지 말지 고민했다.
결국 사용하는 곳이 너무 적어서 직접 구현을 결정했지만, 라이브러리를 구경하는 건 재밌었다.
아무튼 평소에 가볍게 읽어둔 글이 아니었다면 조사 처리가 가능하다는 사실 자체를 떠올리지 못했을 것 같다. 그럼 제안도 못 했겠지? 앞으로도 꾸준히 관심 가져야겠다.
참고
👉 기본적인 한글 인코딩 설명
👉 이름 지을 때 참고
👉 한글 자모, 음절 유니코드 표
👉 구현 시 참고