기본 콘텐츠로 건너뛰기

Avoiding a Leak on Allocation Failure

thumbnail

CPython: 서브인터프리터 데이터 공유에서 ‘할당 실패’ 한 번이 누적 메모리 누수로 이어질 수 있던 경로(gh-147960)

meta_description: CPython의 cross-interpreter data 공유 코드에서 튜플을 공유 데이터로 바꾸는 과정(_tuple_shared) 중 메모리 할당이 실패하면, 에러는 MemoryError로 떨어지지만 일부 구조체가 해제되지 않아 누수가 생길 수 있었다. gh-147960은 실패 경로에서 shared 구조체를 RawFree 해 누수를 막는다. 이 글은 “왜 이런 한 줄이 중요해지는지”, 서브인터프리터/임베딩/확장모듈 개발자가 놓치기 쉬운 실패 경로 설계, 테스트/관측 포인트를 실무 관점으로 정리한다. meta_keywords: python,cpython,subinterpreters,cross-interpreter data,_tuple_shared,MemoryError,allocation failure,memory leak,PyMem_RawFree,PyMem_Calloc,error path,임베딩,확장모듈,안정성,회귀,테스트,관측,메모리 meta_robots: index,follow

CPython PR 하나가 병합됐다.

  • 제목은 소박하다: “할당 실패 시 _tuple_shared()에서 메모리 누수 방지”
  • diff는 더 소박하다: 한 줄

하지만 이런 종류의 패치는, 특정 환경에서는 체감 임팩트가 크다.

  • 서버가 오래 떠 있고
  • 서브인터프리터(subinterpreters)를 많이 돌리거나
  • 임베딩/확장 모듈이 많고
  • 메모리 압박 상황(컨테이너 제한/스파이크)이 가끔 생기는 곳

여기서는 “드물게 한 번 터지는 할당 실패”가 누적으로 번지는 문제가 되기 쉽다.


1) 무엇이 바뀌었나: 실패 경로에서 구조체를 하나 더 해제한다

PR에서 바뀐 코드는 이거다.

  • 튜플 공유 데이터를 만들면서 shared->items 배열을 PyMem_Calloc()으로 잡는다
  • 그런데 그 할당이 실패하면 PyErr_NoMemory()로 MemoryError를 세팅하고 -1로 실패를 반환한다

여기까지는 정상.

문제는 그 직전에 이미 shared 구조체 자체는 할당되어 있었고, items만 실패했을 때 shared가 해제되지 않으면 누수가 된다.

그래서 패치가 추가한 한 줄:

PyMem_RawFree(shared);

정리하면:

  • 에러는 그대로 MemoryError
  • 동작도 그대로 실패 반환
  • 하지만 실패 직전에 잡아둔 메모리는 확실히 반환

이다.

이게 “한 줄인데 중요한 이유”는 다음 섹션에서 나온다.


2) 왜 중요한가: ‘할당 실패’는 0이 아니라, 가끔은 1이 된다

일반적인 개발 환경에서는 메모리 할당 실패를 잘 안 본다.

그래서 많은 코드(특히 내부/저수준)에서 실패 경로가 허술해지기 쉽다.

그런데 운영 환경에서는 아래 조건이 겹치면 할당 실패가 실제로 발생한다.

  • 컨테이너 메모리 제한이 빡빡함
  • 동시 요청이 순간적으로 몰림
  • 메모리 단편화가 쌓임
  • (특히) 여러 인터프리터/샌드박스/플러그인 구조로 객체를 많이 만들고 지움

이때 “가끔 한 번의 할당 실패”가 생기고, 그 경로에서 누수가 나면 상황이 이렇게 변한다.

  • 메모리 압박 → 할당 실패 발생
  • 실패 경로 누수 → 회복이 안 됨
  • 누적 → 다음 압박이 더 빨리 옴
  • 결국 OOM/재시작으로 이어짐

즉, 누수는 ‘평소엔 안 보이는’ 문제가 아니라 압박이 올 때 폭발하는 문제다.


3) 이 코드는 어디에 있나: cross-interpreter data(서브인터프리터용) 쪽

수많은 사용자에게는 생소한 파일이다.

Python/crossinterp_data_lookup.h 는 이름 그대로 “서브인터프리터 간에 데이터를 안전하게 넘기기 위한 내부 경로”에 가깝다.

여기서 중요한 건:

  • 이 코드는 “기능 추가”보다 “안정성”이 더 중요하고
  • 오류가 나면 파이썬 예외로 끝나길 기대하지만
  • 실패 경로에서 누수가 있으면 서비스 전체가 영향을 받는다는 점이다.

특히 서브인터프리터를 활용하는 쪽(또는 앞으로 활용할 가능성이 있는 쪽)은, 이런 작은 안정성 패치가 누적되어야 운영이 쉬워진다.


4) 실무에서 어떤 팀이 관심 가져야 하나

이 패치가 당장 “내 코드가 느려졌다/빨라졌다”를 만드는 건 아니다.

대신 아래 팀은 관심을 가져야 한다.

(A) 서브인터프리터 기반 아키텍처를 실험 중인 팀

  • 플러그인/멀티테넌트 격리를 인터프리터로 해결하려는 경우
  • 워커 프로세스를 늘리기보다 내부 격리를 늘리는 방향

이때 cross-interpreter data 공유는 피할 수 없이 등장한다.

