Pandas 실전 트러블슈팅: SettingWithCopyWarning, 결측치(NaN), merge·concat 제대로 다루기
TL;DR — Pandas로 데이터를 다룰 때 가장 자주 발목 잡는 SettingWithCopyWarning 경고, 결측치(NaN) 처리, 그리고 merge와 concat의 차이를 실무 예제와 함께 정리했습니다.
데이터 분석을 처음 시작하면 누구나 Pandas를 거치게 됩니다. 그런데 막상 코드를 돌리다 보면 빨간 경고 메시지가 뜨거나, 분명히 값을 바꿨는데 원본은 그대로이거나, 두 테이블을 합쳤더니 행 수가 이상하게 늘어나는 일이 생깁니다. 대부분은 "원본과 복사본"의 개념, 결측치의 동작 방식, 그리고 병합 함수의 차이를 명확히 이해하지 못해서 발생합니다.
이 글에서는 실무에서 가장 빈번하게 마주치는 세 가지 주제 — SettingWithCopyWarning 경고, 결측치(NaN) 처리, merge와 concat의 차이 — 를 원인부터 해결까지 단계적으로 짚어보겠습니다.
1. 준비: 예제 데이터프레임 만들기
먼저 실습에 쓸 데이터를 만들어 보겠습니다. 사내 직원 명단이라고 가정하겠습니다.
import pandas as pd
# 딕셔너리에서 데이터프레임 생성
staff = pd.DataFrame({
"name": ["수아", "민준", "지호", "예린"],
"age": [27, 34, 41, 29],
"team": ["기획", "개발", "개발", "디자인"],
})
print(staff)
CSV 파일이 있다면 아래처럼 바로 읽어올 수 있습니다.
# CSV에서 불러오고 상위 5행만 미리보기
staff = pd.read_csv("staff.csv")
print(staff.head())
조건 필터링과 정렬도 자주 쓰는 기본기입니다.
# 나이가 30 이상인 직원만 추출
seniors = staff[staff["age"] >= 30]
# 나이 기준 오름차순 정렬
ordered = staff.sort_values(by="age")
여기까지는 직관적입니다. 문제는 "추출한 결과를 수정"하려는 순간 시작됩니다.
2. SettingWithCopyWarning은 왜 뜨는가
가장 악명 높은 경고입니다. 다음 코드를 실행하면 노란 경고가 출력됩니다.
# ❌ 경고를 유발하는 전형적 패턴
seniors = staff[staff["age"] >= 30]
seniors["age"] = 40 # SettingWithCopyWarning 발생
원인
staff[staff["age"] >= 30]로 만들어진 seniors가 원본의 복사본인지, 원본을 가리키는 뷰(view)인지 Pandas 자신도 확신할 수 없기 때문입니다. 이 상태에서 값을 대입하면, 변경이 원본에 반영될지 사본에만 남을지 보장되지 않습니다. Pandas는 "당신의 의도가 모호하다"는 신호로 경고를 띄우는 것입니다.
즉, 이 경고는 버그가 아니라 "의도를 명확히 하라"는 안내에 가깝습니다.
해결법 1 — 원본을 직접 고치려면 .loc[]
원본 데이터프레임의 특정 행/열을 직접 수정하고 싶다면, 슬라이싱으로 중간 객체를 만들지 말고 .loc[행조건, 열] 한 번에 지정합니다.
# ✅ 원본을 직접, 명확하게 수정
staff.loc[staff["age"] >= 30, "age"] = 40
.loc는 "이 행, 이 열을 원본에서 직접 가리킨다"는 의도가 분명하기 때문에 경고가 사라집니다.
해결법 2 — 별도 사본으로 작업하려면 .copy()
원본은 그대로 두고 추출본만 따로 가공하고 싶다면, 추출 시점에 .copy()로 명시적 복사본을 만듭니다.
# ✅ 독립된 사본을 만들어 자유롭게 수정
seniors = staff[staff["age"] >= 30].copy()
seniors["age"] = 40 # 경고 없음, 원본 staff는 영향 없음
정리하면 규칙은 단순합니다. 원본을 바꿀 거면 .loc, 따로 가공할 거면 .copy(). 둘 중 무엇을 원하는지만 결정하면 이 경고는 다시는 나타나지 않습니다.
3. 결측치(NaN)를 안전하게 다루는 법
현실 데이터에는 빈 칸이 가득합니다. Pandas는 이런 빈 값을 NaN(Not a Number)으로 표현합니다.
먼저 결측치가 어디에 얼마나 있는지부터 파악하는 습관이 중요합니다.
# 열별 결측치 개수 확인
print(staff.isna().sum())
결측치 채우기 (fillna)
빈 값을 특정 값으로 대체합니다. 숫자형은 0이나 평균값으로, 문자열은 "미정" 같은 라벨로 채우는 식입니다.
# age 열의 결측치를 평균 나이로 대체
mean_age = staff["age"].mean()
staff["age"] = staff["age"].fillna(mean_age)
# team 열의 결측치를 '미배정'으로 대체
staff["team"] = staff["team"].fillna("미배정")
결측치 제거 (dropna)
값을 채우는 게 부적절하다면 결측 행을 삭제합니다.
# 결측치가 하나라도 있는 행 제거
clean = staff.dropna()
# 특정 열(age)이 비어 있는 행만 제거
clean = staff.dropna(subset=["age"])
inplace에 대한 권장 사항
예전 코드에서는 df.fillna(0, inplace=True) 형태를 자주 봅니다. 동작은 하지만, 최신 Pandas에서는 반환값을 다시 변수에 할당하는 방식을 권장합니다. 메서드 체이닝과의 호환성이 좋고, 의도가 명확하기 때문입니다.
# 권장: 결과를 다시 대입
staff = staff.fillna(0)
4. merge vs concat: 언제 무엇을 쓰는가
두 함수 모두 데이터프레임을 합치지만 목적이 전혀 다릅니다. 예제 데이터를 준비합니다.
orders = pd.DataFrame({
"user_id": ["u1", "u2", "u3"],
"amount": [12000, 8000, 30000],
})
users = pd.DataFrame({
"user_id": ["u1", "u2", "u4"],
"grade": ["GOLD", "SILVER", "VIP"],
})
merge — 공통 키로 옆으로 붙이기 (SQL JOIN)
merge는 공통 열(key)을 기준으로 두 테이블을 가로로 결합합니다. 관계형 DB의 JOIN과 동일한 개념입니다.
# user_id가 양쪽 모두에 있는 행만 결합 (교집합)
inner = pd.merge(orders, users, on="user_id", how="inner")
# 왼쪽(orders)은 전부 유지, 오른쪽은 매칭되는 것만
left = pd.merge(orders, users, on="user_id", how="left")
how 옵션이 핵심입니다.
inner: 양쪽 모두 키가 있는 행만 (기본값)left: 왼쪽 전부 + 오른쪽 매칭분right: 오른쪽 전부 + 왼쪽 매칭분outer: 양쪽 전부 (합집합, 없는 값은 NaN)
concat — 단순히 쌓아 붙이기
concat은 키 매칭 없이 데이터를 그대로 이어 붙입니다. 같은 구조의 데이터를 누적할 때 씁니다.
# 행 방향(아래로) 쌓기 — 같은 컬럼 구조의 데이터 누적
stacked = pd.concat([orders, users], axis=0, ignore_index=True)
# 열 방향(옆으로) 붙이기 — 인덱스 기준 정렬
side_by_side = pd.concat([orders, users], axis=1)
한눈에 비교
| 구분 | merge | concat |
|---|---|---|
| 결합 기준 | 공통 키(key) 값 매칭 | 인덱스/위치 |
| 비유 | SQL JOIN | 파일 이어붙이기 |
| how 옵션 | 지원 (inner/left/right/outer) | 없음 |
| 주 용도 | 서로 다른 테이블 연결 | 같은 구조 데이터 누적 |
5. 흔한 실수와 엣지 케이스
- NaN끼리는 같지 않다:
np.nan == np.nan은False입니다. 결측치 비교에는==이 아니라 반드시.isna()를 쓰세요. - merge 후 행이 폭증한다: 키가 한쪽에 중복되어 있으면 곱집합처럼 행이 늘어납니다. 합치기 전에
df["key"].duplicated().sum()으로 중복을 확인하세요. - concat의 인덱스 중복:
axis=0으로 쌓을 때ignore_index=True를 빼먹으면 인덱스가 0,1,2,0,1,2처럼 겹칩니다. - dropna가 너무 공격적: 기본
dropna()는 한 칸이라도 비면 행 전체를 지웁니다. 특정 열만 보려면subset을 지정하세요. - fillna(0)의 함정: 결측을 0으로 채우면 평균·합계 통계가 왜곡됩니다. 0이 의미 있는 값인지 먼저 판단하세요.
6. 요약
- SettingWithCopyWarning: 원본 수정은
.loc, 사본 작업은.copy(). 의도를 명확히 하면 사라진다. - 결측치(NaN):
isna()로 진단 →fillna()로 채우거나dropna()로 제거.inplace보다 재할당 권장. - merge vs concat: 키로 옆으로 붙이면 merge(JOIN), 단순히 쌓으면 concat. merge는
how, concat은axis가 핵심.
이 세 가지만 확실히 잡아도 Pandas 작업의 체감 난이도가 크게 낮아집니다.
7. AI에게 물어볼 때 (프롬프트 팁)
Pandas 문제는 ChatGPT나 Claude 같은 AI에게 물으면 빠르게 해결되지만, 질문에 실제 데이터 구조와 에러 전문을 함께 주는지에 따라 답변 품질이 크게 갈립니다. Prompt Architect가 권하는 프롬프트 예시입니다.
예시 1 — 경고 원인 분석
다음 Pandas 코드에서 SettingWithCopyWarning이 발생합니다.
컬럼은 name(str), age(int), team(str) 3개입니다.
[코드 붙여넣기]
이 경고가 뜨는 이유를 view/copy 개념으로 설명하고,
원본 수정 버전과 사본 작업 버전 두 가지로 고쳐 주세요.
예시 2 — 병합 결과 검증
df_a(1만 행)와 df_b(5천 행)를 user_id로 merge했더니
결과가 1만 5천 행이 됐습니다. 키 중복으로 인한 곱집합이 의심됩니다.
원인을 진단하는 점검 코드와, 의도한 left join으로 안전하게 합치는 방법을 알려 주세요.
예시 3 — 결측치 전략 설계
매출 데이터에 age, amount, grade 열의 결측치가 각각 5%, 1%, 12% 있습니다.
각 열의 데이터 특성을 고려해 fillna와 dropna 중 무엇을 쓸지
열별 권장 전략과 그 근거를 표로 정리해 주세요.
핵심은 (1) 데이터 스키마, (2) 에러 메시지 전문, (3) 원하는 결과 형태를 함께 제시하는 것입니다. 더 정교한 프롬프트 설계가 궁금하다면 Prompt Architect의 프롬프트 분석기로 본인의 질문을 점검해 보세요.