대소문자 정규화와 dropDuplicates()의 함정 — 정제는 기본값이 아니라 선택이다
PySpark Tutorial: Build a Real Pipeline and Read the Spark UI — Darshil Parmar PySpark Tutorial: Build a Real Pipeline and Read the Spark UI — "The lower() call earns its place too" ~ "dropDuplicates() misses the duplicates that matter" 구간 (11~14페이지)
이 모듈을 다 읽으면
- 필터링 이전에 문자열 정규화를 먼저 해야 하는 이유를 status 대소문자 혼재 사례로 설명할 수 있다
- dropDuplicates()가 전체 컬럼 값이 모두 같아야 중복으로 판단한다는 점과, 그로 인해 발생하는 실제 데이터 사고를 설명할 수 있다
- 결측치나 고아 레코드를 삭제하는 것이 파이프라인의 기본 동작이 아니라 각 상황에서 내려야 하는 명시적 결정임을 설명할 수 있다
status 컬럼을 소문자로 정규화하기 전에 raw 데이터를 그대로 집계하면 delivered와 Delivered가 서로 다른 값으로 취급되어 다섯 가지 상태가 열 가지로 쪼개진다. 만약 정규화 이전에 필터부터 걸었다면 Delivered로 표기된 16건이 조용히 통계에서 빠졌을 것이다. dropDuplicates()를 인자 없이 호출하면 전체 컬럼이 완전히 같은 행만 중복으로 보는데, 이 소스 시스템은 최소 한 번(at-least-once) 재전송 과정에서 customer_id 같은 필드 하나를 훼손한 채 같은 주문을 두 번 보내기도 해서, 3,305행까지 줄여도 order_id 기준으로는 여전히 5건이 중복으로 남는다. dropDuplicates(['order_id'])처럼 업무 키를 지정해야 진짜 중복이 제거되며, 결측 customer_id를 가진 행을 지우는 것도 조용한 삭제가 아니라 별도로 추적 가능한 결정이어야 한다.
필터 전에 정규화하라 — status 대소문자 혼재 사례
raw.groupBy("status").count()로 원본 파일의 상태값을 세어 보면 cancelled 629건과 Cancelled 21건, delivered 699건과 Delivered 16건처럼 사실상 같은 값이 대소문자 차이로 갈라져 총 열 가지 상태로 잡힌다. F.lower(F.col("status"))로 정규화한 뒤 다시 세면 cancelled 650, delivered 715, pending 688, returned 684, shipped 647로 다섯 가지 값으로 정확히 합쳐진다. 만약 이 정규화 이전에 status == "delivered"로 먼저 필터링했다면 Delivered로 기록된 16건이 조용히 통계에서 빠졌을 것이다 — 트랜스포메이션을 어떤 순서로 쌓느냐가 결과의 정확성을 좌우하는 실제 사례다.
핵심 포인트
- 원본 status는 대소문자가 섞여 사실상 5가지 값이 10가지로 쪼개져 카운트된다
- 정규화(lower()) 이후에는 cancelled/delivered/pending/returned/shipped 다섯 가지로 정확히 합쳐진다
- 정규화보다 필터를 먼저 적용하면 대문자로 시작하는 값들이 조용히 누락된다
dropDuplicates()는 전체 컬럼이 같아야 중복으로 본다
orders.dropDuplicates()를 인자 없이 호출하면 3,384행이 3,305행으로 줄어 79개의 중복 행이 제거된 것처럼 보인다. 하지만 distinct order_id 개수를 세어 보면 여전히 3,300개뿐이라 5개 주문이 두 번씩 남아 있다. 원인을 들여다보면 같은 order_id, order_ts, amount를 갖지만 customer_id만 다른(하나는 NULL이거나 다른 고객 ID인) 행이 짝을 이루고 있다 — 이 소스 시스템이 최소 한 번(at-least-once) 방식으로 이벤트를 재전송하면서 그 과정에서 필드 하나가 유실되거나 바뀐 것이다. dropDuplicates()는 행 전체가 완전히 동일해야 중복으로 인식하므로, 이런 '거의 같은' 행은 서로 다른 행으로 남아 이후의 모든 SUM 집계에서 해당 주문이 두 번 카운트된다.
핵심 포인트
- dropDuplicates()는 모든 컬럼 값이 완전히 일치해야 중복으로 판단한다
- at-least-once 재전송 과정에서 한 필드만 바뀐 '유사 중복' 행은 dropDuplicates()로 걸러지지 않는다
- dropDuplicates(["order_id"])처럼 업무적으로 레코드를 식별하는 키를 명시해야 진짜 중복이 제거된다
삭제는 결정이지 기본값이 아니다
customer_id가 비어 있는 47개 주문을 filter(F.col("customer_id").isNotNull())로 걸러내는 것 자체는 정당한 정제 작업이다. 다만 실무 파이프라인에서는 이런 행을 조용히 지우기보다 별도의 quarantine(격리) 테이블에 적재해, 소스 시스템 담당자가 왜 customer_id가 누락되었는지 추적할 수 있게 남겨두는 것이 바람직하다. 조용히 삭제된 행은 그 존재 자체를 아무도 알 수 없기 때문에 원인이 고쳐질 기회조차 사라진다.
핵심 포인트
- 결측치나 고아 레코드를 필터로 제거하는 것은 정당하지만 반드시 의도적인 결정이어야 한다
- 삭제된 행을 quarantine 테이블에 별도로 남겨두면 소스 시스템의 근본 원인을 추적할 수 있다
- 조용히 삭제하면 그 문제를 아무도 고칠 기회를 얻지 못한다