Go

Go의 문자열 결합 성능 비교

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

안녕하세요!

 

이번 글에서는 Go 언어에서 문자열을 결합하는 여러 가지 방법을 비교해보려고 합니다.

 

다양한 방법을 벤치마크 테스트를 통해 성능을 비교하고, 가장 효율적인 방법을 찾아봅시다.

테스트 케이스

아래와 같은 9글자 * 10개의 요소로 이루어진 문자열 배열을 ","로 결합하고, 마지막에 ","를 추가하는 코드를 구현했습니다.

 

원하는 출력은 string이기 때문에, []byte나 bytes.Buffer를 사용할 경우 마지막에 string으로 캐스팅합니다.

var m = [...]string{
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
}

 

사용한 코드는 다음과 같습니다:

Golang string join benchmark

package main

import (
    "bytes"
    "strings"
    "testing"
)

var m = [...]string{
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
    "AAAAAAAAA",
}

func BenchmarkStringsJoin____(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strings.Join(m[:], ",") + ","
    }
}

func BenchmarkAppendOperator_(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 string
        for _, v := range m {
            m2 += m2 + "," + v
        }
        m2 += ","
    }
}

func BenchmarkHardCoding_____(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = m[0] + "," + m[1] + "," + m[2] + "," + m[3] + "," + m[4] + "," + m[5] + "," + m[6]
    }
}

func BenchmarkByteArray______(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 []byte
        for _, v := range m {
            m2 = append(m2, v...)
            m2 = append(m2, ',')
        }
        _ = string(m2)
    }
}

func BenchmarkCapByteArray___(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 = make([]byte, 0, 100)
        for _, v := range m {
            m2 = append(m2, v...)
            m2 = append(m2, ',')
        }
        _ = string(m2)
    }
}

func BenchmarkBytesBuffer____(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 bytes.Buffer
        for _, v := range m {
            m2.Write([]byte(v))
            m2.Write([]byte{','})
        }
        _ = m2.String()
    }
}

func BenchmarkCapBytesBuffer_(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 = bytes.NewBuffer(make([]byte, 0, 100))
        for _, v := range m {
            m2.Write([]byte(v))
            m2.Write([]byte{','})
        }
        _ = m2.String()
    }
}

func BenchmarkCapBytesBuffer2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 = bytes.NewBuffer(make([]byte, 0, 100))
        for _, v := range m {
            m2.WriteString(v)
            m2.WriteString(",")
        }
        _ = m2.String()
    }
}

구현

+= 연산자 루프

가장 기본적인 구현입니다.

 

성능이 가장 느립니다.

func BenchmarkAppendOperator(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 string
        for _, v := range m {
            m2 += m2 + "," + v
        }
        m2 += ","
    }
}
// BenchmarkAppendOperator   1000000          3043 ns/op        3808 B/op          8 allocs/op
결과: 3043ns/op

strings.Join 함수

strings.Join을 사용한 방법입니다.

func BenchmarkStringsJoin(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strings.Join(m[:], ",") + ","
    }
}
// BenchmarkStringsJoin  5000000           413 ns/op         240 B/op          3 allocs/op
결과: 413ns/op

한 번의 대입문으로 + 연산자로 모든 요소를 결합

한 번의 대입문으로 모든 요소를 결합하도록 하드코딩합니다.

 

strings.Join보다 할당이 적고 더 빠릅니다.

func BenchmarkHardCoding(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = m[0] + "," + m[1] + "," + m[2] + "," + m[3] + "," + m[4] + "," + m[5] + "," + m[6]
    }
}
// BenchmarkHardCoding  10000000           216 ns/op          80 B/op          1 allocs/op
결과: 216ns/op

[]byte 사용

var m2 []byte로 선언한 []byte에 append()를 반복합니다.

 

strings.Join보다 느립니다.

func BenchmarkByteArray(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 []byte
        for _, v := range m {
            m2 = append(m2, v...)
            m2 = append(m2, ',')
        }
        _ = string(m2)
    }
}
// BenchmarkByteArray  5000000           613 ns/op         320 B/op          5 allocs/op
결과: 613ns/op

