기본 콘텐츠로 건너뛰기

OrderedDict popitem leak fix

thumbnail

`OrderedDict.popitem()`을 돌렸는데 메모리가 내려오지 않는다: PR #146537이 손본 ‘가능한 누수’의 실전 증상

meta_description: CPython PR #146537은 OrderedDict의 popitem() 경로에서 발생할 수 있는 메모리 누수 가능성을 수정한다. 반복 popitem을 쓰는 캐시/큐/스케줄러에서 “요소는 지웠는데 RSS가 안 내려가는” 현상이 왜 나올 수 있는지, 어떤 버전에서 조심해야 하는지, 그리고 운영 코드에서 진단(tracemalloc/heap snapshot)과 완화(업그레이드, 패턴 변경)를 실무적으로 정리한다. meta_keywords: python, OrderedDict, popitem, memory leak, RSS, tracemalloc, CPython, cache, queue, eviction, dict, reference, practical, 운영, 진단 meta_robots: index,follow

운영에서 메모리 이슈는 늘 불쾌하다. 특히 더 불쾌한 건 이런 형태다.

  • 캐시에서 항목을 “계속 지운다”
  • 모니터링 그래프에서 객체 개수도 줄어든다
  • 그런데 RSS가 안 내려온다(혹은 아주 천천히만)

이때 팀은 서로 다른 결론으로 갈라진다.

  • “파이썬은 원래 메모리 반환을 안 해”
  • “어딘가 참조가 남았어(진짜 leak)”
  • “컨테이너가 찢어져서 조각난 거야(fragmentation)”

정답은 케이스 바이 케이스인데, 이런 논쟁의 바닥엔 늘 같은 질문이 있다.

“내 코드가 지운 게 진짜로 지워졌나?”

CPython PR #146537은 이 질문에 직접 연결되는 작은 수정이다.

  • [3.14] Fix possible memory leak in OrderedDict popitem

이 글은 PR을 곧이곧대로 요약하기보다, “운영에서 popitem이 등장하는 상황”에 맞춰서 설명한다.



1) 왜 popitem()이 실무에서 중요하나: LRU 비슷한 걸 다들 한 번은 만든다

OrderedDict.popitem(last=False)는 사실상 “맨 앞(가장 오래된)”을 뽑는 도구다.

  • 자체 캐시 eviction
  • 작업 큐(먼저 들어온 것부터 처리)
  • 최근 N개만 유지하는 로그 버퍼

이런 패턴에서 흔히 이렇게 쓴다.

from collections import OrderedDict

od = OrderedDict()

# ... 쌓고

while len(od) > LIMIT:
    key, value = od.popitem(last=False)

이때 사람의 기대는 명확하다.

  • popitem 했으니 그 엔트리는 없어졌고
  • value 쪽 참조도 끊겼고
  • 반복하면 메모리도 어느 정도 회수될 것이다

PR #146537은 바로 이 “참조가 제대로 끊기느냐” 쪽의 가능 누수를 다룬다.


2) PR #146537의 메시지(실무 번역): ‘지웠는데 남는 참조’는 최악이다

diff는 작지만 메시지는 크다.

  • popitem 경로에서 가능한 누수(possible leak)가 있었다
  • 그래서 그 경로를 수정했다

여기서 “possible”이 중요한 이유는,

  • 모든 코드에서 항상 leak이 보이는 게 아니라
  • 특정 반복 패턴/특정 GC 타이밍/특정 workload에서
  • 오래 돌리면 “어딘가 찝찝한 증가”로 보일 수 있다는 뜻이기 때문이다.

이런 류의 bugfix는 운영자 입장에서는 아주 가치가 있다.

  • 내가 잘못한 게 아닐 수 있다
  • 업그레이드로 사라질 수 있다
  • 그리고 “진단할 가치”가 생긴다

3) 현장에서 어떻게 보이나: ‘요소 개수는 맞는데 RSS가 찜찜하게 오른다’

OrderedDict 기반 eviction에서 가장 흔한 증상은 이거다.

  • len(od)는 LIMIT 근처로 유지된다
  • GC도 잘 돈다(major GC 로그가 정상)
  • 그런데 RSS가 완만하게 오른다

이때 단순히 “파이썬은 메모리 반환을 안 한다”로 끝내면 위험하다.

  • 진짜로는 참조가 남아 있을 수 있고
  • 그 남은 참조가 특정 객체(큰 bytes/큰 list)를 붙잡고 있을 수 있다

그래서 나는 이런 이슈를 보면 두 단계를 나눈다.

1) 파이썬 객체가 남아 있나? (heap 관점) 2) 객체는 없어도 RSS가 안 내려오나? (allocator/fragmentation 관점)

PR #146537은 1)의 가능성을 줄이는 종류다.


