데이터프레임에 NaN이 보이면 dropna()부터 호출하고 싶어진다. 결과는 깔끔해지지만, 중요하지 않은 메모 열 하나가 비었다는 이유로 정상적인 주문 금액까지 사라질 수 있다. 반대로 아무 값이나 fillna()로 채우면 결측이 실제 관측값처럼 섞인다.
결측치는 빈칸 하나가 아니라 '왜 값이 없고, 이 열을 어떤 판단에 쓰는가'라는 데이터 문제다. 삭제와 대체 전에 위치·비율·업무 의미를 확인해야 한다.
isna로 결측치의 위치와 비율부터 확인한다
작은 주문 표를 만들어 보자. amount는 분석에 필요한 금액이고 memo는 선택 입력이다.
import pandas as pd
orders = pd.DataFrame(
{
"order_id": [101, 102, 103, 104],
"amount": [12000, None, 18000, 21000],
"memo": ["문 앞", None, None, "경비실"],
}
)
missing_count = orders.isna().sum()
missing_rate = orders.isna().mean()
assert missing_count.to_dict() == {
"order_id": 0,
"amount": 1,
"memo": 2,
}
assert missing_rate["amount"] == 0.25
assert missing_rate["memo"] == 0.5isna()는 셀마다 결측 여부를 불리언으로 반환한다. 여기에 sum()을 적용하면 열별 개수, mean()을 적용하면 열별 비율을 얻는다. True가 계산에서 1로 취급되기 때문이다.
비율만 보고 자동으로 삭제 기준을 정하지는 말자. 네 행짜리 예제의 25%와 수백만 행 데이터의 25%는 복구 비용과 영향이 다르고, 열의 중요도도 다르다.
dropna는 어느 행을 지우는지 지정해야 한다
인자 없이 dropna()를 호출하면 어느 열이든 결측인 행을 모두 지운다.
all_columns_required = orders.dropna()
assert all_columns_required["order_id"].tolist() == [101, 104]주문 103은 금액이 있는데도 선택 메모가 비어서 사라졌다. 금액이 있는 주문을 분석하려는 목적이라면 subset으로 중요한 열을 지정하는 편이 맞다.
amount_required = orders.dropna(subset=["amount"])
assert amount_required["order_id"].tolist() == [101, 103, 104]
assert amount_required["amount"].sum() == 51000원본 orders는 바뀌지 않는다. 전처리 결과를 다음 단계에서 쓰려면 새 변수에 받거나 명시적으로 재할당한다. inplace=True를 써도 처리 의미가 더 분명해지는 것은 아니므로, 원본과 변환 결과를 비교할 수 있는 코드를 우선하자.
fillna는 '모름'과 실제 0을 구분해야 한다
선택 메모는 표시 목적이라면 문자열로 대체할 수 있다.
display_orders = orders.assign(
memo=orders["memo"].fillna("메모 없음")
)
assert display_orders.loc[1, "memo"] == "메모 없음"
assert display_orders["amount"].isna().sum() == 1이 코드는 amount의 결측을 건드리지 않는다. 금액이 없다고 0원으로 채우면 '무료 주문'이라는 다른 의미가 생길 수 있다. 평균이나 중위수로 채우는 방법도 있지만, 분포와 그룹 차이를 바꾸고 편향을 만들 수 있다. 대체값을 선택했다면 왜 그 값이 타당한지 기록하고, 결측 여부 열을 함께 남기는 방안도 검토한다.
날짜 열을 '모름' 같은 문자열로 채우면 날짜시간 자료형이 일반 객체형으로 바뀔 수 있다. 이후 기간 계산이나 정렬이 예상과 달라진다. 날짜는 NaT를 유지하거나 별도의 상태 열로 의미를 표현하는 편이 안전하다.
전처리 결과를 테스트하고 디버깅하는 순서
전처리 함수는 행 수만 확인하지 말고 중요한 계약을 함께 검사한다.
def prepare_orders(frame: pd.DataFrame) -> pd.DataFrame:
required = {"order_id", "amount", "memo"}
missing_columns = required - set(frame.columns)
if missing_columns:
raise ValueError(f"필수 열 누락: {sorted(missing_columns)}")
result = frame.dropna(subset=["amount"]).copy()
result["memo"] = result["memo"].fillna("메모 없음")
return result
prepared = prepare_orders(orders)
assert len(prepared) == 3
assert prepared["order_id"].is_unique
assert prepared["amount"].notna().all()
assert prepared["memo"].notna().all()결과가 예상과 다르면 다음 순서로 좁힌다.
- 입력 열 이름과 자료형이 예상과 같은지
info()로 확인한다. isna().sum()과isna().mean()으로 결측 위치와 비율을 본다.- 삭제 전후 행 수와 사라진 식별자를 비교한다.
- 대체 뒤 자료형과 요약 통계가 달라졌는지 확인한다.
- 결측이 특정 기간·수집 장치·사용자 그룹에 몰렸는지 조사한다.
마지막 확인이 중요한 이유는 결측이 무작위가 아닐 수 있기 때문이다. 특정 그룹이 응답하지 않았거나 센서 장애 시간대에만 값이 비었다면 행 삭제가 결과를 한쪽으로 기울게 한다.
운영 파이프라인에서는 결측률 변화를 관찰한다
한 번 정한 dropna나 fillna 규칙이 영원히 맞는 것은 아니다. 수집 방식이 바뀌면 결측률이 갑자기 늘 수 있고, 그대로 삭제하면 출력 행 수만 조용히 줄어든다. 입력 건수, 열별 결측 건수, 삭제 건수, 대체 건수를 배치 실행마다 기록하면 데이터 계약의 변화를 빨리 찾을 수 있다.
원본 데이터는 덮어쓰지 않고 보존한다. 대체 규칙과 적용 버전도 남겨야 같은 분석을 재현하거나 잘못된 처리 결과를 되돌릴 수 있다. 이름·연락처처럼 민감한 열은 디버깅 로그에 원문을 출력하지 말고 건수와 비율만 기록한다.
핵심 요약
dropna와 fillna를 선택하기 전에 isna()로 결측치의 위치와 비율을 확인한다. 삭제할 때는 중요한 열을 subset으로 좁히고, 대체할 때는 새 값이 원래 의미와 자료형을 바꾸지 않는지 본다. 전처리 전후의 행·식별자·자료형·요약 통계를 테스트하고 운영에서는 결측률 변화를 관찰하자.
작성자
기초 개념을 구현과 검증, 실제 운영 판단까지 연결해 기록합니다.

