LabHub
배우기 러닝패스 코스

Queues and Asynchronous APIs

λ, μ and Queue Depth

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

안정 조건은 λ < μ 하나다. 유입률이 처리율을 넘는 순간 큐 깊이는 선형이 아니라 폭발적으로 자란다.

Concept map: 네 번째 · 오래된 것이 가치가 없는 데이터 · 소진 시간 · 진행 중 작업 수

왜 이게 필요했나

큐 깊이 그래프를 보면 이상한 점이 있습니다. 부하가 서서히 오르는데 큐 깊이는 한동안 0 근처에 붙어 있다가, 어느 지점을 넘으면 갑자기 수직으로 치솟습니다. 이것은 버그가 아니라 대기 행렬의 성질입니다.

유입률 λ 가 처리율 μ 보다 작으면 큐는 대체로 비어 있습니다. 둘이 가까워질수록 순간적인 변동을 흡수하느라 평균 깊이가 커지고, λ 가 μ 를 넘으면 큐는 무한히 자랍니다. 이용률 ρ = λ/μ 가 0.7 일 때와 0.95 일 때 대기 시간의 차이는 두 배가 아니라 훨씬 큽니다.

그래서 워커 용량을 유입의 딱 100% 로 맞추면 안 됩니다. 70~80% 이용률을 목표로 잡고 여유를 남기는 것이 실무의 관행입니다.

어떻게 동작하나

큐 깊이를 지연으로 번역하는 도구가 리틀의 법칙입니다. L = λ x W. 시스템 안의 평균 항목 수는 유입률 곱하기 평균 체류 시간입니다. 뒤집으면 W = L / λ 입니다. 큐 깊이가 3,000 이고 처리율이 초당 50건이면 지금 들어온 메시지는 60초 뒤에 처리됩니다. 이 계산이 되면 "큐가 좀 쌓였네"가 "지금 접수하는 사용자는 1분을 기다린다"로 바뀝니다.

백프레셔는 이 상황에서 시스템이 스스로를 지키는 방법입니다. 세 층위가 있습니다.

첫째, 큐 상한. 큐에 최대 길이를 두고 초과하면 생산자에게 429 를 돌려줍니다. 무한히 받아 놓고 나중에 못 하는 것보다, 지금 못 받는다고 말하는 편이 정직합니다.

둘째, 부하 차단(load shedding). 우선순위가 낮은 요청부터 버립니다. 모든 요청을 붙잡고 느리게 처리하다 다 같이 죽는 것보다, 일부를 빨리 거절하고 나머지를 살리는 편이 전체 가용성에 이롭습니다.

셋째, 소비자 동시성 조절. μ 를 올립니다. 다만 무한정 올릴 수는 없습니다 — 워커를 늘리면 그 뒤의 DB 나 외부 API 가 새 병목이 됩니다. 병목을 옮긴 것인지 없앤 것인지 매번 확인해야 합니다.

현장에서 만나는 모습

429 를 돌려줄 때 Retry-After 헤더를 함께 주면 좋습니다. 눈치 빠른 클라이언트는 이 신호를 존중해 불필요한 조기 재시도를 삼갑니다. 이것을 안 주면 클라이언트는 자기 나름의 백오프로 재시도하고, 그것이 서로 겹칩니다.

그리고 큐 깊이 알림은 절대값보다 추세로 거는 편이 낫습니다. 깊이 1,000 이 정상인 시스템도 있고 10 이 비정상인 시스템도 있습니다. "5분간 계속 증가"가 훨씬 신뢰할 만한 신호입니다.

역압을 어디에 거는가

큐가 자라기 시작할 때 선택지는 셋뿐이고, 아무것도 고르지 않으면 네 번째 가 일어납니다 — 메모리를 다 쓰고 죽습니다.

방법 무엇을 포기하나 맞는 곳
생산자를 막는다(blocking) 응답 시간 내부 파이프라인, 배치
새 요청을 거절한다(429) 일부 요청 공개 API
오래된 것부터 버린다 오래된 데이터 지표·로그·실시간 시세

세 번째가 의외로 자주 옳습니다. 초당 갱신되는 시세를 5분 뒤에 처리하는 것은 아무 값이 없습니다. 오래된 것이 가치가 없는 데이터 라면 버리는 것이 정답입니다. 반대로 결제 이벤트는 절대 버리면 안 되므로 첫 줄이나 둘째 줄을 씁니다.

유계 큐가 기본이다

무제한 큐는 문제를 늦출 뿐 없애지 않습니다. 상한을 두면 문제가 큐가 아니라 생산자에게서 드러납니다 — 그것이 훨씬 빨리 보이고 대응할 수 있습니다.

# ❌ 무제한 — 메모리가 다 찰 때까지 아무 신호가 없다
q = asyncio.Queue()

# ✅ 상한을 두면 put 이 기다리고, 그 대기가 곧 신호다
q = asyncio.Queue(maxsize=1000)
await asyncio.wait_for(q.put(item), timeout=0.5)   # 못 넣으면 거절한다

상한값은 소진 시간 으로 정합니다. 처리율이 초당 100건이고 최대 10초까지 지연을 허용한다면 1,000이 상한입니다. 이렇게 정하면 큐 깊이가 곧 지연 예산이 됩니다.

동시성 제한이 더 정확할 때

큐 깊이보다 진행 중 작업 수 를 제한하는 편이 나은 경우가 많습니다. 특히 하류가 취약할 때 그렇습니다.

sem = asyncio.Semaphore(20)      # 하류에 동시에 20개까지만

async def handle(item):
    async with sem:
        await downstream.call(item)

큐를 아무리 크게 잡아도 하류가 동시 20개만 견딘다면, 그 이상을 보내는 것은 하류를 무너뜨리는 일입니다. 큐는 버퍼이고 세마포어는 보호막 입니다. 둘은 같이 씁니다.

무엇을 대시보드에 올리나

큐 깊이                       ← 지금 얼마나 밀렸나
소진 시간 = 깊이 ÷ 처리율      ← 이것이 경보 기준
생산율 λ 와 소비율 μ           ← 둘의 차이가 추세를 만든다
거절·폐기 건수                ← 역압이 실제로 작동했는가
가장 오래된 항목의 나이        ← 지연의 최대치

마지막 줄이 특히 유용합니다. 깊이가 같아도 오래된 것이 계속 밀려 있는지 (선입선출이 깨졌는지)를 알려 줍니다.

다음 실습에서 할 것

부하 생성기로 큐를 채우면서 깊이를 시계열로 기록하고, 리틀의 법칙으로 대기 시간을 추정하고, 큐 상한과 429 를 붙이고, 워커 동시성을 올려 처리율 개선을 측정한 뒤 안정 조건을 문서로 정리합니다.