Published on

Python pandas SettingWithCopyWarning 완전 정복

Authors

서브셋을 만들고 값을 바꾸는 순간, 갑자기 콘솔에 SettingWithCopyWarning이 튀어나오면 당황하기 쉽습니다. 더 큰 문제는 이 경고가 “그냥 시끄러운 경고”가 아니라, 내가 수정했다고 믿는 값이 실제 원본 DataFrame에 반영되지 않을 수 있음을 의미한다는 점입니다.

이 글에서는 다음을 목표로 합니다.

  • SettingWithCopyWarning이 왜 발생하는지(뷰 vs 복사)
  • 어떤 코드가 위험한지(대표 패턴)
  • 안전하게 고치는 표준 해법(.loc, .copy, .assign, .pipe)
  • 경고를 끄는 대신 “의도를 코드로 고정”하는 방법

중간중간 디버깅 관점도 함께 다룹니다. 성격은 다르지만, 원인을 추적하고 재발을 막는 사고방식은 비슷합니다. 예를 들어 장애 원인 추적을 체계화하는 글로는 Spring Boot HikariCP 커넥션 누수 진단·해결 같은 접근이 참고가 됩니다.

SettingWithCopyWarning이 의미하는 것

SettingWithCopyWarning은 대략 이런 상황에서 발생합니다.

  • 어떤 DataFrame에서 조건 필터링, 슬라이싱, 체이닝 인덱싱 등으로 부분집합을 만든다
  • 그 부분집합에 대해 값을 대입한다
  • pandas가 내부적으로 “이 부분집합이 원본의 뷰인지, 독립 복사본인지 확실히 보장할 수 없다”고 판단한다

핵심은 **“내가 지금 수정하는 대상이 원본과 연결된 뷰인지, 분리된 복사본인지 불명확”**하다는 점입니다. 이 불명확성 때문에 다음 두 가지 모두가 가능해집니다.

  • 수정이 원본에 반영된다(뷰였다)
  • 수정이 원본에 반영되지 않는다(복사였다)

경고는 “너 지금 의도대로 안 될 수도 있어”라는 신호입니다.

가장 흔한 재현 코드(체이닝 인덱싱)

다음은 전형적인 문제 코드입니다.

import pandas as pd

df = pd.DataFrame({
    "user": ["a", "b", "c"],
    "age": [17, 22, 19],
    "country": ["KR", "US", "KR"],
})

# 문제 패턴: 필터링한 결과에 다시 컬럼 선택 후 대입
kr = df[df["country"] == "KR"]
kr["adult"] = kr["age"] >= 20

이때 kr가 뷰인지 복사인지 pandas는 확신하기 어렵고, 그래서 경고가 발생합니다.

더 위험한 형태: 한 줄 체이닝

# 더 위험: 한 줄로 체이닝 인덱싱
df[df["country"] == "KR"]["adult"] = df["age"] >= 20

이 코드는 경고가 뜨는 정도가 아니라, 대입 자체가 기대대로 동작하지 않을 가능성이 큽니다.

원인 이해: 뷰(view) vs 복사(copy)

  • 뷰(view): 원본 데이터(메모리)를 공유하는 “창” 같은 것. 뷰를 수정하면 원본도 바뀔 수 있습니다.
  • 복사(copy): 원본과 분리된 별도 데이터. 복사를 수정해도 원본은 바뀌지 않습니다.

pandas는 연산 최적화, 메모리 절약 등을 위해 때로는 뷰를, 때로는 복사를 반환합니다. 문제는 “어떤 연산이 항상 뷰를 반환한다/항상 복사를 반환한다”가 명확하지 않은 조합이 존재한다는 점입니다.

그래서 pandas는 모호한 상황에서 경고를 띄워 개발자가 의도를 명확히 쓰도록 강제합니다.

정석 해법 1: .loc로 한 번에 인덱싱하고 대입하기

SettingWithCopyWarning을 가장 깔끔하게 없애는 방법은 원본 df에 대해 .loc로 조건과 컬럼을 동시에 지정하는 것입니다.

