본문 바로가기

Python/Python

[Python] Memory 관리에 대한 이해와 Garbage Collection(GC)

반응형

파이썬은 C 계열의 언어들과는 다르게 메모리 관리를 파이썬 스스로 수행해준다. 덕분에 많은 파이썬 개발자들은 어렵고 귀찮은 메모리 관리 작업을 파이썬에게 위임하고 편한 마음으로 개발을 시작한다. 하지만 파이썬의 메모리 누수를 유발하는 문법이나 방식을 남발(?)하게 되다 보면 파이썬에서도 Garbage Collection과 같은 것을 이용해 메모리 관리를 직접 해주는 것이 필요할 수 있다. 

 

그런 최악의 시나리오에 들어가지 않기 위해서 파이썬 개발자로서 파이썬이 메모리를 어떻게 관리하는지 알고 싶었다. 그리고 내가 작성해오는 코드에는 메모리 누수가 없는지도 확인해보고 싶었다.(꽤 많을걸..) 그래서 여러 자료를 찾아보던 중 2015년 배준현님의 Pycon 발표 영상을 찾아보았고, 30분 남짓 되는 시간 동안 많은 내용을 얻어간 것 같아 이를 내 기억에 영구적으로 저장하고자 블로그 글에 담아보려고 한다. 그럼 시작해보자.

 

text to image 생성형 모델에 "awesome memory in computer science" 라고 했더니 생성해준 이미지

 

참고로 아래 글은 파이썬의 컴파일러가 CPython3.x 구현체로 구현된 파이썬에 해당하는 내용이다. Jytho, Cython, pypy 등 다른 컴파일러로 구현된 파이썬에 대해서는 다를 수 있다.

1. 변수의 메모리를 확인해보자.

"파이썬에서는 클래스, 함수 등 모든 것이 모두 객체이다." 라는 말을 들어본 적이 있다. 객체라는 것은 곧 변수로 할당을 할 수 있다는 의미이고, 이 할당된 변수는 메모리 주소 값을 갖고 있음을 의미한다. 파이썬에서도 built-in으로 제공되는 id() 라는 함수를 활용해서 파이썬의 메모리 주소를 반환할 수 있다. 예시 코드는 아래와 같다. 메모리 주소를 보기 쉽게 16진수로 변환하였다. 

 

temp = "zedd"
print(hex(id(temp))) # 0x104a5c170

 

위처럼 우리가 흔히 할당하는 변수에는 해당 데이터(위 예시에서는 'zedd' 라는 데이터) 자체가 아닌 데이터가 담겨있는 메모리 주소가 들어있다. 사람 눈으로 보기에는 마치 temp 라는 변수에 zedd 라는 데이터가 담겨있을 것 같아 보이지만 실질적으로는 아니라는 것이다. 이를 머릿속에 두고 이제 파이썬에서 == 과 is 라는 비교연산자 차이를 알아볼 필요가 있다. 

 

먼저 == 이라는 연산자를 사용하게 되면 먼저 변수에 담겨 있는 메모리 주소를 가져오고, 그 주소에 있는 데이터를 가져오는 2단계의 스텝을 밟는다. 반면에 is 라는 연산자는 메모리 주소를 비교하는 것이기 때문에, 변수에 담겨있는 메모리 주소로 비교를 수행해 1단계의 스텝만을 밟는다. 

 

그렇기 때문에 is 라는 연산자가 == 이라는 연산자보다 약간 더 빠른 장점이 있다. 하지만 is 연산자는 개발자가 직접 커스터마이징 하지 못한다. 즉, 오버라이딩(Overriding)이 불가능하다. 반면에 == 연산자는 __eq__ 이라는 매직 메서드를 커스텀하게 조작할 수 있기 때문에 오버라이딩이 가능하다. 결국 is 와 == 중에 어떤 것이 마냥 더 좋다라고 할 수 없고, 구현하려는 로직에 따라 각각의 장단점을 따져서 취사선택하면 좋을 듯 하다.

2. Mutable vs Immutable 객체

