Bluetape4k Text Part 4: 사전과 금칙어 변경을 테스트로 다루기

Part 3에서는 키워드 사전을 Aho-Corasick 자동 장치로 만들고 요청 입력을 한 번 훑는 구조를 살펴봤습니다. 그러나 자동 장치나 토크나이저의 구조가 안정적이어도 입력 사전이 달라지면 결과도 달라집니다.
사전 파일은 대체로 평범합니다. 한 줄에 단어 하나, 비슷한 형식의 파일 몇 개, 테스트 몇 개. 그래서 더 방심하기 쉽습니다. “단어 하나
추가했을 뿐인데요?”로 시작한 일이 금칙어 오탐, 마스킹 누락, 토크나이저 결과 변경으로 이어지면 원인 분석과 복구 비용이 증가합니다.
이번 글에서는 bluetape4k-text에서 사전과 금칙어 변경을 어떻게 다루면 좋은지 정리해 보겠습니다.
사전은 코드다
섹션 제목: “사전은 코드다”bluetape4k-text의 사전은 클래스패스 리소스로 들어갑니다. 라이브러리 사용자는 보통 이 파일을 직접 수정하지 않지만, 사전이
어떻게 읽히는지 알면 운영 코드에서 런타임 추가 API를 사용할 때도 실수를 줄일 수 있습니다.
| 모듈 | 리소스 기준 경로 | 로더 | 확인할 내용 |
|---|---|---|---|
tokenizer-core | 공통 클래스패스 리소스 | DictionaryProvider | 파일 적재, .gz 압축 사전, 누락 파일 처리 |
tokenizer-korean | koreantext | KoreanDictionaryProvider | 품사 사전, 고유명사, 금칙어 심각도 |
tokenizer-japanese | japanesetext | JapaneseDictionaryProvider | 일본어 금칙어와 분류 테스트 데이터 |
DictionaryProvider는 UTF-8 리소스를 읽고 각 줄을 trim()합니다. .gz 파일이면 GZIPInputStream으로 풀어서 읽습니다.
없는 리소스를 요청하면 IllegalStateException을 던집니다. 빈 사전으로 처리하고 넘어가지 않습니다. 사전 파일 경로가
잘못됐는데도 서비스가 정상 기동되면, 결과가 달라진 뒤에 원인을 찾기가 훨씬 어려워집니다.
여기까지만 보면 “사전은 결국 JAR 안의 리소스로만 넣어야 하나?”라는 생각이 들 수 있습니다. 그렇지는 않습니다. 현재
기본 사전은 리소스로 배포하지만, 애플리케이션에서는 DB나 파일에서 단어를 읽어 런타임 API로 넣을 수 있습니다. 즉,
bluetape4k-text가 사전 저장소를 하나로 고정하는 구조는 아닙니다.
변경 절차는 단순해야 한다
섹션 제목: “변경 절차는 단순해야 한다”사전 변경 절차는 복잡할 필요가 없습니다. 대신 매번 같은 순서로 처리해야 합니다.

