Published on

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

Authors

서브셋 DataFrame에 값을 넣었는데 경고가 뜨고, 어떤 때는 값이 반영되고 어떤 때는 반영되지 않는 경험이 있다면 SettingWithCopyWarning을 정확히 이해해야 합니다. 이 경고는 “지금 수정한 대상이 원본의 뷰(view) 일 수도 있고 복사(copy) 일 수도 있어서, 당신이 기대한 대로 원본이 바뀐다는 보장이 없다”는 신호입니다.

Pandas는 성능을 위해 슬라이싱 결과를 항상 복사로 만들지 않습니다. 어떤 연산은 뷰를 반환할 수 있고(특히 열/행 슬라이싱, 체인 인덱싱), 어떤 연산은 복사를 반환합니다. 그런데 사용자가 그 결과에 다시 값을 대입하면, Pandas 입장에서는 원본을 바꿔야 하는지, 임시 객체를 바꿔야 하는지 확신하기 어렵습니다. 그래서 “확실한 방식으로 쓰라”는 의미로 경고를 띄웁니다.

이 글에서는 경고가 생기는 핵심 원리, 자주 나오는 재현 코드, 그리고 실무에서 안전하게 없애는 패턴(.loc, .copy, .assign, .where 등)을 정리합니다. 디버깅 체크리스트 관점은 다른 글의 방식과 비슷하게, 원인→증상→확실한 해결책 순서로 가져가겠습니다. (참고: 장애/경고를 “재현 → 원인 격리 → 안전한 수정”으로 푸는 접근은 systemd 서비스가 반복 재시작될 때 원인 추적법 같은 글과도 결이 같습니다.)

SettingWithCopyWarning이 정확히 의미하는 것

경고 메시지의 요지

대표 메시지는 다음과 같습니다.

A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

핵심은 두 가지입니다.

  1. df[...] 같은 슬라이싱 결과가 원본의 뷰일 수도 있고 복사본일 수도 있다.
  2. 그 결과에 다시 [...] = ... 형태로 값을 대입하면, 원본에 반영된다는 보장이 없다.

왜 “체인 인덱싱(chained indexing)”이 특히 위험한가

체인 인덱싱은 보통 이런 형태입니다.

# 나쁜 예: 체인 인덱싱
subset = df[df["country"] == "KR"]
subset["age"] = subset["age"].fillna(0)

df[df[...]]가 이미 “중간 객체”를 만들었고, 거기에 다시 subset["age"] = ...를 하니 Pandas는 원본과의 연결성을 확신할 수 없습니다.

가장 흔한 재현 패턴 5가지

아래 예제는 모두 경고가 나거나, 더 위험하게는 “경고 없이도 의도와 다르게 동작”할 수 있는 패턴입니다.

1) boolean filter 후 컬럼 대입

import pandas as pd

df = pd.DataFrame({
    "country": ["KR", "US", "KR"],
    "score": [10, 20, 30]
})

kr = df[df["country"] == "KR"]
kr["score"] = kr["score"] * 2  # SettingWithCopyWarning 가능

2) 슬라이싱 후 컬럼 대입

part = df[:2]
part["score"] = 0  # SettingWithCopyWarning 가능

3) 열 선택 후 조건 대입(또는 그 반대)

# 특히 이런 형태가 자주 문제를 만듭니다.
df[df["country"] == "KR"]["score"] = 999  # 거의 확정적으로 위험

위 코드는 “필터링 결과에서 score만 선택해서 대입”이라는 두 단계가 체인으로 이어져, 원본 df에 반영되지 않을 수 있습니다.

4) inplace 연산과 결합

kr = df[df["country"] == "KR"]
kr["score"].replace(10, 11, inplace=True)  # 경고/비권장 패턴

Pandas에서 inplace=True는 점점 권장되지 않는 흐름이며(특히 중간 객체에서), SettingWithCopyWarning과 결합하면 디버깅이 더 어려워집니다.

5) 함수 체이닝 중 중간 슬라이스 수정

(
    df[df["country"] == "KR"]
      .assign(score=lambda x: x["score"] * 2)  # assign은 안전한 편
)

이 경우는 assign을 쓰면 안전하지만, 중간에 [...] = ...로 끼워 넣으면 다시 위험해집니다.

해결의 정석: “원본에 .loc로 한 번에 쓰기”

SettingWithCopyWarning을 가장 확실하게 없애는 방법은 원본 DataFrame에 대해 .loc[행조건, 열] = 값 형태로 한 번에 대입하는 것입니다.

boolean 조건에 대한 안전한 대입

mask = df["country"] == "KR"
df.loc[mask, "score"] = df.loc[mask, "score"] * 2
  • mask로 행을 고르고
  • 같은 mask를 사용해 원본 df에서 값을 읽고
  • 원본 df에 다시 씁니다.

이 방식은 “원본을 수정한다”는 의도가 명확해서 경고가 사라지고, 동작도 결정적입니다.

여러 컬럼을 동시에 수정

mask = df["country"] == "KR"
df.loc[mask, ["score"]] = df.loc[mask, ["score"]].mul(2)

또는 상수 대입이라면 더 간단합니다.

df.loc[df["country"] == "KR", "score"] = 0

“부분집합을 따로 쓰고 싶다”면 .copy()로 의도를 고정

원본을 수정하는 게 아니라, 필터링 결과를 독립된 데이터로 다루고 싶다면 .copy()를 붙여 “이건 복사본이다”를 명시하세요.

