- Published on
Pandas SettingWithCopyWarning 완전 정복 - 버그 7패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서브쿼리처럼 보이는 Pandas 체이닝 인덱싱은 생각보다 자주 데이터 품질 사고로 이어집니다. SettingWithCopyWarning은 그 사고를 미리 알려주는 경고인데, 많은 팀에서 pd.options.mode.chained_assignment = None로 꺼버리거나, copy()를 남발해 “조용히” 지나가게 만드는 경우가 많습니다.
이 글은 경고를 없애는 요령이 아니라, 왜 경고가 뜨는지(뷰 vs 카피), 어떤 코드가 실제 버그로 이어지는지, 그리고 실무에서 바로 적용 가능한 안전한 패턴을 7가지로 정리합니다.
관련해서 데이터프레임 결합에서 흔한 함정은 별도 글로 정리해두었습니다: Pandas merge KeyError·중복 컬럼 오류 완전 정복
SettingWithCopyWarning의 정체: “뷰인지 카피인지 확신 못함”
Pandas는 슬라이싱/필터링/일부 열 선택 등의 결과가 원본을 바라보는 뷰(view) 인지, 새 객체(copy) 인지 상황에 따라 달라질 수 있습니다. 문제는 사용자가 그 결과에 값을 대입(=)할 때입니다.
- 뷰라면 원본이 수정될 수 있음
- 카피라면 원본은 그대로이고, 수정은 임시 객체에만 반영됨
Pandas가 “이 대입이 원본에 반영될지 확신이 없다”고 판단하면 SettingWithCopyWarning을 띄웁니다.
핵심 원칙은 단 하나입니다.
- 읽기용 중간 결과를 만들었으면, 거기에 대입하지 말고 원본에
loc로 대입하라
실무 버그 패턴 1: 필터링 후 컬럼 대입(가장 흔함)
문제 코드
import pandas as pd
df = pd.DataFrame({
"user_id": [1, 2, 3, 4],
"country": ["KR", "US", "KR", "JP"],
"amount": [10, 20, 30, 40],
})
kr = df[df["country"] == "KR"]
kr["amount"] = kr["amount"] * 1.1 # SettingWithCopyWarning
이 코드는 “KR 유저만 금액을 10% 올린다”처럼 보이지만, 실제로는 df가 바뀌지 않을 수 있습니다.
안전한 수정
mask = df["country"] == "KR"
df.loc[mask, "amount"] = df.loc[mask, "amount"] * 1.1
mask는 조건- 대입은 원본
df에 대해loc[행조건, 열]로 수행
실무 버그 패턴 2: 체이닝 인덱싱(이중 대괄호)
문제 코드
# 특정 국가의 amount만 수정
df[df["country"] == "KR"]["amount"] = 0 # 경고 + 반영 안 될 수 있음
df[조건]이 먼저 실행되어 중간 객체가 만들어지고, 그 다음 ["amount"]가 적용됩니다. 이 “중간 객체”가 뷰인지 카피인지 불명확하니 경고가 뜨고, 실제 결과도 불안정합니다.
안전한 수정
mask = df["country"] == "KR"
df.loc[mask, "amount"] = 0
실무 버그 패턴 3: 부분 컬럼 선택 후 대입(열 서브셋)
열 몇 개만 뽑아 가공한 뒤, 그 결과에 값을 대입하는 패턴도 위험합니다.
문제 코드
sub = df[["user_id", "amount"]]
sub["amount"] = sub["amount"] + 5 # 경고 가능
이 경우 sub는 보통 별도 객체로 보이지만, 내부 최적화나 블록 매니저 구성에 따라 뷰처럼 동작할 여지가 있어 Pandas가 확신하지 못합니다.
의도가 “sub만 수정”이라면
sub = df[["user_id", "amount"]].copy()
sub["amount"] = sub["amount"] + 5
의도가 “원본 df를 수정”이라면
df.loc[:, "amount"] = df["amount"] + 5
여기서 포인트는 copy()가 “경고 제거용 부적”이 아니라, ‘원본과 분리된 가공 테이블’이라는 의도를 코드로 명시하는 도구라는 점입니다.
실무 버그 패턴 4: 정렬/리셋 인덱스 후 중간 객체에 대입
ETL에서 흔히 등장합니다. 정렬한 테이블에서 특정 컬럼을 채우는 코드가 대표적입니다.
문제 코드
sorted_df = df.sort_values("amount")
sorted_df["rank"] = range(1, len(sorted_df) + 1) # 경고가 날 수 있음
sort_values()가 반환하는 객체가 항상 “완전한 독립 카피”라고 가정하면 위험합니다.
안전한 수정(의도별)
- 정렬 결과만 쓰고, 별도 테이블로 유지할 거면
sorted_df = df.sort_values("amount").copy()
sorted_df["rank"] = range(1, len(sorted_df) + 1)
- 원본에
rank를 붙이고 싶다면(정렬은 계산용)
order = df["amount"].rank(method="first").astype(int)
df.loc[:, "rank"] = order
실무 버그 패턴 5: inplace=True 이후 참조를 계속 쓰는 코드
inplace=True는 SettingWithCopyWarning의 직접 원인은 아닐 수 있지만, 경고와 함께 등장하며 디버깅을 어렵게 만듭니다. 특히 “중간 객체인지 원본인지”가 더 헷갈리게 됩니다.
문제 코드(혼란 유발)
kr = df[df["country"] == "KR"]
kr.dropna(subset=["amount"], inplace=True) # 경고가 뜨거나, 의도와 다르게 동작
kr["amount"] = kr["amount"] * 1.1
권장 패턴
- 중간 결과는 명시적으로
copy()로 분리 inplace=True대신 반환값을 받아 재할당
kr = df.loc[df["country"] == "KR"].copy()
kr = kr.dropna(subset=["amount"])
kr.loc[:, "amount"] = kr["amount"] * 1.1
실무에서는 “원본을 수정하는 단계”와 “파생 테이블을 만드는 단계”를 분리해두면, SettingWithCopyWarning뿐 아니라 전체 파이프라인 안정성이 올라갑니다.
실무 버그 패턴 6: 함수 체인 중간에서 생성된 DF에 대입
메서드 체이닝은 깔끔하지만, 중간 결과에 대입이 섞이면 위험해집니다.
문제 코드
result = (
df[df["country"] == "KR"]
.assign(amount=lambda x: x["amount"] * 1.1)
)
이 코드는 사실 assign()을 쓰고 있어서 비교적 안전한 편입니다. 진짜 문제는 체인 중간에서 “대입”을 해버리는 경우입니다.
tmp = df[df["country"] == "KR"]
tmp["amount"] = tmp["amount"] * 1.1 # 경고
안전한 수정
assign()을 선호(새 DF 생성이 명확)
result = df.loc[df["country"] == "KR"].assign(
amount=lambda x: x["amount"] * 1.1
)
- 원본 수정이 목적이면
loc로 원본에 대입
mask = df["country"] == "KR"
df.loc[mask, "amount"] = df.loc[mask, "amount"] * 1.1
실무 버그 패턴 7: 그룹/집계 결과를 원본 슬라이스에 다시 대입
집계 후 다시 원본 일부에 값을 채우는 로직에서 경고가 자주 발생합니다.
문제 코드
# 국가별 평균을 구해서 KR 행에만 평균을 넣는다(예시)
mean_by_country = df.groupby("country")["amount"].mean()
kr = df[df["country"] == "KR"]
kr["amount_mean"] = mean_by_country["KR"] # 경고
안전한 수정
mean_by_country = df.groupby("country")["amount"].mean()
mask = df["country"] == "KR"
df.loc[mask, "amount_mean"] = mean_by_country.loc["KR"]
또는 전체 행에 매핑하는 형태가 더 자연스럽고 안전합니다.
mean_by_country = df.groupby("country")["amount"].mean()
df.loc[:, "amount_mean"] = df["country"].map(mean_by_country)
경고를 “끄는 것”이 왜 위험한가
다음 설정은 팀 코드베이스에서 흔히 보이지만, 추천하지 않습니다.
import pandas as pd
pd.options.mode.chained_assignment = None
이 설정은 경고만 숨깁니다. 즉,
- “원본이 수정되지 않았는데 수정된 줄 아는” 버그
- “일부만 수정되는” 데이터 불일치
- 재현 어려운 환경 의존 동작
을 더 늦게 발견하게 만듭니다.
경고를 줄이고 싶다면, 끄는 대신 코드 구조를 loc 기반으로 통일하는 편이 낫습니다.
디버깅 체크리스트: 경고가 떴을 때 30초 점검
- 대입 구문이
df[조건][열] = ...형태인가 - 대입 대상이 원본
df가 아니라, 필터링/정렬/열선택의 결과물인가 - “원본 수정”이 목적이라면
df.loc[mask, col] = ...로 바꿀 수 있는가 - “파생 테이블 생성”이 목적이라면 해당 라인에서
.copy()로 의도를 고정했는가 - 동일 변수를 재사용하며 타입이 바뀌는가(원본
df였다가 슬라이스였다가)
이 체크리스트는 merge 이후 컬럼이 예상과 다르게 생겨 후속 대입이 꼬이는 경우에도 도움이 됩니다. 필요하면 위의 내부 링크 글도 함께 보세요.
안전한 코딩 규칙(팀 컨벤션으로 추천)
1) 대입은 무조건 loc로
mask = (df["country"] == "KR") & (df["amount"] > 10)
df.loc[mask, "flag"] = True
2) 파생 DF는 생성 시점에 .copy()로 선언
features = df.loc[df["country"].isin(["KR", "JP"])].copy()
features.loc[:, "log_amount"] = (features["amount"] + 1).map(lambda v: __import__("math").log(v))
3) 가능하면 assign, where, mask, map을 활용
mean_by_country = df.groupby("country")["amount"].mean()
df = df.assign(
amount_mean=df["country"].map(mean_by_country),
amount_capped=df["amount"].where(df["amount"] < 35, 35),
)
assign()은 “새 DF를 만든다”는 의미가 명확해 체이닝에 특히 유리합니다.
마무리: 경고를 없애는 게 아니라, 의도를 고정하라
SettingWithCopyWarning은 Pandas가 사용자 대신 “이 대입, 원본에 반영되는 거 맞아?”를 물어보는 장치입니다. 실무에서 중요한 건 경고를 없애는 것이 아니라, 코드가 다음 중 하나로 명확히 분기되도록 만드는 것입니다.
- 원본을 수정한다:
df.loc[mask, col] = value - 파생 테이블을 만든다:
new_df = df.loc[mask, cols].copy()후new_df를 수정
이 두 규칙만 팀 컨벤션으로 고정해도, 데이터 파이프라인에서 발생하는 “조용한 실패”의 상당수를 예방할 수 있습니다.