mask = df["country"] == "KR"

df.loc[mask, "adult"] = df.loc[mask, "age"] >= 20

이 패턴의 장점:

  • “원본의 특정 행/열을 수정한다”는 의도가 코드에 명확히 남습니다.
  • 경고가 사라지는 것이 목적이 아니라, 동작이 보장됩니다.

여러 컬럼을 동시에 세팅

mask = df["country"] == "KR"

df.loc[mask, ["adult", "age_group"]] = [
    True,
    "KR-group",
]

단, 오른쪽 값의 브로드캐스팅 규칙 때문에 의도와 다르게 들어갈 수 있으니, 복잡한 경우는 아래의 .assign이나 개별 컬럼 대입을 추천합니다.

정석 해법 2: 부분집합이 필요하면 .copy()로 의도를 고정

부분집합 DataFrame을 별도로 만들어 후속 처리(피벗, 그룹바이, 저장 등)를 하려면, 애초에 복사본임을 명확히 선언하세요.

kr = df.loc[df["country"] == "KR"].copy()
kr["adult"] = kr["age"] >= 20

이렇게 하면:

  • kr는 독립 객체가 되어 대입이 안전합니다.
  • 원본 df를 수정하지 않는다는 의도가 분명해집니다.

정석 해법 3: .assign()으로 함수형 스타일로 처리

assign은 새로운 DataFrame을 반환하므로, 체이닝이 필요할 때 안전한 선택입니다.

kr = (
    df.loc[df["country"] == "KR"]
      .assign(adult=lambda x: x["age"] >= 20)
)
  • lambda x:에서 x는 필터링된 결과 DataFrame입니다.
  • 새 컬럼이 추가된 새 객체가 만들어집니다.

정석 해법 4: .where / np.where로 조건부 대입

원본에 조건부로 값을 넣고 싶다면 .loc가 가장 일반적이지만, 경우에 따라 where류가 더 읽기 쉬울 때가 있습니다.

import numpy as np

df["adult"] = np.where(df["age"] >= 20, True, False)

특정 조건에서만 값을 바꾸고, 나머지는 기존 값을 유지하려면:

df["score"] = 0
mask = df["country"] == "KR"

df.loc[mask, "score"] = 10

“경고 끄면 되지 않나요?”가 위험한 이유

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

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

하지만 이는 “화재 경보기 배터리 빼기”에 가깝습니다.

  • 경고가 사라져도 동작이 안전해지는 건 아닙니다.
  • 팀 코드에서 이런 설정이 들어가면, 나중에 데이터가 조용히 틀어져도 발견이 늦습니다.

경고가 거슬린다면, 끄는 대신 문제가 되는 대입 코드를 .loc 또는 .copy()로 치환하세요.

실전에서 자주 만나는 위험 패턴 6가지와 치환법

1) 필터링 후 대입

sub = df[df["age"] >= 20]
sub["flag"] = 1

치환:

df.loc[df["age"] >= 20, "flag"] = 1

또는 부분집합이 목적이면:

sub = df.loc[df["age"] >= 20].copy()
sub["flag"] = 1

2) 컬럼 선택 후 슬라이스 대입

df["age"][0:2] = 99

치환:

df.loc[0:1, "age"] = 99

3) 정렬/필터 체인 뒤 대입

x = df.sort_values("age")[df["country"] == "KR"]
x["adult"] = x["age"] >= 20

치환(단계 분리 + .copy() 또는 원본 .loc):

x = df.sort_values("age")
kr = x.loc[x["country"] == "KR"].copy()
kr["adult"] = kr["age"] >= 20

4) 그룹 연산 결과에 대입하려고 시도

groupby 결과는 원본과 개념적으로 다르니, 원본에 반영할 목적이면 transform을 사용하세요.

# 각 country별 평균 age를 원본에 붙이기
mean_age = df.groupby("country")["age"].transform("mean")
df["country_mean_age"] = mean_age

5) .query() 이후 대입

q = df.query("country == 'KR'")
q["adult"] = q["age"] >= 20

