용량 한계에서의 우아한 동작
원문은 Nelson Elhage님이 에 게재했습니다. 이 블로그 구독하기
서비스 하나가 있다고 가정해 보자. 세부 사항은 일단 넘어가고, 이 서비스가 외부로부터 요청을 받아 그에 대응하는 어떤 동작을 수행한다고 전제하자. 그 요청은 HTTP 요청일 수도, RPC일 수도, 혹은 네트워크 계층에서 라우팅해야 할 인입 패킷일 수도 있다. 더 구체적인 이야기는 나중에 하기로 하자.
그 성능에 대해 우리는 무엇을 말할 수 있을까?
우리가 아는 것은 요청을 받아 처리한다는 것뿐이다. 이것만으로도 유용한 지표 두 가지를 이야기할 수 있다.
- 요청률(request rate)에 대해 이야기할 수 있다. 단위 시간당 들어오는 요청의 양이다. 보통 “초당 요청 수”로 측정하지만, 경우에 따라서는 개별 요청보다 전체 데이터 양이 더 중요하므로 “초당 바이트 수”로 이야기하기도 한다(혹은 두 가지를 함께 본다).
- 그 중 일부(최상의 경우 전부)는 성공적으로 처리된다. 이 성공적으로 처리된 작업량 역시 단위 시간당 비율로 이야기할 수 있으며, 이를 처리량(throughput)이라 부른다.
이제 구체적인 예시를 들어볼 만한 시점이다. 앞으로 논의 전반에 걸쳐 계속 참조할 예시다. 내가 잘 아는 시스템을 바탕으로 두 가지 시스템 스케치를 골라봤지만, 아마 익숙하게 느낄 만한 꽤 일반적인 원형들이다.
첫 번째 예시는 HTTP 웹 애플리케이션이다. 구체성을 위해 비교적 흔한 특정 아키텍처로 구축된 웹 서비스를 생각해 보자. 리버스 프록시로 동작하는 nginx 웹 서버 앞에 Python 워커 프로세스 풀이 있고, 각 워커는 애플리케이션 코드를 담고 백엔드 데이터베이스를 사용하는 형태다.
이런 서비스라면 “요청률”은 nginx에 도달하는 HTTP 요청의 비율로, “처리량”은 “성공한 요청의 비율” 정도로 측정할 것이다. 여기서 성공이란 예컨대 “HTTP 200 응답을 받았다”는 의미다.
두 번째 사례는 TCP/IP 라우터 또는 방화벽이다. 이 서비스는 둘 이상의 네트워크 사이에 위치해, 어느 인터페이스로든 들어오는 패킷을 받아 어디로 포워딩할지 결정한 뒤 다른 인터페이스로 내보낸다.
이 시스템에서는 “요청률”을 초당 수신 패킷 수와 초당 바이트 수 두 가지로 본다. “작고 많은 패킷”과 “크고 적은 패킷” 영역에서 특성이 달라질 수 있기 때문이다. 여기서 처리량은 초당 성공적으로 포워딩된 패킷(또는 바이트) 수다.
두 지표를 정의했으니, 이제 성능을 추상적으로 둘 사이의 관계, 즉 요청률의 함수로서의 처리량으로 이야기할 수 있다. 다시 말해 트래픽이 늘어날 때 우리가 수행하는 유용한 작업량이 어떻게 달라지는가 하는 문제다.
순수한 사유의 완벽한 세계라면 모든 요청은 항상 성공적으로 처리되어 요청률과 처리량 사이에 1:1 관계가 성립할 것이다.

그러나 타락한 현실 세계에서 우리는 유한한 양의 물리적 하드웨어 위에서 시스템을 돌리며, 따라서 시스템은 유한한 용량(capacity)을 갖는다. 시스템 설계와 가용한 하드웨어가 주어졌을 때 달성 가능한 최대 처리량이 그것이다. 그렇다면 더 현실적인 목표는 용량 한계까지는 선형 관계를 유지하다가, 그 이후에는 포화(saturation) 상태에 들어가는 것이다. 추가 요청은 실패하지만 처리량을 해치지는 않는 영역이다.

모든 시스템에는 한계가 있다는 점을 기억할 가치가 있다. 운 좋게 그 한계가 충분히 커서 사실상 무한대로 간주할 수 있는 경우도 있지만, 한계는 항상 존재한다. 문서화된 용량 한계가 없는 시스템도 한계가 없는 것이 아니라, 그 한계가 무엇인지 알려주지 않을 뿐이다.
하지만 현실은 한층 더 가혹하다. 세심한 설계와 튜닝 없이는 대부분의 시스템이 위 그래프보다 훨씬 더 나쁘게 동작한다. 용량에 도달한 뒤 추가 요청이 어떤 식으로든 시스템에 과부하(overload)를 일으켜, 유용한 처리량으로 이어지지 않으면서 귀중한 자원을 소모하고, 그 결과 다음과 같은 형태가 나타난다.

