[파이썬 100강] 83강. copy 모듈로 얕은 복사와 깊은 복사를 안전하게 구분하기

[파이썬 100강] 83강. copy 모듈로 얕은 복사와 깊은 복사를 안전하게 구분하기

실무에서 리스트나 딕셔너리를 "복사했다"고 생각하고 수정했는데, 원본까지 같이 바뀌는 버그를 한 번쯤은 겪게 됩니다. 특히 설정값, API 응답 데이터, 템플릿 객체를 재사용할 때 이런 문제가 자주 터집니다. 이번 강의에서는 copy 모듈의 copy()(얕은 복사)와 deepcopy()(깊은 복사)를 정확히 구분해서, 데이터 오염 버그를 예방하는 방법을 바로 예제로 익힙니다.


핵심 개념

  • 얕은 복사(copy.copy): 바깥 컨테이너만 새로 만들고, 내부 중첩 객체는 원본 참조를 그대로 공유합니다.
  • 깊은 복사(copy.deepcopy): 중첩된 하위 객체까지 재귀적으로 새로 만들어, 원본과 복사본이 독립적으로 동작합니다.
  • = 대입은 복사가 아니라 **같은 객체를 가리키는 별칭(alias)**을 만드는 동작입니다.
  • 복사 전략은 "무조건 깊은 복사"가 아니라, 데이터 구조와 변경 범위를 기준으로 선택해야 합니다.

초보자가 가장 많이 헷갈리는 지점은 "겉보기로는 새 변수인데 왜 같이 바뀌지?"입니다. 파이썬 변수는 상자를 복제하는 개념이 아니라 객체를 가리키는 이름표에 가깝기 때문입니다. 그래서 리스트 안에 딕셔너리, 딕셔너리 안에 리스트처럼 중첩이 생기면 공유 참조가 쉽게 숨어듭니다.

copy.copy()는 빠르고 가볍지만 중첩 객체 공유가 남고, copy.deepcopy()는 안전하지만 데이터가 클수록 비용이 커집니다. 즉, 성능과 안전성의 균형이 중요합니다. 실무에서는 "어디까지 변경할지"를 먼저 정하고, 그 경계에 맞는 복사 방식을 택하면 대부분의 버그를 예방할 수 있습니다.


기본 사용

예제 1) 대입(=)은 복사가 아니라 같은 객체 참조

>>> profile = {"name": "건우", "skills": ["python", "sql"]}
>>> alias = profile
>>> alias["name"] = "KUNWOO"
>>> alias["skills"].append("docker")
>>> profile
{'name': 'KUNWOO', 'skills': ['python', 'sql', 'docker']}

해설:

  • alias = profile은 새 객체를 만들지 않습니다.
  • aliasprofile이 같은 객체를 보므로, 어느 쪽에서 바꿔도 둘 다 변합니다.
  • 상태를 분리하려면 복사를 사용해야 합니다.

예제 2) 얕은 복사: 바깥은 분리, 안쪽은 공유

>>> import copy
>>> config = {
...     "env": "prod",
...     "db": {"host": "localhost", "port": 5432},
...     "features": ["search", "report"],
... }
>>> copied = copy.copy(config)
>>> copied["env"] = "staging"
>>> copied["db"]["host"] = "staging-db"
>>> copied["features"].append("realtime")
>>> config["env"], config["db"], config["features"]
('prod', {'host': 'staging-db', 'port': 5432}, ['search', 'report', 'realtime'])

해설:

  • env처럼 1단계 값 변경은 원본에 영향이 없습니다(바깥 dict 분리).
  • 하지만 db, features는 중첩 객체라 공유되어 원본도 함께 바뀝니다.
  • 얕은 복사는 "겉껍데기만 복사"라는 걸 꼭 기억해야 합니다.

예제 3) 깊은 복사: 중첩 구조까지 완전 분리

>>> import copy
>>> template = {
...     "job": "daily-report",
...     "meta": {"owner": "data-team", "retry": 1},
...     "targets": ["mail", "slack"],
... }
>>> isolated = copy.deepcopy(template)
>>> isolated["meta"]["retry"] = 5
>>> isolated["targets"].append("dashboard")
>>> template
{'job': 'daily-report', 'meta': {'owner': 'data-team', 'retry': 1}, 'targets': ['mail', 'slack']}
>>> isolated
{'job': 'daily-report', 'meta': {'owner': 'data-team', 'retry': 5}, 'targets': ['mail', 'slack', 'dashboard']}

해설:

  • deepcopy는 하위 객체까지 새로 만드므로 원본 데이터가 오염되지 않습니다.
  • 템플릿 객체에서 실행별 파라미터를 덮어쓸 때 특히 유용합니다.

예제 4) 리스트 슬라이싱/dict.copy()도 기본은 얕은 복사

>>> rows = [[1, 2], [3, 4]]
>>> rows2 = rows[:]   # 또는 list(rows)
>>> rows2[0].append(99)
>>> rows
[[1, 2, 99], [3, 4]]

해설:

  • "복사처럼 보이는" 다른 문법도 중첩에서는 얕은 복사라는 점이 같습니다.
  • 중첩 구조를 독립시켜야 하면 deepcopy가 필요합니다.

자주 하는 실수

실수 1) 함수 기본 인자로 가변 객체를 두고 누적 오염

>>> def add_tag(tag, bucket=[]):
...     bucket.append(tag)
...     return bucket
...
>>> add_tag("python")
['python']
>>> add_tag("fastapi")
['python', 'fastapi']

원인:

  • 기본 인자는 함수 정의 시 1번만 생성됩니다.
  • 호출마다 같은 리스트를 재사용해 상태가 누적됩니다.