파이썬의 자료구조 중에는 크게 mutable한 객체와 immutable한 객체로 나뉠 수 있다. mutable의 사전적 정의는 '변하기 쉬운'이라는 용어로 말 그대로 이미 생성된 자료구조에 원소를 추가하거나 제거하는 등 '수정을 가할 수 있는' 자료구조를 의미한다. 그렇다면 당연히 immutable한 객체는 그 반대의 특성을 가질 것이다. 파이썬에서 대표적인 mutable, immutable한 자료구조의 종류들은 아래와 같다.

 

  • immutable : int, str, float, tuple, ...
  • mutable : list, dict, set, ...

그런데 이 두 종류의 자료구조 간의 차이점이 단순히 원소를 추가하거나 제거하는 것과 같이 수정을 가할 수 있는 것에 따라 차이만 있는 것이 아니다. 우리가 이번에 살펴볼 점은 바로 메모리 관점에서의 차이다.

 

먼저 mutable한 자료구조들은 이미 생성된 객체에 원소를 추가하거나 제거한다고 해도 새롭게 메모리를 재할당하지 않는다. 실제로 아래 코드를 돌려보면 메모리 주소가 여전히 동일한 것을 알 수 있다.

 

lst = ["foo", "bar"]
print(hex(id(lst))) # 0x103466780
lst.append("baz")
print(hex(id(lst))) # 0x103466780

 

참고로 append와 같은 함수를 쓰지 않고, 단순히 리스트를 더하는(+) 연산자를 사용하게 되면 mutable한 자료구조임에도 새로운 메모리 주소가 할당됨에 주의하자. 아래 예시 코드이다.

 

lst = ["foo", "bar"]
print(hex(id(lst)))  # 0x1032d2780
lst2 = ["baz"]
print(hex(id(lst2))) # 0x1033c2000
print(hex(id(lst + lst2))) # 0x1033ae740



그러면 이제 immutable한 대표적인 자료구조 중에 흔히 우리가 프로그래밍할 때 자주 하는 숫자를 반복해서 더하는 형태의 코드를 보자.

 

integer = 100
print(hex(id(integer))) # 0x102aa8d50
integer += 10
print(hex(id(integer))) # 0x102aa8e90

 

메모리 주소 출력을 보면 100에 10이 더해진 숫자 데이터를 저장하는 메모리 주소가 새롭게 할당된 것을 볼 수 있다. 

 

그럼 mutable, immutable 각 자료구조와 메모리 간의 관계를 좀 더 면밀히 살펴보자. 우선 immutable한 자료구조 중 int 형을 이용해서 아래와 같은 코드를 실행해보자.

 

a = 100
b = a
b += 99

print(a, b, a is b) # 100 199 False

 

immutable한 자료구조는 어차피 새로운 메모리 주소를 할당하기 때문에 is 연산자를 활용했을 때 당연히 False가 나오게 된다. 이는 b에다가 a를 할당한 후 99라는 값을 더했지만 b 값만 199가 되고 a는 100이 되어 있게 하는 이유가 되기도 한다. 그러면 mutable한 객체 중 하나인 리스트에서는 어떨까?

 

a = ['foo', 'bar']
b = a
b.append('baz')

print(a, b, a is b) # ['foo', 'bar', 'baz'] ['foo', 'bar', 'baz'] True

 

똑같이 b에다가 a를 할당하고, b에다가 baz라는 원소를 추가했다. 그런데 결과를 보니 a 리스트에도 baz 라는 원소가 추가된 것을 볼 수 있다. 이유가 뭘까? 바로 mutable한 자료구조는 새로운 메모리 주소를 할당하지 않기 때문이다. 그래서 is 연산자를 비교한 결과 값도 True인 것을 볼 수 있다. 이러한 특징은 우리가 원본의 자료구조 상태를 보존하지 못하고 원본 자료구조도 수정을 하게 만드는 실수를 유발한다. 예시는 아래와 같다.

 

origin = ["이것은 원본이야. 보존해야 함"]
temp = origin
temp[0] = "이것은 수정본이야"