혹은, 더 나쁜 경우에는 다음과 같다.

시스템이 높은 요청률을 받으면서도 용량보다 훨씬 낮은 처리량을 내는 이 상태를 설명하는 용어는 여러 가지가 있다. 어떤 용어를 쓰는지는 장애 모드의 세부 사항에 따라 다르지만, 나는 이 영역을 설명하는 포괄적인 용어로 혼잡 붕괴(congestion collapse)라는 말을 즐겨 쓴다. 이 용어가 네트워킹이라는 특정 맥락 밖에서 널리 쓰이는지는 잘 모르겠지만, 보통은 쉽게 이해되는 편이다.
특정 시스템에서 혼잡 붕괴를 해결하는 방법은 세부 사항과 구체적인 문제에 따라 크게 달라지지만, 자주 반복되는 일반적인 패턴들이 몇 가지 있다.
경합과 승인 제어
시스템이 고부하에서 형편없이 동작하는 가장 흔한 이유는 공유 자원에 대한 경합(contention)이다. 여기서는 여러 프로세스가 어떤 자원을 함께 쓰려고 시도하고, 그 상호작용으로 인한 오버헤드 때문에 동시성을 높일수록 해당 자원의 전체 유효 처리량이 오히려 떨어지는 상황을 의미한다. 이런 경합 때문에 성능이 저하될 때 우리는 이를 스래싱(thrashing)이라 부르곤 한다.
많은 자원이 경합을 겪지만, 흔한 예시는 다음과 같다.
- 실행 중인 프로세스가 물리적 CPU보다 훨씬 많으면 컨텍스트 스위칭과 스케줄러 오버헤드가 늘어나 처리량이 감소한다.
- 캐시(예컨대 데이터베이스의 인메모리 디스크 캐시)가
k개의 동시 요청에 필요한 데이터를 캐시할 만큼 크다면,k개보다 많은 동시 요청을 처리하려 할 때 캐시가 서로 다른 요청의 데이터 사이에서 스래싱하며 히트율이 급락해 성능이 빠르게 떨어진다. - 많은 낙관적 동시성 제어 시스템 — 락 프리 알고리즘 다수를 포함해 — 은 고부하에서 심각하게 성능이 저하되며, 최악의 경우 어떤 클라이언트도 진전을 이루지 못하는 상태에 빠진다. 각 클라이언트가 많은 작업을 하고 있지만 아무도 진전을 못 하는 이런 최악의 장애를 라이브락(livelock)이라 부른다.
nginx→python→데이터베이스 아키텍처의 경우, 문제가 되는 경합이 발생하기 쉬운 지점이 여러 곳 있다.
- 워커 프로세스가 메모리를 너무 많이 쓰면 서버는 메모리 압박을 받아 페이징 또는 스와핑으로 성능을 잃는다.
- 마찬가지로 서버 CPU 수보다 훨씬 많은 프로세스를 돌리면 과도한 컨텍스트 스위칭과 캐시 압박이 발생할 수 있다.
- 스레드 기반 Python 서비스에서는 어느 지점을 넘어서면 GIL 경합이 처리량을 떨어뜨릴 수 있다.
- 데이터베이스는 매우 흔하게 혼잡 붕괴를 겪으며, 따라서 애플리케이션 서버 자체는 건강하더라도 동시 요청이 너무 많으면 데이터베이스 처리량이 떨어진다.
내가 알기로 “혼잡 붕괴”라는 용어는 원래 네트워킹 맥락에서 만들어졌으므로, 네트워크 라우터가 이런 장애에 취약한 것은 놀랍지 않다. 몇 가지 구체적인 메커니즘은 다음과 같다.
- WiFi용 RF 대역폭이나 클래식 이더넷 같은 공유 물리 매체를 여러 송신자가 사용하면, 여러 노드가 동시에 “서로 말을 가로막으며” 사용 가능한 통신 시간이 손실되어 가용 대역폭이 깎인다.
- 네트워크가 패킷을 드롭해야 하는 상황에서 클라이언트의 재시도 동작이 잘못 설정되어 있으면 시도되는 전체 트래픽 양이 오히려 증가해 폭주하는 피드백 루프가 발생할 수 있다.
- 소프트웨어 라우터가 인입 패킷을 알리기 위해 인터럽트를 받는다면, 들어오는 패킷 알림을 처리하는 데 가용한 CPU 시간을 모두 써버리고 정작 라우팅 작업은 하나도 수행하지 못하는 인터럽트 라이브락 상태에 빠질 수 있다.
경합을 해결하는 기본 전략은 의도적으로 동시성을 경합이 심하지 않은 수준으로 제한하는 것이다. 큰 틀에서 보면, 들어오는 요청을 담는 요청 큐와 가용 용량이 생겼을 때만 요청이 큐를 떠나 처리를 시작하도록 허용하는 승인 제어기(admission controller)를 추가해 이를 구현할 수 있다. “승인 제어(admission control)”1라는 용어는 대략 이런 의미로 일부 데이터베이스와 통신 시스템에서 쓰이지만, 나는 이 넓은 패턴에 해당하는 모든 기법을 일반화해 그렇게 부르는 편이다.