해결:

>>> def add_tag(tag, bucket=None):
...     if bucket is None:
...         bucket = []
...     bucket.append(tag)
...     return bucket
...
>>> add_tag("python")
['python']
>>> add_tag("fastapi")
['fastapi']

실수 2) API 응답을 얕게 복사하고 후처리하다 원본 캐시 오염

>>> import copy
>>> cached = {"items": [{"id": 1, "status": "new"}]}
>>> view = copy.copy(cached)
>>> view["items"][0]["status"] = "done"
>>> cached
{'items': [{'id': 1, 'status': 'done'}]}

원인:

  • 상위 dict만 복제되고 items 내부 리스트/딕셔너리는 공유되었습니다.

해결:

>>> safe_view = copy.deepcopy(cached)
>>> safe_view["items"][0]["status"] = "processing"
>>> cached
{'items': [{'id': 1, 'status': 'done'}]}

실수 3) 무조건 deepcopy를 남발해 성능 저하

  • 증상: 대량 데이터 처리 시 처리시간 증가, 메모리 급증
  • 원인: 변경하지 않는 큰 구조까지 매번 깊은 복사
  • 해결:
    • 변경 대상이 1단계 값이면 얕은 복사 + 선택적 재할당으로 충분한지 먼저 검토
    • 구조를 불변(튜플, frozenset, dataclass frozen)로 바꿔 복사 필요성을 줄임
    • 핫패스에서는 필요한 필드만 새 dict로 재구성하는 방식 고려

실무 패턴

  • 입력 검증 규칙

    • 함수 경계(입력 직후)에서 "원본 보존이 필요한 데이터인지"를 먼저 구분합니다.
    • 외부에서 받은 payload를 바로 수정하지 말고, 복사 정책을 명시한 뒤 처리합니다.
  • 로그/예외 처리 규칙

    • 원본과 가공본의 핵심 필드(hash, 길이, 개수)를 함께 로그에 남겨 데이터 오염 여부를 빠르게 추적합니다.
    • "왜 원본이 바뀌었지?" 이슈가 반복되면 단위 테스트에 참조 분리 검증(is, id)을 추가합니다.
  • 재사용 함수/구조화 팁

    • 팀 공용 유틸로 clone_for_edit(data) 같은 래퍼를 만들어 도메인별 복사 정책(얕은/깊은)을 통일합니다.
    • 템플릿 기반 생성은 deepcopy(template) 후 필요한 필드만 덮어쓰는 패턴으로 표준화합니다.
  • 성능/메모리 체크 포인트

    • 큰 데이터에서 deepcopy는 비용이 큽니다. 프로파일링 없이 습관적으로 쓰지 않습니다.
    • 변경 범위가 작으면 "부분 새 객체 생성"이 더 빠르고 메모리 친화적일 수 있습니다.

오늘의 결론

한 줄 요약: 복사 버그의 핵심은 문법이 아니라 참조 공유를 이해하는 것이고, 얕은/깊은 복사를 변경 범위 기준으로 선택해야 안전하다.

기억할 것:

  • =는 복사가 아니라 같은 객체를 가리키는 별칭입니다.
  • 중첩 구조를 독립적으로 수정하려면 deepcopy가 필요합니다.
  • 성능이 중요한 구간에서는 무조건 깊은 복사 대신 부분 재구성을 우선 검토하세요.

연습문제

  1. settings = {"mode": "prod", "db": {"host": "127.0.0.1"}}를 얕은 복사한 뒤 db.host를 바꾸면 왜 원본이 바뀌는지 직접 확인해 보세요.
  2. 주문 템플릿 dict(중첩 리스트 포함)를 deepcopy로 복제한 뒤 항목을 추가해도 원본이 유지되는지 검증 코드를 작성해 보세요.
  3. 대량 리스트 처리에서 deepcopy 방식과 "필요 필드만 새 dict 구성" 방식의 실행 시간을 time.perf_counter()로 비교해 보세요.

이전 강의 정답

  1. itemgetterordersprice 내림차순 정렬
>>> from operator import itemgetter
>>> orders = [{"id": 1, "price": 5000}, {"id": 2, "price": 1200}, {"id": 3, "price": 8000}]
>>> sorted(orders, key=itemgetter("price"), reverse=True)
[{'id': 3, 'price': 8000}, {'id': 1, 'price': 5000}, {'id': 2, 'price': 1200}]
  1. Product(name, category, rank)category, rank 순으로 정렬
>>> from dataclasses import dataclass
>>> from operator import attrgetter
>>> @dataclass
... class Product:
...     name: str
...     category: str
...     rank: int
...
>>> products = [
...     Product("A", "book", 2),
...     Product("B", "book", 1),
...     Product("C", "toy", 1),
... ]
>>> sorted(products, key=attrgetter("category", "rank"))
[Product(name='B', category='book', rank=1), Product(name='A', category='book', rank=2), Product(name='C', category='toy', rank=1)]
  1. methodcallerstrip -> lower -> replace(" ", "") 정규화
>>> from operator import methodcaller
>>> strip_ = methodcaller("strip")
>>> lower_ = methodcaller("lower")
>>> no_space = methodcaller("replace", " ", "")
>>> data = [")  Hello ", " PYTHON ", " data "]
>>> [no_space(lower_(strip_(x))) for x in data]
[')hello', 'python', 'data']

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: copy (copy, deepcopy)
  • 재현 팁:
    • id(obj)로 객체 동일성 확인
    • 중첩 구조는 1단계와 하위 단계를 분리해서 변화 추적
    • 성능 비교는 샘플 데이터 크기를 키워가며 측정