asyncio가 정말 멀티스레딩보다 나은가? 100개의 Concurrent Requests를 테스트했더니 차이가 엄청났습니다

발행: (2026년 5월 1일 AM 10:45 GMT+9)
7 분 소요
원문: Dev.to

Source: Dev.to

죄송합니다만, 제공해 주신 텍스트 외에 번역할 내용이 포함되어 있지 않아 번역을 진행할 수 없습니다. 번역이 필요한 전체 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.

Introduction

지난달, 제가 관리하던 데이터 플랫폼에 갑자기 새로운 요구사항이 생겼습니다: 100개 이상의 하위 서비스에 대해 헬스 체크를 수행하라는 것이죠. 각 엔드포인트는 평균 200 ms가 소요되고, 전체 체크는 5 seconds 이내에 끝나야 했습니다. 고민 없이 100개의 스레드를 띄웠지만, 스레드 전환 오버헤드가 즉시 CPU를 포화시켰고 응답 시간은 8 seconds를 훌쩍 넘었습니다. 운영팀 동료는 그룹 채팅에 물음표 세 개를 남겼습니다.

그 순간 저는 asyncio를 진지하게 재검토하게 되었습니다. 예전에는 비동기 프로그래밍이 학습 곡선이 가파르고 버그가 많이 발생하는 함정이라고 생각했지만, 철저한 벤치마크를 수행한 뒤에야 확신하게 되었습니다: I/O‑bound 작업에서는 asyncio와 멀티스레딩이 같은 수준이 아니라는 사실을요. 여기서는 동일한 작업을 세 가지 전략—동기식, 멀티스레드, asyncio—으로 실행했을 때의 전체 결과를 정면으로 비교합니다.

우리는 FastAPI를 사용해 모의 하위 서비스를 만들었습니다. /health 엔드포인트는 의도적으로 200 ms 동안 대기한 뒤 {"status": "ok"}를 반환합니다. 클라이언트는 세 가지 접근 방식을 각각 사용해 100개의 동시 요청을 보내고, 전체 소요 시간과 자원 사용량을 측정합니다.

동기식 데모 (sync_demo.py)

# sync_demo.py — 同步请求,一个接一个
import time
import requests

URLS = [f"http://localhost:8000/health" for _ in range(100)]

def check_sync():
    results = []
    for url in URLS:
        resp = requests.get(url, timeout=5)
        results.append(resp.json())
    return results

if __name__ == "__main__":
    start = time.perf_counter()
    check_sync()
    elapsed = time.perf_counter() - start
    print(f"同步耗时: {elapsed:.2f}s")   # 20.3s 左右

놀랍지 않게도, 100 × 200 ms = 20 초가 된다. 스레드는 네트워크 I/O를 기다리는 데 모든 시간을 소비하고 CPU는 거의 유휴 상태가 된다. 이는 요청이 100개일 때의 상황이며, 1 000개가 되면 동시성이 전혀 없어서 시스템이 사실상 3분 동안 멈춰버린다.

Multithreaded Demo (thread_demo.py)

# thread_demo.py — 100 个线程并发
import time
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

URLS = [f"http://localhost:8000/health" for _ in range(100)]

def fetch(url):
    return requests.get(url, timeout=5).json()

def check_thread():
    results = []
    with ThreadPoolExecutor(max_workers=100) as executor:
        futures = {executor.submit(fetch, url): url for url in URLS}
        for future in as_completed(futures):
            results.append(future.result())
    return results

if __name__ == "__main__":
    start = time.perf_counter()
    check_thread()
    elapsed = time.perf_counter() - start
    print(f"多线程耗时: {elapsed:.2f}s")  # 第一次 8.5s,后来波动在 3~6s

첫 번째 실행은 8.5초가 걸렸으며, CPU 사용량이 즉시 90%까지 급증했습니다. Python의 GIL은 I/O 중에 해제되지만, 100개의 스레드를 생성하고 지속적인 컨텍스트 스위칭과 락 경쟁이 큰 오버헤드를 발생시킵니다. max_workers를 30으로 낮추었을 때 실행 시간이 2.1초로 감소하고 CPU 사용량도 안정되었습니다—하지만 이는 “직감에 의한 튜닝”이 되고, 스레드 수가 다시 증가하면 시스템이 다시 불안정해집니다.

더 교묘한 함정이 하나 더 있습니다: requests 라이브러리는 가장 스레드‑안전한 선택이 아니며, 연결 풀 재사용이 제한적이고 가끔 ConnectionResetError를 발생시켜 디버깅이 매우 어렵습니다.

Asyncio Demo (async_demo.py)

# async_demo.py — 使用 asyncio 和 aiohttp 并发请求
import asyncio
import time
import aiohttp

URLS = [f"http://localhost:8000/health" for _ in range(100)]

async def fetch(session, url):
    try:
        async with session.get(url, timeout=5) as resp:
            return await resp.json()
    except Exception as e:
        return {"error": str(e)}

async def check_async():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in URLS]
        results = await asyncio.gather(*tasks)
    return results

if __name__ == "__main__":
    start = time.perf_counter()
    asyncio.run(check_async())
    elapsed = time.perf_counter() - start
    print(f"asyncio 耗时: {elapsed:.2f}s")  # 稳定在 0.45~0.60s

100개의 모든 작업이 단일 이벤트 루프 안에서 스케줄링되고 비동기적으로 디스패치됩니다. 전체 경과 시간은 가장 느린 I/O 호출에 의해서만 결정되며, 일관되게 0.6초 이하로 측정됩니다. CPU 사용량은 15 %를 넘지 않았고, 메모리 사용량은 거의 완전히 평탄하게 유지되었습니다. 상사가 모니터링 대시보드에서 결과를 보고는 제가 비밀리에 서버를 더 추가했는지 물었지만, 실제로는 async/await으로 코드를 다시 작성했을 뿐이었습니다.

요약

동기 코드를 코루틴에 섞어 넣으면 즉시 성능이 크게 저하됩니다. 처음에는 requests.get()async def 안에 바로 넣었고… (이야기의 나머지는 계속됩니다).

0 조회
Back to Blog

관련 글

더 보기 »