Kiwi에 glue와 space를 넣었습니다
한국어 형태소 분석기 Kiwi에 보낸 PR이 어제 머지됐습니다.
glue와 space라는 두 함수가 파이썬 래퍼(kiwipiepy)에만 구현돼 있었습니다. 그래서 C나 Java, WASM으로 Kiwi를 쓰면 이 기능을 못 썼어요. 그걸 C++ 코어로 내리고 네 바인딩에 전부 노출한 작업입니다. 이슈 #153으로 열려 있던 요청이기도 했고요.
space는 띄어쓰기가 엉망인 문장을 형태소 기준으로 교정합니다.glue는 줄 단위로 잘린 조각을 붙일 때, 붙인 후보와 띄운 후보를 각각 분석해서 언어모델 점수가 높은 쪽을 고릅니다. PDF에서 복사한 텍스트를 되살릴 때 쓰는 함수예요.
이미 있는 구현을 옮기는 거라 기계적인 작업일 줄 알았는데, 걸린 데가 몇 군데 있었습니다. 그것만 적어봅니다.
1. Match.ALL과 Match::all이 같은 게 아니었습니다
glue는 후보 문자열을 분석할 때 매치 옵션을 넘깁니다. 파이썬 쪽은 Match.ALL을 넘기고 있었고, C++에도 Match::all이 있으니 그냥 쓰면 되는 줄 알았습니다.
아니었습니다. C++의 Match::all에는 Z_CODA가 들어 있는데 파이썬의 Match.ALL에는 없습니다. 이름이 같고 뜻도 같아 보이는데 구성이 다릅니다.
그대로 뒀으면 대부분의 입력에서는 똑같이 동작하다가 특정 입력에서만 파이썬과 결과가 갈렸을 겁니다. 나중에 누가 “왜 파이썬이랑 다르지” 하고 물어봐야 발견되는 종류의 차이예요. 그래서 all을 쓰지 않고 필요한 옵션을 하나씩 나열해뒀습니다.
포팅에서 위험한 건 번역이 어려운 부분이 아니라 번역이 필요 없어 보이는 부분이었습니다.
2. 비슷한 함수가 이미 있었지만 고치지 않았습니다
space는 인접한 두 형태소 사이에 공백을 넣어도 되는지 판단해야 합니다. 그런데 cmb::Joiner에 isSpaceInsertable이라는, 거의 같은 일을 하는 함수가 이미 있었습니다.
처음엔 당연히 그걸 쓰려고 했는데 규칙이 여러 군데에서 달랐습니다. SN-NR, J-XS*, -SSO, J/E-SS, W_* 태그가 전부 다르게 동작해요. 둘 중 하나가 틀린 게 아니라 쓰이는 맥락이 다른 겁니다.
그래서 기존 함수는 건드리지 않고 판정 함수를 따로 뒀습니다. 중복처럼 보이지만, 기존 함수를 space에 맞게 고쳤으면 Joiner를 쓰는 쪽이 조용히 깨졌을 거예요.
파이썬 구현은 이 규칙을 품사 태그 이름에 대한 정규식으로 써놨는데, 여기서는 태그 문자열의 문자 검사로 다시 썼습니다. 토큰마다 정규식을 돌릴 이유가 없어서요.
3. glue가 필요한 것보다 두 배 느렸습니다
glue는 조각 사이의 틈마다 후보 두 개를 분석합니다. 처음 구현은 이걸 하나씩 순서대로 돌렸어요. 틈이 n개면 순차 분석이 2n번입니다.
파이썬 구현에는 이 문제가 없었습니다. 후보 전체를 스트림으로 analyze에 넘기면 그쪽이 알아서 스레드풀에 뿌리거든요. C++에도 같은 형태의 오버로드가 있어서 그걸 쓰도록 고쳤습니다. 동시에 떠 있는 분석을 pool->size() * 2로 제한해 메모리가 늘지 않게 하고, 결과는 입력 순서대로 받고, 워커 없이 만든 인스턴스면 알아서 순차로 떨어집니다. 그 경우를 위한 분기를 따로 쓸 필요가 없었습니다.
여기서 한 번 더 걸렸는데, 이 스트림은 빈 문자열을 “끝”이라는 신호로 씁니다. 그런데 진짜로 비어 있는 후보가 나올 수 있어요. 양쪽 조각이 모두 빈 경우의 “붙인 후보”가 그렇습니다. 구분할 방법이 없어서 그 케이스만 스트림에서 빼고 따로 점수를 매겼습니다.
센티널 값을 쓰는 API는 그 값이 실제 데이터로 나올 수 있는지부터 확인해야 하는데, 또 잊고 있었습니다.
4. 아무도 안 밟아서 남아 있던 JNI 버그
Java 바인딩에서 glue 결과를 돌려주려는데 배열이 전부 0으로 왔습니다.
JNI 헬퍼(JniUtils.hpp)의 정수 벡터 변환 코드가 Get*ArrayElements로 얻은 포인터에 값을 쓰고는 Release*ArrayElements를 부르지 않고 있었습니다. JVM이 직접 포인터를 주는 환경에서는 그냥 동작하고, 복사본을 주는 환경에서는 쓴 값이 조용히 버려집니다.
이게 지금까지 안 드러난 이유는 단순합니다. 정수 배열을 반환하는 네이티브 메소드가 하나도 없었어요. 코드가 어려워서가 아니라 그 방향으로 아무도 안 가봐서 남아 있던 버그입니다.
std::vector<std::u16string>(String[])도 아예 컴파일이 안 되던 상태라 같이 고쳤습니다. 기능 구현과 섞이면 리뷰하기 어려울 것 같아 커밋은 분리했습니다.
5. 리뷰에서 배운 것: std::vector<bool>
Java의 glue는 결합된 텍스트와 함께 “각 틈에 공백이 들어갔는지”를 돌려줍니다. 자연스럽게 std::vector<bool>을 썼습니다.
메인테이너(@bab2min)가 리뷰에서 이걸 std::vector<uint8_t>로 바꾸는 커밋을 직접 붙여줬어요. std::vector<bool>은 C++ 표준에서 비트 단위로 압축되는 특수화라 원소의 주소를 얻을 수 없고, 다른 컨테이너처럼 굴지 않습니다.
사실 저는 이미 한 번 우회하고 있었습니다. Java 쪽 반환 타입을 boolean[]이 아니라 byte[]로 뒀거든요. JNI 헬퍼가 1바이트 정수 벡터만 매핑할 수 있고 std::vector<bool>은 통과를 못 해서요. 우회를 해놓고도 원인이 타입 선택에 있다는 생각은 못 했습니다.
“이 타입은 여기서 안 통하네” 싶을 때 우회하기 전에 타입을 먼저 의심했어야 했습니다.
6. 검증은 네 겹으로 겹쳐놨습니다
포팅이라 정답이 명확했습니다. kiwipiepy 0.23.2가 내놓는 값이 기준이에요.
그래서 C++, C API, Java, WASM 네 레이어의 테스트가 전부 같은 기대값 테이블을 쓰게 했습니다. 한 레이어만 어긋나면 바인딩 문제, 넷이 같이 어긋나면 코어 문제라는 게 바로 나옵니다.
Windows / MSVC 14.51 기준으로 kiwi-test 125개, Java JUnit 17개(JDK 21), WASM vitest 11개가 통과했습니다. 여기에 공식 C# 래퍼(kiwi-gui의 KiwiCS)로 이 브랜치에서 빌드한 kiwi.dll을 로드하는 작은 WinForms 데모를 만들어서, 실제 애플리케이션이 부르는 경로로도 kiwipiepy와 결과가 같은지 확인했습니다. 데모는 검증용이라 PR에는 넣지 않았습니다.
💡 팁 / 주의할 점
- 팁: 같은 기능을 여러 바인딩에 낼 때는 기대값 테이블을 하나로 공유하세요. 레이어마다 따로 쓰면 “이 바인딩만 다른데 누가 맞는 건가”를 매번 사람이 판단해야 합니다.
- 팁: 반환 문자열의 소유권 규칙은 새로 정하지 말고 그 저장소에 이미 있는 걸 따르세요.
kiwi_space와kiwi_glue는kiwi_free_string으로 해제하는데,kiwi_get_morpheme_form과 같은 방식이라 쓰는 사람이 문서를 새로 읽을 필요가 없습니다. - 주의할 점: 포팅할 때 양쪽에 같은 이름으로 있는 상수나 옵션이 가장 위험합니다. 번역이 필요한 부분은 어차피 들여다보게 되는데, 이름이 같으면 그냥 지나가게 되니까요.
정리
바뀐 건 15개 파일에 810줄쯤인데, 정작 시간은 새로 짠 부분에서 안 걸렸습니다. 이미 있는 코드가 왜 그렇게 돼 있는지 알아내는 쪽이 오래 걸렸어요.
isSpaceInsertable을 재사용하지 않기로 한 것도, Match::all을 나열로 푼 것도 결국 “이건 왜 이렇게 생겼지”를 한 번 더 물어봐서 나온 결정입니다. 남의 저장소에서는 이 질문 값이 훨씬 비싸더라고요.
태그: #오픈소스, #Kiwi, #형태소분석, #C++, #JNI, #WASM, #자연어처리, #한국어, #코드리뷰, #포팅