Published on

판다스 SettingWithCopyWarning 완전 정복

Authors

서론부터 단도직입으로 말하면, SettingWithCopyWarning은 “경고니까 무시해도 되는 것”이 아니라 “내가 바꿨다고 믿는 값이 실제로는 안 바뀔 수 있다”는 신호입니다. 특히 실무에서는 전처리 파이프라인, 피처 엔지니어링, 리포트 집계에서 이 경고를 억지로 끄고 넘어가다가 배포 후 지표가 틀어지는 일이 자주 발생합니다.

이 글에서는 경고가 발생하는 구조적 이유(뷰 vs 복사), 실무에서 가장 많이 만나는 버그 패턴, 그리고 팀 규칙으로 정착시키기 좋은 안전한 수정법을 정리합니다.

SettingWithCopyWarning이 의미하는 것

판다스에서 df[...]로 부분 집합을 만들면 그 결과가:

  • 원본의 뷰(view) 일 수도 있고
  • 원본과 독립된 복사(copy) 일 수도 있습니다.

문제는 “어느 쪽인지 항상 명확하지 않다”는 점입니다. 그리고 그 상태에서 부분 집합에 값을 대입하면:

  • 원본이 바뀌기도 하고
  • 원본이 안 바뀌기도 하며
  • 심지어 실행 환경/버전/최적화 경로에 따라 동작이 달라질 여지도 있습니다.

그래서 판다스는 이런 대입을 감지하면 SettingWithCopyWarning을 띄워 “너 지금 위험한 코드 쓰고 있다”고 알려줍니다.

핵심 결론은 다음 한 줄입니다.

  • 필터링(선택)과 대입(수정)을 한 번에, 명시적으로 하라: df.loc[조건, 컬럼] = 값

실무에서 터지는 대표 버그 패턴 6가지

1) 체인 인덱싱(Chained indexing): 가장 흔한 원인

다음 형태가 대표적입니다.

import pandas as pd

df = pd.DataFrame({
    "user": ["a", "b", "c"],
    "age": [10, 20, 30],
    "active": [True, False, True],
})

# 위험: df[조건][컬럼] = 값
# SettingWithCopyWarning 발생 가능
# 그리고 df 원본이 안 바뀔 수 있음
subset = df[df["active"]]
subset["age"] = subset["age"] + 1

왜 위험하냐면 df[df["active"]]가 뷰인지 복사인지 불명확하기 때문입니다.

안전한 수정법

mask = df["active"]
df.loc[mask, "age"] = df.loc[mask, "age"] + 1

이 방식은 “원본 df의 특정 행/열을 수정한다”는 의도가 코드에 그대로 드러납니다.

2) 필터링한 DataFrame을 변수에 담고 수정

겉으로 보기엔 안전해 보이지만 똑같이 문제가 됩니다.

adult = df[df["age"] >= 20]
adult["active"] = True  # 경고 가능, 원본 df 반영 불확실

안전한 선택지 2개

  1. 원본을 수정할 목적이면 loc로 바로 수정
mask = df["age"] >= 20
df.loc[mask, "active"] = True
  1. 원본과 분리된 테이블로 쓰는 게 목적이면 명시적으로 복사
adult = df[df["age"] >= 20].copy()
adult["active"] = True  # 이제 경고 없이 안전

여기서 중요한 팀 규칙은 “부분 집합을 만들어 따로 쓸 거면 .copy()를 습관화”하는 것입니다.

3) inplace=True와 섞이면서 디버깅이 어려워지는 케이스

부분 집합에 대해 inplace=True를 쓰면, 경고 여부와 상관없이 “원본이 바뀌었는지” 추적이 더 어려워집니다.

subset = df[df["active"]]
subset.rename(columns={"age": "Age"}, inplace=True)  # 원본 df는?

권장 패턴: inplace를 피하고, 반환값을 명시적으로 받기

subset = df.loc[df["active"]].copy()
subset = subset.rename(columns={"age": "Age"})

실무에서는 이런 스타일이 테스트/리뷰/리팩터링에 훨씬 유리합니다.

4) groupby 결과에 대한 대입을 시도

groupby 후 일부를 뽑아 수정하는 과정에서도 비슷한 문제가 생깁니다.

# 예시 데이터
sales = pd.DataFrame({
    "team": ["A", "A", "B"],
    "amount": [100, 200, 50],
    "flag": [0, 0, 0],
})

# 위험한 접근(부분집합 수정)
a = sales[sales["team"] == "A"]
a["flag"] = 1

안전한 수정법

sales.loc[sales["team"] == "A", "flag"] = 1

추가로 “집계 결과를 원본에 붙이는” 목적이라면 transform이 더 안전하고 의도가 명확합니다.

sales["team_sum"] = sales.groupby("team")["amount"].transform("sum")

5) 함수 체이닝 중 중간 결과를 수정

메서드 체이닝을 쓰다가 중간 결과를 변수로 받아 수정하면 경고가 끼어들기 쉽습니다.

filtered = (
    df[df["active"]]
      .sort_values("age")
)

filtered["age"] = filtered["age"] * 2  # 경고 가능

안전한 수정법

  • 원본을 수정할 거면: 처음부터 loc 기반으로 설계
  • 분리된 결과를 만들 거면: .copy()로 경계를 확정
filtered = df.loc[df["active"]].copy()
filtered = filtered.sort_values("age")
filtered["age"] = filtered["age"] * 2

6) 슬라이싱 후 대입: df[0:10]["col"] = ...

first10 = df[:10]
first10["age"] = 0  # 경고 가능

안전한 수정법

df.loc[:9, "age"] = 0

