C#에서 Python의 비동기 세계 호출하기: [PyDotNet]( 비동기 브리지 심층 분석
요약
C# 프로젝트에서 Python 라이브러리나 머신러닝 모델을 효율적으로 호출하기 위한 PyDotNet 라이브러리를 소개합니다. 기존 Python.NET의 지연 시간과 비동기 지원 부족 문제를 해결하며, 결정적인 메모리 관리와 Python의 async/await 모델 지원에 초점을 맞춥니다.
핵심 포인트
- 서브프로세스 방식의 성능 병목 및 직렬화 문제 해결
- Python.NET의 타입 마샬링 지연 및 비동기 지원 부재 극복
- using 블록을 통한 Python 객체의 결정적 메모리 관리 제공
- C#에서 Python 코루틴 및 비동기 제너레이터 호출 지원
C#에서 Python의 비동기 세계 호출하기: PyDotNet의 비동기 브리지 심층 분석
누구나 결국 마주하게 되는 문제
어느 시점에 이르면, 거의 모든 진지한 .NET 프로젝트는 Python을 필요로 하게 됩니다. Python SDK만 존재하는 머신러닝 (Machine Learning) 모델일 수도 있고, NuGet에 존재하지 않는 데이터 과학 (Data Science) 라이브러리일 수도 있으며, 혹은 수년간 Python으로 데이터 엔지니어링 (Data Engineering)을 수행해 온 팀이 그 모든 것을 새로 작성할 의사가 없는 경우일 수도 있습니다. 이때 질문은 다음과 같습니다. 전체 시스템이 유지보수의 악몽이 되지 않도록 두 언어가 서로 통신하게 만드는 방법은 무엇인가?
오랫동안 표준적인 답변은 서브프로세스 (Subprocess)를 실행하는 것이었습니다. python script.py를 실행하고, 표준 출력 (stdout)을 캡처하여 파싱하는 방식입니다. 이 방식은 작동하며 단순하지만, 실제적인 비용이 따릅니다. 수백 밀리초(ms) 단위로 측정되는 시작 시간, 텍스트로 직렬화 (Serialization) 되었다가 다시 복구되어야 하는 데이터, 그리고 스트리밍 결과나 비동기 (Async) 코드를 처리할 실질적인 방법이 없다는 점입니다. 성능에 민감하거나 대화형 (Interactive)인 작업의 경우, 이는 빠르게 병목 현상 (Bottleneck)이 됩니다.
더 정교한 접근 방식은 적절한 인프로세스 임베딩 (In-process embedding)을 사용하는 것입니다. 즉, Python 공유 라이브러리를 직접 로드하고 C API를 통해 이를 호출하는 방식입니다. 이것이 바로 Python.NET (pythonnet)이 수년간 해온 방식이며, .NET/Python 상호 운용성 (Interop)을 가능하게 했다는 점에서 충분한 공로를 인정받아야 합니다. 하지만 pythonnet은 시간이 흐르면서 몇 가지 거친 부분들이 쌓였습니다. 이 라이브러리의 타입 마샬링 (Type marshaling) 레이어는 내부적으로 COM 스타일의 리플렉션 (Reflection)을 사용하는데, 이는 지연 시간 (Latency)을 추가합니다. 벤치마크 결과에 따르면 일반적으로 로우 레벨 C API 비용 대비 호출당 5~20µs의 지연이 발생합니다. 현대적인 애플리케이션 관점에서 더 치명적인 문제는 Python의 async/await 모델에 대한 내장 지원이 없다는 점입니다. 상당한 양의 보일러플레이트 (Boilerplate) 코드 없이는 C#에서 Python 코루틴 (Coroutine)을 await 할 방법이 없으며, 스트리밍 API의 중추인 Python 비동기 제너레이터 (Async generators)는 아예 접근조차 불가능합니다. 메모리 관리 또한 비결정적 (Non-deterministic)입니다. Py_DecRef가 .NET의 최종화 도구 (Finalizer)로부터 호출되기 때문에, Python 객체가 예상보다 훨씬 오래 살아남을 수 있으며, 가비지 컬렉션 (GC) 일시 중단이 예측할 수 없는 시점에 나타날 수 있습니다.
PyDotNet의 위치
PyDotNet은 다른 접근 방식을 취합니다. 이 역시 CPython을 인프로세스로 임베딩한다는 점은 동일하지만, pythonnet이 타협했던 세 가지 아이디어를 중심으로 처음부터 설계되었습니다.
명시적이고 결정적인 소유권 (Explicit, deterministic ownership). C#에서 보유하는 모든 Python 객체는 using 변수입니다. using 블록이 종료되면 즉시 Py_DecRef가 호출됩니다. 최종화 도구 간의 경합 (Finalizer races)도, 갑작스러운 GC 일시 중단도, 남아도는 Python 객체도 없습니다. 참조 횟수 (Refcount) 문제를 디버깅할 때, 스택을 통해 객체가 정확히 어디에서 해제되었는지 알 수 있습니다.
Zero-copy memory (제로 카피 메모리). NumPy arrays, PyTorch tensors, Python bytearrays 등 Python의 버퍼 프로토콜 (buffer protocol)을 구현하는 모든 것은 복사 없이 C#에서 Span<T> 또는 Memory<T>로 접근할 수 있습니다. DLPack 텐서 교환은 여기서 더 나아갑니다. 데이터가 CUDA에 있더라도 호스트 측의 복사 없이 PyTorch, JAX, TensorFlow와 GPU 텐서를 공유할 수 있습니다. pythonnet은 이 두 가지 모두를 지원하지 않습니다.
진정한 비동기 브리지 (A real async bridge). Python의 asyncio 코루틴 (coroutines)은 C#의 Task가 됩니다. Python 비동기 제너레이터 (async generators)는 IAsyncEnumerable<T>가 됩니다. CancellationToken이 작동하며, Task.WhenAll도 작동합니다. 현재 C#에서 await를 작성하고 있다면, Python의 비동기 세계를 호출하는 것은 임시방편이 아닌 일등 시민 (first-class citizen)처럼 느껴질 것입니다.
호출 지연 시간 (call latency) 차이도 알아둘 가치가 있습니다. PyDotNet은 호출당 약 1–3 µs의 벤치마크 성능을 보이는 반면, pythonnet은 5–20 µs, subprocess 방식은 1–50 ms가 소요됩니다.
| 방식 | 호출 지연 시간 | Zero-copy memory | 비동기 코루틴 |
|---|---|---|---|
| PyDotNet | ~1–3 µs | ✓ Span<T> / DLPack | ✓ native Task |
| ... |
비동기 생태계 문제
Python은 어떤 언어보다도 풍부한 비동기 생태계를 조용히 구축해 왔습니다. asyncio, httpx, LangChain의 스트리밍 API, 또는 거의 모든 현대적인 데이터 파이프라인 라이브러리에 이르기까지, Python의 async/await 모델은 어디에나 존재합니다. 문제는 이러한 라이브러리 대부분이 .NET 프로세스에서 호출되도록 설계된 것이 아니라, Python 이벤트 루프 (event loop) 내부에서 동작하도록 설계되었다는 점입니다.
PyDotNet의 비동기 브리지는 이 문제를 해결합니다. Python 코루틴은 일반적인 .NET Task처럼 보이고, Python 비동기 제너레이터는 IAsyncEnumerable<T>처럼 보입니다. 이미 C#에서 await를 작성하고 있다면, Python의 비동기 코드를 사용하는 것은 놀라울 정도로 자연스럽게 느껴집니다.
이 글에서는 가장 단순한 코루틴 호출부터 프로듀서/컨슈머 큐 (producer/consumer queues), 구조적 동시성 (structured concurrency), 그리고 적절한 취소 (cancellation)에 이르기까지 전체적인 그림을 살펴봅니다.
가장 단순한 사례: 코루틴 await 하기
데이터를 비동기적으로 가져오는 Python 함수가 있다고 가정해 봅시다. 그 함수가 실제로 무엇을 하는지는 중요하지 않습니다. httpx를 호출할 수도 있고, 데이터베이스를 쿼리할 수도 있으며, 단순히 데모를 위해 await asyncio.sleep(...)을 호출할 수도 있습니다. C#에서는 CallAsync<T>()를 사용하여 이를 호출합니다:
interp.Execute("""
import asyncio
...
그게 전부입니다. 코루틴 (coroutine)은 자체 asyncio 이벤트 루프 (event loop)를 사용하여 .NET 스레드 풀 (thread-pool) 스레드에서 실행되며, 결과는 적절한 Task<int>로 반환됩니다. C#의 await는 스레드를 차단하지 않고 호출 컨텍스트 (calling context)를 일시 중단하며, 이는 정확히 여러분이 기대하는 방식대로 동작합니다.
만약 반환 값이 필요하지 않다면 — 예를 들어 실행 후 잊어버리는 (fire-and-forget) 로그 기록 같은 경우 — 다음과 같은 비제네릭 (non-generic) 오버로드 (overload)가 있습니다:
await log.CallAsync("System started successfully");
코루틴을 병렬로 실행하기
CallAsync<T>()는 실제 Task<T>를 반환하기 때문에, 이를 Task.WhenAll에 바로 전달할 수 있습니다:
using var greet = module.GetFunction("fetch_greeting");
var tasks = new[]
...
세 개의 Python 코루틴이 동시에 실행됩니다. 각 코루틴은 스레드 풀 스레드 상에서 자신만의 이벤트 루프를 가지므로, 공유 루프로 인한 병목 현상이 발생하지 않습니다. 여러 API로부터 데이터를 가져오거나 일괄적인 모델 추론 (model inference)을 실행하는 것과 같은 독립적인 I/O 바운드 (I/O-bound) 작업의 경우, 별도의 수동 스레딩 작업 없이도 간단하게 속도를 높일 수 있습니다.
키워드 인자 (Keyword arguments)
Python 함수에는 키워드 전용 인자 (keyword-only arguments)가 있는 경우가 많으며, 비동기 함수도 마찬가지입니다. PyDotNet은 IDictionary<string, object?>를 통해 키워드 인자 (kwargs)를 전달합니다:
var result = await module.CallAsync<string>(
"compute_stats",
new object?[] { new[] { 10, 20, 30, 40 } },
...
또한 함수 객체에 대한 참조를 유지할 필요가 없는 경우에는 GetFunction을 완전히 건너뛰고 module.CallAsync(...)를 통해 이름으로 직접 호출할 수도 있습니다.
IAsyncEnumerable<T>로서의 비동기 제너레이터 (Async generators)
이 부분이 진정으로 유용한 지점입니다. Python의 비동기 제너레이터 (async generators) — async def 내부에서 yield를 사용하는 함수 — 는 C#의 IAsyncEnumerable<T>로 직접 매핑됩니다. 즉, await foreach를 사용하여 이를 반복 (iterate)할 수 있음을 의미합니다:
interp.Execute("""
import asyncio
...
""")
각 값은 지연(lazily) 방식으로 가져와집니다. Python 제너레이터(generator)는 각 yield 지점에서 일시 중단(suspend)되며, C# 측은 각 MoveNextAsync에 대해 ValueTask<bool>을 받습니다. 시퀀스 전체를 사전에 실체화(materialising)하지 않습니다. 만약 제너레이터가 연속적인 피드(가격, 센서 데이터, 로그 이벤트 등)를 생성한다면, C# 코드는 항목이 도착하는 대로 즉시 처리합니다.
비동기 제너레이터에서의 키워드 인자 (Kwargs)
키워드 인자(keyword arguments)를 받는 제너레이터의 경우, 위치 인자(positional arguments)와 키워드 인자를 모두 허용하는 오버로드(overload)를 사용하십시오:
using var rangeStream = module.GetFunction("range_stream");
await foreach (var v in rangeStream.CallAsyncEnumerable<int>(
...
조기 종료 및 리소스 정리
비동기 제너레이터를 사용할 때 주의해야 할 점 중 하나는 finally 블록입니다. 만약 Python 제너레이터가 리소스(파일, 연결, 잠금 등)를 보유하고 있다면, 제너레이터가 닫힐 때 실행되는 finally에서 이를 해제해야 합니다. 하지만 루프를 조기에 break로 빠져나가는 경우, Python 측에 aclose()를 호출하여 제너레이터를 명시적으로 닫으라고 알려주어야 합니다.
PyDotNet은 이를 자동으로 처리합니다. await foreach 루프를 중단하면 열거자(enumerator)의 DisposeAsync()가 호출되며, 이는 Python의 aclose() 코루틴(coroutine)을 호출합니다. 그러면 제너레이터의 finally 블록이 실행됩니다:
async def resource_stream():
try:
for i in range(1000):
...
await foreach (var item in resourceStream.CallAsyncEnumerable<int>())
{
if (item >= 3)
...
제너레이터가 자연스럽게 소진(exhaust)되면 aclose()는 건너뜁니다. StopAsyncIteration은 이미 닫을 것이 없음을 의미하기 때문입니다.
CancellationToken 지원
CallAsync<T>는 타임아웃 및 취소 시나리오를 위해 CancellationToken을 허용합니다:
// 호출 전에 이미 취소됨 — 즉시 예외 발생
using var cts = new CancellationTokenSource();
cts.Cancel();
...
// 타임아웃 기반 취소
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await slowValue.CallAsync<int>(new object?[] { 21 }, cts.Token);
취소(cancellation)가 발생하면, 반환된 `Task`는 `Canceled` 상태로 전환됩니다. Python 코루틴(coroutine)은 실행 중이던 스레드 풀(thread-pool) 스레드에서 여전히 완료될 수 있습니다. 외부에서 실행 중인 CPython을 중간에 중단할 방법은 없기 때문입니다. 하지만 C# 호출자는 즉시 취소 예외(cancellation exception)를 전달받게 됩니다.
## `PyAsyncQueue<T>`: 경계를 넘나드는 생산자/소비자 (producer/consumer)
때로는 .NET 측과 Python 측이 모두 독립적으로 활성화되는 진정한 생산자/소비자(producer/consumer) 설정을 원할 때가 있습니다. `PyAsyncQueue<T>`는 Python의 `asyncio.Queue`를 래핑(wrap)하여 `PutAsync` / `GetAsync` / `ReadAllAsync`를 갖춘 .NET 큐로 노출합니다:
using var queue = PyAsyncQueue<string>.Create(interp);
var producer = Task.Run(async () =>
...
큐는 Python 측(`asyncio.Queue`)에 존재하며, 두 .NET 태스크(task)는 PyDotNet의 GIL(Global Interpreter Lock) 관리를 통해 큐와 상호작용합니다. 선택적으로 `Create(interp, maxsize: 10)`와 같이 `maxsize`를 전달하여 백프레셔(backpressure)를 구현할 수 있습니다. 이 경우 공간이 확보될 때까지 `PutAsync`가 차단(block)됩니다.
## `PyTaskGroup`: 공유 결과 세트를 가진 동시 코루틴
일련의 코루틴(coroutine)들을 실행하고 그 결과들을 함께 수집하고 싶을 때, `PyTaskGroup`을 사용하는 것이 태스크(task) 리스트를 수동으로 관리하는 것보다 깔끔합니다:
using var computeFunc = module.GetFunction("compute");
using var group = new PyTaskGroup(interp);
...
내부적으로는 `asyncio.gather()`를 사용하므로, 세 개의 코루틴 모두 동일한 이벤트 루프(event loop) 반복(iteration) 내에서 실행됩니다. 즉, 별도의 스레드가 아닌 적절한 협력적 동시성(cooperative concurrency)을 구현합니다. Python 3.11 이상 버전에서는 `RunWithTaskGroupAsync()`를 사용할 수도 있으며, 이는 구조적 동시성(structured concurrency) 의미론을 위해 `asyncio.TaskGroup`을 통해 라우팅됩니다 (그룹 내 어떤 멤버에서든 예외가 올바르게 전파됩니다).
## `EvaluateAsync<T>`: 이미 보유한 코루틴 구동하기
때때로 Python 코드는 C#에서 호출하기 전에 코루틴 (coroutine) 객체를 생성하기도 합니다. 인터프리터의 `EvaluateAsync<T>`는 이 상황을 처리합니다:
interp.Execute("""
async def async_pow(base, exp):
await asyncio.sleep(0)
...
이 문자열은 Python의 `__main__` 스코프에서 평가되며, 결과는 코루틴 객체로 취급되어 스레드 풀 이벤트 루프 (thread-pool event loop)에서 완료될 때까지 구동됩니다.
## 실제 작동 방식
알아둘 만한 몇 가지 구현 세부 사항이 있습니다.
각 `CallAsync<T>` 호출은 **전용 `asyncio` 이벤트 루프** (`asyncio.new_event_loop()`, `loop.run_until_complete(coro)`, `loop.close()`)를 사용하여 스레드 풀 스레드에서 코루틴을 실행합니다. 이는 `asyncio.sleep`, `asyncio.gather` 또는 기타 표준 `asyncio` 프리미티브 (primitive)를 사용하는 코루틴이 올바르게 작동함을 의미합니다. 또한 병목 현상이 될 수 있는 공유 이벤트 루프가 없음을 의미합니다.
GIL (Global Interpreter Lock)은 모든 Python 호출 전에 스레드 풀 스레드에 의해 획득되고 호출 후에 해제됩니다. 호출하는 C# 스레드는 절대 GIL을 보유하지 않으므로, Python이 실행되는 동안 .NET의 스레드 풀은 정상적으로 계속 작동합니다.
비동기 제너레이터 (async generators)의 경우, 각 `MoveNextAsync()`는 GIL을 획득하는 스레드 풀 스레드로 디스패치(dispatch)됩니다. 이 스레드는 Python 비동기 이터레이터 (async iterator)에 대해 `__anext__()`를 호출하고, 미니 이벤트 루프를 사용하여 다음 `yield`까지 구동한 뒤 값을 반환합니다. 이터레이터 객체는 `IAsyncEnumerator<T>` 구현체에 의해 소유되며, `DisposeAsync`에서 (필요한 경우 `aclose()`와 함께) 해제됩니다.
## 종합
만약 여러분의 애플리케이션이 이미 C#에서 `await`를 사용하고 있다면, PyDotNet의 비동기 브리지는 Python의 비동기 생태계로 가는 가장 저항이 적은 경로입니다. 아키텍처를 재설계하거나, 사이드카 프로세스 (sidecar process)를 띄우거나, 새로운 IPC 프로토콜을 배울 필요가 없습니다. Python 코루틴은 `Task`처럼 보이고, Python 비동기 제너레이터는 `IAsyncEnumerable<T>`처럼 보입니다. 취소 (Cancellation) 또한 이미 보유하고 있는 토큰 (token)으로 작동합니다.
라이브러리는 NuGet에서 사용할 수 있습니다:
dotnet add package PyDotNet
Python 3.11–3.14, .NET 8/9/10, Windows/Linux/macOS, x64 및 ARM64를 지원합니다. 비동기 기능 (async features)은 별도의 선택적 패키지를 필요로 하지 않으며, 핵심 라이브러리 (core library)에 포함되어 있습니다.
[샘플 프로젝트](https://github.com/zcsizmadia/PyDotNet/tree/main/samples)에는 여기서 다루는 모든 시나리오에 대한 실행 가능한 예제들이 포함되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기