Bluetape4k Text Part 2: 토크나이저와 언어 감지로 입력 라우팅하기

Part 1에서는 bluetape4k-text의 전체 지도를 봤습니다. 이번에는 실제 서비스 입력을 기준으로 한 단계 더 들어갑니다.
사용자가 검색어, 신고 문구, 상품명, 댓글을 입력한다고 가정해 보겠습니다. 입력에는 한국어, 일본어, 영어가 섞일 수 있고, 이모지만
들어올 수도 있습니다.
이때 모든 입력을 split(" ")로 처리하면 처음에는 간단해 보입니다. 그러나 검색 품질이 저하되거나 금칙어 필터가 누락되면,
텍스트가 어느 단계에서 잘못 분리되었는지 역추적해야 합니다.
그래서 Part 2의 기준은 단순합니다.
- 먼저 입력 경계를 검증합니다.
- 언어를 감지합니다.
- 한국어는
KoreanProcessor, 일본어는JapaneseProcessor, 그 외 입력은 더 단순한 경로로 보냅니다. - 결과는 토큰, 명사, 언어 집합처럼 다음 단계에서 검증할 수 있는 값으로 남깁니다.
처리 경로
섹션 제목: “처리 경로”서비스 코드는 아래 흐름으로 잡으면 이해하기 쉽습니다.

