IO-Bound vs CPU-bound

동시성 또는 병렬성이 성능 향상을 제공하는 정도는 작업 부하의 특성에 따라 달라집니다. 서버 애플리케이션에서는 일반적으로 두 가지 주요 유형의 작업을 구분합니다.

최근 몇 년간 대부분의 웹 애플리케이션은 IO-bound 작업 부하 방향으로 이동하고 있습니다. 이는 마이크로서비스, 원격 API, 클라우드 서비스의 성장에 의해 주도됩니다. 여러 소스에서 데이터를 집계하는 Frontend for Backend(BFF) 및 API Gateway와 같은 접근 방식이 이 효과를 증폭시킵니다.

현대 서버 애플리케이션은 로깅, 텔레메트리, 실시간 모니터링 없이는 상상하기 어렵습니다. 이 모든 작업은 본질적으로 IO-bound입니다.

IO-bound 작업의 효율성

IO-bound 작업의 동시 실행 효율성은 작업이 실제로 CPU를 사용하는 시간의 비율과 I/O 작업 완료를 기다리는 데 소비하는 시간의 비율에 의해 결정됩니다.

리틀의 법칙

대기열 이론에서 기본 공식 중 하나는 리틀의 법칙(Little’s Law)입니다:

\[L = \lambda \cdot W\]

여기서:

이 법칙은 보편적이며 특정 시스템 구현에 의존하지 않습니다: 스레드, 코루틴 또는 비동기 콜백을 사용하는지는 중요하지 않습니다. 부하, 지연 시간, 동시성 수준 사이의 근본적인 관계를 설명합니다.

서버 애플리케이션의 동시성을 추정할 때, 본질적으로 리소스를 효율적으로 사용하기 위해 시스템에 동시에 몇 개의 작업이 있어야 하는지에 대한 문제를 풀고 있는 것입니다.

IO-bound 작업 부하의 경우, 평균 요청 처리 시간은 능동적 계산에 소요되는 시간에 비해 큽니다. 따라서 CPU가 유휴 상태가 되지 않으려면 시스템에 충분한 수의 동시 작업이 있어야 합니다.

이것이 바로 형식적 분석이 추정할 수 있는 양이며, 다음을 연관시킵니다:

유사한 접근 방식이 업계에서 최적 스레드 풀 크기 계산에 사용됩니다 (Brian Goetz, “Java Concurrency in Practice” 참조).

이 공식의 각 요소에 대한 실제 통계 데이터 (HTTP 요청당 SQL 쿼리 수, DB 지연 시간, PHP 프레임워크 처리량)는 별도의 문서에 수집되어 있습니다: 동시성 계산을 위한 통계 데이터.

기본 CPU 사용률

단일 작업을 실행할 때 프로세서가 실제로 유용한 작업을 수행하는 시간의 비율을 계산하려면 다음 공식을 사용할 수 있습니다:

\[U = \frac{T_{cpu}}{T_{cpu} + T_{io}}\]

합계 T_cpu + T_io는 시작부터 완료까지 작업의 전체 수명을 나타냅니다.

U는 0에서 1 사이이며 프로세서 사용률의 정도를 나타냅니다:

따라서 이 공식은 CPU가 얼마나 효율적으로 사용되고 있는지, 그리고 해당 작업 부하가 IO-bound인지 CPU-bound인지에 대한 정량적 평가를 제공합니다.

동시성의 영향

여러 IO-bound 작업을 동시에 실행할 때, CPU는 한 작업의 I/O 대기 시간을 다른 작업의 계산을 수행하는 데 사용할 수 있습니다.

N개의 동시 작업에서 CPU 사용률은 다음과 같이 추정할 수 있습니다:

\[U_N = \min\left(1,\; N \cdot \frac{T_{cpu}}{T_{cpu} + T_{io}}\right)\]

동시성을 높이면 CPU 사용률이 향상되지만, 특정 한계까지만 그렇습니다.

효율성 한계

동시성으로 인한 최대 이득은 I/O 대기 시간과 계산 시간의 비율에 의해 제한됩니다:

\[E(N) \approx \min\left(N,\; 1 + \frac{T_{io}}{T_{cpu}}\right)\]

실제로 이는 진정으로 유용한 동시 작업의 수가 대략 T_io / T_cpu 비율과 같다는 것을 의미합니다.

