Published on

판다스 SettingWithCopyWarning 완전 정복

Authors

서브쿼리처럼 보이는 한 줄의 판다스 코드가, 운영 데이터에서만 조용히 틀어지는 경우가 있습니다. 그 출발점이 SettingWithCopyWarning 입니다. 이 경고는 "지금 수정한 게 원본에 반영될 수도 있고 아닐 수도 있다"는 의미라서, 테스트에서는 우연히 맞고 실전에서는 틀리는 유형의 버그를 만들기 쉽습니다.

이 글에서는 경고가 왜 생기는지, 어떤 코드가 위험한지, 그리고 팀 코드베이스에서 "경고를 없애는 수준"을 넘어 "버그 가능성을 구조적으로 제거"하는 실무 패턴을 정리합니다.

SettingWithCopyWarning이 진짜 위험한 이유

SettingWithCopyWarning은 판다스가 다음 상황을 감지했을 때 발생합니다.

  • 어떤 DataFrame에서 부분집합을 만든 뒤
  • 그 부분집합에 값을 대입했는데
  • 그 부분집합이 원본의 view 인지 copy 인지 판다스가 확신할 수 없을 때

핵심은 "원본이 바뀌었는지"가 불확실하다는 점입니다.

  • view 라면 대입이 원본에 반영될 수 있음
  • copy 라면 대입이 원본에 반영되지 않음

이 불확실성이 실무에서 치명적인 이유는 다음과 같습니다.

  • 로컬에서는 우연히 view 로 동작해 통과
  • 배치 환경이나 다른 판다스 버전에서는 copy 로 동작해 실패
  • 실패해도 예외가 아니라 "조용히" 값이 안 바뀜

분산 시스템에서 "중복 실행"이나 "정확히 한 번"이 깨지는 문제가 조용히 누적되는 것과 비슷한 결입니다. 데이터 파이프라인의 신뢰성을 다루는 관점은 Kafka Exactly-Once가 깨질 때 - Outbox+사가 같은 글에서 다루는 문제의식과도 닿아 있습니다.

경고를 재현하는 가장 흔한 패턴

1) 체이닝 인덱싱: 가장 대표적인 지뢰

아래 코드는 매우 흔합니다.

import pandas as pd

df = pd.DataFrame({
    "user_id": [1, 2, 3, 4],
    "country": ["KR", "US", "KR", "JP"],
    "score": [10, 20, 30, 40],
})

# 위험: 체이닝 인덱싱
kr = df[df["country"] == "KR"]
kr["score"] = kr["score"] * 10

문제는 kr 이 원본 dfview 인지 copy 인지 불명확하다는 점입니다. 어떤 경우에는 df 가 바뀌지 않습니다.

2) 부분 슬라이스 후 대입

# 위험: 슬라이스 결과에 대입
sub = df[:2]
sub["score"] = 0

3) inplace 혼합으로 더 헷갈리게 만들기

inplace=True 를 섞으면 디버깅 난이도가 올라갑니다.

sub = df[df["country"] == "KR"]
sub.rename(columns={"score": "s"}, inplace=True)  # 경고 또는 예측 불가 동작 가능

원리: view vs copy, 그리고 판다스의 "모호함"

판다스는 내부적으로 넘파이 배열을 기반으로 하고, 성능을 위해 가능한 경우 원본 데이터의 뷰를 반환하려고 시도합니다. 하지만 다음 요소들 때문에 판다스는 때로 "이 결과가 뷰인지 복사본인지"를 확실히 말할 수 없습니다.

  • boolean mask, fancy indexing, column subset 조합
  • 연산 중간 결과가 임시 객체로 만들어지는 경우
  • 블록 매니저 구조와 dtype 혼합

그래서 판다스는 "너 지금 위험한 짓 하는 것 같아"라는 경고를 띄웁니다.

중요한 결론은 하나입니다.

  • 체이닝 인덱싱을 없애고
  • 대입은 항상 원본에 대해 loc 로 수행하거나
  • 명시적으로 copy() 를 떠서 "원본과 분리"를 선언해야 합니다.

해결 패턴 1: 대입은 무조건 loc

가장 정석이자 팀 표준으로 삼기 좋은 규칙입니다.

