Go 동시성 패턴: Worker Pool로 대용량 이미지 처리 최적화

문제 상황

사용자가 업로드한 원본 이미지를 3가지 사이즈 썸네일로 변환하는 배치 작업을 Go로 구현했다. 초기에는 단순하게 모든 이미지마다 고루틴을 생성했는데, 처리할 이미지가 1만 장을 넘어가자 메모리 사용량이 급증하며 OOM이 발생했다.

// 초기 구현 - 문제 있음
for _, img := range images {
    go processImage(img) // 고루틴 폭증
}

Worker Pool 패턴 적용

Worker Pool 패턴으로 고루틴 수를 제한했다. 채널을 통해 작업을 분배하고, 고정된 수의 워커만 생성하도록 변경했다.

func processImages(images []Image, numWorkers int) {
    jobs := make(chan Image, len(images))
    results := make(chan error, len(images))

    // 워커 생성
    for w := 0; w < numWorkers; w++ {
        go worker(jobs, results)
    }

    // 작업 분배
    for _, img := range images {
        jobs <- img
    }
    close(jobs)

    // 결과 수집
    for range images {
        if err := <-results; err != nil {
            log.Printf("처리 실패: %v", err)
        }
    }
}

func worker(jobs <-chan Image, results chan<- error) {
    for img := range jobs {
        err := generateThumbnails(img)
        results <- err
    }
}

최적 워커 수 찾기

CPU 코어 수와 I/O 대기 시간을 고려해 실험했다.

  • 4 workers: 느림
  • 16 workers: 적절 (CPU 4코어 환경)
  • 64 workers: 개선 미미

결국 runtime.NumCPU() * 4로 설정했다. 이미지 처리는 CPU 집약적이면서도 S3 다운로드/업로드로 I/O 대기가 있어서 코어 수보다 많은 워커가 효율적이었다.

결과

  • 메모리 사용량: 4GB → 1.2GB
  • 1만 장 처리 시간: 12분 → 8분
  • OOM 발생 없음

Worker Pool은 Go 동시성의 기본 패턴이지만, 실제로 적용해보니 리소스 제어의 중요성을 체감할 수 있었다. 무한정 고루틴을 생성하는 실수는 프로덕션에서 치명적이다.

Go 동시성 패턴: Worker Pool로 대용량 이미지 처리 최적화