Gukhanmun 설계 문서

Gukhanmun은 국한문혼용체로 쓰인 한국어 텍스트를 한글 전용 텍스트로 변환하는 라이브러리이다. Seonbi의 후계 프로젝트로서, 한자 변환 파이프라인 하나에 집중하면서도 다음과 같은 축들로 확장되었다: 스트리밍 입출력, 결합 가능한 사전, 라티스 기반 분할, 더 다양한 출력 형식. 프로젝트는 Rust로 구현되었고 Rust 라이브러리·명령줄 도구·WebAssembly 바인딩·Node-API 바인딩의 형태로 제공된다.

목표와 범위

이 라이브러리는 한국어 텍스트의 한자어를 한글 독음으로 변환한다. 필요에 따라 동음이의 구별·루비 마크업·문체상 이유 등을 위하여 원(原) 한자를 병기할 수 있다. 변환 대상 텍스트 바깥의 구조나 내용은 손대지 않으며, 한국어가 아닌 구역이나 보존 대상으로 표지된 구역(코드 블록 등)은 그대로 통과시킨다. 입출력은 가능한 범위 내에서 스트리밍으로 처리하며, 버퍼링은 한자를 포함하는 연속된 변환 후보 범위의 크기와 선택적 문맥간 동음이의 구별에 국한된다. 메모리 내 데이터·mmap 기반·또는 엔진에 불투명한 기타 형태의 결합 가능한 사전을 받아들인다. 대한민국 국립국어원(院)의 《표준국어대사전》에서 추출한 기본 사전을 함께 제공한다. Rust에서, Node-API를 통한 Node.js에서, WebAssembly를 통한 브라우저와 Deno에서, 그리고 명령줄에서 사용 가능하다.

이 라이브러리는 스마트 따옴표·줄표·줄임표·인용 표지 같은 더 광범위한 한국어 조판 보정은 의도적으로 제공하지 않는다; 그것들은 Seonbi의 역할이다. HTML5 완전 적합 파서를 제공하지 않는다; HTML 스캐너는 단락 중심이고 가벼운 오류는 복구하지만 html5ever의 대체물은 아니다. Markdown을 바이트 단위로 보존하지 않는다; 의미 보존은 계약이지만 원형 보존은 최선의 노력일 뿐이다. 언어 간 번역이나 한자에서 한글로의 사상을 넘어서는 표기 변환은 하지 않는다.

설계 원칙

이 설계를 관통하는 원칙은 셋이다.

엔진은 형식 중립적이다. 같은 엔진이 HTML·Markdown·순수 텍스트를 모두 처리할 수 있는 것은, 각 형식이 동일한 중간 표현으로 해석되어 들어왔다가 다시 그 형식으로 돌아 나가기 때문이다. 형식에 따른 세부 사항, 즉 스캐닝·직렬화·해당 형식에서 「보존 대상 구역」이나 「단락 경계」가 무엇인지에 대한 정의는 모두 경계면의 어댑터가 처리한다. 엔진은 HTML 태그명이나 pulldown-cmark 이벤트의 종류, 또는 그 밖에 형식에 결합될 만한 어떤 것도 들여다보지 않는다. 이 원칙 덕분에 한 번 만든 테스트 픽스처가 세 형식 모두에서 의미를 가지게 되고, 새 형식을 추가하는 작업이 국소화된다.

책임은 독립적으로 교체 가능한 파이프라인 단계들로 분담된다. 리더(reader)는 입력 형식을 IR로 해석한다. 엔진(engine)은 한자를 포함한 어휘 범위를 찾아 분할하고 주해(annotation)를 만든다. 미들웨어(middleware)들은 주해의 플래그를 조정해 IR 흐름을 가공한다. 렌더러(renderer)는 주해를 구체적 텍스트나 마크업으로 풀어낸다. 라이터(writer)는 IR을 목표 형식으로 직렬화한다. 어느 한 단계를 교체해도 다른 단계들은 영향을 받지 않으며, 단계 사이의 경계는 메서드 호출이 아닌 명시적인 데이터 흐름이다.

정확성이 허용하는 곳에서는 스트리밍이 기본이다. 본질적으로 선독이 필요한 동작은 해당 문맥 경계까지 버퍼링한다. HTML과 Markdown에서는 기본 per-block 동음이의 창이 보통 단락·리스트 항목·헤딩 같은 스코프에서 경계를 만난다. 반면 순수 텍스트에는 블록 스코프가 없으므로 같은 기본 창이 문서 전체 창이 된다: 나중 줄의 주해가 앞 줄의 주해에 한자 병기를 요구할 수 있고, 바이트 스트림은 이미 stdout에 쓴 텍스트를 되돌려 고칠 수 없다. 순수 텍스트에서 즉시 출력이 필요한 호출자는 동음이의 표지을 끄고, 그에 따른 구별 정보 손실을 받아들여야 한다.

스트리밍 변환은 fallback 주해 범위도 정확하게 유지한다. 꼬리에 남은 fallback 한자 연속 범위는 사전의 최대 단어 길이보다 길더라도, 후속 비변환 경계나 EOF를 만날 때까지 보류한다. 이 때문에 fallback 한자만 이어지는 입력은 사전 선독만 놓고 보면 필요한 것보다 덜 즉시적일 수 있지만, hangul-hanja-parens 같은 렌더링 모드가 one-shot 변환과 같은 주해 묶음을 보게 된다.

구조

파이프라인은 다섯 단계로 구성된다.

리더는 바이트를 입력 토큰의 흐름으로 해석한다. 엔진은 입력 토큰을 읽고 출력 토큰을 내놓는데, 출력 토큰에는 「원 한자」와 「한글 독음」을 모두 들고 있는 주해 토큰이 포함될 수 있다는 점이 다르다. 미들웨어들은 출력 흐름을 순회하면서 주해의 플래그들을 조정한다. 렌더러는 각 주해를 설정된 모드와 플래그에 따라 구체적 텍스트나 마크업으로 풀어낸다. 라이터는 최종 흐름을 목표 형식으로 직렬화한다.

중간 표현

중간 표현은 평면적인 토큰 흐름이며, 스코프 데이터 타입에 의해 파라미터화되어 있다. 이 타입은 엔진이 아니라 어댑터에 속한다. 엔진은 토큰의 형태만 알 뿐, 스코프가 들고 다니는 페이로드(HTML의 날(raw) 속성 문자열, pulldown-cmark 이벤트의 변종, 등)의 내용에 대하여는 아무것도 모른다.

엔진에 들어가는 토큰은 다음 중 하나이다:

  • Open(Scope<S>): HTML 요소나 Markdown 블록 같은 구조적 스코프 진입. Scope<S>는 어댑터의 불투명 데이터와 함께 아래에서 설명할 세 가지 미리-계산된 플래그를 들고 다닌다.
  • Close: 가장 최근의 스코프를 닫음. 엔진이 스택을 자체 유지하므로, 어댑터는 어떤 스코프가 닫히는지 다시 알려줄 필요가 없다.
  • Text(Cow<str>): 엔진이 변환할 수 있는 텍스트 조각.
  • Verbatim(Cow<str>): HTML의 <code> 내용이나 Markdown 코드 스팬처럼, 그대로 통과되어야 하는 텍스트. 무엇이 verbatim인지는 엔진이 아니라 어댑터가 결정한다.

엔진에서 나오는 토큰은 다음 중 하나이다:

  • Open(Scope<S>), Close, Text(Cow<str>), Verbatim(Cow<str>): 위와 같은 형태로 그대로 통과.
  • Annotated(Annotation): 엔진이 한 한자어를 변환한 자리. 주해는 원 한자, 한글 독음, 그리고 이 변환이 왜 일어났고 후속 단계가 그것을 어떻게 다룰지를 묘사하는 플래그를 들고 있다.