기본 절차는 이렇습니다.
| 단계 | 하는 일 | 이유 |
|---|---|---|
| 1 | UTF-8 텍스트 파일로 준비 | 클래스패스 리소스에서 일관되게 읽기 위해 |
| 2 | 앞뒤 공백 제거, 빈 줄 제거 | 눈에 보이지 않는 차이로 픽스처가 달라지지 않게 |
| 3 | 의미 없는 중복 제거 | 같은 단어가 여러 번 들어가도 결과가 좋아지지 않음 |
| 4 | 순서가 의미 없을 때만 정렬 | 변경 차이를 줄이고 검토하기 쉽게 만들기 위해 |
| 5 | 바뀐 사전 영역의 테스트 실행 | 토크나이저와 금칙어 결과가 바뀌었는지 확인 |
| 6 | 릴리스 노트에 변경 영역과 검증 명령 기록 | 다음 사람이 같은 검증을 재현할 수 있게 |
코드로 줄이면 이런 모양입니다. 실제 프로젝트에서는 정렬이 의미를 바꾸지 않는 파일에만 적용해야 합니다.
val cleanedWords = lines.asSequence() .map(String::trim) .filter(String::isNotEmpty) .distinct() .sorted() .toList()여기서 .sorted()를 습관처럼 넣으면 안 됩니다. 어떤 사전은 순서가 동작에 영향을 줄 수 있습니다. 그런 파일은 중복과 공백만
정리하고, 정렬은 하지 않는 편이 맞습니다. 자동화를 적용하기 전에는 파일 순서가 동작에 영향을 주는지 확인해야 합니다.
런타임 추가 API는 사전 주입 경계다
섹션 제목: “런타임 추가 API는 사전 주입 경계다”한국어 토크나이저는 런타임에 단어를 추가할 수 있습니다.
KoreanProcessor.addNounsToDictionary("블루테이프4K", "주말특가")금칙어도 런타임에 추가할 수 있습니다.
KoreanProcessor.addBlockwords( words = listOf("걸레"), severity = Severity.LOW,)
val response = KoreanProcessor.maskBlockwords( BlockwordRequest("홈쇼핑 미니미는 무슨 걸.레 어쩌라구?"))
response.maskedText// "홈쇼핑 미니미는 무슨 ** 어쩌라구?"
response.blockWords// ["걸레"]이 API는 운영 중 임시 대응에도 유용하고, 서비스가 관리하는 사전 저장소의 내용을 주입하는 경계로도 사용할 수 있습니다. 예를 들어 관리자 화면에서 사전을 DB에 저장하고, 배포나 재시작 시점에 읽어서 토크나이저에 주입할 수 있습니다.
interface DictionaryWordSource { suspend fun loadNouns(): List<String> suspend fun loadBlockwords(): List<String>}
suspend fun importDictionary(source: DictionaryWordSource) { KoreanProcessor.addNounsToDictionary(source.loadNouns()) KoreanProcessor.addBlockwords( words = source.loadBlockwords(), severity = Severity.MIDDLE, )}파일을 쓰는 서비스라면 같은 구조로 바꿀 수 있습니다.
val words = Path("config/blockwords.txt") .readLines() .map(String::trim) .filter(String::isNotEmpty)
KoreanProcessor.addBlockwords(words, Severity.HIGH)다만 런타임 추가와 클래스패스 사전 배포는 같은 일이 아닙니다. 런타임에 추가한 단어는 해당 프로세스의 메모리 상태입니다. DB나
파일에서 읽어 넣을 수는 있지만, 여러 인스턴스에 같은 변경을 언제 반영할지, 재시작 뒤에도 유지할지, 언제 제거할지는 서비스가
정해야 합니다.
bluetape4k-text의 버전 기반 사전 재적재는 아직 별도 작업입니다. 여기서는 현재 사용할 수 있는 런타임 추가 API와
클래스패스 사전 배포만 다룹니다.
금칙어는 명사 사전도 같이 본다
섹션 제목: “금칙어는 명사 사전도 같이 본다”KoreanProcessor.addBlockwords는 단어를 금칙어 사전에만 넣지 않습니다. 내부적으로 명사 사전과 고유명사 집합에도 함께
추가합니다. 처음 보면 “왜 금칙어를 명사 사전에 넣지?”라는 생각이 들 수 있습니다.
이유는 토크나이저 때문입니다. 금칙어가 복합 명사나 변형된 입력 안에 들어오면 토크나이저가 그 단어를 하나의 후보로 잡아야 마스킹 단계까지 연결됩니다. 금칙어 사전에만 단어를 넣고 토크나이저 사전이 모르면, “사전에는 있지만 결과에서는 찾지 못하는” 상황이 생길 수 있습니다.
테스트에는 이런 종류의 사례가 들어 있습니다.
KoreanProcessor.addBlockwords( words = listOf("분수쑈"), severity = Severity.LOW,)
KoreanProcessor.maskBlockwords( BlockwordRequest("분수쑈를 진행합니다")).maskedText// "***를 진행합니다"실제 금칙어 처리는 예쁜 단어만으로 테스트할 수 없습니다. 운영에서 문제가 되는 입력은 대체로 예쁘지 않습니다. 그래도 테스트가 불편한 단어를 다루는 이유는 명확합니다. 필터가 정제된 예제에서만 통과하면 실제 입력에서 누락이 다시 발생합니다.
심각도는 정책이다
섹션 제목: “심각도는 정책이다”한국어 금칙어는 LOW, MIDDLE, HIGH 심각도를 가집니다. 요청 심각도를 어떻게 보느냐에 따라 검사 범위가 달라집니다.
| 요청 심각도 | 검사 대상 |
|---|---|
LOW | LOW, MIDDLE, HIGH |
MIDDLE | MIDDLE, HIGH |
HIGH | HIGH |
이 구조는 “낮은 기준으로 보면 더 많이 잡고, 높은 기준으로 보면 가장 위험한 것만 잡는다”는 정책입니다. 그래서 테스트도 심각도별 기대 결과를 분리해서 남겨야 합니다. 단순히 “금칙어가 하나 잡혔다”로는 부족합니다. 어느 심각도에서 잡혀야 하는지까지 남겨야 나중에 정책을 바꿨을 때 변경 차이를 설명할 수 있습니다.
마스킹 문자열의 길이도 테스트해야 합니다.
val response = KoreanProcessor.maskBlockwords( BlockwordRequest( text = "걸.레 그리고 걸.레", mask = "##", ))
response.maskedText// "#### 그리고 ####"
response.blockWords// ["걸레", "걸레"]여기서 결과가 "## 그리고 ##"가 되면 구간 처리 계약이 바뀐 것입니다. 마스킹은 단순 치환으로 끝나지 않습니다. 사용자에게 보여줄
텍스트의 오프셋과 길이도 다룹니다. 이 경계를 검증하지 않으면 프런트엔드 강조 표시, 콘텐츠 검토 화면, 감사 로그가 서로 다른
결과를 나타낼 수 있습니다.
입력 경계는 처리기 호출 전에 검증한다
섹션 제목: “입력 경계는 처리기 호출 전에 검증한다”사전과 금칙어가 잘 준비되어도, 입력 경계가 약하면 서비스 코드에서 다시 문제가 생깁니다. BlockwordRequest는 빈 문자열과 너무
긴 입력을 거부합니다. 너무 긴 입력의 제한은 100_000자입니다.
예제 모듈의 TokenizerSafetyHandler는 처리기를 호출하기 전에 먼저 입력을 확인합니다.
fun handleBlockword(text: String): SafetyResponse { if (text.isBlank()) { return SafetyResponse(400, "text is blank") } if (text.length > maxLength) { return SafetyResponse( status = 413, body = "text too long: ${text.length} chars (max $maxLength)", ) }
return processor.mask(text)}테스트는 여기서 세 가지를 봅니다.
| 상황 | 기대 결과 |
|---|---|
| 입력이 너무 길다 | 413, 길이와 최대값은 알려주지만 원문은 오류 메시지에 넣지 않음 |
| 입력이 비어 있다 | 400, 처리기를 호출하지 않음 |
| 처리기 내부 오류 | 500, 외부에는 정제된 메시지만 반환 |
이런 테스트는 기능의 정상 동작보다 눈에 띄지 않습니다. 그러나 운영에서는 이러한 경계가 더 중요할 수 있습니다. 금칙어 필터가 실패했는데 오류 메시지에 사용자가 보낸 원문을 그대로 넣으면, 그 순간부터 필터 문제가 아니라 개인정보와 로그 정책 문제가 됩니다.
검증 명령은 사전 종류별로 남긴다
섹션 제목: “검증 명령은 사전 종류별로 남긴다”사전 변경을 검증할 때는 전체 빌드만 돌리고 끝내지 말고, 바뀐 영역을 직접 확인하는 테스트를 같이 남기는 편이 좋습니다.
| 변경 영역 | 검증 명령 |
|---|---|
| 공통 사전 로더 | ./gradlew :tokenizer-core:test --tests "io.bluetape4k.tokenizer.utils.DictionaryProviderTest" |
| 한국어 명사·품사 사전 | ./gradlew :tokenizer-korean:test |
| 한국어 금칙어 사전 | ./gradlew :tokenizer-korean:test --tests "io.bluetape4k.tokenizer.korean.block.KoreanBlockwordProcessorTest" |
| 일본어 금칙어 사전 | ./gradlew :tokenizer-japanese:test --tests "io.bluetape4k.tokenizer.japanese.block.JapaneseBlockwordProcessorTest" |
| 입력 안전 예제 | ./gradlew :examples:tokenizer-safety-examples:test |
이 테스트들은 “한국어/일본어 NLP 정확도 벤치마크”가 아닙니다. 목표는 더 좁습니다. 저장소의 사전, 픽스처, 입력 경계가 기대한 대로 동작하는지 반복해서 확인하는 품질 게이트입니다.
통계적 정확도를 주장하려면 별도 데이터 집합, 평가 기준, 통계가 필요합니다. 이 글의 범위는 “이번 사전 변경으로 이미 보장하던 동작이 깨지지 않았는가?”를 확인하는 데 있습니다.
운영 코드에서 가져갈 기준
섹션 제목: “운영 코드에서 가져갈 기준”서비스에서 bluetape4k-text를 쓸 때는 아래 기준을 가져가면 됩니다.
| 기준 | 적용 방법 |
|---|---|
| 사전 변경은 동작 변경으로 본다 | 릴리스 노트와 테스트 명령을 함께 남긴다 |
| 런타임 추가 API는 주입 경계로 쓴다 | DB나 파일에서 읽은 단어를 주입하되, 인스턴스 반영과 제거 정책은 따로 정한다 |
| 금칙어는 토크나이저 결과와 같이 본다 | 마스킹 결과뿐 아니라 감지 단어와 구간도 테스트한다 |
| 심각도는 정책이다 | 심각도별 예상 결과를 픽스처로 남긴다 |
| 입력 경계는 먼저 검증한다 | 처리기 호출 전에 길이와 공백 입력을 확인하고 원문을 오류에 남기지 않는다 |
사전 변경은 대개 작게 느껴집니다. 하지만 토크나이저와 필터에서는 작은 한 줄이 “잡아야 할 것을 못 잡는 문제”나 “정상 문장을 막는 문제”가 될 수 있습니다. 따라서 사전 작업은 다음 배포에서 같은 결함을 반복하지 않도록 하는 품질 관리에 가깝습니다.
사전을 바꿀 때는 파일을 정규화하고, 변경 영역을 구체적인 테스트로 검증하며, 릴리스 노트에 변경 내용과 검증 명령을 남겨야 합니다. 이 기록이 있어야 장애 분석에서 사전 한 줄의 변경까지 빠르게 추적할 수 있습니다.
- DictionaryProvider
- DictionaryProviderTest
- KoreanDictionaryProvider
- KoreanProcessor
- KoreanBlockwordProcessorTest
- JapaneseBlockwordProcessorTest
- BlockwordRequest
- TokenizerSafetyExamples
- TokenizerSafetyExamplesTest
시리즈
섹션 제목: “시리즈”- Part 1: 텍스트 처리의 시작점과 품질 기준
- Part 2: 토크나이저와 언어 감지로 입력을 라우팅하기
- Part 3: Aho-Corasick 검색과 워크숍 시나리오
- Part 4: 사전과 금칙어 변경을 테스트로 다루기
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.