AI가 생성한 동시성 Python 코드에서의 레이스 컨디션 (Race Conditions)
요약
AI 어시스턴트가 생성한 Python 비동기 코드에서 빈번하게 발생하는 레이스 컨디션 패턴을 분석합니다. asyncio 환경에서 공유 가변 상태를 다룰 때 await 지점이 레이스 윈도우가 되어 데이터 손상을 일으키는 메커니즘을 설명합니다.
핵심 포인트
- AI는 비동기 환경에서 락(lock) 없는 공유 가변 상태를 생성하는 경향이 있음
- asyncio의 협력적 스케줄링에서 await는 레이스 윈도우를 형성함
- 읽기-수정-쓰기 시퀀스가 await를 가로지를 때 데이터 무결성이 깨짐
- 싱글 스레드 모델이라도 await 지점에서는 다른 코루틴이 개입 가능함
프롬프트는 다음과 같이 말했습니다: "이 엔드포인트가 동시 요청을 효율적으로 처리하도록 만드세요." AI 어시스턴트는 async def 함수, asyncio.gather, 그리고 모든 코루틴(coroutine)이 락(lock) 없이 쓰는 공유 dict를 제공했습니다. 코드는 컴파일됩니다. 테스트도 통과합니다. 하지만 부하가 걸리면, 스스로 데이터를 손상시킵니다.
이것은 AI 어시스턴트가 Python에서 가장 자주 생성하는 레이스 컨디션 (Race Condition) 패턴입니다. 그 위험이 정확히 어디에 있는지 이해할 가치가 있습니다. 메커니즘을 알고 나면 해결 방법은 간단하기 때문입니다.
AI가 생성하는 비동기 성능 패턴
AI 어시스턴트가 구조적으로 놓치는 부분을 잡아내는 스캐너인 BrassCoders는 특정 비동기 안티패턴(antipattern)을 정기적으로 발견합니다. 그것은 바로 여러 코루틴 사이의 동기화 없이 공유 가능한 가변 상태(shared mutable state)를 작성하는 것입니다. 그 형태는 항상 동일합니다. 모듈 또는 클래스 범위에서 정의된 dict, list 또는 카운터(counter)가 async def 함수 내부에서 수정되고, asyncio.gather 또는 asyncio.create_task를 통해 호출되는 방식입니다.
생성된 코드는 다음과 같은 모습입니다:
# AI 생성: 공유 카운터, 락 없음
import asyncio
...
AI는 관용적인(idiomatic) 비동기 Python 코드를 작성했습니다. 버그는 await asyncio.sleep(0)이 제어권을 이벤트 루프(event loop)로 반환하고, 첫 번째 코루틴이 결과를 쓰기 전에 다른 코루틴이 실행된다는 점입니다. 세 개의 코루틴이 모두 0을 읽고, 모두 1을 계산하며, 모두 1을 씁니다. 세 번의 증가가 일어났지만, 순 변화량은 1입니다. asyncio에 대한 Python 문서는 이에 대해 명시적으로 설명합니다: 코루틴은 선점(preempted)되는 것이 아니라 협력적으로 스케줄링(cooperatively scheduled)되므로, 모든 await는 공유 상태에 대한 잠재적인 레이스 윈도우(race window)가 됩니다.
레이스 윈도우가 존재하는 곳
Python의 asyncio 이벤트 루프는 싱글 스레드(single-threaded)입니다. 이것이 핵심적인 아키텍처 사실입니다. 코루틴은 await 표현식을 만날 때까지 실행되며, 그 시점에 이벤트 루프는 다른 코루틴을 스케줄링할 수 있습니다. 두 await 지점 사이에서 코루틴은 스레드를 독점적으로 보유하므로, 다른 코루틴이 끼어들 수 없습니다.
따라서 레이스 윈도우(race window)는 await를 가로지르는 모든 읽기-수정-쓰기(read-modify-write) 시퀀스입니다.
# 레이스(Race): await 이전에 읽기가 발생하고, 이후에 쓰기가 발생함
value = shared_state["key"] # 읽기 (read)
await some_io_call() # 양보 (yield) — 여기서 다른 코루틴(coroutine)들이 실행됨
...
상태(state)가 await를 가로지르지 않는다면, asyncio의 단일 스레드(single-threaded) 모델이 이를 보호합니다. 하지만 상태가 await를 가로지른다면, 레이스(race)가 발생합니다.
스레딩(Threading)은 상황을 더 악화시킵니다. asyncio를 threading.Thread 또는 concurrent.futures.ThreadPoolExecutor와 혼용하면 협력적 스케줄링(cooperative-scheduling)의 안전망을 완전히 잃게 됩니다. 스레드 컨텍스트(threaded context)에서는 락(lock) 없이 공유 가변 상태(shared mutable state)에 접근하는 모든 행위가 레이스이며, 이때는 await가 필요하지도 않습니다. Python의 threading 모듈 문서에서는 표준 보호 수단으로 threading.Lock을 다룹니다.
위험 프로필(risk profile)은 공유 상태가 무엇을 제어하느냐에 따라 달라집니다. 손상된 요청 카운터(request counter)는 메트릭(metrics) 버그입니다. 손상된 세션 토큰 맵(session token map)이나 권한 캐시(permission cache)는 보안 버그입니다. 인증(Auth) 코드와 속도 제한기(rate-limiter) 상태는 AI가 생성한 비동기(async) 패턴이 실제 노출(exposure)을 가장 흔하게 유발하는 두 지점입니다.
BrassCoders가 잡아낼 수 있는 것
BrassCoders는 Bandit, Pylint, Pyre/Pysa, Semgrep, ast-grep, detect-secrets 및 6개의 커스텀 탐지기를 포함한 12개의 정적 분석 스캐너(static-analysis scanners)를 실행하며, 소스 코드에서 결정론적(deterministically)으로 식별할 수 있는 패턴을 보고합니다.
비동기 동시성(async concurrency)의 경우, BrassCoders가 포착하는 결정론적 신호는 다음과 같습니다:
threading.Lock이 쌍으로 존재하지 않는threading.Thread호출 — Pylint는 전역 변수(globals)를 수정하는 함수에 대해 단순 스레드 생성을 플래그(flag)로 표시합니다.async def함수 내부에서 수정되는 전역 가변 컬렉션(Global mutable collections) — Semgrep 및 ast-grep은 커스텀 규칙을 통해 이러한 구조적 패턴을 매칭할 수 있습니다.- 공유된 외부 스코프(outer-scope) 변수를 쓰는 함수들에 대한
asyncio.gather— 공유 변수가 스코프 내에 있는 경우 AST 패턴 매칭을 통해 포착 가능합니다.
BrassCoders가 포착하지 못하는 것: 두 개의 특정 코루틴 (coroutine)이 논리적으로 독립적인지 여부입니다. 만약 코루틴 A가 results["user_a"]를 쓰고 코루틴 B가 results["user_b"]를 쓴다면, 이들은 동일한 dict 객체를 공유하지만 결코 같은 키 (key)를 쓰지는 않습니다. 이는 안전합니다. 변수 이름과 키 표현식으로부터 논리적 독립성을 추론하는 것은 문맥 추론 (context inference)이며, 이는 패턴 보고자 (pattern reporter)가 아닌 AI 소비자 (AI consumer)의 역할입니다.
Bandit 1.8.6 (BrassCoders가 포함하고 있는 버전)에는 레이스 컨디션 (race conditions)을 위한 스레딩 전용 플러그인 (threading-specific plugin)이 없습니다. Bandit의 플러그인들은 인젝션 (injection), 암호화 (crypto), 파일 권한 (file permissions), 그리고 서브프로세스 안전성 (subprocess safety)을 다룹니다. Bandit B351은 hashlib이며, B411은 xmlrpc입니다. 둘 다 공유 상태 동시성 (shared-state concurrency)을 다루지 않습니다. 이는 Bandit 규칙 세트의 문서화된 공백이지 BrassCoders의 한계가 아닙니다. 이것이 바로 커스텀 탐지기 (custom detectors)와 AI 분류 계층 (AI triage layer)이 존재하는 이유입니다.
AI 분류 계층 (AI Triage Layer)이 처리하는 작업
BrassCoders는 가공되지 않은 스캐너 결과 (raw scanner findings)를 YAML 형식으로 출력하여 여러분의 AI 어시스턴트 — Claude Code, Cursor 또는 여러분이 사용하는 어떤 도구 — 에게 전달합니다. AI 분류 계층은 이 YAML을 읽고, 어떤 정적 스캐너도 신뢰성 있게 수행할 수 없는 문맥 추론 (context inference)을 적용합니다.
"async 함수가 락 (lock) 없이 공유 dict를 수정함"으로 플래그가 지정된 결과에 대해, AI 분류 계층은 다음과 같은 작업을 수행할 수 있습니다:
- 실제 호출 지점 (call sites) 추적. 호출자들이 항상 서로 다른 키 (disjoint keys)를 전달하고 있는가? 그렇다면 안전합니다. 충돌할 수 있는 사용자 제어 키 (user-controlled keys)를 전달하고 있는가? 그렇다면 안전하지 않습니다.
- 쓰기 작업이 await에 걸쳐 있는지 확인. 만약 dict 쓰기가 함수 내의 어떤
await보다 먼저 발생한다면, asyncio에서는 레이스 윈도우 (race window)가 존재하지 않습니다. 스캐너는 이를 알지 못하지만, AI는 함수 본문 (function body)을 읽을 수 있습니다. - 보안 영향 (security impact) 평가. 이 dict가 메트릭 저장소 (metrics store)인가 아니면 권한 캐시 (permission cache)인가? 이 답변에 따라 이것이 심각도가 낮은 정확성 문제인지, 아니면 심각도가 높은 보안 결함인지가 결정됩니다.
이러한 역할 분담은 의도적인 것입니다. BrassCoders는 구조적 패턴을 신뢰할 수 있고 직접적으로 식별(flag)합니다. AI는 문맥에 따라 심각도를 할당합니다. 이러한 시퀀스는 변수 이름으로부터 보안 영향력을 추론하려는 스캐너보다 오탐(false positives)을 적게 발생시키며, 변수 이름이 무해해 보인다는 이유로 실제 버그를 억제하는 일도 결코 발생하지 않습니다.
해결책: 공유 상태 잠금 (Locking Shared State)
asyncio에서 공유 가능한 가변 상태(shared mutable state)에 대한 표준 해결책은 asyncio.Lock입니다. Python 표준 라이브러리는 Python 3.4부터 이를 제공해 왔으며, asyncio 동기화 기본 요소(synchronization primitives) 문서에서 이를 직접 다루고 있습니다.
공유 카운터 예제의 경우:
import asyncio
request_counts = {}
...
해결책에 관한 주요 사항:
이벤트 루프(event loop)가 시작되기 전에 락(lock)을 생성해야 합니다. gather되는 함수 내부에서 asyncio.Lock을 생성하는 것은 흔한 실수입니다. 이 경우 각 코루틴(coroutine)이 자신만의 락 객체를 갖게 되어 상호 배제(mutual exclusion)가 이루어지지 않습니다.
임계 구역(critical section) 내부에서 await를 사용하는 것을 피하십시오. 만약 락을 보유한 상태에서 이벤트 루프에 제어권을 양보(yield)하면, 다른 코루틴들은 귀하의 I/O 호출이 완료될 때까지 락에서 차단(block)됩니다. 읽기-수정-쓰기(read-modify-write) 시퀀스를 원자적(atomic)으로 유지하고, 제어권 양보는 외부에서 수행하십시오.
특히 카운터의 경우, asyncio.Queue가 때로는 더 깔끔한 대안이 될 수 있습니다. 프로듀서(producer) 코루틴들이 증가 값을 큐에 넣고, 단일 컨슈머(consumer) 코루틴이 이를 소진(drain)합니다. 공유 상태도, 락도, 레이스 컨디션(race condition)도 없습니다.
asyncio.run_coroutine_threadsafe를 통해 비동기 함수를 호출하는 스레드 기반 코드의 경우, asyncio.Lock이 아닌 threading.Lock을 사용하십시오. asyncio 기본 요소들은 동일한 스레드 내에서 실행 중인 이벤트 루프 안에서만 작동합니다.
BrassCoders는 동기화 누락을 나타내는 구조적 패턴을 식별합니다. 귀하의 AI 어시스턴트는 특정 사례가 정확성 버그인지, 보안 버그인지, 아니면 오탐인지 분류(triage)합니다. 어느 한 계층만으로는 전체 그림을 볼 수 없지만, 두 계층이 함께 작동하면 가능합니다.
코드베이스에 BrassCoders를 설치하세요:
pip install brasscoders
brasscoders scan .
OSS 코어는 Apache 2.0 라이선스를 따릅니다 — 계정 생성, 텔레메트리 (Telemetry), 네트워크 호출이 전혀 없습니다. BrassCoders 유료 플랜을 사용하면, 추가적인 전체 신호 감소 (Signal reduction) 패스를 적용할 수 있는 AI 기반 강화 기능을 개발자당 월 $12에 제공합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기