Published on

pandas SettingWithCopyWarning 0으로 만드는 법

Authors

서브셋을 만든 뒤 컬럼을 수정했는데 SettingWithCopyWarning이 뜨면, 대부분의 개발자는 일단 경고를 끄고 넘어가고 싶어집니다. 하지만 이 경고는 단순한 소음이 아니라 내가 수정한 값이 원본 DataFrame에 반영될 수도, 안 될 수도 있는 애매한 상태를 알려주는 신호입니다. 즉 “경고를 0으로 만든다”는 말은 경고를 억지로 숨기는 것이 아니라, 애매함을 제거해 경고가 발생하지 않는 코드로 바꾸는 것에 가깝습니다.

이 글에서는 SettingWithCopyWarning이 왜 생기는지, 어떤 패턴이 위험한지, 그리고 실무에서 경고를 0으로 만드는 가장 안전한 리팩터링 방법을 정리합니다.

SettingWithCopyWarning의 정체: 뷰(view) vs 복사(copy)

pandas에서 df[...]로 일부 행/열을 선택하면 그 결과가 원본의 뷰일 수도 있고 복사본일 수도 있습니다. 문제는 이 둘이 항상 명확히 구분되지 않는다는 점입니다.

  • 뷰(view): 원본 데이터의 “창” 같은 것. 뷰를 수정하면 원본이 바뀔 수 있습니다.
  • 복사(copy): 독립적인 사본. 복사본을 수정해도 원본은 바뀌지 않습니다.

SettingWithCopyWarning은 “지금 수정하려는 대상이 뷰인지 복사본인지 확실치 않다”는 상황에서 자주 발생합니다. 특히 체이닝 인덱싱(chained indexing) 이 대표적인 트리거입니다.

경고를 만드는 대표 패턴: 체이닝 인덱싱

다음 코드는 경고의 교과서입니다.

import pandas as pd

df = pd.DataFrame({
    "user": ["a", "b", "c"],
    "score": [10, 60, 80],
    "grade": [None, None, None],
})

# 나쁜 예: 체이닝 인덱싱
high = df[df["score"] >= 60]
high["grade"] = "A"  # SettingWithCopyWarning 가능

high = df[df["score"] >= 60]에서 high가 뷰인지 복사본인지 확실치 않은데, 그 다음 줄에서 high["grade"] = ...로 값을 설정하려 하니 경고가 뜹니다.

목표: 경고를 0으로 만드는 3가지 원칙

실무에서 가장 확실하게 SettingWithCopyWarning을 0으로 만드는 방법은 아래 원칙을 지키는 것입니다.

  1. 원본을 수정할 거면 한 번에 loc로 끝낸다
  2. 파생 DataFrame을 수정할 거면 copy()로 의도를 고정한다
  3. 가능하면 assign/where/mask 같은 함수형 스타일로 체이닝을 줄인다

아래에서 각각을 실전 코드로 정리합니다.

1) 원본을 수정할 거면 loc로 한 번에 끝내기

원본 df에 반영하고 싶은 변경이라면, 부분집합을 따로 변수로 빼서 수정하지 말고 원본에 대해 loc[row_selector, col_selector] = value 형태로 한 번에 끝내는 것이 가장 안전합니다.

import pandas as pd

df = pd.DataFrame({
    "user": ["a", "b", "c"],
    "score": [10, 60, 80],
    "grade": [None, None, None],
})

mask = df["score"] >= 60

df.loc[mask, "grade"] = "A"  # 경고 없음, 의도 명확

여러 컬럼을 동시에 세팅하기

mask = df["score"] >= 60

df.loc[mask, ["grade", "user"]] = ["A", "VIP"]

단, 위처럼 서로 다른 타입/의미의 값을 한 번에 넣을 때는 브로드캐스팅 규칙 때문에 의도치 않은 결과가 나올 수 있어, 보통은 컬럼별로 명시하는 편이 안전합니다.

