- Published on
pandas SettingWithCopyWarning 완전정복 - 안전한 대입
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
pandas를 쓰다 보면 한 번쯤은 아래 경고를 마주칩니다.
> SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame
처음엔 “그냥 경고니까 무시해도 되겠지”라고 생각하기 쉽지만, 이 경고는 데이터 정합성 문제로 이어질 수 있습니다. 특히 전처리 파이프라인에서 필터링한 결과에 값을 대입했는데 원본에 반영되지 않거나, 반대로 “반영된 줄 알았는데 일부만 반영되는” 식의 미묘한 버그를 만들 수 있습니다.
이 글에서는 SettingWithCopyWarning이 왜 발생하는지(뷰/복사 문제), 어떤 코드가 위험한지, 그리고 안전하고 예측 가능한 대입 패턴을 정리합니다. 경고를 “끄는 법”이 아니라, 경고가 의미하는 모호함을 제거하는 법이 목표입니다.
SettingWithCopyWarning의 핵심: 뷰(view) vs 복사(copy) 모호성
pandas의 DataFrame/Series는 내부적으로 NumPy 배열을 기반으로 합니다. 어떤 연산은 원본 데이터의 뷰(view) 를 반환하고, 어떤 연산은 복사(copy) 를 반환합니다. 문제는 pandas의 여러 인덱싱/슬라이싱 조합에서 “이 결과가 뷰인지 복사인지”가 항상 명확하지 않다는 점입니다.
- 뷰: 원본과 같은 메모리를 바라봄 → 뷰에 대입하면 원본이 바뀔 수 있음
- 복사: 별도 메모리 → 복사본에 대입해도 원본은 안 바뀜
pandas는 사용자가 df_filtered["col"] = ... 같은 대입을 했을 때, 그 df_filtered가 뷰인지 복사인지 확신할 수 없으면 SettingWithCopyWarning을 띄웁니다.
경고 재현: “체이닝 인덱싱(Chained indexing)”이 가장 위험하다
SettingWithCopyWarning의 대표적인 원인은 체이닝 인덱싱입니다. 즉, 인덱싱을 두 번 연달아 하면서 마지막에 대입하는 패턴입니다.
import pandas as pd
df = pd.DataFrame({
"user": ["a", "b", "c", "d"],
"score": [10, 20, 30, 40],
"active": [True, False, True, False],
})
# 체이닝 인덱싱: 1) 행 필터링 2) 컬럼 선택 후 대입
# 경고가 발생할 수 있고, 대입이 원본 df에 반영되지 않을 수 있음
df[df["active"]]["score"] = 0
이 코드는 “active==True인 행의 score를 0으로 만들고 싶다”는 의도지만, 실제로는 다음 문제가 생깁니다.
df[df["active"]]가 뷰인지 복사인지 불명확- 그 결과에서
["score"]를 골라 대입 → pandas가 모호함을 경고
정답 패턴 1: .loc로 한 번에 선택하고 대입하라
행/열 선택과 대입을 한 문장으로 끝내면 모호함이 사라집니다.
mask = df["active"]
df.loc[mask, "score"] = 0
.loc[행조건, 열]은 “원본 df의 특정 위치”를 명확히 가리킵니다.- SettingWithCopyWarning을 피하는 가장 표준적인 방법입니다.
여러 컬럼을 동시에 바꾸는 것도 동일합니다.
df.loc[df["active"], ["score", "user"]] = [0, "ACTIVE"]
값이 행마다 달라야 하면 np.where나 Series를 사용합니다.
import numpy as np
df["grade"] = np.where(df["score"] >= 30, "A", "B")
정답 패턴 2: 필터링 결과를 “독립된 데이터”로 쓸 거면 .copy()를 명시하라
필터링한 결과를 원본과 분리된 별도 DataFrame으로 다루려면, 애초에 복사본임을 명시해서 경고의 원인을 제거해야 합니다.
filtered = df[df["active"]].copy() # 명시적 복사
filtered["score"] = filtered["score"] * 2
이때 핵심은 “원본 df를 바꾸려는 목적이 아니라면” .copy()가 오히려 안전하다는 점입니다.
- 원본을 바꿀 목적:
.loc로 원본에 직접 대입 - 원본과 분리된 파생 데이터가 목적:
.copy()후 파생 데이터에 대입
정답 패턴 3: .assign()으로 파이프라인을 안전하게 구성하라
.assign()은 새로운 DataFrame을 반환하는 방식이라, 체이닝 인덱싱 경고를 피하면서도 메서드 체이닝을 깔끔하게 유지할 수 있습니다.
result = (
df
.query("active == True")
.assign(score=lambda x: x["score"] * 2)
)
query()는 필터링assign()은 “새 컬럼 생성/변경”을 함수형 스타일로 적용- 원본 df는 그대로 두고 결과를 얻는 전처리 파이프라인에 적합
대규모 파이프라인에서 예외/타임아웃/재시도 같은 운영 이슈까지 고려한다면, 데이터 변환은 가능하면 부수효과(side effect) 를 줄이는 게 좋습니다. 네트워크 호출을 포함한 ETL에서 재시도 설계가 중요한 것처럼, 데이터프레임 변환도 “한 번 더 실행해도 같은 결과(멱등성)”를 유지하는 편이 안전합니다. 관련해서는 Python httpx ReadTimeout·ConnectError 재시도 설계도 함께 참고할 만합니다.
위험 패턴 정리: 이런 코드는 경고가 없어도 피하자
1) 체이닝 인덱싱 대입
# 위험
subset = df[df["active"]]
subset["score"] = 0
경고가 뜨는 대표 케이스입니다. 의도가 “원본 변경”이면 .loc로 바꾸고, 의도가 “부분집합 가공”이면 .copy()를 붙이세요.
2) 컬럼을 뽑아온 뒤 다시 필터링해서 대입
# 위험
s = df["score"]
s[df["active"]] = 0
Series s가 원본과 어떤 관계인지(뷰/복사)가 상황에 따라 달라질 수 있습니다. 아래처럼 원본에 직접 대입하는 편이 명확합니다.
# 안전
df.loc[df["active"], "score"] = 0
3) inplace 연산 남용
inplace=True는 직관적으로 “원본을 바꾼다”처럼 보이지만, pandas에서는 점점 권장되지 않는 방향이고(일부 API에서 제거/변경 논의가 계속됨), 중간 객체에 대해 inplace를 쓰면 더 헷갈릴 수 있습니다.
- 가급적 “원본에
.loc로 대입” 또는 “새 객체를 반환받는 함수형 스타일” 중 하나로 통일하세요.
실전 디버깅: 경고가 떴을 때 확인할 체크리스트
1) 내가 바꾸려는 대상이 “원본 df”인가, “부분집합 df”인가?
- 원본 df를 바꾸려는 것:
.loc로 원본에 직접 대입 - 부분집합을 가공하려는 것:
.copy()로 독립 객체를 만든 뒤 대입
이 질문 하나로 대부분의 SettingWithCopyWarning은 해결됩니다.
2) 대입 코드가 “한 문장으로 목적 위치를 지정”하는가?
다음 형태를 목표로 하세요.
# 목표 형태
# df.loc[행조건, 열] = 값
3) 경고를 “숨기지 말고”, 원인을 제거하라
아래처럼 옵션으로 경고를 끄는 건 문제를 숨길 뿐입니다.
pd.options.mode.chained_assignment = None # 비권장: 경고 숨김
운영 환경에서 경고가 쌓이는 것은 관측성(Observability) 관점에서 중요한 신호입니다. 애플리케이션의 타임아웃을 경고 없이 숨기면 장애를 키우는 것과 비슷합니다. (예: Gunicorn Uvicorn Worker timeout 재현과 해결처럼 “증상을 재현하고 원인을 제거하는 접근”이 장기적으로 안전합니다.)
케이스별 안전한 대입 레시피
레시피 A: 조건에 맞는 행의 특정 컬럼 업데이트
mask = (df["score"] < 20) & (df["active"])
df.loc[mask, "score"] = 20
레시피 B: 조건에 맞는 행의 여러 컬럼 업데이트
mask = df["active"]
df.loc[mask, ["score", "user"]] = [999, "vip"]
레시피 C: 파생 컬럼 생성(원본 유지)
out = df.assign(
score_norm=lambda x: (x["score"] - x["score"].mean()) / x["score"].std()
)
레시피 D: 부분집합을 따로 가공(원본과 분리)
active_users = df.loc[df["active"], ["user", "score"]].copy()
active_users["score"] = active_users["score"] + 100
여기서 .loc[..., ...].copy() 조합은 “필터링 + 필요한 컬럼만 추출 + 독립 객체 생성”을 한 번에 명확히 표현합니다.
요약: SettingWithCopyWarning을 ‘없애는’ 가장 좋은 방법
- 체이닝 인덱싱으로 대입하지 말 것:
df[cond][col] = ...금지 - 원본을 바꿀 거면:
df.loc[cond, col] = ...로 한 번에 대입 - 부분집합을 가공할 거면:
filtered = df[cond].copy()로 의도를 명시 - 파이프라인형 변환은:
.assign()/.query()로 “새 객체 반환” 스타일을 선호
SettingWithCopyWarning은 귀찮은 경고가 아니라, pandas가 “지금 코드가 데이터 정합성을 깨뜨릴 수 있다”고 알려주는 안전장치입니다. 경고를 억지로 끄기보다, 뷰/복사 모호성을 제거하는 코드 스타일로 바꾸면 전처리 품질과 디버깅 난이도가 함께 좋아집니다.