흔히 managed 언어라고 불리는 C언어에서는 프로그램을 짤 때 일일이 메모리를 할당하고 해제하는 작업이 필요했습니다. 하지만 Java, Python 과 같은 unmanaged 언어는 메모리를 신경 쓰지 않아도 그 자체적으로 알아서 관리하게 되어있습니다. 어쩌면 “알아서” 라는 말이 편하게 들릴 수도 있지만, 자칫하다가는 프로그램이 우리가 원하지 않는 방향으로 작동할 수도 있다는 의미이기도 합니다. 요즘 웬만한 웹 애플리케이션은 대부분 이러한 unmanaged 언어를 사용하여 개발하기 때문에 직접 메모리를 관리해야 할 일은 거의 없을 수 있습니다. 하지만 프로그램을 작성하는 과정에서 해당 언어가 어떤 방식으로 메모리를 관리하는지 이해할 필요는 있습니다.


1. Python Object

먼저 파이썬에서 객체와 변수를 어떻게 취급하는지부터 살펴봅시다. 그 이전에, C언어에서 변수를 할당하는 경우를 생각해보면 보통 처음에 변수를 선언하여 메모리 공간을 할당받고, 해당 공간에 값을 저장하게 됩니다. 하지만 파이썬은 자료형의 타입이나 크기를 사전에 정의하지 않기 때문에 C언어와는 다르게 동작합니다.

파이썬에서의 변수는 객체를 담은 상자보다는 객체에 붙이는 라벨과 같은 개념에 더 가깝습니다. 변수에 할당되기 이전에 메모리 공간 어딘가에 객체가 생성되고, 후에 변수 a라는 alias를 붙이는 개념으로 이해하면 쉽습니다.

a = [1, 2, 3]
b = a
print(b)
# [1, 2, 3]

a.append(4)
print(b)
# [1, 2, 3, 4]

위 코드에서 변수 ab는 같은 객체를 참조하고 있습니다. 따라서 a에 변화를 주면 b에도 동일하게 적용됩니다.

어떤 변수가 같은 객체를 참조하는지는 id() 함수를 이용하여 확인할 수 있습니다. 이 함수는 해당 변수가 참조하는 객체 고유값을 반환하는데, 그 값은 객체가 메모리에서 해제되기 전까지 절대로 변하지 않습니다. 일례로 CPython에서는 id()의 결과로 겹칠 일이 없는 메모리 주소 값을 반환합니다.


2. Reference Counting

이렇게 메모리에 할당된 객체들을 관리하기 위해 CPython 은 레퍼런스 카운팅(reference counting) 방식을 사용하고 있습니다. 모든 파이썬 객체들은 C언어의 PyObject객체로 이루어져 있으며, 해당 객체를 참조하는 모든 변수를 집계하여 내부 멤버 변수 ob_refcnt 에 기록합니다. 파이썬 코드가 실행되면서 해당 객체를 참조하는 변수가 할당되면 ob_refcnt값을 하나 늘리고, 참조가 사라지면 값을 하나 줄이는 식으로 동작합니다. 그리고 마침내 그 값이 0이 되는 순간. 즉, 자신을 참조하는 변수가 남아있지 않으면 __del__ 메소드를 호출하고 메모리에서 해제하여 해당 객체가 제거됩니다.

import sys

class Foo:
    def __del__(self):
        print("Removed!")

a = Foo()
sys.getrefcount(a)
# 2

b = a
sys.getrefcount(a)
# 3

del b
sys.getrefcount(a)
# 2

del a
# Removed!

특정 객체에 얼마나 많은 참조가 있는지는 파이썬의 sys.getrefcount() 함수로 확인할 수 있습니다. 위 코드에서는 객체를 변수에 할당할때마다 참조의 개수가 늘어나고 지울 때마다 줄어드는걸 볼 수 있습니다. 다만, 실제 선언한 참조의 개수보다 1개씩 더 추가되어 나타나게 되는데, sys.getrefcount 함수를 실행하면서 내부에서 참조하는 변수까지 포함하여 집계하기 때문입니다.

파이썬에서의 del은 해당 변수가 더는 객체를 참조하지 않도록 제거하는 역할만 할 뿐 객체의 메모리 해제에는 어떤 영향도 주지 않습니다.

3. 순환 참조 이슈

하지만 레퍼런스 카운팅을 이용하더라도 완벽하게 메모리를 관리하지 못하는 경우가 생깁니다. 다음과 같은 순환 참조 상황이 그 예시입니다.

import sys

class Foo:
    def __init__(self):
        self.child = None

    def __del__(self):
        print("Removed!")

a = Foo()  # Foo object at 0x10
b = Foo()  # Foo object at 0x55
a.child = b
b.child = a

sys.getrefcount(a)
# 3
sys.getrefcount(b)
# 3