최적 동시성

\[N_{opt} \approx 1 + \frac{T_{io}}{T_{cpu}}\]

공식의 1은 현재 CPU에서 실행 중인 작업을 설명합니다. T_io / T_cpu 비율이 큰 경우(IO-bound 작업 부하에서 일반적), 1의 기여는 무시할 수 있으며 공식은 종종 T_io / T_cpu로 단순화됩니다.

이 공식은 Brian Goetz가 “Java Concurrency in Practice” (2006)에서 제안한 고전적인 최적 스레드 풀 크기 공식의 특수한 경우(단일 코어)입니다:

\[N_{threads} = N_{cores} \times \left(1 + \frac{T_{wait}}{T_{service}}\right)\]

비율 T_wait / T_service블로킹 계수로 알려져 있습니다. 이 계수가 높을수록 단일 코어에서 더 많은 동시 작업을 효과적으로 활용할 수 있습니다.

이 수준의 동시성에서 프로세서는 대부분의 시간을 유용한 작업에 소비하며, 작업 수를 더 늘려도 눈에 띄는 이득을 제공하지 않습니다.

이것이 바로 비동기 실행 모델이 IO-bound 웹 작업 부하에 가장 효과적인 이유입니다.

일반적인 웹 애플리케이션에 대한 예제 계산

단순화되었지만 상당히 현실적인 평균 서버 측 웹 애플리케이션 모델을 살펴보겠습니다. 단일 HTTP 요청 처리가 주로 데이터베이스와의 상호 작용을 포함하며 계산적으로 복잡한 작업을 포함하지 않는다고 가정합니다.

초기 가정

왜 20개의 쿼리인가? 이는 중간 복잡도의 ORM 애플리케이션에 대한 중앙값 추정치입니다. 비교를 위해:

  • WordPress는 페이지당 ~17개의 쿼리를 생성하고,
  • 캐싱 없는 Drupal은 80~100개,
  • 일반적인 Laravel/Symfony 애플리케이션은 10~30개입니다.

성장의 주요 원인은 ORM이 관련 엔티티를 별도의 쿼리로 로드하는 N+1 패턴입니다.

실행 시간 추정

추정에는 평균값을 사용합니다:

HTTP 요청당 총합:

선택된 지연 시간 값에 대해. 단일 SQL 쿼리의 I/O 시간은 네트워크 지연 시간(round-trip)과 DB 서버에서의 쿼리 실행 시간으로 구성됩니다. 단일 데이터 센터 내 네트워크 왕복 시간은 ~0.5 ms이며, 클라우드 환경(cross-AZ, managed RDS)의 경우 1~5 ms입니다. 중간 복잡도 쿼리의 실행 시간을 고려하면, 쿼리당 4 ms는 클라우드 환경에서 현실적인 추정치입니다. CPU 시간(0.05 ms)은 ORM 결과 매핑, 엔티티 하이드레이션, 기본 처리 로직을 포함합니다.

작업 부하 특성

대기 시간과 계산 시간의 비율:

\[\frac{T_{io}}{T_{cpu}} = \frac{80}{1} = 80\]

이는 작업이 주로 IO-bound임을 의미합니다: 프로세서는 대부분의 시간을 유휴 상태로 보내며 I/O 작업 완료를 기다립니다.

코루틴 수 추정

CPU 코어당 최적 동시 코루틴 수는 대략 I/O 대기 시간과 계산 시간의 비율과 같습니다:

\[N_{coroutines} \approx \frac{T_{io}}{T_{cpu}} \approx 80\]

다시 말해, 코어당 약 80개의 코루틴이 I/O 지연을 거의 완전히 숨기면서 높은 CPU 사용률을 유지할 수 있습니다.

비교를 위해: Zalando Engineering은 응답 시간이 50 ms이고 처리 시간이 5 ms인 듀얼 코어 머신에서 마이크로서비스의 예를 제공합니다: 2 × (1 + 50/5) = 22 스레드 — 같은 원리, 같은 공식입니다.

코어 수에 따른 확장

C개의 코어를 가진 서버의 경우:

\[N_{total} \approx C \cdot \frac{T_{io}}{T_{cpu}}\]

