백엔드

처리 속도보다 빠르게 채워지는 워커 큐를 위한 백프레셔

생산자가 작업자보다 더 빨라지면, 무한 큐는 부하를 흡수하는 것이 아니라 충돌을 지연시킬 뿐입니다. 역압력이 시스템을 계속 유지하는 방법은 다음과 같습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

생산자와 작업자 사이의 큐는 충격 흡수 장치처럼 느껴집니다. 작업이 폭주하면 큐가 이를 담아두고, 작업자는 자신의 속도에 맞춰 이를 처리합니다. 그러한 직관은 평균 도착률이 평균 처리율보다 낮게 유지되는 동안에만 유효합니다. 생산자가 충분히 오랫동안 작업자를 앞지르는 순간, 무한 큐는 더 이상 아무것도 흡수하지 못하게 됩니다. 큐는 무한정 커지고, 메모리나 큐 저장소가 고갈되며, 전체 시스템이 한 번에 무너집니다. 해결책은 큐를 더 크게 만드는 것이 아닙니다. 바로 역압(backpressure)입니다. 이는 시스템이 충돌할 때까지 과부하를 숨기는 대신 생산자에게 되돌려 보내는 유한 큐입니다. 이 게시물에서는 무한 큐가 실패하는 이유, Go에서 역압이 어떤 모습인지, 그리고 '아니오'라고 말하는 법을 배우는 데 따르는 트레이드오프를 보여줍니다.

무제한 큐가 충돌을 흡수하는 대신 지연시키는 이유

큐를 물이 쏟아져 들어오고 빠져나가는 양동이라고 생각해 보세요. 평균적으로 배출 속도가 유입 속도보다 최소한 빠르다면, 수위는 일정하게 유지되고 폭증은 완화됩니다. 평균적으로 유입 속도가 조금이라도 더 빠르다면, 수위는 영원히 상승합니다. 영구적인 불균형을 해결할 수 있는 양동이 크기는 없으며, 넘치기까지 기다리는 시간을 바꾸는 크기만 있을 뿐입니다.

무제한 큐는 오버플로 지점을 제거하는데, 이는 기능처럼 들리지만 실제로는 실패입니다. 불균형은 여전히 존재하므로, 백로그는 사용 가능한 모든 메모리를 소모하거나 큐 저장소를 하드 한계까지 채울 때까지 증가합니다. 그 전에 두 가지가 망가지는데, 둘 다 깔끔한 거부보다 더 나쁩니다.

첫 번째는 지연 시간입니다. 앞에 백만 개의 항목이 있는 큐에 들어간 작업은 백만 개의 항목이 처리될 때까지 기다립니다. 워커가 해당 작업에 도달할 때쯤이면, 그 작업을 생성한 요청은 종종 시간 초과되었거나, 사용자는 떠났거나, 결과는 오래된 것이 되어버립니다. 이제 여러분은 부족한 워커 용량을 아무도 기다리지 않는 답을 계산하는 데 사용하고 있으며, 이는 배출 속도를 더욱 늦추고 백로그를 심화시킵니다. 이는 저절로 악화되는 피드백 루프입니다.

두 번째는 연쇄 장애입니다. 큐 저장소의 메모리가 마침내 고갈되면, 정중하게 실패하지 않습니다. 그것은 자신을 호스팅하는 프로세스를 중단시키고, 큐에 넣으려는 모든 생산자를 차단하며, 동일한 머신이나 동일한 커넥션 풀을 공유하는 관련 없는 서비스까지 무너뜨릴 수 있습니다. 단 하나의 느린 워커가 시스템 전체의 중단으로 이어집니다. 무제한 큐는 붕괴를 막지 못했고, 단지 지연시키고 더 크게 만들었을 뿐입니다.

백프레셔는 되돌려 보내는 유한 큐입니다

백프레셔란 다운스트림 단계가 처리 속도를 따라잡지 못할 때, 압력이 중간에 보이지 않게 쌓이지 않고 업스트림의 생산자에게 전달되는 속성입니다. 이를 구축하는 가장 간단한 방법은 고정 용량을 가진 큐를 사용하는 것입니다. 큐가 가득 차면 생산자는 인큐(enqueue)할 수 없으며, 바로 그 자리에서 결정을 내려야 합니다. 그 결정이 바로 핵심입니다. 그것이 바로 부하가 시스템을 멈추게 하는 대신, 시스템이 의도적으로 부하를 줄이는 방법을 선택하는 지점입니다.

