Go

Go 언어로 문자열 결합 최적화하기: strings.Builder 완벽 가이드 및 벤치마크

드리프트2 2024. 5. 19. 15:01

strings.Builder를 사용한 문자열 결합은 단순히 +=를 사용하는 것보다 훨씬 빠르다!

 

특히, strings.Builder.Grow()를 사용해 미리 용량을 확보해두는 방법이 가장 빠르고 사용하기도 좋다.

strings.Builder 내부 살펴보기

strings.Builder는 Write 메서드를 사용해 문자열 등을 효율적으로 구축하기 위해 사용된다.

 

strings.Builder 자체는 외부로 공개된 필드가 없는 구조체이다.

type Builder struct {
    addr *Builder // 값에 의한 복사를 감지하기 위한 수신기
    buf  []byte
}

 

내부에 []byte가 정의되어 있다.

 

buf에 추가하기 위한 builder.WriteString()이 있다.

 

내부적으로는 단순히 Builder.buf에 대해 append가 동작하고 있다.

func (b *Builder) WriteString(s string) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, s...)
    return len(s), nil
}

 

Builder.bufappend한 내용을 마지막에 string 타입으로 반환하면 결합된 문자열이 완성된다.

func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

 

이제 벤치마크를 포함한 실제 사용 예를 살펴보자.

package Builder_test

import (
    "strings"
    "testing"
)

// 단순히 +=를 사용한 구현
func joinWithPlus(strs ...string) string {
    var ret string
    for _, str := range strs {
        ret += str
    }
    return ret
}

// Builder.Builder를 사용한 구현
func joinWithBuilder(strs ...string) string {
    var sb strings.Builder
    for _, str := range strs {
        sb.WriteString(str)
    }
    return sb.String()
}

// 단순히 +=를 사용한 구현의 벤치마크
func BenchmarkPlus(b *testing.B) {
    strs := []string{"aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg", "hhh", "iii"}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = joinWithPlus(strs...)
    }
}

// Builder.Builder를 사용한 구현의 벤치마크
func BenchmarkBuilder(b *testing.B) {
    strs := []string{"aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg", "hhh", "iii"}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = joinWithBuilder(strs...)
    }
}
$ go test -bench . -benchmem
goos: darwin
goarch: amd64
pkg: go-playground/benchmark/Builder
BenchmarkPlus-12                 5000000           292 ns/op         176 B/op          8 allocs/op
BenchmarkBuilder-12             20000000           114 ns/op          56 B/op          3 allocs/op

 

첫 번째는 +=를 사용한 문자열 결합, 두 번째는 strings.Builder를 사용한 방법이다.

 

확실히 Builder.Builder 쪽이 더 빠르다!

 

하지만 여전히 몇 번의 할당이 발생한다.

용량이 있는 []byte에 append하는 것이 더 빠르다!

strings.Builder.WriteString의 구현을 다시 한 번 살펴보자.

type Builder struct {
    addr *Builder // 값에 의한 복사를 감지하기 위한 수신기
    buf  []byte
}

// ...

func (b *Builder) WriteString(s string) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, s...)
    return len(s), nil
}

 

실제는 []byteappend()하는 형태이므로, 용량이 있는 []byte 쪽이 할당 수를 줄여 비용을 확실히 낮출 수 있다!

 

바로 strings.Builder.buf의 용량을 설정하고 싶지만, strings.Builder.buf에는 직접 접근할 수 없다.

 

하지만 걱정하지 마라. 용량을 추가하는 Grow 메서드가 제공된다.

func (b *Builder) Grow(n int) {}

 

인수로 전달된 수만큼 buf의 용량을 확보한다.

 

이를 통해 용량이 있는 []byte를 설정할 수 있다.

 

바로 벤치마크를 해보자.

// Grow를 사용해 용량을 확보한 문자열 결합
func joinWithBuilderAndGrow(strs ...string) string {
    var sb strings.Builder
    sb.Grow(30)
    for _, str := range strs {
        sb.WriteString(str)
    }
    return sb.String()
}

func BenchmarkBuilderAndGrow(b *testing.B) {
    strs := []string{"aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg", "hhh", "iii"}

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = joinWithBuilderAndGrow(strs...)
    }
}
$ go test -bench . -benchmem
goos: darwin
goarch: amd64
pkg: go-playground/benchmark/Builder
BenchmarkPlus-12                 5000000           289 ns/op         176 B/op          8 allocs/op
BenchmarkBuilder-12             20000000           119 ns/op          56 B/op          3 allocs/op
BenchmarkBuilderAndGrow-12      20000000            64.7 ns/op        32 B/op          1 allocs/op

 

첫 번째는 +=를 사용한 결합, 두 번째는 strings.Builder를 사용했지만 Grow 메서드를 사용하지 않은 경우다.

 

세 번째는 Grow도 사용한 경우다. 결과적으로 할당도 줄어들고, 당연히 성능도 향상되었다!

 

정말 좋다! strings.Builder!

strings.Builder의 편리한 메서드

한 번 초기화한 strings.Builder는 재사용할 수 있다.

 

특정 문자열을 생성한 후, 빌더를 리셋해서 새로운 문자열을 생성할 수도 있다.

func joinedAndReverse(strs ...string) (string, string) {
    var sb strings.Builder
    for _, str := range strs {
        sb.WriteString(str)
    }
    joined := sb.String()

    // Reset을 호출해 strings.Builder.buf를 nil로 만든다
    sb.Reset()
    for i := len(strs) - 1; i >= 0; i-- {
        sb.WriteString(strs[i])
    }
    return joined, sb.String()
}

결론

strings.Builder 정말 편리하다!