ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Go의 문자열 결합 성능 비교
    Go 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 프로그래밍에 도움이 되었기를 바랍니다!

Designed by Tistory.