기본 콘텐츠로 건너뛰기

Stack Pointer and Recursion Safety

thumbnail

CPython: 재귀(RecursionError) 보호가 더 ‘실제 스택 포인터’ 기반으로 정교해졌다 — 왜 중요하냐면

meta_description: CPython이 _Py_get_machine_stack_pointer()를 “진짜 머신 스택 포인터(sp/rsp)”로 반환하도록 바꾸면서, 재귀 한계/스택 오버플로 보호 로직이 더 일관되게 동작하도록 개선됐다(GH-126910). 이 글은 어떤 문제가 있었는지(프레임 주소 vs 스택 포인터, 스택 전환 코너 케이스), 무엇이 바뀌었는지(soft/hard limit 체크 정리), 그리고 임베딩/샌드박스/깊은 파서 입력처럼 재귀가 자주 터지는 실무 환경에서 어떤 이득이 있는지 설명한다. meta_keywords: python,cpython,recursion,RecursionError,stack pointer,_Py_get_machine_stack_pointer,stack overflow,soft limit,hard limit,Py_FatalError,sanitizers,UBSan,embedding,parser,xml,security,robustness,debugging,limits meta_robots: index,follow

재귀 제한은 파이썬에서 흔히 “그냥 RecursionError 뜨는 거”로만 보이지만, CPython 입장에서는 꽤 민감한 영역이다.

  • 너무 늦게 막으면: C 스택을 진짜로 넘어서 프로세스 크래시로 간다
  • 너무 일찍 막으면: 정상적인 입력/코드도 쓸데없이 RecursionError가 난다
  • 예외를 던지는 순간에도 스택이 충분히 남아 있어야 한다(안 그러면 예외 처리 중에 또 터진다)

이번 CPython PR(GH-126910)은 이 방어선을 한 단계 더 “하드웨어에 가깝게” 정리한다.

핵심만 말하면:

  • _Py_get_machine_stack_pointer()가 이제 “프레임 주소 비슷한 값”이 아니라
  • 실제 스택 포인터(sp/rsp)를 읽어서 반환하도록 바뀌었다

겉보기엔 내부 구현 변경인데, 이런 변화는 임베딩/샌드박스/파서 입력(예: XML)처럼 재귀가 자주 문제 되는 환경에서 안정성에 영향을 준다.


1) 왜 스택 포인터를 ‘진짜’로 읽는 게 중요할까

기존 코드에서는 플랫폼에 따라 스택 위치를 추정할 때,

  • __builtin_frame_address(0) 같은 “프레임 주소” 계열을 쓰거나
  • fallback으로 스택에 있는 지역 변수 주소(&here)를 쓰는 식의 방법이 섞여 있었다.

이런 값들은 대부분 “대충 스택 근처”는 맞지만, 상황에 따라 정확히 원하는 값(현재 SP)과 차이가 날 수 있다.

그리고 재귀/스택 보호는 경계값에서 동작하는 로직이라,

  • 경계 근처에서 흔들리면
  • “어떤 환경에서는 RecursionError로 끝나는데, 어떤 환경에서는 FatalError/크래시”

같은 이상한 차이가 생길 수 있다.

이번 PR은 아예 아키텍처별로 스택 포인터를 읽는 경로를 명시한다.

  • aarch64: inline asm로 sp
  • x86_64: inline asm로 rsp
  • MSVC x64: _AddressOfReturnAddress()
  • 그 외: 지역 변수 주소 fallback

즉, 가능한 곳에서는 “진짜 SP”를 쓰도록 한다.


2) soft limit / hard limit 체크가 어떻게 정리됐나

PR diff를 보면, 재귀 체크 흐름이 더 명확해진다.

2-1) ‘재귀 한계에 도달했는가?’는 soft limit 기준으로 간단히

기존에는 soft limit 주변의 마진(2 * STACK_MARGIN)을 같이 끼워서

  • “지금이 정말 위험 구간인가?”를 섞어 판단하는 코드가 있었다.

이번 변경은 이 단계를 단순화한다.

  • 먼저 “soft limit을 넘어섰나?”만 본다
  • 그 다음에 _Py_CheckRecursiveCall()이 코너 케이스를 처리한다

이건 실무적으로 좋은 방향이다.

  • 빠른 체크는 단순하게
  • 복잡한 예외/오류 보고는 한 곳에서

2-2) hard limit은 “정말 위험할 때”만 Fatal로 간다

_Py_CheckRecursiveCall() 안에서 hard limit을 넘었을 때는

  • 너무 멀리 벗어나면(stack switching 같은 상황을 의심) 조용히 0을 반환(= 여기서는 막지 않음)
  • hard limit 근처에서 넘었으면 “예외를 던질 스택도 없다”고 보고 FatalError

즉, “진짜 스택 오버플로 상황”과 “스택 전환/특수 스레딩 환경”을 구분하려는 의도가 들어가 있다.

이 부분이 왜 중요하냐면, 임베딩이나 사용자 공간 스레드(그린 스레드) 같은 환경에서는 스택 경계 추정이 틀릴 수 있기 때문이다.