Annotation은 정책 플래그들을 들고 있다. homophone은 유효 사전 항목 집합이나 현재 문맥이 같은 한글 독음을 가진 다른 한자 단어가 있음을 알려줄 때 설정되며, 이 경우 출력을 읽는 사람이 한글만으로는 위안 한자를 복원할 수 없다. require_hanja는 출처 사전이나 사용자 지시에 의해 원 한자를 한글 옆에 함께 표시할 것이 요구되는 경우에 설정된다. require_hangul은 그 반대 방향이다; 출력에서는 한자를 그대로 유지하지만 한글 병기가 필요한 경우에 설정되며, 국한문 표기를 그대로 두고 어려운 한자만 보조 표기하는 Original 렌더링 모드에서 사용된다. skip_annotation은 렌더러가 주해를 붙이지 않고 주 표기의 순수 텍스트만 내보내길 원하는 사용자 지시에 의해 설정된다. first_in_context는 설정된 문맥 창 내에서 해당 한자어의 첫 번째 등장일 때 설정되며, 문맥은 설정에 따라 블록·섹션·문서 중 하나이다. from_dictionary는 사전 일치인지 글자 단위 폴백인지를 구분한다. 디버깅 용도로 렌더러가 이 둘을 다르게 표지하도록 선택할 수 있다.

InputTokenOutputToken을 별개의 타입으로 둔 것은 이 IR에서 가장 큰 형태 상 결정이다. 우리가 검토했던 대안은 Annotated 변종을 처음부터 포함하는 단일 Token 열거형이었다. 어댑터가 Annotated를 만들 수 있는 가능성을 열어 두자는 취지였다. 이 안을 두 가지 이유로 기각했다. 첫째, 입력 측의 Annotated 변종은 리더와 엔진 전 구간에서 「절대 일어나지 않는」 변종이 되어, 코드베이스의 모든 match 문에 도달 불가능한 가지를 남기게 된다. 둘째, 「엔진이 주해를 생성하고 렌더러가 소비한다」는 계약을 흐리게 만든다. 두 타입을 분리하면 데이터 흐름이 한눈에 들어오고, 「미처리 주해가 라이터까지 흘러갈 수 없다」는 불변식을 타입 시스템에 맡길 수 있다.

스코프 데이터

불투명 스코프 페이로드는 작은 트레이트를 따른다:

pub trait ScopeData: Clone + 'static {
    /// 이 스코프 內의 텍스트는 그대로 두어야 하는가?
    fn is_preserve(&self) -> bool;

    /// 렌더러가 이 스코프 內에 `<ruby>` 같은 인라인 마크업을 揷入해도 되는가?
    fn allows_inline_markup(&self) -> bool { true }

    /// 이 스코프가 per-block 미들웨어를 리셋시키는 境界인가?
    fn is_block_boundary(&self) -> bool { false }
}

엔진은 현재 스코프의 is_preserve()를 「이 Text 토큰을 건너뛸 것인가」에 대한 유일한 판단 근거로 다룬다. 상속이 필요한 어댑터는 각 열린 스코프에 이미 유효한 답을 담아 보낸다. HTML 어댑터의 ScopeData 구현은 여러 관심사를 그 한 플래그에 모은다: 상속된 lang 속성(한국어 술어와 비교됨), 현재 태그와 보존 대상 조상(보존 대상 태그 목록인 pre, code, kbd, script, style, textarea와 비교됨), 그리고 사용자가 날 속성에 대하여 제공한 임의의 술어. 엔진 자체는 lang 속성이 무엇인지조차 모른다.

이는 Seonbi와 다른 점이다. Seonbi는 LangHtmlEntity라는 별도의 주해를 파이프라인에 함께 흘려보낸다. 우리는 그 접근을 기각했는데, 엔진이 두 신호를 따로따로 필요로 하는 경우가 없기 때문이다; 엔진이 묻는 질문은 언제나 「이 텍스트를 건너뛰는가」 하나뿐이다. lang 상속을 (이미 태그명 보존 목록이 살고 있는) 어댑터 내부로 밀어 넣음으로써, 엔진은 HTML에 대하여 무지할 수 있고 IR에는 엔진이 소비하지 않는 필드가 남지 않는다.

엔진

엔진의 임무는 셋이다: 한자를 포함하는 변환 가능 어휘 범위를 찾는 일, 그 범위를 사전 변과 폴백 변으로 분할하는 일, 그리고 그에 따라 주해와 폴백 텍스트를 내놓는 일. 대부분의 범위는 연속된 한자 열이지만, 사전 항목은 汽車길이나 色깔論처럼 한자 앞뒤에 고유어·한글 조각을 포함할 수 있다. 또한 한자 한 글자를 한글로 사상하고 필요하면 두음법칙을 적용하는 폴백 음역기와, 한자 수자를 설정에 따라 한글이나 아라비아 수자로 변환하는 수자 변환기를 함께 가지고 있다.

라티스 분할

한자를 포함하는 범위를 사전 단어와 폴백 조각으로 분할하는 일은 엔진의 핵심 결정이고, 가장 자연스러워 보이는 산법이 틀린 답을 낸다.

자연스러워 보이는 산법은 좌→우 최장 일치(eager longest-match)이다: 왼쪽에서 오른쪽으로 주사하며 각 위치에서 그 자리로 시작하는 가장 긴 사전 항목을 취한 뒤, 그 끝에서 다시 시작한다. Seonbi가 취한 방식이기도 하다. 이 산법의 실패 모드는 더 긴 접두사가 더 자연스러운 분할에 속해야 할 글자를 집어삼키는 경우이다. 예를 들어 사전에 行事, 行事場, 場所, 入口가 들어 있다고 하자. 行事場入口라는 입력에 대하여 좌→우 최장 일치는 行事場 + 入口로 분할하는데, 이는 옳다. 그러나 行事場所에 대하여 같은 산법은 行事場 + 로 분할하고, 는 글자 단위 폴백으로 넘어간다. 行事 + 場所로 갈라야 두 부분 모두 사전으로 덮을 수 있는데도 그렇다.

범위는 순수한 한자만으로 한정되지 않는다. 《표준국어대사전》에는 汽車길이 「기찻길」로, 祭祀날이 「제삿날」로, 洗手대야가 「세숫대야」로, 火김이 「홧김」으로, 色깔論이 「색깔론」으로 수록되는 것처럼 고유어와 한자가 섞인 항목도 있다. 따라서 사전 변은 현재 텍스트 커서에서 시작하는 혼용 표기 접두사를 소비할 수 있어야 한다. 반면 폴백 변은 사전 변이 덮지 못한 한자 글자에 대하여만 작성되며, 사전 일치의 일부가 아닌 한글은 보통 텍스트로 통과한다.

올바른 산법은 라티스 위에서 동적 계획법을 돌리는 것이다. 변환 후보 범위의 각 글자 위치 i에서, 엔진은 사전에 그 자리로 시작하는 모든 일치를 묻고, 현재 글자가 한자일 때에는 보조로 글자 한 개짜리 폴백 변을 함께 고려한다. 평가함수가 대안들을 비교하고, 우리는 범위의 끝에서 비터비식 역추적으로 최적 분할을 선택한다.

평가함수는 의도적으로 단순하다. 먼저 사전 일치가 덮은 글자 수를 최대화한다. 따라서 行事 + 場所는 폴백을 남기지 않으므로 行事場 + 보다 낫다. 사전으로 덮은 글자 수가 같으면 분할 수가 더 적은 쪽을 선호한다. 그래서 天地 같은 전체 단어 일치는 + 같은 구성요소 분할보다 낫다. 그래도 동점이면 같은 점수에 먼저 도달한 후보를 유지해 결과를 결정적으로 만든다.

라티스 분할의 비용은 변환 후보 범위의 길이와 사전의 최대 항목 길이의 곱에 비례한다. 보통의 한국어 텍스트에서 한자를 포함하는 범위는 짧다; 대부분 1–4 글자이고, 혼용 표기 사전 항목 같은 드문 경우를 빼면 10 글자를 넘는 일은 거의 없다. 사전의 최대 항목 길이도 10 자 내외이다. 따라서 범위 하나당 수십 번의 사전 조회면 충분하며, FST와 CDB 백엔드 모두 마이크로초 단위로 처리한다.

