(파이썬) 약한 참조 사용하기

흔히 파이썬과 C와의 차이점을 묻는 질문이 많은데, 이런 저런 답 중에서 “파이썬은 메모리 관리가 필요없다”는 것도 있다. 파이썬에서 실행시간에 생성된 객체는 자동으로 관리되어 “더 이상 쓸모가 없어지면” 자동으로 파괴된다. 따라서 파이썬에서는 명시적으로 객체를 파괴하는 코드를 작성하지 않는 것이 보통이다.

파이썬의 이러한 특성 때문에 파이썬의 메모리 관리는 가비지 콜렉터에 의해서 관리된다는 믿음이 있는데, 이것도 (어느 정도 예전에는 사실이었으나) 진짜는 아니다. 파이썬은 Objective-C와 비슷하게 참조수(Reference Count)기반의 자동 메모리 관리 모델을 따르고 있다. 파이썬의 모든 변수는 값을 담는 영역이 아니라 객체에 바인딩 되는 이름이다. 객체와 이름이 바인딩되면, 해당 객체는 그 이름에 의해 참조되는 것이고 이는 그 참조수를 1만큼 증가시키는 작용을 한다.

그 이름이 더 이상 해당 객체를 가리키지 않게 되는 경우, (변수 스코프를 벗어나거나 다른 객체에 바인딩 되는 경우) 참조수는 1이 감소하고, 그 결과로 참조수가 0이되면 해당 객체는 가비지 콜렉터의 도움없이 그 자리에서 즉시 파괴된다.  이 글에서는 파이썬의 이러한 메모리 관리방식으로부터 발생할 수 있는 함정, 메모리 누수가 생길 수 있는 케이스와 어떻게 이를 회피할 수 있는지를 알아보도록 하자.

유언을 남기는 클래스

객체가 참조수 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  ## 1)
Object(1087b819c18:<class '__main__.Foo'>) is being destroyed.

위 코드를 보면 __del__() 메소드를 정의하여 객체가 삭제될 때 자신이 파괴된다는 유언을 출력하도록 했다. 그리고 실제로 인스턴스를 생성한 다음, a라는 이름에 바인딩했다. 곧이어 a라는 이름을 다른 객체, int 타입 1이라는 객체에 바인딩하게 되면 처음에 Foo()로 생성된 객체는 자신을 참조하는 이름이 0개가 되고 곧 GC에 의해서 제거된다. (아래 출력되는 메시지는 바로 그 증거이다.)

몇 가지 테스트를 더 해보자.

>> a = Foo()
>> b = Foo()
>> a = b  ## 1)
Object(1087b8192b0:<class '__main__.Foo'>) is being destroyed.
>> b = 1  ## 2)
>> a = 2  ## 3)
Object(1087b827ba8:<class '__main__.Foo'>) is being destroyed.

두 개의 객체 인스턴스를 생성하고 이름 a를 이름 b가 가리키고 있는 객체로 바인딩한다. 그러면 처음에 a 이름으로 생성된 객체는 이 시점에 파괴되는 것을 확인할 수 있다. (1)) 다음으로 이름 b를 다른 객체로 바인딩한다. 처음에 b 라는 이름으로 생성된 객체는 이름 a에 의해서 여전히 참조되고 있으므로 파괴되지 않는다. (2)) 다시 남은 이름 a가 다른 객체를 가리키게 되는 시점에 두 번째로 생성한 객체가 파괴되었다.

메모리 누수

여기까지 보면 이 모델에서 큰 문제점을 느끼지 못할 수도 있다. 하지만 상황은 늘 쉬운 것만은 아니다. 다음 예제는 어떨까?

>> a, b = Foo(), Foo()
>> a
<__main__.Foo object at 0x000001D953A38BE0>
>> b
<__main__.Foo object at 0x000001D953A38BA8>
>> a.friend = b  ## 1
>> b = None      ## 2
>> a = None      ## 3
Object(at 1d953a38be0, <class '__main__.Foo'>) is being destroyed. 