인덱스가 정수 RangeIndex가 아닐 수도 있으니, 실무에서는 위치 기반이면 iloc를 쓰는 편이 더 명확합니다.

df.iloc[:10, df.columns.get_loc("age")] = 0

안전한 수정법을 “규칙”으로 만들기

규칙 1) 수정은 무조건 loc 또는 iloc

  • 라벨 기반: df.loc[행조건, "col"] = 값
  • 위치 기반: df.iloc[행범위, 열범위] = 값

가장 중요한 건 “선택과 대입을 분리하지 말고 한 문장으로 끝내는 것”입니다.

규칙 2) 부분 집합을 별도 테이블로 다룰 땐 .copy()

report_df = df.loc[df["active"]].copy()
report_df["segment"] = "active"

이렇게 하면 경고를 없애는 것을 넘어, 데이터 파이프라인에서 “이 시점부터는 원본과 독립”이라는 경계가 생깁니다.

규칙 3) SettingWithCopyWarning을 억지로 끄지 말기

가끔 아래처럼 경고를 숨기는 코드를 넣는 경우가 있습니다.

import pandas as pd
pd.options.mode.chained_assignment = None

이건 “경고음을 끈 것”이지 “화재를 진압한 것”이 아닙니다. 실무 버그는 그대로 남습니다.

경고를 끄는 대신, 테스트에서 경고를 실패로 취급해 조기에 잡는 편이 낫습니다.

import warnings
warnings.simplefilter("error", category=pd.errors.SettingWithCopyWarning)

테스트 환경에서만 이렇게 두면, 위험한 코드가 PR 단계에서 걸러집니다.

디버깅 체크리스트: 원본이 정말 바뀌었나?

SettingWithCopyWarning이 떴을 때, 아래 순서로 확인하면 빠릅니다.

  1. 내가 수정하려는 대상이 “원본 df”인가, “부분 집합”인가를 먼저 확정
  2. 부분 집합이면 .copy()가 필요한지 판단
  3. 원본 수정이면 df.loc[...] = ...로 재작성
  4. 수정 후 df.loc[조건, 컬럼].head()로 값이 실제로 바뀌었는지 검증

추적이 어려운 버그는 “관찰 가능성(observability) 부족”에서 커지는 경우가 많습니다. 데이터 이슈도 본질은 비슷합니다. 애플리케이션에서 원인 추적을 체계화하는 접근은 다른 영역에서도 그대로 통합니다. 예를 들어 트랜잭션이 먹지 않는 케이스를 체크리스트로 분해하는 방식은 Spring Boot 3.2에서 @Transactional 무시되는 7가지 같은 글의 문제 해결 흐름과도 닮아 있습니다.

실전 리팩터링 예시: 경고 제거 + 의도 명확화

다음은 실무에서 흔한 “전처리 후 파생 컬럼 생성” 코드입니다.

문제 코드

import numpy as np

# 활성 사용자만 뽑아서 점수 보정
active = df[df["active"]]
active["score"] = np.where(active["age"] >= 20, active["score"] * 1.1, active["score"])  # 경고 가능

겉보기엔 맞지만, 원본 dfscore가 바뀐다는 보장이 없습니다.

개선 코드 1: 원본을 직접 수정

import numpy as np

mask = df["active"]
df.loc[mask, "score"] = np.where(
    df.loc[mask, "age"] >= 20,
    df.loc[mask, "score"] * 1.1,
    df.loc[mask, "score"],
)

개선 코드 2: 결과 테이블을 분리해서 사용

import numpy as np

active = df.loc[df["active"]].copy()
active["score"] = np.where(
    active["age"] >= 20,
    active["score"] * 1.1,
    active["score"],
)

둘 중 무엇이 정답인지는 “원본을 업데이트해야 하는가?”에 따라 결정됩니다. 중요한 건 그 의도가 코드로 명확히 표현돼야 한다는 점입니다.

성능 관점에서의 오해: .copy()는 느리니까 피해야 하나?

.copy()는 메모리를 추가로 사용하고 비용이 들 수 있습니다. 하지만 다음을 같이 봐야 합니다.

  • 부분 집합을 여러 번 수정/가공한다면, 애매한 뷰 상태로 두는 것 자체가 위험
  • 경고를 무시해 생기는 데이터 품질 사고 비용이 훨씬 큼
  • 정말 큰 데이터라면 애초에 “원본을 loc로 직접 수정”하거나, 아예 파이프라인을 재설계해야 함

대규모 시스템에서 타임아웃/장애를 추적하듯, 데이터 파이프라인도 비용과 리스크를 함께 봐야 합니다. 인프라에서 병목을 추적하는 접근이 궁금하다면 EKS에서 ALB Ingress 408 Request Timeout 해결 가이드처럼 “원인 후보를 좁히는 방식”이 참고가 됩니다.

결론: 팀에서 합의할 최소 원칙

SettingWithCopyWarning을 “판다스가 예민하게 구는 경고”로 취급하면 언젠가 실데이터에서 반드시 발목을 잡힙니다. 아래 3가지만 팀 규칙으로 고정해도 대부분의 사고를 막을 수 있습니다.

  1. 수정은 df.loc[...] = ... 또는 df.iloc[...] = ...로만 한다
  2. 부분 집합을 별도 객체로 쓸 땐 .copy()로 경계를 확정한다
  3. 경고를 끄지 말고, 테스트에서 경고를 에러로 승격해 조기에 차단한다

이 원칙을 지키면 경고를 “없애는” 수준을 넘어, 데이터 변경의 의도를 코드로 명확히 만들고 리뷰 가능하게 만들어 실무 버그를 크게 줄일 수 있습니다.