라티스의 정확도가 필요 없고 범위 당 추가 비용을 줄이고 싶은 호출자를 위하여 eager 분할 전략도 옵션으로 제공한다. 기본은 라티스이다.

폴백 음역기

사전 일치가 한자 글자를 덮지 못할 때, 폴백 음역기는 그 글자를 내장된 Unihan 기원 글자 표에서 표준 독음을 찾아 한글로 변환한다. 글자 표는 Unicode kHangul 속성으로부터 빌드되어 gukhanmun-core에 생성된 정렬 테이블로 내장되고 이분 탐색으로 조회된다. 수천 개의 한자를 다루며 기본 빌드는 네트워크 접근에 의존하지 않는다.

폴백이 만들어 낸 단어의 첫 글자, 즉 폴백 전용 열의 첫 글자 또는 사전 일치 직후의 첫 글자에는 선택적으로 두음법칙이 적용된다. 이 법칙은 ㄴ이나 ㄹ로 시작하던 한글 음절 일부를 대한민국 정서법에 맞는 형태로 변환한다(녀→여, 려→여, 례→예, 등). 대조표는 작고(16 개), gukhanmun-core에 내장되어 있다. 법칙을 켜고 끄는 옵션은 폴백에만 적용된다; 사전 항목은 이미 옳은 독음을 들고 있다고 가정한다(대한민국 사전은 來日을 「내일」로, 조선민주주의인민공화국 사전은 「래일」로 수록한다).

폴백에는 글자별 사상으로는 얻을 수 없는 Seonbi의 작은 규칙 두 가지가 남아 있다. 첫째, · 규칙은 두음법칙의 일부로 다룬다. 두음법칙이 켜져 있고 이 글자들이 앞 음절의 받침이 ㄴ이거나 받침이 없을 때 「렬·률」 대신 「열·율」로 미끄러진다. 두음법칙이 꺼져 있는 조선민주주의인민공화국 정서법에서는 「렬·률」로 남는다. 둘째, 한자 수자 규칙: 두 개 이상의 한자 수자가 연속되면 한 단어로 읽고, 두음법칙은 첫머리에만 적용하고 내부에는 적용하지 않는다. 두 규칙 모두 글자 흐름에 대한 작은 파서로 인코딩되어 있다.

수자 변환

한자 수자는 특수한 경우인데, 문맥에 따라 세 가지 표면 형태로 변환될 수 있고 어느 쪽이 옳은지가 문맥에 의해 결정되기 때문이다. 라이브러리는 NumeralStrategy 옵션을 네 가지로 제공한다:

전략예: 二〇一六年예: 十一月예: 一千二百三十四
hangul-phonetic이공일륙년십일월일천이백삼십사
positional-arabic2016년(적용 불가)(적용 불가)
additive-arabic(적용 불가)11월1234
smart2016년11월1234

hangul-phonetic 전략은 Seonbi의 동작이고 ko-kr·ko-kp 두 프리셋의 기본값이다. positional-arabic 전략은 수자만으로 이뤄진 열(〇一二三四五六七八九와 그 이체자)을 위치 표기로 보고 아라비아 수자로 변환한다. additive-arabic 전략은 자릿값 표지(十百千萬億兆京)가 포함된 열을 스택 기반 누적으로 해석해 아라비아 수자를 내놓으며, 앞에서 생략되는 한국식 표기(十一이 11이지 一十一이 아님)를 처리한다. smart 전략은 주변 문맥을 본다: 단위 한자(年月日時分秒號世紀 등)가 따라오면 additive-arabic을 쓰고, 그렇지 않으면서 4 자 이상의 순수 수자라면 positional-arabic을 쓰며(연도 표기 관행에 맞춤), 그 외에는 hangul-phonetic으로 환원한다.

수자 변환은 폴백 경로 안에서, 라티스가 「사전에 일치하지 않음」으로 판정한 구간에 대하여 동작한다. hangul-phonetic 전략은 폴백 Annotated 토큰을 내보내어 원 한자 수자를 보존하므로, HangulHanjaParens 같은 렌더러도 원문을 표시할 수 있다. 아라비아 수자 전략은 원 한자의 한글 독음이 아니라 수자 정규화를 출력하는 것이므로, 순수 텍스트를 내보낼 수 있다.

사전

사전은 「주어진 텍스트 위치에서 시작하는 일치가 무엇인지 물어볼 수 있는」 모든 것이다.

pub trait HanjaDictionary {
    /// `s`의 첫머리에서 始作하는 모든 一致를 yield 한다.
    fn matches_at<'a>(&'a self, s: &'a str)
        -> Box<dyn Iterator<Item = Match<'a>> + 'a>;

    /// 이 辭典에 들어 있는 項目 中 가장 긴 것의 길이(글字 單位).
    /// 라티스의 終了 條件으로 使用된다.
    fn max_word_chars(&self) -> Option<usize> { None }

    /// 배치 政策 index를 만들 만큼 效率的으로 列擧할 수 있을 때의 完整한 項目들.
    fn entries<'a>(&'a self)
        -> Option<Box<dyn Iterator<Item = DictionaryRecord> + 'a>>
    { None }

    /// 同一한 한글 讀音을 가진 다른 漢字 單語가 있는가?
    /// 便宜 API이다. homophone-marker 미들웨어는 反復 全體 走査를 피하려고
    /// entries()를 使用한다.
    fn has_homophone(&self, hanja: &str, reading: &str) -> bool { false }
}

matches_at이 이례적인 부분이다. 한국어 텍스트 처리에 자연스러운 시그니처는 longest_match처럼 보이지만, 그 eager longest-match가 바로 우리가 엔진에서 배제한 산법이다. 라티스 분할기가 대안들을 평가하려면 시작 위치에서 가능한 모든 일치 집합이 필요하므로, 트레이트는 그 전체를 노출한다. 입력 문자열은 미리 잘라 낸 한자 열만이 아니라 현재 커서에서 시작하는 텍스트의 나머지이다. 이 형태 덕분에 사전은 汽車길 같은 혼용 표기 키도, 그 일치 안에 한자가 하나 이상 들어 있는 한, 담을 수 있다.

Match는 일치한 바이트 길이, 한글 독음, 그리고 MatchMark를 들고 있다. byte_len은 일치한 사전 키의 UTF-8 길이이며, 그 키에는 한자와 한글이 함께 들어 있을 수 있다:

pub struct Match<'a> {
    pub byte_len: usize,
    pub reading: Cow<'a, str>,
    pub mark: MatchMark,
}

pub struct MatchMark {
    /// 이 項目은 항상 漢字와 함께 表示되어야 한다고 辭典이 主張.
    pub require_hanja: bool,
    /// 이 項目은 항상 한글과 함께 表示되어야 한다고 辭典이 主張.
    pub require_hangul: bool,
}

이 표지들은 출처 사전의 빌드-시점 메타데이터에서 온다. 내장된 《표준국어대사전》의 CDB·FST 파일은 어려운 한자와 애매한 독음 등 한자 병기가 바람직한 항목을 열거하는 규칙 파일과 함께 빌드된다.

내장 구현체

UnihanCharDict는 글자 단위 Unihan 독음 테이블을 HanjaDictionary로 노출한 것이다. 호출자는 다른 사전 소스와 같은 공개 사전 인터페이스로 이 독음을 결합할 수 있다. 구현은 Unicode kHangul 속성에서 만들어지는 생성 정렬 테이블이며, 두음법칙이나 수자 묶음 같은 상태 있는 폴백 규칙은 엔진의 역할로 남긴다.

MapDictionary는 테스트, 프로그램 내에서 제공되는 항목, 이미 메모리에 있는 작은 용어집을 위한 메모리 내 사전이다. 의존성을 작게 유지하고 no_std core 크레이트에서 쓸 수 있도록 순서 맵을 기반으로 한다. 압축된 직렬화 데이터, mmap 친화적 로딩, 큰 정적 사전이 필요한 호출자는 대신 gukhanmun-fst 백엔드를 사용해야 한다.

