튜토리얼약 11분 읽기

종목을 늘렸더니 느려졌다 — asyncio 동시조회와 요청 제한

관심 종목이 몇 개일 때는 아무 문제가 없다. 반복문으로 하나씩 조회하고, 다 받으면 판단한다. 코드도 짧고 읽기 쉽다.

그러다 종목을 늘리면 어느 순간 이상해진다. 한 바퀴 도는 데 걸리는 시간이 눈에 띄게 길어지고, 마지막 종목의 시세를 받았을 때는 첫 종목의 시세가 이미 낡아 있다. 같은 시점의 시장을 보고 판단한다는 전제가 조용히 깨진다.

이 글은 그 지점에서 비동기로 넘어가는 과정과, 넘어가자마자 새로 터지는 문제를 정리한 것이다.

이 글의 범위

증권사별 초당 요청 상한값이나 엔드포인트는 다루지 않는다. 회사마다 다르고 공지 없이 바뀌기도 해서, 여기에 적으면 읽는 시점에 이미 틀린 정보가 된다. 코드는 전부 generic 의사코드이며 실제 상한과 주소는 각자 쓰는 증권사의 공식 문서에서 확인해야 한다.

다루는 것은 구조다 — 왜 순차가 느린가, 동시에 던지면 무엇이 바뀌는가, 그리고 그때 무엇이 새로 깨지는가.

순차 조회는 왜 종목 수에 그대로 비례하나

핵심은 이 시간이 계산 시간이 아니라 기다리는 시간이라는 데 있다. 시세를 요청하고 응답이 올 때까지, 내 프로그램은 아무 일도 하지 않고 서 있는다. CPU가 부족한 게 아니라 그냥 대기다.

python 코드 보기
# 순차: 앞 요청이 끝나야 다음 요청이 시작된다
for code in codes:
    quotes[code] = broker.get_quote(code)   # 요청 → 대기 → 응답

종목이 하나 늘면 그 대기가 통째로 한 번 더 붙는다. 그래서 소요 시간이 종목 수에 거의 그대로 비례한다. 종목이 열 배가 되면 한 바퀴도 대략 열 배가 된다.

문제는 느리다는 것 자체가 아니라 신선도가 종목마다 달라진다는 데 있다. 목록 앞쪽 종목과 뒤쪽 종목이 서로 다른 시점의 가격이 되고, 그 둘을 비교하는 전략은 있지도 않은 차이를 보게 된다.

비동기가 이 문제를 푸는 이유는 단순하다. 기다리는 동안 다른 요청을 보낼 수 있으면, 대기 시간이 겹쳐진다. 대기는 쌓이지 않고 포개진다.

async/await로 동시에 던지기

파이썬에서는 asyncio로 이 구조를 만든다. 요청 함수를 코루틴으로 만들고, gather로 한꺼번에 넘긴다.

python 코드 보기
# generic 의사코드. 실제 클라이언트·엔드포인트는 각 증권사 공식 문서 확인.
import asyncio

async def fetch_quote(client, code):
    return code, await client.get_quote(code)      # await 지점에서 다른 작업에 양보

async def fetch_all(client, codes):
    tasks = [fetch_quote(client, c) for c in codes]
    results = await asyncio.gather(*tasks)
    return dict(results)

여기서 짚어둘 것이 두 가지 있다.

첫째, HTTP 클라이언트도 비동기여야 한다. 동기 방식 라이브러리를 코루틴 안에서 그대로 호출하면 그 순간 전체가 멈춘다. async def를 붙였다고 비동기가 되는 게 아니라, await 지점에서 실제로 양보가 일어나야 한다. 이걸 놓치면 코드만 복잡해지고 속도는 그대로다.

둘째, 비동기는 대기를 겹치는 기술이지 계산을 빠르게 하는 기술이 아니다. 조회 후 무거운 계산이 있다면 그 부분은 그대로 느리다. 지금 겪는 병목이 대기인지 계산인지 먼저 구분해야 한다. 대기가 아니라면 비동기로 바꿔도 달라지지 않는다.

그리고 여기서 터진다 — 초당 요청 제한

순차 코드를 위 구조로 바꾸면 대개 잘 돌아간다. 종목이 적을 때는.

종목을 늘려서 다시 돌리면 이번엔 다른 것이 깨진다. gather는 넘긴 작업을 전부 동시에 시작한다. 종목이 백 개면 요청 백 개가 사실상 같은 순간에 나간다. 증권사 API에는 초당 요청 수 제한이 있고, 이 방식은 그 제한을 정면으로 넘는다.

돌아오는 결과는 대체로 둘 중 하나다. 요청 일부가 거부되거나, 계정 단위로 일시 차단된다. 후자가 더 나쁘다 — 조회뿐 아니라 주문 경로까지 같이 막힐 수 있기 때문이다.

역설적이지만 이 상태는 순차보다 나쁘다. 순차는 느렸을 뿐 결과는 다 받았다. 제한을 넘긴 비동기는 빠르게 실패한다. 속도를 얻으려다 완결성을 잃는 교환은 대체로 손해다.

제한 자체를 어떻게 다루는지 — 재시도·백오프·요청 예산 — 는 증권 API 레이트리밋과 재시도 설계에 정리해뒀다. 이 글에서는 동시성 쪽만 본다.

Semaphore로 동시성 상한 두기

해법은 간단하다. 동시에 진행 중인 요청 수에 상한을 두고, 그 상한을 넘는 작업은 자리가 날 때까지 기다리게 한다. asyncio.Semaphore가 그 역할을 한다.