kr = df.loc[df["country"] == "KR"].copy()
kr["score"] = kr["score"] * 2  # 경고 없음 (독립 복사본)

여기서 중요한 포인트:

  • .copy()를 하지 않으면 kr이 뷰인지 복사인지 애매할 수 있음
  • .copy()를 하면 이후 연산은 kr 내부에서만 일어나며, 원본 df는 바뀌지 않음

즉, “원본을 바꾸고 싶다”면 .loc로 원본에 쓰고, “원본과 분리하고 싶다”면 .copy()로 분리합니다.

실무에서 자주 쓰는 안전 패턴들

1) .assign()으로 새 컬럼 만들기 (함수형 스타일)

kr = (
    df.loc[df["country"] == "KR"].copy()
      .assign(score2=lambda x: x["score"] * 2)
)

assign은 “새 DataFrame을 반환”하는 스타일이라 중간 수정의 모호함이 줄어듭니다.

2) .where()/.mask()로 조건부 치환

df["score"] = df["score"].where(df["country"] != "KR", df["score"] * 2)
  • where(cond, other)는 cond가 True면 원래값 유지, False면 other로 대체
  • 조건을 반대로 쓰고 싶으면 .mask()가 직관적인 경우도 많습니다.

3) np.where로 조건부 값 생성 후 대입

import numpy as np

df["score"] = np.where(df["country"] == "KR", df["score"] * 2, df["score"])

이 방식은 “새 시리즈를 만들어 컬럼에 통째로 대입”이므로 경고가 날 여지가 적습니다.

경고를 “끄는 것”은 해결이 아니라 위험 은폐다

다음처럼 옵션으로 경고를 끌 수는 있습니다.

pd.options.mode.chained_assignment = None  # 경고 비활성화

하지만 이는 문제를 해결하지 않고 신호만 제거합니다. 원본이 실제로 수정됐는지/안 됐는지 모호한 상태가 그대로 남아, 데이터 품질 이슈로 이어질 수 있습니다.

경고를 끄는 것은 시스템에서 오류 로그를 꺼두는 것과 비슷합니다. 디버깅 관점에서는 “재현 가능한 최소 코드로 원인을 확인하고, 안전한 수정 경로로 바꾸는 것”이 정석입니다. (장애 대응에서 재시도/백오프를 설계하듯이 신호를 무시하지 않는 것이 중요합니다. 관련해서는 Anthropic Claude 429 레이트리밋 재시도 설계법도 참고할 만합니다.)

디버깅 체크리스트: 경고가 났을 때 무엇을 확인할까

1) “원본을 바꾸려는가, 복사본을 만들려는가”를 먼저 결정

  • 원본 변경 목적: .loc[mask, col] = ...
  • 복사본 목적: .loc[mask].copy() 후 수정

이 결정을 못 하면 코드가 흔들립니다.

2) 체인 인덱싱을 한 번에 펼치기

# 나쁜 예
# df[df['A'] > 0]['B'] = 1

# 좋은 예
mask = df["A"] > 0
df.loc[mask, "B"] = 1

3) 중간 변수로 받은 DataFrame이 어디서 왔는지 추적

  • subset = df[cond] 형태면 의심
  • subset = df.loc[cond].copy()면 안전

4) inplace 사용을 줄이기

  • 특히 subset["col"].method(..., inplace=True)는 피하기
  • 반환값을 다시 받는 형태로 변경
subset["col"] = subset["col"].replace({"": None})

자주 묻는 질문(실무형)

Q1. .loc를 썼는데도 경고가 날 수 있나?

대부분은 .loc로 해결되지만, 여전히 “중간 객체”에 .loc를 쓰면 애매해질 수 있습니다.

subset = df[df["country"] == "KR"]
subset.loc[:, "score"] = 0  # subset 자체가 뷰/복사 불명확

이 경우도 정석은 둘 중 하나입니다.

  • 원본에 직접 쓰기: df.loc[mask, "score"] = 0
  • 복사본을 명시: subset = df.loc[mask].copy()

Q2. 성능 때문에 .copy()가 부담되면?

  • 원본을 수정해도 된다면 .copy()가 아니라 .loc로 원본에 직접 쓰는 게 오히려 효율적입니다.
  • 복사본이 필요하다면, 필요한 컬럼만 선택해서 복사 크기를 줄이세요.
cols = ["country", "score"]
kr = df.loc[df["country"] == "KR", cols].copy()

Q3. 경고가 안 떴는데도 위험할 수 있나?

네. 경고는 “항상” 뜨는 게 아니라, Pandas 내부 판단에 따라 뜨거나 안 뜰 수 있습니다. 그래서 경고 유무보다 코딩 패턴 자체를 안전하게 바꾸는 게 중요합니다.

결론: 한 문장으로 정리

  • SettingWithCopyWarning은 “슬라이스 결과가 뷰인지 복사인지 애매한데 거기에 값을 써서, 원본 반영이 보장되지 않는다”는 경고입니다.
  • 해결은 단순합니다: 원본을 바꾸려면 .loc로 한 번에, 부분집합을 따로 쓰려면 .copy()로 의도를 고정하세요.

데이터 전처리 파이프라인에서 이 경고를 방치하면, 조용히 값이 누락되거나 일부만 바뀌는 형태로 품질 문제가 누적됩니다. 경고를 끄기보다, 위의 패턴으로 코드를 정리해 “결정적(deterministic)인 대입”을 만드는 것이 가장 확실한 해결입니다.