print(origin, temp, origin is temp) # ['이것은 수정본이야'] ['이것은 수정본이야'] True

 

그렇다면 mutable한 자료구조를 사용해야만 하는데, 위와 같은 상황을 예방하기 위해서는 어떻게 해야할까? 만약 리스트를 사용한다면 slicing operator를 사용하면 된다. 아래 예시 코드를 살펴보자.

 

a = ['foo', 'bar']
b = a[:] # slicing operator
b.append('baz')

print(a, b, a is b) # ['foo', 'bar'] ['foo', 'bar', 'baz'] False

 

하지만 slicing operator는 리스트에만 적용이 가능하다. 그러면 리스트가 아닌 다른 자료구조에서는 뭘 사용해야할까? 두 번째 방법으로는 copy() 라는 메소드를 사용하면 된다.(물론 리스트도 가능함)

 

a = {'foo': 1, 'bar': 2}
b = a.copy()
b['baz'] = 3

print(a, b, a is b) # {'foo': 1, 'bar': 2} {'foo': 1, 'bar': 2, 'baz': 3} False

 

하지만 slicing operator나 copy()와 같은 built-in 메소드를 사용하는 것은 직접 만든 클래스나 인스턴스 객체같은 것에는 적용되지 않는다. 그리도 또 다른 단점으로 이중 리스트, 이중 딕셔너리와 같이 nested한 객체에서 nested한 객체 안의 값을 수정하거나 삭제할 때는 적용되지 않는다. 예를 들어 아래와 같은 nested 한 이중 리스트가 있다고 해보자.

 

a = ['foo', ['bar']]
b = a.copy()
b[1][0] = 'baz'

print(a, b, a is b) # ['foo', ['baz']] ['foo', ['baz']] False

 

출력 결과를 보면 copy() 메소드를 사용했음에도 불구하고 a 라는 변수도 baz 값으로 수정된 것을 볼 수 있다. 참고로 이는 nested된 객체 즉, 안쪽의 리스트의 값에만 해당이 된다. 만약에 foo 라는 원소를 baz로 바꾸는 코드로 수행하면 여전히 copy() 메소드가 잘 동작한다. nested된 객체까지 새롭게 복사가 되지 않는 이유는 copy() 메소드가 얕은 복사를 수행하기 때문이다. 이럴 때의 해결책은 무엇일까?

 

바로 copy 라는 모듈을 사용하면 된다. copy 모듈을 기본적으로 import 할 수 있는 라이브러리로, copy() 와 deepcopy() 메소드가 존재한다. copy() 메소드는 우리가 위에서 알아본 built-in copy() 메소드나 slicing operator와 같이 얕은 복사를 수행한다. 반면에 deepcopy()는 이름에서도 유추할 수 있다시피 깊은 복사를 수행한다. 고로 deepcopy()를 이용해서 nested된 객체도 새롭게 복사를 수행한다. 아래 예시 코드를 보자.

 

import copy

a = ['foo', ['bar']]
b = copy.deepcopy(a)
b[1].append('baz')

print(a, b, a is b) # ['foo', ['bar']] ['foo', ['bar', 'baz']] False

3. Singleton 객체

다음은 singleton 객체에 대해서 알아보자. singleton 객체는 모든 인스턴스에서 동일한 메모리 주소를 공유하는 것들이다. singleton 객체의 종류로는 True, False, None, Ellipsis(...이라는 문자열), NotImplemented 예외 키워드가 있다. 예를 들어서, singleton 객체 종류 중 하나인 True 값을 대화형 인터프리터에서 계속 메모리 주소 값을 출력하더라도 동일한 값이 나오는 것을 볼 수 있다. 

 

for _ in range(5):
    print(hex(id(True)))

 

그렇기 때문에 singleton 객체에 한해서는 == 이라는 연산자보다 is 라는 메모리 주소만을 비교하는 연산자를 활용해서 비교하는 것이 좀 더 빠르고 확실한 결과를 얻을 수 있는 방법이다.

4. String Interning

