Published on

pandas SettingWithCopyWarning 확실히 없애는 법

Authors

서브셋을 만든 뒤 값 하나를 바꿨는데, 경고만 뜨고 결과가 애매하게 바뀌는 경험은 pandas에서 흔합니다. SettingWithCopyWarning은 “지금 수정한 대상이 원본의 뷰(view)인지, 복사(copy)인지 확실하지 않다”는 뜻이고, 이 모호성이 실제 버그로 이어질 수 있습니다. 이 글에서는 경고를 “숨기는” 것이 아니라, 데이터 흐름을 명확히 만들어 경고가 발생할 여지를 제거하는 방법을 다룹니다.

실무에서 이 경고를 방치하면 다음 문제가 생깁니다.

  • 수정이 원본에 반영된 줄 알았는데 반영되지 않음
  • 반대로, 원본이 바뀌지 않을 거라 생각했는데 일부가 바뀜
  • 코드 리팩터링이나 pandas 버전 차이로 동작이 흔들림

아래 원칙만 지키면 SettingWithCopyWarning은 “확실히” 사라지고, 코드도 더 읽기 쉬워집니다.

SettingWithCopyWarning이 뜨는 근본 원리

pandas에서 df[mask] 또는 df[[...]] 같은 연산은 상황에 따라 뷰를 반환할 수도, 복사를 반환할 수도 있습니다. 문제는 그 다음에 체이닝된 대입(assignment)을 하면 pandas가 다음을 확신할 수 없다는 점입니다.

  • 지금 수정이 원본 df에 반영돼야 하는가
  • 아니면 임시 객체(복사본)에만 적용되는가

대표적인 위험 패턴은 다음입니다.

import pandas as pd

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

# 위험: 체이닝 인덱싱
# df[df["active"]]가 뷰인지 복사인지 불명확
# 그 결과 score 수정이 원본에 반영될지 확실치 않음

df[df["active"]]["score"] = 0

이 코드는 경고가 뜨는 게 정상이며, 무엇보다 “의도대로 동작한다”는 보장이 없습니다.

해결 원칙 1: 대입은 무조건 .loc로 끝낸다

SettingWithCopyWarning을 없애는 가장 확실한 방법은 필터링과 대입을 한 문장으로 묶어 .loc에 맡기는 것입니다.

mask = df["active"]

# 안전: 원본 df의 특정 행/열을 명확히 지정
# 행은 mask, 열은 "score"
df.loc[mask, "score"] = 0

이 패턴은 다음 장점이 있습니다.

  • 원본 df에 대한 대입이라는 의도가 분명함
  • 경고가 사라짐
  • 성능도 대체로 좋음

여러 컬럼을 동시에 갱신하는 .loc 패턴

mask = df["active"]

df.loc[mask, ["score", "user"]] = [0, "unknown"]

여러 컬럼에 서로 다른 값을 넣는 경우, 브로드캐스팅 규칙 때문에 의도와 다르게 들어갈 수 있으니 결과를 꼭 확인하세요. 안전하게 하려면 컬럼별로 명시하는 것도 좋습니다.

mask = df["active"]

df.loc[mask, "score"] = 0
df.loc[mask, "user"] = "unknown"

해결 원칙 2: “서브셋을 새 데이터프레임으로 쓸 거면” 바로 .copy()

원본을 수정할 생각이 전혀 없고, 필터링 결과를 별도 데이터프레임으로 만들어 변환 파이프라인을 계속 이어가고 싶다면 처음부터 복사본임을 선언해야 합니다.

active_df = df.loc[df["active"], ["user", "score"]].copy()

# 이제 active_df는 독립 객체이므로 대입이 안전
active_df["score"] = active_df["score"] * 2

여기서 핵심은 다음 두 가지입니다.

  • 서브셋 만들 때부터 .loc[..., ...]로 범위를 명확히 하고
  • “이건 복사본이다”를 .copy()로 확정한다

이렇게 하면 경고도 없어지고, 코드 리뷰에서도 의도가 명확해집니다.

해결 원칙 3: df[mask][col] = ... 같은 체이닝 대입은 금지

SettingWithCopyWarning의 대부분은 체이닝 인덱싱에서 나옵니다. 아래 형태는 습관적으로라도 피하는 게 좋습니다.

  • df[mask]["col"] = value
  • df["col"][mask] = value
  • df[mask].loc[:, "col"] = value 같은 애매한 조합

대신 항상 다음 중 하나로 고정하세요.

  • 원본 수정: df.loc[mask, "col"] = value
  • 복사본 작업: tmp = df.loc[mask, cols].copy()tmp[...] = ...

실전 예제: 조건부 컬럼 생성과 수정

조건에 따라 새 컬럼을 만들거나 값을 채우는 작업에서 경고가 자주 발생합니다.

잘못된 예

# 위험: 필터링 결과에 새 컬럼을 추가
sub = df[df["score"] >= 20]
sub["grade"] = "A"  # SettingWithCopyWarning 가능

안전한 예 1: 원본에 컬럼 생성

mask = df["score"] >= 20

df.loc[:, "grade"] = "B"        # 기본값
df.loc[mask, "grade"] = "A"     # 조건 만족만 덮어쓰기

안전한 예 2: 서브셋을 독립 데이터로 만들기

