기본 콘텐츠로 건너뛰기

디버깅 로그가 서비스를 죽인다: defaultdict.__repr__ 무한 재귀 버그가 보여준 ‘가드의 계약’

thumbnail

디버깅 로그가 서비스를 죽인다: `defaultdict.__repr__` 무한 재귀 버그가 보여준 ‘가드의 계약’

운영하다 보면 가끔 이해가 안 되는 장애를 만난다. 기능을 바꾼 것도 아닌데 프로세스가 죽고, 원인을 쫓아가 보면 마지막 로그가 defaultdict(...) 같은 문자열 하나로 끝나 있다.

“그냥 출력인데요?”

repr은 그렇게 순한 기능이 아니다. repr은 ‘출력’이 아니라 실행 경로다. 로그를 남기려고 객체를 문자열로 바꾸는 순간, 그 객체의 코드가 실행된다. 그리고 그 코드가 재귀로 빠지면, 디버깅이 아니라 장애가 된다.

이번 주 CPython에 흥미로운 버그가 하나 정리됐다. collections.defaultdict.__repr__이 특정 조건에서 무한 재귀로 빠질 수 있었고, PR이 2026-03-10에 머지되면서 수정됐다(참고자료).

이 글은 CPython 내부를 공부하자는 글이 아니다. 내가 말하고 싶은 건 훨씬 단순하다. 재귀 가드가 있는 API는 enter/leave의 계약을 지키지 않으면, 디버깅이 서비스 안정성을 공격한다는 것.


문제가 생기는 코드: 최소 재현은 ‘이상한’ 코드가 아니다

defaultdict를 쓰는 건 흔하다. 없는 키를 조회할 때 기본값을 만들어 주는 게 편하니까.

여기서 트릭은 default_factory다. 보통은 listint를 넣지만, 실무에서는 조금 더 복잡한 팩토리를 넣기도 한다. 예를 들어 로깅을 하고 싶어서, 혹은 상태를 캡처하고 싶어서, 팩토리 자체가 뭔가를 품고 있는 객체가 되기도 한다.

아래 코드는 “이상한 코드”처럼 보이지만, 구조 자체는 흔하다. 핵심은 팩토리의 __repr__이 자신을 참조하는 구조를 건드리게 되는 순간이다.

from collections import defaultdict

class Factory:
    def __init__(self):
        self.d = None

    def __call__(self):
        return 0

    def __repr__(self):
        return f"Factory(d={self.d})"

f = Factory()
d = defaultdict(f)
f.d = d

print(d)

이 패턴이 무서운 이유는 재현이 “print를 누르는 사람”에게만 일어나는 게 아니라는 점이다.

  • 예외가 났을 때 스택트레이스가 객체를 repr로 출력할 수 있고
  • 로깅이 객체를 포맷팅하면서 repr을 호출할 수 있고
  • 디버깅용으로 남긴 한 줄이 프로덕션에서 실행될 수 있다

즉, repr은 개발자의 편의지만, 프로덕션에서는 실행 경로다.

내 경험상 이런 문제는 ‘재현이 잘 안 되는’ 방식으로 터진다. 로컬에서는 print 한 번 하고 지나가는데, 프로덕션에서는 로거 포맷터나 예외 핸들러가 더 복잡한 객체 그래프를 잡아당긴다. 로그 한 줄이 남기기도 전에 프로세스가 죽어서, 남는 건 “마지막으로 찍히려던 문자열”뿐이다. 그래서 이런 버그는 기능 버그보다 운영 버그에 가깝다.


PR이 고친 3줄의 의미: enter가 실패했을 땐 leave를 하면 안 된다

이번 버그의 핵심은 ‘재귀 가드’의 사용 계약을 어긴 실수였다.

CPython에는 repr이 자기 자신을 다시 repr 하다가 끝없이 들어가는 걸 막기 위해 재귀 감지 가드가 있다. C 레벨에서는 Py_ReprEnter/Py_ReprLeave 같은 API로 관리한다.

이걸 실무 감각으로 번역하면 이렇다.

  • enter는 “지금 repr 중이야”라고 표식하는 동작이고
  • leave는 그 표식을 지우는 동작이다

그런데 enter가 “이미 repr 중”을 감지했을 때는, 표식을 새로 세우지 않는다. 표식을 세우지 않았는데 leave를 호출하면, 균형이 깨진다. 균형이 깨지면 가드가 제대로 작동하지 않는다. 가드가 무너지면 무한 재귀가 다시 열린다.

PR이 고친 건 결국 이 계약을 코드로 다시 맞추는 일이었다(참고자료).

간단한 의사코드로 쓰면 이런 느낌이다.

enter_result = ReprEnter(obj)
if enter_result > 0:
    # recursion detected: 이미 들어와 있으니, 여기서 빠져나갈 표현을 반환
    return "..."

