DUJINLABS · Web Development

ASP.NET Core 동시성: 스레드·연결·처리량의 차이

요청이 기다리는 장소를 구분하면 CPU 사용률만으로 보이지 않는 병목을 찾을 수 있습니다.

동시 접속 수는 하나의 자원 사용량이 아닙니다

같은 요청도 실행 가능한 코드, 외부 응답을 기다리는 상태, 제한기 앞의 대기 상태를 오갑니다. 열린 연결 수, 실행 중인 작업 수, 초당 완료 요청 수를 같은 수치로 취급하면 대기 시간이 늘어난 원인을 잘못 짚게 됩니다. 어느 자원이 먼저 부족해지는지는 작업과 설정에 달려 있으며 고정된 장애 순서는 없습니다.

입장동시 실행 허가대기 또는 거절
실행ThreadPool 작업CPU 또는 블로킹
외부 대기연결 풀과 응답비동기 완료 후 재개
요청 경로의 개념도. 비동기 대기 중에는 작업자 스레드를 계속 점유할 필요가 없지만, 요청 상태와 외부 연결 등 다른 자원은 남을 수 있습니다. 세 상자는 고정된 장애 발생 순서가 아닙니다.

비동기는 기다리는 동안 스레드를 돌려줍니다

Thread.Sleep, 동기식 I/O, 완료되지 않은 작업에 대한 .Wait()나 .Result는 호출 스레드를 붙잡을 수 있습니다. 반대로 완료되지 않은 비동기 작업을 await하면 대기 중 작업자 스레드를 다른 일에 쓸 수 있습니다. async라는 선언만으로 내부의 블로킹 호출이 없어지거나 CPU 계산이 빨라지지는 않습니다.

낮은 CPU 사용률과 긴 지연이 함께 보여도 곧바로 스레드 부족으로 확정하지 않습니다. ThreadPool 작업 대기열·스레드 수·완료량의 시간 변화를 확인하고, 스택이나 추적으로 실제 블로킹 위치를 찾습니다. .NET 6 이후 일부 블로킹 Task API에 대한 스레드 증가 정책이 바뀌었으므로 증가 속도를 언제나 초당 1~2개라고 일반화할 수 없습니다. Microsoft의 ThreadPool 진단 설명

HttpClient 객체보다 핸들러와 연결 풀의 수명이 중요합니다

연결 풀은 HTTP 핸들러에 속합니다. 기본 생성 방식의 HttpClient를 매 요청마다 만들고 폐기하면 연결을 반복해서 열고 닫게 되어 포트 자원이 부담을 받을 수 있습니다. 대안은 적절한 연결 수명을 가진 장수 클라이언트 또는 핸들러를 재사용하는 IHttpClientFactory입니다. 팩터리가 만든 짧은 수명의 클라이언트를 기본 생성 클라이언트와 같은 문제로 취급하면 안 됩니다.

DNS는 새 연결을 만들 때 반영됩니다. PooledConnectionLifetime은 사용 중인 요청을 강제로 중단하는 타이머가 아니며 환경의 DNS 변경 주기에 맞춰 정합니다. 연결 제한, 응답 시간, 취소·타임아웃을 함께 살펴봐야 합니다. 모든 환경에서 포트가 같은 수만큼 열리거나 TIME_WAIT가 같은 시간 지속된다고 가정하지 않습니다. Microsoft HttpClient 지침

동시 실행 제한은 초당 요청 제한이 아닙니다

Concurrency limiter의 허가는 요청 처리가 끝날 때 반환됩니다. PermitLimit은 동시에 처리할 요청의 수를, QueueLimit은 기다릴 수 있는 수를 제한합니다. 고정 시간창 방식처럼 초당 처리량을 직접 정하지는 않습니다. 이 제한은 기본적으로 앱 인스턴스별 메모리 상태이므로 여러 서버를 합친 전역 한도로 읽으면 안 됩니다.

지연이 늘어날 때 확인할 서로 다른 대상
대상확인할 증거구분할 결과
작업자 스레드작업 대기열, 스레드 수, 블로킹 스택CPU 포화와 블로킹 대기
외부 호출연결 재사용, 응답 지연, 취소·타임아웃연결 부족과 상대 서버의 지연
입장 제한처리 중·대기·거절 수와 성공 지연성공 처리와 의도적인 거절

거절 응답을 429로 설정하는 것은 과부하를 통제하는 방법이지 업무 요청을 성공 처리한 것은 아닙니다. 성공률·거절률·타임아웃률을 따로 기록해야 합니다. 요청 취소 신호도 실제 하위 비동기 호출에 전달해야 대기 작업을 줄일 수 있습니다. 한도 숫자는 측정으로 정하며 특정 예제의 500·100을 보편적인 권장값으로 사용하지 않습니다. ASP.NET Core 10 제한기 문서

저자의 실험 코드와 일반 원리를 나누어 읽습니다

Stefan Đokić의 동시성 실험 글과 ConcurrencyLab 고정 버전을 대조했습니다. 저장소는 .NET 10 대상이며, HTTP 예제의 하위 호출은 외부 서비스가 아니라 같은 서버의 /health입니다. 부하 스크립트도 모두 같은 조건이 아닙니다. storm.js는 최대 10,000 VU로 증가하고, http-storm.js는 500 VU, io-storm.js는 10,000 VU를 사용합니다. 본문에 제시된 일부 실행 결과는 이 기본값과 다른 VU·기간을 사용하므로 그 처리량을 ‘1만 접속 성능’으로 묶지 않습니다.

VU는 가상 사용자의 실행 흐름이지 TCP 연결 수 자체가 아닙니다. 닫힌 부하 모델에서는 한 반복이 끝난 뒤 다음 반복이 시작되므로 응답이 느려지면 유입 속도도 달라집니다. 같은 VU라도 연결 재사용·프로토콜·반복 동작에 따라 서버가 받는 부하는 다릅니다. k6의 열린·닫힌 부하 모델

io-storm.js의 check는 200과 429를 모두 받아들입니다. 이것은 테스트가 의도한 응답인지 검사하는 조건이며, 사용자 요청의 성공률이나 모든 HTTP 오류가 0이라는 증거는 아닙니다. check 실패와 테스트 종료 판정도 별개이며 종료 기준에는 threshold가 필요합니다. 비교할 때는 상태 코드별 처리량과 성공 응답의 지연 분포를 분리합니다. k6 checks의 의미

이 글은 원문과 소스의 정적 대조입니다. 저장소를 설치하거나 부하를 발생시키지 않았고, 저자의 RPS를 재현한 측정값으로 제시하지 않습니다.

출처와 범위

  • Program.cs: 블로킹·비동기·팩터리·제한기 예제의 실제 구현.
  • k6 시나리오 3개: 테스트 조건과 판정 기준.
  • 원문의 장애 순서와 성능 수치를 보편 법칙으로 채택하지 않았습니다. 본문에 연결한 공식 문서와 고정 코드가 설명의 기준입니다.