다음은 메모리 최적화 기법 중 하나로, 문자열을 메모리 하나에만 저장해놓는 기법이다. 이를 사용하면 동일한 문자열을 여러 군데의 메모리에 할당하지 않는 방법이다. 파이썬은 현재 기본적으로 string interning 기법이 활성화되어 있다. 그래서 아래와 같은 코드로 수행하면 True가 나오는 것을 볼 수 있다. 발표 영상 당시에는 파이썬이 기본적으로 string interning 기법이 비활성화되어 있어서 sys 라이브러리의 intern() 함수를 사용해 활성화하는 방법을 알려준다.(2015년 기준의 파이썬 버전은 String interning 기능이 비활성화된 상태였다고 함)

 

a = "zedd"
b = "zedd"
print(a is b)

5. 필요없는 메모리 지우기

다음은 더 이상 사용하지 않는 데이터의 메모리를 직접 지워볼 수 있는 방법에 대해 배워보자. 먼저 del 키워드를 사용하면 된다. 리스트나 딕셔너리에서 사용이 가능하다.

 

a = ['foo', 'bar']
b = a.copy()
del b

print(b)

 

다음은 파이썬이 알아서 해주는 참조 카운트(Reference Count) 기반으로 Garbage Collection 때 메모리에서 삭제하는 방법이다. 이전에 밑시딥 책을 공부하면서 잠깐 파이썬에서 참조 카운트 기반으로 메모리를 관리하는 것에 대해 배운 적이 있었다. 말 그대로 특정 변수의 메모리 주소가 참조되고 있는 횟수를 측정하고, 그 횟수가 0이 되면 파이썬이 알아서 메모리로부터 수거해나가는 것이다. 

 

특정 변수의 메모리 주소가 얼마나 참조되고 있는지 횟수를 측정할 수도 있다. 아래 예시 코드를 보자.

 

import sys

a = ['foo', 'bar']
b = a  # 동일한 memory id 할당

print(sys.getrefcount(a)) # 3

 

우선 a 라는 변수에 최초로 리스트의 데이터를 할당하였다. 그러면 a 라는 변수의 메모리 주소가 참조하고 있는 횟수는 1이다. 그리고 난 뒤 b = a를 할당하였다. 위에서 배운 것처럼 리스트는 mutable한 자료구조로서 기본적으로 새로운 메모리를 할당하지 않는다고 배웠다. 따라서 b = a로 하는 순간 a는 참조 횟수가 하나 증가하여 2가 된다. 그리고 마지막으로 sys.getrefcount() 함수를 사용해서 a의 참조 횟수를 측정하는데, 이 때 2가 아니라 3이 나온다. 왜냐하면 sys.getrefcount() 함수에 a를 인자로 넣어주기 때문에 그 순간 sys.getrefcount() 라는 함수가 a의 메모리 주소를 참조하기 때문에 1이 더 증가한다. 

6. Garbage Collection(GC)

다음은 방금 소개했던 파이썬이 알아서 메모리를 관리하는 데 활용하는 Garbage Collection에 대해 알아보자. 필요 없는 메모리를 자동으로 해제하는데, 해제할 메모리를 선택하는 근거는 참조 카운트 방식과 휴리스틱 방법들을 이용한다. 여기서 휴리스틱 방법이라고 한다면 기본적으로 GC는 Generation(세대)를 나누어서 관리를 하게 된다. 그리고 각 세대에 들어갈 수 있는 변수의 최대개수인 threshold를 설정할 수가 있다. 그리고 이 threshold가 넘어가는 변수들 중 세대가 가장 늙은(오래된) 것부터 '쓰레기'라고 간주하고 메모리에서 수거해나간다.

 

그러면 GC가 동작하는 과정을 한 번 도식화해서 알아보자. 그림 속에 상세한 설명까지 적어놓았으니 자료로 충분히 이해할 수 있을 것이다.

 

7. 약한 참조를 하자; weakref

