PySpark 대규모 텍스트·정규식 처리 — 수십억 로그를 파싱하기
비정형 로그·텍스트 수십억 건을 정규식으로 파싱하고 정제할 때의 성능 함정. catastrophic backtracking, UDF 대신 내장 정규식 함수, 토큰화·정규화, 그리고 깨진 인코딩 처리까지 PySpark 패턴으로 정리합니다.
수억 건의 웹 서버 로그가 쌓여 있고, 각 줄에서 IP, 상태 코드, 응답 시간을 뽑아내야 한다고 상상해 보세요. 정규식 몇 줄이면 될 것 같지만, 수십억 건 규모에서는 정규식 패턴 하나를 잘못 쓰면 잡 전체가 멈춥니다(catastrophic backtracking). 이 글은 그 함정을 피하면서 대규모 텍스트를 빠르고 안정적으로 처리하는 PySpark 패턴을 정리합니다.
이 글에서 배우는 것
- Python UDF 대신 JVM 내 내장 정규식 함수를 써야 하는 이유
- Apache 로그처럼 구조가 있는 텍스트를 그룹 패턴으로 파싱하는 방법
- Catastrophic backtracking 이 왜 잡을 멈추는지, 어떻게 피하는지
- NLP 전처리(토큰화·불용어 제거)를 MLlib 으로 처리하는 방법
- 깨진 인코딩·제어문자 처리와 성능 최적화 패턴
1. 첫 원칙 — 정규식도 UDF 말고 내장 함수로
텍스트 처리를 Python UDF 로 하면 느립니다(JVM↔Python 직렬화 + 행 단위). Spark 는 JVM 내에서 동작하는 정규식 내장 함수를 제공합니다. 이걸 쓰세요.
from pyspark.sql import functions as F
# ❌ Python UDF (느림)
import re
@F.udf("string")
def extract_ip(line):
m = re.search(r"\d+\.\d+\.\d+\.\d+", line)
return m.group() if m else None
# ✅ 내장 정규식 함수 (JVM 내 실행, 빠름)
df = df.withColumn("ip", F.regexp_extract("line", r"(\d+\.\d+\.\d+\.\d+)", 1))| 내장 함수 | 용도 |
|---|---|
regexp_extract(col, pattern, group) | 그룹 추출 |
regexp_extract_all | 모든 매칭 추출(배열) |
regexp_replace(col, pattern, repl) | 치환 |
rlike / regexp_like | 패턴 매칭 필터 |
split(col, pattern) | 분할 |
(UDF 가 느린 근본 이유는 별도 글 "PySpark UDF가 느린 이유와 Pandas UDF" 참고.)
2. 로그 파싱 — 한 패턴으로 여러 필드
Apache 액세스 로그처럼 포맷이 일정한 텍스트는 캡처 그룹을 활용하면 한 번의 패턴으로 여러 필드를 동시에 뽑아낼 수 있습니다.
# Apache 액세스 로그 파싱
pattern = r'(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) [^"]*" (\d{3}) (\d+)'
parsed = df.select(
F.regexp_extract("line", pattern, 1).alias("ip"),
F.regexp_extract("line", pattern, 2).alias("ts"),
F.regexp_extract("line", pattern, 3).alias("method"),
F.regexp_extract("line", pattern, 4).alias("path"),
F.regexp_extract("line", pattern, 5).cast("int").alias("status"),
F.regexp_extract("line", pattern, 6).cast("long").alias("bytes"))팁: 같은 패턴을
regexp_extract로 6번 호출하면 매번 매칭합니다. 성능이 중요하면 한 번 매칭해 구조체로 받는 방식(또는from_csv/고정 구분자split)을 고려하세요. 구분자가 명확하면 정규식보다split이 훨씬 빠릅니다.
3. 가장 위험한 함정 — Catastrophic Backtracking
정규식 엔진이 매칭을 시도할 때 실패하면 이전 단계로 돌아가 다른 경우를 시도합니다. 특정 패턴에서 이 되돌아가기가 폭발적으로 늘어나 한 행 처리에 수분이 걸리기도 하는데, 이것이 catastrophic backtracking 입니다.
위험 패턴: 중첩된 수량자 (a+)+, (.*)*, (\d+)+$
악성 입력: "aaaaaaaaaaaaaaaaaaaaaaaa!" 같은 거의-매칭
→ 백트래킹이 폭발 → 한 행 처리에 수초~수분 → 잡 멈춤| 위험 | 안전 |
|---|---|
(a+)+, (.*)* | 중첩 수량자 제거 |
.*foo.* 남발 | 앵커(^, $)·구체적 문자클래스 |
탐욕적(.*) | 게으른(.*?) 또는 문자클래스 [^"]* |
# ❌ 위험: 중첩 수량자
r"(\w+\s*)+"
# ✅ 안전: 구체적 문자클래스, 앵커
r"^\w+(\s\w+)*$"진단: Spark UI 에서 한 태스크만 끝없이 RUNNING 이고 CPU 가 100% 인데 데이터 스큐가 아니라면, 악성 입력 + 취약 정규식의 backtracking 을 의심하세요. 패턴을 단순화하거나 입력 길이를 제한하세요.
한 문장으로: 중첩 수량자는 수십억 건 중 단 한 행만으로도 잡 전체를 멈출 수 있습니다.
4. 토큰화·정규화 (NLP 전처리)
검색·임베딩·분류를 위한 텍스트 전처리는 MLlib 의 텍스트 트랜스포머나 내장 함수로 합니다.
from pyspark.ml.feature import RegexTokenizer, StopWordsRemover
# 정규화: 소문자 + 특수문자 제거
df = df.withColumn("clean",
F.regexp_replace(F.lower("text"), r"[^\w\s가-힣]", " "))
# 토큰화 (정규식 기반)
tokenizer = RegexTokenizer(inputCol="clean", outputCol="tokens",
pattern=r"\s+", minTokenLength=2)
df = tokenizer.transform(df)
# 불용어 제거
remover = StopWordsRemover(inputCol="tokens", outputCol="filtered")
df = remover.transform(df)| 작업 | 도구 |
|---|---|
| 소문자·정제 | lower, regexp_replace |
| 토큰화 | RegexTokenizer, split |
| 불용어 | StopWordsRemover |
| n-gram | NGram |
| 벡터화 | HashingTF, CountVectorizer, Word2Vec |
(한국어 다국어 검색 전처리는 별도 글 "특허·법률·논문 검색을 위한 다국어 검색 엔진 설계"에서도 다룹니다.)
5. 인코딩·깨진 문자 처리
실제 현장 데이터를 다루다 보면 깨진 인코딩, 제어문자, 이모지가 예고 없이 섞여 있습니다.
# 제어문자·비인쇄 문자 제거
df = df.withColumn("clean",
F.regexp_replace("text", r"[\x00-\x1F\x7F]", ""))
# 읽기 시 인코딩 지정 (깨짐 방지)
df = spark.read.option("encoding", "UTF-8").text("path")
# 깨진 행이 많으면 분리·격리 (별도 글 "중첩 반정형 데이터"의 quarantine 패턴)6. 성능 패턴
텍스트 처리에서 속도를 높이는 핵심은 비싼 작업을 최대한 늦게, 최소한의 데이터에만 적용하는 것입니다.
| 패턴 | 효과 |
|---|---|
| 내장 정규식 함수 | UDF 대비 수배 |
split (구분자 명확 시) | 정규식보다 빠름 |
필터 먼저(rlike) | 처리량 축소 후 파싱 |
| 패턴 단순화 | backtracking 회피 |
| 컬럼 일찍 좁히기 | 불필요 텍스트 제거 |
# 관심 있는 로그만 먼저 필터 → 그 다음 비싼 파싱
errors = df.filter(F.col("line").rlike(r"\bERROR\b"))
parsed = errors.select(F.regexp_extract(...))먼저 rlike 로 대상을 줄이고 비싼 추출은 줄어든 데이터에만 적용하는 것이 핵심입니다.
7. 정리
| 영역 | 핵심 |
|---|---|
| 함수 선택 | UDF 금지, 내장 정규식 함수 |
| 로그 파싱 | 그룹 추출 또는 split(구분자 명확 시) |
| backtracking | 중첩 수량자·.* 남발 금지 |
| NLP 전처리 | RegexTokenizer + StopWordsRemover |
| 인코딩 | 제어문자 제거, 깨진 행 격리 |
| 성능 | rlike 로 먼저 필터, 컬럼 일찍 좁히기 |
대규모 텍스트 처리의 핵심은 두 가지입니다. 첫째, JVM 내 내장 정규식 함수를 써서 UDF 직렬화 비용을 없애는 것. 둘째, catastrophic backtracking 을 일으키는 정규식을 피하는 것 — 수십억 건 중 단 몇 개의 악성 입력이 취약한 패턴을 만나면 잡 전체가 멈추기 때문입니다. 구분자가 명확하면 정규식보다 split 을, 비싼 파싱 전에는 rlike 필터로 데이터를 먼저 줄이는 습관이 텍스트 파이프라인을 빠르고 안정적으로 만듭니다.
마치며 — 핵심 요약
- UDF 는 쓰지 마세요. 정규식 처리도 내장 함수(
regexp_extract,regexp_replace,rlike)로 처리하면 JVM 직렬화 비용 없이 훨씬 빠릅니다. - 구분자가 명확하면 정규식보다
split이 빠릅니다. 정규식이 꼭 필요한 곳에만 쓰세요. - 중첩 수량자는 재앙입니다.
(a+)+같은 패턴 하나가 수십억 건 파이프라인을 통째로 멈출 수 있습니다. - rlike 로 먼저 걸러내세요. 비싼 추출 작업은 관심 대상이 된 적은 수의 행에만 적용합니다.
- NLP 전처리는 MLlib 트랜스포머(
RegexTokenizer,StopWordsRemover)가 분산 환경에 최적화되어 있습니다. - 인코딩 문제는 초반에 잡으세요. 읽기 시 인코딩 지정과 제어문자 제거를 파이프라인 첫 단계에 두면 이후 처리가 훨씬 안정적입니다.
텍스트는 지저분하고 정규식은 강력하지만 날카롭습니다. 올바른 도구와 올바른 패턴을 함께 쓰면, 수십억 건의 비정형 데이터도 충분히 다룰 수 있습니다.
이 글은 Spark 3.5 기준으로 작성되었습니다. 대규모 로그·텍스트 파싱 파이프라인 설계가 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