python 코드 보기
async def fetch_quote(client, code, sem):
    async with sem:                                # 자리가 없으면 여기서 대기
        return code, await client.get_quote(code)

async def fetch_all(client, codes, limit):
    sem = asyncio.Semaphore(limit)                 # 동시 진행 상한
    tasks = [fetch_quote(client, c, sem) for c in codes]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    return results

이 구조의 좋은 점은 종목 수가 늘어도 서버에 가하는 압력이 늘지 않는다는 것이다. 종목이 백 개든 천 개든 동시 진행은 상한 이하로 유지되고, 늘어나는 건 전체 소요 시간뿐이다. 순차의 안전함과 비동기의 속도 사이에서 조절 손잡이를 하나 갖게 되는 셈이다.

상한값을 얼마로 둘지는 증권사 문서의 제한과 응답 시간에 따라 달라지므로 여기에 숫자를 적지 않는다. 다만 정하는 방법에는 원칙이 있다.

함정 — 예외 하나와 재시도의 조합

비동기로 넘어오면 실패의 모양이 달라진다. 세 가지를 미리 알아두는 편이 좋다.

첫째, 예외 하나가 전체를 죽인다. gather는 기본 설정에서 작업 중 하나가 예외를 던지면 그 예외를 그대로 올려보낸다. 종목 하나의 조회가 실패했을 뿐인데 나머지 아흔아홉 개의 결과까지 함께 잃는다. 위 코드에서 return_exceptions=True를 쓴 이유가 이것이다. 다만 이걸 켜면 결과 목록에 예외 객체가 섞여 들어오므로, 받은 쪽에서 반드시 분류해야 한다.

python 코드 보기
ok, failed = {}, []
for item in results:
    if isinstance(item, Exception):
        failed.append(item)          # 실패는 실패로 남긴다
    else:
        code, quote = item
        ok[code] = quote

if failed and len(failed) > FAIL_LIMIT:
    halt_new_entries()               # 시세를 못 믿는 상태에서는 신규 진입 없음

분류하지 않고 통째로 넘기면 예외 객체가 시세인 척 전략까지 흘러간다. 그게 가장 나쁜 결말이다.

둘째, 부분 실패를 성공으로 처리하지 않는다. 백 개 중 아흔 개만 받았을 때 그대로 판단해도 되는지는 전략이 정할 문제다. 다만 코드가 그 구분을 하지 않으면, 조용히 빠진 열 개는 "값이 없음"이 아니라 "그 종목에 아무 일도 없었음"으로 읽힌다. 무인 운영에서 이런 침묵을 어떻게 감시할지는 24시간 무인운영과 장애대응에서 다룬 것과 같은 문제다.

셋째, 재시도와 동시성 상한은 서로를 밀어낸다. 실패한 요청을 세마포어 안에서 재시도하면, 재시도가 대기 슬롯을 계속 점유해 다른 작업이 굶는다. 반대로 세마포어 밖에서 재시도하면 상한을 넘어선다. 실무에서는 재시도를 같은 상한 아래 두되 횟수와 백오프를 짧게 가져가는 편이 안전하다. 어떤 오류를 재시도 대상으로 볼지는 증권사 API 에러코드 핸들링의 분류를 그대로 쓴다. 재시도하면 안 되는 오류를 재시도 목록에 넣어두면, 동시성 상한이 있어도 요청 예산은 그대로 소모된다.

자주 하는 실수

  1. 동기 클라이언트를 코루틴 안에서 그대로 쓴다. async만 붙고 실제 양보가 없어 속도가 그대로다.
  2. gather에 전 종목을 그냥 넘긴다. 동시성 상한이 없으면 종목 수가 곧 동시 요청 수가 된다.
  3. return_exceptions를 켜놓고 결과를 분류하지 않는다. 예외 객체가 시세 자리에 들어앉는다.
  4. 동시성 상한만 두고 초당 제한은 잊는다. 응답이 빠르면 낮은 상한으로도 초당 제한을 넘는다.
  5. 모듈마다 각자 상한을 둔다. 계정 단위 합계가 관리되지 않아 결국 넘긴다.
  6. 부분 실패를 조용히 넘긴다. 빠진 종목이 "사건 없음"으로 읽혀 전략이 잘못된 전제로 판단한다.

오늘의 정리

  1. 순차 조회가 느린 이유는 계산이 아니라 대기다. 비동기는 그 대기를 겹쳐서 줄이는 기술이지 계산을 빠르게 하지 않는다.
  2. 종목 수에 비례해 늘어나는 진짜 비용은 시간이 아니라 신선도 차이다. 앞뒤 종목이 다른 시점의 가격이 되면 비교 자체가 무의미해진다.
  3. gather로 전부 던지는 순간 동시 요청 수가 종목 수가 된다. 초당 요청 제한은 이 지점에서 깨진다.
  4. 동시성 상한(Semaphore)은 종목이 늘어도 서버 압력이 늘지 않게 한다. 다만 동시 요청 수와 초당 요청 수는 다른 값이라 조절 장치가 따로 필요할 수 있다.
  5. 비동기에서는 실패가 뭉쳐서 온다. 예외를 분류하고, 부분 실패를 성공으로 처리하지 않고, 재시도를 상한 안에서 다루는 것까지가 한 세트다.

이 블로그는 익명으로 운영되며, 실계좌 정보·실제 금액·구체적인 시스템 상세는 공개하지 않는다. 이 글은 개인의 개발·운영 경험을 정리한 기록으로 투자 조언이나 종목 추천이 아니며, 어떤 수익도 보장하지 않는다. 투자 판단과 그로 인한 손실의 책임은 투자자 본인에게 있다.

← 전체 글 목록