치환:

q = df.query("country == 'KR'").copy()
q["adult"] = q["age"] >= 20

또는 원본에 반영이 목적이면:

mask = df["country"].eq("KR")
df.loc[mask, "adult"] = df.loc[mask, "age"] >= 20

6) 함수 안에서 슬라이스를 받아 수정

def mark_adult(d):
    d[d["country"] == "KR"]["adult"] = d["age"] >= 20
    return d

치환:

def mark_adult(d):
    d = d.copy()
    mask = d["country"].eq("KR")
    d.loc[mask, "adult"] = d.loc[mask, "age"] >= 20
    return d

디버깅 팁: “원본이 바뀌었는지”를 빠르게 검증하기

경고가 뜨는 상황에서 가장 무서운 건 “바뀐 줄 알았는데 안 바뀐 상태로 다음 단계가 진행되는 것”입니다. 아래처럼 체크를 습관화하면 좋습니다.

before = df.copy()

mask = df["country"].eq("KR")
df.loc[mask, "adult"] = df.loc[mask, "age"] >= 20

changed = (before != df).any(axis=1)
print("changed rows:", df.index[changed].tolist())

데이터 파이프라인에서 이런 검증을 자동화하는 관점은, 예를 들어 API 재시도 설계에서 “관측 가능성”을 올리는 방식과 닮아 있습니다. 관심 있다면 OpenAI 429와 Rate Limit 헤더로 재시도 설계처럼 문제를 재현 가능하게 만들고 신호를 수집하는 접근도 도움이 됩니다.

권장 코딩 규칙(팀 컨벤션으로 박아두기)

SettingWithCopyWarning을 근본적으로 줄이려면, 팀/프로젝트 차원에서 규칙을 정하는 게 효과가 큽니다.

  1. 대입은 원본 DataFrame에 대해 .loc[row_mask, col]로만 한다
  2. 부분집합을 변수로 뽑아 후속 처리할 때는 .copy()를 기본으로 한다
  3. 한 줄 체이닝 인덱싱으로 대입하지 않는다
  4. 새 컬럼 생성은 assign을 우선 고려한다(체이닝 안전)

이 규칙만 지켜도 경고는 대부분 사라지고, 무엇보다 “내 코드가 어떤 객체를 수정하는지”가 명확해집니다.

자주 묻는 질문

.copy()는 성능에 나쁘지 않나요?

맞습니다. copy는 메모리와 시간이 듭니다. 하지만 다음을 구분하면 됩니다.

  • 원본을 수정할 목적: .copy()가 아니라 .loc로 원본에 직접 대입
  • 원본과 분리된 결과가 목적: .copy()로 독립성을 확보(안전성 우선)

즉, copy는 “무조건”이 아니라 “부분집합을 독립 객체로 다루고 싶다”는 의도를 코드로 표현하는 도구입니다.

경고가 안 뜨는데도 위험할 수 있나요?

가능합니다. 경고는 탐지 장치일 뿐, 모든 케이스를 100퍼센트 잡아내는 보증은 아닙니다. 그래서 더더욱 .loc 기반 대입 규칙이 중요합니다.

결론: 경고를 없애는 게 아니라 의도를 고정하자

SettingWithCopyWarning은 pandas가 “너 지금 모호한 코드를 쓰고 있어”라고 알려주는 장치입니다. 해결의 핵심은 간단합니다.

  • 원본을 수정하려면 df.loc[mask, col] = value
  • 분리된 부분집합을 다루려면 df.loc[mask].copy() 후 수정
  • 체이닝 인덱싱 대입은 금지

이 원칙만 지키면, 경고를 억지로 숨기지 않아도 데이터 처리 코드의 재현성과 신뢰성이 크게 올라갑니다.

추가로, pandas 외에도 런타임/환경 이슈로 디버깅이 필요할 때는 Python 3.12에서 pkg_resources 에러 근본 해결처럼 원인-증상-해결을 구조화하는 글을 참고하면 문제 해결 속도가 빨라집니다.