두 개의 Foo 타입 객체 a, b를 만들고 b를 a의 속성으로 만들었다. 이후에 b의 바인딩을 바꾸어도 두 번째 객체는 제거되지 않는다. (왜냐면 a.friend라는 속성 이름이 참고 있다. 그 상태에서 a를 제거하면? 원래 이름 a 가 바인딩하던 첫번째 객체만 제거되었고, 원래 b였던 두 번째 객체는 제거되지 않았다. 왜냐하면 객체 a가 제거되는 과정에서 a.friend 에 대한 참조를 제거하는 작업이 전혀 진행되지 않았기 때문이다.

그렇다면 “죽을 때 속성을 정리하기”만 하면 괜찮을까? Foo 클래스의 __del__() 메소드를 약간 수정해서 다음과 같이 바꾸어서 테스트해본다.

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
2520efc8b70 is destroyed.
2520ef38630 is destroyed.
## 성공!

a에 대한 참조가 없어지면서 a.__del__()이 호출되고, 이 시점에 최초 b 였던 두번째 객체에 대한 참조가 모두 제거되면서 b 객체가 제거된다. 그리고 곧이어 a 객체도 제거되었다.

이러면 완벽한가? 다음은 어떤가?

>> a, b, c = [Foo() for _ in range(3)]
>> a.friend = b
>> b.friend = a
>> b = None
>> a = None  ## 1)
## 아예 이건 어떤가?
>> c.friend = c
>> c = None  ## 2)

이번에는 또다른 문제이다. 두 객체가 각각 자신의 속성으로 서로를 참조하고 있다. 따라서 a = None이 호출되는 시점에 원래 a 였던 객체의 참조수는 2에서 1로 줄어든다. 하지만 남은 참조를 가지고 있던 이름 b는  None을 가리키고 있고, b.friend 라는 속성 이름 자체에 대한 접근이 막혔다. 따라서 a.__del__() 이 호출될 수 없기 때문에 두 객체는 메모리에 계속 남아 메모리 누수가 발생하게 된다.

아예 세 번째 객체는 어떠한가? 자기 자신의 속성 이름에 의해서 스스로 순환참조를 만들어버렸다. 따라서 c 역시 수동으로 c.friend=None 과 같은 식으로 먼저 속성에 의한 참조를 해제하지 않는 이상 이대로 메모리 누수를 일으키는 고립된 객체가 되고 만다.

약한 참조

클래스 인스턴스의 속성이 만약 다른 클래스의 인스턴스를 참조하거나, 동일 클래스의 다른 인스턴스를 참조할 가능성이 높다면 이는 메모리 누수가 발생할 가능성이 매우 높은 지점이 된다. 물론 파이썬 프로그램의 생애주기는 대부분 짧기 때문에 문제가 되지 않을 수 있지만, 서버와 같이 생애 주기가 길거나, 매우 많은 인스턴스를 생성하고 연결하는 동작을 하는 경우에 메모리 누수가 큰 문제가 될 수 있다.

파이썬에서는 이러한 문제를 조금 간단히 처리하기 위해서 약한 참조를 제공한다. 약한 참조는 말 그대로 대상 객체를 참조는 하지만, 대상 객체에 대한 소유권을 주장하지 않는, 즉 reference count를 올리지 않는 참조를 말한다.

약한참조는 weakref 모듈의 ref 라는 클래스를 통해서 생성할 수 있다. 기본적인 사용법은 이렇다.

  1. 특정 대상에 대해 약한 참조를 만들 때는 weakref.ref(target) 으로 ref()에 인자로 넘겨 약한참조 객체를 생성한다.
  2. 생성된 약한참조로부터 참조대상을 얻으려 할 때는 약한참조 객체를 호출한다.

약한 참조는 대상에 대한 강한 참조를 유지하지 않기 때문에 위에서 언급한 문제에 대해서 구애받지 않는다. 참조하려는 대상이 파괴되었다면 약한참조는 참조 대상에 대한 액세스를 요청받을 때 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는 사전이나 리스트를 인자로 받을 수 없다. 사전이나 리스트에 대한 약한 참조가 필요하다면, dictlist 를 서브클래싱한 별도 타입을 생성하면 된다.

부록 2 : 조금 더 편리한 약한 참조 속성

약한 참조를 사용하면 메모리 누수를 막을 수 있다는 좋은 점이 있지만, 위의 Foo 의 경우와 같이 커스텀 클래스 인스턴스를 속성을 사용하는 부분이 많다면 매번 weakref.ref(x) 를 쓰거나, obj.attr() 과 같이 호출하는 식의 코드를 작성하는 것은 피곤한 일이다. 객체 프로퍼티를 사용해서 접근자를 호출하는 식으로 우회하여 이 문제를 개선할 수 있다.

import weakref

class ConvenientFoo:
  def __init__(self, value):
    self.value = value
    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

조금 더 깊이 : 그러면 가비지 콜렉터는?

그렇다면 파이썬에서 가비지 콜렉터를 사용한다는 것은 낭설인가? 그렇지는 않다. 대신에 그것은 가비지 콜렉터에 대한 일반적인 오해일 뿐이다. 가비지 콜렉터는 더 이상 쓰여지지 않을 객체를 처분하는 프로세스를 말하는 것이 아니다. 앞서 살펴본 예를 통해서 알 수 있듯이 메모리 누수는 생성된 이후에 참조가능한 위치를 모두 잃어버렸지만, 참조수를 유지하고 있는 객체들 때문에 발생한다. 상호간에 참조를 갖는 두 객체나 참조를 따라가보면 사이클을 만드는 3개 이상의 객체, 혹은 자기 스스로를 참조하는 객체등은 그 객체에 대한 이름을 잃게 되더라도 파괴되지 않고 메모리를 점유한다.

가비지 콜렉터는 이렇게 누수가 발생한 경우, 고립된 객체를 찾아서 제거하는 기능이다. 따라서 프로세스가 사용하는 모든 힙 메모리 공간을 다 뒤져서 살아있는 객체를 찾은 다음, 이 객체를 외부에서 사용 가능한지를 검사한다. 많약 특정 객체 혹은 객체 그룹이 외부로 부터 고립되어 있는 것을 발견하면 가비지 콜렉터는 해당 객체들을 제거하고 메모리를 회수한다.

듣기만해도 가슴이 답답할 지경으로 이는 엄청나게 빡센 작업이다. 수많은 객체들을 전수조사해야하고 그 객체에 대한 명시적이지 않은 모든 참조를 찾아야 하니, 그 자체로도 엄청난 리소스를 소모해야 하는 작업이다. 일부 서비스에서는 가능한 명시적인 참조만을 사용하여 가비지 콜렉터의 도움 없이 서비스를 만들고, 아예 GC 자체를 꺼버리는 곳도 있다. (인스타그램 서버가 그렇단다)