Published on

pandas SettingWithCopyWarning 완전 해결 가이드

Authors

서브셋 DataFrame에 값을 넣었는데 경고가 뜨고, 어떤 날은 값이 반영되고 어떤 날은 안 반영되는 경험이 있다면 SettingWithCopyWarning을 제대로 이해해야 합니다. 이 경고는 단순히 “거슬리는 메시지”가 아니라, 내 코드가 의도한 DataFrame을 수정하지 않았을 가능성을 알려주는 신호입니다.

이 글에서는 경고가 발생하는 구조적 이유(뷰와 복사), 가장 안전한 해결 패턴, 그리고 팀/프로덕션 코드에서 재발을 막는 습관까지 정리합니다.

또한 데이터 파이프라인을 운영하다 보면 pandas 이슈가 CI나 배포에서 다른 장애로 이어지기도 합니다. 운영 환경에서 문제를 추적하는 관점은 예를 들어 systemd 서비스가 계속 재시작될 때 원인 9가지 같은 글의 접근과도 통합니다. “원인 분류”와 “재현 가능한 진단”이 핵심입니다.

SettingWithCopyWarning이 의미하는 것

SettingWithCopyWarning은 보통 아래 상황에서 발생합니다.

  • 어떤 DataFrame에서 조건 필터링이나 슬라이싱을 해서 부분집합을 만든다
  • 그 부분집합에 대해 컬럼 값을 대입한다
  • 그런데 그 부분집합이 원본의 뷰(view) 인지, 독립 복사(copy) 인지 pandas가 확신하지 못한다

pandas 내부에서는 상황에 따라 “원본을 바라보는 뷰”를 만들기도 하고, “새로운 복사본”을 만들기도 합니다. 문제는 사용자가 df2 = df[df["A"] > 0] 같은 코드를 썼을 때, df2가 무엇인지(뷰인지 복사인지) 코드만 보고는 명확하지 않다는 점입니다.

경고의 핵심 메시지는 다음과 같습니다.

  • 지금 대입이 원본에 반영될 거라고 믿고 있는가
  • 그런데 실제로는 반영되지 않을 수도 있다

즉 “경고를 끄면 해결”이 아니라, 대입 대상 DataFrame을 명확히 하라는 요구입니다.

가장 흔한 트리거: 체이닝 인덱싱(chained indexing)

체이닝 인덱싱은 보통 이런 형태입니다.

import pandas as pd

df = pd.DataFrame({
    "name": ["a", "b", "c", "d"],
    "score": [10, 55, 70, 20],
    "passed": [False, True, True, False],
})

# 체이닝 인덱싱: df[mask][col] = value
mask = df["score"] >= 60

df[mask]["passed"] = True  # SettingWithCopyWarning 가능

df[mask]가 먼저 실행되어 부분집합이 만들어지고, 그 결과에 대해 ["passed"]를 선택해 대입합니다. 이 과정에서 pandas는 “원본 df의 passed를 수정하는 건지” 확신할 수 없습니다.

해결 1순위: .loc로 한 번에 지정

같은 작업을 아래처럼 바꾸면 경고도 사라지고, 의도도 명확해집니다.

mask = df["score"] >= 60

df.loc[mask, "passed"] = True

.loc[행조건, 열] 형태는 pandas가 권장하는 정석 패턴이며, SettingWithCopyWarning을 사실상 근본적으로 차단합니다.

“필터링한 df에 컬럼 추가”가 위험한 이유

많이 하는 패턴이 다음입니다.

high = df[df["score"] >= 60]
high["grade"] = "A"  # SettingWithCopyWarning 가능

여기서 목표가 두 가지 중 무엇인지 먼저 정해야 합니다.

  • 목표 A: 원본 df에도 grade를 반영하고 싶다
  • 목표 B: high라는 별도 데이터셋을 만들고 그쪽에만 grade를 추가하고 싶다

목표 A라면: 원본에 .loc로 대입

mask = df["score"] >= 60

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

목표 B라면: 명시적으로 .copy()

high가 독립 데이터셋이라면, 애초에 복사본임을 명확히 선언해야 합니다.

high = df.loc[df["score"] >= 60].copy()
high["grade"] = "A"

여기서 중요한 포인트는 copy()가 “경고를 없애는 트릭”이 아니라, 내 의도가 복사본이라는 것을 코드로 문서화한다는 점입니다.

.assign()으로 안전하게 파이프라인 구성하기

데이터 전처리를 체인 형태로 쓰는 팀이라면 .assign()이 특히 좋습니다. .assign()은 새로운 DataFrame을 반환하므로 “어디를 수정하는지”가 명확해집니다.

high = (
    df.loc[df["score"] >= 60]
      .assign(grade="A")
)

여러 컬럼을 만들 때도 깔끔합니다.

result = (
    df
    .assign(
        passed=lambda x: x["score"] >= 60,
        bucket=lambda x: pd.cut(x["score"], bins=[0, 50, 80, 100], labels=["low", "mid", "high"])
    )
)

이 스타일은 “중간 객체가 뷰인지 복사인지”를 고민할 여지를 줄여줍니다.

그룹 연산 후 대입: transform을 활용

groupby 후 조건에 따라 값을 넣을 때도 경고가 종종 등장합니다. 예를 들어 그룹 평균을 기준으로 플래그를 만들고 싶다면, transform으로 원본 인덱스에 정렬된 시리즈를 만든 다음 대입하는 방식이 안전합니다.

df = pd.DataFrame({
    "team": ["A", "A", "B", "B"],
    "score": [10, 80, 40, 90],
})

team_mean = df.groupby("team")["score"].transform("mean")
df.loc[:, "above_team_mean"] = df["score"] > team_mean