3) UBSan(Undefined Behavior Sanitizer) 환경도 고려된다

PR에는 _Py_UNDEFINED_BEHAVIOR_SANITIZER 감지 매크로가 추가되고,

  • 스택 마진(log2)이 조정되는 조건에 UBSan이 포함된다.

이건 일반 사용자 입장에선 티가 안 나지만,

  • CI
  • 디버그 빌드
  • 산티나이저 기반 테스트

에서 “재귀/스택 관련 테스트가 더 일관되게 통과/실패한다”는 쪽의 의미가 있다.


4) 실무에서 어디가 체감될까

대부분의 서비스 코드는 재귀를 직접 쓰지 않아도, 아래 영역에서 재귀가 등장한다.

(A) 파서 입력(예: XML, 수식, 중첩 구조)

PR에서 테스트 수정이 들어간 것도 test_pyexpat의 “깊게 중첩된 컨텐츠 모델” 케이스다.

핵심은:

  • RecursionError로 안전하게 끝나야 한다
  • 크래시하면 안 된다

이건 단순한 안정성 이슈를 넘어, 입력 기반 공격 표면에서도 중요하다.

(B) 임베딩/플러그인/샌드박스

한 프로세스 안에서 파이썬을 “부분적으로” 돌리는 환경은

  • 스레드/스택 모델이 표준적인 것과 다를 수 있고
  • 이런 환경에서 스택 경계 판단이 애매하면
  • 재귀 보호 로직이 뜻밖의 방식으로 동작할 수 있다.

“가능하면 진짜 SP를 쓰자”는 방향은 이런 환경에서 특히 낫다.

(C) 관측/장애 대응

현장에서 가장 짜증나는 건 이거다.

  • 어떤 입력은 RecursionError인데
  • 어떤 입력은 갑자기 프로세스가 죽는다

원인이 스택 경계값/플랫폼 차이일 수 있으면, 추적이 어렵다.

이번 변경은 그런 ‘환경별 흔들림’을 줄이는 쪽에 가깝다.


5) 내가 할 일: 일반 사용자 vs 라이브러리/플랫폼 개발자

  • 일반 사용자: 할 일 거의 없음. 다만 “깊게 중첩된 입력”을 다룬다면, 파이썬 업그레이드 우선순위에서 이런 안정성 패치를 무시하지 않는 편이 좋다.

  • 라이브러리/플랫폼 개발자:

    • 재귀가 깊어질 수 있는 입력을 받는 API라면, RecursionError를 정상 경로로 취급(= 잡아서 안전하게 실패 응답)
    • 테스트에 “매우 깊은 중첩” 케이스를 넣어 크래시가 아닌 예외로 끝나는지 고정

추가로 현실적인 팁 하나.

  • “RecursionError는 버그다”라고 몰아붙이기보다
  • “깊은 중첩 입력은 원래 실패해야 한다(그리고 실패는 예외로 끝나야 한다)”

라고 제품 요구사항 수준에서 정해두는 게 좋다.

예:

  • XML/JSON 같은 입력은 최대 깊이를 두고
  • 그 깊이를 넘으면 400/422 같은 에러로 안전하게 종료
  • 로그엔 입력 전체를 찍지 말고(폭발 위험) 깊이/길이 같은 메타만 남기기

이런 운영 규칙이 있으면, 파이썬 런타임의 재귀 보호 로직이 조금 바뀌어도 서비스가 덜 흔들린다.


6) 결론: ‘재귀 예외’는 기능이 아니라 안전장치다

RecursionError는 불편한 예외가 아니라, 런타임이 “여기서 더 가면 죽는다”라고 말해주는 안전장치다.

이번 GH-126910 같은 변경은

  • 그 안전장치가 더 정확한 센서(진짜 스택 포인터)를 쓰게 만들고
  • 특수 환경(스택 전환/임베딩/산티나이저)에서도 더 일관되게 동작하도록

정리하는 방향이다.

표준 사용자에게는 조용한 패치지만, 운영/플랫폼 쪽에서는 이런 조용한 패치가 결국 가장 큰 차이를 만든다.

한 줄 요약:

  • “재귀 제한은 성능 옵션이 아니라, 프로세스 생존을 위한 안전장치다.”

이 PR이 당장 API를 바꾸진 않지만, “RecursionError가 뜨는지/크래시로 죽는지” 같은 운영 결과에는 영향을 줄 수 있는 종류의 변경이다.

그래서 깊은 중첩 입력을 다루는 서비스라면, 이런 내부 변경도 릴리스 노트에서 한 번쯤 눈여겨볼 가치가 있다.


Keywords

python,cpython,recursion,RecursionError,stack pointer,stack overflow,soft limit,hard limit,Py_FatalError,UBSan,embedding,parser,xml,security,robustness,debugging


이미지 크레딧/라이선스

  • Gaming PC (Unsplash).jpg — Maxime Rossignol / CC0
    • https://commons.wikimedia.org/wiki/File:Gaming_PC_(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=...

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

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

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