Published on

pandas SettingWithCopyWarning 완전 해결 가이드

Authors

서브셋에 값을 대입했는데 경고가 뜨는 순간, 많은 사람이 pd.options.mode.chained_assignment = None 같은 설정으로 “조용히” 만들고 끝냅니다. 하지만 SettingWithCopyWarning은 단순 소음이 아니라 내가 수정했다고 믿는 값이 실제 원본에 반영되지 않을 수 있음을 알려주는 신호입니다. 특히 전처리 파이프라인에서 이 경고를 무시하면, 모델 입력이 조용히 틀어져도 발견이 늦어집니다.

이 글에서는 경고가 왜 생기는지, 무엇이 위험한지, 그리고 실무에서 확실하게 없애는 패턴을 코드로 정리합니다.

SettingWithCopyWarning의 정체: view vs copy + chained assignment

pandas에서 df[...]로 슬라이싱한 결과는 상황에 따라 원본을 바라보는 뷰(view) 일 수도 있고 새 복사본(copy) 일 수도 있습니다. 문제는 사용자가 이를 직관적으로 알기 어렵다는 점입니다.

SettingWithCopyWarning은 주로 아래 형태의 chained assignment(연쇄 인덱싱) 에서 발생합니다.

  • df[df["col"] == 1]["x"] = 0
  • df[mask]["x"] = 0

이 코드는 “필터링한 다음 컬럼 선택 후 대입”처럼 보이지만, pandas 내부에서는 중간 객체가 뷰인지 복사본인지 불분명합니다. 그래서 pandas는 경고로 알려줍니다.

재현 코드: 값이 바뀌는 듯 보이지만 안 바뀔 수 있음

import pandas as pd

pd.set_option("mode.chained_assignment", "warn")

df = pd.DataFrame({
    "group": ["A", "A", "B", "B"],
    "score": [10, 20, 30, 40]
})

# 문제 패턴: chained assignment
mask = df["group"] == "A"
df[mask]["score"] = 999  # SettingWithCopyWarning 가능

print(df)

여기서 핵심은 경고가 뜨는 것 자체보다 결과가 불확실하다는 점입니다. 어떤 경우에는 바뀐 것처럼 보였다가, 다른 연산을 끼우면 사라지기도 합니다.

완전 해결 1: 조건부 대입은 무조건 loc로 끝낸다

가장 안전하고 권장되는 정답은 loc를 사용하는 것입니다. 조건 필터링과 컬럼 선택을 한 번에 표현하면 pandas가 의도를 명확히 이해합니다.

mask = df["group"] == "A"
df.loc[mask, "score"] = 999
  • df.loc[행조건, "컬럼"] = 값 형태는 경고를 없애는 것뿐 아니라
  • “원본 df의 특정 위치를 수정한다”는 의도가 코드에 드러나 유지보수성이 좋아집니다.

여러 컬럼 동시 대입

mask = df["group"] == "A"
df.loc[mask, ["score"]] = 999

혹은 서로 다른 값을 넣고 싶다면:

mask = df["group"] == "A"
df.loc[mask, "score"] = df.loc[mask, "score"] + 1

완전 해결 2: 서브셋을 ‘새 데이터프레임’으로 쓸 거면 copy()를 명시

경고가 뜨는 상황 중 상당수는 “원본을 수정하려는 게 아니라, 필터된 결과를 별도 테이블로 만들어 가공하려는” 케이스입니다. 이때는 애초에 복사본임을 명시하면 됩니다.

subset = df[df["group"] == "A"].copy()
subset["score"] = subset["score"] * 10

이 패턴의 장점:

  • subset이 원본과 독립적이라는 사실이 명확
  • 이후 어떤 대입을 해도 경고가 나지 않음
  • 실무에서 “원본 보존” 요구사항을 만족하기 쉬움

단, copy()는 메모리를 더 씁니다. 대규모 데이터에서는 필요할 때만 쓰고, 원본을 수정하는 목적이라면 loc가 더 적합합니다.

완전 해결 3: assign, where, mask로 대입을 ‘표현식’으로 만들기

직접 대입 대신, “새 컬럼을 계산해 반환”하는 함수형 스타일을 쓰면 경고를 피하면서 파이프라인이 깔끔해집니다.

assign 사용

df2 = df.assign(
    score=lambda x: x["score"].where(x["group"] != "A", 999)
)

mask 사용

df["score"] = df["score"].mask(df["group"] == "A", 999)

이 방식은 특히 여러 조건이 겹치는 전처리에서 “대입 순서”를 줄이고, 테스트하기 쉬운 형태로 만들 수 있습니다.

자주 터지는 함정 5가지와 정답 패턴

1) 컬럼을 먼저 뽑아놓고 필터링 후 대입

s = df["score"]
s[df["group"] == "A"] = 999  # 경고 가능

정답:

df.loc[df["group"] == "A", "score"] = 999

2) query() 결과에 바로 대입

df.query("group == 'A'")["score"] = 999  # 경고 가능

정답:

idx = df.query("group == 'A'").index
df.loc[idx, "score"] = 999

3) dropna()나 정렬 결과에 바로 대입

df.dropna()["score"] = 0  # 경고 가능

