Published on

pandas SettingWithCopyWarning 완전해결 - loc·copy

Authors

데이터 전처리나 피처 엔지니어링을 하다 보면 어느 순간 콘솔에 SettingWithCopyWarning 이 뜹니다. 코드가 “돌아가긴” 하는데 결과가 맞는지 확신이 없고, 경고를 무시하기도 찜찜합니다. 이 경고는 단순한 잔소리가 아니라, 내가 수정한 값이 원본 DataFrame 에 반영될 수도 있고 안 될 수도 있는 모호한 상태를 알려주는 신호입니다.

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

  • SettingWithCopyWarning 이 왜 발생하는지(뷰 vs 복사)
  • 어떤 코드 패턴이 위험한지(대표적인 체인 인덱싱)
  • loccopy 로 “완전히” 해결하는 표준 패턴
  • 디버깅 시 유용한 설정과 점검 체크리스트

참고로, 이런 류의 경고/에러는 겉으로는 사소해 보여도 실제로는 파이프라인 결과를 조용히 망가뜨릴 수 있습니다. 운영 환경에서의 “조용한 실패”가 얼마나 위험한지는 캐시 충돌 케이스를 다룬 글인 Next.js ISR 500 - revalidate·캐시 충돌 해결 같은 사례와 결이 비슷합니다.

SettingWithCopyWarning의 정체: 뷰(view)와 복사(copy)의 모호함

pandas는 성능을 위해 어떤 연산에서는 원본 데이터의 를 반환하고, 어떤 연산에서는 복사본을 반환합니다. 문제는 사용자가 작성한 코드가 “지금 내가 들고 있는 객체가 뷰인지 복사인지”를 명확히 보장하지 못할 때입니다.

  • 뷰(view): 원본과 메모리를 공유할 수 있음. 수정이 원본에 반영될 가능성이 있음.
  • 복사(copy): 원본과 독립. 수정해도 원본은 그대로.

SettingWithCopyWarning 은 주로 아래 상황에서 발생합니다.

  1. 어떤 방식으로든 부분집합을 만들었고(필터링/슬라이싱)
  2. 그 부분집합에 대해 값을 대입했는데
  3. pandas가 “이게 원본에 반영되는지 보장 못 하겠다”고 판단한 경우

핵심은 체인 인덱싱(chain indexing) 입니다.

가장 흔한 원인: 체인 인덱싱

다음 코드는 경고의 교과서 같은 예시입니다.

import pandas as pd

df = pd.DataFrame({
    "city": ["Seoul", "Seoul", "Busan"],
    "sales": [10, 20, 5],
    "grade": ["B", "B", "C"],
})

# 체인 인덱싱: (1) 필터링으로 부분집합 생성 후 (2) 다시 컬럼 선택
seoul = df[df["city"] == "Seoul"]
seoul["grade"] = "A"  # SettingWithCopyWarning 가능

왜 위험하냐면, df[df["city"] == "Seoul"] 이 뷰인지 복사인지 pandas 내부 최적화에 따라 달라질 수 있기 때문입니다. 그러면 seoul["grade"] = "A" 가 원본 df 에 반영될 수도 있고, 안 될 수도 있습니다.

이 경고를 “없애는” 것이 목적이 아니라, 의도를 코드로 고정하는 것이 목적입니다.

해결 전략 1: 원본을 직접 수정할 땐 loc로 한 번에

원본 df 를 수정하는 것이 목적이라면, 부분집합을 변수로 빼서 수정하지 말고 loc 로 한 번에 끝내는 게 정석입니다.

mask = df["city"] == "Seoul"

df.loc[mask, "grade"] = "A"  # 안전: 원본 df의 특정 행/열을 명시적으로 수정

loc가 안전한 이유

  • loc[행조건, 열] 형태는 “원본의 특정 위치에 대입”을 명시합니다.
  • pandas가 뷰/복사 모호성을 고민할 여지가 줄어듭니다.

여러 컬럼을 동시에 수정하는 패턴

mask = df["city"] == "Seoul"

df.loc[mask, ["sales", "grade"]] = [999, "VIP"]

조건부로 계산 결과를 넣는 패턴

mask = df["city"] == "Seoul"

df.loc[mask, "sales"] = df.loc[mask, "sales"] * 1.1

포인트는 우변에서도 df.loc[mask, ...] 를 써서 같은 인덱싱 체계로 일관성을 유지하는 것입니다.

해결 전략 2: 부분집합을 독립적으로 다룰 땐 copy로 명시

부분집합 DataFrame 을 만들어서 별도로 가공하고, 원본 df 는 건드리지 않겠다는 의도라면 copy 를 명시하세요.

seoul = df.loc[df["city"] == "Seoul"].copy()
seoul["grade"] = "A"  # 경고 없이 안전: seoul은 독립 복사본

이 패턴의 장점은 “이후 수정이 원본에 영향을 주지 않는다”는 것을 코드로 보장한다는 점입니다.

copy를 언제 쓰면 좋은가

  • 필터링 결과를 여러 단계로 가공해야 할 때
  • 원본을 보존해야 할 때(재현성/검증/롤백)
  • 함수 내부에서 입력 DataFrame 을 불변처럼 다루고 싶을 때

반대로, 부분집합을 만든 뒤 다시 원본에 병합/대입하려는 목적이라면 copy 는 오히려 “원본과 연결이 끊어져” 추가 작업이 필요합니다. 그 경우는 loc 로 원본을 직접 수정하는 편이 낫습니다.

