파일 저장 API 서버에서 업로드가 끝난 이미지를 후처리해야 했습니다. 원본을 디스크에 기록한 뒤 썸네일과 변환 이미지를 만드는 작업은 CPU 부하가 컸습니다. 요청에 따라 결과를 기다려야 할 때도 있었고, 응답과 무관하게 백그라운드에서 처리해야 할 때도 있었습니다.
처음에는 전형적인 Goroutine Worker Pool을 만들었습니다. 작업 채널에 Job을 넣고, 고정된 수의 worker가 채널을 읽으면서 후처리를 실행하는 방식이었습니다. 동시 실행 수를 통제하고 비동기 작업을 처리한다는 목적에는 잘 맞아 보였습니다.
하지만 서버가 일정량의 요청을 처리하고 나면 이미지 후처리가 더 이상 진행되지 않는 문제가 발생했습니다. 프로세스가 종료된 것도 아니고 명확한 panic 로그도 없었습니다. 요청은 들어오지만 worker가 다음 작업으로 넘어가지 않았습니다.
최초의 Worker Pool
실제 코드를 단순화하면 다음과 같은 구조였습니다.
type Job struct {
Task func() string
ResultChan chan string
}
type WorkerPool struct {
jobs chan Job
results chan string
}
func (wp *WorkerPool) worker() {
for job := range wp.jobs {
result := job.Task()
wp.results <- result
if job.ResultChan != nil {
job.ResultChan <- result
}
}
}
jobs와 results는 모두 buffered channel이었습니다. 동기 요청은 Job마다 별도의 ResultChan을 만들어 결과를 기다렸고, 비동기 요청은 결과 채널 없이 작업만 등록했습니다.
문제는 공용 results 채널이었습니다. 원래는 모든 작업 결과를 모으기 위해 추가했지만, 실제 요청 처리 흐름에서는 이 채널을 읽는 코드가 없었습니다. 동기 요청은 개별 ResultChan을 읽었고 비동기 요청은 결과를 사용하지 않았습니다. 공용 results는 쓰기만 하고 읽지 않는 채널이 되었습니다.
버퍼가 문제를 해결한 것이 아니라 늦췄다
Buffered channel은 수신자가 없어도 버퍼가 남아 있는 동안에는 송신할 수 있습니다. 그래서 개발 환경이나 짧은 테스트에서는 문제가 잘 드러나지 않았습니다.
results := make(chan string, queueSize) results <- result
작업이 끝날 때마다 결과가 하나씩 쌓입니다. 처리한 작업 수가 queueSize에 도달하면 더 이상 빈 공간이 없습니다. 그다음 worker는 아래 송신에서 멈춥니다.
wp.results <- result // 받을 goroutine이 없고 버퍼도 가득 차면 대기한다.
이 상태가 되면 worker는 jobs에서 다음 작업을 꺼내지 못합니다. 시간이 지나 작업 채널까지 가득 차면 요청을 등록하는 코드도 wp.jobs <- job에서 대기합니다. 결과적으로 하나의 사용되지 않는 채널이 worker와 요청 처리 흐름을 순서대로 막습니다.
여기서 중요한 점은 이 현상이 panic이 아니라 정상적인 channel blocking이라는 것입니다. worker에 recover를 추가하고 stack trace를 강화해도 아무것도 잡히지 않습니다. 프로그램 입장에서는 채널에 빈자리가 생기기를 기다리는 정상적인 상태이기 때문입니다.
문제를 확인하는 데 가장 도움이 된 것은 복잡한 예외 처리보다 송신 직전의 채널 상태 로그였습니다.
log.Printf("results len=%d cap=%d", len(wp.results), cap(wp.results))
wp.results <- result
마지막 로그에서 len과 cap이 같고 이후 완료 로그가 남지 않는다면 조사할 위치가 명확해집니다. 단, len(channel)은 동시성 제어를 위한 조건으로 사용하면 안 됩니다. 여기서는 상태를 관찰하는 진단 정보로만 사용했습니다.
정말 Worker Pool이 필요했을까
원인을 찾은 뒤 기존 Worker Pool을 고치는 방법부터 생각했습니다.
- 사용하지 않는
results채널을 제거합니다. - 결과를 계속 소비하는 goroutine을 추가합니다.
- 작업별 결과 전달 방식과 공용 결과 수집 방식을 하나로 통합합니다.
- 종료 시점에 채널을 닫고 worker의 종료를 기다리는 lifecycle을 정의합니다.
첫 번째 방법만 적용해도 직접적인 blocking은 해결할 수 있었습니다. 하지만 코드를 다시 살펴보니 더 근본적인 질문이 생겼습니다.
이 서버에 정말 작업 큐와 상주 worker가 필요할까요?
필요한 기능은 다음 두 가지뿐이었습니다.
- 이미지 후처리의 동시 실행 수를 제한합니다.
- 요청에 따라 같은 작업을 동기 또는 비동기로 실행합니다.
작업 순서 보장, 우선순위, 재시도, 결과 집계는 필요하지 않았습니다. HTTP 요청이 이미 실행할 작업을 가지고 있었고, 동시에 너무 많은 이미지 처리가 실행되는 것만 막으면 됐습니다. Worker Pool은 요구사항보다 많은 상태와 lifecycle을 관리하고 있었습니다.
Buffered channel을 Semaphore로 사용하기
Worker Pool을 제거하고 buffered channel을 semaphore로 사용했습니다.
type Limiter struct {
semaphore chan struct{}
}
func NewLimiter(maxConcurrent int) *Limiter {
return &Limiter{
semaphore: make(chan struct{}, maxConcurrent),
}
}
func (l *Limiter) Run(task func()) {
l.semaphore <- struct{}{}
defer func() {
<-l.semaphore
}()
task()
}
작업을 시작하기 전에 빈 구조체를 채널에 넣어 permit을 얻습니다. 채널이 가득 차면 다른 작업이 permit을 반환할 때까지 기다립니다. 작업이 끝나면 defer에서 값을 하나 꺼내 permit을 반환합니다.
동기·비동기 분기도 단순해졌습니다.
task := func() {
if err := postprocessImage(key); err != nil {
log.Printf("image postprocess failed: %v", err)
}
}
if syncRequest {
limiter.Run(task)
} else {
go limiter.Run(task)
}
동기 요청은 Run이 끝날 때까지 기다립니다. 비동기 요청은 goroutine에서 같은 함수를 실행합니다. 별도의 Job 타입, worker lifecycle, 결과 채널이 필요하지 않습니다.
defer를 사용했기 때문에 task가 정상 반환하거나 panic으로 stack을 거슬러 올라가더라도 획득한 permit은 반환됩니다. 다만 permit 반환과 panic 복구는 서로 다른 문제입니다. 처리되지 않은 panic이 프로세스에 미치는 영향을 막아야 한다면 작업 경계에서 별도의 recover 정책을 정의해야 합니다.
Semaphore가 항상 더 좋은 것은 아니다
이번 요구사항에는 semaphore가 잘 맞았지만 Worker Pool을 대체하는 만능 해법은 아닙니다.
| 요구사항 | 더 적합한 방식 |
|---|---|
| 동시 실행 수만 제한 | Semaphore |
| 대기 작업 수도 제한 | 크기가 제한된 작업 큐 |
| FIFO 또는 우선순위 보장 | Worker Pool 또는 전용 Queue |
| 재시도와 실패 상태 관리 | 외부 Queue 또는 Job System |
| 결과 수집과 graceful shutdown | lifecycle이 정의된 Worker Pool |
Semaphore 방식에서도 비동기 요청마다 goroutine을 먼저 생성하면, 부하가 큰 상황에서 permit을 기다리는 goroutine 수가 계속 늘어날 수 있습니다. 대기 작업 수까지 제한해야 한다면 요청을 거절하거나 bounded queue를 두는 편이 낫습니다.
또한 동시 실행 한도는 감으로 정할 값이 아닙니다. 이미지 크기, CPU 사용량, 디스크 I/O, 메모리 사용량을 함께 측정하면서 조정해야 합니다. 후속 개선에서는 업로드 요청마다 전체 시간과 파일 저장, 인증, 후처리 같은 구간별 시간을 한 줄의 구조화된 로그로 남기도록 했습니다. 동시성 제한을 바꿀 때도 처리량뿐 아니라 각 구간의 지연이 어떻게 변하는지 확인할 수 있게 하기 위해서입니다.
마무리
이번 문제의 직접적인 원인은 읽히지 않는 결과 채널이었습니다. Buffered channel이 충분히 커서 문제를 해결한 것처럼 보였지만, 실제로는 장애가 나타나는 시점만 뒤로 미루고 있었습니다.
선택 기준은 분명했습니다. 작업 큐와 worker, 결과 집계가 필요하면 Worker Pool이 적합합니다. 동시 실행 수만 제한하면 된다면 semaphore가 더 적은 상태와 단순한 lifecycle로 같은 목적을 달성합니다.
동시성 코드는 줄 수보다 상태의 수가 중요합니다. 관리해야 할 채널과 종료 조건을 하나 줄이는 것만으로도, 정상처럼 보이는 영구 대기 상태를 만들 가능성을 크게 낮출 수 있습니다.
답글 남기기