콘텐츠로 건너뛰기
Home » (파이썬) 약한 참조 사용하기

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

파이썬과 C를 비교하면서 차이점을 이야기하는 사람 중에는 “파이썬은 별도의 메모리 관리가 필요없다”는 이야기를 하는 사람들이 있다. 실제로 프로그램이 실행되는 동안 객체를 위한 메모리는 파이썬에 의해 자동으로 할당받게 되고, 객체의 파괴 역시 대부분 파이썬이 자동으로 처리한다. 따라서 파이썬 코드에서는 malloc()이나 free() 같은 메모리 관리를 위한 코드가 존재하지 않는 것은 사실이다.

파이썬에서 메모리 관리에 있어 가장 주요한 개념은 참조수인데, 참조수는 어떤 객체 외부에서 그 객체를 참조하는 지점의 개수이다. 즉 어떤 객체를 누군가 참조한다는 것은, 외부의 누군가가 그 객체가 계속 살아있기를 원한다는 의미이므로 그 수명을 유지하게 하며, 반대로 이러한 참조점이 없는 객체는 사용할 수 없는 객체가 되기 때문에 파괴되어도 상관없다는 의미가 된다.

파이썬의 모든 것은 객체이고, 모든 파이썬 변수는 객체에 붙는 이름이다. 즉 객체에 어떤 이름이 붙었다는 것은 이 객체를 참조하는 곳이 한 군데 생겼다는 뜻이다. 그 외에 객체가 다른 객체의 속성으로 바인딩되는 것도 참조수를 늘리는 것이며, 리스트나 사전과 같은 컨테이너에 포함되는 것도 참조수가 증가하는 효과를 갖는다.

만약 그 이름이 다른 객체를 가리키도록 바뀌게 되면, 파이썬은 내부적으로 참조수를 1 떨어뜨린다. 그리하여 참조수가 0이된 객체는 그냥 그 자리에서 즉시 파괴되는 식이다. 그러니, 별도로 사용하지 않는 객체를 명시적으로 파괴하는 코드를 호출할 필요가 없는 것이다. 하지만 세상 일이란게 단순해보이더라도 그게 늘 마음처럼 한결같지는 않은 법이다. 이러한 참조수에 의한 메모리 관리가 실패할 수 있는 경우와, 그럼 문제를 어떻게 해결할 수 있는지를 살펴보도록 하자.

유언을 남기는 클래스

파이썬에서 객체를 수동으로 파괴하는 직접적인 방법은 없다. del 구문을 떠올릴 수 있겠지만, 이 명령은 해당 스코프에서 변수 이름을 파괴하는 것으로 객체의 참조수를 1 낮추는 역할을 한다. 어쨌거나 객체의 참조수가 0이 되는 것이 감지된다면, 객체가 파괴되기 직전에 파이썬은 해당 객체에 대해서 __del__() 이라는 매직 메소드를 호출한다. 이 시점에서 프로그래머는 캐시의 삭제 등 특별히 정리해야 하는 리소스를 시스템에 반납하는 코드를 넣을 수 있다. 그렇다면 여기에 유언을 출력하는 코드를 넣는다면, 객체가 파괴되는 것을 시각적으로 알아 챌 수 있을 것이다.

class Foo:
  value = 1
  def __del__(self):
    print(f'{self.__class__}(0x{id(self):016X}) is being destroyed.')

이런 클래스를 만들었다고 하면, 다음과 같이 파이썬 쉘에서 확인해 볼 수 있다.

>>>> a = Foo()
>>>> a = 1
# 이 시점에 이름 'a'는 int 객체 1을 가리키게 되고, Foo의 인스턴스였던 객체는 참조수 0이 된다.
<class '__main__.Foo'>(2188612888272) is begin destroyed.

만약 ‘a’ 가 다른 객체를 가리키기 전에 다른 이름을 바인딩해준다면 객체의 참조수는 남아있게 되므로, 객체는 유지될 것이다.

>>> a = Foo()
>>> a = 1
<class '__main__.Foo'>(2188612888272) is begin destroyed.
>>> a = Foo()
>>> b = Foo()
>>> a = b
<class '__main__.Foo'>(2188612888144) is begin destroyed.
>>> b = 1
>>> a = 1
<class '__main__.Foo'>(2188612888336) is begin destroyed.

함수 내부에서 객체를 생성했을 때의 상황을 보자. 먼저 함수 내부에서 객체를 생성하고, 이 객체를 함수 내부에서만 사용하는 경우이다.

