본문으로 건너뛰기

[loki] Go에서 불필요한 String 할당 제거하기: Grafana Loki의 98% Allocation 최적화 사례

PR 링크: grafana/loki#24245 상태: Merged | 변경: +38 / -21

들어가며

대규모 로그 시스템인 Grafana Loki에서 인덱스를 읽는 방식은 크게 두 가지입니다. 파일을 메모리에 매핑하는 mmap 방식과, 메모리 사용량을 최소화하며 데이터를 읽는 streaming reader 방식입니다. 이론적으로 streaming reader는 메모리 효율적이어야 하지만, 실제 운영 환경에서는 인덱스를 여는 속도가 mmap보다 훨씬 느린 문제가 발생했습니다.

원인은 과도한 메모리 할당(Allocations)에 있었습니다. 인덱스의 오프셋 테이블과 심볼(Symbols)을 스캔할 때, 거의 모든 엔트리에 대해 string 변환이 일어나고 있었고, 이 문자열들 중 대부분은 즉시 버려졌습니다. 이번 PR에서는 []byte를 활용해 불필요한 할당을 제거함으로써 Allocations/op를 98.96% 감소시킨 최적화 사례를 분석합니다.


코드 분석: 할당을 지연시키고 버퍼를 재사용하라

1. 콜백 인터페이스의 변경 (stream_postings.go)

가장 먼저 수행된 작업은 데이터를 순회하는 콜백 함수에서 string 대신 []byte를 받도록 변경한 것입니다. Go에서 string(byte_slice)는 새로운 메모리 할당을 유발하기 때문입니다.

Before

perEntryFn func(
    labelName, labelValue string, // 모든 엔트리를 string으로 변환하여 전달
    postingsOffset uint64,
    entryOffset int,
) error

After

perEntryFn func(
    labelName, labelValue []byte, // 원본 바이트 슬라이스를 그대로 전달
    postingsOffset uint64,
    entryOffset int,
) error

이 변경 덕분에 streamPostingsOffsetTable 함수 내부에서 decbuf.UvarintStr()(내부적으로 string 할당 발생) 대신 decbuf.UnsafeUvarintBytes()를 사용하여 할당 없이 데이터를 읽을 수 있게 되었습니다.

2. 지연된 할당 (Deferred Allocation) 전략

streamPostings.build 메서드에서는 모든 라벨 값을 저장하지 않고, 특정 주기(symbolFactor)마다 혹은 라벨의 마지막 값만 저장합니다. 이전에는 모든 값을 일단 string으로 만들었지만, 이제는 저장이 확정된 순간에만 string으로 변환합니다.

Before

// 매 루프마다 labelName, labelValue가 이미 string으로 생성되어 들어옴
err := streamPostingsOffsetTable(ctx, p.factory, p.off, func(labelName, labelValue string, _ uint64, entryOffset int) error {
    if _, ok := p.postings[labelName]; !ok {
        // ... 신규 라벨 처리
    }
    // ...
})

After

// labelValue는 []byte이며, 보관이 필요한 경우에만 string()으로 캐스팅
lastValue  []byte // 재사용되는 스크래치 버퍼

err := streamPostingsOffsetTable(ctx, p.factory, p.off, func(labelName, labelValue []byte, _ uint64, entryOffset int) error {
    if lastName != string(labelName) {
        if haveLast {
            // 보관이 확정된 시점에만 string 할당
            p.postings[lastName] = append(p.postings[lastName], streamPostingOffset{labelValue: string(lastValue), offset: lastOff})
            haveLast = false
        }
        lastName = string(labelName)
    }
    // ...
    // 보관할지 모르는 값은 재사용 버퍼에 복사만 해둠
    lastValue = append(lastValue[:0], labelValue...)
    haveLast = true
})

여기서 lastValue = append(lastValue[:0], labelValue...) 패턴은 Go에서 매우 유용한 기법입니다. 기존 슬라이스의 용량(capacity)을 재사용하면서 데이터만 덮어쓰기 때문에 추가 할당이 발생하지 않습니다.

3. 심볼 스캔 최적화 (stream_symbols.go)

인덱스의 심볼 테이블을 읽을 때도 동일한 원리가 적용되었습니다. Loki는 수많은 심볼 중 실제 라벨 이름으로 사용되는 것들만 메모리에 유지하면 됩니다.

Before/After 비교

// Before
symbol := decbuf.UvarintStr() // 무조건 string 할당
if isLabelName(symbol) {
    s.labelNameSymbols[uint32(i)] = symbol
}

// After
symbol := decbuf.UnsafeUvarintBytes() // 할당 없음
if isLabelName(symbol) {
    s.labelNameSymbols[uint32(i)] = string(symbol) // 필요한 경우에만 할당
}

왜 이게 좋은 최적화인가?

1. 압도적인 성능 향상

벤치마크 결과는 놀랍습니다.

  • Time per op: 202.7ms → 153.0ms (24.5% 감소)
  • Memory per op: 72.61MiB → 11.24MiB (84.5% 감소)
  • Allocations per op: 6,000k → 62k (98.96% 감소)

대부분의 할당이 루프 내부에서 잠깐 쓰이고 버려지는 단기 생존 객체(Short-lived objects)였기 때문에, 이를 제거하자마자 GC(Garbage Collection) 부하가 급격히 줄어들고 전체적인 실행 속도가 빨라진 것입니다.

2. 리뷰어 피드백: 불필요한 상태 제거

리뷰 과정에서 bborehamhaveName과 같은 불필요한 플래그 변수를 지적했습니다. Loki의 라벨 포맷상 빈 문자열("")은 유효한 라벨 이름이 아니라는 도메인 지식을 바탕으로 코드를 더 단순화할 수 있었습니다. 이는 성능 최적화 과정에서도 코드의 가독성과 논리적 간결함을 유지하는 것이 중요함을 보여줍니다.

3. 일반적인 교훈

  • Hot Path에서는 string 변환을 피하라: []bytestring 사이의 변환은 복사(copy)를 수반합니다. 루프 내부나 빈번하게 호출되는 함수에서는 []byte를 우선적으로 사용하세요.
  • Copy-on-Write/Keep: 데이터를 보관해야 할 때만 복사본(string)을 만들고, 그전까지는 원본 버퍼나 재사용 가능한 슬라이스를 참조하세요.
  • Unsafe API의 적절한 활용: UnsafeUvarintBytes처럼 내부 버퍼를 직접 노출하는 API는 주의해서 사용해야 하지만(콜백 밖에서 참조 금지), 이 PR처럼 스캔 로직 내에서만 사용한다면 엄청난 성능 이득을 줍니다.

결론

이번 최적화는 복잡한 알고리즘의 변경 없이, 데이터의 흐름과 메모리 할당 지점을 정밀하게 분석하는 것만으로도 시스템 성능을 수십 배 개선할 수 있음을 보여주는 좋은 사례입니다. 특히 대규모 데이터를 다루는 Go 애플리케이션 개발자라면 []byte 재사용 패턴을 반드시 익혀두시길 권장합니다.

참고 자료

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

댓글

관련 포스트

PR Analysis 의 다른글