값과 공유 참조 확인하기
80분 안팎
학습 목표
복사와 참조 공유를 수정 결과로 구분합니다.
개념
조회가 원본을 바꾸는 이유
조회 함수는 값을 보여 주려는 용도인데 반환 기록의 제목을 고친 뒤 저장소가 바뀌면 호출자가 내부 상태를 우연히 수정한 것입니다. Python에서 이름 대입은 객체를 복제하지 않습니다. found = store[key]는 같은 기록 딕셔너리를 가리키는 이름을 추가합니다. found["title"]을 대입하면 그 객체가 바뀌므로 store에서도 수정된 제목을 읽습니다. 이번 목표는 공유를 작은 실험으로 확인하고 조회와 목록 반환에서 변경 범위를 분리하는 것입니다.
같음과 같은 객체는 다릅니다
a == b는 값이 같은지 비교하고 a is b는 같은 객체인지 비교합니다. dict.copy로 새 딕셔너리를 만들면 처음 내용은 같아서 ==는 True지만 is는 False입니다. 복사본 한 필드를 변경하면 값도 달라집니다. 검증에서 출력만 확인하지 말고 원본 내용과 복사본 내용을 함께 읽습니다. 정수나 문자열의 is 결과로 값 비교를 하지는 않습니다. 이 레슨은 가변 딕셔너리의 공유 여부를 관찰하며 ID 문자열의 동등성 판단에는 ==나 딕셔너리 키 조회를 사용합니다.
기록 한 건을 복사합니다
find_record는 record = store.get(key)로 내부 기록을 얻은 다음 None이 아니면 record.copy를 반환합니다. 새 바깥 딕셔너리의 title·pages·cost·completed·id 필드는 원래 값을 가리킵니다. 지금 필드는 문자열과 정수여서 반환 필드에 새 값을 대입해도 원본 딕셔너리의 필드 연결은 바뀌지 않습니다. 따라서 이 계약에서는 얕은 복사로 격리할 수 있습니다. copy가 모든 종류의 데이터를 완전히 독립시키는 도구라고 확대해서 설명하지 않습니다.
목록만 복사하면 부족합니다
list(store.values())는 바깥 리스트만 새로 만들며 안의 기록 딕셔너리는 원본과 공유합니다. result.clear를 하면 새 리스트만 비지만 result[0]["cost"] = 0은 원본 기록을 바꿀 수 있습니다. 미션 list_records는 각 record.copy 결과를 새 리스트에 담습니다. 바깥 목록과 각 기록을 각각 복사해야 호출자가 반환 목록을 지우거나 반환 기록의 필드를 고쳐도 내부 저장소가 유지됩니다. 테스트도 두 수정 종류를 따로 실행해 책임 범위를 확인합니다.
중첩 가변 필드를 발견합니다
tags가 리스트인 기록을 얕게 복사하면 tags 리스트는 공유됩니다. copied["tags"].append는 원본 tags도 바꿉니다. 이 경우 copy.deepcopy를 쓰거나 반환 시 tags도 list로 복사하는 등 중첩 구조에 맞는 정책이 필요합니다. 깊은 복사는 비용과 객체 구조에 따른 고려가 있어 기본값으로 무작정 붙이지 않습니다. 이번 미션은 평평한 문자열·정수 필드만 사용합니다. 향후 파일에서 중첩 데이터를 불러오게 되면 이전 복사 계약을 다시 검토한다는 제한을 문서에 남깁니다.
추가 시점의 공유도 점검합니다
외부에서 받은 기록 딕셔너리를 그대로 저장한 뒤 호출자가 바꾸면 저장소도 바뀔 수 있습니다. 이번 add_record는 원문 문자열들을 받고 검증 후 새 기록 딕셔너리를 만듭니다. 호출자가 저장할 딕셔너리를 직접 넘기는 API가 아니므로 이러한 별칭을 줄입니다. 원본 store는 프로그램 내부 상태이므로 외부 호출자는 find_record와 list_records로 읽는다는 약속입니다. 복사 반환이 호출자의 store 직접 변경까지 막는 보안 경계라고 주장하지 않습니다.
실패 전 스냅샷을 제대로 만듭니다
before = store라고 쓰면 상태 보존 테스트도 같은 객체를 비교하게 됩니다. store가 바뀌어도 before가 함께 바뀌어 검사가 통과할 수 있습니다. 평평한 기록이면 각 기록을 복사해 독립 스냅샷을 만들고 일반적인 테스트에서는 copy.deepcopy로 전체 기대 상태를 보관합니다. 호출 전후 길이만 비교하면 제목이 덮어써진 버그를 놓칩니다. 전체 딕셔너리를 비교하고 성공 뒤 반환 기록을 바꾸는 검사까지 추가해 실패 보존과 읽기 격리를 각각 확인합니다.
브라우저 과제에서 복사 경계를 확인합니다
첫 줄은 JSON 배열이고 각 원소는 id와 title 문자열 필드를 가집니다. ID는 고유합니다. 다음 두 줄은 조회 ID와 새 제목입니다. 조회 함수가 돌려준 기록의 title만 새 제목으로 바꾸고 원본 제목과 반환 제목을 차례로 출력합니다. 없는 ID면 NOT_FOUND 한 줄만 출력합니다. 제공된 입출력 부분은 유지하고 find 함수의 공유 반환을 고칩니다. 같은 제목으로 바꾸는 테스트도 있어 출력이 같아도 복사가 필요한 이유를 다른 변경 사례와 함께 확인합니다.
원본과 반환을 각각 출력합니다
브라우저 과제의 json.loads는 입력 문자열을 배열과 딕셔너리로 해석합니다. store 생성 뒤 result를 받고 새 제목을 대입한 다음 ORIGINAL과 RETURNED를 구분해 출력합니다. 원본도 새 제목이면 find가 공유 객체를 반환한 것입니다. 새 제목이 원본과 같은 사례만 실행하면 공유 버그를 발견하지 못합니다. 따라서 다른 제목과 한글 내부 공백이 있는 제목을 함께 시험합니다. 없는 ID에서는 result가 None이므로 title 필드를 대입하기 전에 분기를 마쳐야 합니다.
목록과 필터 반환을 모두 검사합니다
list_records의 전체 결과가 안전해도 completed_only=True 경로에서 원본을 그대로 반환할 수 있습니다. 같은 저장소를 준비해 두 경로의 반환 cost를 바꾸고 저장소 전체가 그대로인지 비교합니다. 반환 리스트를 clear하는 검사도 별도로 실행합니다. 함수 안의 복사 위치를 조건 통과 뒤에 두면 결과에 포함되는 기록만 복사합니다. 검토자는 결과 생성 줄에 copy가 있는지뿐 아니라 다른 return 경로에 공유 기록을 넘기는 코드가 남았는지 확인합니다. 격리는 한 예제보다 모든 반환 경로에 관한 계약입니다.
복사 정책을 문서로 남깁니다
docs/structures.md에는 복사 대상이 바깥 목록과 각 기록 딕셔너리라는 점을 적습니다. 필드가 문자열·정수라는 제한과 중첩 가변 값 추가 시 재검토 조건도 설명합니다. 반환에서 id를 바꾸어도 원본 딕셔너리의 키와 원본 id가 유지되어야 합니다. 현재 테스트는 그 수정도 확인합니다. 호출자가 원본 store를 직접 가지고 변경하는 경우까지 막아 주는 구조는 아니므로 내부 저장소를 직접 편집하지 않는 호출 규칙을 함께 남깁니다. 이후 모듈의 저장 기능도 반환 복사 계약을 이어받습니다.
따라하기
공유와 기록 복사
다음 코드를 demo.py에 저장하고 python3 demo.py로 실행합니다. 단계마다 파일 전체를 교체하므로 이전 단계의 변수에 의존하지 않습니다.
record = {"title": "달빛"}
shared = record
shared["title"] = "별빛"
print(record["title"], shared is record)
returned = record.copy()
returned["title"] = "새 제목"
print(record["title"], returned["title"], returned is record)
실행 결과
별빛 True 별빛 새 제목 False
목록 복사 한계
다음 코드를 demo.py에 저장하고 python3 demo.py로 실행합니다. 단계마다 파일 전체를 교체하므로 이전 단계의 변수에 의존하지 않습니다.
records = [{"title": "달빛"}]
outer = list(records)
outer[0]["title"] = "별빛"
print(records[0]["title"])
protected = [r.copy() for r in records]
protected[0]["title"] = "다른 제목"
protected.clear()
print(records[0]["title"], len(records))
실행 결과
별빛 별빛 1
중첩 필드 경계
다음 코드를 demo.py에 저장하고 python3 demo.py로 실행합니다. 단계마다 파일 전체를 교체하므로 이전 단계의 변수에 의존하지 않습니다.
import copy
record = {"title": "달빛", "tags": ["독서"]}
shallow = record.copy()
shallow["tags"].append("완료")
print(record["tags"])
deep = copy.deepcopy(record)
deep["tags"].append("추천")
print(record["tags"])
print(deep["tags"])
실행 결과
['독서', '완료'] ['독서', '완료'] ['독서', '완료', '추천']
확인 문제
실습
첫 줄은 id·title 문자열 필드의 JSON 배열(고유 ID)입니다. 다음 줄은 조회 ID, 마지막 줄은 새 제목입니다. 제공된 호출부는 반환 기록의 제목을 바꿉니다. find를 완성해 원본 제목은 보존하고 반환 제목만 바뀌게 합니다. 출력은 ORIGINAL|원래제목 뒤 RETURNED|새제목이며 없는 ID면 NOT_FOUND 한 줄입니다. 이번 필드는 문자열뿐입니다.
모범 답안
import json
def find(store, key):
record = store.get(key)
return None if record is None else record.copy()
records = json.loads(input())
store = {record["id"]: record for record in records}
key = input()
new_title = input()
result = find(store, key)
if result is None:
print("NOT_FOUND")
else:
result["title"] = new_title
print("ORIGINAL|" + store[key]["title"])
print("RETURNED|" + result["title"])
더 읽기
면접 질문
- 리스트 대신 딕셔너리를 선택하는 사례를 설명합니다.