Published on

pandas SettingWithCopyWarning 완전 정복 - 원인·해결

Authors

서론에서 결론부터 말하면, 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"로 조기 탐지

이 원칙만 지켜도 전처리 코드의 예측 가능성이 크게 올라가고, “분명 바꿨는데 왜 안 바뀌지?” 같은 시간을 잡아먹는 디버깅을 상당 부분 없앨 수 있습니다.