Go에서는 기본 요소가 이미 백프레셔를 인지합니다. 버퍼 채널(buffered channel)은 유한 큐이며, 가득 찬 채널에 전송하면 워커(worker)가 공간을 만들 때까지 전송자가 블록됩니다. 그 블록이 가장 직접적인 형태의 백프레셔입니다.

// A buffered channel of capacity N is a bounded queue. When it holds N jobs,
// the next send blocks until a worker receives one. The block is the
// backpressure: the producer cannot outrun the workers.
jobs := make(chan Job, 256)

// Producer. This send parks the goroutine if the buffer is full, so the
// producer's own rate is capped by how fast workers drain.
jobs <- job

생산자가 속도를 늦출 여유가 있을 때, 예를 들어 반대편에서 기다리는 사용자가 없는 내부 배치 로더와 같은 경우 블로킹 전송은 올바른 동작입니다. 작업자에 맞춰 생산자의 속도를 늦추는 것이 바로 원하는 바입니다. 왜냐하면 두 속도가 이제 결합되어 백로그가 버퍼를 넘어 증가할 수 없기 때문입니다.

하지만 마감 시간이 있는 사용자나 업스트림 호출자가 있을 때 블로킹 전송은 잘못된 동작입니다. 작업자가 뒤처졌다는 이유로 HTTP 핸들러를 무기한 블로킹하는 것은 무한정 대기를 큐에서 커넥션 풀로 옮기는 것일 뿐이며, 메모리 대신 커넥션이 고갈됩니다. 누군가 기다리고 있을 때는 블로킹하기보다는 빠르게 거부하는 것이 좋습니다. 그러면 호출자는 시스템이 포화 상태라는 것을 즉시 알게 되고 백오프하거나 장애 조치할 수 있습니다.

// Non-blocking enqueue for request paths. If the buffer is full, do not wait.
// Reject now so the caller gets a fast, honest "try later" instead of a hang.
select {
case jobs <- job:
    // accepted
default:
    return errQueueFull // surface as HTTP 429 or 503 with Retry-After
}

생산자를 차단하거나 작업을 거부하는 두 가지 옵션은 역압(backpressure)의 두 가지 측면입니다. 차단은 속도를 연동시킵니다. 거부는 초과분을 버립니다. 생산자별로 어느 것을 선택할지는 대기 중인 것이 있는지에 따라 달라지며, 실제 시스템은 보통 서로 다른 곳에서 두 가지 방법을 모두 사용합니다.

작업자가 다운스트림에 과부하를 주지 않도록 동시성 제한하기

생산자는 유한 큐에 작업을 넣고, 고정된 작업자 풀이 동시성 제한 하에 이 큐에서 작업을 꺼냅니다. 큐가 가득 차면 생산자의 속도를 늦추거나 429 코드로 거부하고, 마감 기한이 지난 오래된 작업은 폐기되며, 풀은 다운스트림 부하를 poolSize로 제한합니다.

큐를 유한하게 만들면 생산자로부터 시스템을 보호할 수 있습니다. 작업자도 제한해야 합니다. 왜냐하면 작업자는 그 아래에 있는 시스템, 보통 데이터베이스, GPU 또는 다른 서비스에 대한 생산자이기 때문입니다. 흔한 실수는 작업마다 goroutine을 생성하는 것입니다. 이 방법은 깔끔해 보이고 큐를 완전히 제거하지만, 무한한 증가를 다른 곳으로 옮길 뿐입니다. 들어오는 작업 1만 개가 1만 개의 goroutine이 되어 데이터베이스를 한꺼번에 강타하게 되고, 결국 데이터베이스가 다운됩니다.

고정된 작업자 풀이 상한선 역할을 합니다. 정해진 수의 작업자를 시작하고, 각 작업자는 유한 큐에서 작업을 가져옵니다. 이 작업자의 수가 하위 리소스에 도달할 수 있는 최대 동시성입니다. 풀 크기는 유입된 트래픽 양에 따른 우연의 결과가 아니라, 다운스트림의 용량에 맞춰 의도적으로 설정하는 한계치가 됩니다.

// Fixed worker pool. poolSize is the hard ceiling on concurrent work hitting
// the downstream resource. It does not grow with load, which is the point.
func startPool(poolSize int, jobs <-chan Job) {
    var wg sync.WaitGroup
    for i := 0; i < poolSize; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                process(job) // the only place downstream load is generated
            }
        }()
    }
    wg.Wait()
}

