From 10abba1518e5a5651ea13a18e5e29eabd55fdaf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 04:25:26 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B9=84=EC=86=8D=EC=96=B4=20=EA=B2=80?= =?UTF-8?q?=EC=B6=9C=20=EC=9C=84=EC=B9=98=EB=A5=BC=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원문에서 키워드 글자를 indexOf로 다시 찾던 방식을 정리 시점에 기록한 원문 위치로 바꾼다. - 앞에 같은 첫 글자가 있으면 그 위치부터 잘라 정상 단어까지 마스킹하던 문제("시간 시발" -> "*****")를 고친다. - Trie는 대소문자를 무시하는데 indexOf는 구분해서, 대문자 영문이 NORMAL/FILTER에서 누락되고 QUICK에서 StringIndexOutOfBoundsException이 나던 문제를 고친다. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJgrQc8EvNg9yM7peD7cCF --- .../filter/NormalProfanityFilter.java | 48 +++++-------- .../app/application/filter/ProfanityText.java | 42 +++++++++++ .../filter/AllowedWordsFilterTest.java | 17 +++++ .../filter/DefaultProfanityHandlerTest.java | 13 ++++ .../filter/NormalProfanityFilterTest.java | 70 +++++++++++++++++++ .../application/filter/ProfanityTextTest.java | 57 +++++++++++++++ 6 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 profanity-domain/src/test/java/app/application/filter/ProfanityTextTest.java diff --git a/profanity-domain/src/main/java/app/application/filter/NormalProfanityFilter.java b/profanity-domain/src/main/java/app/application/filter/NormalProfanityFilter.java index 796d840..ec7f7d3 100644 --- a/profanity-domain/src/main/java/app/application/filter/NormalProfanityFilter.java +++ b/profanity-domain/src/main/java/app/application/filter/NormalProfanityFilter.java @@ -81,21 +81,12 @@ public FilterResponse allMatched(String text, Set allowedWords) { ElapsedStartAt start = ElapsedStartAt.now(); - String cleanedText = ProfanityText.clean(text); - int currentPos = 0; + ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text); Set filterWords = new HashSet<>(); - for (Emit emit : trie.parseText(cleanedText)) { - int startPos = text.indexOf(emit.getKeyword().charAt(0), currentPos); - if (startPos == -1) continue; - int endPos = startPos; - for (char c : emit.getKeyword().toCharArray()) { - endPos = text.indexOf(c, endPos) + 1; - } - // 허용 단어도 원문 위치는 그대로 지나가야 뒤따르는 검출의 위치가 어긋나지 않는다. - currentPos = endPos; + for (Emit emit : trie.parseText(cleaned.text())) { if (isAllowed(emit, allowedWords)) continue; - filterWords.add(FilterWord.create(text.substring(startPos, endPos), startPos, endPos)); + filterWords.add(toFilterWord(text, cleaned, emit)); } Elapsed elapsed = Elapsed.end(start); log.debug("[NormalProfanityFilter] 전체 비속어 필터링 완료 (지연 시간 : {})", elapsed); @@ -111,22 +102,16 @@ public FilterWord firstMatched(String text) { ElapsedStartAt start = ElapsedStartAt.now(); - String cleanedText = ProfanityText.clean(text); - Emit emit = trie.firstMatch(cleanedText); + ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text); + Emit emit = trie.firstMatch(cleaned.text()); if (emit == null) { return FilterWord.empty(); } - int startPos = text.indexOf(emit.getKeyword().charAt(0)); - int endPos = startPos; - for (char c : emit.getKeyword().toCharArray()) { - endPos = text.indexOf(c, endPos) + 1; - } - Elapsed elapsed = Elapsed.end(start); log.debug("[NormalProfanityFilter] 단일 비속어 필터링 완료 (지연 시간 : {})", elapsed); - return FilterWord.create(text.substring(startPos, endPos), startPos, endPos); + return toFilterWord(text, cleaned, emit); } /** 첫 검출이 허용 단어이면 그 뒤의 검출을 찾아야 하므로 전체를 훑습니다. 결과를 뒤에서 걸러 내면 허용 단어 뒤에 있는 실제 비속어를 놓칩니다. */ @@ -139,22 +124,21 @@ public FilterWord firstMatched(String text, Set allowedWords) { return FilterWord.empty(); } - String cleanedText = ProfanityText.clean(text); - int currentPos = 0; - for (Emit emit : trie.parseText(cleanedText)) { - int startPos = text.indexOf(emit.getKeyword().charAt(0), currentPos); - if (startPos == -1) continue; - int endPos = startPos; - for (char c : emit.getKeyword().toCharArray()) { - endPos = text.indexOf(c, endPos) + 1; - } - currentPos = endPos; + ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text); + for (Emit emit : trie.parseText(cleaned.text())) { if (isAllowed(emit, allowedWords)) continue; - return FilterWord.create(text.substring(startPos, endPos), startPos, endPos); + return toFilterWord(text, cleaned, emit); } return FilterWord.empty(); } + /** 검출 구간을 원문 위치로 옮깁니다. 사이에 끼운 기호(`바-보`)는 원문 조각에 그대로 포함됩니다. */ + private static FilterWord toFilterWord(String text, ProfanityText.Cleaned cleaned, Emit emit) { + int startPos = cleaned.originalStart(emit.getStart()); + int endPos = cleaned.originalEnd(emit.getEnd()); + return FilterWord.create(text.substring(startPos, endPos), startPos, endPos); + } + private static boolean isAllowed(Emit emit, Set allowedWords) { return allowedWords != null && !allowedWords.isEmpty() diff --git a/profanity-domain/src/main/java/app/application/filter/ProfanityText.java b/profanity-domain/src/main/java/app/application/filter/ProfanityText.java index 543b31c..ab67a21 100644 --- a/profanity-domain/src/main/java/app/application/filter/ProfanityText.java +++ b/profanity-domain/src/main/java/app/application/filter/ProfanityText.java @@ -1,6 +1,8 @@ package app.application.filter; +import java.util.Arrays; import java.util.Locale; +import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -20,6 +22,46 @@ public static String clean(String text) { return NOT_MATCHABLE.matcher(text).replaceAll(""); } + /** + * {@link #clean}과 같은 규칙으로 정리하면서 남은 글자마다 원문 위치를 함께 기록합니다. + * + *

매칭은 정리된 문자열에서 일어나지만 응답과 마스킹은 원문 위치를 써야 합니다. 원문에서 글자를 다시 찾으면 앞에 있는 같은 글자를 잡거나 대소문자가 달라 놓치므로, + * 정리하는 순간의 위치를 그대로 씁니다. + */ + static Cleaned cleanWithOffsets(String text) { + StringBuilder cleaned = new StringBuilder(text.length()); + int[] offsets = new int[text.length()]; + int size = 0; + int from = 0; + Matcher removed = NOT_MATCHABLE.matcher(text); + while (removed.find()) { + for (int i = from; i < removed.start(); i++) { + cleaned.append(text.charAt(i)); + offsets[size++] = i; + } + from = removed.end(); + } + for (int i = from; i < text.length(); i++) { + cleaned.append(text.charAt(i)); + offsets[size++] = i; + } + return new Cleaned(cleaned.toString(), Arrays.copyOf(offsets, size)); + } + + /** 정리된 문자열과, 그 문자열의 각 글자가 원문에서 차지하던 위치입니다. */ + record Cleaned(String text, int[] offsets) { + + /** 정리된 문자열의 시작 위치를 원문 위치로 바꿉니다. */ + int originalStart(int start) { + return offsets[start]; + } + + /** 정리된 문자열의 마지막 글자 위치(포함)를 원문의 끝 위치(배타)로 바꿉니다. Aho-Corasick의 Emit 끝 위치가 포함 구간입니다. */ + int originalEnd(int endInclusive) { + return offsets[endInclusive] + 1; + } + } + /** * 허용 단어와 사전 단어를 비교하는 키입니다. 필터가 대소문자를 구분하지 않으므로 소문자로 맞춥니다. * diff --git a/profanity-domain/src/test/java/app/application/filter/AllowedWordsFilterTest.java b/profanity-domain/src/test/java/app/application/filter/AllowedWordsFilterTest.java index fcb0768..771020a 100644 --- a/profanity-domain/src/test/java/app/application/filter/AllowedWordsFilterTest.java +++ b/profanity-domain/src/test/java/app/application/filter/AllowedWordsFilterTest.java @@ -90,6 +90,15 @@ void allowsEnglishCaseInsensitively() { assertThat(result.filterWords()).isEmpty(); } + @Test + @DisplayName("허용하지 않으면 대소문자가 달라도 원문 그대로 검출한다") + void detectsEnglishCaseInsensitivelyWhenNotAllowed() { + // 위 허용 테스트가 검출 실패 때문에 통과하지 않도록, 허용하지 않은 경우의 검출을 함께 확인한다. + FilterResponse result = filter.allMatched("you FOOL", Set.of()); + + assertThat(result.filterWords()).containsExactly(FilterWord.create("FOOL", 4, 8)); + } + @Test @DisplayName("사전에 없는 단어를 허용해도 결과가 바뀌지 않는다") void ignoresAllowedWordsNotInDictionary() { @@ -129,5 +138,13 @@ void returnsEmptyWhenEverythingIsAllowed() { assertThat(result).isEqualTo(FilterWord.empty()); } + + @Test + @DisplayName("허용 단어를 건너뛴 뒤의 영문 검출도 원문 대소문자와 위치를 지킨다") + void keepsCaseAndPositionAfterSkippingAllowedWord() { + FilterWord result = filter.firstMatched("죽여 you FOOL", allowed("죽여")); + + assertThat(result).isEqualTo(FilterWord.create("FOOL", 7, 11)); + } } } diff --git a/profanity-domain/src/test/java/app/application/filter/DefaultProfanityHandlerTest.java b/profanity-domain/src/test/java/app/application/filter/DefaultProfanityHandlerTest.java index 7c1eb73..405dfc4 100644 --- a/profanity-domain/src/test/java/app/application/filter/DefaultProfanityHandlerTest.java +++ b/profanity-domain/src/test/java/app/application/filter/DefaultProfanityHandlerTest.java @@ -83,4 +83,17 @@ void test_3() { assertTrue(detected.filteredWord().equals("욕설") && detected.length() == 2); assertEquals("**이 들어간 문장입니다.", filtered); } + + @Test + @DisplayName("filter 타입은 앞에 같은 글자로 시작하는 단어까지 마스킹하지 않는다") + void sanitizeProfanity_sameLeadingCharBefore_masksOnlyMatchedRange() { + // given + String word = "욕심 많은 욕설"; + + // when + FilterApiResponse response = profanityHandler.sanitizeProfanity(word, UUID.randomUUID()); + + // then + assertEquals("욕심 많은 **", response.filtered()); + } } diff --git a/profanity-domain/src/test/java/app/application/filter/NormalProfanityFilterTest.java b/profanity-domain/src/test/java/app/application/filter/NormalProfanityFilterTest.java index 35d571a..11888de 100644 --- a/profanity-domain/src/test/java/app/application/filter/NormalProfanityFilterTest.java +++ b/profanity-domain/src/test/java/app/application/filter/NormalProfanityFilterTest.java @@ -13,10 +13,12 @@ import app.dto.response.FilterWord; import java.time.Instant; import java.util.List; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -176,4 +178,72 @@ void handleSpecialCharacters(String text) { assertFalse(response.filterWords().isEmpty()); log.info("Found words: {}", response.filterWords()); } + + @Nested + @DisplayName("검출 위치는 원문 기준으로 계산한다") + class OriginalOffsets { + + @Test + @DisplayName("앞에 같은 글자로 시작하는 단어가 있어도 검출된 구간만 돌려준다") + void allMatched_sameLeadingCharBefore_returnsOnlyMatchedRange() { + // given + String text = "욕심 많은 욕설"; + + // when + FilterResponse result = normalProfanityFilter.allMatched(text); + + // then + assertEquals(Set.of(FilterWord.create("욕설", 6, 8)), result.filterWords()); + } + + @Test + @DisplayName("첫 검출도 앞에 같은 글자로 시작하는 단어를 끌어오지 않는다") + void firstMatched_sameLeadingCharBefore_returnsOnlyMatchedRange() { + // when + FilterWord result = normalProfanityFilter.firstMatched("욕심 많은 욕설"); + + // then + assertEquals(FilterWord.create("욕설", 6, 8), result); + } + + @Test + @DisplayName("사이에 끼운 기호는 원문 조각에 그대로 포함한다") + void allMatched_symbolsInside_keepsOriginalFragment() { + // when + FilterResponse result = normalProfanityFilter.allMatched("아 진짜 욕!@#설"); + + // then + assertEquals(Set.of(FilterWord.create("욕!@#설", 5, 10)), result.filterWords()); + } + + @Test + @DisplayName("같은 단어가 반복되면 각 위치를 따로 돌려준다") + void allMatched_repeatedWord_returnsEachRange() { + // when + FilterResponse result = normalProfanityFilter.allMatched("욕설 욕설"); + + // then + assertEquals( + Set.of(FilterWord.create("욕설", 0, 2), FilterWord.create("욕설", 3, 5)), + result.filterWords()); + } + + @ParameterizedTest + @DisplayName("영문은 대소문자가 달라도 원문 그대로 검출한다") + @ValueSource(strings = {"fool", "FOOL", "Fool", "fOoL"}) + void matched_englishInAnyCase_returnsOriginalFragment(String word) { + // given + repository.save(ProfanityWord.create("fool")); + normalProfanityFilter.synchronizeProfanityTrie(); + String text = "you " + word; + + // when + FilterResponse all = normalProfanityFilter.allMatched(text); + FilterWord first = normalProfanityFilter.firstMatched(text); + + // then + assertEquals(Set.of(FilterWord.create(word, 4, 8)), all.filterWords()); + assertEquals(FilterWord.create(word, 4, 8), first); + } + } } diff --git a/profanity-domain/src/test/java/app/application/filter/ProfanityTextTest.java b/profanity-domain/src/test/java/app/application/filter/ProfanityTextTest.java new file mode 100644 index 0000000..ed022e1 --- /dev/null +++ b/profanity-domain/src/test/java/app/application/filter/ProfanityTextTest.java @@ -0,0 +1,57 @@ +package app.application.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ProfanityTextTest { + + @Nested + @DisplayName("위치를 기록하는 정리는") + class CleanWithOffsets { + + @ParameterizedTest + @DisplayName("clean과 같은 규칙으로 정리한다") + @ValueSource(strings = {"", " ", "바-보", "욕12#설", "😀욕설😀", "Fo!oL", "ㅅ ㅂ", "탭\t줄\n바꿈"}) + void cleanWithOffsets_anyText_matchesClean(String text) { + // when + ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text); + + // then + assertEquals(ProfanityText.clean(text), cleaned.text()); + } + + @ParameterizedTest + @DisplayName("정리된 글자마다 원문의 같은 글자 위치를 가리킨다") + @ValueSource(strings = {"바-보", "욕12#설", "😀욕설😀", "Fo!oL"}) + void cleanWithOffsets_anyText_offsetsPointToSameChar(String text) { + // when + ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text); + + // then + for (int i = 0; i < cleaned.text().length(); i++) { + assertEquals(cleaned.text().charAt(i), text.charAt(cleaned.offsets()[i])); + } + } + + @Test + @DisplayName("정리된 구간을 사이에 끼운 기호까지 포함한 원문 구간으로 바꾼다") + void originalRange_symbolsInside_coversOriginalFragment() { + // given + String text = "이런 바-보야"; + ProfanityText.Cleaned cleaned = ProfanityText.cleanWithOffsets(text); + int start = cleaned.text().indexOf("바보"); + + // when + int originalStart = cleaned.originalStart(start); + int originalEnd = cleaned.originalEnd(start + 1); + + // then + assertEquals("바-보", text.substring(originalStart, originalEnd)); + } + } +}