파이썬 네임스페이스와 얕은 복사 vs 깊은 복사

파이썬 얕은 복사와 깊은 복사

파이썬 관련된 문서를 보다가 수년 동안 파이썬으로 프로그램을 개발하면서 단 한 번도 문제가 되지도 않았고 의문도 갖지 않았던 복사 관련한 글을 보게 되었습니다. 문서를 보면서 다른 언어에 존재하는 개념인 call-by-value나 call-by-reference를 자연스럽게 떠올렸습니다. 하지만 관련된 다른 문서를 확인할수록 제 생각이 잘못된 생각이라는 것을 깨닫게 되었습니다.

 

그리고 무엇보다도 파이썬 공식문서에는 그 어느 곳에서도 call-by-value나 call-by-reference를 언급하고 있지 않습니다. 즉, 파이썬에서는 call-by-value나 call-by-referece 개념이 없습니다. 

 

다른 분들이 작성한 포스팅 글을 확인해 보니 얕은 복사와 깊은 복사 개념을 설명할 때 call-by-value와 call-by-reference 개념을 많이 언급하는 것을 확인하였습니다. 사실 여기까지만 확인했어도 관련된 주제로 포스팅을 할까? 말까? 고민을 했습니다. 그다지 중요한 내용이라 판단하지도 않았기 때문입니다.

 

그러다가 스택을 사용하는 것이 call-by-value방식이고 파이썬은 애초에 스택에서 작동하지 않는다고 작성한 아주 아주 이상한 포스팅 글을 보게 되었습니다. 그 글로 인하여 관련 주제로 글을 써야겠다는 생각을 하게 되었습니다.

 

본격적으로 설명에 들어가기 앞서 메모리 구조 주제에 대해 이야기할 것은 아니지만 초 간단으로 설명드리자면 운영체제에서 제공하는 메모리 영역은 os영역을 제외하고 "코드 영역", "데이터 영역", "힙 영역", "스택 영역"이 있습니다.

 

즉, 프로그램 종류를 막론하고 프로그램이 실행되기 위해서는 운영체제가 제공해주는 이 메모리 공간 안에서 지지고 볶고 해야 하는 것입니다. 스택의 영역은 프로그램에서 주로 함수 수행 시 필요한 정보를 저장하고 사용하는 일종의 메커니즘이라 할 수 있습니다. 함수를 사용하는 파이썬도 당연히 이 스택 영역을 사용합니다. (혹시나 파이썬은 다른 메커니즘을 사용한다면 댓글로 알려 주세요.)

 

파이썬은 특이하게 힙 영역을 자체적으로 파이썬 메모리 관리지가 제어를 하고 모든 객체와 데이터는 이 힙 영역에 할당합니다.

 

 

파이썬 네임스페이스 (namespace)

얕은 복사와 깊은 복사를 설명하는데 왜? 네임스페이스를 설명할까? 하는 생각을 가질 수도 있겠습니다. 파이썬 철학 내용 중 최고의 아이디어라고 말했던 이 네임스페이스가 무엇인지 알아야 비로소 얕은 복사와 깊은 복사를 원활히 이해할 수 있을 것 같은 판단에 먼저 네임 스페이스에 대해 간단히 알아보도록 하겠습니다.

 

혹시 파이썬 철학 내용이 궁금하시다면 "2019/08/20 - [IT/Python] - 파이썬이란" <- 이곳에서 확인하실 수 있습니다.

 

파이썬에 관련된 중요한 사항 중 하나는 바로 파이썬은 모든 것이 객체라는 사실입니다. 그래서 위에 잠깐 언급한 내용이지만 힙 영역을 파이썬 메모리 관리자가 관리하고 모든 객체와 데이터를 힙 영역에 할당합니다. 이 힙 영역에 할당된 객체와 데이터를 바로 이 네임스페이스에서 관리합니다. 

 

이 네임스페이스는 globals()와 locals() 내장 함수로 확인할 수 있습니다. 함수명을 보시고 유추하셨겠지만 globals() 함수는 전역 이름(전역 변수)을 확인하는 함수이고 locals() 함수는 지역 이름(지역 변수)을 확인하는 변수입니다. 저장되어 있는 형태를 마치 딕셔너리 형태 같습니다. 참고로 지역 이름을 관리하는 네임스페이스는 함수 별로 생성이 됩니다.

 

즉, C언어처럼 프로그래머가 메모리를 직접 컨트롤하면서 프로그래밍을 하는 언어에서의 개념인 call-by-value와 call-by-reference는 메모리를 직접 컨트롤할 수 없는 파이썬에 그 개념을 적용한다는 것은 조금 무리가 있습니다. 사실 파이썬도 프로그래머가 직접 컨트롤을 못할 뿐이지 파이썬 내부적으로 주소를 가지고 데이터를 변경하는 것은 매한가지입니다. 

 