이제 두 가지 경계가 함께 작동합니다. 버퍼링된 채널은 대기할 수 있는 작업의 양을 제한하고, 풀은 실행할 수 있는 작업의 양을 제한합니다. 다운스트림의 총 부하는 최대 poolSize개의 동시 작업과 최대 버퍼 용량의 큐를 더한 것이며, 트래픽 스파이크가 발생해도 두 수치 모두 변하지 않습니다. 스파이크는 인큐 경로에 도달하여 거기서 거부되거나 속도가 느려지며, 데이터베이스에 홍수처럼 도달하지 않습니다. 풀 크기는 넉넉하게 느껴지는 어림수가 아니라, 예를 들어 데이터베이스의 연결 풀 제한이나 소유한 GPU 수와 같은 다운스트림 용량에 따라 선택하세요.

이미 너무 오래되어 의미 없는 작업은 버리기

큐와 풀의 크기를 제한하면 시스템을 유지할 수 있지만, 지속적인 과부하 상태에서는 들어온 작업이 워커에 도달할 때쯤에는 이미 오래된 상태일 수 있습니다. 요청의 예산이 5초인데 작업이 8초 동안 대기했다면, 그 작업을 실행하는 것은 순전한 낭비입니다. 호출자가 이미 포기한 결과를 생성하는 데 워커 시간을 소비하게 되며, 이는 아직 기회가 있는 새로운 작업의 처리 용량을 빼앗는 것입니다.

각 작업에 마감 시간을 두면 이 문제를 해결할 수 있습니다. 작업에 반드시 완료되어야 하는 시간을 기록하고, 워커가 비용이 많이 드는 부분을 시작하기 전에 해당 마감 시간을 확인하도록 합니다. 마감 시간이 지났다면, 작업을 버리고 다음으로 넘어갑니다. Go에서는 마감 시간이 있는 context가 관용적인 전달 수단이며, 이를 통해 이미 진행 중인 작업을 취소할 수도 있습니다.

type Job struct {
    Payload  []byte
    Deadline time.Time
}

func process(job Job) {
    // Skip work that can no longer be useful. Under overload this is what
    // keeps workers spending their time on jobs someone still wants.
    if time.Now().After(job.Deadline) {
        metrics.Expired.Inc()
        return
    }
    ctx, cancel := context.WithDeadline(context.Background(), job.Deadline)
    defer cancel()
    doExpensiveWork(ctx, job.Payload)
}

오래된 작업을 버리는 것은 양보다는 시간을 목표로 하는 역압(backpressure)의 한 형태입니다. 이는 일부 작업이 이미 손실되었음을 인정하고, 처리되는 과정에서 리소스를 소비하도록 내버려두지 않는 것입니다. 트래픽 급증 시에, 이것은 포화 상태의 시스템이 시체들의 백로그를 힘겹게 처리하는 대신 여전히 유효한 요청들을 계속해서 처리할 수 있게 해줍니다.

무한 큐와 역압력의 직접 비교

두 설계의 차이점은 중요한 경우, 즉 도착률이 의미 있는 시간 동안 처리율을 계속 초과하는 지속적인 과부하 상태에서 가장 쉽게 확인할 수 있습니다.

지속적인 과부하 상태에서의 동작 무한 큐 역압력을 사용하는 유한 큐
큐 깊이 제한 없이 증가 버퍼 크기로 제한됨
메모리 또는 큐 저장소 고갈될 때까지 채워짐 일정하게 유지됨
수락된 작업의 지연 시간 무한정 상승 버퍼와 서비스 시간의 합으로 제한됨
생산자가 알게 되는 것 시스템이 충돌할 때까지 아무것도 없음 차단 또는 거부를 통해 즉시
장애 형태 한 번에 전체 시스템 붕괴 부분적, 일부 작업 거부됨
다운스트림 (데이터베이스, GPU) 고루틴이 생성되면서 폭주 풀 크기로 제한됨
스파이크 이후 복구 먼저 막대한 백로그를 처리해야 함 이미 정상 상태에 가까움

무한 큐 열은 더 많은 부하를 처리하는 시스템이 아닙니다. 동일한 부하를 처리하지만 장애가 치명적인 수준에 이를 때까지 숨기는 시스템입니다. 유한 큐 열은 더 일찍, 더 작게, 그리고 명확하게 실패하는데, 이것이 바로 장애에서 원하는 바입니다.

문제를 증폭시키지 않는 우선순위, 격리, 재시도