(B) 파이썬을 임베딩하는 제품/서버

  • C/C++ 앱 안에 Python을 심는 경우
  • “한 프로세스가 오래 살아있고” 메모리 프로파일이 중요함

이런 환경에서는 드문 누수도 시간이 지나면 부담이 된다.

(C) 확장 모듈 개발자(특히 메모리/레퍼런스에 민감한 코드)

이 패치가 직접적으로 extension API를 바꾸는 건 아니지만,

  • 내부에서는 이런 실패 경로를 계속 다듬고 있다는 신호고
  • 확장 모듈 쪽도 “실패 경로 정리”를 습관으로 가져가야 한다는 알림이 된다.

5) 개발자 팁: 실패 경로를 설계할 때 ‘한 덩어리만’ 누수 나도 패턴이 된다

이 PR에서 제일 배우기 좋은 포인트는 이거다.

성공 경로가 아니라, 실패 경로를 끝까지 걷는다.

실전에서 누수는 보통 이런 모양으로 생긴다.

  • 큰 구조체 A 할당
  • A 안의 배열 B 할당
  • B가 실패
  • 예외 반환
  • A가 해제되지 않음

즉, “A를 free 해야 한다”가 코드 리뷰에서 빠지면 그대로 누수가 된다.

추천 습관 2개

1) 자원 획득 순서를 따라 반대로 해제한다 - A 잡고 → B 잡고 → 실패하면 B는 없고 A는 있음 → A 해제

2) 실패 지점마다 ‘지금까지 잡은 것’을 주석으로 남긴다 - “여기선 shared만 잡혀있다” 같은 메모가 있으면 리뷰가 쉬워진다.


6) 관측(Observability) 관점: 누수는 로그가 아니라 지표에서 먼저 보인다

이 패치 같은 건 보통 로그로는 안 잡힌다.

왜냐하면 할당 실패는 예외로 올라가고, 누수는 조용히 누적되기 때문이다.

그래서 추천하는 관측 포인트는 단순하다.

  • 프로세스 RSS/메모리 사용량 추세
  • 요청량 대비 메모리 곡선(steady state가 있는지)
  • MemoryError 발생 빈도(완전 0이 아닌지)

“MemoryError가 가끔 뜨는데 서비스는 살아있다”는 상태가 가장 위험하다.

  • 살아있다는 이유로 무시하기 쉽고
  • 하지만 그때마다 실패 경로가 자원을 더 잡고 있다면
  • 곡선은 서서히 위로 기울어진다

이번 패치는 이런 종류의 기울기를 한 군데 줄여준다.


7) 결론: ‘한 줄 패치’는 결국 운영을 편하게 한다

정리하면, gh-147960은 대단한 기능 추가가 아니다.

하지만 “할당 실패가 실제로 발생하는 환경”에서는

  • 실패는 어차피 실패지만
  • 실패 후 회복이 가능하게 만들고
  • 장기 운영에서 메모리 곡선을 덜 위험하게 만든다

는 의미가 있다.

파이썬을 제품/서비스로 운영하는 입장에서는, 이런 안정성 패치를 꾸준히 태우는 게 결국 가장 싸다.

덧붙이면, 이런 류의 버그는 “한 번의 큰 장애”로 나타나기보다, 아래처럼 회색 지대로 오래 남아있다가 문제를 만든다.

  • 가끔 MemoryError가 뜨는데 재시도하면 살아난다
  • RSS가 조금씩 올라가지만 그래프가 급격하진 않다
  • 특정 시간대(트래픽 피크)만 지나면 안정된다

이 상태가 지속되면, 팀은 문제를 “일시적 트래픽”으로 오해하기 쉽다.

그래서 나는 이런 패치를 볼 때마다 체크를 하나 한다.

  • 우리 서비스는 ‘희귀 실패’가 나올 수 있는 조건(메모리 제한/긴 런타임/내부 격리)이 있는가?

답이 Yes면, 기능 패치보다 이런 안정성 패치가 더 ‘ROI가 좋다’고 느끼는 편이다.


Keywords

python,cpython,subinterpreters,cross-interpreter data,_tuple_shared,MemoryError,allocation failure,memory leak,PyMem_RawFree,PyMem_Calloc,error path,embedding,extension,stability,testing,observability


이미지 크레딧/라이선스

  • Macro laptop coding (Unsplash).jpg — Marc Mueller / CC0
    • https://commons.wikimedia.org/wiki/File:Macro_laptop_coding_(Unsplash).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...

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁 안녕하세요! 오늘은 AWS RDS를 사용하는 Django 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 1. RDS 인스턴스 최적화 1.1 인스턴스 타입 선택 # RDS 인스턴스 크기 조정 import boto3 def resize_rds_instance(): rds = boto3.client('rds') response = rds.modify_db_instance( DBInstanceIdentifier='your-db', DBInstanceClass='db.t3.large', # 워크로드에 맞는 인스턴스 타입 선택 ApplyImmediately=True ) return response['DBInstance'] 1.2 파라미터 그룹 설정 def create_parameter_group(): rds = boto3.client('rds') # PostgreSQL 파라미터 그룹 생성 response = rds.create_db_parameter_group( DBParameterGroupName='django-optimized', DBParameterGroupFamily='postgres13', Description='Optimized parameters for Django applications' ) # 성능 관련 파라미터 설정 parameters = [ { 'ParameterName': 'shared_buffers', 'ParameterValue': '2GB...