- Published on
pandas SettingWithCopyWarning 0으로 만드는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서브셋을 만든 뒤 컬럼을 수정했는데 SettingWithCopyWarning이 뜨면, 대부분의 개발자는 일단 경고를 끄고 넘어가고 싶어집니다. 하지만 이 경고는 단순한 소음이 아니라 내가 수정한 값이 원본 DataFrame에 반영될 수도, 안 될 수도 있는 애매한 상태를 알려주는 신호입니다. 즉 “경고를 0으로 만든다”는 말은 경고를 억지로 숨기는 것이 아니라, 애매함을 제거해 경고가 발생하지 않는 코드로 바꾸는 것에 가깝습니다.
이 글에서는 SettingWithCopyWarning이 왜 생기는지, 어떤 패턴이 위험한지, 그리고 실무에서 경고를 0으로 만드는 가장 안전한 리팩터링 방법을 정리합니다.
SettingWithCopyWarning의 정체: 뷰(view) vs 복사(copy)
pandas에서 df[...]로 일부 행/열을 선택하면 그 결과가 원본의 뷰일 수도 있고 복사본일 수도 있습니다. 문제는 이 둘이 항상 명확히 구분되지 않는다는 점입니다.
- 뷰(view): 원본 데이터의 “창” 같은 것. 뷰를 수정하면 원본이 바뀔 수 있습니다.
- 복사(copy): 독립적인 사본. 복사본을 수정해도 원본은 바뀌지 않습니다.
SettingWithCopyWarning은 “지금 수정하려는 대상이 뷰인지 복사본인지 확실치 않다”는 상황에서 자주 발생합니다. 특히 체이닝 인덱싱(chained indexing) 이 대표적인 트리거입니다.
경고를 만드는 대표 패턴: 체이닝 인덱싱
다음 코드는 경고의 교과서입니다.
import pandas as pd
df = pd.DataFrame({
"user": ["a", "b", "c"],
"score": [10, 60, 80],
"grade": [None, None, None],
})
# 나쁜 예: 체이닝 인덱싱
high = df[df["score"] >= 60]
high["grade"] = "A" # SettingWithCopyWarning 가능
high = df[df["score"] >= 60]에서 high가 뷰인지 복사본인지 확실치 않은데, 그 다음 줄에서 high["grade"] = ...로 값을 설정하려 하니 경고가 뜹니다.
목표: 경고를 0으로 만드는 3가지 원칙
실무에서 가장 확실하게 SettingWithCopyWarning을 0으로 만드는 방법은 아래 원칙을 지키는 것입니다.
- 원본을 수정할 거면 한 번에
loc로 끝낸다 - 파생
DataFrame을 수정할 거면copy()로 의도를 고정한다 - 가능하면
assign/where/mask같은 함수형 스타일로 체이닝을 줄인다
아래에서 각각을 실전 코드로 정리합니다.
1) 원본을 수정할 거면 loc로 한 번에 끝내기
원본 df에 반영하고 싶은 변경이라면, 부분집합을 따로 변수로 빼서 수정하지 말고 원본에 대해 loc[row_selector, col_selector] = value 형태로 한 번에 끝내는 것이 가장 안전합니다.
import pandas as pd
df = pd.DataFrame({
"user": ["a", "b", "c"],
"score": [10, 60, 80],
"grade": [None, None, None],
})
mask = df["score"] >= 60
df.loc[mask, "grade"] = "A" # 경고 없음, 의도 명확
여러 컬럼을 동시에 세팅하기
mask = df["score"] >= 60
df.loc[mask, ["grade", "user"]] = ["A", "VIP"]
단, 위처럼 서로 다른 타입/의미의 값을 한 번에 넣을 때는 브로드캐스팅 규칙 때문에 의도치 않은 결과가 나올 수 있어, 보통은 컬럼별로 명시하는 편이 안전합니다.
df.loc[mask, "grade"] = "A"
df.loc[mask, "user"] = "VIP"
loc로도 경고가 날 수 있나?
대부분의 경우 loc는 안전합니다. 하지만 df 자체가 이미 어떤 연산의 결과로 만들어진 애매한 객체라면(예: 다른 DataFrame에서 슬라이싱된 결과를 다시 참조) 경고가 이어질 수 있습니다. 이때는 “수정 대상이 되는 최종 DataFrame”을 명확히 하거나, 수정 대상이 파생 객체라면 다음 섹션의 copy() 원칙을 적용하세요.
2) 파생 DataFrame을 수정할 거면 copy()로 의도 고정하기
원본을 바꾸고 싶은 게 아니라, “필터링된 결과를 별도 테이블로 만들어 가공”하려는 경우가 많습니다. 이때는 애초에 복사본임을 명시해야 경고가 사라지고, 코드의 의미도 명확해집니다.
high = df.loc[df["score"] >= 60, ["user", "score"]].copy()
high["grade"] = "A" # 경고 없음
핵심은 두 가지입니다.
- 필터링은 가능하면
df[...]대신df.loc[...]로 명확히 하기 - 결과를 수정할 거라면
.copy()로 복사본임을 확정하기
copy(deep=True)가 필요할까?
대부분의 일반적인 컬럼(숫자/문자)에서는 .copy()로 충분합니다. 다만 컬럼에 리스트/딕셔너리 같은 가변 객체가 들어 있고, 내부 객체까지 완전 복제를 기대한다면 별도 처리가 필요합니다. pandas의 깊은 복사는 파이썬 객체 내부까지 완전 복제하는 의미와는 다를 수 있으니, 가변 객체 컬럼을 다루는 설계 자체를 재검토하는 편이 좋습니다.
3) 함수형 스타일로 체이닝을 줄이기: assign, where, mask
체이닝 인덱싱은 “중간 결과를 만들고 거기에 대입”할 때 자주 생깁니다. 이를 줄이려면 대입을 직접 하지 않고, “새 컬럼을 만들어 반환”하는 스타일이 도움이 됩니다.
assign으로 새 컬럼 생성
import numpy as np
result = (
df
.assign(grade=np.where(df["score"] >= 60, "A", "F"))
)
이 방식은 원본을 건드리지 않고 새 DataFrame을 만들기 때문에, 뷰/복사 애매함이 줄어듭니다.
mask/where로 조건부 치환
result = df.copy()
result["grade"] = "F"
result["grade"] = result["grade"].mask(result["score"] >= 60, "A")
혹은 한 줄로도 가능합니다.
result = df.assign(
grade=pd.Series("F", index=df.index).mask(df["score"] >= 60, "A")
)
실무에서 자주 터지는 케이스별 처방전
1) 컬럼 일부만 뽑은 뒤 수정
sub = df[["user", "score"]]
sub["score"] = sub["score"] * 2 # 경고 가능
해결(복사본으로 고정):
sub = df.loc[:, ["user", "score"]].copy()
sub["score"] = sub["score"] * 2
해결(원본 수정 의도라면 loc):
df.loc[:, "score"] = df["score"] * 2
2) 필터링 후 inplace 계열 메서드 사용
inplace=True는 특히 “이게 원본에 적용되는가?”를 더 헷갈리게 만드는 경우가 많습니다.
sub = df[df["score"] >= 60]
sub.rename(columns={"score": "s"}, inplace=True) # 경고/비권장
해결:
sub = df.loc[df["score"] >= 60].copy()
sub = sub.rename(columns={"score": "s"})
3) 정렬/리셋 인덱스 후 특정 행 업데이트
sub = df[df["score"] >= 60].sort_values("score")
sub.iloc[0, sub.columns.get_loc("grade")] = "B" # 경고 가능
해결:
sub = df.loc[df["score"] >= 60].copy()
sub = sub.sort_values("score")
sub.iloc[0, sub.columns.get_loc("grade")] = "B"
또는 원본 반영이 목적이면 “원본에서의 인덱스”를 기준으로 loc 업데이트가 더 안전합니다.
“경고를 끄면 되지 않나?”: 0으로 만드는 것과 숨기는 것의 차이
아래처럼 옵션으로 경고를 끄는 방법이 널리 알려져 있습니다.
import pandas as pd
pd.options.mode.chained_assignment = None # 경고 숨김
하지만 이건 경고를 0으로 만든 게 아니라, 경고를 보지 않게 만든 것입니다. 실제로는 여전히 “원본이 바뀌었는지/안 바뀌었는지”가 불명확할 수 있고, 데이터 파이프라인에서 조용히 잘못된 결과를 만들 위험이 있습니다.
특히 팀 단위 분석 코드나 배치 작업에서는 이런 류의 “조용한 실패”가 가장 비용이 큽니다. 비슷한 맥락으로, 장애를 뿌리부터 제거하기 위해 체크리스트 기반으로 원인을 제거하는 접근이 중요합니다. 예를 들어 재시도/중복 실행 문제를 설정으로 덮지 않고 원인을 추적하는 방식은 Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트 같은 글의 접근과도 닮아 있습니다.
디버깅 팁: 경고가 난 코드가 “원본을 바꿨는지” 확인하기
경고가 난다고 항상 원본이 안 바뀌는 것은 아닙니다. 그래서 더 위험합니다. 아래처럼 재현 코드를 만들어 “원본 반영 여부”를 확인해보면 문제를 빠르게 감지할 수 있습니다.
import pandas as pd
df = pd.DataFrame({"x": [1, 2, 3], "y": [10, 20, 30]})
sub = df[df["x"] >= 2]
sub["y"] = 999 # 경고 가능
print("sub:\n", sub)
print("df:\n", df)
이 출력에서 df의 y가 바뀌었는지 여부는 상황에 따라 달라질 수 있으며, 바로 그 불확실성을 제거하는 게 목표입니다.
권장 리팩터링 패턴 요약(체크리스트)
- 원본을 업데이트해야 한다
df.loc[조건, "col"] = 값으로 끝내기- 체이닝 인덱싱 금지:
df[cond]["col"] = ...형태 제거
- 파생 테이블을 만들어 가공해야 한다
sub = df.loc[조건, cols].copy()로 시작- 그 다음
sub[...] = ...는 OK
inplace=True는 가급적 피하기- 반환값을 다시 변수에 할당해 흐름을 명확히 하기
- 경고 옵션으로 숨기지 말기
pd.options.mode.chained_assignment = None는 최후의 수단(그리고 보통은 비추천)
결론: SettingWithCopyWarning을 0으로 만드는 가장 현실적인 해법
SettingWithCopyWarning을 없애는 가장 좋은 방법은 “경고를 끄는 설정”이 아니라, 수정의 대상이 원본인지 복사본인지 의도를 코드로 고정하는 것입니다.
- 원본 수정이면
loc로 직접 대입 - 파생 가공이면
.copy()로 독립성을 확보 - 체이닝 인덱싱을 제거하고
assign/mask같은 표현으로 흐름을 단순화
이 3가지만 습관화해도 경고는 사실상 0에 수렴하고, 더 중요한 건 데이터가 조용히 틀어지는 리스크를 크게 줄일 수 있습니다.
추가로, 파이썬 런타임에서 “겉으로는 돌아가는데 특정 조건에서만 깨지는” 문제를 체크리스트로 정리해 해결하는 방식이 필요하다면 Python asyncio RuntimeError - Event loop is closed 해결도 함께 참고하면, 원인-증상-재현-해결의 글 구성 관점에서 도움이 될 수 있습니다.