흔히 파이썬과 C와의 차이점을 묻는 질문이 많은데, 이런 저런 답 중에서 “파이썬은 메모리 관리가 필요없다”는 것도 있다. 파이썬에서 실행시간에 생성된 객체는 자동으로 관리되어 “더 이상 쓸모가 없어지면” 자동으로 파괴된다. 따라서 파이썬에서는 명시적으로 객체를 파괴하는 코드를 작성하지 않는 것이 보통이다.
파이썬의 이러한 특성 때문에 파이썬의 메모리 관리는 가비지 콜렉터에 의해서 관리된다는 믿음이 있는데, 이것도 (어느 정도 예전에는 사실이었으나) 진짜는 아니다. 파이썬은 Objective-C와 비슷하게 참조수(Reference Count)기반의 자동 메모리 관리 모델을 따르고 있다. 파이썬의 모든 변수는 값을 담는 영역이 아니라 객체에 바인딩 되는 이름이다. 객체와 이름이 바인딩되면, 해당 객체는 그 이름에 의해 참조되는 것이고 이는 그 참조수를 1만큼 증가시키는 작용을 한다.
그 이름이 더 이상 해당 객체를 가리키지 않게 되는 경우, (변수 스코프를 벗어나거나 다른 객체에 바인딩 되는 경우) 참조수는 1이 감소하고, 그 결과로 참조수가 0이되면 해당 객체는 가비지 콜렉터의 도움없이 그 자리에서 즉시 파괴된다. 이 글에서는 파이썬의 이러한 메모리 관리방식으로부터 발생할 수 있는 함정, 메모리 누수가 생길 수 있는 케이스와 어떻게 이를 회피할 수 있는지를 알아보도록 하자.
-목차-
1. 클래스 객체의 메모리 해제
객체가 참조수 0이 되거나 다른 이유로 메모리에서 해제될 때에는 해당 객체의 __del__() 메소드가 호출된다. 쉘에서 del foo 라고 했을 때에도 foo.__del__()이 호출된다. 우리는 특정 클래스에서 이 메소드를 변경해서 객체가 제거되는 시점에 필요한 리소스 정리를 할 수 있다. (물론 보통은 파이썬의 메모리 자동 관리를 믿고 이런 작업은 하지 않는다.)
class Foo:
def __init__(self):
self.value = 1
def __del__(self):
print(f'Object({id(self)}:{self.__class__}) is being destroyed.')
# 쉘에서 테스트
a = Foo()
a = 1
#결과
Object(1087b819c18:<class '__main__.Foo'>) is being destroyed.
위 코드를 보면 __del__() 메소드를 정의하여 객체가 삭제될 때 자신이 파괴된다는 문구를 출력하도록 했다. 그리고 실제로 인스턴스를 생성한 다음, a라는 이름에 바인딩했다. 곧이어 a라는 이름을 다른 객체, int 타입 1이라는 객체에 바인딩하게 되면 처음에 Foo()로 생성된 객체는 자신을 참조하는 이름이 0개가 되고 곧 GC에 의해서 제거된다. (아래 출력되는 메시지는 바로 그 증거이다.)
몇 가지 테스트를 더 해보자.
# 쉘에서 테스트
# 두 개의 객체 인스턴스를 생성하고 이름 a를 이름 b가 가리키고 있는 객체로 바인딩
a = Foo()
b = Foo()
a = b # a로 생성된 객체 파괴
b = 1 # b를 다른 타입의 객체로 바인딩 b는 a에 의해 여전히 잠조되고 있으므로 파괴되지 않음
a = 2 # a가 다를 객체를 가리키게 되는 시점에 두 번째로 생성한 객체가 파괴됨
두 개의 객체 인스턴스를 생성하고 이름 a를 이름 b가 가리키고 있는 객체로 바인딩한다. 그러면 처음에 a 이름으로 생성된 객체는 이 시점에 파괴 된다. 다음으로 이름 b를 다른 객체로 바인딩한다. 처음에 b 라는 이름으로 생성된 객체는 이름 a에 의해서 여전히 참조되고 있으므로 파괴되지 않는다. 다시 남은 이름 a가 다른 객체를 가리키게 되는 시점에 두 번째로 생성한 객체가 파괴된다.
2. 메모리 누수
메모리 누수를 어떻게 정의하냐에 따라 조금 다르다. 예를 들어 a = 1을 선언한 후에 프로그램에서 더 이상 a를 사용하지 않아도 이것을 메모리 누수라고 볼 수 있다. 다음 예제를 보자
class Foo:
def __init__(self):
self.value = 1
self.friend = None
def __del__(self):
# __del__() 메소드를 정의하여 객체가 삭제될 때 자신이 파괴된다는 유언을 출력
print(f'{id(self):x} is destroyed.')
a, b = Foo(), Foo()
a.friend = b ## b를 a의 속성으로 만든다
b = None ## b를 a.friend의 속성으로 참조하고 있기 때문에 b의 바인딩을 바꾸어도 제거 되지 않는다.
a = None ## a가 바인딩하던 첫번째 객체만 제거된다. a.friend 에 대한 참조를 제거하는 작업이 전혀 진행되지 않았기 때문
두 개의 Foo 타입 객체 a, b를 만들고 b를 a의 속성으로 만들었다. 이후에 b의 바인딩을 바꾸어도 두 번째 객체는 제거되지 않는다. (왜냐면 a.friend라는 속성 이름이 참고 있다. 그 상태에서 a를 제거하면? 원래 이름 a 가 바인딩하던 첫번째 객체만 제거되었고, 원래 b였던 두 번째 객체는 제거되지 않았다. 왜냐하면 객체 a가 제거되는 과정에서 a.friend 에 대한 참조를 제거하는 작업이 전혀 진행되지 않았기 때문이다. 메모리 누수가 발생한 것이다. 그렇다면 “죽을 때 속성을 정리하기”만 하면 괜찮을까?
class Foo:
def __init__(self):
self.v = 1
self.friend = None
def __del__(self):
self.friend = None
print(f'{id(self):x} is destroyed.')
a, b = Foo(), Foo()
a.friend = b
b = None # 참조가 살아있기 때문에 파괴되지 않음
a = None # 최초 b 였던 두번째 객체에 대한 참조가 모두 제거되면서 b 객체가 제거된다. 그리고 곧이어 a 객체도 제거
a, b, c = [Foo() for _ in range(3)]
a.friend = b
b.friend = a
b = None
# a = None이 호출되는 시점에 원래 a 였던 객체의 참조수는 2에서 1로 줄어든다.
# 하지만 남은 참조를 가지고 있던 이름 b는 None을 가리키고 있고, b.friend 라는 속성 이름 자체에 대한 접근이 막혔다.
# 따라서 a.__del__() 이 호출될 수 없기 때문에 두 객체는 메모리에 계속 남아 메모리 누수가 발생하게 된다.
a = None
c.friend = c # 자기 자신의 속성 이름에 의해서 스스로 순환참조 생성
c = None # c.friend=None 과 같은 식으로 먼저 속성에 의한 참조를 해제하지 않는 이상 메모리 누수를 일으키는 고립된 객체가 된다.
위 코드에서는 객체의 인스턴스로 다른 객체를 참조하였을 경우에 메모리에서 해제하기 위해 __del()__ 메서드를 수정하였다. 하지만 또 다른 문제가 있다. 위 코드에서는 두 객체가 각각 자신의 속성으로 서로를 참조하고 있다.
따라서 a = None이 호출되는 시점에 원래 a 였던 객체의 참조수는 2에서 1로 줄어든다. 하지만 남은 참조를 가지고 있던 이름 b는 None을 가리키고 있고, b.friend 라는 속성 이름 자체에 대한 접근이 막혔다. 따라서 a.__del__() 이 호출될 수 없기 때문에 두 객체는 메모리에 계속 남아 메모리 누수가 발생하게 된다.
아예 세 번째 객체는 어떠한가? 자기 자신의 속성 이름에 의해서 스스로 순환참조를 만들어버렸다. 따라서 c 역시 수동으로 c.friend=None 과 같은 식으로 먼저 속성에 의한 참조를 해제하지 않는 이상 이대로 메모리 누수를 일으키는 고립된 객체가 되고 만다.
3. 약한 참조
클래스 인스턴스의 속성이 만약 다른 클래스의 인스턴스를 참조하거나, 동일 클래스의 다른 인스턴스를 참조할 가능성이 높다면 이는 메모리 누수가 발생할 가능성이 매우 높은 지점이 된다. 물론 파이썬 프로그램의 생애주기는 대부분 짧기 때문에 문제가 되지 않을 수 있지만, 서버와 같이 생애 주기가 길거나, 매우 많은 인스턴스를 생성하고 연결하는 동작을 하는 경우에 메모리 누수가 큰 문제가 될 수 있다.
파이썬에서는 이러한 문제를 조금 간단히 처리하기 위해서 약한 참조를 제공한다. 약한 참조는 말 그대로 대상 객체를 참조는 하지만, 대상 객체에 대한 소유권을 주장하지 않는, 즉 reference count를 올리지 않는 참조를 말한다.
약한참조는 weakref 모듈의 ref 라는 클래스를 통해서 생성할 수 있다. 기본적인 사용법은 이렇다.
- 특정 대상에 대해 약한 참조를 만들 때는 weakref.ref(target) 으로 ref()에 인자로 넘겨 약한참조 객체를 생성한다.
- 생성된 약한참조로부터 참조대상을 얻으려 할 때는 약한참조 객체를 호출한다.
약한 참조는 대상에 대한 강한 참조를 유지하지 않기 때문에 위에서 언급한 문제에 대해서 구애받지 않는다. 참조하려는 대상이 파괴되었다면 약한참조는 참조 대상에 대한 액세스를 요청받을 때 None 을 리턴한다.
class Foo:
def __init__(self):
self.value = 1
self.friend = None
def __del__(self):
## 여기서 딱히 friend 속성을 정리하지 않는다.
print(f'Object({id(self):x}) is being destroyed.')
## 테스트
>> import weakref
>> a, b = Foo(), Foo()
## 각각의 객체를 약한 참조를 이용해서 할당한다.
>> a.friend = weakref.ref(b)
>> b.friend = weakref.ref(a)
## 객체를 지워본다.
>> b = None
Object(2520efedc18) is being destroyed.
>> a.friend() ## 제거된 대상을 액세스하려하면 None이 리턴된다.
>> a = None
Object(2520efc8ba8) is being destroyed.
## 자가 참조에 대해서도 테스트
>> c = Foo()
>> c.friend = weakref.ref(c)
>> c = None
Object(2520efed518) is being destroyed.
약한 참조는 위에서 살펴본바와 같이 한 개 이상의 객체가 참조 순환 고리를 만드는 경우에 메모리 누수를 방지하기 위해서 주로 사용한다.
추가로 weakref.ref는 사전이나 리스트를 인자로 받을 수 없다. 사전이나 리스트에 대한 약한 참조가 필요하다면, dict와 list 를 서브클래싱한 별도 타입을 생성하면 된다.
3.1 조금 더 편리한 약한 참조 속성
약한 참조를 사용하면 메모리 누수를 막을 수 있다는 좋은 점이 있지만, 위의 Foo 의 경우와 같이 커스텀 클래스 인스턴스를 속성을 사용하는 부분이 많다면 매번 weakref.ref(x) 를 쓰거나, obj.attr() 과 같이 호출하는 식의 코드를 작성하는 것은 피곤한 일이다. 객체 프로퍼티를 사용해서 접근자를 호출하는 식으로 우회하여 이 문제를 개선할 수 있다.
import weakref
class ConvenientFoo:
def __init__(self):
self.value = 1
self._friend = None
@property
def friend(self):
if self._friend is None:
return None
return self._friend()
@friend.setter
def friend(self, target):
self._friend = weakref.ref(target)
def __del__(self):
print(f'{id(self):x} is being destroyed.')
## 테스트
>> a, b, c = [ConvenientFoo() for _ in range(3)]
>> a.friend = b
>> b.friend = a
>> c.friend = c
>> a = None
2520efc8ba8 is being destroyed.
>> b = None
2520efedc18 s being destroyed
>> c = None
2520efed518 is being destroyed
4. 가비지 콜렉터가 필요한가?
그렇다면 파이썬에서 가비지 콜렉터를 사용한다는 것은 낭설인가? 그렇지는 않다. 대신에 그것은 가비지 콜렉터에 대한 일반적인 오해일 뿐이다. 가비지 콜렉터는 더 이상 쓰여지지 않을 객체를 처분하는 프로세스를 말하는 것이 아니다. 앞서 살펴본 예를 통해서 알 수 있듯이 메모리 누수는 생성된 이후에 참조가능한 위치를 모두 잃어버렸지만, 참조수를 유지하고 있는 객체들 때문에 발생한다. 상호간에 참조를 갖는 두 객체나 참조를 따라가보면 사이클을 만드는 3개 이상의 객체, 혹은 자기 스스로를 참조하는 객체등은 그 객체에 대한 이름을 잃게 되더라도 파괴되지 않고 메모리를 점유한다.
가비지 콜렉터는 이렇게 누수가 발생한 경우, 고립된 객체를 찾아서 제거하는 기능이다. 따라서 프로세스가 사용하는 모든 힙 메모리 공간을 다 뒤져서 살아있는 객체를 찾은 다음, 이 객체를 외부에서 사용 가능한지를 검사한다. 많약 특정 객체 혹은 객체 그룹이 외부로 부터 고립되어 있는 것을 발견하면 가비지 콜렉터는 해당 객체들을 제거하고 메모리를 회수한다.
가비지 콜렉터는 수많은 객체들을 전수조사해야하고 그 객체에 대한 명시적이지 않은 모든 참조를 찾아야 하니, 그 자체로도 엄청난 리소스를 소모해야 하는 작업이다. 일부 서비스에서는 가능한 명시적인 참조만을 사용하여 가비지 콜렉터의 도움 없이 서비스를 만들고, gc 파이썬 공식문서에서도 순환 참조를 만들지 않는다고 확신할 수 있으면 gc.disable()을 통해 garbage collector 를 비활성화 시켜도 된다고 언급하고 있다.
참고 자료
https://github.com/JaeYeopHan/Interview_Question_for_Beginner/tree/master/Python#메모리-누수
https://soooprmx.com/파이썬-약한참조/
'Python' 카테고리의 다른 글
Duck Typing (0) | 2022.02.17 |
---|---|
property (함수/데코레이터)[파이썬/python] (0) | 2022.02.17 |
GC(Garbage Collection) (0) | 2022.02.16 |
GIL(Global Interpreter Lock) (0) | 2022.02.16 |
Generator[파이썬/Python] (0) | 2022.02.15 |
댓글