
결론부터 말씀드리면, pandas 3.0에서 조용히 깨지는 코드 대부분은 “체인 할당(chained assignment)” 패턴입니다. df[mask]["col"] = value처럼 한 줄로 쓰면 이제 바로 에러가 나서 오히려 고치기 쉽지만, 임시 변수에 담아 두 줄로 나눠 쓴 코드는 에러도 경고도 없이 그냥 원본을 바꾸지 못한 채 넘어갑니다. pandas 3.0은 이전까지 옵션으로 켜고 끌 수 있던 Copy-on-Write(CoW)를 기본이자 유일한 동작 방식으로 고정했고, 이 글은 그 변화 때문에 어떤 코드가 왜 조용히 깨지는지를 실제 코드로 짚어봅니다.
Copy-on-Write가 조용히 바꿔놓은 기본 규칙
Copy-on-Write는 원래 운영체제나 데이터베이스가 메모리를 아끼려고 쓰던 기법인 Copy-on-Write 개념을 DataFrame에 그대로 옮겨온 것입니다. 데이터를 실제로 복사하지 않고 원본과 메모리를 공유하다가, 둘 중 하나에 값을 쓰는 순간에만 비로소 복사본을 만듭니다.
pandas는 이 방식을 2.0부터 pd.set_option("mode.copy_on_write", True)로 선택할 수 있게 제공했고, 3.0부터는 이 옵션 자체를 없애고 CoW를 기본값으로 고정했습니다. 이 방향은 pandas 프로젝트의 공식 제안 문서인 PDEP-7에 정리되어 있는데, 핵심은 “슬라이스가 view인지 copy인지 내부 구조에 따라 달라지던 불확실한 동작을 없애고, 항상 예측 가능하게 만든다”는 것입니다.
문제는 이 “예측 가능”이 곧 “원본은 절대 안 바뀐다”는 뜻이라는 점입니다. 예전 코드 중에는 슬라이스가 우연히 view였던 덕분에 원본까지 바뀌는 걸 전제로 짜인 것들이 적지 않습니다. pandas 3.0에서 조용히 깨지는 코드는 대개 이런 전제 위에 서 있던 코드입니다.
체인 할당 코드는 한 줄이냐 두 줄이냐에 따라 결과가 다릅니다
가장 흔한 패턴부터 보겠습니다.
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
df[df["a"] > 1]["b"] = 999
print(df)
pandas 3.0에서는 이 코드가 실행되는 순간 pandas.errors.ChainedAssignmentError가 발생합니다. df[df["a"] > 1]이 만든 임시 객체에 값을 쓰려 했다는 걸 pandas가 같은 표현식 안에서 감지했기 때문입니다. 차라리 이건 다행입니다. 실행이 멈추니 바로 눈에 띕니다.

정작 조용히 깨지는 쪽은 이렇게 나눠 쓴 코드입니다.
tmp = df[df["a"] > 1]
tmp["b"] = 999
print(df) # 여전히 원래 값 그대로입니다
에러도 경고도 없습니다. tmp는 값이 바뀌지만 df는 조용히 그대로 남습니다. pandas의 체인 할당 감지는 같은 표현식 안에서만 작동하기 때문에, 변수에 한 번 담아 문장을 나누면 pandas 입장에서는 그냥 서로 다른 두 객체에 값을 넣는 것처럼 보입니다. 함수에 df를 넘기고 그 안에서 필터링해 값을 채우는 유틸 함수들이 특히 이 패턴에 잘 걸립니다.
세 패턴을 정리하면 아래와 같습니다.
| 코드 패턴 | pandas 3.0에서의 결과 |
|---|---|
df[df["a"]>1]["b"] = 1 (한 줄) |
ChainedAssignmentError 즉시 발생 |
tmp = df[df["a"]>1]; tmp["b"] = 1 (두 줄) |
에러·경고 없이 df는 그대로, tmp만 변경 |
df.loc[df["a"]>1, "b"] = 1 |
df가 정상적으로 변경됨 (권장 방식) |
해결책은 간단합니다. 조건과 대상 컬럼을 .loc[행조건, 열이름] = 값 한 번의 호출로 합치면 됩니다. 리팩터링이 번거롭다면, 최소한 두 줄짜리 체인 할당이 있는 함수부터 찾아 .loc로 바꾸는 작업이 우선순위가 높습니다.
.values로 넘파이 배열을 직접 고치던 코드는 어떻게 되나요?
두 번째로 자주 걸리는 패턴은 .values나 .to_numpy()로 뽑은 배열을 제자리에서 수정하는 코드입니다.
arr = df.to_numpy()
arr[0, 0] = 12345
print(df.iloc[0, 0])
CoW 이전에는 컬럼 dtype이 전부 같은 단일 블록 DataFrame이라면 to_numpy()가 내부 배열을 그대로 돌려주는 경우가 있었고, 이때는 arr을 고치면 df까지 같이 바뀌었습니다. 문서화된 동작이 아니라 내부 구조에 우연히 기댄 결과였습니다. pandas 3.0에서는 반환되는 배열이 원본과 메모리를 공유하지 않는 것을 기본으로 하기 때문에, 위 코드는 df.iloc[0, 0]을 그대로 출력합니다. 경우에 따라서는 배열이 쓰기 금지로 표시되어 arr[0, 0] = 12345 시점에 ValueError: assignment destination is read-only가 먼저 발생하기도 합니다.
넘파이·사이킷런 파이프라인에서 “DataFrame을 넘파이로 바꿔서 제자리 수정한 뒤 다시 DataFrame에 반영된다”고 가정한 코드가 있다면, 이 부분을 먼저 점검해 보시는 걸 권합니다. df.iloc[:, :] = arr처럼 명시적으로 다시 대입하는 코드로 바꾸면 CoW 여부와 무관하게 안전합니다.

