- Published on
pandas SettingWithCopyWarning 완전정복
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
pandas를 쓰다 보면 한 번쯤은 다음 경고를 만나게 됩니다. SettingWithCopyWarning 은 “경고가 떠도 결과가 맞는 것 같던데?”라는 착각을 유발하고, 반대로 “무조건 copy()를 붙이면 되나?” 같은 과잉 방어로 성능을 망치기도 합니다. 이 글에서는 경고가 왜 뜨는지(뷰와 복사, 체이닝 인덱싱), 언제 실제 버그로 이어지는지, 그리고 팀 코드베이스에서 재발을 막는 실전 규칙까지 한 번에 정리합니다.
이미 결론만 빠르게 보고 싶다면 내부 글인 pandas SettingWithCopyWarning 완전 해결법도 함께 참고하면 좋습니다. 여기서는 “완전정복”답게 원리와 패턴을 더 넓게 다룹니다.
SettingWithCopyWarning이 말하는 것
SettingWithCopyWarning 은 pandas가 다음 상황을 의심할 때 발생합니다.
- 어떤 DataFrame에서 부분을 잘라 만든 객체가 원본의 “뷰(view)”인지 “복사(copy)”인지 확실하지 않다
- 그런데 그 잘라낸 객체에 값을 대입(set)하려고 한다
- 이 대입이 원본에 반영될 수도 있고, 안 될 수도 있다
핵심은 “경고 자체가 에러는 아니지만, 코드 의도가 불명확해서 실제 버그로 이어질 가능성이 높다”는 점입니다.
왜 뷰/복사가 애매해질까
pandas 내부는 NumPy 배열 기반이고, 슬라이싱 결과가 메모리를 공유(뷰)할 때도 있고 새로 할당(복사)될 때도 있습니다. 문제는 DataFrame의 열/행 선택, 불리언 필터링, 체이닝 인덱싱이 섞이면 이 판단이 복잡해지고, pandas는 안전을 위해 경고를 띄웁니다.
가장 흔한 원인: 체이닝 인덱싱
체이닝 인덱싱(chained indexing)은 “인덱싱을 연달아 두 번” 하는 패턴입니다. 예를 들어 아래 코드는 경고의 대표 사례입니다.
import pandas as pd
df = pd.DataFrame({
"team": ["A", "A", "B"],
"score": [10, 20, 30]
})
# 체이닝 인덱싱: df[조건][컬럼] = 값
# SettingWithCopyWarning이 뜰 가능성이 높음
df[df["team"] == "A"]["score"] = 999
이 코드는 다음 두 단계를 거칩니다.
df[df["team"] == "A"]로 부분 DataFrame을 만든다(뷰일 수도, 복사일 수도)- 그 결과에서
["score"]를 선택한 뒤 값을 대입한다
즉 “원본 df를 수정하려는 건지, 부분 결과를 수정하려는 건지”가 애매합니다.
정답 패턴: .loc로 한 번에 인덱싱
위 코드는 아래처럼 고쳐야 합니다.
mask = df["team"] == "A"
df.loc[mask, "score"] = 999
.loc[행조건, 열선택] 은 “원본 df의 특정 행/열을 지정해 수정한다”는 의도가 명확합니다. 경고도 사라지고, 실제로 원본이 수정됩니다.
경고가 진짜 버그가 되는 순간
경고를 무시했는데도 우연히 결과가 맞아 보이는 경우가 있습니다. 그러나 아래 같은 상황에서 “원본이 수정되지 않았는데 수정된 줄 알고 다음 로직을 진행”하는 버그가 터집니다.
버그 재현 예시
import pandas as pd
df = pd.DataFrame({
"user": ["u1", "u2", "u3", "u4"],
"country": ["KR", "US", "KR", "JP"],
"active": [1, 1, 0, 1]
})
kr = df[df["country"] == "KR"]
kr["active"] = 1 # 경고가 뜰 수 있고, 원본 df에 반영 안 될 수 있음
# 기대: KR은 모두 active=1
# 현실: df는 그대로일 수 있음
print(df)
이게 위험한 이유는, kr을 고쳤다고 믿고 df를 기반으로 집계/저장/머신러닝 피처 생성 등을 진행하면 데이터 품질 문제가 뒤늦게 발견되기 때문입니다.
SettingWithCopyWarning “완전 해결”을 위한 규칙
여기서부터는 실무에서 재발을 막는 규칙을 제시합니다. 핵심은 “수정은 항상 원본에 대해 .loc로 한다”입니다.
규칙 1) 필터링 후 수정하지 말고, 수정까지 한 번에
나쁜 예:
subset = df[df["score"] > 0]
subset["grade"] = "P" # 경고 가능
좋은 예:
mask = df["score"] > 0
df.loc[mask, "grade"] = "P"
규칙 2) 정말 별도 데이터가 필요하면 copy()를 “명시적으로”
필터링 결과를 원본과 독립적으로 다루고 싶다면, 애초에 복사본임을 선언해야 합니다.
subset = df.loc[df["score"] > 0, ["team", "score"]].copy()
subset["score"] = subset["score"] * 10 # 경고 없이 안전
여기서 중요한 포인트는 copy()가 “경고를 숨기는 마법”이 아니라, “의도를 코드로 고정하는 장치”라는 점입니다.
규칙 3) 열 선택은 가급적 .loc[:, cols]를 사용
특히 컬럼 리스트를 동적으로 만들거나, 후속 대입이 이어질 때 df[cols] 보다 .loc가 더 일관적입니다.
cols = ["team", "score"]
view_or_copy = df[cols]
# 이후 view_or_copy에 대입하면 경고 가능성이 커짐
safe = df.loc[:, cols].copy() # 독립적으로 쓰려면 copy까지
규칙 4) inplace=True는 경고 해결책이 아니다
inplace=True는 SettingWithCopyWarning의 근본 원인(체이닝/뷰-복사 모호성)을 해결하지 못합니다. 오히려 메서드 체이닝을 어렵게 만들어 디버깅을 방해할 때가 많습니다.
예를 들어 아래처럼 “부분 DataFrame에 대해 inplace”를 시도하면 더 혼란스러워질 수 있습니다.
subset = df[df["team"] == "A"]
subset.rename(columns={"score": "s"}, inplace=True) # 의도 불명확
대신 아래 중 하나로 의도를 분리하세요.
- 원본을 바꾸려면: 원본에 대해
.rename결과를 재할당 - 독립본이 필요하면:
.copy()후 rename
# 원본 변경
mask = df["team"] == "A"
# 열 이름 변경은 행 마스크와 무관하므로 보통은 df 전체에 적용
df = df.rename(columns={"score": "s"})
# 독립본
subset = df.loc[df["team"] == "A", :].copy()
subset = subset.rename(columns={"score": "s"})
자주 나오는 케이스별 처방전
케이스 A) 새 컬럼 만들기: assign + loc 조합
새 컬럼을 만들 때도 체이닝이 섞이면 경고가 나기 쉽습니다.
# 나쁜 예
filtered = df[df["team"] == "A"]
filtered["ratio"] = filtered["score"] / filtered["score"].sum()
원본에 컬럼을 추가하려면:
mask = df["team"] == "A"
df.loc[mask, "ratio"] = df.loc[mask, "score"] / df.loc[mask, "score"].sum()
필터링 결과만 필요하면:
filtered = df.loc[df["team"] == "A", :].copy()
filtered = filtered.assign(ratio=filtered["score"] / filtered["score"].sum())
케이스 B) 정렬/리셋 인덱스 후 수정
정렬이나 reset_index() 이후에도 “어떤 객체를 수정 중인지”가 흐려질 수 있습니다. 특히 메서드 체이닝으로 중간 결과를 바로 수정하려는 순간 위험해집니다.
# 위험한 스타일(중간 결과를 바로 수정)
# df.sort_values("score")["score"] = 0 # 체이닝 인덱싱
대신 단계 분리 또는 .loc를 사용하세요.
df = df.sort_values("score")
df.loc[:, "score"] = 0
케이스 C) 함수로 전처리할 때: 입력/출력 계약을 명확히
전처리 함수를 만들 때는 “원본을 mutate하는 함수인지, 복사본을 반환하는 함수인지”를 명확히 정하세요.
복사본 반환(권장):
def normalize_score(frame: pd.DataFrame) -> pd.DataFrame:
out = frame.copy()
out.loc[:, "score_norm"] = out["score"] / out["score"].max()
return out
원본 수정(필요할 때만):
def activate_kr_inplace(frame: pd.DataFrame) -> None:
mask = frame["country"] == "KR"
frame.loc[mask, "active"] = 1
이렇게 계약을 고정하면 팀원이 함수 내부에서 체이닝 인덱싱을 섞을 여지가 줄어듭니다.
디버깅: 경고를 “에러로 승격”해서 조기 차단
경고를 무시하면 운영/배치 환경에서 조용히 데이터가 틀어질 수 있습니다. 개발/테스트에서는 아예 실패하게 만드는 편이 안전합니다.
import pandas as pd
pd.options.mode.chained_assignment = "raise" # warn 대신 예외 발생
# 이후 체이닝 인덱싱으로 값 대입 시 예외로 즉시 발견 가능
팀 프로젝트에서는 테스트 실행 시에만 이 옵션을 켜는 방식도 좋습니다.
성능 관점: 무조건 copy()는 답이 아니다
SettingWithCopyWarning을 없애겠다고 모든 중간 결과에 copy()를 붙이면 메모리 사용량과 실행 시간이 급격히 늘 수 있습니다. 특히 수백만 행 이상 데이터에서 copy()는 곧바로 비용으로 체감됩니다.
권장 우선순위는 다음과 같습니다.
- 원본을 수정하려는 의도면
.loc[mask, col] = value로 해결 - 독립본이 필요할 때만
.copy()를 “최종 경계”에서 사용 - 파이프라인 중간중간 무분별한 복사 생성은 피하기
실전 체크리스트
df[조건][컬럼] = 값형태가 보이면 즉시.loc로 리팩터링- 필터링 결과를 “수정”해야 한다면 원본에서 수정하거나, 독립본이면
.copy()로 명시 - 전처리 함수는 “복사본 반환” 또는 “inplace 수정” 중 하나로 계약을 고정
- 테스트에서는
pd.options.mode.chained_assignment = "raise"로 조기 차단
마무리
SettingWithCopyWarning은 pandas가 친절하게 알려주는 “데이터 품질 사고 예방 알람”에 가깝습니다. 경고를 억지로 숨기기보다, 인덱싱과 대입을 한 번에 표현하는 .loc 패턴으로 의도를 명확히 하면 코드 안정성과 가독성이 동시에 올라갑니다.
데이터 파이프라인에서 경고를 방치하면, 애플리케이션에서 원인 모를 장애를 디버깅하는 것만큼 비용이 커집니다. (예를 들어 쿠버네티스 환경에서 원인 파악이 어려운 장애를 추적하는 글로는 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 참고할 만합니다.)
이제부터는 “경고가 떠도 돌아가니까 OK”가 아니라, “경고가 뜨는 코드는 의도가 불명확하니 구조를 고친다”를 팀 규칙으로 가져가면 SettingWithCopyWarning은 더 이상 공포의 대상이 아닙니다.