del a
del b

위 코드를 보면 a, b를 모두 지워도 __del__ 메소드가 호출되지 않는것을 볼 수 있습니다. 그 이유는 a.childb.child에 각각 b, a 에 해당하는 객체가 할당되어 있어서 참조가 남아있기 때문입니다. 따라서 해당 객체들은 접근할 방법이 없지만 ob_refcnt가 계속 집계되고 있어서 레퍼런스 카운팅만으로는 메모리에서 제거할 수 없습니다.


4. Generational Garbage Collection

객체의 순환 참조 문제를 해결하기 위해 파이썬은 generational garbage collection 알고리즘을 사용하였습니다. 기본 개념은 의외로 간단합니다. 주기적으로 모든 객체를 전수 조사하여 접근할 수 없다고 판단되는 객체들을 메모리에서 해제하는 방식입니다. 다만, 이 과정에서 모든 객체를 스캔해야 하므로 가비지 컬렉션이 일어나는 동안에는 파이썬 인터프리터가 잠시 멈추게 되고 다른 작업을 실행할 수 없습니다.

가비지 컬렉션은 generation(세대)과 threshold(임계값)에 따라서 동작합니다. 파이썬은 객체를 0~2세대로 구분하여 관리하고 있습니다. 새로 생성된 객체는 0세대에 할당되고 시간이 지날수록 1세대, 2세대로 옮겨지게 됩니다. 또한 각 세대별로 임계값이 존재합니다. 기본적으로 0세대에는 700, 1세대와 2세대는 10으로 설정되어 있는데, 특정 세대에 할당된 객체의 숫자가 임계값에 도달하게 되면 가비지 컬렉션이 실행됩니다.

파이썬 코드를 실행하면서 처음 객체가 생성되면 0세대에 해당하는 컬렉션에 할당됩니다. 그리고 여러 변수가 생성되었다가 해제되면서 0세대의 컬렉션 횟수가 임계값인 700을 넘어서는 순간이 발생합니다. 이때 0세대 컬렉션에 할당된 객체 중 더 이상 도달할 수 없다고 판단된 객체들은 메모리에서 해제되며 살아남은 객체들은 다음 1세대로 이동하게 됩니다. 동시에 0세대의 컬렉션 횟수는 다시 0으로 세팅되고 1세대의 컬렉션 횟수는 1이 늘어납니다. 0세대에서의 가비지 컬렉션이 반복되면서 1세대의 컬렉션 횟수가 늘어나게 되고, 1세대 임계값인 10이 넘어가면 1세대의 객체를 대상으로 다시 가비지 컬렉션이 반복됩니다. 기본적으로 높은 세대보다 낮은 세대에 할당된 객체에 대해 좀 더 자주 가비지 컬렉션이 진행됩니다. 이런 식으로 가비지 컬렉션을 이용하여 이전 레퍼런스 카운팅으로 제거하지 못했던 순환 참조된 객체도 메모리에서 해제가 가능합니다.

import gc

gc.get_threshold()  # 임계값
# (700, 10, 10)

gc.get_count()  # 컬렉션 횟수
# (629, 4, 1)

위와 같이 현재 각 세대별로 설정된 임계값과 현재 상태의 컬렉션 횟수를 확인할 수 있습니다.

import gc

class Foo:
    def __init__(self):
        self.child = None

    def __del__(self):
        print("Removed!")

a = Foo()
b = Foo()
a.child = b
b.child = a
del a, b

gc.collect()
# Removed!
# Removed!

꼭 주기가 돌아올 때까지 기다리지 않고 곧바로 실행시킬 수도 있습니다. gc.collect() 함수로 즉시 가비지 컬렉션을 동작시킬 수 있습니다.


5. Conclusion

파이썬이 메모리를 관리하기 위해 사용하는 메인 방법은 레퍼런스 카운팅이고, 가비지 컬렉션은 레퍼런스 카운팅이 놓치는 객체까지 확인하는 보조 수단으로 사용합니다. 파이썬 공식 문서에서도 ‘참조 순환이 발생하지 않는다고 확신한다면 GC를 비활성해도 좋다.’ 고 말하고 있습니다. 실제로 인스타그램에서는 내부적으로 GC를 제거함으로써 성능적인 이득을 보았다고 하는데, 물론 그만큼 메모리 관리에 대한 확신도 따라주어야 합니다.

가비지 컬렉션은 어떻게 보면 개발자들이 떠안던 문제를 언어 내부에서 해결해주었기 때문에 일손을 많이 덜어주었습니다. 파이썬을 실무에 사용하면서 메모리에 대한 관리를 하게 되는 경우는 흔하지 않겠지만 기본적으로 자신이 사용하는 언어가 어떻게 동작하는지 이해한다면 좀 더 효율적인 프로그램을 만들 수 있습니다.


References