try:
    # 정상 repr 생성
    return make_repr(obj)
finally:
    if enter_result == 0:
        ReprLeave(obj)

여기서 포인트는 finally가 아니라 조건이다.

leave는 “내가 들어갔을 때만” 나와야 한다.

이건 C-API를 외우라는 말이 아니다. 어떤 언어든, 어떤 라이브러리든, “가드 enter/leave” 쌍을 제공하면 같은 실수가 나온다. enter가 실패했는데 leave를 부르면, 가드는 무너진다.


내 코드에서 같은 실수를 줄이는 법: repr-safe 습관

실무에서 이 버그가 보여주는 교훈은 두 갈래다.

첫째, repr을 너무 믿지 말자. 특히 로깅에서.

로그를 남기기 위해 객체를 통째로 출력하는 습관은 편하지만, 위험하다. repr이 안전하다는 보장은 없다. repr이 무겁거나, repr이 외부를 건드리거나, repr이 재귀를 유발할 수도 있다.

둘째, 그래도 출력이 필요하면 ‘가드’를 내 쪽에서도 가져야 한다.

파이썬에서 간단하게는 이런 식으로 방어할 수 있다. repr이 실패하거나 너무 깊게 들어가면, 짧은 대체 표현으로 내려앉게 한다.

def safe_repr(obj, *, fallback="<unreprable>"):
    try:
        return repr(obj)
    except Exception:
        return fallback

이 코드가 모든 걸 해결하진 않는다. 하지만 장애의 모양을 바꾼다. repr이 터져서 전체가 죽는 대신, 로그 한 줄이 덜 친절해지는 쪽으로 피해를 이동시킨다.

실제로는 여기서 한 걸음만 더 가면 좋다. repr()은 성공해도 너무 길어질 수 있고(대형 컨테이너), 내부에서 또 다른 코드 경로를 탈 수도 있다. 그래서 운영 코드에서는 reprlib.repr()처럼 길이를 제한하거나, 로깅 포맷터에서 객체를 문자열로 바꾸는 책임을 한 곳에 모으는 편이 안전하다. 중요한 건 “디버그를 많이 찍자”가 아니라, “디버그 때문에 죽지 않게 하자”다.

그리고 이런 방어는 파이썬 코드에만 해당하지 않는다. C 확장, 바인딩, 심지어 다른 언어의 런타임에서도 enter()/leave() 같은 가드를 제공하는 API는 흔하다. 문제는 언제나 똑같다. 실패한 enter를 성공으로 착각하고 leave를 불러버리면, 가드는 가드가 아니라 “상태를 망가뜨리는 코드”가 된다.

그래서 나는 팀에서 이런 규칙을 자주 반복한다. 가드가 있으면, 가드는 “짝”이 아니라 “계약”이다. 들어갔으면 반드시 나와야 하지만, 들어가지 않았으면 절대 나오면 안 된다.

그리고 확장 모듈이나 C-API를 다루는 팀이라면, 이번 PR의 교훈을 그대로 가져가면 된다. enter/leave 계약을 함수 단위로 가두고, “내가 들어갔을 때만 leave”가 되게 만들 것.

repr은 디버깅을 돕기 위해 존재하지만, 운영에선 디버깅이 곧 실행이다.


마무리: “안전한 디버깅”은 기능이 아니라 계약이다

이번 defaultdict.__repr__ 버그는 흔한 코드에서 시작해, 흔한 경로(로그/예외/디버거)로 터질 수 있는 종류였다. 그래서 더 흥미롭다.

우리는 디버깅을 ‘관찰’이라고 부르지만, 실제로는 ‘실행’이다. 객체를 repr 하는 순간, 그 객체의 코드가 돈다.

그래서 안전한 디버깅은 기능이 아니라 계약이다.

가드가 있는 API를 쓰면, enter/leave를 “항상 부른다”가 아니라 “내가 들어갔을 때만 leave”로 고정해야 한다. 이번 PR은 그걸 3줄로 다시 못 박았다(참고자료).

로그는 도움이 될 수도 있고, 장애의 트리거가 될 수도 있다. 차이는 ‘출력 경로도 실행 경로’라는 사실을 기억하느냐에 달려 있다.


참고자료

  • CPython PR: gh-145492: Fix defaultdict __repr__ infinite recursion
    • https://github.com/python/cpython/pull/145659
  • PR diff
    • https://github.com/python/cpython/pull/145659.diff
  • Merge commit
    • https://github.com/python/cpython/commit/2d35f9bc1cf61b27639ed992dfbf363ab436fd8b
  • Patched file context
    • https://github.com/python/cpython/blob/2d35f9bc1cf61b27639ed992dfbf363ab436fd8b/Modules/_collectionsmodule.c

댓글

이 블로그의 인기 게시물

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...