ChainDictionary는 우선순위 정책에 따라 사전들을 연속으로 결합한다. 호출자는 작은 사용자 사전(최고 우선), 주제별 사전, 《표준국어대사전》을 잇고, 정규 글자 단위 사전 match가 필요할 때 UnihanCharDict(최저 우선)를 더할 수 있다.

외부 백엔드

두 개의 외부 사전 백엔드가 별도 크레이트로 제공된다.

gukhanmun-cdb는 CDB 파일을 HanjaDictionary로 감싼다. CDB는 djb의 constant database이다; O(1)O(1) 조회를 제공하는 정적 디스크 해시 테이블이며, 파일 형식이 사소하다. 한자→한글 사상을 소박하게 CDB에 넣으면 실패한다, CDB는 접두사 순회 없는 해시 테이블이라 「어떤 키가 이 바이트 열로 시작하는가」를 물을 수 없기 때문이다. 우리는 사전을 CDB 키 공간에 매장된 trie로 인코딩해서 이를 회피한다. 빌드 시점에 gukhanmun-mkdict가 각 항목의 모든 접두사를 열거해 각각에 대하여 한 개의 레코드를 저장하며, 완전한 단어인지 중간 접두사인지를 1 바이트 플래그로 구분한다:

key   = 接頭辭의 utf-8 바이트
value = { is_complete: u8, mark: u8, reading_len: u16, reading: utf-8 }

조회는 커서 위치에서 한 글자씩 진행한다. 실패(miss)가 나면 이 위치에서 더 긴 일치는 불가능하므로 순회를 종료한다. is_complete = 1로 일치하면 그 일치를 yield한다; is_complete = 0으로 일치하면 더 긴 일치를 찾아 순회를 계속한다. 비용은 위치 당 O(textmax_word_chars)O(\\text{max\_word\_chars}) 회의 CDB 조회이고, 각 조회는 O(1)O(1)이다. 크기 비용은 실재한다: 각 항목의 모든 접두사가 한 레코드를 차지하므로 내장 stdict CDB는 원본 TSV의 약 2 배이다. 절충의 대가는 CDB의 단순함이다(파일 형식 문서가 6 개 syscall 분량이고 공공 영역의 참조 구현체들이 존재함). 백엔드의 감사가 사소해진다.

gukhanmun-fstfst::MapHanjaDictionary로 감싼다. FST(finite state transducer)는 접두사 순회를 native로 지원하고 CDB-as-trie보다 압축이 좋지만, 디스크 형식이 그만큼 보편적으로 구현되어 있지는 않다. 둘 다 제공하는 이유는 사용자의 우선순위가 다르기 때문이다; CDB는 코드 감사와 사소한 mmap 지원을 위한 선택이고, FST는 작은 WebAssembly 번들을 위한 선택이다.

사전 도구

gukhanmun-mkdict는 CDB·FST 사전 파일을 빌드하는 별도의 CLI 도구이다. TSV·CSV·JSON Lines 입력을 받고, 설정 가능한 충돌 정책으로 여러 입력 파일을 병합하며, 결과를 라운드트립으로 검증하고, 헤더에 빌드 메타데이터(출처·라이선스·빌드 일자)를 매장한다.

gukhanmun-mkdict [OPTIONS] -o OUTPUT <INPUT>...

INPUT FORMATS:
    tsv     hanja TAB hangul [TAB flags]
    csv     hanja,hangul[,require_hanja,require_hangul]
    jsonl   {"hanja":..., "hangul":..., "requireHanja":..., ...}

OPTIONS:
    -o, --output PATH               output path
    -f, --format FMT                cdb|fst            (default: fst)
        --merge STRATEGY            first-wins|last-wins|error
        --metadata KEY=VAL          embedded metadata
        --validate                  round-trip verification
        --max-key-bytes N           reject pathologically long entries
        --rules PATH                annotation rules TSV (repeatable)
        --allow-unmatched-rules     accept rules that match no entries

규칙(rules) 파일은 kind, pattern, require_hanja, require_hangul, reason 컬럼을 갖는 TSV이다. kind는 세 종류의 셀렉터를 고른다. entry는 한자 키 전체와 정확히 일치하는 단일 엔트리, containspattern이 한자 substring(1자 이상)이면 그것을 포함하는 모든 엔트리, readingpattern이 한글 reading일 때 같은 reading을 가진 모든 엔트리를 표시한다. contains pattern은 한자만 허용한다. 사전 키가 mixed-script (布告하다 등)이라 한글이 섞인 substring을 허용하면 무관한 엔트리까지 조용히 표시되기 때문이다. 같은 엔트리에 닿는 여러 규칙의 표시 비트는 OR-merge된다. 각 규칙은 require_hanjarequire_hangul 중 하나 이상을 설정해야 하고 reason이 비어 있으면 안 되며, 실제로 매칭되는 엔트리가 0개이면 빌드를 실패시킨다(규칙 파일이 사전과 어긋나 사라진 stale rule을 방지). 더 작은 사전과 규칙 파일을 공유하는 경우 --allow-unmatched-rules로 우회한다.

이 동일한 바이너리를 gukhanmun-stdict의 빌드 스크립트도 호출해서 내장 《표준국어대사전》 FST 파일을 만든다. CDB 백엔드는 사용자가 빌드하는 사전에 대해 같은 정규화 입력과 검증 경로를 사용한다. 즉 말단 사용자와 라이브러리 자체가 같은 빌드 경로를 공유하는 셈이다. 빌드 경로의 버그는 라이브러리 자신의 통합 테스트에 먼저 걸린다.

미들웨어

렌더러의 입력은 OutputToken 흐름이고, 그 안의 각 Annotated는 플래그들을 들고 있다. 플래그는 엔진이 해당 주해에 대하여 알고 있는 것을 묘사한다: 사전에서 왔는가, 동음이의어가 있는가, 이 문맥에서 처음 본 단어인가. 플래그는 「렌더러가 이를 어떻게 처리할 것인가」를 묘사하지는 않는다; 그것은 미들웨어가 설정한다.

「어떤 주해를 한자와 함께 제시할지, 어떤 것은 한글만 제시할지, 『첫 번째』는 언제 리셋되는지」와 같은 정책을, 「괄호·루비·한글 전용」 같은 형식 결정과 분리하면, Seonbi에 비해 깔끔한 파이프라인이 나온다. Seonbi에서는 렌더링 함수가 「무엇을 제시할지」와 「어떻게 제시할지」를 함께 결정한다. 그 결과, 동음이의 구별 렌더러는 내부에 동음이의 탐지 패스를 거의 그대로 다시 구현해야 하고, 다른 동음이의 휴리스틱으로 갈아끼우려면 렌더러 자체를 다시 써야 한다. Gukhanmun에서는 미들웨어가 OutputToken 흐름에 대한 상태 유지 필터이고, 렌더러는 (보통) 무상태의 Annotated 번역기이다.

내장 미들웨어

HomophoneMarker는 흐름을 주사하면서, 유효 사전 항목 집합이나 설정된 문맥 창 내에서 다른 한자 표기와 한글 독음을 공유하는 주해에 homophone = true를 설정한다. 백엔드가 항목 열거를 노출하면 HanjaDictionary::entries()로 reading-to-hanja index를 한 번 만든다. 조회 전용 사전은 has_homophone()으로 fallback하며 문맥 내 표시도 계속 받는다. 창은 per-block(기본값)·per-document·off 셋 중 하나이다. per-block 창은 다음 「is_block_boundary()가 true를 반환하는 스코프」가 나올 때까지만 버퍼링하며, 보통 한 단락이나 한 리스트 항목 정도이다. per-document 창은 흐름 전체를 버퍼링하므로 입력이 작거나 완전한 정확도가 응답 지연보다 중요할 때에만 적합하다. 순수 텍스트에는 블록 스코프가 없으므로 per-block은 문서 전체 창이 된다. 이는 의도한 계약이다. 1번째 줄에 漢字가 나오고 100번째 줄에 翰字가 나오면 두 줄 모두 한자 병기로 렌더링되어야 하며, 스트리밍 라이터는 100번째 줄을 본 뒤 1번째 줄을 소급 수정할 수 없다.

