- Published on
pandas SettingWithCopyWarning 완전 정복 - 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론에서 결론부터 말하면, SettingWithCopyWarning은 “지금 하고 있는 대입이 원본 DataFrame에 반영될 수도/안 될 수도 있다”는 신호입니다. 이 경고를 무시하면 필터링한 결과에 값을 넣었는데 원본은 안 바뀌거나, 반대로 의도치 않게 원본이 바뀌는 식의 데이터 품질 사고로 이어질 수 있습니다. 특히 전처리 파이프라인이 길어질수록 문제를 재현하기 어려워져, 원인 파악이 늦어지는 경우가 많습니다.
이 글에서는 경고가 발생하는 구조적 이유(뷰 vs 복사), 대표적인 재현 패턴, 그리고 실무에서 가장 안전한 해결책을 코드로 정리합니다. 에러 재현/원인별 빠른 진단이라는 관점은 Python UnicodeDecodeError 재현·원인별 5분 해결 글의 접근과 유사하게, “왜 뜨는지 → 어떻게 고칠지”를 짧은 루프로 반복 가능하게 만드는 데 초점을 둡니다.
SettingWithCopyWarning이 의미하는 것
pandas에서 df[...]로 슬라이싱/필터링한 결과는 항상 “독립적인 복사본”이 아닙니다. 내부 최적화/메모리 공유 여부에 따라 원본의 뷰(view) 일 수도 있고, 복사(copy) 일 수도 있습니다.
- 뷰(view): 원본 데이터 버퍼를 공유. 뷰에 대입하면 원본에 반영될 가능성이 큼.
- 복사(copy): 별도 데이터. 복사본에 대입해도 원본은 그대로.
문제는, pandas가 많은 경우에 “지금 결과가 뷰인지 복사인지”를 사용자가 직관적으로 알기 어렵다는 점입니다. 그래서 pandas는 아래처럼 체인 인덱싱(chained indexing) 형태의 대입을 보면 경고를 띄웁니다.
# (1) 필터링 -> (2) 컬럼 선택 -> (3) 대입
# df[df["A"] > 0]["B"] = 1
이 코드는 중간 결과가 뷰인지 복사인지에 따라 동작이 달라질 수 있어, pandas가 “안전하지 않다”고 판단합니다.
가장 흔한 재현 패턴 3가지
1) 체인 인덱싱으로 컬럼에 대입
import pandas as pd
df = pd.DataFrame({
"A": [1, -1, 2],
"B": [10, 20, 30]
})
# 경고 유발 가능
df[df["A"] > 0]["B"] = 999
print(df)
원하는 의도는 A > 0인 행의 B만 바꾸는 것이지만, 위 코드는 중간 객체(df[df["A"] > 0])에 먼저 접근한 뒤 그 결과의 "B"에 대입합니다.
2) 슬라이스 후 대입
df2 = df[:2] # 앞 2행
df2["B"] = 0 # 경고가 뜰 수 있음
특히 행 슬라이스는 내부적으로 뷰가 될 가능성이 있어 혼란을 키웁니다.
3) inplace=True와 결합된 중간 객체 조작
filtered = df[df["A"] > 0]
# filtered가 뷰인지 복사인지 불명확한 상태에서 inplace 조작
filtered["B"].fillna(0, inplace=True)
inplace=True는 pandas에서 점점 권장되지 않는 방향이고, 중간 객체에서의 inplace 변경은 SettingWithCopyWarning과 결합해 디버깅을 더 어렵게 만듭니다.
정석 해결: .loc로 “한 번에” 선택하고 대입하기
가장 권장되는 패턴은 행 조건과 열을 한 번에 지정하는 것입니다.
mask = df["A"] > 0
df.loc[mask, "B"] = 999
df.loc[행조건, 열] = 값형태는 pandas가 의도를 명확히 이해합니다.- 중간 객체가 없으므로 뷰/복사 모호성이 크게 줄어듭니다.
여러 컬럼을 동시에 수정
mask = df["A"] > 0
df.loc[mask, ["B"]] = 999
# 또는
# df.loc[mask, ["B", "C"]] = ...
조건부 계산 대입
mask = df["A"] > 0
df.loc[mask, "B"] = df.loc[mask, "B"] * 2
핵심은 좌변/우변 모두 같은 인덱싱 규칙(.loc) 을 써서 “어떤 행을 바꾸는지”를 명확히 하는 것입니다.
“필터링 결과를 새로운 DataFrame으로 쓰고 싶다”면 .copy()
원본을 건드리지 않고, 필터링된 결과를 별도 데이터로 다루려면 의도를 코드에 박아야 합니다.
filtered = df.loc[df["A"] > 0].copy() # 명시적 복사
filtered["B"] = 999
print(df) # 원본 유지
print(filtered) # 변경된 별도 객체
.copy()는 메모리를 더 쓰지만, 예측 가능성을 얻습니다.- 전처리 단계가 길고 재현이 어려운 버그를 줄이는 데 비용 대비 효과가 큽니다.
파이프라인 스타일: .assign / .pipe로 “대입 없는 변환” 만들기
SettingWithCopyWarning은 “대입”에서 주로 발생합니다. 그래서 실무에서는 대입을 줄이고 변환을 함수형으로 구성하면 경고를 원천 차단하기 좋습니다.
.assign 사용
import numpy as np
out = (
df
.loc[df["A"] > 0]
.assign(B=lambda x: x["B"] * 2,
flag=lambda x: np.where(x["A"] > 1, "big", "small"))
)
.assign은 새 DataFrame을 반환하므로, 중간 뷰/복사 모호성이 줄어듭니다.- 체인 인덱싱 대입을 피할 수 있습니다.
.pipe로 전처리 단계 모듈화
def add_ratio(d: pd.DataFrame) -> pd.DataFrame:
return d.assign(ratio=d["B"] / d["B"].max())
out = (
df
.loc[df["A"] > 0]
.copy()
.pipe(add_ratio)
)
이런 방식은 “경고를 없애기 위해 .loc를 붙이는” 수준을 넘어, 데이터 변환을 테스트 가능한 단위로 만들기 때문에 유지보수에 유리합니다.
경고를 ‘끄는’ 것은 최후의 수단
간혹 아래처럼 전역 옵션으로 경고를 숨기는 글을 볼 수 있습니다.
import pandas as pd
pd.options.mode.chained_assignment = None # 경고 비활성화
하지만 이는 문제를 해결하는 게 아니라 신호를 제거하는 것입니다. 코드베이스가 커질수록 “어느 지점에서 원본이 안 바뀌었는지”를 더 찾기 어려워집니다.
경고를 끄는 것이 합리적인 경우는 매우 제한적입니다.
- 이미
.loc/.copy()로 안전성을 보장했고, 경고가 특정 pandas 버전에서만 과도하게 뜨는 상황 - 성능/로그 노이즈 이슈로 CI에서 경고를 잠깐 억제해야 하는 상황(그리고 추후 제거 계획이 있는 경우)
원칙적으로는 경고를 수정 신호로 받아들이는 편이 좋습니다.
디버깅 체크리스트: 어디서 뷰/복사가 갈렸는지 찾기
SettingWithCopyWarning이 떴을 때는 “내가 원본을 바꾸려는 건가? 아니면 새 데이터로 만들려는 건가?”를 먼저 결정해야 합니다.
1) 의도 결정
- 원본을 바꾸려면:
df.loc[mask, col] = ...로 바꿔 쓰기 - 새 DataFrame이면:
filtered = df.loc[mask].copy()후filtered[...] = ...
2) 체인 인덱싱을 검색
프로젝트에서 아래 패턴을 grep/검색하면 대부분 잡힙니다.
df[...][...] =df[...]["col"].something(inplace=True)
3) 중간 객체에 대한 inplace 제거
# 나쁜 예
filtered["B"].fillna(0, inplace=True)
# 좋은 예
filtered["B"] = filtered["B"].fillna(0)
4) 경고를 에러로 승격해 조기 발견 (테스트/CI에서 유용)
import pandas as pd
pd.options.mode.chained_assignment = "raise" # 경고를 예외로
# 이제 SettingWithCopyWarning 상황에서 바로 예외 발생
이 방법은 데이터 파이프라인에서 “조용히 실패”하는 케이스를 줄여줍니다. CI에서 캐시/환경 차이로 문제가 늦게 드러나는 상황을 줄이는 접근은 GitHub Actions 캐시 안 먹힘 원인 7가지 같은 글에서 다루는 ‘재현성 확보’와도 결이 같습니다.
실전 예제: 잘못된 전처리 코드를 안전하게 리팩터링
문제 코드
# 목표: VIP 고객(is_vip=True) 중 구매액(amount)이 0인 값을 결측으로 처리 후 평균으로 채우기
vip = df[df["is_vip"]]
vip[vip["amount"] == 0]["amount"] = None
vip["amount"].fillna(vip["amount"].mean(), inplace=True)
문제점:
vip[vip["amount"] == 0]["amount"] = ...는 체인 인덱싱vip["amount"].fillna(..., inplace=True)는 중간 객체 inplace- 원본
df에 반영되어야 하는지/vip만 바뀌어야 하는지 불명확
해결 1) 원본 df를 수정하는 의도라면
mask_vip = df["is_vip"]
mask_zero = df["amount"] == 0
# VIP이면서 0인 amount를 NaN으로
import numpy as np
df.loc[mask_vip & mask_zero, "amount"] = np.nan
# VIP의 amount 평균으로 VIP의 결측치 채우기
vip_mean = df.loc[mask_vip, "amount"].mean()
df.loc[mask_vip, "amount"] = df.loc[mask_vip, "amount"].fillna(vip_mean)
해결 2) vip라는 별도 데이터셋을 만들 의도라면
import numpy as np
vip = df.loc[df["is_vip"], ["amount", "is_vip"]].copy()
vip.loc[vip["amount"] == 0, "amount"] = np.nan
vip_mean = vip["amount"].mean()
vip["amount"] = vip["amount"].fillna(vip_mean)
의도가 코드에 드러나므로, 이후 유지보수자가 봐도 “원본 수정인지/파생 데이터인지”가 명확해집니다.
자주 묻는 질문(FAQ)
Q1. .loc를 썼는데도 경고가 뜰 수 있나?
대부분 .loc로 해결되지만, .loc[...]의 결과를 다시 체인으로 잡아 대입하면 다시 문제가 될 수 있습니다.
# 여전히 체인 인덱싱
df.loc[df["A"] > 0]["B"] = 1 # .loc를 썼지만, 결과에 다시 ["B"]
# 올바른 형태
df.loc[df["A"] > 0, "B"] = 1
Q2. df.query(...)는 안전한가?
query 자체가 문제라기보다, 그 결과에 체인 대입을 하면 동일한 위험이 있습니다.
tmp = df.query("A > 0").copy()
tmp["B"] = 1
원본을 바꾸려면 query 대신 마스크+.loc가 더 직관적입니다.
Q3. 성능은 어떤가? .copy()가 너무 비싸지 않나?
- 대규모 데이터에서
.copy()는 분명 비용이 있습니다. - 하지만 SettingWithCopyWarning을 방치해 생기는 데이터 오류는 비용이 더 큽니다.
- 타협안으로는 “원본을 수정할 때는
.loc로 직접 수정”, “파생 데이터가 필요할 때만.copy()”를 추천합니다.
결론
SettingWithCopyWarning은 pandas가 친절하게 알려주는 데이터 정합성 경고입니다. 해결의 핵심은 단순합니다.
- 원본을 수정할 거면:
df.loc[mask, col] = ... - 파생 DataFrame을 만들 거면:
.copy()후 수정 - 가능하면:
.assign/.pipe로 대입을 줄이고 파이프라인화 - 테스트/CI에서는:
pd.options.mode.chained_assignment = "raise"로 조기 탐지
이 원칙만 지켜도 전처리 코드의 예측 가능성이 크게 올라가고, “분명 바꿨는데 왜 안 바뀌지?” 같은 시간을 잡아먹는 디버깅을 상당 부분 없앨 수 있습니다.