승인 제어 정책은 다양하다. 가장 단순하게는 최대 동시성에 대한 단일 정적 값을 계산해 세마포어로 처리 중인 요청을 제한할 수 있고, 다른 극단에서는 시스템 내 병목 자원에 대한 모델과 각 인입 요청에 필요한 자원에 대한 모델을 모두 갖춘 정교한 게이팅 에이전트를 둘 수도 있다.
Python HTTP 서비스의 경우, 가장 단순한 형태의 승인 제어는 호스트당 정확히 N개의 워커 프로세스만 돌리도록 설정하는 것일 수 있다. N을 신중하게 선택하면 이런 제한만으로 CPU와 메모리 스래싱을 대부분 해결할 수 있다.
더 정교한 설계에서는 각 노드가 로드 밸런서에 자신의 상태를 전달할 수 있다. 이 판단이 가용한 로컬 자원을 기반으로 한다면 이를 승인 제어의 한 형태로 볼 수 있다.
어느 쪽이든 애플리케이션 서비스 앞에 명시적인 큐를 둔다고 생각하지는 않지만, 로드 밸런서는 종종 내부 큐를 유지하고, 네트워크 리슨 큐와 소켓 버퍼가 추가로 대기 중인 요청을 위한 암묵적 큐 역할을 한다.
네트워킹 시스템에서는 승인 제어를 주로 네트워크의 물리 계층을 보호하는 것으로 생각한다. 공기, 구리선, 광섬유 케이블 등 전송 매체는 어쨌든 최대 용량을 가지며, 하드웨어는 그 매체가 감당할 수 있는 속도로만 패킷을 전송해야 한다. 이 용량을 넘는 버스트를 흡수하기 위해 NIC와 라우터 위 소프트웨어는 모두 패킷 큐를 유지하며, 용량이 생기는 대로 네트워크로 빼낸다.
경합이라는 현상은 적어도 하나의 중요한 함의를 갖는다. 시스템이 어떤 핵심 자원에서 용량 한계에 도달했다면, 동시성을 높이는 것은 처리량을 돕기보다 해칠 가능성이 높다. 최악의 경우, 병목이 아닌 자원에 용량을 추가하는 것이 오히려 성능을 떨어뜨릴 수 있는데, 제한된 자원에 대한 경합을 더 키우기 때문이다. 구체적으로, 애플리케이션이 데이터베이스에 병목되어 있다면 동시 프로세스 수를 늘리는 것이 데이터베이스를 더 깊은 수렁으로 몰아 전체 처리량을 떨어뜨릴 수 있으며, 상황을 개선하지 못한다.
큐는 비어 있거나 가득 차 있다
적절한 승인 제어를 갖추면 어떤 요청률에서도 경합을 제한하고 내부 처리량을 높게 유지할 수 있다. 하지만 문제가 거기서 끝나지는 않는다. 인입 요청률이 최대 처리량보다 지속적으로 높다면, 이 요청들은 기본적으로 요청 큐에 무한정 쌓이게 된다.