boolean mask 대입

mask = df["country"] == "KR"
df.loc[mask, "score"] = df.loc[mask, "score"] * 10

이 방식은 다음을 보장합니다.

  • 대입 대상이 df 임이 명확
  • 판다스가 경고를 띄울 이유가 없음
  • 원본이 반드시 변경됨

여러 컬럼을 한 번에 대입

mask = df["country"] == "KR"
df.loc[mask, ["score", "country"]] = [999, "KOREA"]

단, 우변이 브로드캐스팅 규칙에 맞아야 합니다.

해결 패턴 2: 부분집합을 수정해야 한다면 copy() 를 선언

"필터링된 결과를 따로 가공해서 다른 곳에 저장"하는 경우에는 원본 변경이 목적이 아닙니다. 이때는 copy() 를 명시해 경고도 없애고 의도도 명확히 합니다.

kr = df.loc[df["country"] == "KR"].copy()
kr["score"] = kr["score"] * 10

이 패턴은 코드 리뷰에서 의도가 선명합니다.

  • copy() 가 있으면 "원본과 분리해서 작업"이라는 선언
  • 이후 대입은 안전

해결 패턴 3: 새로운 컬럼은 assign 으로 만들기

대입 자체를 줄이는 것도 좋은 전략입니다. assign 은 체이닝 파이프라인에서 특히 안전합니다.

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

assign 은 "새로운 DataFrame 을 반환"하는 스타일이라, 중간 대입으로 인한 모호함을 줄입니다.

해결 패턴 4: pipe 로 단계 분리, 중간 객체 대입 금지

실무에서 SettingWithCopyWarning은 "한 줄에 너무 많은 일을 하는 코드"에서 자주 나옵니다. pipe 로 단계를 분리하면, 각 단계에서 입력과 출력이 명확해집니다.

def filter_kr(df: pd.DataFrame) -> pd.DataFrame:
    return df.loc[df["country"] == "KR"].copy()

def scale_score(df: pd.DataFrame) -> pd.DataFrame:
    df["score"] = df["score"] * 10
    return df

kr = df.pipe(filter_kr).pipe(scale_score)

여기서 핵심은 첫 단계에서 copy() 로 분리해두었기 때문에 두 번째 단계의 대입이 안전하다는 점입니다.

실무 체크리스트: "경고 없애기"가 아니라 "버그 0"으로

1) 금지 패턴을 팀 규칙으로 못 박기

다음 형태는 사실상 금지하는 게 좋습니다.

  • df[mask]["col"] = ...
  • df[df["a"] == 1]["b"] = ...

이건 경고가 뜨면 고치고, 안 뜨면 넘어가는 문제가 아닙니다. "경고가 안 떠도" 위험합니다.

2) 데이터 변경은 한 곳에서만, 원본에 대해 수행

데이터를 변경해야 한다면 다음 둘 중 하나로만 하도록 표준화합니다.

  • df.loc[mask, col] = value
  • df = df.assign(...) 같은 함수형 스타일

3) 중간 결과를 수정할 거면 copy() 를 강제

필터링 결과를 변수에 담는 순간, 그 변수는 원본과의 관계가 모호해질 수 있습니다. 수정할 가능성이 있다면 처음부터 이렇게 씁니다.

  • df.loc[...].copy()

4) 경고를 무시하거나 끄지 말기

가끔 아래처럼 경고를 숨기기도 합니다.

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

이건 "연기 감지기를 끄는" 것에 가깝습니다. 경고가 사라져도 버그 가능성은 그대로입니다.

운영에서 조용히 터지는 문제는 원인 추적 비용이 큽니다. 예를 들어 시스템 레벨에서 조용히 죽는 문제를 추적할 때도 비용이 폭증하는데, 이런 관점은 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl 같은 글에서 다루는 방식과 비슷합니다. "증상을 숨기지 말고, 원인을 제거"해야 합니다.

디버깅 팁: 어디서 SettingWithCopyWarning이 발생했는지 빠르게 찾기

경고가 파이프라인 중간에서 발생하면 위치 찾기가 번거롭습니다. 다음처럼 경고를 예외로 승격하면 즉시 스택 트레이스로 잡을 수 있습니다.