먼저 구분할 것은 Lingua와 토크나이저의 역할입니다. Lingua는 “이 입력을 어느 처리 경로로 보낼까?”를 결정하는 쪽에 가깝습니다. 한국어 형태소 분석과 일본어 형태소 분석은 그 다음 단계입니다. 역할이 섞이면 코드가 금방 모호해집니다. 언어 감지 결과와 관계없이 모든 입력을 같은 토크나이저에 넣는다면 감지기를 둔 의미가 줄어듭니다.
한국어 입력: 정규화부터 결과를 고정한다
섹션 제목: “한국어 입력: 정규화부터 결과를 고정한다”한국어 입력은 같은 의미라도 표기가 쉽게 흔들립니다. 반복 문자가 길어지고, 구어체 오타가 들어오고, 조사와 어미가 붙습니다. 검색 색인이나 필터링에 바로 넣기 전에 정규화와 토큰화를 분리하는 편이 좋습니다.
val normalized = KoreanProcessor.normalize("그랰ㅋㅋㅋㅋ 샤릉햌ㅋㅋ")// "그래ㅋㅋㅋ 사랑해ㅋㅋ"
val tokens = KoreanProcessor.tokenize( KoreanProcessor.normalize("한국어를 처리하는 예시입니닼ㅋㅋㅋㅋ"))
val surfaces = KoreanProcessor.tokensToStrings(tokens)// ["한국어", "를", "처리", "하는", "예시", "입니다", "ㅋㅋㅋ"]이 예제에서 normalize는 구어체 반복과 오타를 줄이고, tokenize는 품사와 위치 정보를 가진 token을 만듭니다.
서비스 코드에 문자열 목록만 필요하다면 tokensToStrings로 공백 토큰을 제외하고 표면형만 가져오면 됩니다.
한국어 검색에서 자주 필요한 작업은 구 추출입니다.
val phrases = KoreanProcessor.extractPhrases( KoreanProcessor.tokenize(KoreanProcessor.normalize("한국어를 처리하는 예시입니닼ㅋㅋㅋㅋ")))
phrases.joinToString("/")// "한국어(Noun: 0, 3)/처리(Noun: 5, 2)/처리하는 예시(Noun: 5, 7)/예시(Noun: 10, 2)"여기서 얻는 값은 단순히 “문자열을 잘랐다”가 아닙니다. 검색어 후보, 색인 단어, 추천 키워드처럼 다음 단계가 사용할 수 있는 단위입니다. 이 값을 테스트로 고정해 두면 사전 갱신이나 토크나이저 변경으로 달라진 결과를 바로 확인할 수 있습니다.
어간 복원도 같은 이유로 따로 봐야 합니다.
val stemmed = KoreanProcessor.stem(KoreanProcessor.tokenize("가느다란"))stemmed.first().stem// "갈다"검색 품질을 맞추다 보면 “사용자가 입력한 표면형”과 “검색에 사용할 원형”을 나눠야 할 때가 있습니다. 둘을 구분하지 않고 문자열만 저장하면 이후 순위 산정이나 강조 표시에서 문제가 생깁니다. 표면형은 화면 표시에 필요하고, 어간은 검색 확장이나 그룹화에 필요합니다.
한국어 사전과 금칙어: 런타임 확장은 편하지만 조심해야 한다
섹션 제목: “한국어 사전과 금칙어: 런타임 확장은 편하지만 조심해야 한다”KoreanProcessor는 런타임 명사 사전과 금칙어 사전을 확장합니다.
KoreanProcessor.addNounsToDictionary("블루테이프4K", "주말특가")
KoreanProcessor.addBlockwords( listOf("은꼴사", "물쑈", "혼숙"), severity = Severity.LOW,)
val response = KoreanProcessor.maskBlockwords( BlockwordRequest("홈쇼핑 미니미는 무슨 은꼴사야 어쩌라구? 물쑈야? 혼숙이야?"))
response.maskedText// "홈쇼핑 미니미는 무슨 ***야 어쩌라구? **야? **이야?"런타임 확장은 운영에서 유용합니다. 서비스 재시작 없이 새 단어를 넣을 수 있기 때문입니다. 다만 이건 “설정 조금 바꿨다”로 끝낼 일이 아닙니다. 사전이 바뀌면 토큰화와 마스킹 결과도 달라집니다. 금칙어 사전을 잘못 구성하면 정상 문구가 가려지고, 누락하면 금칙어가 필터를 통과합니다. 두 경우 모두 운영 장애로 이어질 수 있습니다.
따라서 사전 변경은 테스트와 함께 관리해야 합니다. 입력 문장, 기대 토큰, 기대 마스킹 결과를 작은 사례로라도 고정하면 다음 릴리스에서 같은 문제의 재발을 막을 수 있습니다. 사전 변경은 코드보다 눈에 덜 띌 뿐, 서비스 동작을 바꾸는 변경입니다.
일본어 입력: 명사 후보를 먼저 만든다
섹션 제목: “일본어 입력: 명사 후보를 먼저 만든다”일본어는 JapaneseProcessor가 Kuromoji IPAdic 기반 형태소 분석을 감쌉니다. 가장 작은 시작점은 표면형 목록 확인입니다.
val tokens = JapaneseProcessor.tokenize("お寿司が食べたい。")val surfaces = tokens.map { it.surface }// ["お", "寿司", "が", "食べ", "たい", "。"]검색어나 추천 후보를 만들 때는 모든 토큰보다 명사만 필요한 경우가 많습니다.
val nouns = JapaneseProcessor.filterNoun( JapaneseProcessor.tokenize("私は、日本語の勉強をしています。")).map { it.surface }
// ["私", "日本語", "勉強"]이렇게 명사 후보를 명시적으로 추출하면 후속 코드가 단순해집니다. 검색 색인에 넣을지, 추천 후보로 사용할지, 위험 키워드와 비교할지 결정하기 쉬워집니다. 반대로 컨트롤러에서 단순 문자열 포함 여부만 검사하면 조사, 활용형, 복합어 때문에 예외 처리가 계속 늘어납니다. 예외 규칙이 누적되면 사실상 별도의 처리 체계를 만드는 셈입니다.
일본어 금칙어도 같은 퍼사드에서 처리합니다.
val found = JapaneseProcessor.findBlockwords("ホモの男性を理解できない").map { it.surface }// ["ホモ"]
val result = JapaneseProcessor.maskBlockwords( blockwordRequestOf("ホモの男性を理解できない"))
result.maskedText// "**の男性を理解できない"일본어 쪽도 입력 길이 경계를 먼저 봅니다. JapaneseProcessor.tokenize, findBlockwords, maskBlockwords는 너무 긴 입력을
비용이 큰 형태소 분석 전에 거부합니다. 이 경계가 없으면 전송 또는 서비스 입력 단계에서 거부했어야 할 요청이
토크나이저의 성능 문제로 오해될 수 있습니다.
혼합 입력: 둘 다 살아남아야 한다
섹션 제목: “혼합 입력: 둘 다 살아남아야 한다”서비스 입력에는 한 언어만 들어오지 않습니다.
val ko = KoreanProcessor.tokensToStrings( KoreanProcessor.tokenize(KoreanProcessor.normalize("서울에서 東京까지 주말 특가 티켓")))// contains ["서울", "에서", "까지", "주말", "특가", "티켓"]
val ja = JapaneseProcessor.tokenize("서울から東京まで週末チケット").map { it.surface }// contains ["東京", "週末", "チケット"]이 테스트는 혼합 문장을 완벽하게 해석하는지를 검증하지 않습니다. 한국어 토크나이저가 한국어 표면형을 안정적으로 남기고, 일본어 토크나이저가 일본어 표면형을 보존하는지 확인합니다. 검색어 일부가 다른 언어여도 필요한 토큰은 유지되어야 합니다.
Lingua: 언어 감지기는 재사용한다
섹션 제목: “Lingua: 언어 감지기는 재사용한다”언어 감지는 lingua 모듈의 helper로 시작합니다.
val detector = allLanguageDetector { withMinimumRelativeDistance(0.0)}
detector.detectAllLanguagesOf("Hello 안녕 こんにちは")// setOf(Language.ENGLISH, Language.KOREAN, Language.JAPANESE)detectAllLanguagesOf는 공백 입력이면 빈 집합을 반환하고, emoji처럼 인식할 수 없는 입력도 빈 집합으로 처리합니다.
detector.detectAllLanguagesOf(" ")// emptySet()
detector.detectAllLanguagesOf("🔥🎉🧪")// emptySet()여기서 빈 집합은 명시적인 미확정 상태로 다루는 편이 안전합니다. 검색어라면 일반 정규화만 하고 토크나이저 경로는 건너뜁니다. 신고 문구라면 “언어 미확정” 상태를 남겨 후속 분석 대상으로 보냅니다. 감지 실패를 성공으로 간주하면 이후 처리의 신뢰성이 낮아집니다.
Lingua 언어 감지기는 생성 비용이 크므로 요청마다 만들지 않는 편이 좋습니다. 워크숍의 LanguageDetectionService도
감지기를 필드로 만들어 재사용합니다.
class LanguageDetectionService { private val detector: LanguageDetector = allLanguageDetector { withMinimumRelativeDistance(0.0) withLowAccuracyMode() }
fun detectLanguage(text: String): Language? { if (text.isBlank()) return null val detected = detector.detectLanguageOf(text) return if (detected == Language.UNKNOWN) null else detected }}신뢰도가 필요할 때는 점수 맵에서 최상위 결과와 점수를 함께 판단합니다.
val values = service.computeConfidenceValues( "This is a well-formed English sentence with enough words for detection.")
val top = values.keys.firstOrNull()// Language.ENGLISH운영 코드에서는 detectLanguage 하나로 모든 결정을 끝내기보다 신뢰도, 입력 길이, 서비스 정책을 함께 보는 편이
낫습니다. 짧은 단어 하나만 들어온 입력은 언어 감지 자체가 불안정할 수 있습니다. 이때는 토크나이저로 무리하게 보내지 말고
대체 경로를 명확히 두는 편이 안전합니다.
코루틴 환경에서 감지기를 재사용한다고 해서 동시 접근까지 자동으로 안전해지는 것은 아닙니다. 워크숍의
CoroutineLanguageDetectionService는 호출자가 제공한 디스패처에서 감지를 실행하고 Mutex로 공유 감지기 접근을
직렬화합니다. 생성 비용과 동시성 정책을 함께 설계해야 합니다.
애플리케이션 정책으로 경계를 조합한다
섹션 제목: “애플리케이션 정책으로 경계를 조합한다”아래 코드는 라이브러리 API가 아니라 애플리케이션이 소유하는 의사코드입니다. HTTP 본문 크기와 Content-Type은
전송 계층에서 먼저 검사해야 합니다. 본문을 제한된 크기로 읽어 String을 만든 뒤에야 공백 여부와
MAX_TOKENIZE_TEXT_LENGTH의 100,000자 제한을 검사할 수 있습니다. 라이브러리는 너무 긴 문자열에
IllegalArgumentException을 던지며, 이를 HTTP 413 응답으로 바꾸는 책임은 애플리케이션에 있습니다.
fun analyzeText(text: String): TextAnalysis { // 애플리케이션 정책: 전송 계층의 본문 제한은 String 생성 전에 수행한다. if (text.isBlank()) return TextAnalysis.rejected(400) if (text.length > MAX_TOKENIZE_TEXT_LENGTH) return TextAnalysis.rejected(413)
val languages = detector.detectAllLanguagesOf(text)
return when { Language.KOREAN in languages -> { val normalized = KoreanProcessor.normalize(text) val tokens = KoreanProcessor.tokensToStrings(KoreanProcessor.tokenize(normalized)) TextAnalysis.korean(tokens) }
Language.JAPANESE in languages -> { val nouns = JapaneseProcessor .filterNoun(JapaneseProcessor.tokenize(text)) .map { it.surface } TextAnalysis.japanese(nouns) }
else -> { TextAnalysis.generic(TextNormalizer.extractKeywords(text)) } }}이 의사코드는 한국어가 먼저 감지되면 한국어 경로를 선택합니다. 혼합 언어를 감지했다고 해서 라이브러리가 처리 정책까지 결정하는 것은 아닙니다. 신고·보안 필터라면 한국어와 일본어 경로를 모두 실행하는 편이 안전할 수 있고, 검색어 정규화라면 비용과 지연 시간을 고려해 주 언어 하나를 선택할 수 있습니다. 입력 경계, 언어 감지, 처리 경로 선택을 명시하면 정책을 개별적으로 검증할 수 있습니다.
어디까지 믿을까
섹션 제목: “어디까지 믿을까”이 글의 예제는 “모든 NLP 문제를 해결한다”는 이야기가 아닙니다. bluetape4k-text는 서비스 코드에서 반복되는 텍스트 처리
경계를 작게 재사용하게 해주는 라이브러리입니다.
| 상황 | 권장 시작점 | 주의할 점 |
|---|---|---|
| 한국어 검색어·상품명 처리 | KoreanProcessor.normalize + tokenize | 사전 변경 전후 기대 토큰을 테스트로 고정 |
| 한국어 구문 기반 검색 | extractPhrases | 구문 결과를 순위 산정·색인과 바로 연결하지 말고 정책을 분리 |
| 일본어 검색 후보 | JapaneseProcessor.tokenize + filterNoun | 조사/동사까지 필요한지 먼저 결정 |
| 일본어 금칙어 처리 | findBlockwords + maskBlockwords | 입력 길이와 원문 노출 방어를 먼저 확인 |
| 다국어 라우팅 | Lingua detectAllLanguagesOf | 감지기 재사용, 공백·미확정 대체 경로 명시 |
| 간단한 영어 검색어 | 워크숍 TextNormalizer | 토크나이저가 필요 없는 입력은 단순 경로 유지 |
한국어와 일본어 처리를 같은 문자열 함수로 버티는 건 처음에는 빠릅니다. 하지만 검색 품질, 금칙어 필터, 로그 분석, 고객 신고 처리로 이어지면 초기의 편의는 빠르게 기술 부채가 됩니다. 토크나이저와 감지기의 책임을 나누면 코드가 조금 늘어나지만, 각 단계의 결과를 테스트로 고정할 수 있습니다. 운영 환경에서는 이 구조가 문제의 원인과 영향 범위를 파악하기 쉽습니다.
다음 글에서는 Part 3으로 넘어가 text-search의 Aho-Corasick 검색과 워크숍의 금칙어 필터링 예제를 살펴봅니다.
키워드가 많아졌을 때 contains()를 반복하지 않고 입력을 한 번 훑는 구조를 다룹니다.
- bluetape4k-text README
- 한국어 README
- KoreanProcessor
- KoreanProcessorTest
- JapaneseProcessor
- JapaneseProcessorTest
- Lingua 확장 함수
- LanguageDetectorExtensionsTest
- kotlin/text-processing 워크숍
- LanguageDetectionService
- CoroutineLanguageDetectionService
- TextNormalizer
시리즈
섹션 제목: “시리즈”- Part 1: 텍스트 처리의 시작점과 품질 기준
- Part 2: 토크나이저와 언어 감지로 입력 라우팅하기
- Part 3: Aho-Corasick 검색과 워크숍 시나리오
- Part 4: 사전과 금칙어 변경을 테스트로 다루기
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.