← 기술 지식

기술 지식 / 프로그래밍 언어 / Python / 객체·프로토콜

Python 기본값·클로저·Deepcopy의 공유 상태

정의 시 평가와 호출 시 바인딩 조회를 구분하고 shallow copy·memo·순환·별칭·외부 자원·일관된 snapshot의 조건을 설명합니다.

언어·런타임 · 원리·구현·운영
기술 지식 트리

Python에서 예상 밖의 상태 공유는 값이 복사됐는지보다 객체가 언제 만들어졌고 어떤 참조 경로가 남았는지에서 생깁니다. 기본 인자·closure·shallow/deep copy를 호출 시점과 객체 정체성의 추적으로 비교합니다.

가변 기본값과 호출 간 공유 상태

def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add_item('a'))  # ['a']
print(add_item('b'))  # ['a', 'b']

기본 인자 표현식은 함수 정의를 평가할 때 실행됩니다. 같은 함수 객체를 호출할 때마다 새 리스트를 만들지 않으므로 두 호출은 같은 기본 리스트를 수정합니다. 이는 모든 메모리 증가를 뜻하는 누수라기보다 의도하지 않은 호출 간 공유입니다. 함수를 새로 정의하면 또 다른 기본 객체가 생긴다는 점도 구분합니다.

호출별 새 리스트가 필요하면 기본값 None을 두고 if bucket is None: bucket = []로 만듭니다. if not bucket은 호출자가 명시적으로 준 빈 리스트까지 대체하므로 같은 계약이 아닙니다. None도 유효 입력이면 전용 sentinel 객체를 사용합니다. 명시적으로 전달한 리스트를 수정하는 API인지 복사해서 보관하는 API인지도 정해야 합니다.

함수 객체의 __defaults__에 저장된 리스트가 두 호출 사이 같은 identity인지 is로 확인하면 값 누적의 원인이 드러납니다. 호출별 상태가 필요하면 None/sentinel 분기와 명시적으로 전달된 빈 리스트의 의미를 구분하고, caller-owned list를 수정할지 복사할지도 API에 적습니다.

기본값 평가 시점과 Closure의 늦은 조회

late = [lambda: i for i in range(3)]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in late])   # [2, 2, 2]
print([f() for f in fixed])  # [0, 1, 2]

late는 나중 실행할 때 공유 바인딩 i의 현재 값을 읽습니다. fixed는 각 lambda를 정의할 때 기본 인자 표현식 i를 평가해 그 값을 보관합니다. 기본 인자에 객체 참조를 고정해도 객체 내부까지 깊게 복제하는 것은 아닙니다.

구성 값이 정해지는 때 남을 수 있는 공유
bucket=[] 함수 정의 평가 같은 기본 리스트
now=clock() 함수 정의 평가 오래된 시각 값
closure의 바깥 변수 읽기 본문 실행 때 바인딩 조회 이후 재대입된 값
lambda x=x 해당 lambda 정의 평가 가변 참조 대상

현재 시각처럼 불변 값도 정의 때 계산하면 이후 요청에서 낡은 값이 됩니다. mutable 여부와 기본값 평가 시점은 서로 다른 축입니다. 의도적인 캐시는 기본 인자에 숨기기보다 소유 객체·상한·무효화·동시성 규칙을 드러냅니다.

loop가 끝난 뒤 late closure를 실행하면 세 함수가 같은 cell의 현재 값 2를 읽고, lambda i=i는 각 정의 시점의 값을 보관합니다. now=clock()도 mutable 여부와 별개로 정의 시점에 고정되므로 요청별 시각은 함수 본문이나 명시적 clock 인자로 받습니다.

얕은 복사와 중첩 객체 참조 공유

import copy
original = {'items': [1]}
shallow = copy.copy(original)
deep = copy.deepcopy(original)
shallow['items'].append(2)
print(original['items'], deep['items'])  # [1, 2] [1]

shallow의 items를 새 리스트로 대입하는 것은 원본 dict의 키를 바꾸지 않지만, 공유 리스트에 append하는 것은 원본에서도 보입니다. 수정할 경로와 복사할 경로를 맞춰야 합니다. copy.copy가 모든 사용자 타입에서 반드시 새 객체를 만드는지도 hook·불변 타입의 계약에 따라 다를 수 있습니다.