import pandas as pd

pd.options.mode.chained_assignment = "raise"

# 이후 위험한 대입이 발생하면 SettingWithCopyError 예외가 발생

배치 작업이나 CI에서만 켜도 효과가 큽니다.

자주 묻는 함정 5가지

1) df2 = df[df["x"] == 1] 는 항상 copy 인가

아닙니다. 상황에 따라 달라져서 문제가 됩니다. "항상"을 기대하면 안 됩니다.

2) df.loc[...] 는 무조건 안전한가

대입 관점에서는 안전한 편입니다. 다만 df.loc[...] 로 부분집합을 만든 뒤 그 결과를 또 수정하려면, 그때는 copy() 로 분리하는 습관이 좋습니다.

3) df.query(...) 는 어떤가

query 자체는 필터링을 도와주지만, 이후 대입을 체이닝으로 하면 동일한 문제가 생깁니다.

# 위험
sub = df.query("country == 'KR'")
sub["score"] = 1

# 안전
mask = df["country"].eq("KR")
df.loc[mask, "score"] = 1

4) inplace=True 를 쓰면 안전해지나

반대입니다. 중간 객체에 inplace 를 쓰면 더 모호해집니다. 실무에서는 inplace 를 지양하고 반환값을 명확히 받는 쪽이 디버깅에 유리합니다.

5) 경고가 없으면 안전한가

아닙니다. 경고는 "탐지"일 뿐이고, 모든 위험한 케이스를 100퍼센트 잡지 못합니다. 그래서 패턴 자체를 바꿔야 합니다.

실전 예시: 전처리 파이프라인을 안전하게 리팩터링

아래는 흔한 "경고가 뜨거나, 안 떠도 위험한" 전처리 코드입니다.

# 위험한 스타일
active = df[df["is_active"] == 1]
active["tier"] = "A"
active[active["score"] >= 30]["tier"] = "S"

문제점은 두 가지입니다.

  • 부분집합 active 에 대입
  • 그 위에 또 체이닝 인덱싱

안전한 형태로 바꾸면 다음처럼 됩니다.

import numpy as np

active = df.loc[df["is_active"].eq(1)].copy()
active.loc[:, "tier"] = "A"
active.loc[active["score"].ge(30), "tier"] = "S"

원본 df 를 직접 업데이트해야 한다면 더 단순합니다.

mask_active = df["is_active"].eq(1)
df.loc[mask_active, "tier"] = "A"
df.loc[mask_active & df["score"].ge(30), "tier"] = "S"

이렇게 바꾸면 경고 제거를 넘어, "원본이 바뀌었는지"라는 불확실성이 사라집니다.

팀 운영 관점: CI에서 경고를 품질 게이트로 만들기

SettingWithCopyWarning은 데이터 품질 사고로 이어질 수 있으니, 팀 레벨에서 다음을 권합니다.

  • 개발 환경에서는 pd.options.mode.chained_assignment = "raise" 를 켠 테스트를 1개 이상 둠
  • 코드 리뷰 룰로 체이닝 인덱싱 금지
  • 전처리 함수는 가능하면 "입력 DataFrame 불변"을 기본으로 하고, 변경이 필요하면 명시적으로 반환

이런 식의 품질 게이트는 애플리케이션 영역에서도 효과가 큽니다. 예를 들어 배포 파이프라인에서 인증 오류를 조기에 잡는 방식은 GitHub Actions OIDC로 AWS 키 없이 배포 오류 해결 같은 글의 접근과 유사합니다. "운영에서 맞기 전에 CI에서 먼저 맞자"는 원칙입니다.

결론: 정답은 간단한데, 습관이 중요하다

SettingWithCopyWarning을 완전히 정복하는 핵심 규칙은 세 가지로 요약됩니다.

  • 대입은 원본 DataFrame 에 대해 loc 로만 한다
  • 부분집합을 수정할 거면 처음부터 copy() 로 분리한다
  • 체이닝 인덱싱은 금지한다

이 규칙을 팀 표준으로 만들면, 경고를 없애는 수준을 넘어 "조용히 데이터가 안 바뀌는" 유형의 실무 버그를 사실상 제거할 수 있습니다.