FirstOccurrenceFilter는 설정된 문맥 내에서 첫 등장 이후의 주해들에 대하여 require_hanja·require_hangul을 해제한다. 첫 번째 등장은 그대로 두어 독자가 한 번은 병기를 보게 한다. 문맥은 per-block·per-section·per-document 중 하나이다. section 변종은 모든 헤딩 경계에서 리셋되며, HTML·Markdown 어댑터가 헤딩 스코프의 is_section_boundary()로 그 경계를 노출한다.

UserDirectives는 사용자가 제공한 규칙 집합을 적용한다. 규칙은 「한자 표기에 대한 술어」 + 「동작」으로, 동작은 require_hanja 설정·require_hangul 설정·주해 자체를 건너뛰기(이 경우 활성화된 렌더러에 따라 한글이나 한자만 남는다) 중 하나이다. 술어는 문자열 집합·glob 패턴·또는 Rust 호출자의 임의 클로저일 수 있다; JavaScript 측에서는 토큰 당 경계 횡단 비용을 피하기 위하여 문자열 집합 형태만 노출한다.

사용자 정의 미들웨어

미들웨어는 상류 이터레이터를 받는 impl Iterator<Item = OutputToken<S>>이다. 트레이트 표면이 작아 한 자리에서 손쉽게 쓸 수 있다. 예를 들어 자신의 용어집에서 기술 용어 주해를 표지하고 싶다면, 용어집 집합을 들고 일치 시 require_hanja를 설정하는 미들웨어를 짜면 된다.

렌더러

렌더러는 Annotated 토큰을 그 모드와 플래그에 따라 구체적인 Text·Open·Close 토큰으로 풀어낸다. 라이브러리가 제공하는 렌더러는 다섯이다.

HangulOnly 렌더러는 한글 독음만을 내보낸다. require_hanjahomophone이 설정되어 있으면 한글(漢字) 형태로 내보낸다. require_hangul이 설정되어 있어도 결과는 이미 한글이라 변화가 없다.

HangulHanjaParens 렌더러는 항상 한글(漢字)을 내보낸다. require_hangul은 한글 부분이, require_hanja는 한자 부분이 충족한다.

HanjaHangulParens 렌더러는 항상 漢字(한글)을 내보낸다. 학술·고문서 양식에서 한자를 앞세우는 표기에 적합하다.

Ruby 렌더러는 <ruby> 요소를 만들어 내며, 어느 쪽을 기저로 두는지는 하위 모드로 결정된다: on-hangul<ruby>한글<rp>(</rp><rt>漢字</rt><rp>)</rp></ruby>, on-hanja<ruby>漢字<rp>(</rp><rt>한글</rt><rp>)</rp></ruby>. <rp> 요소는 괄호 대체 텍스트를 담아, <ruby>를 지원하지 않는 브라우저에서도 독음이 기저에 붙어 버리지 않고 基底(讀音) 형태로(on-hangul한글(漢字), on-hanja漢字(한글)) 괄호로 나타나도록 한다. 현재 스코프의 allows_inline_markup()이 false면 괄호 형식으로 환원한다.

Original 렌더러는 원 한자를 순수 텍스트로 내보낸다. require_hangul이 설정되어 있거나 사용자 지시로 표지된 주해만 병기를 받으며, 병기 형태는 하위 옵션에 따라 괄호나 ruby가 된다. 「국한문 표기는 그대로 두고 어려운 글자만 병기한다」는 모드이며, 이 설계 문서의 한국어판이 바로 이 양식을 채택하고 있다.

렌더러는 단일 Annotated 토큰과 현재 스코프의 allows_inline_markup() 값에 대한 순수 함수이다. 출력은 작고 고정된 크기의 토큰 시퀀스이다(한글이나 한자 단독이면 1 개, 괄호 형식이면 3 개, ruby면 5–9 개). 상태도 없고 버퍼링도 없다.

형식 결정을 렌더러에 둔 이유는, 그 형식이 동일한 스코프 위치에 다른 어떤 토큰들이 흐르고 있는가에 의존하기 때문이다. 예를 들어 <pre> 내부의 <ruby>는 잘못이며, 그 판단은 스코프 스택을 필요로 한다. 스코프 스택은 IR을 통해 흐르고 있는 그 데이터 자체이다. 형식 결정을 다른 곳에 두면 스코프 추적을 이중으로 가지든가, 렌더러가 다른 미들웨어들에 대하여 알고 있어야 하는 결과가 된다.

형식 어댑터

라이브러리에 포함된 어댑터는 셋이다. 새 어댑터를 추가할 때 필요한 것은 「형식의 토큰과 IR을 번역하는」 Reader·Writer 구현뿐이다.

HTML

HTML 어댑터는 손수 쓴 스캐너로 InputToken<HtmlScopeData> 이벤트를 만든다. 이 스캐너는 Seonbi의 Text.Seonbi.Html.Scanner 모듈을 거의 그대로 옮긴 것으로, 수 연간 실 환경의 한국어 웹 콘텐츠에서 검증되어 왔다. 단락 중심 설계: 입력은 완전한 문서·<body> 내용·또는 단편일 수 있고, 스캐너는 트리 구성을 시도하지 않고 본 이벤트를 그대로 내놓는다.

html5ever와 lol_html도 검토했다. html5ever는 Rust의 표준 HTML5 파서이고 DOM을 만들며 사양의 모든 변연 사례를 처리한다. lol_html은 Cloudflare의 스트리밍 HTML 리라이터로, 셀렉터 중심이며 WebAssembly에 잘 어울린다. 그럼에도 손수 쓴 스캐너를 택한 두 가지 이유가 있다. 첫째, Seonbi 접근은 속성 문자열을 날 문자열로 그대로 보존하므로, 라이터가 변화 없는 스코프를 입력에 나타난 그대로 직렬화할 수 있다. 둘째, WebAssembly 번들에 충분히 작게 들어간다; html5ever를 함께 가져오면 바이너리 크기를 지배해 버린다. 대가는 이 스캐너가 HTML5에 완전히 적합하지는 않다는 점이고, 우리는 그 절충을 수용한다.

스캐너는 가벼운 오류는 복구한다. 닫히지 않은 태그는 같은 이름의 가장 최근 스코프를 pop하고, 그런 스코프가 없으면 텍스트로 내보낸다. <로 시작하지만 유효한 태그·코멘트·CDATA 시작이 아닌 구조는 텍스트 글자로 내보낸다. 완전히 망가진 입력도 토큰 흐름은 만들어 내며, 다만 구조적 이상이 포함될 뿐이다; 엔진은 이상에 강건하고(스코프 일치를 가정하지 않는다), 라이터는 받은 스코프를 그대로 내보낸다.

HtmlScopeData::is_preserve()는 현재 요소가 pre·code·kbd·script·style·textarea 중 하나이거나, 상속된 lang 속성이 한국어가 아니면 true를 반환한다. lang 상속은 어댑터 내부에서 계산된다: 각 Open 이벤트에서 날 속성을 평가해 lang 값을 찾고, 어댑터는 자신의 lang 스택을 유지한다. 코드베이스에서 lang이 무엇인지 아는 유일한 곳이며, 보존 대상 태그 목록을 아는 유일한 곳이기도 하다. 엔진은 그 결과를 is_preserve()로 받을 뿐, 어느 규칙도 다시 따라 하지 않는다.

Markdown

Markdown 어댑터는 pulldown-cmark 위에 얹은 얇은 계층이다. 각 pulldown-cmark::Event는 한 개 이상의 IR 토큰이 된다. Start(Tag)·End(Tag)Open·Close로, TextText로, CodeVerbatim으로 대응된다. 인라인 HTML(즉 Event::Html)은 HTML 스캐너로 한 번 더 통과시켜, 단락 내부의 <q lang="ja"> 같은 구조도 올바른 lang 처리를 받게 한다.

