본문으로 건너뛰기

[loki] [Loki] 병렬 Flush 최적화: 가장 큰 데이터부터 먼저 처리하기

PR 링크: grafana/loki#25376 상태: Merged | 변경: +123 / -36

들어가며

대규모 분산 시스템에서 병렬 처리는 성능의 핵심입니다. 하지만 단순히 작업을 병렬로 실행한다고 해서 항상 최적의 속도가 보장되는 것은 아닙니다. 특히 작업 간의 크기 편차가 클 때, 실행 순서에 따라 전체 완료 시간(Total Latency)이 크게 달라질 수 있습니다.

최근 Grafana Loki 프로젝트에 반영된 perf(dataobj): Flush the largest builder first PR은 바로 이 문제를 해결합니다. Loki의 flush 프로세스는 여러 테넌트의 데이터를 builder 단위로 묶어 저장소에 기록하는데, 특정 테넌트의 데이터가 압도적으로 클 경우 이 거대한 빌더가 언제 시작되느냐에 따라 전체 플러시 시간이 결정됩니다.

기존에는 테넌트 이름이나 윈도우 순서대로 처리했으나, 이 경우 큰 빌더가 마지막에 배치되면 앞선 작은 빌더들이 끝난 뒤에야 큰 빌더가 홀로 실행되는 비효율이 발생했습니다. 이번 글에서는 이 문제를 코드로 어떻게 해결했는지 분석해 보겠습니다.

코드 분석: 무엇이 바뀌었나?

1. flushAll의 실행 순서 변경

가장 핵심적인 변화는 pkg/dataobj/builder/flush_committer.go 파일의 flushAll 메서드에 있습니다. 기존에는 전달받은 builders 슬라이스를 그대로 순회하며 실행했지만, 이제는 concurrency가 1보다 클 경우 크기순으로 정렬하는 로직이 추가되었습니다.

Before:

func (c *flushCommitterImpl) flushAll(ctx context.Context, builders []builder, reason string) error {
	g, gctx := errgroup.WithContext(ctx)
	g.SetLimit(c.concurrency)
	for _, b := range builders {
		g.Go(func() error {
            // ... builder.Flush 실행
		})
	}
	return g.Wait()
}

After:

func (c *flushCommitterImpl) flushAll(ctx context.Context, builders []builder, reason string) error {
	g, gctx := errgroup.WithContext(ctx)
	g.SetLimit(c.concurrency)

	ordered := builders
	if c.concurrency > 1 {
		// 큰 빌더가 마지막에 시작되면 작은 빌더들이 끝난 후 혼자 실행되어 전체 시간이 길어짐.
		// 큰 빌더를 먼저 시작하면 작은 빌더들이 그 옆에서 병렬로 실행되어 시간이 단축됨.
		ordered = largestFirst(builders)
	}
	for _, b := range ordered {
		// ... 정렬된 ordered 슬라이스를 순회하며 g.Go() 호출
	}
	return g.Wait()
}

2. largestFirst 정렬 로직 구현

빌더를 크기순으로 정렬하기 위해 Go 1.21에서 도입된 slices 패키지와 cmp 패키지를 활용했습니다.

// largestFirst는 빌더를 예상 크기(Estimated Size)에 따라 내림차순으로 정렬한 새 슬라이스를 반환합니다.
func largestFirst(builders []builder) []builder {
	ordered := slices.Clone(builders)
	slices.SortStableFunc(ordered, func(a, b builder) int {
		return cmp.Compare(b.GetEstimatedSize(), a.GetEstimatedSize())
	})
	return ordered
}

여기서 주목할 점은 slices.SortStableFunc를 사용했다는 것입니다. Stable 정렬을 사용함으로써 크기가 같은 빌더들 사이에서는 기존의 순서(테넌트/윈도우 순)를 유지합니다. 이는 시스템의 결정론적 동작(Deterministic behavior)을 보장하여 디버깅과 운영 추론을 용이하게 합니다.

3. 테스트를 통한 검증

PR 작성자는 이 최적화의 효과를 증명하기 위해 pkg/dataobj/builder/flush_committer_test.go에 시뮬레이션 테스트를 추가했습니다. 빌더의 크기에 비례하여 실행 시간이 걸리는 sizeDelayFlusher를 만들어 실제 효과를 측정했습니다.

// 테스트 코드 일부
sizes := map[string]int{"small-1": 1, "small-2": 1, "small-3": 1, "small-4": 1, "large": 4}
given := fixedSizeBuilders(sizes, "small-1", "small-2", "small-3", "small-4", "large")

start := time.Now()
require.NoError(t, flushCommitter.Flush(t.Context(), given, "test", 7))

// 최적화 전: small(1s*4) + large(4s) 순차 실행 시 concurrency 2여도 6초 소요 가능
// 최적화 후: large(4s)가 먼저 시작되고 그 옆에서 small들이 처리되므로 총 4초 소요
require.Equal(t, 4*time.Second, time.Since(start))

왜 이게 좋은 최적화인가?

1. Critical Path(임계 경로) 단축

병렬 작업에서 전체 완료 시간은 가장 오래 걸리는 작업에 의해 결정됩니다. PR 설명에 따르면, 약 120초가 걸리는 전체 플러시 과정에서 큰 빌더가 뒤에 배치되었을 때 약 11초 동안 작은 빌더들만 먼저 실행되는 현상이 관찰되었습니다. 큰 빌더를 가장 먼저 배치함으로써, 이 120초라는 '임계 경로'가 시작되는 시점을 앞당기고 그동안 남는 CPU/네트워크 자원(Concurrency slot)으로 작은 작업들을 해치울 수 있게 되었습니다.

2. 리소스 활용도 극대화

concurrency가 2 이상일 때, 큰 작업 하나가 슬롯 하나를 점유하고 있는 동안 나머지 슬롯들이 수많은 작은 작업들을 빠르게 처리합니다. 이는 전체 시스템의 처리량(Throughput)을 높이는 전형적인 스케줄링 기법입니다.

3. 트레이드오프의 명확한 인지

코드 주석에서도 언급되었듯이, 큰 빌더들을 동시에 처리하면 Peak Memory 사용량이 증가할 수 있습니다. 큰 객체들을 정렬하고 메모리에 올리는 작업이 겹치기 때문입니다. 하지만 Loki 팀은 이를 인지하고도 전체 플러시 속도 개선이 주는 이득이 더 크다고 판단했습니다.

리뷰어 피드백 분석

리뷰어 pracucci는 테스트 코드에 대해 유의미한 피드백을 남겼습니다. 기존의 엔드투엔드(End-to-end) 테스트가 이미 로직을 검증하고 있으니 중복된 단위 테스트가 필요한지 의문을 제기했습니다. 하지만 작성자는 실행 순서와 그에 따른 시간 단축을 명확히 보장하기 위해 정교하게 설계된 시뮬레이션 테스트를 유지했습니다. 이는 복잡한 동시성 로직을 다룰 때 "동작함"을 넘어 "성능적으로 의도대로 동작함"을 증명하는 좋은 습관입니다.

결론

이번 최적화는 복잡한 알고리즘의 변경 없이, 데이터의 특성을 이해하고 실행 순서를 조정하는 것만으로도 큰 성능 향상을 이끌어낼 수 있음을 보여줍니다. Go의 최신 표준 라이브러리를 활용해 깔끔하게 구현된 점도 인상적입니다. 병렬 처리를 설계할 때 작업의 크기가 균일하지 않다면, '가장 큰 놈부터 먼저(Largest First)' 전략을 고민해 보시기 바랍니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글