4) 빠른 진단 루틴(운영에서 바로 쓰는 것들)

4-1) tracemalloc로 “누가 남는지” 본다

import tracemalloc

tracemalloc.start()

# workload 실행

snap = tracemalloc.take_snapshot()
for stat in snap.statistics('lineno')[:10]:
    print(stat)

여기서 popitem을 반복하는 루프 주변에서 할당이 계속 누적되면, leak 가능성이 올라간다.

4-2) value가 큰 타입인지부터 체크한다

popitem 자체가 leak이 아니어도, value가 다음 패턴이면 RSS가 튄다.

  • 큰 bytes (예: 압축/이미지/바이너리)
  • 큰 문자열
  • 큰 리스트/딕셔너리

그리고 그런 value를 로그/메트릭/예외 객체에 붙여두면 “지웠는데 남는” 착시가 생긴다.

4-3) ‘참조를 내가 붙잡고 있지 않나’ 먼저 의심한다

실무에서 제일 많은 leak은 popitem이 아니라 이런 것들이다.

  • seen.append(value) 같은 디버그 코드
  • 예외를 리스트에 쌓는 에러 수집기
  • 큐에 넣고 소비를 못 하는 백프레셔 붕괴

PR이 있다고 해서 내 코드가 무죄가 되는 건 아니다. 다만, PR이 있으면 “업그레이드로 사라지는지”를 테스트할 이유가 생긴다.


5) 완화 전략(가장 현실적인 순서)

1) 해당 PR이 포함된 버전으로 업그레이드 - 이게 가장 싸고 확실한 옵션이다.

2) OrderedDict eviction 패턴을 단순화 - 예: 지나치게 잦은 popitem을 줄이고 배치로 evict

3) 컨테이너 교체 고려 - 단순 LRU라면 functools.lru_cache 또는 검증된 캐시 라이브러리 사용

4) “RSS가 안 내려오는 게 정상인지”를 문서화 - 파이썬 메모리 할당자는 OS에 즉시 반환하지 않을 수 있다 - 하지만 그건 “heap이 안 남는다”는 전제가 있어야 안전하다

5) 재현을 ‘작게’ 만들어서 팀 논쟁을 끝내기

메모리 이슈는 감정이 붙으면 오래 간다. 그래서 나는 가능하면 최소 재현을 만든다.

  • LIMIT=1000 정도로 고정
  • value는 큰 bytes로 고정(예: b'x' * 1_000_000 같은)
  • od[key] = valuepopitem(last=False) 루프를 충분히 돌린다

가능하면 “측정”도 같이 묶는다.

import os
import tracemalloc
from collections import OrderedDict

tracemalloc.start()

LIMIT = 1000
od = OrderedDict()

for i in range(50_000):
    od[i] = b'x' * 100_000
    if len(od) > LIMIT:
        od.popitem(last=False)

snap = tracemalloc.take_snapshot()
print(snap.statistics('filename')[:3])
print('pid', os.getpid())

그리고 아래 두 값을 같이 본다.

  • heap이 정말로 줄어드는지(tracemalloc)
  • RSS가 어떤 패턴으로 움직이는지

이게 있으면 “파이썬은 원래 그래” vs “진짜 leak이야” 논쟁을 빠르게 끝낼 수 있다.

(코드는 예시일 뿐이고, 운영 환경에서 그대로 돌리라는 뜻은 아니다. 중요한 건 ‘작게 재현 + 같이 측정’이다.)

즉, heap leak인지 먼저 분리하고, 그 다음에 allocator 특성을 논의하는 게 순서다.


6) 마무리

PR #146537은 작은 수정이지만, 메시지는 명확하다.

  • OrderedDict.popitem() 같은 기본 도구도
  • 특정 경로에선 “가능한 누수”가 있을 수 있고
  • 그건 업그레이드로 해결될 수 있다

운영에서 내가 가져가는 결론은 이거다.

  • popitem 기반 eviction을 쓰는 서비스라면
  • 메모리 그래프가 찜찜할 때 “내 코드만” 탓하지 말고
  • CPython 릴리즈 노트/백포트 PR도 같이 보는 게 시간 절약이다

Keywords

python,ordereddict,popitem,leak,memory,RSS,tracemalloc,cache,eviction,queue,gc,reference,heap,allocator,production,bugfix,3.14,collections

References

  • CPython PR #146537
    • https://github.com/python/cpython/pull/146537
  • Diff
    • https://github.com/python/cpython/pull/146537.diff

이미지 크레딧/라이선스

  • Laptop Programmcode.jpg — Negative Space / CC0
    • https://commons.wikimedia.org/wiki/File:Laptop_Programmcode.jpg
    • http://creativecommons.org/publicdomain/zero/1.0/deed.en