얕은 복사와 깊은 복사의 참조 범위original. shallow. 공유 items [1,2]. deep의 별도 items [1]원래 참조참조만 복사originalshallow공유 items [1,2]deep의 별도 items [1]
얕은 복사와 깊은 복사의 참조 범위 화살표는 참조입니다. original과 shallow는 같은 자식을 공유하고 deep은 지원되는 가변 자식을 새 그래프로 복사합니다.
그림을 글로 읽기
  • original
  • shallow
  • 공유 items [1,2]
  • deep의 별도 items [1]
  1. original → 공유 items [1,2]: 원래 참조
  2. shallow → 공유 items [1,2]: 참조만 복사

dict 자체의 identity는 달라도 original["items"] is shallow["items"]는 참일 수 있습니다. 따라서 child에 append하는 변형과 top-level child를 새 리스트로 대입하는 변형을 각각 실행해 어느 경로가 원본에 보이는지 예측합니다.

deepcopy memo와 순환·별칭 보존

원본의 a와 b가 같은 리스트를 가리키면, 한 번의 deepcopy에서는 그 리스트를 한 번 복사해 결과의 a와 b가 같은 새 리스트를 가리키게 할 수 있습니다. memo는 복사 중인 원본 객체와 이미 만든 복사본의 대응표라서, 자기 자신을 가리키는 리스트도 새 리스트를 먼저 등록한 뒤 그 참조를 재사용합니다. 그래서 순환을 무한히 따라가지 않고 원래의 별칭 관계도 보존합니다.

shared = []
graph = {'a': shared, 'b': shared}
graph['self'] = graph
result = copy.deepcopy(graph)
assert result['a'] is result['b']
assert result['a'] is not shared
assert result['self'] is result

각 항목에 deepcopy를 따로 호출하면 호출마다 memo가 새로 시작하므로 원래의 별칭 관계가 달라질 수 있습니다. 사용자 __deepcopy__ 구현도 재귀 호출 전에 자신의 복사본을 memo에 등록하는 규칙을 따라야 합니다. memo는 복사 중 방문한 객체의 대응을 기록하는 표이고, cyclic GC는 도달할 수 없는 메모리를 찾는 기능이라 둘은 다른 역할입니다.

한 번의 deepcopy에서는 a와 b가 같은 원본 child를 가리키는 별칭이 결과에서도 같은 새 child를 가리키고, self link도 결과 자신을 가리킵니다. 항목별로 따로 복사하면 memo 경계가 달라져 별칭 보존 기대가 바뀐다는 점을 테스트에 포함합니다.

deepcopy와 실행 자원의 독립성 한계

파일·소켓·lock·DB 연결은 deepcopy가 성공하거나 새 주소를 만들어도 독립 실행 자원이 된다고 가정하지 않습니다. 함수·불변 객체처럼 원래와 같은 객체를 돌려주는 유형도 있어 모든 노드의 주소가 달라야 한다는 테스트는 잘못될 수 있고, 사용자 hook이 캐시나 특정 자원을 공유하도록 규칙을 바꿀 수도 있습니다.

복사 중 다른 실행 흐름이 원본을 바꾸면 일관된 snapshot이 자동 생성되지 않습니다. 잠금·불변 입력·버전 경계가 필요합니다. 큰 그래프 전체 복사 대신 필요한 DTO로 명시 변환하거나 불변 노드를 공유하면 의미와 비용이 더 분명할 수 있습니다.

파일·소켓·lock을 복사해도 OS 자원이나 동기화 의미가 분리됐다고 볼 수 없습니다. 큰 mutable graph를 복사하는 대신 immutable snapshot 또는 필요한 DTO를 만드는 선택을 먼저 비교하고, 복사 중 writer가 바꿀 수 있으면 lock/version 경계를 둡니다.

반복 호출과 객체 그래프 정체성 시험

인자 생략·명시 빈 리스트·None·반복 정의·클로저 재대입을 나누고 is와 값 비교로 공유 범위를 확인합니다. deepcopy는 공유 자식·자기 순환·사용자 hook 실패·외부 핸들을 따로 검사합니다. 정답은 모든 것을 새 주소로 만드는 것이 아니라 의도한 소유·별칭 관계가 유지되는 것입니다.

값이 같은지와 identity가 같은지를 함께 검사합니다. 인자 생략·빈 리스트 전달·None·sentinel·재정의·closure 재대입·공유 child·순환 graph·외부 handle을 나누면 “복사됐다”라는 한 문장으로 서로 다른 계약을 덮지 않게 됩니다.

후속·관련 개념

이 개념의 확장

함께 읽을 문서

전체 기술 지식
선택 연습 · 관련 질문 4개

질문은 이해를 확인하는 보조 자료입니다. 학습 경로와 문서의 범위는 질문 수와 독립적입니다.