출력은 pulldown-cmark-to-cmark를 통해 이뤄지며, 이는 이벤트 흐름을 다시 Markdown으로 직렬화한다. 의미 보존은 계약이다: 출력을 다시 해석하면 동일한 논리 구조가 나온다. 바이트 단위 보존은 최선의 노력이다: setext 헤딩이 ATX로 바뀔 수 있고, 링크 참조 정의가 인라인화될 수 있으며, 연성 줄바꿈이 규칙화될 수 있다. 바이트 단위 충실성이 필요한 사용자는 Markdown보다는 렌더링된 HTML을 처리해야 한다.

pulldown-cmark를 택한 이유는 CommonMark 적합은 큰 작업이고 그것은 pulldown-cmark가 잘 처리하기 때문이며, 그 이벤트 흐름 API가 우리의 IR과 거의 완벽히 일치하기 때문이다. 대안은 markdown-rs였는데, 그것은 이벤트 흐름이 아니라 AST를 산출한다; 스트리밍 성질을 유지하기 위하여 이벤트 흐름을 선호했다.

순수 텍스트

순수 텍스트 어댑터는 전체 입력을 단일 스코프로 감싸 한 개의 Text 토큰을 내보낸다. 출력은 Text 토큰들의 연결이다. Ruby 렌더링은 순수 텍스트에서 의미가 없으므로 괄호 형식으로 환원된다. CLI는 이미 렌더링한 출력을 문서 전체 미들웨어가 나중에 바꿀 가능성이 없을 때에만 순수 텍스트를 EOF 전에 스트리밍할 수 있다. 실제로 ko-kp 프리셋은 동음이의 표지이 꺼져 있으므로 스트리밍하지만, 기본 ko-kr 프리셋은 줄 사이 동음이의를 정확히 렌더링하기 위해 순수 텍스트 출력을 EOF까지 보류한다.

배포

Rust 워크스페이스

Rust 코드는 Cargo 워크스페이스로 다음과 같이 구성된다:

  • Cargo.toml: 워크스페이스 매니페스트
  • DESIGN.md: DESIGN.en.md로의 심볼릭 링크
  • DESIGN.en.md, DESIGN.ko-Kore.md: 설계 문서
  • crates/
    • gukhanmun-core/: IR 타입·엔진·사전 트레이트·라티스 분할기·폴백 음역기·두음법칙 대조표·내장 UnihanCharDict. I/O 없음, 형식 의존 코드 없음, 의존성 최소. alloc이 있는 no_std 환경에도 적합.
    • gukhanmun-html/: HTML 스캐너와 직렬화기. HtmlScopeData 구현.
    • gukhanmun-markdown/: pulldown-cmark 위에 얹은 Markdown 어댑터.
    • gukhanmun-cdb/: CDB-trie 사전 백엔드.
    • gukhanmun-fst/: FST 사전 백엔드.
    • gukhanmun-stdict/: 내장된 《표준국어대사전》을 FST 바이트 배열로 제공.
    • gukhanmun-mkdict/: TSV·CSV·JSONL 입력에서 CDB·FST 사전을 빌드하는 CLI.
    • gukhanmun/: 우산 라이브러리 크레이트. 기능 플래그에 따라 다른 크레이트들을 재노출하고, 고수준 Builder API와 우산 Error 열거형을 제공.
    • gukhanmun-cli/: gukhanmun 명령줄 바이너리.
    • gukhanmun-wasm/: wasm-bindgen을 통한 WebAssembly 바인딩.
    • gukhanmun-napi/: napi-rs를 통한 Node-API 바인딩.

우산 크레이트의 기능 플래그가 나머지를 조합한다. 기본 기능은 HTML·Markdown·내장 stdict를 활성화한다. CDB·FST는 개별 선택 가능하다. 모두 꺼 두면 엔진과 UnihanCharDict만 남는 Rust API 전용 빌드가 되며, 임베디드 환경에 적합하다.

JavaScript 패키지

JavaScript 측은 타입 전용 패키지 하나와, 런타임 구현체별 패키지들로 분리된다:

  • @gukhanmun/types (npm·JSR): TypeScript 인터페이스·타입 별칭·에러 클래스 선언·GukhanmunFactory 인터페이스. 런타임 코드 없음; npm에는 선언만, JSR에는 .ts 원본을 그대로 배포. API 계약의 정본이며, 두 구현체 모두 구조적으로 이를 만족한다.
  • @gukhanmun/wasm (npm·JSR): WebAssembly 구현체. 타입을 재노출. .wasm 아티팩트를 import.meta.url로 해결해서, Deno·브라우저가 native로, Node 22 이상이 표준 ESM 로더로 해결할 수 있게 한다.
  • @gukhanmun/napi (npm 전용): Node-API 구현체. 타입을 재노출. napi-rs의 optional dependency 패키징으로 플랫폼별 prebuilt 바이너리를 배포.

사전 데이터는 별도 패키지로 배포해 런타임 번들이 작게 유지되도록 한다:

  • @gukhanmun/stdict-fst (npm·JSR): 내장 stdict를 FST 형태로 Uint8Array export.
  • @gukhanmun/stdict-cdb (npm·JSR): 동일한 사전을 CDB 형태로 제공.
  • @gukhanmun/stdict-min (npm·JSR): 동음이의 항목과 애매 독음만 줄여서 담은 축소 FST. 크기 민감한 문맥용.

타입 전용 패키지를 따로 두는 이유는 API 계약의 정본이 한 곳에만 있어야 하기 때문이다. 만일 계약이 @gukhanmun/wasm에 살고 @gukhanmun/napi가 그것을 중복하거나 서로에서 가져온다면, 두 구현체 사이 버전 어긋남이 유지보수 부담이 된다. @gukhanmun/types를 양 구현체의 peerDependency로 두면, 사용자는 런타임 선택과 무관하게 유일한 출처와 유일한 타입 집합을 얻는다. 기각한 두 대안은 다음과 같다: 타입을 WASM 패키지에 두고 NAPI가 그것에 직접 의존(비대칭이며, NAPI가 WASM에 종속됨); 두 패키지에 타입을 중복(복사본 사이의 drift, 정본 TSDoc을 둘 곳이 없음).

JavaScript 측 옵션 열거형은 const-as 대상이 아니라 문자열 union 타입이다. 이것이 @gukhanmun/types를 진정한 의미의 타입 전용으로 만든다: 런타임 코드를 0 바이트 내보내므로 번들에서 중요하고, JSR 패키지의 원본을 옮겨 적기(transpile) 없이 .ts 한 개로 끝낼 수 있다. 절충은 옵션 문자열이 런타임에 「stringly typed」가 된다는 점이다; 두 구현체는 경계에서 이를 검증하고, 인식할 수 없는 값에 대하여는 invalid-input 코드의 GukhanmunError를 던진다.

JavaScript 측 스트리밍은 플랫폼의 TransformStream<string, string> 인터페이스를 이용한다. 이 인터페이스는 브라우저·Deno·Node 18 이상·Bun에서 두루 사용 가능하다. 청크는 JavaScript 문자열이고, 인코딩 처리(TextDecoderStream·TextEncoderStream)는 Gukhanmun 스트림 바깥에 있다. 엔진 내부에서는 청크가 변환 후보 범위 중간에서 끝나거나, 한자 글자에 너무 가까워 혼용 표기 사전 키가 경계를 넘어 이어질 가능성이 있으면, 그 꼬리 범위를 다음 청크까지 보류하며, 그 앞까지는 즉시 내보낸다. 그 중 사전 선독에 필요한 버퍼는 사전의 max_word_chars에 라티스의 출구 상태를 위한 작은 상수를 더한 정도로 제한되며, 보통 수 십 자 내외이다. 그러나 fallback 한자만 이어지는 연속 범위는 청크 경계에서 나누지 않는다. 원 한자를 보여 주는 렌더링 모드에서는 주해 묶음이 출력에 드러나므로, 그런 범위는 후속 비변환 경계나 EOF에서 내보낸다.