sub = df.loc[df["score"] >= 20].copy()
sub["grade"] = "A"

실전 예제: 정렬/슬라이싱 이후 값 변경

정렬이나 슬라이싱 뒤에 값을 바꾸는 경우도 경고가 자주 나옵니다.

sorted_df = df.sort_values("score")

# 위험 가능: sorted_df가 이후 연산에서 원본과 어떻게 연결되는지 모호해질 수 있음
sorted_df[sorted_df["active"]]["score"] = 999

안전한 방식은 역시 .loc입니다.

sorted_df = df.sort_values("score").copy()  # 독립적으로 쓸 거면 copy
mask = sorted_df["active"]

sorted_df.loc[mask, "score"] = 999

원본 df를 수정하려는 목적이라면, 애초에 정렬된 뷰를 통해 수정하지 말고 원본에서 마스크를 만들어 .loc로 수정하세요.

mask = df["active"]
df.loc[mask, "score"] = 999

“경고를 끄면 되지 않나”에 대한 결론

가끔 아래처럼 경고를 비활성화하는 예제가 보입니다.

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

이 방식은 문제 해결이 아니라 문제 은폐입니다. 경고가 사라져도 “대입이 기대대로 반영되는지”는 여전히 불확실합니다. 팀 코드베이스에서는 특히 추천하지 않습니다.

정말 필요한 경우는 다음처럼 제한적이어야 합니다.

  • 레거시 코드에서 단계적으로 .loc로 리팩터링하는 동안 일시적으로 사용
  • 단위 테스트로 결과가 확정적으로 검증되는 구간에서만 제한적으로 적용

하지만 최종 상태는 경고 비활성화가 아니라, 경고가 애초에 발생하지 않게 만드는 것입니다.

디버깅 팁: 어디서 체이닝이 생겼는지 빠르게 찾기

경고가 뜨는 줄을 찾았는데도 “왜 체이닝으로 간주되는지” 감이 안 올 때가 있습니다. 그럴 땐 다음을 확인하세요.

  • 대입문 왼쪽에 ][ 형태가 있는지 확인
  • 중간 변수 sub = df[...]가 있고, 그 sub[...] = ...가 원본 수정 의도인지 확인
  • 원본 수정 의도라면 sub를 없애고 한 번에 df.loc[...] = ...로 변경
  • 원본과 분리된 변환 파이프라인이라면 sub = df.loc[...].copy()로 확정

특히 “중간 변수에 담았으니 안전하겠지”는 착각인 경우가 많습니다. 중간 변수는 단지 참조를 보관할 뿐, 뷰/복사 여부를 보장하지 않습니다.

추천 패턴 모음 (치환용 레시피)

자주 쓰는 위험 코드를 안전 코드로 바꾸는 치환 레시피입니다.

레시피 1: 조건부 단일 컬럼 대입

  • 위험: df[mask]["col"] = v
  • 안전: df.loc[mask, "col"] = v
mask = df["active"]
df.loc[mask, "score"] = 0

레시피 2: 서브셋을 만든 뒤 수정

  • 위험:
sub = df[df["active"]]
sub["score"] = 0
  • 안전(원본 수정 목적):
df.loc[df["active"], "score"] = 0
  • 안전(서브셋 독립 목적):
sub = df.loc[df["active"]].copy()
sub["score"] = 0

레시피 3: 일부 컬럼만 뽑아 가공

features = df.loc[:, ["user", "score"]].copy()
features["score"] = features["score"].astype("int64")

성능 관점에서의 .copy() 사용 기준

.copy()는 안전하지만 비용이 있습니다. 큰 데이터프레임에서 무분별한 .copy()는 메모리 사용량과 실행 시간을 늘릴 수 있습니다.

  • 원본을 수정할 거면 .copy() 대신 .loc로 원본에 직접 대입
  • 원본과 분리된 데이터셋이 필요할 때만 .copy()
  • “경고가 싫어서” .copy()를 남발하지 말고, 의도를 먼저 결정

즉, 선택지는 둘뿐입니다.

  • 원본 수정: .loc
  • 독립 데이터: .copy()

이분법으로 정리하면 팀 내 코드 규칙도 세우기 쉽습니다.

마무리: 경고를 없애는 게 아니라 모호성을 없애자

SettingWithCopyWarning을 확실히 없애려면 핵심은 간단합니다.

  • 체이닝 인덱싱 대입을 하지 않는다
  • 원본 수정은 df.loc[rows, cols] = ...로 끝낸다
  • 서브셋을 독립적으로 쓸 땐 df.loc[..., ...].copy()로 의도를 확정한다

이 원칙을 지키면 경고는 자연스럽게 사라지고, 데이터 파이프라인이 예측 가능해집니다.

추가로, 운영 환경에서 원인 추적이 어려운 문제는 캐시나 상태가 꼬여서 재현이 힘든 경우가 많습니다. 그런 유형의 디버깅 관점은 GitHub Actions 캐시 충돌 시 빌드 완전 초기화 전략 글의 접근 방식도 참고할 만합니다. 대규모 시스템에서 “경고를 끄는 대신 원인을 제거”하는 태도는 Spring Boot 3+ JPA N+1 즉시 잡는 7가지처럼 다른 영역에서도 동일하게 유효합니다.