🔍 검색 키워드: python 메모리 누수, tracemalloc 사용법, gc.collect 효과 없음, 순환 참조 메모리 누수, gc.get_referrers, 파이썬 RSS 계속 증가
요약: 파이썬에서 메모리가 계속 늘어나는 원인은 대부분 가비지 컬렉터의 실패가 아니라, 전역 캐시나 리스트처럼 아무도 놓아주지 않는 참조다. tracemalloc의 스냅샷 비교로 어느 파일 몇 번째 줄에서 할당이 늘어나는지 특정하고, 그 객체를 붙잡고 있는 범인은 gc.get_referrers()로 확인한 뒤 참조를 끊으면 해결된다.
파이썬에서 메모리 누수는 왜 생기나요?
파이썬은 참조 카운팅으로 객체를 즉시 해제하고, 참조 카운팅이 풀지 못하는 순환 참조는 세대별 가비지 컬렉터가 따로 회수한다. 그래서 참조가 하나라도 살아 있으면 그 객체는 애초에 회수 대상이 아니다. 흔히 말하는 파이썬의 메모리 누수는 대부분 이 경우이고, 코드상으로는 다음 세 가지 형태로 나타난다.
- 모듈 전역 dict·list·set에 계속 쌓이는데 아무도 비우지 않는 경우(가장 흔하다)
- 순환 참조가 있는데 GC를 꺼 두었거나 수집 주기 사이에 메모리가 치솟는 경우
- C 확장 모듈이 직접 할당한 메모리(파이썬 추적 도구로는 보이지 않는다)
테스트 환경
아래 출력은 전부 이 환경에서 직접 실행한 결과다.
- macOS 27.0.1 (Darwin 27.0.0, arm64)
python3.12 --version출력: Python 3.12.13
재현 1. 전역 캐시가 메모리를 먹는 과정
import tracemalloc, gc
CACHE = {} # 모듈 전역 캐시 - 아무도 비우지 않는다
def handle(i):
CACHE[i] = {"payload": "x" * 1000, "idx": i}
tracemalloc.start()
for i in range(1000):
handle(i)
snap1 = tracemalloc.take_snapshot()
for i in range(1000, 11000):
handle(i)
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:3]:
print(stat)
print("CACHE len:", len(CACHE))
print("gc.collect() ->", gc.collect())직접 실행 결과
leak1.py:6: size=2543 KiB (+2337 KiB), count=21916 (+20000), average=119 B
leak1.py:13: size=312 KiB (+312 KiB), count=10000 (+10000), average=32 B
CACHE len: 11000
gc.collect() -> 0tracemalloc 결과는 어떻게 읽나요?
각 줄은 파일:줄번호: size=현재 누적 크기 (증가분), count=블록 수 (증가분), average=블록당 평균 형식이다. 위 출력에서 6번째 줄, 즉 CACHE[i] = {...}가 두 스냅샷 사이에 2,337 KiB · 블록 20,000개를 더 썼다. 범인이 한 줄로 특정된 것이다. 13번째 줄은 그 줄을 호출한 반복문이다.
그리고 마지막 줄이 핵심이다. gc.collect()가 0을 반환했다. 회수할 것이 없다는 뜻이다. CACHE가 모든 객체의 참조를 쥐고 있으므로 가비지 컬렉터 입장에서는 전부 멀쩡히 살아 있는 객체다. 메모리가 느는데 gc.collect()를 아무리 불러도 소용없다면 십중팔구 이 상황이다.
호출 경로까지 보고 싶다면
어느 줄인지는 알겠는데 그 줄이 어디서 불렸는지 모를 때는 tracemalloc.start()에 프레임 수를 넘기고 statistics("traceback")을 쓴다.
tracemalloc.start(5) # 콜 스택 5프레임까지 보관
...
top = snap.statistics("traceback")[0]
print("크기:", top.size // 1024, "KiB / 블록:", top.count)
for line in top.traceback.format():
print(line)직접 실행 결과
크기: 72 KiB / 블록: 1
File "tb.py", line 7
for i in range(2000): outer(i)
File "tb.py", line 4
def outer(i): inner(i)
File "tb.py", line 3
def inner(i): CACHE[i] = "x" * 1000재현 2. RSS로 확인할 때 흔히 속는 지점
import resource, gc
def rss_mb():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 / 1024
CACHE = []
print("start RSS:", round(rss_mb(), 1), "MB")
for i in range(50_000):
CACHE.append({"id": i, "payload": "x" * 500})
print("적재 후 RSS:", round(rss_mb(), 1), "MB / 항목:", len(CACHE))
CACHE.clear()
gc.collect()
print("clear 후 RSS:", round(rss_mb(), 1), "MB")직접 실행 결과
start RSS: 13.2 MB
적재 후 RSS: 24.5 MB / 항목: 50000
clear 후 RSS: 24.5 MB5만 건을 담았더니 11MB가 늘었다. 그런데 CACHE.clear()와 gc.collect()를 했는데도 숫자가 24.5MB 그대로다. 메모리가 안 풀린 것처럼 보이지만 사실이 아니다. ru_maxrss는 지금 쓰는 메모리가 아니라 지금까지의 최대치라서 원래 줄어들지 않는다. 해제 여부를 보려면 ru_maxrss가 아니라 tracemalloc의 현재 스냅샷이나 psutil의 현재 RSS를 봐야 한다.
단위도 함정이다. 같은 환경에서 ru_maxrss 원본 값은 13746176이었고, 바이트로 읽으면 13.1MB로 상식적인 값이 된다(킬로바이트로 읽으면 13GB가 되어 말이 안 된다). 파이썬 공식 문서는 이 필드의 단위를 명시하지 않고 각 OS의 getrusage(2) man 페이지를 보라고 안내하므로, 코드에 숫자를 하드코딩하기 전에 실행 환경에서 한 번 확인하는 편이 안전하다.
재현 3. 순환 참조는 정말 누수인가요?
자주 오해하는 부분이다. 실제로 확인해 보자.
import gc
class Node:
def __init__(self, name):
self.name = name
self.peer = None
self.blob = bytearray(100_000)
def make_cycle():
a, b = Node("a"), Node("b")
a.peer = b
b.peer = a # 순환 참조
return
gc.collect()
gc.disable() # GC를 끄고 참조 카운트만 남긴다
before = len(gc.get_objects())
for _ in range(500):
make_cycle()
after = len(gc.get_objects())
print("gc.disable() 상태 - 추적 객체 증가:", after - before)
gc.enable()
print("gc.collect()가 회수한 객체 수:", gc.collect())
print("회수 후 추적 객체 증가:", len(gc.get_objects()) - before)직접 실행 결과
gc.disable() 상태 - 추적 객체 증가: 1000
gc.collect()가 회수한 객체 수: 1000
회수 후 추적 객체 증가: -1GC를 끈 동안에는 함수가 끝났는데도 객체 1,000개(500쌍)가 그대로 남았고, GC를 켜고 수집하자 1,000개가 전부 회수됐다. 즉 순환 참조 자체는 파이썬이 알아서 처리한다. 성능 때문에 gc.disable()을 해 둔 코드가 있다면 그쪽이 진짜 위험 지점이다. 참고로 공식 문서에 따르면 gc.collect()의 반환값은 "회수된 객체 수와 회수 불가능한 객체 수의 합"이다.
__del__이 있으면 영영 못 지운다던데 사실인가요?
지금은 사실이 아니다. 파이썬 3.4에서 PEP 442가 적용되면서 바뀌었다. 공식 gc 문서는 "Changed in version 3.4: Following PEP 442, objects with a __del__() method don't end up in gc.garbage anymore"라고 명시한다. 직접 확인해 보면 이렇다.
import gc
class WithDel:
def __init__(self): self.peer = None
def __del__(self): pass
a, b = WithDel(), WithDel()
a.peer = b; b.peer = a
del a, b
print("gc.collect() ->", gc.collect())
print("gc.garbage:", gc.garbage)직접 실행 결과
gc.collect() -> 8
gc.garbage: []회수됐고 gc.garbage는 비어 있다. 오래된 블로그 글을 보고 __del__을 지우러 다닐 필요는 없다.
참조를 붙잡고 있는 범인은 어떻게 찾나요?
tracemalloc이 "이 줄에서 객체가 늘어난다"까지 알려줬다면, 다음 질문은 "그래서 누가 안 놓아주고 있나"다. 여기에 gc.get_referrers()를 쓴다.
import gc
registry = {}
class Session: pass
s = Session()
registry["user-1"] = s
del s # 지역 변수는 지웠는데도
target = [o for o in gc.get_objects()
if type(o).__name__ == "Session"][0]
for r in gc.get_referrers(target):
print(type(r).__name__, "->", str(r)[:60])직접 실행 결과
dict -> {'user-1': <__main__.Session object at 0x1097bac90>}
dict -> {'__name__': '__main__', '__doc__': None, '__package__': Non첫 줄이 범인이다. registry 딕셔너리가 Session을 붙잡고 있다. 두 번째는 모듈 전역 네임스페이스라 항상 따라 나온다. 공식 문서는 get_referrers()에 대해 "디버깅 외의 목적으로는 사용하지 말라"고 경고하고, 정확한 결과를 위해 호출 전에 collect()를 먼저 부르라고 안내한다.
실무에서는 어떤 순서로 해결하나요?
- 1단계. 앱 시작 지점에서
tracemalloc.start()를 호출하고, 운영 중 주기적으로 스냅샷을 떠 둔다(관리자 전용 엔드포인트나 시그널 핸들러에 붙이면 편하다). - 2단계. 메모리가 늘어난 구간의 두 스냅샷을
compare_to(old, "lineno")로 비교해 증가 상위 10줄을 본다. - 3단계. 줄이 특정되면
statistics("traceback")으로 호출 경로까지 확인한다. - 4단계. 그 줄이 만든 객체를 누가 붙잡고 있는지
gc.get_referrers()로 확인한다. - 5단계. 참조를 끊는다. 캐시라면
functools.lru_cache(maxsize=...)처럼 상한이 있는 자료구조로 바꾸고, 수명이 요청 단위인 객체는 전역이 아니라 요청 스코프에 두며, 레지스트리 성격이면weakref.WeakValueDictionary를 검토한다.
상한을 두는 것만으로 충분한 경우가 많다. 아래는 maxsize를 준 캐시를 1만 번 호출한 결과다.
import functools
@functools.lru_cache(maxsize=128)
def f(i): return "x" * 1000
for i in range(10000): f(i)
print(f.cache_info())직접 실행 결과
CacheInfo(hits=0, misses=10000, maxsize=128, currsize=128)1만 번을 호출했지만 캐시에 남은 항목은 128개다. 상한 없는 dict였다면 1만 개가 그대로 쌓였을 것이다.
주의할 점과 한계
- tracemalloc은 파이썬 할당기를 거친 메모리만 추적한다. C 확장 모듈이 직접 malloc한 영역은 잡히지 않으므로, tracemalloc 수치는 안 느는데 RSS만 느는 경우에는 C 확장을 의심해야 한다.
- tracemalloc 자체가 메모리와 속도를 쓴다. 프레임 수를 크게 잡을수록 비용이 커지므로 운영 환경에서는 필요한 구간에만 켜는 편이 좋다.
- RSS가 줄지 않는다고 해서 누수는 아니다. 파이썬이 객체를 해제해도 할당기가 OS에 메모리를 바로 돌려주지 않는 경우가 많다.
- 이 글에서 직접 재현하지 않은 것: C 확장 모듈의 누수, 멀티프로세스·멀티스레드 환경에서의 누적. 이 경우는 위 도구만으로 부족하다.
정리표
| 증상 | 확인 방법 | 조치 |
| 메모리가 계속 느는데 gc.collect()가 0을 반환 | tracemalloc compare_to("lineno") | 참조를 쥔 전역 캐시·리스트를 비우거나 상한 적용 |
| 어느 줄인지는 알지만 호출 경로를 모름 | tracemalloc.start(5) + statistics("traceback") | 호출 경로상의 적절한 지점에서 참조 해제 |
| 누가 참조를 쥐고 있는지 모름 | gc.collect() 후 gc.get_referrers(obj) | 지목된 컨테이너에서 제거, weakref 검토 |
| RSS가 줄지 않음 | ru_maxrss가 아니라 현재 RSS 확인 | ru_maxrss는 최대치라 원래 줄지 않음 |
| 순환 참조가 쌓임 | gc.isenabled() 확인 | gc.disable()을 걷어내거나 주기적 collect() |
| tracemalloc은 그대로인데 RSS만 증가 | 사용 중인 C 확장 목록 점검 | 확장 모듈 쪽 이슈 트래커 확인 |
자주 묻는 질문(FAQ)
gc.collect()를 주기적으로 호출하면 메모리 누수가 해결되나요?
대부분 해결되지 않는다. 직접 재현한 전역 캐시 예제에서 gc.collect()는 0을 반환했다. 캐시가 참조를 쥐고 있는 한 가비지 컬렉터가 회수할 대상 자체가 없기 때문이다. gc.collect()가 효과를 보는 경우는 순환 참조가 많은데 수집이 미뤄지고 있을 때로 한정된다.
tracemalloc은 운영 서버에 켜도 되나요?
켤 수 있지만 비용이 있다. tracemalloc은 모든 할당의 호출 프레임을 기록하므로 메모리와 CPU를 추가로 쓰고, start()에 넘기는 프레임 수가 클수록 비용이 커진다. 문제가 재현되는 시간대나 특정 워커에만 켜고, 스냅샷을 뜬 뒤 끄는 방식을 권한다.
순환 참조가 생기지 않게 코드를 짜야 하나요?
신경 쓸 필요는 거의 없다. 직접 확인한 결과 순환 참조로 만든 객체 1,000개는 gc.collect() 한 번에 전부 회수됐고, __del__이 있어도 파이썬 3.4부터는 gc.garbage에 남지 않는다. 정말 문제가 되는 상황은 성능 때문에 gc.disable()을 해 둔 경우다.
메모리가 늘어나는데 tracemalloc 수치는 그대로입니다. 왜 그런가요?
tracemalloc은 파이썬 할당기를 거친 메모리만 추적하기 때문이다. NumPy, 이미지 처리, DB 드라이버처럼 C로 작성된 확장 모듈이 자체적으로 할당한 메모리는 집계되지 않는다. 이 경우에는 해당 확장 모듈 쪽의 메모리 관련 옵션이나 알려진 이슈를 먼저 확인해야 한다.
관련 글: Python ModuleNotFoundError 해결 — pip install 했는데 왜 못 찾냐 · Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법
공식 문서: Python — tracemalloc · Python — gc
최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기