>>> def bar():
...   a = Foo()
...   return 1
...
>>> bar()
<class '__main__.Foo'>(2188612888592) is begin destroyed.
1

return 을 만나서 함수의 실행이 종료되면, 함수의 지역 변수들은 모두 파괴되고 내부에서 만들어진 객체는 자연스럽게 제거된다. 하지만 그렇게 생성된 객체는 함수 밖으로 빠져나올 수 있다.

>>> def baz():
...   a = Foo()
...   return a
>>> g = baz()
<__main__.Foo object at ... >  # 아직 파괴되지 않았음
>>> g = 1
<class '__main__.Foo'>(2188612888592) is begin destroyed.
# 이제 참조수가 없어지면서 파괴되었다.

문제는 이렇게 함수가 새로 만든 객체를 리턴했는데, 이 객체가 바인딩되는 이름이 없을 때이다. 언어에 따라서는 이 경우 즉시 해제되어버리는 경우도 있는데, 파이썬에서는 이렇게 참조점이 없는 상태로 만들어진 객체가 일단 유지된다. 물론 계속유지되지는 않고 특정한 시점 언젠가에는 GC가 실행되면서 제거된다.

>>> baz()
<__main__.Foo object at 0x00000229151C6790>
# 안 없어짐..
>>> locals()
<class '__main__.Foo'>(0x00000229151C6790) is being destroyed. <<< 이 시점에 제거됨
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'Foo':
<class '__main__.Foo'>, 'a': 1, 'b': 1, 'bar': <function bar at 0x0000022914D604A0>, 'g': 1, 'baz': <function baz at 0x0000022914D60680>}

메모리 누수

객체가 함수 내부에서 만들어져서, 자신이 생성된 범위 밖으로 전달되는 시점이 메모리 관리라는 난제가 시작되는 지점이다. 단순히 g = baz() 와 같이 생성된 객체가 다른 참조점에 연결되는 경우도 있겠지만, 객체에 참조점이 추가되는 것은 단순히 변수이름에 바인딩 되는 것외에도 존재하기 때문이다.

def ham(arr):
  a = Foo()
  arr.append(a)

이 예제만 봐도 그렇다. 함수 내부에서 객체를 생성했고 리턴하지 않았지만, 함수 외부에 존재하는 리스트에 넣었다. 물론 이 리스트에서 해당 원소를 제거하면 객체는 참조점을 잃고 파괴될 것이다. 그럼에도 코드 상으로는 명시적인 리턴이 아니기 때문에 쉽게 찾아내기 어려운 부분이 될 수 있다. 그럼에도 파이썬은 내부적으로 가비지콜렉터가 이렇게 갈곳을 잃은 객체들을 수거해서 파괴하기는 한다. 다만, 방금 살펴본 예들은 운이 좋아서 참조점을 잃은 객체가 바로 바로 파괴되는 것처럼 보였지만, 현실 세계의 프로그램에서는 GC가 언제 실행될지 예측할 수 없다는 점을 알아두는 것이 좋다. 즉, __del__() 과 같은 메소드에서 캐시 등의 리소스를 반환하는 형태로 객체를 디자인 하는 것은 좋은 선택이 아닐 것이다.

참조수로 해결하지 못하는 메모리 누수 문제의 대표적인 예는 순환참조이다. 두 개 이상의 객체가 자기들끼리 참조하다가, 외부로부터의 연결점이 모두 떨어져 나가는 경우이다.

class Foo2:
  value = 1
  def __init__(self):
    self.f = None
  def __del__(self):
    self.f = None
    print(f'{self.__class__}(0x{id(self):016X}) is being destroyed.')

이런 클래스를 하나 만들고, 두 개의 인스턴스를 생성해서 서로를 연결한다. 그리고 다시 파괴하려고 시도해보자.

>>> x = Foo2()
>>> y = Foo2()
>>> x.f = y
>>> y.f = x    # 순환 참조가 만들어졌다.
>>> del x
>>> del y

이 두 객체는 파괴되지 않는다. x가 가리키던 객체는 ‘x’ 라는 이름외에도 y 가 가리키던 객체의 속성으로도 참조되고 있다. 반대로 y의 경우도 마찬가지이다. 변수 이름 x, y를 파괴하더라도 각 객체는 서로의 속성으로 참조되고 있던 참조수가 계속 남아있기 때문에 어느 한쪽의 __del__() 도 호출되지 않을 것이며, 속성으로 연결된 참조가 제거될 기회가 없어진다.

