
틀린 부분이 있다면 댓글로 알려주신다면 감사하겠습니다.
기술 면접과는 관련이 없지만, 파이썬과 메모리에 대해 어느 정도 이해한 후에 갑자기 생각난 일입니다. 작년 이맘때쯤 사이드 프로젝트를 하고 있었던 상황입니다. 딕셔너리에 대한 키 값을 할당받은 다른 변수에서 값을 수정했더니 동일하게 값이 수정되는 문제가 발생했습니다. 이 문제의 원인을 몰라서 파이썬 단톡방에 물어봤을 때, 깊은 복사에 대해 알아보라는 조언을 얻었습니다. 그 후 문제를 메모만 해놓고 잊어버리고 있었는데, 이번 기회에 공부해보면 좋을 듯해서 작성해봅니다.

그때 제가 동일한 값을 변수에 할당받았을 때 객체의 타입은 딕셔너리였습니다. 파이썬을 처음 공부할 때 immutable과 mutable에 대해서 살펴봤던 적이 있습니다. 그때 배웠던 내용을 어렴풋이 기억해보면, immutable 객체는 변경할 수 없고, mutable 객체는 변경할 수 있다는 것이었습니다. 그래서 다른 변수에 값을 할당받은 후에도 값을 수정하면 같이 수정되었던 이유가 딕셔너리가 mutable 객체였기 때문이라는 생각에 도달했습니다.
https://www.pythonpool.com/python-data-types
https://docs.python.org/es/3.11/reference/datamodel.html#the-standard-type-hierarchy
https://www.geeksforgeeks.org/mutable-vs-immutable-objects-in-python/
list, dict, set, bytearray 등이 있으며 객체는 생성된 이후에도 그 값을 변경할 수 있습니다.
하나씩 예시를 본다면 이해가 빠릅니다.
# 리스트 생성
my_list = [1, 2, 3]
# 요소 추가
my_list.append(4)
print(my_list) # [1, 2, 3, 4]
# 요소 삽입
my_list.insert(1, 5)
print(my_list) # [1, 5, 2, 3, 4]
# 요소 삭제
my_list.remove(2)
print(my_list) # [1, 5, 3, 4]
# 요소 수정
my_list[0] = 10
print(my_list) # [10, 5, 3, 4]
# 딕셔너리 생성
my_dict = {"name": "Alice", "age": 25}
# 요소 추가 및 수정
my_dict["age"] = 26
my_dict["city"] = "New York"
print(my_dict) # {'name': 'Alice', 'age': 26, 'city': 'New York'}
# 요소 삭제
del my_dict["city"]
print(my_dict) # {'name': 'Alice', 'age': 26}
# 키-값 쌍 접근
print(my_dict["name"]) # Alice
# 집합 생성
my_set = {1, 2, 3}
# 요소 추가
my_set.add(4)
print(my_set) # {1, 2, 3, 4}
# 요소 삭제
my_set.remove(2)
print(my_set) # {1, 3, 4}
# 집합 연산
another_set = {3, 4, 5}
union_set = my_set.union(another_set)
print(union_set) # {1, 3, 4, 5}
intersection_set = my_set.intersection(another_set)
print(intersection_set) # {3, 4}
# 바이트 배열 생성
my_bytearray = bytearray(b"hello")
# 요소 변경
my_bytearray[0] = 72 # ASCII 코드 72는 'H'
print(my_bytearray) # bytearray(b'Hello')
# 요소 추가
my_bytearray.append(33) # ASCII 코드 33은 '!'
print(my_bytearray) # bytearray(b'Hello!')
# 슬라이싱 및 부분 변경
my_bytearray[1:5] = b"i"
print(my_bytearray) # bytearray(b'Hi!')
mutable 객체는 객체를 변경해도 메모리 주소값이 변경되지 않습니다.
# 리스트 (List) 예제
list1 = [1, 2, 3]
print(f"Original list ID: {id(list1)}")
# 리스트 변경
list1.append(4)
print(f"Modified list ID: {id(list1)}")
# 딕셔너리 (Dictionary) 예제
dict1 = {"key1": "value1"}
print(f"Original dict ID: {id(dict1)}")
# 딕셔너리 변경
dict1["key2"] = "value2"
print(f"Modified dict ID: {id(dict1)}")
# 집합 (Set) 예제
set1 = {1, 2, 3}
print(f"Original set ID: {id(set1)}")
# 집합 변경
set1.add(4)
print(f"Modified set ID: {id(set1)}")
Original list ID: 140200636486016
Modified list ID: 140200636486016
Original dict ID: 140200636559488
Modified dict ID: 140200636559488
Original set ID: 140200636524896
Modified set ID: 140200636524896
int, float, str, tuple, frozenset 등이 있으며 객체는 생성된 이후에는 그 값을 변경할 수 없습니다.
immutable에서 변경을 하려고 할 때 에러가 발생합니다.
a = "hello"
a[0] = "H" # 오류 발생, 문자열은 변경 불가
immutable 객체는 객체를 변경할 경우 다른 메모리 주소를 참조하게 됩니다.
# 정수 (Integer) 예제
int1 = 42
print(f"Original int ID: {id(int1)}")
# 정수 변경
int1 = 43
print(f"Modified int ID: {id(int1)}")
# 문자열 (String) 예제
str1 = "hello"
print(f"Original str ID: {id(str1)}")
# 문자열 변경
str1 = "world"
print(f"Modified str ID: {id(str1)}")
# 튜플 (Tuple) 예제
tuple1 = (1, 2, 3)
print(f"Original tuple ID: {id(tuple1)}")
# 튜플 변경
tuple1 = (4, 5, 6)
print(f"Modified tuple ID: {id(tuple1)}")
Original int ID: 9794400
Modified int ID: 9794432
Original str ID: 140472287810672
Modified str ID: 140472287810992
Original tuple ID: 140472287805920
Modified tuple ID: 140472287805440
Mutable 객체는 데이터를 조작할 수 있어 유연성이 좋습니다. 반면에 immutable 객체는 상태를 변경할 수 없는 객체로, 경합 조건(race condition)이 발생하지 않으며 락(lock)이 필요하지 않습니다. 이러한 특성 덕분에 immutable 객체는 스레드 안전성을 보장받을 수 있으며, 무단 변경이 불가능하여 mutable 객체보다 보안성이 높습니다.
mutable의 경우도 스레드 안정성을 보장받기 위해 기본적으로 파이썬에서는 GIL(Global Interpreter Lock)라는 하나의 스레드로만 바이트 코드를 실행할 수 있도록 하는 것입니다. (이 부분에 대해서는 다음장에 알아보려고 합니다.)
immutable 객체 존재 이유 및 장점
https://en.wikipedia.org/wiki/Thread_safety
https://wiki.python.org/moin/GlobalInterpreterLock
https://docs.python.org/ko/3/library/copy.html
mutable 객체는 같은 메모리 주소를 참조하기 때문에, 서론에서 설명한 것과 같은 문제가 발생할 수 있습니다. 이를 해결하기 위해 deep copy를 사용하여 독립적인 객체로 만들어 문제를 해결할 수 있습니다. 아래는 그와 관련된 개념입니다.
얕은 복사는 객체의 최상위 레벨의 데이터만 복사하고, 중첩된 객체(예: 리스트 안의 리스트)는 원본 객체와 같은 참조를 가집니다.
import copy
original_list = [1, 2, [3, 4]]
shallow_copied_list = copy.copy(original_list)
print(original_list) # [1, 2, [3, 4]]
print(shallow_copied_list) # [1, 2, [3, 4]]
shallow_copied_list[2].append(5)
print(original_list) # [1, 2, [3, 4, 5]]
print(shallow_copied_list) # [1, 2, [3, 4, 5]]
original_list와 shallow_copied_list는 최상위 레벨에서 다른 객체이지만, 내부의 리스트 [3, 4]는 동일한 객체를 참조합니다. 따라서, shallow_copied_list의 내부 리스트를 변경하면 original_list에도 영향을 미치며 객체의 중첩된 내용이 변경되지 않을 때 유용합니다.
깊은 복사는 모든 레벨의 객체를 재귀적으로 복사하여 원본 객체와 완전히 독립적인 복사본을 만듭니다.
import copy
original_list = [1, 2, [3, 4]]
deep_copied_list = copy.deepcopy(original_list)
print(original_list) # [1, 2, [3, 4]]
print(deep_copied_list) # [1, 2, [3, 4]]
deep_copied_list[2].append(5)
print(original_list) # [1, 2, [3, 4]]
print(deep_copied_list) # [1, 2, [3, 4, 5]]
위 예제에서 deep_copied_list는 original_list와 완전히 독립적이기 때문에, deep_copied_list의 내부 리스트를 변경해도 original_list에는 영향을 미치지 않으며 객체의 중첩된 내용이 변경될 수 있고, 원본 객체에 영향을 미치지 않도록 하고 싶을 때 유용합니다.
그래서 mutable과 같이 변경될 수 있는 객체의 경우 내용을 독립적인 객체로 변경하고 싶을 때 깊은 복사를 사용합니다. 하지만 이로 인해 생길 수 있는 메모리를 할당 하게된다면 이것또한 메모리를 쓰기에 메모리 낭비를 조심해야합니다.