다음은 참조 횟수를 증가시키지 않고 특정 객체를 참조할 수 있도록 해주는 weakref에 대해 알아보자. weakref는 '약한 참조'라는 의미로, 이름에서 느낌을 알 수 있듯이 원본을 약하게만 참조하는 것이다. 사실 이것도 밑시딥 책을 공부할 때 배운 적이 있었다. weakref는 메모리를 많이 쓰지만 필수적이지는 않는 것들(ex. 이미지 캐싱)에 대해서 적용하면 이미지를 메모리에 두고 있어서 다른 파이썬 실행 코드가 죽거나 하는 문제들을 막을 수 있다. weakref 모듈에는 여러 메소드가 있지만 여기서는 ref() 라는 함수만 살펴보도록 하자. weak.ref() 함수는 참조할 객체와 해당 객체가 혹시나 참조 카운트가 0이 되어서 GC가 수거해갔을 때, 어떤 값을 내뱉도록 할지 설정하는 callback 인자가 있다. 참고로 weakref 는 빌트인 자료구조에서는 동작을 안한다. 반드시 클래스 형태에만 동작한다.

 

예를 들어, 아래와 같이 1억개의 사이즈가 되는 큰 리스트를 만드는 클래스가 있다고 해보자. Request 요청 마다 매번 이 객체를 생성하는 운영 서버가 있다고 해보자. 그렇게 되면 메모리에 엄청난 부하가 될 것이다. 이를 막기 위해 우리는 아래처럼 weakref를 사용해서 참조 카운트를 증가시키지 않되 객체의 데이터에는 접근할 수 있도록 할 수 있다.

 

import weakref


class Dummy(object):
    def __init__(self):
        self.data = list(range(int(1e8)))


dummy = Dummy()
temp = weakref.ref(dummy)
print(len(temp().data))  # 객체에 접근 가능

del dummy  # 원본 객체를 메모리에서 삭제
print(temp())  # 약한 참조를 한 객체에서도 삭제됨

8. 메모리를 효율적으로 관리하는 몇 가지 팁

영상 말미에는 발표자 분께서 소개해주는 메모리를 효율적으로 관리하는 몇 가지 팁을 제공해준다. 먼저 + 연산자를 활용한 문자열 결합을 피하도록 하자. 예시 코드는 아래와 같다.

 

a = ["foo", "bar", "baz"]
res = ""


def concat_hello(string: str) -> str:
    return "Hello" + string + "\n"


for i in a:
    res += concat_hello(i)

print(res)

 

위와 같은 문법 대신 join 와 map 함수를 활용해서 대체하도록 하자. 그리고 문자열 결합은 format 함수나 f-string을 사용하도록 하자. 메모리를 고려한 개선한 코드는 아래와 같다.

 

a = ["foo", "bar", "baz"]


def concat_hello(string: str) -> str:
    return "Hello {}\n".format(string)


res = ''.join(map(concat_hello, a))
print(res)

 

다음으로는 context manger로 구현된 with 구문을 적극 활용하는 것이다. 특정 파일을 읽어오는 코드 예시가 아래처럼 있다.

 

f = file("test.pak", "rb")
data = f.read()
print(data)

 

이렇게 하면 읽어들인 파일은 더 이상 필요없음에도 불구하고 여전히 참조 카운트가 1로 남아있기에 계속 메모리에 남아있게 된다. 따라서 읽어들인 파일은 더 이상 사용하지 않기에 with 구문을 사용하면 좋다. 

 

with file("test.pak", "rb") as f:
    data = f.read()
    print(data)

 

이러면 with 블록이 끝나고 읽어들였던 파일이 메모리에서 해제가 된다.


지금까지 파이썬의 메모리 관리의 원리와 다양한 방법에 대해 알아보았다. 물론 엄청난 로우 레벨의 메모리 관리법을 익힌 것은 아니지만 발표자 분이 알기 쉽게 설명해주어서 앞으로 개발할 때 엄청 유용할 것 같다. 조만간 작업할 때 배운 내용을 바로 적용해보려 한다. 파이콘 영상을 제대로 본건 처음이였는데, 2015년 자료임에도 불구하고 유용한 것 같다. 앞으로 자주 찾아봐야겠음!

 

 

 

 

반응형