이 상황에 대해 몇 가지 관찰할 점이 있다.
- 여기서 처리량은 큐 크기와 무관하다. 큐가 짧든 길든(비어 있지만 않다면) 우리는 같은 속도로 메시지를 꺼내고 같은 속도로 메시지를 처리한다. 이 사실이 중요하다. 큐는 최대 용량을 늘릴 수 없다.
- 큐가 커질수록 전체 지연도 커진다. 큐에 N개의 메시지가 있으면 이를 처리하는 데 N/Y초가 걸리고, 따라서 FIFO 동작을 가정하면 요청이 큐의 왼쪽에서 오른쪽으로 이동하는 데도 N/Y초가 걸린다.
- 큐가 커지다 보면 결국 다음 중 하나 이상이 일어난다.
- 큐가 설정된 최대 큐 크기에 도달해 메시지를 드롭해야 한다.
- 큐에 설정된 최대 크기가 없더라도 서버의 가용 저장 공간(일반적으로 디스크나 메모리)을 고갈시킨다.
- 지연이 상승하면 결국 우리 서비스 상위에 있는 클라이언트들이 타임아웃을 일으키고, 내부적으로는 결국 성공할 수도 있는 요청조차 실패한 것으로 처리하기 시작한다.
많은 시스템에서 과부하 시 우리가 실제로 오류를 처음 관찰하게 되는 지점은 설정된 큐 크기 제한으로 인한 요청 드롭이라는 첫 번째 증상이다. 이런 일이 생기면 큐 크기를 늘려 당장의 문제를 고치고 싶은 유혹이 든다. 하지만 우리의 문제가 진정한 용량 문제 — 총 처리량이 요청률보다 작은 경우 — 라면 그렇게 해도 상태가 회복되지 않는다. 큐는 최대 처리량을 늘릴 수 없다.
대신 세 번째 문제 — 높은 지연으로 인한 타임아웃 — 를 겪는다면, 관련 타임아웃을 넘겨 우리 시스템에 머물러 온 요청은 “이미 실패한” 것이므로 더 이상 시간을 쓸 가치가 없다는 관찰을 할 수 있다. 그러면 요청 큐 내부나 요청을 꺼낼 때 명시적인 검사를 추가해 그런 요청에 더 이상 작업을 들이지 않도록 할 수 있다. 논리는 다음과 같이 작성할 수 있다.
request = queue.pop()
if time.time() - request.arrival_time >= REQUEST_LATENCY_BUDGET:
return_error(request, RequestTimedOut()
else:
process(request)이런 검사는 제대로 수행된다면 상황을 다소 개선할 수 있다. 평균적으로 큐의 dequeue 속도를 enqueue 속도에 맞게 높여 큐가 더 이상 커지지 않게 한다.
하지만 이런 검사 역시 어느 지점 이상으로 큐를 줄일 수는 없다. 요청이 통과하는 데 REQUEST_LATENCY_BUDGET만큼 걸리는 크기가 되면 우리는 큐를 비우는 것을 멈춘다. 요청률이 계속 높게 유지되면, 우리는 정확히 그 크기 주변에서 맴돌며 처리하는 모든 요청에 추가 지연이라는 부담을 지게 된다. 최상의 경우에도 좋은 처리량을 달성하지만 REQUEST_LATENCY_BUDGET만큼의 추가 지연을 안게 되고, 최악의 경우 충분한 헤드룸을 확보하지 못해 요청은 어차피 타임아웃된다.
우리는 이런 시스템을 상시 큐(standing queue)를 가진 시스템이라고 말한다. 건강한 시스템에서는 큐가 일시적인 스파이크를 흡수하고 빠르게 비워지지만, 여기서는 정상 상태에서도 상당한 크기의 큐가 지속되어 원치 않는 지연을 초래한다.
우리의 전형적인 HTTP 웹 서비스 아키텍처에서는 고부하 시 “가장 먼저 알아차리는”, 즉 오류를 시그널링하거나 로그로 남기는 첫 번째 구성 요소가 nginx 프런트엔드인 경우가 매우 흔한데, nginx는 허용할 동시 처리 요청 수에 대해 신중하게 관리되는 제한을 갖고 있다.
하지만 앞서 설명했듯 이 숫자를 단순히 늘리는 것은 좀처럼 실제 해결책이 되지 못하고 문제를 다른 곳으로 밀어낼 뿐이다. 일반적으로 nginx 로그에서 “worker_connections are not enough”를 본다면, 그 설정을 실제로 늘려야 한다는 생각보다는 nginx 뒤쪽 어딘가에 용량 병목이 있다는 것이 나의 첫 번째 추측이다.
nginx 내부와 다양한 네트워크 버퍼에 있는 요청 큐는 매우 암묵적이어서 운영자 입장에서 직접 관찰하기 어려울 수 있다. 내가 좋아하는 한 가지 해결책은 nginx가 요청에 헤더를 추가해 현재 타임스탬프를 담도록 하는 것이다. 요청이 애플리케이션 코드에 도달하면 현재 시각에서 nginx가 기록한 시각을 빼서 큐 지연을 측정할 수 있다.
네트워킹 맥락에서 이 상시 큐 문제는 바로 악명 높은 “버퍼블로트(bufferbloat)” 문제다. 패킷 드롭을 피하려는 시도와 네트워크 처리량보다 더 빠르게 떨어지는 메모리 비용 때문에, 네트워킹 하드웨어는 인입 패킷을 저장해 네트워크 용량에 맞춰 포워딩하기 위해 점점 더 큰 패킷 큐를 추가해 왔다. 하지만 네트워크가 지속적으로 과도하게 사용되면, 이 큐들은 물리적인 선을 더 빠르게 하거나 두껍게 만들 수 없으며, 세심한 관리 없이는 빠르게 상시 큐가 되어 그 안에 대기해야 하는 모든 패킷에 지연을 추가한다!
이 절에서 얻을 두 가지 교훈은 내가 여러분에게 남기고 싶은 가장 중요한 것들이다.
- 큐는 버스트성 부하를 시간에 걸쳐 분산시키는 데 도움이 될 수 있지만, 총합에서의 최대 처리량을 늘릴 수는 없다.
- 부하 상황에서 지속적인 상시 큐가 쌓인다면, 우리 큐는 아무런 이득 없이 지연만 추가하는 셈이며, 그 지연은 잠재적으로 치명적인 수준이 될 수 있다.
무엇이 효과가 있는가
결국 지속적으로 용량을 초과한 상태이고 용량을 추가할 수 없거나 추가하려 하지 않는다면, 해결책은 하나뿐이다. 어떻게든 해야 할 작업을 줄이는 것이다.
나는 이 목표를 위한 두 가지 관련된 전략을 생각하는 편이다.
- 어떻게든 클라이언트에게 요청을 덜 보내달라고 요청해 인입 요청 부하를 줄일 수 있다.
- 요청의 일부를 처리하지 않기로 선택하고, 가능한 한 저렴하게 버린 뒤 남은 요청을 성공적으로 처리할 자원을 확보할 수 있다.
우리는 이 두 전략 중 하나 또는 둘 다를 백프레셔(backpressure)라고 부른다. 어느 쪽이든 들어오는 작업에 대해 어떤 의미에서는 “거꾸로 압박”을 가해 클라이언트가 우리의 용량 한계를 인지하도록 만드는 것이다. 전자의 경우 명시적으로 속도를 늦춰 달라고 요청하는 것이고, 후자의 경우 오류나 드롭된 요청을 받게 하여 클라이언트가 어떻게든 처리하도록 만든다.
가용성과 사용자 경험을 신경 쓰는 서비스 설계자이자 운영자로서, 이런 식으로 문제를 사용자에게 되돌리는 것은 직관에 반하게 느껴질 수 있다. 실패를 인정하거나 책임을 회피하는 것처럼 느껴질 수 있다. 누구도 유효한 요청을 일부러 버리고 싶어 하지 않는다. 하지만 탄력적인 시스템은 필연적으로 어떤 형태의 백프레셔를 필요로 하는데, 몇 가지 이유가 있다.
- 언급했듯이 모든 시스템에는 어떤 형태로든 한계가 있다. 그 한계에 도달했을 때, 우리는 무슨 일이 일어날지에 대해 의도적인 결정을 내리고 가능한 한 신중하고 우아하게 동작하는 것을 선호한다.
- 더 중요하게는 백프레셔가 폐루프 시스템을 만든다는 점이다. 정상적으로 건강한 시스템이 과부하 상태라면, 그 과부하의 직접적인 원인은 누군가 우리에게 너무 많은 트래픽을 보내고 있다는 것이다. 문제를 그들에게 되돌림으로써 문제와 문제의 원인을 더 가깝게 만들고, 실제 해결 가능성을 열어준다.
흐름 제어
시스템과 함께 클라이언트 프로토콜 및/또는 그와 상호작용하는 클라이언트를 공동 설계할 수 있다면, 때로는 수신자가 송신자에게 안전하거나 지원 가능한 트래픽 비율이 무엇인지 직접 신호를 보내는 흐름 제어(flow control) 메커니즘 형태로 백프레셔를 구축할 수 있다.
대부분의 저수준 통신 프리미티브(예: TCP 소켓이나 UNIX 파이프)는 스트림 내의 전송됐지만 아직 처리되지 않은 데이터 양을 제한함으로써 어떤 형태의 흐름 제어를 구현한다. 따라서 프로토콜이 주로 단일 스트림 또는 소수의 스트림을 통해 통신을 수행한다면, 때로는 기반 substrate로부터 기본적인 형태의 흐름 제어를 “물려받을” 수 있다.
명시적인 흐름 제어 메커니즘은 HTTP 웹 서비스에서는 덜 흔하지만, 적어도 취지 차원에서는 그에 해당한다고 볼 수 있는 몇 가지 메커니즘을 떠올릴 수 있다.
- “429 Too Many Requests” HTTP 상태 코드는 2012년경 표준화되어, 서버가 부하 또는 속도 제한 때문에 요청을 거부하고 있음을 명시적으로 신호할 수 있게 하며,
Retry-After헤더로 클라이언트에게 재시도 전 얼마나 기다려야 하는지 알려줄 수 있다. - 일부 HTTP API는 모든 응답에 속도 제한 상태와 요청자가 제한에 얼마나 가까운지 알려주는 헤더를 제공한다.
우리는 종종 TCP 프로토콜의 주 목표가 신뢰성 있고 순서가 보장된 전달이라고 생각하지만, 그 흐름 제어 동작 역시 모든 현대 네트워크의 기능에 매우 중요하다.
TCP는 두 가지 관련된 흐름 제어 문제를 해결해야 한다. 빠른 송신자가 느린 수신 프로세스를 압도하지 않도록 해야 하고, 송신자들이 집합적으로 어떤 네트워크 링크도 압도하지 않도록 해야 한다. 이러한 요구를 관리하기 위해 TCP는 송신자가 한 번에 전송할 수 있는 데이터 양을 추적하는 “윈도우 크기”라는 개념을 사용한다. TCP 송신자는 흐름의 수신자와 네트워크 링크 각각에 대해 가용 용량을 따로 추정하기 위해 두 개의 윈도우 크기를 추적한다.
TCP는 수신 윈도우에 대해 명시적인 흐름 제어 메커니즘을 사용한다. 각 TCP 패킷은 수신 윈도우의 현재 값을 담고 있어, 수신자가 자신이 받아들일 준비가 된 데이터 양을 지속적으로 신호할 수 있게 한다.
로드 셰딩
흐름 제어 메커니즘을 사용할 수 없거나, 클라이언트가 이를 준수하지 않는다면 남은 선택지는 로드 셰딩(load shedding)이다. 우리는 요청 중 일부를 선택해 가능한 한 일찍 버린다(이것은 말 그대로 잊어버리거나, 그렇게 하는 것이 충분히 저렴하다면 오류를 반환하는 것을 의미할 수 있다). 그렇게 함으로써 자원을 확보하고 큐를 비워, 남은 요청들이 성공적으로 처리될 수 있게 한다.
로드 셰딩은 레이트 리미팅(rate limiting)과 관련이 있는데, 레이트 리미팅에서는 각 사용자당 인바운드 요청 수를 제한하고 사용자가 어떤 한도를 넘으면 오류를 반환한다. 일반적으로 내가 보는 구분은 “레이트 리미팅”은 전체 시스템 용량과 무관하게 항상 적용되는 사용자별 제한을, “로드 셰딩”은 시스템 전체가 어떤 용량 한계에 도달했거나 근접했을 때 작동하는 메커니즘을 가리킨다는 것이다. 실제로 많은 맥락에서 용량 초과 상태가 되는 흔한 이유 중 하나가 특정 한 명의 폭주하는 사용자이며, 그래서 두 개념은 밀접하게 관련되고 종종 겹치는 문제를 해결한다.
로드 셰딩을 위해 어떤 요청을 버릴지 고르는 전략은 많다. 흔한 전략으로는 다음과 같은 것들이 있다.
- 무작위 드롭
- 구현이 단순하고 저렴하다. 들어오는 요청을 전혀 파싱하지 않고도, 여러 프런트엔드 구성 요소 간의 조율 없이도 수행될 수 있다. 맥락에 따라서는 “공정하다”고 인식되기도 한다.
- 클라이언트 등급화
- 무료 티어와 유료 티어가 있다면 무료 요청을 우선적으로 버릴 수 있다. 이는 요청에 용량에 대한 입찰가를 담는 스팟 마켓에 이르기까지 여러 방식으로 일반화될 수 있다. 등급화는 일부 사용자에게는 계약상 일정 수준의 서비스가 보장되지만 다른 사용자에게는 그렇지 않은 등 명시적인 SLA가 있는 시스템에서 특히 잘 작동할 수 있다.
- 요청 등급화
- 대신 요청의 유형에 따라 우선순위를 정해, 시간에 덜 민감하거나 나중에 재시도하기 쉬운 요청을 우선적으로 버릴 수 있다. 예컨대 Stripe에서는 고객에게 요금을 청구하는 요청이 과거 결제를 단순히 조회하거나 나열하는 요청보다 훨씬 더 중요하게 여겨졌다. API 서비스가 과도한 부하 상태라면 전자를 우선시하고 후자 중 [일부]를 의도적으로 버렸다.
- 공정한 할당
- 균일한 무작위 드롭은 모든 요청이 드롭될 확률이 동일하다는 의미에서 공정하다. 하지만 이는 요청을 마구 보내도록 하는 인센티브를 만들기도 한다. 한 사용자가 전체 인입 요청의 80%를 차지한다면 전체 처리량의 80%를 가져가게 된다. 따라서 때로는 “공정성”에 대한 더 미묘한 정의에 따라 용량을 할당하는 것이 타당하다 — 예컨대 요청별이 아니라 클라이언트별로 가용 용량을 균등하게 할당하는 것이다.
- 실제로는 이는 종종 추가적인 조율을 필요로 하고 구현이 어려울 수 있으며, 로드 셰더보다 먼저 동작하는 사용자별 레이트 리미팅을 구현하는 것으로 충분한 경우가 많은데, 이는 비슷한 목표를 달성한다.
HTTP 웹 서비스에서는 매우 다양한 로드 셰딩 및 레이트 리미팅 기법을 볼 수 있다.
“Python plus 데이터베이스” 애플리케이션에서 흔한 패턴 중 하나는, 요청을 처리하는 데 비교적 많은 애플리케이션 코드를 실행하거나 많거나 비용이 큰 데이터베이스 쿼리를 수행하는 등 요청 처리가 비교적 무거운 경우가 많다는 점이다. 이런 비용 때문에 종종 다단계 로드 셰딩 시스템으로 기울게 된다.
- 우리는 종종 서비스 앞에 고성능 레이트 리미터를 두어, 아마도 nginx나 CDN 또는 클라우드 서비스를 이용해 가장 높은 버스트성 트래픽(명시적인 DDoS 공격까지 포함해 잠재적으로)을 무거운 애플리케이션 코드에 도달하기 전에 거부한다.
- 그런 다음 트래픽이 애플리케이션에 도달하면, 애플리케이션의 인증 및/또는 라우팅 로직을 이용해 요청을 분류함으로써 더 미묘한 제한을 추가로 구현할 수 있다. 데이터베이스에 접근하지 않고 이를 수행할 수 있다면 여전히 요청을 처리하는 것보다 훨씬 저렴할 수 있다.
- 또는 애플리케이션 앞에 “API Gateway”를 두어 라우팅, 인증 및 요청 인식 레이트 리미팅의 조합을 애플리케이션 코드 밖에서 전적으로 구현할 수도 있다.
나의 전 동료 Paul Tarjan은 Stripe에서 훌륭한 글을 작성해 매우 유사한 프런트엔드/애플리케이션 코드/데이터베이스 아키텍처를 사용해 구현된 레이트 리미팅 및 로드 셰딩 결정과 기법을 설명했다.
TCP 수신자는 자신의 용량을 알고 송신자에게 그냥 느리라고 말하면 되지만, 네트워크 용량을 관리하고 추정하는 것은 더 어렵다. 링크를 공유하는 서로 다른 흐름과 노드들 사이의 상호작용을 모두 포함하기 때문이다.
전통적으로 TCP는 혼잡을 감지하기 위해 드롭된 패킷을 감지하는 데 의존한다. 드롭된 패킷은 네트워크 링크가 과부하 상태이며 송신자가 속도를 늦춰야 한다는 의미로 가정된다. 이 설계는 본질적으로 로드 셰딩 메커니즘(네트워크가 패킷을 드롭하는 것)을 가져와 흐름 제어의 한 형태로 재활용한다.
역사적으로 네트워크 장비는 부하 시 패킷을 드롭하기 위해 단순한 “테일 드롭” 알고리즘을 사용했다. 이 선택은 라우터 자체의 부하를 관리하는 데는 훌륭하게 동작하지만, 네트워크 전체의 TCP 흐름 제어 동작이라는 창발적 특성에서는 매우 좋지 않은 것으로 드러났다. 따라서 현대 라우터는 어떤 패킷을 드롭할지, 즉 어떤 흐름에 속도를 늦추라고 신호를 보낼지에 대해 전략적인 결정을 내리는 “능동적 큐 관리”를 구현한다. 현대의 CoDel 알고리즘은 버퍼블로트 문제를 완전히 해결하지는 못했지만 크게 완화하는 데 기여했다.
또한 TCP 명시적 혼잡 알림(Explicit Congestion Notification) 확장을 통해 라우터는 데이터를 드롭하지 않고도 혼잡이 발생하고 있음을 나타내는 플래그를 설정할 수 있다. 네트워크 흐름의 모든 참여자가 ECN을 지원한다면, 능동적 큐 관리를 수행하는 라우터는 실제로 패킷을 드롭하지 않고도 스트림에 속도를 늦추라는 신호를 보내기 위해 “혼잡 발생” 플래그를 설정할 수 있다.
능동적 큐 관리는 라우터가 패킷에 대해 신중한 결정을 내릴 만큼 충분한 여유 용량을 갖고 있어야 한다. 대신 인입 패킷 속도가 라우터 자체의 처리 능력을 압도할 위험이 있을 만큼 높다면, 라우터 파이프라인의 더 앞단, 어쩌면 NIC 하드웨어 수준에서조차 패킷을 드롭해야 할 수도 있다.
배틀쇼트
제대로 동작하려면 로드 셰딩이나 레이트 리밋은 시스템이 아직 건강한 동안, 즉 심각하게 과부하되기 전에 작동해야 한다. 하지만 시스템이 아직 건강하다면 보통은 약간의 추가적인 한계 요청 정도는 여전히 처리할 수 있으며, 따라서 로드 셰더는 반사실적으로 성공했을 요청 중 일부를 필연적으로 거부하거나 드롭하게 된다.
이는 삼키기 어려운 결정일 수 있다. 아무도 일부러 요청을 드롭하고 싶어 하지 않는다. 하지만 보통은 통제된 방식으로 의도적인 시점에 요청을 드롭하는 것이 시스템이 과부하 상태에 빠지도록 내버려 두는 것보다 낫다.
하지만 일부 중요한 시스템에서는 실패 비용이 너무 높아, 어떤 상황에서는 일부러 보호 장치를 비활성화하고 요청을 일부러 드롭하거나 서브시스템을 비활성화하는 대신 위험을 감수하기로 결정할 수도 있다. 이러한 모드로 들어가는 스위치를 흔히 군용 장비에서 전투 교전 중에 퓨즈를 우회하거나 구리 바로 “쇼트”시키는 관행에서 따온 “배틀쇼트(battleshort)”라고 부른다. 논리는 실전에서는 기동 능력이나 반격 능력을 잃는 것이 차량 전기 하위 시스템의 과열이나 손상 위험보다 더 큰 위험으로 판단된다는 것이다.
용량 추가
이 글의 주된 목표는 지속적으로 어떤 용량 한계에 도달했거나 그 이상인, 즉 과부하 상태인 시스템을 이해하고 관리하기 위한 기법과 프레임워크를 탐구하는 것이다. 실제로 시스템이 일상적으로 과부하 상태라면, 과부하 상태를 더 우아하게 만드는 개입을 하는 대신(또는 그에 더해) 용량을 추가해 문제를 해결하고 싶을 것이다. 떠나기 전에 시스템 용량을 늘리는 것에 대해 몇 가지 덧붙이고 싶다.
첫째, “용량 추가”와 “과부하를 우아하게 처리하기”는 종종 어느 하나를 택하는 문제가 아니다. 실제 시스템은 두 가지를 어느 정도 함께 갖추면 이득을 보는 경향이 있다. 예컨대 용량을 추가하기 위해 오토스케일링을 구현하면서도, 그 용량이 온라인이 되기를 기다리는 동안에도 우아하게 동작하도록 로드 셰더를 사용할 수 있다. 혹은 “정상적인” 부하를 처리할 만큼 용량을 프로비저닝하면서도, 가끔 기준 부하의 10배를 보내오는 버그가 있거나 잘못 설계된 프로세스에 대처하기 위해 여전히 백프레셔나 로드 셰딩 기법이 필요할 수 있다.
둘째, 용량을 추가할 때는 병목 자원(들)을 식별해 확장하는 것이 중요하다는 점을 강조하고 싶다. 잘못된 자원을 확장하면(예: 데이터베이스에 병목이 걸린 서비스에 애플리케이션 CPU를 추가하는 경우) 오히려 문제를 더 악화시킬 수 있다. 그리고 이런 일이 일어나는 이유는 경합과 상시 큐에 관해 여기서 논의한 주제들과 밀접하게 관련되어 있다. 시스템은 더 작은 시스템들이 프랙탈처럼 모여 이루어져 있으며, 개별 하위 시스템은 서로 다르고 미묘한 방식으로 이러한 장애 모드에 취약할 수 있다. 따라서 우리의 목표가 항상 과잉 프로비저닝된 상태가 될 때까지 용량을 추가하는 것이라 하더라도, 용량 초과 시 시스템이 어떻게 동작하는지에 대한 이러한 교훈을 이해하는 것이 종종 도움이 된다.
결론
“시스템은 용량 한계에서 혹은 그 이상에서 어떻게 동작하는가?”라는 질문은 입문용 소프트웨어 엔지니어링 자료나 초기 시스템 설계에서 종종 간과되지만, 내 경험상 이는 대규모의 복잡한 시스템을 다루는 경험을 사실상 규정하는 질문이다.
특정 시스템 하나하나에 대해서는 시스템의 세부 사항과 용량 한계가 매우 중요하다. 하지만 나는 또한 많은 공통된 경향과 주제가 있으며, 세부 사항을 몇 단계 추상화해 보면 높은 수준에서 많은 유사점을 발견할 수 있다는 것도 알게 되었다. 그리고 그러한 패턴을 정말로 내면화하고 알아볼 수 있다면, 어떤 세부 사항이 중요한지 식별하고 맥락 속에 배치하는 데 귀중한 길잡이가 될 수 있다.
이 글은 이 질문을 둘러싸고 내 머릿속에 존재하는 주제와 개념의 지형을 요약하고, 그러한 패턴과 경향을 공유 가능한 방식으로 풀어내려는 시도다. 새로운 애플리케이션에서 이러한 문제들을 처음 마주하는 엔지니어들이 이를 맥락 속에 두고, 가치 있을 수 있는 선행 연구나 개념에 대한 단서를 찾는 데 도움이 되기를 바란다. 여러분에게 와닿았는지 알려 달라.
위키백과에는 네트워킹과 인터커넥트 분야에서 이 용어에 대한 많은 인용이 있다. 나는 데이터베이스 맥락에서 처음 접했지만 그만큼 좋은 인용을 찾지 못했다. 이 용법의 한 예로 이 논문 14페이지를 참고할 수 있다. ↩︎
전투 바지(fighting trousers)와는 구별된다. ↩︎
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기