얕은 복사 vs 깊은 복사

리스트를 복사했는데 원본이 같이 바뀌는 당황스러운 현상. 얕은 복사가 안쪽 객체를 공유하는 원리와 copy.deepcopy로 끝까지 떼어내는 법을 정리합니다.

· 7 min read · PALDYN Team

지난 글에서 가변과 불변의 차이를 이야기했다면, 이번에는 그 가변성이 가장 자주 사고를 일으키는 지점 — 복사를 다룬다. “리스트를 복사해서 따로 두고 작업했는데, 원본까지 같이 바뀌어 있더라”는 경험은 파이썬을 쓰다 보면 거의 누구나 한 번은 겪는다. 버그처럼 보이지만, 파이썬이 객체를 어떻게 가리키는지 이해하면 완전히 예측 가능한 동작이다.

대입은 복사가 아니다

먼저 짚을 것은, 대입(=)은 복사가 아니라는 점이다. 파이썬에서 변수는 값을 담는 상자가 아니라 객체를 가리키는 이름표다. b = aa가 가리키는 객체를 복제하는 게 아니라, 같은 객체에 이름표 하나를 더 붙이는 것이다.

a = [1, 2, 3]
b = a          # 복사가 아니라 같은 객체에 이름 하나 더
b.append(4)
print(a)       # [1, 2, 3, 4]  ← a도 바뀐다
print(a is b)  # True — 둘은 같은 객체

그래서 “따로 쓰고 싶다”면 명시적으로 복사해야 한다. 여기서 얕은 복사와 깊은 복사의 구분이 등장한다.

얕은 복사 — 바깥만 복제하고 안쪽은 공유

얕은 복사(shallow copy)는 가장 바깥쪽 컨테이너만 새로 만들고, 그 안에 든 원소들은 원본과 똑같은 객체를 가리키게 한다. 슬라이스 a[:], list(a), copy.copy(a)가 모두 얕은 복사다.

a = [1, 2, 3]
b = a[:]        # 얕은 복사
b.append(4)
print(a)        # [1, 2, 3]  ← 이번엔 a가 안 바뀐다
print(a is b)   # False — 바깥 리스트는 별개

원소가 숫자나 문자열처럼 불변인 경우에는 얕은 복사로 충분하다. 바깥 리스트가 별개이니 b에 원소를 추가해도 a는 그대로다. 문제는 원소가 가변 객체(중첩 리스트, 딕셔너리 등)일 때 생긴다.

a = [[1, 2], 3]
b = a[:]            # 얕은 복사
b[0].append(99)     # 안쪽 리스트를 건드리면?
print(a)            # [[1, 2, 99], 3]  ← a까지 바뀐다!

바깥 리스트는 떼어냈지만, 0번 자리의 안쪽 리스트는 여전히 ab가 같은 객체를 공유하기 때문이다. b[0]을 건드리는 순간 그건 a[0]을 건드리는 것과 같다.

바깥은 복제, 안쪽은?

깊은 복사 — 끝까지 따라가며 복제

깊은 복사(deep copy)는 이 문제를 해결한다. copy.deepcopy는 바깥부터 안쪽까지 재귀적으로 모든 가변 객체를 복제해서, 원본과 어떤 객체도 공유하지 않는 완전히 독립된 사본을 만든다.

import copy

a = [[1, 2], 3]
b = copy.deepcopy(a)
b[0].append(99)
print(a)   # [[1, 2], 3]      ← a는 그대로
print(b)   # [[1, 2, 99], 3]  ← b만 바뀐다

이제 안쪽 리스트까지 별개이므로, b를 아무리 깊숙이 건드려도 a는 영향을 받지 않는다.

얕은 복사의 함정과 해결

어느 쪽을 쓸까

선택 기준은 단순하다. 복사하려는 구조가 한 겹이면(원소가 모두 불변이면) 얕은 복사로 충분하다. a[:]list(a), dict(d)가 가볍고 빠르다. 반면 리스트 안에 리스트, 딕셔너리 안에 딕셔너리처럼 가변 객체가 중첩되어 있고 그 안쪽까지 독립적으로 수정할 거라면 copy.deepcopy가 필요하다.

다만 deepcopy는 공짜가 아니다. 구조 전체를 재귀적으로 훑어 복제하므로 큰 자료에서는 느리고 메모리도 더 쓴다. 그래서 무조건 deepcopy를 쓰기보다, “내가 정말 안쪽까지 떼어내야 하는가”를 먼저 따지는 편이 좋다. 종종 더 나은 답은 복사 자체를 피하는 것 — 즉 데이터를 불변(튜플, frozenset 등)으로 설계해 애초에 공유돼도 안전하게 만드는 것이다.

import copy

# 얕게 복사 후 안쪽만 골라 새로 만드는 절충안
a = [[1, 2], [3, 4]]
b = [row[:] for row in a]   # 각 안쪽 리스트만 얕게 복사
b[0].append(99)
print(a)   # [[1, 2], [3, 4]]  ← 안전

이렇게 “필요한 깊이만큼만” 복사하는 방식은 deepcopy보다 빠르면서도 흔한 두 겹 구조를 안전하게 다룬다.

정리

대입은 복사가 아니라 같은 객체에 이름을 더 붙이는 일이다. 따로 쓰려면 명시적으로 복사해야 하는데, 얕은 복사(a[:], list(a), copy.copy)는 바깥 컨테이너만 새로 만들고 안쪽 객체는 원본과 공유한다. 그래서 중첩된 가변 객체를 건드리면 원본까지 바뀌는 함정이 생긴다. 안쪽까지 완전히 독립시키려면 copy.deepcopy를 쓰되, 비용이 있으니 정말 필요한지 따지고, 가능하면 불변 설계로 복사 자체를 줄이는 편이 가장 깔끔하다.


지난 글: 리스트 vs 튜플, 언제 무엇을 쓸까

다음 글: 왈러스 연산자 vs 일반 대입


읽어주셔서 감사합니다. 😊