예를 들어, 8코어 프로세서의 경우:

\[N_{total} \approx 8 \times 80 = 640 \text{ 코루틴}\]

이 값은 유용한 동시성 수준을 반영하며, 하드 제한이 아닙니다.

환경에 대한 민감도

코어당 80개의 코루틴은 보편적 상수가 아니라 I/O 지연 시간에 대한 구체적인 가정의 결과입니다. 네트워크 환경에 따라 최적의 동시 작업 수는 크게 다를 수 있습니다:

환경 SQL 쿼리당 T_io T_io 총합 (×20) 코어당 N
Localhost / Unix-socket ~0.1 ms 2 ms ~2
LAN (단일 데이터 센터) ~1 ms 20 ms ~20
클라우드 (cross-AZ, RDS) ~4 ms 80 ms ~80
원격 서버 / cross-region ~10 ms 200 ms ~200

지연 시간이 클수록 CPU를 유용한 작업으로 완전히 활용하기 위해 더 많은 코루틴이 필요합니다.

PHP-FPM vs 코루틴: 근사 계산

코루틴의 실질적 이점을 추정하기 위해, 같은 서버에서 같은 작업 부하로 두 가지 실행 모델을 비교해 보겠습니다.

초기 데이터

서버: 8코어, 클라우드 환경 (cross-AZ RDS).

작업 부하: 일반적인 Laravel API 엔드포인트 — 인증, Eloquent 쿼리 (eager loading), JSON 직렬화.

SevallaKinsta의 벤치마크 데이터 기반:

파라미터 출처
Laravel API 처리량 (30 vCPU, localhost DB) ~440 req/s Sevalla, PHP 8.3
벤치마크의 PHP-FPM 워커 수 15 Sevalla
벤치마크의 응답 시간 (W) ~34 ms L/λ = 15/440
PHP-FPM 워커당 메모리 ~40 MB 일반적인 값

1단계: T_cpu 및 T_io 추정

Sevalla 벤치마크에서 데이터베이스는 localhost에서 실행됩니다 (지연 시간 <0.1 ms). 엔드포인트당 ~10개의 SQL 쿼리가 있으므로 총 I/O는 1 ms 미만입니다.

주어진 조건:

리틀의 법칙으로:

\[W = \frac{L}{\lambda} = \frac{15}{440} \approx 0.034 \, \text{s} \approx 34 \, \text{ms}\]

이 벤치마크에서 데이터베이스가 localhost에서 실행되고 총 I/O가 1 ms 미만이므로, 결과적인 평균 응답 시간은 거의 전적으로 요청당 CPU 처리 시간을 반영합니다:

\[T_{cpu} \approx W \approx 34 \, \text{ms}\]

이는 localhost 조건에서 거의 모든 응답 시간(~34 ms)이 CPU임을 의미합니다: 프레임워크, middleware, ORM, 직렬화.

같은 엔드포인트를 20개의 SQL 쿼리가 있는 클라우드 환경으로 옮겨봅시다:

\[T_{cpu} = 34 \text{ ms (프레임워크 + 로직)}\] \[T_{io} = 20 \times 4 \text{ ms} = 80 \text{ ms (DB 대기 시간)}\] \[W = T_{cpu} + T_{io} = 114 \text{ ms}\]

블로킹 계수:

\[\frac{T_{io}}{T_{cpu}} = \frac{80}{34} \approx 2.4\]

2단계: PHP-FPM

PHP-FPM 모델에서 각 워커는 별도의 OS 프로세스입니다. I/O 대기 중 워커는 블록되어 다른 요청을 처리할 수 없습니다.

8개의 코어를 완전히 활용하려면, 어느 순간에든 8개의 워커가 CPU 작업을 수행하도록 충분한 워커가 필요합니다:

\[N_{workers} = 8 \times \left(1 + \frac{80}{34}\right) = 8 \times 3.4 = 27\]
지표
워커 27
메모리 (27 × 40 MB) 1.08 GB
처리량 (27 / 0.114) 237 req/s
CPU 사용률 ~100%

실제로 관리자들은 종종 pm.max_children = 50–100으로 설정하는데, 이는 최적값 이상입니다. 추가 워커는 CPU를 두고 경쟁하고, OS 컨텍스트 스위치 수를 증가시키며, 처리량 증가 없이 메모리를 소비합니다.

