[성능 개선] [Python] 효율적인 텍스트 필터링 구현 원리와 최적화 방법

안녕하세요, 개발자 여러분! 사용자 입력이나 외부 데이터를 처리할 때 특정 단어를 걸러내야 하는 상황은 흔하게 발생합니다. 오늘 우리는 스택오버플로우의 한 질문, “텍스트를 필터링하는 더 나은 방법이 있을까요?”에서 시작하여 파이썬(Python)으로 텍스트를 효율적이고 견고하게 필터링하는 다양한 방법을 모색해보고자 합니다.

기존 코드의 한계를 분석하고, 정규표현식(Regex)을 활용한 정교한 필터링부터 대규모 금지어 목록에 최적화된 방법까지 깊이 있게 다뤄보겠습니다. 이 문서를 통해 여러분의 텍스트 처리 로직을 한 단계 업그레이드할 수 있기를 바랍니다.

1. 에러 발생 상황 (혹은 개선 필요 상황)

특정 단어(예: 비속어)를 사용자 입력에서 걸러내고자 할 때, 개발 초기 단계에서는 직관적인 방법으로 접근할 수 있습니다. 아래는 스택오버플로우에서 제시된 초기 코드의 예시입니다. 이 코드는 주어진 금지어 목록에 특정 단어가 포함되어 있는지 확인하는 기본적인 기능을 수행합니다.

# 금지어 목록 정의
forbidden_words = ("shit", "fuck", "ass", "bitch")

# 사용자 입력 받기
query = input("문자열을 입력하세요: ")

# 소문자로 변환하여 대소문자 구분 없이 처리
query = query.lower()

# 분석: 금지어가 입력 문자열에 포함되어 있는지 확인
if any(word in query for word in forbidden_words):
    print(query, "은(는) 허용되지 않습니다!")
else:
    print(query, "은(는) 허용됩니다")

이 코드는 간단한 상황에서는 잘 작동하지만, 몇 가지 잠재적인 문제점과 개선의 여지를 가지고 있습니다. 예를 들어, “assumption”과 같은 단어에 “ass”가 포함되어 있다고 해서 금지어로 판단하는 오작동의 가능성이 있으며, 금지어 목록이 매우 많아지거나 필터링해야 할 텍스트가 길어질 경우 성능 저하가 발생할 수 있습니다.

2. 명확한 발생 원인 (혹은 개선점 분석)

위 코드의 개선 필요성은 다음 두 가지 주요 원인에서 비롯됩니다.

2.1. 단순 부분 문자열 매칭의 한계

any(word in query for word in forbidden_words) 구문은 wordquery의 부분 문자열인지(substring) 여부만 확인합니다. 이는 다음과 같은 문제를 야기합니다.

  • 오탐(False Positive) 가능성: ‘ass’가 금지어인데 ‘assumption’이라는 단어가 포함된 문장도 필터링될 수 있습니다. (예: “My assumption is that…”)
  • 불완전한 필터링: ‘fuck!’처럼 특수문자가 붙거나 ‘f u c k’처럼 공백이 섞인 변형된 형태의 단어는 걸러내지 못할 수 있습니다.

2.2. 성능 및 확장성 문제

금지어 목록(forbidden_words)의 크기가 커지거나, 필터링해야 할 텍스트(query)의 길이가 매우 길어질 경우 성능 문제가 발생할 수 있습니다.

  • 선형 탐색: any() 함수는 기본적으로 forbidden_words의 모든 단어에 대해 query 문자열 내에서 해당 단어를 찾으려는 시도를 합니다. 이는 금지어 목록의 길이에 비례하는 연산이 필요하며, 효율적이지 않을 수 있습니다.
  • 재사용성 부족: 여러 번 필터링 작업을 수행해야 할 경우, 매번 같은 로직을 반복하게 되어 비효율적입니다.

3. 해결 방법 및 코드 예시

위에서 언급된 문제점들을 해결하고, 더 효율적이며 견고한 텍스트 필터링 로직을 구축하는 방법을 제시합니다.

3.1. 해결 방법 1: 정규표현식(Regex)을 이용한 정확한 단어 경계 처리

부분 문자열 매칭의 문제를 해결하기 위해 정규표현식을 활용하여 단어 경계(word boundary)를 명시적으로 지정할 수 있습니다. 파이썬의 re 모듈을 사용합니다.

import re

forbidden_words = ("shit", "fuck", "ass", "bitch")
query = input("문자열을 입력하세요: ").lower()

is_forbidden = False
for word in forbidden_words:
    # 단어 경계(\b)를 사용하여 정확한 단어 매칭
    pattern = r"\b" + re.escape(word) + r"\b"
    if re.search(pattern, query):
        is_forbidden = True
        break

if is_forbidden:
    print(query, "은(는) 허용되지 않습니다! (정규표현식 - 단어 경계)")
else:
    print(query, "은(는) 허용됩니다 (정규표현식 - 단어 경계)")

\b는 단어 경계를 의미하므로, ‘ass’는 ‘assumption’에서는 매칭되지 않고 ‘ass’ 자체로만 매칭됩니다. re.escape()는 단어 내에 정규표현식 특수문자가 있을 경우를 대비하여 해당 문자를 이스케이프 처리해주는 역할을 합니다.

3.2. 해결 방법 2: 여러 금지어를 효율적으로 처리 (단일 정규표현식 컴파일)

