TypeScript(이하 타입스크립트)의 타입 시스템만으로 한국어 문법을 표현하는typed-korean을 만들면서 자모 분해와 조합을 테이블로 미리 생성해두고 사용하는 것이 계속 신경쓰여 이번에 개선 작업을 진행해보았다.
사실 이 모든 불행은 한글의 완성형 음절은 단일 코드 포인트로 표현되지만, 타입스크립트의 타입 시스템에서는 코드포인트 산술을 지원하지 않기 때문에 일어난 일이었다. 예를 들어, "먹"(U+BA39)이라는 글자를 템플릿 리터럴 타입(Template literal type)으로 아무리 쪼개봐야 "ㅁ" + "ㅓ" + "ㄱ"으로 갈라지지 않는다. 당연하지만 Uppercase 같은 intrinsic 타입도 유니코드 정규화(NFD)는 지원하지 않는다.
즉, 음절 <> 자모 매핑은 반드시 어딘가에 존재해야 했고 손으로 쓸게 아니라면 이런 경우 항상 코드 생성이 대체로 옳고 이번에도 그랬다.
지금까지는 등록된 동사나 명사 기준으로 어휘에서 필요한 엔트리만 생성하는 방식을 사용했었다. 아무래도 완성형 한글을 전부 생성하기엔 양도 많거니와 불요한 데이터가 많을 것이라는 생각에 테이블을 작게 유지하려는 의도 때문이었는데, 시간이 지나 다시 확인해보니 숨은 비용이 두 개나 있었다.
- 코드 생성 스크립트가 활용 엔진의 복제본이 됨
- 어휘의 단일 진실 공급원(Source of Truth)이 두개가 됨
예를 들어 어떤 초성_중성_종성 조합이 필요한지 알아내려면 각 어간의 활용을 시뮬레이션해야 한다. 모음 조화/축약, ㄹ탈락, ㅂ/ㄷ/ㅅ/ㅎ/르 불규칙 등 타입 레벨로 구현한 규칙을 런타임 코드로 한 번 더 작성한 셈이다. 타입 엔진에 규칙을 추가할 때마다 시뮬레이터도 고쳐야 하고, 빠뜨리면 의문의 never가 튀어나오는 등의 문제가 발생할 수 있었다.
또한, 기존 코드 구조에서 어휘는 scripts/vocabulary.ts와 src/vocabulary/에 어휘를 정의했어야 했는데 이 자체로 이미 중복이었다. 만약 src에만 동사를 추가하면 조용히 깨지게 된다.
앞서 언급했던 것 처럼 테이블을 작게 유지한 이유는 컴파일 비용을 줄이기 위함이었다. 하지만 지레짐작만 하고 실제로 측정하지는 않아서 실제 비용이 어떻게 되는지 확인을 해보았고, 측정은 한글 음절 전체 11,172자 분해 테이블과 조합 테이블을 실제로 생성해서 tsc --extendedDiagnostics를 실행하는 방식으로 측정했다.
| 구성 |
Check time |
메모리 |
| 현재 프로젝트 전체 (baseline) |
0.12s |
136MB |
| 전체 JamoTable 11,172개 + ComposeTable 1,995개 |
0.50s |
199MB |
프로퍼티 이름 조회 자체는 Map 기반이라 엔트리 수와 무관하게 O(1)이지만, 파일을 파싱하고 심볼 테이블을 구성하는 비용은 엔트리 수에 비례해서 증가한다. 다만 그 비례 상수가 작아서(11,172개 기준 +0.4초) 실용적으로는 감내할 만한 수준이었다.
조합 테이블은 더 좋은 발견이 있었는데, 활용 엔진이 음절에 삽입하는 종성은 결국 ㄴ, ㄹ, ㅂ, ㅆ(+null) 다섯 슬롯(현재 4개 활성 + ㄹ 예약)이다. 그러니 19초성 × 21중성 × 5종성 = 1,995개를 무조건 다 생성하면 시뮬레이터 200줄을 통째로 제거할 수 있다.
테이블 뒤집기
하지만 전체 테이블로 전환하고 측정해보니까 tsc 메모리가 136MB에서 479MB로 증가한 것을 확인했고, 이는 사전 벤치마크(+60MB)보다 훨씬 커진 수치다. 참고로 사전 벤치마크의 JamoTable도 지금과 동일한 { 초; 중; 종 } 객체 값 형태였다 — 차이는 테이블이 아니라 측정 범위로, 사전 벤치마크는 테이블과 조회 몇 개만 있는 단독 파일이었지만 프로젝트에서는 활용 엔진과 타입 테스트가 이 무거운 객체 타입들을 대량으로 인스턴스화하면서 비용이 증폭됐다. 프로파일링 결과 범인은 테이블 엔트리 수가 아니라 값의 모양이었다.
"먹": { 초: "ㅁ"; 중: "ㅓ"; 종: "ㄱ" }을 예시로 생각해보면 이 값 하나하나가 tsc 입장에서는 자기만의 심볼 테이블을 가진 익명 객체 타입인데 문제는 그게 11,172개가 있다는 것이다.
지금은 제거되었지만, 완성형 한글 음운 구조를 조회하기 위해 JamoTable이라는 테이블을 두었었고 아래와 같은 형태였다.
JamoTable
"가": { 초: "ㄱ"; 중: "ㅏ"; 종: null }
"각": { 초: "ㄱ"; 중: "ㅏ"; 종: "ㄱ" }
"갂": { 초: "ㄱ"; 중: "ㅏ"; 종: "ㄲ" }
...
"힣": { 초: "ㅎ"; 중: "ㅣ"; 종: "ㅎ" }
음절 -> 자모 객체로 표현하면 총 비용은 객체 타입 11,172개(각각 심볼 테이블 + 멤버 3개)가 된다. 하지만 자모 -> 그 자모를 가지는 음절의 유니언(union)으로 표현하면 이 비용을 줄일 수 있다. 이렇게 하면 프로퍼티 심볼이 11,172개에서 68개로 줄고, 음절 리터럴 타입은 어차피 tsc에서 intern하기 때문에 union 멤버로 3번 참조돼도 추가 비용이 거의 없을 것이라 생각했다.
type ChoTable = { "ㄱ": "가" | "각" | /* 588개 */; /* 19키 */ };
type JungTable = { "ㅏ": /* 532개 */; /* 21키 */ };
type JongTable = { NULL: /* 399개 */; "ㄱ": /* 399개 */; /* 28키 */ };
type Find<T, C extends string> =
{ [K in keyof T]: C extends T[K] ? K : never }[keyof T];
type Decomp<C extends string> = {
초: Find<ChoTable, C>;
중: Find<JungTable, C>;
종: Find<JongTable, C> extends "NULL" ? null : Find<JongTable, C>;
};
이렇게 타입을 정의하면 매핑은 다음과 같이 표현된다:
ChoTable (19개)
"ㄱ": "가" | "각" | ... (588개)
"ㄲ": ...
...
"ㅎ": ...
JungTable (21개)
"ㅏ": "가" | "낙" | ... (532개)
...
"ㅣ": ...
JongTable (28개)
NULL: "가" | "개" | ... (399개)
"ㄱ": ...
...
"ㅎ": ...
이렇게 하면 "먹"이라는 문자열 리터럴 타입은 tsc 내부에서 한 번만 만들어지고, 세 union이 전부 그 하나를 가리킨다. 객체 타입 11,172개와 프로퍼티 심볼 11,172개가 통째로 사라지고, 남는건 어차피 만들어야 했던 리터럴 타입들 뿐이다.
조회는 mapped type으로 뒤집으면 그만이다.
type Find<T, C extends string> =
{ [K in keyof T]: C extends T[K] ? K : never }[keyof T];
// Find<ChoTable, "먹"> = "ㅁ"
키 68개에 대한 union 멤버십 검사인데, 생각보다 빠르게 측정이 되었다.
| 진단 |
객체 값 테이블 |
역방향 union |
감소 |
| Check time |
0.92s |
0.26s |
72% |
| Memory |
479MB |
188MB |
61% |
추가로 기존에 받침 없는 음절을 표현하기 위해 OpenSyllable이라는 유니언 타입을 두었었는데 이 변경으로 인해 굳이 유지할 필요도 없어졌고, 최종적으로 처음 136MB 대비 +50MB 수준으로 전체 한글 음절을 커버하게 되었다.
Union도 비싸다
사실 문자열 리터럴 타입은 길이와 무관하게 타입 객체 1개로 취급할 수 있기 때문에 union을 통째로 한 줄짜리 문자열로 합쳐버리면 더 줄일 수 있다.
예를 들어, 다음과 같이 표현하면 union의 맴버는 한 음절에 대해 588개 씩 유지를 해야 한다.
"ㄱ": "가" | "각" | "갂" | ...
하지만 `"ㄱ": "가각갂갃간갅..." 처럼 표현한다면 한 음절에 대해 문자열 리터럴을 한개만 들고 있어도 된다. 또한 이 경우 음절이 전부 한 글자라 부분문자열 매칭이 곧 멤버십 검사가 된다.
type Find<T, C extends string> =
{ [K in keyof T]: T[K] extends `${string}${C}${string}` ? K : never }[keyof T];
사실 이렇게 하면 함정이 하나 있는데, 한글이 아닌 입력에서 Find가 never를 반환할 때의 처리다. Find의 결과를 소비하는 쪽이 이런 형태였는데:
type Decomp<C extends string> =
Find<ChoTable, C> extends infer Cho extends string
? { 초: Cho; /* ... */ }
: never;
never는 모든 타입에 할당 가능하기 때문에 never extends infer Cho extends string은 참 분기로 빠지고, Decomp<"A">가 기대했던 never가 아니라 { 초: never; ... }가 되어버린다. 실제로 측정 중에 밟은 함정이다. 그래서 [Find<...>] extends [never] ? never : ...처럼 튜플 감싸기가 필수로 들어가야 한다.
이렇게 수정하고 tsc --extendedDiagnostics로 측정한 결과는 다음과 같이 나왔다.
| 측정 |
변경 전 |
변경 후 |
변화 |
| 생성 파일 |
263KB |
99KB |
-62% |
| Memory |
188MB |
177MB |
-6% |
| Check time |
0.26s |
0.26s |
동일 |
파일은 거진 3분의 1로 줄었는데 메모리는 11MB밖에 안 줄었다. 테이블 단독 벤치마크(테이블 + 조회 몇 개만 있는 단독 파일)에서는 극적이었는데:
| 구성 (단독 파일) |
Memory |
baseline 대비 |
| 빈 파일 (baseline) |
80MB |
— |
| 역방향 union 테이블 |
104MB |
+24MB |
| 문자열 blob 테이블 |
81MB |
+1.4MB |
프로젝트 전체에서는 이미 테이블이 지배적인 비용으로 작용하지 않기 때문이 아닌가 싶다. 남은 ~40MB는 조회나 lib 타입 체크 같은 기저 비용이라 여기서 더 뭔가를 줄이는건 불필요해보였고 딱히 생각도 나지 않아 이 정도로 만족하기로 했다.
최종 스코어보드. 어휘 기반 최소 테이블에서 출발해 세 단계를 거쳤다.
| 단계 |
Check time |
Memory |
커버리지 |
| 어휘 기반 (원점) |
0.12s |
136MB |
등록 어휘만 |
| 전체 테이블 (객체 값) |
0.92s |
479MB |
11,172음절 전체 |
| 역방향 union |
0.26s |
188MB |
〃 |
| 문자열 blob |
0.26s |
177MB |
〃 |