Python프로그래밍파이썬 백엔드 개발자

파이썬의 `contextvars` 모듈은 단일 OS 스레드에 다중화된 비동기 작업을 위한 독립적인 논리 실행 컨텍스트를 어떻게 유지하는가?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변

질문의 역사

파이썬 3.7 이전에는 개발자들이 요청 특화 데이터를 저장하기 위해 threading.local()에만 의존했습니다. 하지만 asyncio의 보급으로 인해 기본적인 결함이 드러났습니다: 스레드 로컬 저장소는 동일한 이벤트 루프 스레드에서 실행되는 모든 코루틴이 공유합니다. 하나의 비동기 작업이 제어를 양보할 때, 다른 작업이 첫 번째 작업의 격리된 상태에 우발적으로 접근하거나 변경할 수 있어 보안 취약점과 데이터 손상이 발생합니다. PEP 567은 OS 스레드와 독립적인 논리 실행 컨텍스트 격리를 제공하기 위해 contextvars를 도입했습니다. 이는 C#과 Erlang의 유사한 메커니즘을 모델링한 것 입니다.

문제

동기 파이썬에서는 각 HTTP 요청이 일반적으로 자체 스레드에서 실행되므로 요청 컨텍스트를 저장하는 데 threading.local()이 충분합니다. 하지만 비동기 아키텍처에서는 수천 개의 동시 요청이 이벤트 루프에 의해 관리되는 단일 스레드로 다중화될 수 있습니다. 두 개의 비동기 작업이 실행을 엮는 경우—하나는 await에서 일시 중지되고 다른 하나는 다시 실행되는 경우—그들은 동일한 스레드 로컬 딕셔너리를 공유합니다. 작업 전환 시 컨텍스트를 스냅샷하고 복원하는 메커니즘이 없으면 논리적으로 분리된 작업 간에 글로벌 상태가 누출될 수 있습니다. 이는 작업 A의 인증 토큰이 작업 B에 노출되거나 데이터베이스 트랜잭션 경계가 무관한 요청 간에 흐려지는 경쟁 조건을 생성합니다.

해결책

파이썬은 ContextVar를 스레드 상태에 저장된 불변 맵의 키로 구현합니다. 각 비동기 작업은 자신의 Context 객체에 대한 참조를 유지합니다—수정이 새로운 버전을 생성하고 공유된 상태를 변경하지 않는 지속적인 데이터 구조입니다. asyncioawait에서 작업을 일시 중지할 때, 현재 컨텍스트를 캡처합니다; 다시 시작할 때 해당 컨텍스트를 복원하여, ContextVar.get()이 특정 작업과 연결된 값을 반환하도록 보장합니다. OS 스레드가 이동할 수 있음에도 불구하고 말이죠. 이 복사하여 쓰기 의미론은 잠금 오버헤드 없이 격리를 보장합니다.

import contextvars import asyncio request_id = contextvars.ContextVar('request_id', default='unknown') async def process_task(task_name): # 이 특정 작업 컨텍스트에 대한 값을 설정합니다. token = request_id.set(task_name) try: await asyncio.sleep(0.01) # 제어를 양보하고 다른 작업이 실행될 수 있습니다. current = request_id.get() print(f"작업 {task_name} 읽기: {current}") finally: request_id.reset(token) # 이전 컨텍스트 복원 async def main(): # 동일한 스레드에서 두 작업을 동시에 실행합니다. await asyncio.gather(process_task('Alpha'), process_task('Beta')) asyncio.run(main())

현실 상황

고처리량 API 게이트웨이를 구축하는 팀은 스레드 기반의 Flask 애플리케이션에서 비동기 FastAPI 서비스로 마이그레이션했습니다. 그들은 현재 사용자를 threading.local()에 저장하는 인증 미들웨어가 부하 상태에서 사용자 A의 신원을 사용자 B의 요청에 무작위로 할당하고 있다는 사실을 발견했습니다. 초기 디버깅은 경쟁 조건을 제안했지만, 로그는 할당이 단일 작업자 배포에서조차 발생하고 있음을 보여주었습니다. 근본 원인은 데이터베이스 호출 중 하나의 요청 처리기가 양보하면서 비슷한 스레드에서 다른 핸들러가 실행되고 스레드 로컬 저장소를 상속받는 asyncio의 협력적 다중 작업 때문이었습니다.

팀은 threading.get_ident()에 의해 글로벌 딕셔너리를 키로 하여 요청을 격리할 것이라고 가정했습니다. 이 접근법은 외부 의존성을 도입하지 않고 이전 코드베이스에서 간단한 마이그레이션을 제공했습니다. 하지만 uvicornasyncio 아래에서 동일한 스레드가 여러 요청을 순차적으로 처리하므로, 딕셔너리는 이전 요청으로부터 오래된 데이터를 보유하고 잘못된 특권 상승 버그를 일으켰습니다 – 즉, 무관한 요청 간에 인증 세션이 잘못 지속되는 문제였습니다.

