View
흔히 로그는 카프카나 래빗MQ로 받아야 한다는 식의 주장이 통용된다. 그러나 정말 그러한가? 로그 큐 필요성을 한 번이라도 의심해본 사람이라면 "이 계층을 빼도 되지 않을까" 하는 의문을 품어봤을 것이다. 큐 클러스터를 깔고 운영하는 비용이 만만치 않음에도, 제거 가능 여부에 대한 정량 근거를 찾기는 의외로 어렵다.
그래서 직접 측정했다. Go 1.22로 동일한 sink 앞에 direct goroutine 방식과 bounded queue + 워커 풀 방식을 두고 각각 5만 건을 흘렸다. 처리량, P99 지연, heap 피크, goroutine 수까지 모두 측정했다. 결과는 예상보다 명확하다.

출처: geeksforgeeks
결론부터 정리한다
큐가 항상 필요한 것은 아니다. 정리하면 다음과 같다.
- sink 지연이 평탄하고 트래픽이 튀지 않으면 direct goroutine만으로도 운용 가능하다
- 다만 버스트 + sink 스파이크 + 손실 불허 조합이라면 큐 + 배치가 압도적이다
- 큐를 도입한다고 자동으로 빨라지지 않는다. 본질은 배치 처리에 있다. batch=1 큐는 direct와 큰 차이가 없었다
- 운영 안정성 관점에서는 bounded queue가 무조건 유리하다. goroutine 폭주를 차단해주기 때문이다
핵심 수치만 짚으면, direct_bg 방식은 P99 e2e 지연이 16,109,864µs(약 16초)였고, queue + cap 65,536 + batch 64 조합은 동일한 5만 건을 무손실로 처리하면서도 P99가 262,820µs(약 0.26초)로 약 60배 차이가 났다. heap_max 또한 31.6MB vs 24.1MB, goroutine_max는 48,704개 vs 6개로 마무리됐다.
두 가지 패턴, 무엇이 다른가
direct_bg — 요청마다 goroutine을 던지는 방식
가장 단순한 fire-and-forget 구조이다. 핸들러가 요청을 받으면 그저 go func() { sink.Write(log) }()를 던지고 응답한다. 인프라 추가 비용이 0이다.
// 의사 코드
func handler(w http.ResponseWriter, r *http.Request) {
log := buildLog(r)
go sink.Write(log) // fire-and-forget
w.WriteHeader(200)
}
장점은 단순함이다. 큐 클러스터를 깔 필요도, 워커 풀을 설계할 필요도 없다. 그러나 단점이 치명적이다. sink가 막히는 순간 goroutine이 무제한으로 누적된다. heap이 따라 올라가고, GC 압박이 가중되며, 종국에는 OOM 위험까지 발생한다.
queue — bounded 채널 + 워커 풀 + 배치
producer-consumer 패턴이다. 핸들러는 채널에 enqueue만 하고 반환된다. 워커 N개가 채널에서 항목을 빼내 배치로 묶어 sink에 전송한다.
// 의사 코드
ch := make(chan Log, cap) // bounded
for i := 0; i < workers; i++ {
go worker(ch, batchSize)
}
func handler(...) {
select {
case ch <- log: // 인큐
default: // 풀 차면 드롭
}
}
장점은 백프레셔가 자연스럽게 형성된다는 점이다. cap이 한계로 작용하므로 메모리도 폭주하지 않으며, 배치 인서트로 sink 호출 횟수도 감소한다. 단점은 cap이 차면 드롭이 발생한다는 것과, 인큐 지연이 약간 추가된다는 것이다.
실험 환경 — 동일 조건으로 통제했다
비교의 핵심은 "동일한 sink 앞에서 구조 차이만 관찰한다"는 데 있다. 따라서 환경을 엄격히 통제했다.
| 항목 | 값 |
| 런타임 | golang:1.22-bookworm |
| GOMAXPROCS | 2 |
| 메모리 | 1024MB |
| CPU | 2.0 |
| 네트워크 | deny (인프로세스 시뮬레이션) |
| sink 커넥션 풀 | 4 |
| sink 기본 쓰기 | 200µs |
| sink 스파이크 | 1% 확률로 5,000µs |
| 발행 건수 | 모드별 50,000 |
| 비교 변수 | cap ∈ {256, 4096, 65536} × batch ∈ {1, 64} |
왜일까? 인프로세스 sink로 시뮬레이션한 이유는 네트워크 변동성을 제거하고 구조 차이만 노출시키기 위함이다. 실제 ClickHouse나 RabbitMQ로 돌리면 네트워크 RTT 편차가 너무 커서, 어느 부분이 구조 비용이고 어느 부분이 네트워크 비용인지 분리할 수 없다.
측정 결과 — 수치가 모든 것을 말한다
처리량 비교 — 본질은 배치다
stdout 출력을 그대로 옮기면 다음과 같다.
mode cap batch wkr prod drop wrote thr/s
direct_bg 0 1 0 50000 0 50000 3048
queue 256 1 4 276 49724 276 538813
queue 256 64 4 1152 48848 1152 5923397
queue 4096 1 4 4112 45888 4112 36645
queue 4096 64 4 5376 44624 5376 1961769
queue 65536 1 4 50000 0 50000 3056
queue 65536 64 4 50000 0 50000 184306
여기서 도출되는 인사이트가 강력하다. batch=1짜리 큐(cap=65,536, 무손실)는 처리량이 3,056/s로 direct_bg(3,048/s)와 사실상 동일하다. 큐를 도입한다고 자동으로 빨라지지 않는다는 의미다.
그러나 동일한 cap=65,536에 batch=64를 적용하면 처리량이 184,306/s로 약 60배 도약한다. 이는 sink 호출 횟수를 1/64로 축소했음을 의미한다. ClickHouse나 BigQuery 같은 컬럼 스토어 적재 시 진정 체감되는 지점이다.
e2e 지연 분포 — direct_bg가 무너지는 지점
P50/P95/P99/Max만 별도로 추출하여 비교하면 다음과 같다.
| mode | cap | batch | e2e P50 µs | e2e P95 µs | e2e P99 µs | e2e Max µs |
| direct_bg | 0 | 1 | 8,114,189.8 | 15,434,066.8 | 16,109,864.0 | 16,279,736.8 |
| queue | 256 | 64 | 2,415.8 | 6,486.7 | 6,491.0 | 6,492.2 |
| queue | 4,096 | 64 | 13,111.3 | 20,608.4 | 20,838.2 | 20,855.1 |
| queue | 65,536 | 1 | 8,048,918.6 | 15,510,242.5 | 16,196,630.0 | 16,355,595.3 |
| queue | 65,536 | 64 | 126,913.8 | 250,858.5 | 262,820.4 | 266,307.2 |
direct_bg는 P50조차 8초를 넘는다. sink가 4-conn 풀로 묶여 있어 in-flight goroutine 4만 8천 개가 그대로 줄 서 있는 상황이다. 1% 5ms 스파이크가 누적되면서 tail이 16초까지 끌려간다.
반면 queue + cap 65,536 + batch 64는 동일한 무손실 조건에서 P99가 262,820µs에 머문다. 약 60배 빠른 셈이다.
다만 흥미로운 지점이 또 있다. queue + cap 65,536 + batch 1은 direct_bg와 거의 동일한 P99(16,196,630µs)를 보인다. 배치가 없으면 큐 자체로는 sink 처리속도 한계를 돌파하지 못한다는 의미다. cap만 키워도 batch를 묶지 않으면 무의미하다.
*중요* 해석
줄별 해석
① direct_bg — 무너진 케이스
P50조차 8초다. 절반의 요청이 8초 이상 걸렸다는 뜻이다. sink는 4-conn 풀인데 그 앞에 in-flight
goroutine이 4만 8천 개 줄 서 있는 상황이라, 사실상 sink 처리율 한계에 그대로 묶인다. P95에서 Max까지
차이가 작은 이유는 안정적이어서가 아니다. 거의 모든 요청이 똑같이 막혀서 똑같이 늦어진 결과다.
② queue cap=256, batch=64 — 가장 빠른 케이스
P99 6.5ms로 표 안에서 압도적 1등이다. 다만 cap이 256으로 작은 만큼 드롭률이 따로 기록되는데,
본문에서는 4만 9,724건이 drop된다고 명시한다. 빠른 대신 받지 못한 요청은 통째로 버려지는 셈이라,
"속도 vs 손실" 트레이드오프가 극단으로 기운 케이스다.
③ queue cap=4,096, batch=64 — 균형점
P99 20.8ms. cap을 16배 키운 덕분에 드롭이 줄어드는 대신 큐 대기 시간이 약간 더 붙는다. 배치 효과는
그대로 살아 있어서 절대 지연은 여전히 매우 짧다.
④ queue cap=65,536, batch=1 — "큐만 키워도 소용 없다"의 증거
P50 8초. direct_bg와 거의 동일한 결과다. 큐가 무한히 받아내더라도 워커가 sink에 단건씩 호출하면 sink
처리율(약 3,000건/s)이 그대로 천장이 된다. 본문이 강조하는 핵심도 여기에 있다. cap만 키우고 batch를
묶지 않으면 의미가 없다.
⑤ queue cap=65,536, batch=64 — 드롭 0의 견실한 케이스
P99 263ms. ②④와 비교하면 "드롭 없이 모두 받으면서 sink 호출은 1/64로 줄임" 조합이다. 절대값은 ②③보다
느리지만 단 한 건도 버리지 않는다는 점이 핵심 가치다. 처리량으로는 184,306건/s로 본문에서 1등을
차지한다.
한 문장 요약
직접 던지면 sink 풀이 병목이 되어 16초까지 끌리고, 큐만 키우면 sink 호출 빈도가 새 병목이 되며,
batch=64로 묶어야 비로소 60배 처리량과 안정적인 tail이 같이 나온다. 그게 ②③⑤의 본질이다.
곁다리: P95-P99-Max가 거의 같은 케이스의 의미
queue+batch 케이스(②③⑤)는 P95에서 Max까지 차이가 1~6%에 불과하다. 이는 tail이 평탄하다는 뜻으로, 운
나쁜 1% 요청도 평소 요청과 큰 차이 없이 처리됐음을 의미한다. 운영 관점에서 매우 좋은 신호다. 예측
가능한 지연은 곧 SLO 잡기가 수월하다는 뜻이기도 하다.
반면 direct_bg와 ④도 P95에서 Max까지 가깝지만, 이쪽은 정반대 이유다. 모두 골고루 망한 결과다. 같은
수치라도 의미가 정반대인 경우가 있으니, 분포를 볼 때는 항상 절대값과 함께 읽어야 한다.
heap_max_mb / goroutine_max — 운영 안정성의 진짜 차이
이 부분이 다른 글들이 잘 짚지 않는 영역이다. 그러나 운영 담당자 관점에서는 처리량보다 이쪽이 더 중요한 경우가 많다.
| mode | cap | batch | dropped | heap_max_mb | goroutine_max |
| direct_bg | 0 | 1 | 0 | 31.59 | 48,704 |
| queue | 256 | 1 | 49,724 | 22.11 | 6 |
| queue | 4,096 | 64 | 44,624 | 22.24 | 6 |
| queue | 65,536 | 1 | 0 | 24.11 | 6 |
| queue | 65,536 | 64 | 0 | 24.11 | 6 |
direct_bg는 goroutine_max가 48,704개까지 치솟는다. 5만 건을 처리하는 동안 거의 전부가 살아 있었다는 뜻이다. heap도 31.59MB로 가장 컸다. 부하가 더 커지거나 sink가 더 느렸다면 그대로 OOM으로 직행했을 가능성이 크다.
queue 모드는 워커 4개에 cap이 상한으로 작용해 goroutine_max가 6개로 평탄하다. heap도 22~24MB대로 안정적이다. 운영 환경에서 "갑작스러운 메모리 폭증으로 컨테이너 재시작" 같은 이슈가 발생하지 않는다는 의미다.
인큐 지연 (큐 모드 한정)
큐 모드에서 enqueue P99는 모든 조합에서 0.1µs다. 사실상 공짜에 가깝다. enqueue Max도 17.4~141.9µs로 무시할 만한 수준이다.
이 수치가 의미하는 바는 "큐에 넣는 비용이 부담스러워 못 쓴다"는 우려가 거의 근거 없다는 것이다. 채널은 진정 빠르다.
cap 설정의 의미 — 흡수량과 메모리, 드롭의 트레이드오프
실험에서 cap을 256 → 4,096 → 65,536으로 변경하며 관찰한 결과, cap의 의미가 명확해진다.
- cap 256, batch 1: 49,724건 드롭. 풀이 너무 작아 producer가 거의 잘려나간다
- cap 4,096, batch 64: 44,624건 드롭. 여전히 풀이 부족하다
- cap 65,536, batch 64: 0건 드롭. 5만 건을 모두 흡수한다
cap은 "버스트를 얼마나 흡수할지의 단위"다. 너무 작으면 드롭으로 직결되고, 너무 크면 메모리 낭비 + 큐가 길어질수록 e2e 지연도 함께 증가한다. cap=65,536 + batch=1처럼 누적만 되고 빠지지 않으면 P99가 16초까지 치솟는 양상을 그대로 확인했다.
실무 룰은 단순하다. cap = 예상 burst peak rate × drain time이다. drain time은 워커수 × 배치당 처리시간으로 산출한다.
그렇다면 큐, 도입해야 하는가 말아야 하는가
로그 적재에 큐가 반드시 필요한 것은 아니다. sink 지연이 안정적이고 트래픽이 평탄하면 direct goroutine만으로 충분하다. 다만 버스트가 크고 손실이 허용되지 않으면 bounded queue + 워커 풀 + 배치 인서트가 압도적으로 유리하다.
조건별로 정리하면 다음과 같다.
큐를 빼도 되는 조건
- sink 지연이 안정적이다 (P99/P50 비율이 3 이하)
- 트래픽이 평탄하다 (피크/평균 비율이 작고, 버스트가 거의 없음)
- 약간의 로그 손실이 허용된다 (혹은 sink 자체가 신뢰성을 보장함)
- 운영 인력이 적고 인프라를 단순하게 유지하고자 한다
이 조건이라면 direct_bg + 적절한 goroutine 제한 패턴(semaphore로 동시 in-flight 묶기)이 진정 충분하다. 인프라를 절약하고 코드를 단순하게 가져가는 편이 답이다.
큐를 도입해야 하는 조건
- sink 스파이크가 자주 발생한다 (DB 락, 외부 API rate limit, GC 등)
- 트래픽 버스트가 크다 (피크/평균 > 5)
- 로그 손실이 허용되지 않는다 (감사 로그, 결제 로그, 에러 추적 로그)
- 배치 인서트로 sink 비용을 절감해야 한다 (ClickHouse, BigQuery 같은 컬럼 스토어나 S3 객체 스토리지에 Parquet 적재)
- 메모리·goroutine 안정성이 중요하다 (장기 실행 컨테이너)
이 조건이라면 큐 + 배치 + 워커 풀이 답이다. 다만 반드시 카프카·래빗MQ까지 갈 필요는 없다. 다음 절을 보아야 한다.
중간 지점 — 가벼운 인메모리 큐로 80% 효과
실험 결과가 시사하는 진정한 인사이트는 굳이 외부 큐 시스템을 도입하지 않아도 채널 + 워커만으로 80% 효과를 본다는 점이다. 동일 프로세스 안의 Go 채널이 cap 65,536 + batch 64 조건에서 5만 건 무손실 처리에 P99 262ms를 기록했다. RabbitMQ 클러스터 운영 비용을 빼고 산출한 수치다.
물론 단점은 있다. 프로세스가 죽으면 큐에 있던 데이터는 모두 소실된다. 따라서 손실이 절대 허용되지 않는 결제·감사 로그라면 외부 영속 큐가 필요하다. 그러나 트래픽 메트릭, 일반 액세스 로그, 추적용 이벤트 로그라면 인메모리 큐로 충분하다.
비용 관점에서 보면 카프카 클러스터는 최소 3노드를 띄워야 안정적이고, 그 운영 부담 + 인프라 비용이 만만치 않다. 인메모리 큐는 코드 200줄 미만으로 마무리된다. ROI를 따져보면 작은 서비스 입장에서는 인메모리가 답인 경우가 많다.
실무에서 어떻게 결정할 것인가 — 체크리스트
실제로 결정하기 전에 다음 4가지를 점검하면 된다.
- 트래픽 패턴을 측정해보았는가? 피크/평균 비율을 모른다면 우선 그것부터 측정해야 한다
- sink P99/P50 비율을 잰 적이 있는가? 3 이하라면 direct로 충분하고, 5 이상이라면 큐가 필요하다
- 로그 한 건이 유실됐을 때의 비즈니스 영향이 정의되어 있는가? 정량적으로 답할 수 있어야 한다
- 운영 인력이 카프카·래빗MQ 클러스터를 감당할 수 있는가? 솔직하게 답해야 한다
4개 모두 "글쎄"라면 우선 direct_bg에 semaphore를 걸어 시작하고, 모니터링을 붙여 실제로 문제가 발생하면 채널 기반 큐로 이행하는 편이 합리적이다. 처음부터 카프카부터 도입하는 것은 90% 케이스에서 오버킬이다.
4개 중 3개 이상이 명확하게 "큐가 필요하다"라면 그때 설계에 들어가면 된다. 그것도 외부 큐로 가기 전에 인메모리 큐로 먼저 검증해보는 편이 좋다.
정리 — 로그 큐 필요성, 측정으로 답한다
핵심을 3줄로 정리하면 다음과 같다.
- 큐는 도구이지 신앙이 아니다. 조건이 부합하면 direct goroutine으로도 충분히 운용된다
- 큐의 진정한 가치는 처리량 자체보다 백프레셔 + 메모리 안정성 + 배치 인서트 효과에 있다
- 외부 큐 시스템으로 가기 전에 인메모리 채널 + 워커 풀 + 배치로 충분한지 먼저 검증해야 한다
현재 운영 중인 로그 파이프라인이 있다면 P99를 한 번 측정해보길 권한다. 로그 큐 필요성은 추상적인 베스트 프랙티스가 아니라 측정 가능한 트래픽 특성에서 답이 도출되는 문제다. 5만 건을 돌려본 데이터가 보여주는 바가 그것이다. 큐가 답인지 아닌지는 본인 트래픽 측정 데이터가 결정한다.
큐는 도구이지 신앙이 아니다. 측정해보고 결정하라.
'Backend' 카테고리의 다른 글
| Redis 캐시 stampede를 진짜로 막는 법 — single-flight, XFetch, 분산 lock 직접 돌려본 결과 (0) | 2026.05.04 |
|---|---|
| HTTP Keep-Alive는 정말 효과가 있는가? 1000회 호출로 직접 비교한다 (0) | 2026.05.04 |
| SSE 스트림은 어떤 언어가 안정적인가? Go·Python·Node·Rust를 User 1000명까지 굴려본 결과 (0) | 2026.05.03 |
| gRPC vs REST, 내부 통신에는 왜 모두 gRPC를 쓰는가 (0) | 2026.04.28 |
| FastAPI 백그라운드 태스크는 진정한 비동기인가? 서버 종료 시 살아남는가? (0) | 2026.04.28 |