df.loc[mask, "grade"] = "A"
df.loc[mask, "user"] = "VIP"

loc로도 경고가 날 수 있나?

대부분의 경우 loc는 안전합니다. 하지만 df 자체가 이미 어떤 연산의 결과로 만들어진 애매한 객체라면(예: 다른 DataFrame에서 슬라이싱된 결과를 다시 참조) 경고가 이어질 수 있습니다. 이때는 “수정 대상이 되는 최종 DataFrame”을 명확히 하거나, 수정 대상이 파생 객체라면 다음 섹션의 copy() 원칙을 적용하세요.

2) 파생 DataFrame을 수정할 거면 copy()로 의도 고정하기

원본을 바꾸고 싶은 게 아니라, “필터링된 결과를 별도 테이블로 만들어 가공”하려는 경우가 많습니다. 이때는 애초에 복사본임을 명시해야 경고가 사라지고, 코드의 의미도 명확해집니다.

high = df.loc[df["score"] >= 60, ["user", "score"]].copy()
high["grade"] = "A"  # 경고 없음

핵심은 두 가지입니다.

  • 필터링은 가능하면 df[...] 대신 df.loc[...]로 명확히 하기
  • 결과를 수정할 거라면 .copy()로 복사본임을 확정하기

copy(deep=True)가 필요할까?

대부분의 일반적인 컬럼(숫자/문자)에서는 .copy()로 충분합니다. 다만 컬럼에 리스트/딕셔너리 같은 가변 객체가 들어 있고, 내부 객체까지 완전 복제를 기대한다면 별도 처리가 필요합니다. pandas의 깊은 복사는 파이썬 객체 내부까지 완전 복제하는 의미와는 다를 수 있으니, 가변 객체 컬럼을 다루는 설계 자체를 재검토하는 편이 좋습니다.

3) 함수형 스타일로 체이닝을 줄이기: assign, where, mask

체이닝 인덱싱은 “중간 결과를 만들고 거기에 대입”할 때 자주 생깁니다. 이를 줄이려면 대입을 직접 하지 않고, “새 컬럼을 만들어 반환”하는 스타일이 도움이 됩니다.

assign으로 새 컬럼 생성

import numpy as np

result = (
    df
    .assign(grade=np.where(df["score"] >= 60, "A", "F"))
)

이 방식은 원본을 건드리지 않고 새 DataFrame을 만들기 때문에, 뷰/복사 애매함이 줄어듭니다.

mask/where로 조건부 치환

result = df.copy()
result["grade"] = "F"
result["grade"] = result["grade"].mask(result["score"] >= 60, "A")

혹은 한 줄로도 가능합니다.

result = df.assign(
    grade=pd.Series("F", index=df.index).mask(df["score"] >= 60, "A")
)

실무에서 자주 터지는 케이스별 처방전

1) 컬럼 일부만 뽑은 뒤 수정

sub = df[["user", "score"]]
sub["score"] = sub["score"] * 2  # 경고 가능

해결(복사본으로 고정):

sub = df.loc[:, ["user", "score"]].copy()
sub["score"] = sub["score"] * 2

해결(원본 수정 의도라면 loc):

df.loc[:, "score"] = df["score"] * 2

2) 필터링 후 inplace 계열 메서드 사용

inplace=True는 특히 “이게 원본에 적용되는가?”를 더 헷갈리게 만드는 경우가 많습니다.

sub = df[df["score"] >= 60]
sub.rename(columns={"score": "s"}, inplace=True)  # 경고/비권장

해결:

sub = df.loc[df["score"] >= 60].copy()
sub = sub.rename(columns={"score": "s"})

3) 정렬/리셋 인덱스 후 특정 행 업데이트

sub = df[df["score"] >= 60].sort_values("score")
sub.iloc[0, sub.columns.get_loc("grade")] = "B"  # 경고 가능

해결:

sub = df.loc[df["score"] >= 60].copy()
sub = sub.sort_values("score")
sub.iloc[0, sub.columns.get_loc("grade")] = "B"

