데이터 정제: NUL 문자, 인코딩 정규화, DB측 암호화 컬럼

이전 · 다음

개요

추출한 원시 데이터를 그대로 PostgreSQL에 넣으면 반드시 어딘가에서 깨진다. 이관의 세 원칙 중 하나가 “소스를 신뢰하지 말 것” 인데, 정제 단계가 바로 그 원칙을 실행하는 곳이다. 20년치 레거시 텍스트에는 보이지 않는 제어문자, 잘못 저장된 인코딩, DB 레벨에서 암호화된 컬럼이 뒤섞여 있다.

이 문서는 세 가지 대표적 오염을 다룬다. (1) Oracle CLOB에 섞인 NUL 문자(\x00)를 PostgreSQL text가 거부하는 문제, (2) 한글 인코딩 정규화, (3) DB측 암호화 컬럼을 어떻게 처리할지다.

핵심 개념·원리

NUL 문자를 PostgreSQL이 거부하는 이유

PostgreSQL의 text/varchar 타입은 내부적으로 C 문자열 규약을 따라, 널 바이트(U+0000, \x00)를 문자열의 끝으로 해석한다. 그래서 값 중간에 \x00이 있으면 저장을 거부하고 다음 오류를 낸다.

invalid byte sequence for encoding "UTF8": 0x00
# 또는
unterminated quoted string / null character not permitted

문제는 Oracle CLOB에는 \x00이 아무렇지 않게 저장돼 있을 수 있다는 점이다(과거 바이너리 붙여넣기, 잘못된 인코딩 변환의 잔재 등). 겉으로는 안 보이지만 적재 도중 특정 행에서 트랜잭션 전체를 깨뜨린다. 청크 단위로 커밋하더라도 그 청크가 통째로 롤백되므로, 문제 행 하나가 5만 건을 되돌린다.

인코딩 불일치

레거시 Oracle은 흔히 KO16MSWIN949(CP949)로 한글을 저장하고, 대상 PostgreSQL은 AL32UTF8(UTF-8)이다. 드라이버가 charset을 잘못 잡으면 한글이 모지바케(mojibake)로 깨지는데, 일단 잘못 디코딩된 바이트열은 적재 후 원복이 사실상 불가능하다. 그래서 인코딩은 추출 시점에 확정하고(02.접속 확립 (thin·thick·libaio·NLS)의 NLS 설정), 정제 단계에서는 정규화만 담당한다.

DB측 암호화 컬럼

소스에서 DB 레벨 암호화 기능으로 저장된 컬럼은 암호문(ciphertext)만 들어 있다. 이 암호문을 그대로 대상에 복사하면, 대상에는 복호화 키가 없으므로 영원히 읽을 수 없는 쓰레기가 된다. 암호화 컬럼은 반드시 “소스에서 복호화해 평문으로 받기” 또는 “이관 대상에서 제외” 중 하나를 택해야 한다.

실전

NUL 문자 및 제어문자 제거

가장 흔한 정제는 널 바이트 제거다. 값이 문자열일 때만 안전하게 처리한다.

import re
 
# 널 바이트 제거
def strip_nul(s: str | None) -> str | None:
    return s.replace("\x00", "") if s else s
 
# 널 바이트 + 기타 비인쇄 제어문자 제거(탭/개행은 보존)
_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
 
def clean_text(s: str | None) -> str | None:
    if not s:
        return s
    return _CTRL.sub("", s)

행 전체를 정제하는 래퍼를 두면 적재 직전에 일괄 적용할 수 있다.

TEXT_COLS = ("title", "body", "summary")
 
def clean_row(row: dict) -> dict:
    for col in TEXT_COLS:
        row[col] = clean_text(row.get(col))
    return row

인코딩 정규화

바이트로 받은 경우, 소스 charset으로 디코딩한 뒤 UTF-8 표준형으로 정규화한다. 깨진 바이트는 버리지 말고 로그로 남겨 원인을 추적한다.

import unicodedata
 
def to_utf8(raw: bytes | str) -> str:
    if isinstance(raw, bytes):
        text = raw.decode("cp949", errors="replace")  # KO16MSWIN949 계열
    else:
        text = raw
    return unicodedata.normalize("NFC", text)          # 한글 자모 결합 정규화

NFC 정규화는 조합형(자모 분리) 한글을 완성형으로 통일해, 대상에서의 검색·비교 일관성을 확보한다.

암호화 컬럼 처리

암호화 컬럼은 화이트리스트로 관리한다. 복호화 불가라면 명시적으로 제외하고, 그 사실을 데이터 사전에 기록한다.

# 암호화되어 대상으로 옮기지 않는 컬럼(평문 확보 불가)
ENCRYPTED_EXCLUDE = {"ssn_enc", "account_enc"}
 
def project_columns(row: dict) -> dict:
    return {k: v for k, v in row.items() if k not in ENCRYPTED_EXCLUDE}

암호화 컬럼이 PII이기도 하다면, 제외·복호화 여부는 보안·규정 관점에서 별도로 판단해야 한다(→ 07.보안·프라이버시 (마스킹·DLP)).

함정·트러블슈팅

증상원인해법
invalid byte sequence ... 0x00CLOB의 NUL 문자적재 전 strip_nul/clean_text
청크 하나가 통째로 롤백문제 행 1건이 트랜잭션 오염행 단위 정제 선행, 실패 행 격리
한글이 모지바케charset 불일치추출 시 NLS 확정, cp949→NFC 정규화
정제 후에도 검색 불일치조합형/완성형 혼재unicodedata.normalize("NFC", ...)
대상 컬럼 값이 의미 불명 이진열암호문을 그대로 복사소스 복호화 또는 이관 제외

문제 행 격리

정제 규칙을 다 걸어도 예외는 남는다. 실패 행 하나가 청크 전체를 되돌리는 것을 막으려면, 오류 발생 시 그 행만 격리 테이블로 빼고 나머지는 진행시킨다.

def load_with_quarantine(cur, rows, load_one, quarantine):
    for r in rows:
        try:
            load_one(cur, r)
        except Exception as e:
            quarantine.append({"key": r.get("record_no"), "error": str(e)})

격리된 행은 나중에 별도로 원인 분석·수동 처리한다. “전량 성공 아니면 전량 실패” 대신 “대부분 진행 + 소수 격리”가 대규모 이관의 현실적 태도다.

정리

정제는 소스 불신 원칙의 실행이다. NUL 문자와 제어문자를 적재 전에 제거하고, 인코딩은 추출 시 확정한 뒤 NFC로 정규화하며, 암호화 컬럼은 복호화하거나 제외한다. 문제 행은 격리해 전체 진행을 막지 않는다. 깨끗해진 데이터를 다음 단계에서 멱등하게 적재한다.