두 가지 요소가 더해져 역압(backpressure)은 무디기보다는 실용적이게 됩니다. 첫째는 모든 작업이 동등하지 않다는 것입니다. 만약 상태 확인(health check)과 대량 가져오기(bulk import)가 하나의 큐를 공유한다면, 가져오기 요청이 쇄도할 때 상태 확인 작업이 기아 상태(starve)에 빠지게 되고, 가장 필요할 때 모니터링이 먹통이 됩니다. 별도의 풀이 있는 별도의 큐는 중요한 작업을 격리하여 한 레인(lane)의 과부하가 다른 레인을 마비시키지 않도록 합니다. 중요한 작업을 위한 작은 전용 풀은 대량 작업 레인이 모든 것을 거부하는 동안에도 해당 작업의 흐름을 유지합니다.

둘째는 재시도입니다. 거부된 작업을 재시도하는 것은 합리적이지만, 순진한 재시도 루프는 역압이 재시도 폭풍(retry storm)으로 변하는 방식입니다. 시스템이 포화 상태이기 때문에 작업을 거부할 때 즉시 재시도하면 이미 포화된 시스템에 부하를 더하게 되고, 모든 클라이언트가 동일하게 행동하면 원래의 스파이크가 지나간 후에도 오랫동안 재시도만으로 시스템이 다운될 수 있습니다. 재시도에는 시도할 때마다 증가하는 백오프(backoff)와 시도 횟수에 대한 엄격한 상한선이 필요하므로, 거부는 즉각적인 두 번째 타격이 아니라 더 느린 다음 시도로 이어집니다. 역압과 백오프는 양쪽 끝에 적용되는 동일한 아이디어입니다. 서버는 거부를 통해 밀어내고, 클라이언트는 더 세게 두드리는 대신 속도를 늦춤으로써 그 밀어내기를 존중합니다.

관찰 가능성(Observability)이 이 모든 것을 하나로 묶어줍니다. 큐 깊이와 대기 시간은 조기 경고 신호입니다. 가득 차는 추세의 큐나 마감 시간에 가까워지는 대기 시간은 무언가가 거부되기 훨씬 전에 불균형이 시작되고 있음을 알려줍니다. 이 두 숫자에 대해 경고를 설정하면, 장애를 통해 알게 되는 대신 자신의 일정에 맞춰 작업자(worker)를 추가하거나 생산자(producer)의 속도를 늦출 수 있습니다.

트레이드오프: 부분적 실패는 가동 상태를 유지하기 위한 대가

역압(Backpressure)은 공짜가 아니며, 그 비용을 명시하는 것이 정직합니다. 유한 큐는 작업을 거부합니다. 제한된 풀은 더 적은 동시 요청을 처리합니다. 데드라인은 실제 노력을 들여 생산한 작업을 버립니다. 이 모든 것은 부분적인 실패이며, 누군가는 이를 처리되지 않은 요청으로 경험합니다. 만약 어떤 것도 거부하지 않는 것을 성공으로 측정한다면, 역압은 퇴보처럼 보일 것입니다.

중요한 비교 대상은 역압과 모든 것을 수용하는 완벽한 시스템이 아닙니다. 생산자가 작업자보다 빨라질 수 있는 한, 그러한 시스템은 존재하지 않습니다. 비교 대상은 부분적 실패 대 전체 실패입니다. 무한 큐는 모든 작업을 수용하다가, 어느 순간 아무것도 수용하지 못하게 되면서 프로세스까지 다운시킵니다. 유한 큐는 지속적으로 작업의 일부를 거부하고 나머지는 계속 처리합니다. 이것이 바로 정상적인 성능 저하(graceful degradation)이며, 부하 상태에서 불안정해지는 시스템과 다운되는 시스템을 구분하는 속성입니다.

모든 작업을 수용하고 나중에 처리하려는 본능은 부하가 낮을 때는 괜찮으며, 대부분의 시스템은 대부분의 시간을 낮은 부하 상태에서 보냅니다. 안정성은 부하가 높고 증가하는 극한 상황에서의 동작에서 비롯되며, 이때 유용한 기술은 거부하는 방법을 아는 것입니다. '아니오'라고 말할 수 있는 큐는 스스로를 보호하는 것입니다. 역압은 시스템이 실패하고 있는 것이 아닙니다. 이는 시스템이 결코 큰 실패를 겪지 않기 위해 어떤 작은 실패를 감수할지 선택하는 것입니다.

관련 글