gc 모듈을 사용해서 수동으로 가비지 콜렉터를 실행하는 방법도 있겠지만, 포인터 추적을 통해서 탱글링된 객체들을 모두 찾는 것은 상당히 비싼 비용이 들어가는 작업이다. 따라서 이러한 순환 참조가 생기지 않도록 만드는 방법이 필요한데, 두 개 객체가 서로를 참조하면서 순환 참조의 고리를 만들지 않도록 하기 위해서는 다른 특별한 방법이 필요하다.

약한 참조

파이썬에서는 순환 참조 문제를 처리하기 위해서 약한 참조라는 개념을 제공한다. 약한 참조는 말 그대로 대상 객체를 참조는 하지만, 대상 객체에 대해서 참조 수에는 영향을 주지 않는 것을 말한다. 약한참조는 weakref 모듈의 ref 라는 클래스를 통해서 생성할 수 있다. 기본적인 사용법은 이렇다.

  1. 특정 대상에 대해 약한 참조를 만들 때는 wr = weakref.ref(target)과 같이 약한 참조를 호출할 수 있다.
  2. 약한 참조를 통해 실제 타깃 객체를 획득할 때에는 wr()을 사용한다.

약한 참조는 대상에 대한 강한 참조를 유지하지 않기 때문에 앞서 예제로 제시했던 상호 참조로 인한 순환 참조 문제가 생기지 않는다. 심지어 자기 자신에 대한 참조를 하는 것도 무관하다.

>>> import gc
>>> import weakref
>>>
>>> class Foo:
...   def __init__(self, name):
...     self.name = name
...     self.f = None
...   def __del__(self):
...     self.f = None
...     print(f"{self.name}(0x{id(self):016X}) is being destroyed.")
...
>>> x = Foo('x')
>>> y = Foo('y')
>>> gc.enable()
>>> x.f = weakref.ref(y)
>>> y.f = weakref.ref(x)
>>> del x
x(0x0000018836964710) is being destroyed.
>>> del y
y(0x0000018836964750) is being destroyed.

실제로 이렇게 사용하는 것은 불편하기 때문에 프로퍼티를 이용하면 좀 더 자연스럽게 사용할 수 있다.

class Foo:
    def __init__(self, name):
        self.name = name
        self._f = None

    @property
    def f(self):
        if self._f is None:
            return None
        return self._f()

    @f.setter
    def f(self, obj):
        if obj is None:
            self._f = None
        else:
            self._f = weakref.ref(obj)

    def __del__(self):
        self._f = None
        print(f"{self.name}(0x{id(self):016X}) is being destroyed.")

단 위 방식에서는 x.f = Foo('y') 와 같이 사용하는 것은 제대로 작동하지 않는다. 생성한 객체에 대한 약한 참조만이 유지되어, 이 속성을 사용하기 전에 객체가 파괴될 것이다. 약한 참조에서 이미 파괴된 객체를 참조하려고 하면 None 이 리턴된다.

약한 참조를 순환고리를 피하는 목적으로도 사용되지만, 캐시를 위해서도 사용될 수 있다. 만드는데 비싼 비용이 드는 큰 객체를 참조하려 하지만, 계속 유지하는 것이 더 부담이 되는 경우가 있을 것이다. 이런 경우 약한참조를 사용하면, 메모리 압력이 높은 시점에 캐시를 파괴할 수 있고, 필요한 경우에 None 이 리턴됐다면 다시 생성하는 식으로 사용할 수 있을 것이다.

부록

weakref.ref는 사전이나 리스트와 같은 가변 객체를 대상으로 지정할 수 없다. 하지만 모든 커스텀 클래스는 약한 참조를 만들 수 있기 때문에, 다음과 같이 간단히 이 제약을 회피할 수 있다.

class Dict(dict):
    pass
# `Dict` 타입은 약한 참조를 만들 수 있는 타입이 되었다.

조금 더 깊이 : 가비지 콜렉터

파이썬에서도 가비지 콜렉터는 여전히 유효한 개념이다. 참조수가 0이 되는 객체는 운이 좋다면 즉시 파괴되는 것처럼 보이지만, 실제로 GC가 쓰레기 객체로 인식하는 시점을 다를 수 있다. gc 모듈을 사용하면 가비지 컬렉터의 실행 여부를 변경하거나, 실행 주기를 변경하는 등의 설정을 할 수 있다. GC에 대한 보다 자세한 내용은 이 글을 참고하도록 하자.