금지어 목록의 모든 단어에 대해 개별적으로 정규표현식을 검색하는 것은 여전히 비효율적일 수 있습니다. 모든 금지어를 하나의 정규표현식 패턴으로 결합하고 이를 미리 컴파일해두면 검색 성능을 크게 향상시킬 수 있습니다.

import re

forbidden_words = ("shit", "fuck", "ass", "bitch")

# 모든 금지어를 '단어1|단어2|단어3' 형태의 단일 정규표현식으로 결합
# ?: 는 비캡처링 그룹을 의미하여 성능 향상에 도움을 줍니다.
# re.IGNORECASE 플래그로 대소문자 무시. query.lower()를 제거할 수 있습니다.
# re.escape()로 각 단어 내 특수문자 처리.
pattern = re.compile(r"\b(?:" + "|".join(re.escape(word) for word in forbidden_words) + r")\b", re.IGNORECASE)

query = input("문자열을 입력하세요: ")

# 컴파일된 패턴으로 검색
if pattern.search(query):
    print(query, "은(는) 허용되지 않습니다! (정규표현식 - 단일 패턴)")
else:
    print(query, "은(는) 허용됩니다 (정규표현식 - 단일 패턴)")

re.compile()은 정규표현식을 미리 파싱하여 객체로 만들어두므로, 동일한 패턴으로 여러 번 검색할 때 효율적입니다. 또한, re.IGNORECASE 플래그를 사용하면 query.lower() 호출 없이도 대소문자를 구분하지 않고 검색할 수 있습니다.

3.3. 해결 방법 3: 토큰화(Tokenization) 후 빠른 금지어 목록 검사 (Set 활용)

만약 입력 문자열이 여러 단어로 구성된 문장이고, 그 문장 내의 ‘각 단어’가 금지어 목록에 포함되는지를 빠르게 확인하고 싶다면, 입력 문자열을 개별 단어로 토큰화한 후 set 자료구조를 활용할 수 있습니다. set은 원소의 존재 여부를 평균적으로 O(1) 시간에 확인할 수 있어, 대규모 금지어 목록에 매우 효율적입니다.

import re

# 금지어 목록을 set으로 변환하여 검색 속도 향상
forbidden_words_set = {"shit", "fuck", "ass", "bitch"}

query = input("문자열을 입력하세요: ").lower()

# 텍스트를 단어로 토큰화 (알파벳 단어만 추출)
# re.findall(r'\b\w+\b', query)는 공백, 특수문자를 기준으로 단어를 나눕니다.
words_in_query = re.findall(r'\b\w+\b', query)

is_forbidden = False
for word in words_in_query:
    if word in forbidden_words_set: # Set은 O(1) 평균 시간 복잡도로 검색 가능
        is_forbidden = True
        break

if is_forbidden:
    print(query, "은(는) 허용되지 않습니다! (Set + 토큰화)")
else:
    print(query, "은(는) 허용됩니다 (Set + 토큰화)")

이 방법은 정확한 단어 매칭에 최적화되어 있으며, forbidden_words_set이 수만 개에 달하는 경우에도 빠른 검색 성능을 제공합니다.

4. 향후 예방을 위한 팁

텍스트 필터링 로직을 개발하거나 개선할 때 다음 사항들을 고려하면, 더욱 견고하고 유지보수하기 쉬운 시스템을 구축할 수 있습니다.

  • 정규표현식 숙달: 복잡한 패턴 매칭, 단어 경계, 대소문자 무시 등 다양한 시나리오에 대응하기 위해 정규표현식의 기본적인 사용법과 고급 기능을 익혀두는 것이 중요합니다.
  • 적절한 자료구조 선택: 금지어 목록의 크기와 검색 빈도에 따라 tuple, list, set, 혹은 더 나아가 Trie(트라이) 자료구조를 선택하여 검색 성능을 최적화해야 합니다. 소규모 목록에는 tuple이나 list도 괜찮지만, 대규모 목록에서는 set이나 Trie가 훨씬 효율적입니다.
  • 성능 프로파일링 및 테스트: 실제 운영 환경과 유사한 조건에서 필터링 로직의 성능을 측정하고, 병목 지점을 파악하여 개선해야 합니다. 특히 대용량 텍스트 처리 시에는 필수적입니다.
  • 유연한 필터링 정책 설계: 필터링 규칙이 변경되거나 추가될 때 코드 변경을 최소화할 수 있도록, 필터링 로직과 금지어 목록을 분리하여 관리하는 것이 좋습니다. 외부 설정 파일이나 데이터베이스를 활용하는 것도 방법입니다.
  • 오탐 및 미탐 처리: 필터링 로직이 너무 엄격하여 정상적인 단어를 걸러내거나(오탐), 반대로 걸러내야 할 단어를 놓치는(미탐) 경우가 발생할 수 있습니다. 실제 데이터를 기반으로 필터링 정확도를 지속적으로 검증하고 튜닝해야 합니다.
  • 외부 라이브러리 활용: 더욱 복잡하거나 전문적인 텍스트 필터링이 필요하다면, NLTK나 spaCy 같은 자연어 처리(NLP) 라이브러리, 또는 특정 목적(예: 비속어 필터링)을 위해 만들어진 외부 라이브러리(예: `profanity-filter`)의 사용을 고려해볼 수 있습니다.

효율적이고 정확한 텍스트 필터링은 사용자 경험을 향상시키고, 서비스의 품질을 유지하는 데 필수적인 요소입니다. 이 글에서 제시된 다양한 방법을 통해 여러분의 프로젝트에 최적화된 필터링 솔루션을 구축하시길 바랍니다.

댓글 남기기