또는 원본 반영이 목적이면 “원본에서의 인덱스”를 기준으로 loc 업데이트가 더 안전합니다.

“경고를 끄면 되지 않나?”: 0으로 만드는 것과 숨기는 것의 차이

아래처럼 옵션으로 경고를 끄는 방법이 널리 알려져 있습니다.

import pandas as pd
pd.options.mode.chained_assignment = None  # 경고 숨김

하지만 이건 경고를 0으로 만든 게 아니라, 경고를 보지 않게 만든 것입니다. 실제로는 여전히 “원본이 바뀌었는지/안 바뀌었는지”가 불명확할 수 있고, 데이터 파이프라인에서 조용히 잘못된 결과를 만들 위험이 있습니다.

특히 팀 단위 분석 코드나 배치 작업에서는 이런 류의 “조용한 실패”가 가장 비용이 큽니다. 비슷한 맥락으로, 장애를 뿌리부터 제거하기 위해 체크리스트 기반으로 원인을 제거하는 접근이 중요합니다. 예를 들어 재시도/중복 실행 문제를 설정으로 덮지 않고 원인을 추적하는 방식은 Redis 기반 Celery 유령 작업 근절하기 무한 재시도와 중복 실행을 부르는 acks_late prefetch_multiplier visibility_timeout 충돌 디버깅 체크리스트 같은 글의 접근과도 닮아 있습니다.

디버깅 팁: 경고가 난 코드가 “원본을 바꿨는지” 확인하기

경고가 난다고 항상 원본이 안 바뀌는 것은 아닙니다. 그래서 더 위험합니다. 아래처럼 재현 코드를 만들어 “원본 반영 여부”를 확인해보면 문제를 빠르게 감지할 수 있습니다.

import pandas as pd

df = pd.DataFrame({"x": [1, 2, 3], "y": [10, 20, 30]})
sub = df[df["x"] >= 2]
sub["y"] = 999  # 경고 가능

print("sub:\n", sub)
print("df:\n", df)

이 출력에서 dfy가 바뀌었는지 여부는 상황에 따라 달라질 수 있으며, 바로 그 불확실성을 제거하는 게 목표입니다.

권장 리팩터링 패턴 요약(체크리스트)

  • 원본을 업데이트해야 한다
    • df.loc[조건, "col"] = 값으로 끝내기
    • 체이닝 인덱싱 금지: df[cond]["col"] = ... 형태 제거
  • 파생 테이블을 만들어 가공해야 한다
    • sub = df.loc[조건, cols].copy()로 시작
    • 그 다음 sub[...] = ...는 OK
  • inplace=True는 가급적 피하기
    • 반환값을 다시 변수에 할당해 흐름을 명확히 하기
  • 경고 옵션으로 숨기지 말기
    • pd.options.mode.chained_assignment = None는 최후의 수단(그리고 보통은 비추천)

결론: SettingWithCopyWarning을 0으로 만드는 가장 현실적인 해법

SettingWithCopyWarning을 없애는 가장 좋은 방법은 “경고를 끄는 설정”이 아니라, 수정의 대상이 원본인지 복사본인지 의도를 코드로 고정하는 것입니다.

  • 원본 수정이면 loc로 직접 대입
  • 파생 가공이면 .copy()로 독립성을 확보
  • 체이닝 인덱싱을 제거하고 assign/mask 같은 표현으로 흐름을 단순화

이 3가지만 습관화해도 경고는 사실상 0에 수렴하고, 더 중요한 건 데이터가 조용히 틀어지는 리스크를 크게 줄일 수 있습니다.

추가로, 파이썬 런타임에서 “겉으로는 돌아가는데 특정 조건에서만 깨지는” 문제를 체크리스트로 정리해 해결하는 방식이 필요하다면 Python asyncio RuntimeError - Event loop is closed 해결도 함께 참고하면, 원인-증상-재현-해결의 글 구성 관점에서 도움이 될 수 있습니다.