댓글

이 블로그의 인기 게시물

Django에서 트랜잭션 관리하기

Django에서 트랜잭션 관리하기 안녕하세요! 오늘은 Django에서 데이터베이스 트랜잭션을 효과적으로 관리하는 방법에 대해 알아보겠습니다. 1. 트랜잭션의 중요성 트랜잭션은 데이터베이스의 일관성과 무결성을 보장하는 중요한 개념입니다. Django에서는 여러 가지 방법으로 트랜잭션을 관리할 수 있습니다. 1.1 기본 개념 원자성(Atomicity) : 트랜잭션은 모두 실행되거나 모두 실행되지 않아야 합니다. 일관성(Consistency) : 트랜잭션 전후로 데이터베이스의 일관성이 유지되어야 합니다. 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 합니다. 지속성(Durability) : 완료된 트랜잭션의 결과는 영구적으로 저장되어야 합니다. 2. Django의 트랜잭션 관리 2.1 기본 설정 # settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'mydatabase', 'USER': 'myuser', 'PASSWORD': 'mypassword', 'HOST': 'localhost', 'PORT': '5432', 'ATOMIC_REQUESTS': True, # 모든 뷰를 트랜잭션으로 래핑 } } 2.2 데코레이터 사용 from django.db import transaction @transaction.atomic def create_order(user, items): order = Order.objects.create(user=...

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드 안녕하세요! 오늘은 AWS S3와 CloudFront를 사용하여 정적 파일을 효율적으로 서빙하는 방법에 대해 알아보겠습니다. 왜 S3와 CloudFront를 사용할까요? 높은 가용성 : AWS의 글로벌 인프라를 활용 빠른 전송 속도 : CloudFront의 CDN 기능으로 전 세계 사용자에게 빠른 전송 비용 효율성 : 사용한 만큼만 지불 보안 : AWS의 보안 기능 활용 확장성 : 트래픽 증가에 자동 대응 1. S3 버킷 설정 1.1 버킷 생성 및 설정 import boto3 def create_s3_bucket(): s3 = boto3.client('s3') # 버킷 생성 bucket_name = 'your-static-files-bucket' s3.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ 'LocationConstraint': 'ap-northeast-2' } ) # 버킷 정책 설정 bucket_policy = { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObje...

Python에서 asyncio 완전 정복 (await, async, gather 등)

어휴, 요즘 파이썬으로 비동기 프로그래밍 하는 재미에 푹 빠졌어요! 특히 asyncio 는 정말 마법 같더라고요. 처음엔 좀 낯설었는데, 익숙해지니까 속도 향상이 눈에 띄게 느껴져서 완전 반해버렸습니다. 이 글에선 제가 asyncio 를 배우면서 깨달은 점들을 풀어놓을게요. 혹시 비동기 프로그래밍이 뭔지 잘 모르시겠다면, 간단히 말해 여러 작업을 동시에 처리해서 프로그램 속도를 엄청나게 높이는 기술이라고 생각하시면 돼요. 마치 여러 요리사가 동시에 음식을 만들어서 손님에게 빨리 제공하는 것과 비슷하죠! 일단 async 와 await 라는 녀석들이 핵심인데요, async 는 함수 앞에 붙여서 "얘는 비동기 함수야!"라고 선언하는 거예요. 그리고 await 는 다른 비동기 함수가 끝날 때까지 기다리라고 지시하는 역할을 하죠. 예를 들어, 네트워크에서 데이터를 가져오는 함수가 있다면, await 를 사용해서 데이터가 다 가져올 때까지 기다렸다가 다음 작업을 진행할 수 있어요. 그 동안 다른 작업을 처리할 수 있으니, 마치 멀티태스킹을 하는 것처럼 느껴져요. 신기하지 않나요? 그리고 asyncio.gather 는 여러 비동기 함수를 동시에 실행하고 결과를 모아주는 아주 유용한 친구입니다. 제가 웹사이트 여러 개에서 데이터를 동시에 가져와야 할 때 정말 요긴하게 썼어요. 하나씩 순서대로 가져오는 것보다 훨씬 빠르더라고요! 마치 여러 개의 탭을 동시에 열어놓고 작업하는 것과 같다고 생각하시면 될 것 같아요. 실제로 제가 썼던 코드를 보여드릴게요. 세 개의 웹사이트에서 데이터를 가져오는 예제인데요. (아래 코드 삽입) 이 코드를 보시면, fetch_data 함수가 각 웹사이트에서 데이터를 가져오는 역할을 하고, asyncio.gather 가 이 함수들을 동시에 실행하도록 도와주는 것을 볼 수 있을 거예요. asyncio.sleep(2) 는 네트워크 지연을 시뮬레이션하기 위해 넣...