자주 틀리는 패턴과 올바른 대안

패턴 A: 컬럼 선택 후 필터링 후 대입

# 위험
(df["sales"][df["city"] == "Seoul"]) = 0

위 코드는 체인 인덱싱의 변형입니다. 올바른 코드는 다음입니다.

mask = df["city"] == "Seoul"

df.loc[mask, "sales"] = 0

패턴 B: 필터링한 뒤 inplace 계열 호출

# 위험 가능
seoul = df[df["city"] == "Seoul"]
seoul.fillna(0, inplace=True)

대안은 목적에 따라 갈립니다.

  • 원본을 수정하려면:
mask = df["city"] == "Seoul"

df.loc[mask, "sales"] = df.loc[mask, "sales"].fillna(0)
  • 부분집합만 수정하려면:
seoul = df.loc[df["city"] == "Seoul"].copy()
seoul["sales"] = seoul["sales"].fillna(0)

패턴 C: 함수 체이닝 중간 결과에 대입

# 위험
(df.query("city == 'Seoul'"))["grade"] = "A"

대안:

mask = df["city"] == "Seoul"

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

경고를 “끄는 것”은 해결이 아니다

가끔 아래처럼 경고를 끄고 넘어가려는 시도가 있습니다.

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

이건 증상 숨기기입니다. 경고가 사라질 뿐, 원본 반영 여부가 모호한 상태는 그대로 남습니다. 특히 팀 단위 분석 코드나 배치 파이프라인에서 이런 설정은 나중에 디버깅 비용을 폭발시킵니다.

오히려 개발 중에는 아래처럼 더 엄격하게 해두면 빨리 잡을 수 있습니다.

import pandas as pd
pd.options.mode.chained_assignment = "raise"  # 경고를 예외로

이렇게 해두면 문제 코드를 즉시 예외로 발견할 수 있어, “조용히 잘못된 데이터가 만들어지는 상황”을 예방합니다. 운영에서 조용한 데이터 오류가 쌓이면, 결국은 복구 전략이 필요해지는데, 이벤트 유실/중복 복구를 다룬 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략 같은 글에서 말하는 것처럼 사후 복구는 항상 더 비쌉니다.

실전 레시피: 필터-가공-원본 반영까지 안전하게

요구사항이 자주 이렇습니다.

  1. 특정 조건의 행만 뽑아서
  2. 그 안에서 계산/정제를 하고
  3. 결과를 원본에 반영

이럴 때는 “부분집합을 복사해서 가공”하고, “원본에는 loc로 반영”을 분리하면 깔끔합니다.

import pandas as pd

df = pd.DataFrame({
    "city": ["Seoul", "Seoul", "Busan"],
    "sales": [10, None, 5],
    "discount": [0.1, 0.2, 0.0],
})

mask = df["city"] == "Seoul"

# 1) 부분집합은 독립적으로 가공
seoul = df.loc[mask, ["sales", "discount"]].copy()
seoul["sales"] = seoul["sales"].fillna(0)
seoul["final_sales"] = seoul["sales"] * (1 - seoul["discount"])

# 2) 원본 반영은 loc로 명시적으로
df.loc[mask, "final_sales"] = seoul["final_sales"].to_numpy()

여기서 to_numpy() 를 쓰는 이유는 인덱스 정렬 이슈를 줄이기 위함입니다. 물론 인덱스가 보장된다면 df.loc[mask, "final_sales"] = seoul["final_sales"] 도 가능합니다. 다만 실무에서는 중간에 정렬/리샘플링/리셋 인덱스가 끼어드는 경우가 많아서, “내가 어떤 기준으로 맞춰 대입하는지”를 더 명시적으로 관리하는 습관이 좋습니다.

체크리스트: 이 경고를 만나면 이렇게 판단

1) 내 의도는 원본 수정인가, 부분집합 수정인가

  • 원본 수정 의도: df.loc[mask, col] = ...
  • 부분집합 수정 의도: subset = df.loc[mask, cols].copy()subset[...] = ...

2) 코드에 체인 인덱싱이 있는가

다음 형태가 보이면 의심하세요.

  • df[...][...] = ...
  • df[...].loc[...] = ... (상황에 따라 위험)
  • df.query(...)[...] = ...

3) 경고를 끄지 말고 raise로 잡아라

import pandas as pd
pd.options.mode.chained_assignment = "raise"

배치/노트북에서 조용히 지나가는 것보다, 빨리 실패하는 편이 훨씬 싸게 먹힙니다. 이런 “빠른 실패”는 데이터 파이프라인뿐 아니라 DB 데드락 분석 같은 문제에서도 동일하게 유효한 접근인데, 관련해서는 MySQL 8.0 InnoDB 데드락 원인추적·해결 실전 같은 글의 문제 해결 흐름도 참고할 만합니다.

결론: loc는 원본 수정의 표준, copy는 독립 작업의 표준

SettingWithCopyWarning 을 완전히 해결하는 방법은 요령이 아니라 원칙입니다.

  • 원본 DataFrame 을 수정하려면: loc 로 한 번에 대입
  • 부분집합을 따로 가공하려면: copy 로 독립성을 선언
  • 경고를 숨기지 말고: 개발 중에는 raise 로 강제

이 3가지만 지키면 SettingWithCopyWarning 은 더 이상 “찜찜한 경고”가 아니라, 코드 품질을 올려주는 유용한 안전장치가 됩니다.