3단계: 코루틴 (이벤트 루프)

코루틴 모델에서 단일 스레드(코어당)가 많은 요청을 처리합니다. 코루틴이 I/O를 기다릴 때, 스케줄러는 ~200 나노초 만에 다른 코루틴으로 전환합니다 (근거 자료 참조).

최적 코루틴 수는 동일합니다:

\[N_{coroutines} = 8 \times 3.4 = 27\]
지표
코루틴 27
메모리 (27 × ~2 MiB) 54 MiB
처리량 237 req/s
CPU 사용률 ~100%

처리량은 동일합니다 — CPU가 병목이기 때문입니다. 하지만 동시성을 위한 메모리: 54 MiB vs 1.08 GB~20배 차이입니다.

코루틴 스택 크기에 대해. PHP에서 코루틴의 메모리 사용량은 예약된 C-스택 크기에 의해 결정됩니다. 기본값은 ~2 MiB이지만, 128 KiB까지 줄일 수 있습니다. 128 KiB 스택으로 27개의 코루틴에 대한 메모리는 단 ~3.4 MiB입니다.

4단계: CPU 부하가 더 낮다면?

Laravel 프레임워크는 FPM 모드에서 요청당 ~34 ms의 CPU를 소비하는데, 이에는 매 요청마다의 서비스 재초기화가 포함됩니다.

상태 유지 런타임(True Async가 바로 이것)에서는 이러한 비용이 크게 줄어듭니다: 라우트가 컴파일되고, 의존성 컨테이너가 초기화되며, 커넥션 풀이 재사용됩니다.

T_cpu가 34 ms에서 5 ms로 감소하면(상태 유지 모드에서 현실적), 상황이 극적으로 변합니다:

T_cpu 블로킹 계수 N (8코어) λ (req/s) 메모리 (FPM) 메모리 (코루틴)
34 ms 2.4 27 237 1.08 GB 54 MiB
10 ms 8 72 800 2.88 GB 144 MiB
5 ms 16 136 1 600 5.44 GB 272 MiB
1 ms 80 648 8 000 25.9 GB 1.27 GiB

T_cpu = 1 ms일 때 (가벼운 핸들러, 최소 오버헤드):

5단계: 리틀의 법칙 — 처리량을 통한 검증

T_cpu = 5 ms에 대한 결과를 검증해 봅시다:

\[\lambda = \frac{L}{W} = \frac{136}{0.085} = 1\,600 \text{ req/s}\]

동일한 처리량을 달성하려면 PHP-FPM에 136개의 워커가 필요합니다. 각 워커는 ~40 MB를 차지합니다:

\[136 \times 40 \text{ MB} = 5.44 \text{ GB (워커만)}\]

코루틴:

\[136 \times 2 \text{ MiB} = 272 \text{ MiB}\]

절약된 ~5.2 GB는 캐시, DB 커넥션 풀, 또는 더 많은 요청 처리에 활용할 수 있습니다.

요약: 코루틴이 이점을 제공하는 경우

조건 코루틴의 이점
무거운 프레임워크, localhost DB (T_io ≈ 0) 최소 — 작업 부하가 CPU-bound
무거운 프레임워크, 클라우드 DB (T_io = 80 ms) 보통 — 동일 처리량에서 ~20배 메모리 절약
가벼운 핸들러, 클라우드 DB 최대 — 처리량 최대 13배 증가, ~20배 메모리 절약
마이크로서비스 / API Gateway 최대 — 거의 순수 I/O, 단일 서버에서 수만 req/s

결론: 전체 요청 시간에서 I/O 비중이 크고 CPU 처리가 가벼울수록 코루틴의 이점이 커집니다. IO-bound 애플리케이션(현대 웹 서비스의 대다수)의 경우, 코루틴은 동일한 CPU를 몇 배 더 효율적으로 활용하면서도 훨씬 적은 메모리를 소비할 수 있습니다.

실용적 참고사항

결론

I/O 작업이 지배적인 일반적인 현대 웹 애플리케이션에서 비동기 실행 모델은 다음을 가능하게 합니다:

바로 이러한 시나리오에서 비동기의 장점이 가장 명확하게 나타납니다.


추가 참고자료


참고 문헌