파이썬은 모든 것이 객체라고 했습니다. 흔히 변수라 말하는 이름도 객체이며 데이터도 객체입니다. 이름의 객체는 데이터 객체를 가리키고 있는 것입니다. 즉, 이름 객체가 데이터 객체를 바인딩하고 있다는 것입니다. 그래서 파이썬에서 굳이 call-by-value나 call-by-reference개념을 정의한다면 call-by-object나 call-by-object-reference 정도로 불릴 수 있겠습니다.

 

 

파이썬 mutable과 immutable

파이썬에서 복사 개념 관련하여 자료형을 크게 두 가지로 구분하고 있습니다. 변경할 수 있는 mutable 혹은 변경할 수 없는 immutable 인지 말이죠. 이는 자료형의 특징이 원소가 한 개 이거나 변경할 수 없는 값일 경우 무조건 immutable로 정의합니다. 

 

원소가 여러 개이지만 튜플 자료형은 변경할 수 없기 때문에 당연히 immutable입니다. 하나의 문자열, 정수 등의 숫자 또한 immutable입니다.

 

바꿔 말하면 원소를 여러 개 가지고 변경할 수 있는 자료 구조인 list, set, dict 자료 구조만 mutable이며 나머지 자료구조는 모두 immutable로 정의할 수 있습니다.

 

얕은 복사와 깊은 복사를 설명 전에 mutable과 immutable 구분을 설명드리는 이유는 바로 얕은 복사로 인해 문제가 발생하는 것이 바로 mutable만 해당하기 때문입니다.

 

 

파이썬 얕은 복사 (shallow copy)

얕은 복사로 문제가 발생하는 mutable 분류 중 리스트 자료형으로 발생되는 문제를 확인해 보겠습니다.

 

>>> list_1 = ["Captain","BIN","BLOG"]
>>> list_2 = list_1
>>>
>>> list_1
['Captain', 'BIN', 'BLOG']
>>>
>>> list_2
['Captain', 'BIN', 'BLOG']

 

list_1에 3개의 요소를 갖는 리스트 객체를 생성하였습니다. 그리고 list_2를 생성하면서 list_1의 값을 복사합니다. 

 

복사 후 코드상 살펴보면 큰 문제는 없어 보입니다. 하지만 list_1나 list_2의 값을 변경하면 생각하지도 못했던 일이 발생하게 됩니다.

 

>>> list_2[1] = "America"
>>>
>>> list_1
['Captain', 'America', 'BLOG']
>>>
>>> list_2
['Captain', 'America', 'BLOG']

 

분명 list_2의 두 번째 인덱스의 값만 변경했는데 list_1의 값까지 모두 바뀌어 버리는 현상이 발생했습니다. 이런 현상은 mutable속성의 객체를 얕은 복사로 진행했기 때문에 발생하는 문제입니다. 즉, 위에 언급했던 call-by-object-reference로 처리되었기 때문입니다. 

 

참고로 이름의 객체가 같은 데이터 객체를 바인딩하고 있는지 확인하는 방법이 있습니다.

 

>>> id(list_1)
21449344
>>>
>>> id(list_2)
21449344
>>>
>>> list_1 is list_2
True
>>>
>>> list_1 == list_2
True

 

위와 같이 이름 객체의 id 넘버를 id() 내장 함수를 이용하여 서로의 값을 비교하는 방법이 있습니다. 또한 is 연산자를 통해 이름 객체 서로가 같은 데이터 객체를 바인딩하고 있는지 확인 가능합니다.

 

그리고 어차피 같은 데이터 객체를 바인딩하고 있으니 값 또한 동일하므로 equal 연산자를 통해서도 일치하는지 여부를 확인할 수 있습니다.

 

아마 이름 객체를 서로 복사하는 코드를 작성할 때 십중팔구 데이터 객체까지 서로 복사되기를 원했을 것입니다. 그렇게 하기 위해서는 이름 객체를 깊은 복사를 하면 원하는 대로 작동하도록 구현할 수 있습니다.

 

 

파이썬 깊은 복사 (deep copy)

간혹 리스트 자료형의 깊은 복사 해결 방법으로 슬라이싱을 사용해 해결한다는 글이 있습니다. 일차원 리스트의 경우에는 문제가 되지 않지만 다차원 리스트일 경우에 문제가 발생합니다. 일단 일차원 리스트에서 슬라이싱으로 해결하는 것을 확인해 보겠습니다.

 