용량을 지정한 []byte

var m2 = make([]byte, 0, 100)로 []byte에 100byte의 용량을 확보한 경우, 할당이 줄어들고 가장 빠른 방법이었습니다.

 

마지막 string 캐스팅을 제외하면 할당은 0이 됩니다.

func BenchmarkCapByteArray(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 = make([]byte, 0, 100)
        for _, v := range m {
            m2 = append(m2, v...)
            m2 = append(m2, ',')
        }
        _ = string(m2)
    }
}
// BenchmarkCapByteArray  10000000           171 ns/op          80 B/op          1 allocs/op
결과: 171ns/op

bytes.Buffer 사용

bytes.Buffer를 사용한 방법입니다.

func BenchmarkBytesBuffer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 bytes.Buffer
        for _, v := range m {
            m2.Write([]byte(v))
            m2.Write([]byte{','})
        }
        _ = m2.String()
    }
}
// BenchmarkBytesBuffer  1000000          1074 ns/op         449 B/op         10 allocs/op
결과: 1074ns/op

용량을 지정한 bytes.Buffer

NewBuffer에 용량을 지정한 []byte를 전달합니다.

func BenchmarkCapBytesBuffer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 = bytes.NewBuffer(make([]byte, 0, 100))
        for _, v := range m {
            m2.Write([]byte(v))
            m2.Write([]byte{','})
        }
        _ = m2.String()
    }
}
// BenchmarkCapBytesBuffer  2000000           956 ns/op         419 B/op         10 allocs/op
결과: 956ns/op

용량을 지정한 bytes.Buffer + WriteString

Buffer.Write 대신 Buffer.WriteString 메서드를 사용해봤더니, Buffer.Write보다 빨라졌습니다.

func BenchmarkCapBytesBuffer2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var m2 = bytes.NewBuffer(make([]byte, 0, 100))
        for _, v := range m {
            m2.WriteString(v)
            m2.WriteString(",")
        }
        _ = m2.String()
    }
}
// BenchmarkCapBytesBuffer2  5000000           588 ns/op         307 B/op          3 allocs/op
결과: 588ns/op

결과 정리

각 벤치마크 테스트의 결과는 다음과 같습니다.

BenchmarkAppendOperator_     1000000          3043 ns/op        3808 B/op          8 allocs/op
BenchmarkStringsJoin         5000000           413 ns/op         240 B/op          3 allocs/op
BenchmarkHardCoding         10000000           216 ns/op          80 B/op          1 allocs/op
BenchmarkByteArray           5000000           613 ns/op         320 B/op          5 allocs/op
BenchmarkCapByteArray       10000000           171 ns/op          80 B/op          1 allocs/op
BenchmarkBytesBuffer         1000000          1074 ns/op         449 B/op         10 allocs/op
BenchmarkCapBytesBuffer      2000000           956 ns/op         419 B/op         10 allocs/op
BenchmarkCapBytesBuffer2     5000000           588 ns/op         307 B/op          3 allocs/op

결론

여러 가지 방법의 처리 시간을 대략적으로 비교하면 다음과 같습니다:

[]byte < 하드코딩(200ns) < strings.Join(400ns) < bytes.Buffer(600ns) < += 연산자 루프(2900ns)
  • += 연산자 루프는 확실히 한 자리 느립니다.
  • 문자열의 + 연산자가 특별히 느리다고 할 수는 없으며, s := a + b + c처럼 한 번의 대입으로 여러 결합을 수행하는 것은 충분히 빠릅니다.
  • bytes.Buffer보다 []byte가 더 빠릅니다.
  • make로 충분한 용량을 확보해 할당 횟수를 줄이는 것이 중요합니다.
  • bytes.Buffer로 문자열을 결합할 때는 Write()보다 WriteString()이 더 빠릅니다.

Go는 이러한 벤치마크를 쉽게 작성할 수 있어 좋습니다.

 

성능 최적화가 필요한 경우, 이와 같은 벤치마크를 통해 가장 적합한 방법을 선택할 수 있습니다.

 

이번 글이 여러분의 Go 프로그래밍에 도움이 되었기를 바랍니다!