df.loc[:, "col"] = ...처럼 “전체 행에 대해 특정 컬럼을 대입한다”는 의도를 분명히 하면 경고를 피할 수 있습니다.

함수/모듈로 분리할 때 자주 생기는 함정

전처리 함수를 만들고 DataFrame을 인자로 받아 일부만 필터링한 뒤 수정하는 코드가 흔합니다.

def normalize_positive(d):
    d = d[d["score"] > 0]
    d["score"] = d["score"] / d["score"].max()  # SettingWithCopyWarning 가능
    return d

이 함수는 호출자가 원본이 수정될지, 복사본이 반환될지 예측하기 어렵습니다.

안전한 형태 1: 복사본을 명시하고 반환

def normalize_positive(d):
    out = d.loc[d["score"] > 0].copy()
    out.loc[:, "score"] = out["score"] / out["score"].max()
    return out

안전한 형태 2: 원본을 수정할 의도라면, 원본 인덱스를 유지한 채 .loc로 수정

def mark_positive_inplace(d):
    mask = d["score"] > 0
    d.loc[mask, "is_positive"] = True
    d.loc[~mask, "is_positive"] = False
    return d

핵심은 “부분집합을 만든 다음 수정”이 아니라, “원본에 대해 마스크를 만든 다음 수정”입니다.

경고를 끄는 건 마지막 수단(그리고 보통은 금지)

아래처럼 전역 옵션으로 경고를 숨길 수는 있습니다.

import pandas as pd

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

하지만 이건 문제를 해결한 게 아니라, 데이터 불일치 가능성을 숨긴 것에 가깝습니다. 특히 분석 결과가 리포트/대시보드/모델 학습으로 이어지는 파이프라인이라면, 조용히 잘못된 값이 들어가는 것이 가장 위험합니다.

팀 코드에서는 보통 다음 중 하나를 권합니다.

  • 경고를 에러로 승격해서 조기에 잡기
  • 또는 최소한 CI에서 경고를 감지

예를 들어 테스트에서 경고를 실패로 만들고 싶다면 warnings를 이용할 수 있습니다.

import warnings

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

이런 접근은 “나중에 운영에서 터지는 문제를 개발 단계에서 잡는다”는 점에서, CI 장애를 선제적으로 줄이는 관점과도 닮아 있습니다. CI 환경 트러블슈팅 관점은 GitLab CI Docker 빌드 no space left 해결 가이드 같은 글과 같은 결입니다.

빠른 진단 체크리스트

경고가 떴을 때 아래 질문을 순서대로 확인하면 대부분 해결됩니다.

1) 체이닝 인덱싱을 했는가

  • df[...][...] = ... 형태가 있으면 거의 확정입니다.
  • 해결: .loc[행, 열] = 값으로 변경

2) 부분집합을 만든 뒤 수정했는가

  • sub = df[mask] 또는 sub = df[[...]]sub["col"] = ...
  • 해결:
    • 원본을 수정하려면 df.loc[mask, "col"] = ...
    • 복사본을 만들려면 sub = df.loc[mask].copy()

3) 함수 내부에서 필터링 후 수정하는가

  • 호출자가 원본 변경 여부를 오해하기 쉽습니다.
  • 해결: copy()와 반환 정책을 명확히

4) .iloc 슬라이스 후 대입하는가

예를 들어 아래도 경고가 날 수 있습니다.

sub = df.iloc[:3]
sub.loc[:, "score"] = 0  # 상황에 따라 경고 가능

해결은 동일합니다. 독립 객체가 필요하면 .copy()를 붙이고, 원본을 수정하려면 원본에 직접 .iloc 또는 .loc로 대입합니다.

# 원본 수정 의도
df.iloc[:3, df.columns.get_loc("score")] = 0

# 복사본 의도
sub = df.iloc[:3].copy()
sub.loc[:, "score"] = 0

실전 예제: 경고 없는 전처리 템플릿

다음은 “원본은 유지하고, 결과는 새 DataFrame으로 반환”하는 전처리 템플릿입니다. SettingWithCopyWarning을 피하기 좋은 구조입니다.

import pandas as pd

def preprocess(df: pd.DataFrame) -> pd.DataFrame:
    # 1) 필요한 행 필터링은 loc로 하고 copy로 의도 명시
    out = df.loc[df["score"].notna()].copy()

    # 2) 파생 컬럼은 loc 또는 assign 사용
    out.loc[:, "passed"] = out["score"] >= 60

    # 3) 조건부 대입은 mask + loc
    mask = out["score"] >= 90
    out.loc[mask, "grade"] = "S"
    out.loc[~mask, "grade"] = "N"

    # 4) 필요한 컬럼만 반환
    return out[["name", "score", "passed", "grade"]]

이 패턴의 장점은 다음과 같습니다.

  • “복사본을 만든다”는 의도가 코드에 박혀 있다
  • 이후 대입은 모두 .loc로 이루어져 예측 가능하다
  • 리뷰어가 봐도 경고 가능성이 낮다

결론: 완전 해결의 핵심은 “의도 명시”

SettingWithCopyWarning을 완전히 없애는 가장 현실적인 방법은 규칙을 세우는 것입니다.

  • 부분집합을 만든 뒤 수정하지 말고, 원본에 mask.loc로 수정한다
  • 부분집합을 수정해야 한다면 copy()로 “복사본”임을 선언한다
  • 파이프라인 스타일이라면 .assign()을 적극 활용한다
  • 경고를 무시하지 말고, 필요하면 테스트에서 에러로 승격해 조기에 잡는다

이 원칙만 지켜도 SettingWithCopyWarning은 더 이상 “귀찮은 경고”가 아니라, 데이터 품질을 지키는 안전장치가 됩니다.