>>> list_1 = ["Captain","BIN","BLOG"]
>>> list_2 = list_1[:]
>>>
>>> list_1
['Captain', 'BIN', 'BLOG']
>>> list_2
['Captain', 'BIN', 'BLOG']
>>>
>>> id(list_1)
10046080
>>> id(list_2)
46052176
>>>
>>> list_1[1]="America"
>>>
>>> list_1
['Captain', 'America', 'BLOG']
>>> list_2
['Captain', 'BIN', 'BLOG']

 

[:] 슬라이스를 통해 서로 다른 id값을 가지고 있는 것을 확인하고 list_1의 데이터만 변경하였을 때 원하는 대로 list_1의 데이터만 변경되었습니다. 하지만 다시 한번 말씀드리지만 이는 어디까지나 일차원 리스트일 경우에만 해당됩니다. 2차원일 경우를 확인해 보겠습니다.

 

>>> list_1=["Captain", ["BIN", "BLOG"]]
>>> list_2 = list_1[:]
>>>
>>> id(list_1)
27964240
>>> id(list_2)
27965400
>>>
>>> list_2[1][0] = "America"
>>>
>>> list_1
['Captain', ['America', 'BLOG']]
>>> list_2
['Captain', ['America', 'BLOG']]
>>>

 

분명 id 값도 서로 다르다는 것을 확인했고 list_2의 데이터만 변경하였으나 list_1의 데이터까지 변경되고 말았습니다. 그래서 가장 확실하게 데이터 객체를 복사하는 방법은 copy 패키지의 deepcopy를 사용하는 것입니다.

 

>>> import copy
>>> list_1 = ["Captain", ["BIN", "BLOG"]]
>>> list_2 = copy.deepcopy(list_1)
>>>
>>> id(list_1)
56302368
>>> id(list_2)
56996768
>>>
>>> list_2[1][0] = "America"
>>>
>>> list_1
['Captain', ['BIN', 'BLOG']]
>>> list_2
['Captain', ['America', 'BLOG']]

 

deepcopy로 데이터 객체를 깊은 복사를 진행하였습니다. list_2 이름 객체의 데이터만 변경하니 list_2에만 변경이 되는 것을 확인할 수 있습니다.

 

 

왜 이런 일이 발생하는지?

파이썬 코어 개발자가 아닌 이상에 왜 이렇게 동작하도록 구현했는지는 알 수 없습니다. 하지만 유추해 보자면 프로그래밍은 어차피 데이터 처리입니다. 이 데이터를 처리하는 데 있어서 데이터 중복은 가장 피해야 할 사항입니다.

 

만약 중복된 데이터를 처리하는 데 있어 불필요한 동일 작업을 컴퓨팅 자원을 낭비하면서 처리 완료될 때까지 기다리는 것만큼 어리석은 일은 없을 것입니다.

 

데이터베이스 영역에서도 데이터베이스 설계를 잘했을 경우 중복되는 데이터 없이 깔끔하게 데이터를 관리하고 처리할 수 있습니다. 하지만 설계가 잘못된 데이터베이스의 경우 동일한 데이터가 여기저기 분산되어 있고 데이터를 관리하다 보면 나중에 서로 다른 데이터로 인하여 어느 것이 맞는지 모르는 경우가 발생합니다.

 

이와 비슷한 맥락으로 파이썬에서도 중복된 데이터로 인하여 속도를 저하시키는 일을 방지하고자 얕은 복사를 구현한 것으로 판단됩니다.

 

 

예방책?!

사실상 일반적으로 얕은 복사와 깊은 복사를 인지하면서 코드를 작성하지 않을 것입니다. 이렇게 인지하고 있지 못한 상황에서 위에서와 같이 mutable 속성의 객체를 얕은 복사를 하게 된다면 휴먼 에러에 빠지게 됩니다. 

 

이런 상황을 사전에 방지하기 위한 예방책으로는 죄송하지만 없습니다.

 

하지만 저의 경험을 말씀드리면 위에서 잠깐 말씀드렸다시피 수년간 파이썬으로 개발을 진행해 오면서 단 한 번도 얕은 복사로 인해 문제가 된 적이 없었다고 말씀드렸습니다.

 

왜 그런지 살펴보면 정말 간단합니다. 코드를 작성하기 전 설계단계에서 중복 없는 데이터를 처리하도록 설계하고 함수 설계 시 꼭 필요한 데이터만 넘겨받고 처리 후 값을 반환하도록 설계하여 코드를 작성한다면 아마 deepcopy를 사용하는 일은 단 1%도 없을 것입니다.

 

사실 이 글을 쓰면서 deepcopy를 사용할 로직이 있을까? 라는 생각을 지울 수가 없었습니다. 아니 오히려 deepcopy를 사용하는 코드라면 분명 로직이 비효율적 일것이라는 생각이 깊게 자리 잡았습니다.

 

MORE