그들은 모든 함수 시그니처를 context라는 딕셔너리 매개변수를 받아들일 수 있도록 리팩토링하기 시작했습니다. 이 방식은 미들웨어에서 데이터베이스 레이어에 이르기까지 전체 호출 스택을 통해 스레드됩니다. 이 명시적 데이터 흐름은 숨겨진 상태를 제거하고 동기 및 비동기 경계를 모두 통해 작동했습니다. 불행히도, 이로 인해 수천 개의 함수에 걸쳐 대규모 리팩토링이 필요했으며, 글로벌 구성 객체를 기대하는 서드파티 라이브러리 통합이 깨졌습니다. 결과적으로, 코드 장황함은 유지 관리 부담 및 개발자 오류의 위험을 크게 증가시켰습니다.

그들은 인증된 사용자 객체를 저장하기 위해 contextvars.ContextVar를 채택했습니다. 이를 통해 미들웨어는 요청 진입 시 변수를 설정할 수 있고, 하위 함수는 .get()을 통해 이를 접근할 수 있어 함수 시그니처를 오염시키지 않았습니다. 이 접근법은 아키텍처적 전환이 필요하지 않았으며 동시 작업 간 자동 격리를 제공했습니다. 하지만 이는 장기 실행 프로세스에서 메모리 누수를 방지하기 위해 reset() 토큰을 신중하게 관리해야 했습니다. 또한, 디버깅은 상태가 스택 추적에서 볼 수 있는 것이 아니라 실행 컨텍스트에 암시적이기 때문에 더 어려워졌습니다.

결국 팀은 프로토타입이 미들웨어 레이어의 변경만 필요하다는 것을 입증했기 때문에 contextvars를 선택했습니다. 요청 핸들러를 try/finally 블록으로 래핑하여 토큰이 재설정되도록 보장함으로써, 함수 시그니처를 유지한 채 메모리 누수를 방지했습니다. 이 게이트웨이는 이제 작업자 당 50,000개의 동시 연결을 처리하고 요청 간 데이터 누수 없이 작동하며, 팀은 인스턴스당 OS 스레드 수를 100개에서 4개로 줄여 메모리 사용을 80% 절감하고 전체 처리량을 300% 개선했습니다.

후보자들이 흔히 놓치는 것들

비동기 코드에서 threading.local()이 실패하는 이유는 무엇인가요?

스레드 기반의 파이썬에서 운영 체제는 스레드를 미리 예약하고 각 스레드는 고유한 C 스택과 PyThreadState 구조를 유지합니다. threading.local()은 이 OS 수준의 스레드 ID에 변수를 매핑하여 격리를 보장합니다. 하지만 asyncio에서는 이벤트 루프가 단일 스레드에서 작업을 협력적으로 예약하고 큐를 사용합니다; 작업이 양보하면 루프는 즉시 동일한 스레드에서 다른 작업을 실행하여 PyThreadState를 전환하지 않습니다. 결과적으로, threading.local()은 두 작업 모두에 대해 동일한 키를 보게 되어 상태 누수가 발생합니다. Contextvars는 작업 전환 중에 이벤트 루프가 스왑하는 PyThreadState 내의 컨텍스트 매핑 스택을 유지함으로써 이 문제를 해결하여 OS 스레드와 독립적인 논리적 격리를 생성합니다.

ContextVar 토큰을 재설정하는 것을 잊으면 어떻게 되나요?

ContextVar.set()은 이전 상태를 나타내는 Token 객체를 반환하며, 이 객체는 이전 값을 복원하기 위해 reset()에 전달해야 합니다. 이를 간과하면—예를 들어, try/finally 블록을 생략하는 경우—변수는 의도한 범위를 초과하여 그 값을 유지하게 됩니다. 장기 실행 비동기 서버에서 이는 이전 요청 컨텍스트가 누적되어 메모리 누수를 발생시키며, 그 스레드의 후속 작업이 컨텍스트가 제대로 복원되지 않으면 오래된 값을 상속받을 수 있습니다. 전통적인 스택 변수가 함수가 반환될 때 사라지는 것과 달리, 컨텍스트 변수는 명시적으로 재설정되거나 작업이 종료될 때까지 실행 컨텍스트에 남아 있어 정리가 필수적입니다.

컨텍스트 변수가 자식 작업 및 스레드로 전파되는 방법은 무엇인가요?

asyncio.create_task()를 사용할 때, 자식 작업은 자동으로 부모의 현재 컨텍스트 복사본을 받으며, 이는 컨텍스트 변수가 비동기 호출 그래프를 따라 자연스럽게 흐른다는 것을 보장합니다. 그러나 concurrent.futures.ThreadPoolExecutor 또는 loop.run_in_executor()를 사용할 때, 호출 가능 객체는 기본적으로 빈 상태로 시작하는 다른 OS 스레드에서 실행됩니다. 후보자들은 종종 스레드 로컬 저장소처럼 컨텍스트가 스레드 경계를 가로질러 전파될 것이라고 가정하지만, contextvars는 논리적 비동기 컨텍스트에만 특정합니다. 스레드에 값을 전파하려면, 반드시 contextvars.copy_context()를 사용하여 컨텍스트를 캡처하고 그 안에서 함수를 context.run()으로 실행해야 하거나 변수를 인수로 수동으로 전달해야 합니다.