CoW가 오히려 손해를 보는 경우도 있습니다
CoW가 장점만 있는 건 아닙니다. 트레이드오프를 하나 짚어보겠습니다.
inplace=True는 CoW 아래에서도 문법상 그대로 쓸 수 있지만, 내부적으로는 결국 새 데이터를 만들어 같은 이름에 다시 연결하는 방식으로 처리됩니다. 즉 “제자리에서 고쳐서 메모리를 아낀다”는 기존의 기대와 달리, df.fillna(0, inplace=True)가 df = df.fillna(0)보다 메모리나 속도 면에서 더 유리하다고 보장할 수 없습니다. inplace=True를 성능 최적화 수단으로 써온 코드라면 기대만큼의 이득이 사라졌을 가능성이 있습니다.
또 하나는 반복문 안에서 큰 DataFrame의 슬라이스를 여러 번 나눠 쓰는 경우입니다. CoW는 “값을 쓰는 첫 시점”에 복사를 만들기 때문에, 같은 원본에서 파생된 슬라이스 여러 개에 순서대로 값을 쓰면 그때마다 복사가 일어날 수 있습니다. 예전 코드가 (의도했든 아니든) view 공유에 기대 복사를 회피하고 있었다면, 3.0으로 옮긴 뒤 같은 반복문이 더 느려질 수 있습니다. 이런 경우는 처음부터 .loc로 원본에 한 번에 쓰거나, .copy()를 명시적으로 호출해 의도를 코드에 드러내는 편이 낫습니다.
SettingWithCopyWarning이 안 뜨는데 왜 값이 그대로일까요?
CoW 이전 pandas를 오래 써온 분이라면 SettingWithCopyWarning을 방패 삼아 “경고가 안 뜨면 괜찮다”고 여겨온 경우가 많습니다. pandas 3.0에서는 이 경고 체계 자체가 바뀌어서, 감지되는 체인 할당은 경고가 아니라 ChainedAssignmentError라는 예외로 바로 막힙니다.
문제는 앞서 본 것처럼 두 줄로 나눈 체인 할당은 애초에 감지 대상이 아니라는 점입니다. tmp = df[mask] 시점에 tmp가 변수에 저장되면서 참조가 하나 더 생기고, pandas는 이걸 “사용자가 의도적으로 복사본을 따로 쓰려는 것”으로 해석합니다. 그래서 경고도 에러도 내지 않고 그냥 tmp만 수정한 채 넘어갑니다. 결과적으로 “경고가 안 뜨니 안전하다”는 예전 경험칙이 3.0에서는 정반대로 작동할 수 있습니다. 경고·에러가 없다는 것이 곧 원본이 바뀌었다는 뜻은 아닙니다.
3.0 업그레이드 전 체크리스트
정리하면 pandas 3.0으로 옮기기 전에 확인할 지점은 세 가지가 아니라 딱 두 가지 코드 패턴입니다. 하나는 필터링한 결과를 변수에 담아두고 나중에 값을 쓰는 코드, 다른 하나는 .values나 .to_numpy()로 뽑은 배열을 제자리에서 수정하고 원본에 반영되길 기대하는 코드입니다. 두 패턴 모두 grep으로 = df\[ 형태나 .to_numpy() / .values 사용처를 훑어보는 것만으로도 상당수 찾아낼 수 있습니다.
찾은 부분은 .loc[행조건, 열이름] = 값 한 줄로 합치거나, 복사가 필요한 지점에는 .copy()를 명시적으로 붙여 의도를 코드에 남겨두는 방향으로 고치는 것이 가장 안전합니다. 테스트 코드가 있다면 CoW를 미리 켠 환경(pandas 2.x에서 pd.set_option("mode.copy_on_write", True))에서 한 번 돌려보고 실패하는 지점부터 손보는 방법도 실질적인 이행 경로가 됩니다.