정답:

clean = df.dropna().copy()
clean["score"] = 0

또는 원본을 수정하려면 인덱스를 잡아서 loc로:

idx = df.dropna().index
df.loc[idx, "score"] = 0

4) groupby() 후 일부만 수정하려고 할 때

groupby 결과는 대개 원본에 대한 “수정 뷰”가 아닙니다. 보통은 집계/변환을 만들어서 합치는 방식이 안전합니다.

정답 예:

mean_by_group = df.groupby("group")["score"].transform("mean")
df["score_centered"] = df["score"] - mean_by_group

5) 체인으로 보기 좋게 쓴 코드가 경고를 만든다

(
  df[df["group"] == "A"]
    .sort_values("score")
)["score"] = 0  # 경고 가능

정답: 중간 결과를 명확히 분리해서 copy() 또는 loc.

mask = df["group"] == "A"
df.loc[mask, "score"] = 0

“경고를 끄면 되지 않나?”에 대한 실무적 답

pd.options.mode.chained_assignment = None 또는 "warn"None으로 바꾸면 경고는 사라집니다. 하지만 문제는 사라지지 않습니다.

  • 경고는 “지금 수정이 원본에 반영된다는 보장이 없다”는 시그널
  • 경고를 끄면, 데이터 품질 이슈가 조용히 누적

경고를 끄는 건, 예를 들어 네트워크 장애에서 알람을 끄는 것과 비슷합니다. 원인 제거가 먼저입니다. 장애 원인을 빠르게 좁히는 접근은 인프라 쪽에서도 동일한데, 예를 들어 systemd 서비스 재시작 루프 10분 진단 가이드처럼 “증상 숨기기”보다 “재현과 원인 분리”가 중요합니다.

디버깅: 내 코드가 view를 만들었는지 확인하는 방법

pandas 내부 동작은 케이스마다 달라 “무조건 view다/무조건 copy다”라고 말하기 어렵습니다. 대신 다음 전략이 효과적입니다.

1) 경고를 에러로 바꿔서 발생 지점을 고정

테스트나 노트북에서 원인을 빨리 찾고 싶다면, 경고를 예외로 올려버리면 됩니다.

import pandas as pd

pd.set_option("mode.chained_assignment", "raise")

# 여기서 SettingWithCopyWarning이 발생하면 예외로 터져서
# 스택트레이스로 정확한 라인을 잡을 수 있습니다.

원인 라인을 잡은 뒤에는 loc 또는 copy()로 고치고, 운영 코드에서는 다시 "warn"로 되돌리는 편이 일반적입니다.

2) “내가 바꾸려는 대상”이 원본인지 명확히 설계

  • 원본을 수정하려면: df.loc[...] = ...
  • 파생 테이블이면: subset = ... .copy()subset[...] = ...

이 두 갈래로만 코드를 구성하면, 경고는 사실상 사라집니다.

실전 리팩터링 예시: 나쁜 코드에서 안전한 코드로

케이스: 특정 조건에서 가격을 보정하고, 할인 플래그를 세팅

나쁜 코드:

mask = df["country"] == "KR"
df[mask]["price"] = df[mask]["price"] * 1.1

df[df["price"] > 100]["is_premium"] = True

안전한 코드:

mask_kr = df["country"] == "KR"
df.loc[mask_kr, "price"] = df.loc[mask_kr, "price"] * 1.1

mask_premium = df["price"] > 100
df.loc[mask_premium, "is_premium"] = True

df["is_premium"] = df["is_premium"].fillna(False)

이렇게 바꾸면:

  • 경고가 사라지고
  • 수정 대상이 명확하며
  • 중간 객체 생성에 따른 불확실성이 제거됩니다.

체크리스트: SettingWithCopyWarning을 “완전히” 없애는 규칙

  1. df[mask][col] = ... 형태는 금지하고 df.loc[mask, col] = ...로 통일
  2. 필터 결과를 독립적으로 가공할 거면 .copy()를 즉시 호출
  3. 파이프라인을 선호하면 assign, where, mask로 “대입을 표현식화”
  4. 디버깅 시에는 pd.set_option("mode.chained_assignment", "raise")로 라인을 고정
  5. 경고를 끄는 설정은 최후의 수단이며, 원칙적으로는 리팩터링으로 제거

데이터 전처리에서 이런 “경고 기반 버그”는 프론트 성능 문제의 원인 추적과도 결이 같습니다. 겉으로는 정상처럼 보여도 내부에서 누적되는 비용이 커지기 때문입니다. 원인을 쪼개서 확인하는 접근은 Chrome INP 급등 원인 - Long Task 분해 실전 같은 글에서 다루는 방식과 유사합니다.

결론

SettingWithCopyWarning을 완전히 해결하는 핵심은 단 하나입니다. 내가 수정하려는 대상이 원본인지, 파생 복사본인지 코드를 통해 명시하는 것입니다.

  • 원본 수정: loc로 한 번에 지정해서 대입
  • 파생 데이터: .copy()로 독립성을 선언

이 두 규칙만 지켜도 경고는 사라지고, 더 중요한 문제인 “조용히 틀리는 데이터”를 예방할 수 있습니다.