Go 채널 버퍼 크기 설정이 성능에 미치는 영향

문제 상황

재택근무 전환 이후 트래픽이 급증하면서 기존 Node.js 로그 수집 서버가 메모리 부족으로 자주 재시작되었다. Go로 재작성하기로 결정하고, 로그를 채널로 받아 배치 처리하는 구조를 설계했다.

logChan := make(chan LogEntry, 1000) // 버퍼 크기를 얼마로?

버퍼 크기를 어떻게 설정해야 할지 감이 잡히지 않았다.

테스트 시나리오

실제 프로덕션 로그 패턴을 시뮬레이션하여 버퍼 크기별로 벤치마크를 돌렸다.

func BenchmarkChannelBuffer(b *testing.B, bufferSize int) {
    ch := make(chan LogEntry, bufferSize)
    
    go func() {
        for log := range ch {
            // 100ms 소요되는 DB 쓰기 시뮬레이션
            time.Sleep(100 * time.Microsecond)
            _ = log
        }
    }()
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        ch <- LogEntry{Message: "test"}
    }
    close(ch)
}

결과

  • 버퍼 0 (unbuffered): 처리량 낮음, CPU 컨텍스트 스위칭 과다
  • 버퍼 100: 약 40% 성능 향상
  • 버퍼 1000: 약 65% 성능 향상
  • 버퍼 10000: 70% 성능 향상 (메모리 사용량 급증)

버퍼가 클수록 송신자가 블로킹되는 빈도가 줄었지만, 일정 크기 이상에서는 효과가 체감하며 메모리만 낭비되었다.

최종 선택

우리 워크로드는 평균 200 req/s, 피크 500 req/s였고 로그 처리는 평균 50ms 소요되었다. 계산상 버퍼 500 정도면 피크 타임에도 여유가 있었다.

const (
    logBufferSize = 500
    workerCount = 4
)

logChan := make(chan LogEntry, logBufferSize)

for i := 0; i < workerCount; i++ {
    go logWorker(logChan)
}

워커를 4개로 늘려 병렬 처리하면서 채널 버퍼는 적정 크기로 유지했다. 배포 후 메모리 사용량은 Node 대비 1/3로 줄었고, 피크 타임에도 안정적으로 동작했다.

교훈

채널 버퍼는 송수신 속도 차이를 흡수하는 완충재다. 하지만 무한정 쌓이는 큐가 아니므로, 근본적으로 처리 속도가 느리면 워커 수를 늘리거나 처리 로직을 최적화해야 한다. 프로파일링 없이 임의로 큰 버퍼를 설정하는 건 문제를 감추는 것일 뿐이었다.

Go 채널 버퍼 크기 설정이 성능에 미치는 영향