상태 있는 미들웨어는 자신만의 선독 요건을 추가할 수 있다. 문서 전체 문맥의 동음이의 표지, 그리고 블록 스코프가 없어 문서 전체처럼 동작하는 순수 텍스트 per-block은 정확한 렌더링을 보증하려면 EOF까지 버퍼링해야 한다. 이 미들웨어를 끄면 조기 스트리밍은 회복되지만 줄 사이 동음이의 구별은 함께 사라진다.

스트림 타입으로 Uint8Array가 아니라 문자열을 택한 이유는 엔진이 근본적으로 Unicode 스칼라 단위로 동작하기 때문이다. 바이트 단위 청크화는 어댑터가 모든 경계에서 부분 코드포인트 재조립을 직접 해야 함을 의미하는데, 그것은 플랫폼의 TextDecoderStream이 이미 올바르게 해 주는 일이다. 바이트 스트림을 들고 있는 사용자는 TextDecoderStream → Gukhanmun 변환 순으로 연결하면 된다.

사전 설정

JavaScript의 사전 설정은 파일 출처 또는 메모리 내 맵 중 하나를 받는다:

export type DictionarySource = FileDictionarySource | MapDictionarySource;

export interface FileDictionarySource {
  readonly data: BufferSource | string | URL;
  readonly format: "cdb" | "fst" | "tsv";
}

export type MapDictionarySource =
  ReadonlyMap<string, Omit<DictionaryEntry, "hanja">>;

두 변종은 런타임에서는 instanceof Map으로, 컴파일 시점에서는 구조 타입으로 구분된다. Map 형태는 코드 안에서 만드는 작은 용어집에 편리하고, 파일 형태는 배포되는 사전을 위한 것이다.

레지스트리 대조표

패키지crates.ionpmJSR
gukhanmun-core아니오아니오
gukhanmun-html아니오아니오
gukhanmun-markdown아니오아니오
gukhanmun-cdb아니오아니오
gukhanmun-fst아니오아니오
gukhanmun-stdict아니오아니오
gukhanmun-mkdict아니오아니오
gukhanmun아니오아니오
gukhanmun-cli아니오아니오
gukhanmun-wasm아니오아니오
gukhanmun-napi아니오아니오
@gukhanmun/types아니오예 (선언만)예 (.ts 원본)
@gukhanmun/wasm아니오
@gukhanmun/napi아니오아니오
@gukhanmun/stdict-fst아니오
@gukhanmun/stdict-cdb아니오
@gukhanmun/stdict-min아니오

CLI 바이너리는 각 릴리스 GitHub Releases에 플랫폼별(Linux x86_64·aarch64, macOS arm64·x86_64, Windows x86_64) 첨부된다.

버전은 모든 패키지에서 lockstep으로 진행한다. 매 릴리스 태그가 Rust 워크스페이스의 모든 크레이트 버전과 모든 JavaScript 패키지 버전을 동시에 올린다. 어떤 패키지는 해당 릴리스에서 기능적 변화가 없을 수도 있는데, 그래도 버전은 올라간다. 언어 간 이야기를 명확하게 유지하기 위함이다. 패키지 개별 semver 대신 lockstep을 택한 이유는 의존 범위 불일치 비용(@gukhanmun/wasm@1.2@gukhanmun/types@1.3을 함께 설치한 사용자가 혼란스러운 타입 오류를 보는 등)이 가끔 일어나는 「실질 변화 없는 버전 올림」 비용보다 크기 때문이다. 태그 푸시에 반응하는 CI 워크플로가 crates.io에 배포하고, NAPI prebuilt를 플랫폼별로 병렬 빌드하고, npm·JSR에 배포한다. 같은 태그에서 다시 배포를 실행해도 중복 덮어쓰기를 거부하는 레지스트리들 대상으로는 무동작이다.

엔지니어링 정책

에러

각 크레이트는 thiserror로 자신만의 에러 열거형을 정의한다. 우산 gukhanmun 크레이트가 #[from]으로 그것들을 모아, 호출자가 크레이트 경계를 넘어 ? 연산자를 자연스럽게 쓸 수 있게 한다. 패턴:

// gukhanmun-core/src/error.rs
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
    #[error("dictionary load failed: {0}")]
    DictionaryLoad(String),

    #[error("segmentation failed for {hanja:?}: {reason}")]
    Segmentation { hanja: String, reason: String },

    #[error("invalid hangul reading {reading:?} for hanja {hanja:?}")]
    InvalidReading { hanja: String, reading: String },

    #[error("internal invariant violated: {0}")]
    Internal(&'static str),

    #[error(transparent)]
    Other(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
}

#[non_exhaustive] 속성 덕분에 마이너 릴리스에서 새 변종을 추가해도 호출자의 코드가 깨지지 않는다; 하류의 match 문은 wildcard 가지를 두도록 강제된다. 각 변종은 사람이 읽을 수 있는 메시지와 기계가 다룰 수 있는 구조화 데이터를 동시에 들고 있다. std::error::Error::source() 사슬은 #[from]#[source]로 유지되므로, 에러를 거슬러 올라가면 전체 흔적을 얻을 수 있다.

라이브러리 크레이트는 anyhow를 쓰지 않는다. CLI는 쓴다, CLI의 임무는 사람에게 에러를 보여주는 것이고, 다른 코드의 검사 대상이 되는 것이 아니기 때문이다.

스트림 단계의 복구 정책은 설정 가능하다. 기본값은 Recovery::Strict이다: 엔진은 리더 에러를 그대로 전파하고 중단한다. Recovery::Lenient는 에러를 tracing으로 로그하고 해당 구간에 대하여 Verbatim 토큰을 내보내, 그 뒤 토큰들이 계속 흐르게 한다.

JavaScript 측에서는 에러를 구별자 코드를 가진 단일 클래스로 다룬다:

export class GukhanmunError extends Error {
  readonly code: ErrorCode;
  readonly chain: readonly { code: ErrorCode; message: string }[];
}

export type ErrorCode =
  | "dictionary-load"
  | "segmentation"
  | "invalid-reading"
  | "html-scan"
  | "html-malformed-attr"
  | "markdown"
  | "unsupported-content-type"
  | "invalid-input"
  | "io"
  | "internal"
  | "other";

바인딩은 FFI 경계에서 Rust의 source() 사슬을 순회하면서, 에러에 chain 속성을 구성한다. JavaScript 호출자는 추가 FFI 없이도 원인을 살펴볼 수 있다.

로깅

gukhanmun-core와 동반 크레이트들은 tracing 크레이트에 조건 없이 의존한다. 라이브러리 코드는 tracing::trace!·tracing::debug!·tracing::info!·tracing::warn!·tracing::error!을 직접 사용한다. 구독자(subscriber)가 등록되어 있지 않을 때의 오버헤드는 호출 지점 당 atomic load 한 번과 분기 하나 정도로, 최적화할 만한 한계 아래이다.

호출을 완전히 컴파일아웃하고자 하는 바이너리(WebAssembly 빌드가 대표적)는 자신의 Cargo.toml에서 tracingrelease_max_level_off 기능을 켠다. 이 기능은 바이너리 레벨에서 동작한다: 의존성 전체에 걸친 모든 tracing::*! 호출을 컴파일 시점의 no-op으로 치환하므로, 라이브러리 측 설정을 다시 할 필요가 없다.

라이브러리 레벨에서 tracing 의존성 자체를 옵션으로 두고 off 경노용 스텁 매크로를 제공하는 안도 검토했다. log 모듈을 각 크레이트에 두고 조건 컴파일로 tracing 매크로를 재노출하거나 스텁을 제공하는 추가 복잡도가 해당 바이너리 절약분보다 커서 채택하지 않았다. WebAssembly 번들 측정 결과가 이를 요구하면 그때 재고한다.

테스트

테스트 모음은 네 부분이다.

회귀 픽스처는 Seonbi나 Gukhanmun 초기 개발에서 나타났던 특정 버그 양상을 다룬다. Seonbi test/data/ 디렉터리에서 적절한 부분이 그대로 이식된다; 각 픽스처는 HTML이나 Markdown 입력·기대 출력 파일 한 쌍과 옆의 설정 파일로 구성된다.

스냅샷 테스트insta로 IR 직렬화와 저장된 JSON을 비교한다. 입출력이 텍스트가 아니라 토큰 흐름인 엔진·미들웨어 크레이트에 가장 유용하다. 실패한 스냅샷은 색색의 diff를 출력하고 대화적으로 수용·거부를 묻는다.

성질 기반 테스트proptest로 생성된 입력에 대하여 불변식을 주장한다. 가장 중요한 두 가지는: 리더 → 라이터 라운드트립이 토큰 흐름을 손실 없이 유지한다(문서화된 Markdown 「최선의 노력」 조건 제외); 그리고 한글만 든 입력에 대한 엔진 → 렌더러는 무동작이다(한자가 없는 텍스트에서 주해를 만들어 내면 안 된다).

적합성 테스트는 CommonMark 사양의 일부 예시에 대하여 Markdown 어댑터를 돌려, Gukhanmun이 손대지 않으려 하는 구문을 어댑터가 깨뜨리지 않음을 확인한다.

CI는 네 가지 모두를 stable·beta·워크스페이스의 MSRV(최저 지원 Rust 버전)에서 돌린다. WASM 빌드는 크기 퇴보 대상으로도 감시된다: 각 아티팩트별 고정 크기 예산이 설정되어 있고, 이를 초과하는 퇴보는 빌드를 실패시킨다.

프리셋

옵션ko-krko-kp
renderinghangul-onlyhangul-only
disambiguationper-blockoff
segmentationlatticelattice
initialSoundLawtruefalse
numeralshangul-phonetichangul-phonetic
dictionary.bundled"stdict-ko-kr"false
firstOccurrence(없음)(없음)

ko-kr 프리셋은 대한민국의 정서법과 어휘 관행에 맞춘다: 사전 주도 독음, 라티스 분할, 폴백 구간에 적용되는 두음법칙, 단락 내에서 독음이 애매할 때 한자를 괄호로 병기하는 per-block 동음이의 구별. ko-kp 프리셋은 조선민주주의인민공화국의 관행, 즉 두음법칙을 적용하지 않고 한자어를 한글로 표기하는 관행(래일, 류행, 녀자)을 반영한다. 내장 사전을 가져오지 않는다, 대한민국 《표준국어대사전》의 독음은 ko-KP에서 부정확하기 때문이다.

CLI는 두 프리셋을 --preset ko-kr·--preset ko-kp로 노출한다. 개별 옵션은 프리셋 위에서 덮어쓸 수 있다, 예를 들어 --preset ko-kr --no-stdict는 다른 대한민국 기본값은 유지하면서 내장 사전만 끈다.

두음법칙 대조표

대한민국 한글 맞춤법(제6장 제5절 제52항)은 일부 어두 음절을 변환한다. 대조표는 Seonbi의 Text.Seonbi.Hanja 모듈에서 그대로 옮겨 왔으며, gukhanmun-coreinitial_sound_law_table 상수의 정본이다.

원 음변환 결과

한자 수자 대조표

폴백 음역기와 additive-arabic 수자 전략은 동일한 한자 수자·자릿값 표지 대조표를 공유한다.

한자비고
, 0
, , , , 1
, , , , , 2
, , , , , 3
, , 4
, 5
, , 6
, , 7
, 8
, 9
, 10
, , 100
, , 1000
, 10000
10000000010810^8
101210^{12}
101610^{16}
102010^{20}
102410^{24}
102810^{28}
103210^{32}
103610^{36}

additive-arabic 전략은 자릿값 표지를 승수로, 인접한 수자 한자를 피승수로 다루며, ··이 단독으로 쓰일 때 각각 10·100·1000을 뜻하고 一十·一百·一千이 아니라는 한국식 생략 규칙도 처리한다.

보존 대상 HTML 태그

기본 HtmlScopeData::is_preserve()는 속성 내용과 무관하게 다음 태그명에 대하여 true를 반환한다: pre, code, kbd, script, style, textarea.

또한 상속된 lang 속성의 주 태그가 ko·kor이나 한국어를 가리키는 하위 태그(ko-KR, ko-Hang, ko-Kore, kor-KP 등)가 아닐 때에도 true를 반환한다. 한국어 술어는 Seonbi의 isKorean과 일치한다.

이 목록을 확장하려는 사용자(예: class="no-translate" 속성을 추가)는 HtmlReaderOptionspreserve_when 술어를 read_html_fragment_with_options에 넘긴다. 술어는 각 요소에 대하여 HtmlElementInfo(정규 태그 명칭, 시작 태그의 raw 속성 슬라이스, 상속된 lang 값)를 받아서 true를 반환하면 해당 스코프와 그 후손을 보존 대상으로 표시한다. 술어로 표지된 스코프는 기존 보존 태그처럼 후손에게 보존 플래그를 상속하므로 자 요소마다 규칙을 다시 제시할 필요가 없다. CLI는 이 훅을 가장 자주 쓰는 두 가지 형태로 노출한다: --html-preserve-class CLASS--html-preserve-attr KEY[=VALUE](각각 반복 가능, OR 합성, --format text/html에서만 유효). format에 중립인 skip 클로저를 EngineOptions에 두는 대안은 후속 릴리즈에서 다시 검토한다. 현재 동곤 어댑터들은 각자의 ScopeData 로 보존 규칙을 충분히 표현할 수 있어 이번 범위에서는 제외했다.

CDB-trie 키 스키마

CDB-trie 데이터베이스는 각 항목의 접두사마다 한 개의 레코드를 둔다. 키는 접두사의 UTF-8 바이트이다. 값의 레이아웃은 다음과 같다:

오프셋크기필드
01is_complete (해당 접두사 자체가 항목이면 1)
11mark (비트필드: 0번 비트는 require_hanja, 1번 비트는 require_hangul)
22reading_len (little-endian; is_complete = 0이면 0)
4reading_len독음 (UTF-8)

별도의 잘 알려진 키 __gukhanmun_meta__는 빌드 메타데이터(출처·라이선스·빌드 일자·원본 항목 수·접두사 수·글자/바이트 단위 최대 항목 길이)를 작은 CBOR 문서로 담는다.

FST 스키마

FST 데이터베이스는 각 사전 단어마다 한 개의 항목을 둔다. 키는 한자 표기의 UTF-8 바이트이고, 값은 다음 레이아웃의 64비트 정수이다:

비트필드
0–15UTF-8 독음 길이 (바이트)
16–23mark (CDB의 require_hanja·require_hangul 비트와 동일)
24–63별도 독음 문자열 테이블 내 오프셋

독음 문자열 테이블은 FST 자체에 이어지는 연속된 UTF-8 바이트 블록이다. FST 값 타입이 고정 크기이기 때문에 가변 길이 독음을 인라인으로 둘 수 없어 별도 테이블이 필요하다. mark 바이트는 모든 조회에서 확인되어 핫 경로에 속하므로, 별도 테이블이 아닌 값 안에 함께 둔다.

파일 시작 부분의 메타데이터 헤더(8 바이트 매직, 버전, 레이아웃 오프셋, CDB와 비슷한 CBOR 메타데이터 블롭)가 FST 바이트 앞에 놓인다.

용어집

한글한자영어
한자한자hanja
한자어한자어Sino-Korean word
국한문혼용국한문혼용mixed-script Korean writing
두음법칙두음법칙initial sound law
표준국어대사전표준국어대사전Standard Korean Language Dictionary
한글한글hangul, the native Korean alphabet
한글전용한글전용hangul-only writing
동음이의동음이의homophony