Skip to content

Commit

Permalink
Post: string 이어붙이기 글 퇴고
Browse files Browse the repository at this point in the history
  • Loading branch information
winterjung committed Jan 13, 2025
1 parent 772acdc commit c0daffb
Showing 1 changed file with 145 additions and 39 deletions.
184 changes: 145 additions & 39 deletions src/posts/2025-01-05-string-concat-performance-benchmark-in-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,37 @@ title: Go에서 문자열 빠르게 이어붙이는 방법
image: images/20250105/fixed_sec.png
---

Go에서 문자열을 이어 붙여야 할 때 `pk := row.ID + "#" + row.Name` 혹은 `pk := fmt.Sprintf("%s#%s", row.ID, row.Name)` 같이 관성적으로 썼던 방식을 쓰거나, 한두 가지 방법 정도만 마이크로 벤치마크를 통해 비교해보고 빠른 방법을 사용하곤 했다.
그 때 마다 인자 개수가 고정되어 있는지, 변수를 통해 바뀌는지, 인자로 주어진 문자열들의 길이가 어떤지 등 상황이 다양하곤 했는데, 이 글에선 각 상황별 여러 방법들을 벤치마크를 통해 성능과 메모리 사용량을 비교해 봤다.
Golang을 쓰다 보면 struct의 필드를 이용해 캐시키를 만든다거나, sql 쿼리를 만들거나, 출력을 위한 문자열 포매팅을 할 때 등 종종 문자열을 이어 붙여야 할 때가 있다. 그럴 때마다 `pk := row.ID + "#" + row.Name`처럼 바로 이어버리거나 혹은 `pk := fmt.Sprintf("%s#%s", row.ID, row.Name)` 같이 포매팅 함수를 사용하는 식으로 기존에 관성적으로 썼던 방식을 쓰곤 했다.
사실 막연히 어떤 방법을 써도 그렇게 느리진 않겠지 생각했던 면도 있고, 빈번하게 사용되는 로직이라 성능에 대한 막연한 우려가 있다면 한두 가지 방법 정도만 마이크로 벤치마크로 비교 해보고 빠른 방법을 사용하곤 했다.
그러다 string concat 마저도 줄여야 해 여러 방법을 비교해 볼 필요가 있었고, 그동안의 사용 사례를 생각해 보니 크게 캐시키를 만들 때처럼 **인자 개수가 고정된 상황**과, 쿼리의 조건문이나 저장소에서 가져온 값을 이용하느라 **인자 개수가 변할 수 있는 상황**으로 나눌 수 있었다.
이 글에선 벤치마크를 통해 위 상황마다 여러 방법의 성능과 메모리 사용량을 비교해 봤다.

## 비교할 방법들
## 비교해 볼 방법들

경험적으로 사용했거나 혹은 널리 알려진 방법들을 간추려 보니 대략 아래와 같았다.

1. `+` 연산자: 이어 붙일 string이 고정된 개수일 때 종종 썼다.
2. `+=` 연산자: 사실 그렇게 많이 사용해본 적은 없는데 한 번 넣어봤다.
3. `fmt.Sprintf()`: 어렴풋이 느릴거란 생각은 있으나 자주 쓰곤했다.
4. `fmt.Sprint()`: `fmt.Sprintf`의 template interpolation이 느리지 않을까하여 넣어봤다.
5. `strings.Join()`: 이어 붙여야 할 문자열이 꽤 되는 경우 코드가 간결해 선호하곤 했다.
6. `bytes.Buffer`: buffer를 만들고 `WriteString`하는 방식인데 직접 쓴 적은 없고 라이브러리 내부 구현에서 종종 봤다.
7. `strings.Builder`: 1.10 버전에 생겨 `bytes.Buffer`를 대체한다고 한다.
- 위 두 방식은 모두 내부적으로 `.Grow()` 메서드가 있어 pre allocating이 가능하기에 두 경우를 구분해 테스트해봤다.
1. `+` 연산자: 이어 붙일 string이 고정된 개수일 때 종종 사용했다.
2. `+=` 연산자: 그렇게 많이 사용해 본 적은 없는 방법이다.
3. `fmt.Sprintf()`: 어렴풋이 느릴 거란 생각은 있으나 코드 정렬이 이쁘게 되는 경향이 있어 자주 쓰곤 했다.
4. `fmt.Sprint()`: `fmt.Sprintf`의 template interpolation이 느리지 않을까 해 비교군으로 넣었다.
5. `strings.Join()`: 성능을 고려했다기보단 join을 쓰면 코드가 간결해지는 경우가 있어 선호하곤 했다.
6. `bytes.Buffer`: buffer를 만들고 `WriteString`하는 방식이고 라이브러리 내부 구현에서 종종 보인다.
7. `strings.Builder`: 1.10 버전부터 생겨 `bytes.Buffer`를 대체한다고 한다.
- 위 두 방식은 모두 내부적으로 `.Grow()` 메서드가 있어 pre allocating이 가능하기에 두 경우를 구분해 테스트해 봤다.

### 함수 구현 코드
참고로 전체 개수를 미리 알 수 있는 경우 `ids := make([]string, len(users))`처럼 len 혹은 cap을 설정해 slice나 map의 크기를 미리 늘려주는 pre allocation을 권장한다.
예전에 작성한 [뱅크샐러드 Go 코딩 컨벤션 – Slice 선언 시 len, cap 설정](https://blog.banksalad.com/tech/go-best-practice-in-banksalad/#slice-선언-시-len-cap-설정) 단락과 [Known length slice initialization speed - Golang Benchmark Wednesday](https://simon-frey.com/blog/known-length-slice-initialization-speed-golang-benchmark-wednesday/) 글을 참고하자.

## ⚡️ 결과만 먼저 보기

1. `.Grow()` 메서드를 사용한 `strings.Builder` 혹은 `strings.Join()` 방법이 모든 상황에서 가장 빠르고 효율적인 방법이다.
2. 인자 개수가 고정된 상황에선 `+`로 이어 쓰는 방법도 충분히 좋다.

## 각 방법의 함수 구현

누군가 재현할 수 있도록 최대한 코드 원문을 담고자 했다.

### 인자 개수가 고정된 상황

```go
// concat.go
Expand All @@ -38,7 +52,12 @@ const (
func FixedPlusOp(a, b, c string) string {
return a + delimiter + b + delimiter + c
}
```

인자 개수가 고정된 상황에서 흔히 구현해 사용하는 로직이다.
이 테스트에선 고정된 상황인 경우 임의로 3개의 인자를 받는다고 가정했는데, 경험상 어느 정도 평균 수치를 반영했기도 하고 인자가 많아져도 벤치마크의 상대적 결과에 큰 영향을 주지 못했다.

```go
func FixedAssignOp(a, b, c string) string {
var s string
s += a
Expand Down Expand Up @@ -81,7 +100,11 @@ func FixedBuilderPreAlloc(a, b, c string) string {
builder.WriteString(c)
return builder.String()
}
```

`.Grow()` 메서드를 통해 내부 버퍼의 cap을 미리 늘려둘 수 있다.

```go
func FixedBuffer(a, b, c string) string {
var buf bytes.Buffer
buf.WriteString(a)
Expand All @@ -102,11 +125,13 @@ func FixedBufferPreAlloc(a, b, c string) string {
buf.WriteString(c)
return buf.String()
}
```

const (
delimiterLen = len(delimiter)
)
### 인자 개수가 변할 수 있는 상황

인자 개수가 고정됐을 때와 다르게 비교할 수 있는 방법의 가짓수가 적었다.

```go
func VarPlusOp(ss []string) string {
if len(ss) == 0 {
return ""
Expand All @@ -118,7 +143,12 @@ func VarPlusOp(ss []string) string {
}
return result
}
```

인자 개수가 변할 수 있는 상황에선 `+` 연산자와 `+=` 연산자의 구분이 의미가 없어 위처럼 하나의 함수로 합쳐 구현했다.
[variadic 함수](https://gobyexample.com/variadic-functions)로 구현할 수도 있었으나 큰 차이가 없어 불필요한 변환이 없는 `[]string` 인자를 받는 형태로 구현했다.

```go
func VarJoin(ss []string) string {
return strings.Join(ss, delimiter)
}
Expand All @@ -137,6 +167,10 @@ func VarBuilder(ss []string) string {
return builder.String()
}

const (
delimiterLen = len(delimiter)
)

func VarBuilderPreAlloc(ss []string) string {
if len(ss) == 0 {
return ""
Expand All @@ -158,7 +192,11 @@ func VarBuilderPreAlloc(ss []string) string {
}
return builder.String()
}
```

`.Grow()`를 위해 전체 길이를 알아야 해서 for loop를 2번 도는 코드가 됐다.

```go
func VarBuffer(ss []string) string {
if len(ss) == 0 {
return ""
Expand Down Expand Up @@ -196,7 +234,9 @@ func VarBufferPreAlloc(ss []string) string {
}
```

### 벤치마크 코드
## 벤치마크 코드

먼저 아래 테스트로 모든 함수의 결과가 동일함을 확인했다.

```go
// concat_test.go
Expand All @@ -206,8 +246,53 @@ import (
"fmt"
"math/rand/v2"
"testing"

"github.com/stretchr/testify/assert"
)

func TestAllFuncsAreSame(t *testing.T) {
cases := []struct {
a, b, c string
}{
{randString(1), randString(1), randString(1)},
{randString(10), randString(10), randString(10)},
}

funcs := []func(a, b, c string) string{
FixedPlusOp,
FixedAssignOp,
FixedSprintf,
FixedSprint,
FixedJoin,
FixedBuilder,
FixedBuffer,
FixedBuilderPreAlloc,
FixedBufferPreAlloc,
}
for _, tc := range cases {
result := make([]string, len(funcs))
for i, f := range funcs {
result[i] = f(tc.a, tc.b, tc.c)
}

for i := range result {
assert.Equal(t, result[0], result[i])
}
}
}

const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

func randString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = charset[rand.IntN(len(charset))]
}
return string(b)
}
```

```go
var result string

func BenchmarkFixed(b *testing.B) {
Expand Down Expand Up @@ -242,6 +327,7 @@ func BenchmarkFixed(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// escape compiler optimization
r = f.do(tc.a, tc.b, tc.c)
}
b.StopTimer()
Expand All @@ -250,17 +336,12 @@ func BenchmarkFixed(b *testing.B) {
}
}
}
```

const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

func randString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = charset[rand.IntN(len(charset))]
}
return string(b)
}
보통의 경우 string concat을 할 시점이면 이미 인자로 넘길 데이터는 준비되어 있기에 위 벤치마크에선 인자로 들어갈 데이터의 준비 시간을 제외했다.
그리고 전역변수와 함수 실행의 결과값을 적절히 사용하지 않으면 컴파일 단계에서 코드가 임의로 처리될 수 있기에 위처럼 작성했다. ([Dave Cheney의 2013년 How to write benchmarks in Go 글](https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go)을 참고하자)

```go
func BenchmarkVar(b *testing.B) {
funcs := []struct {
name string
Expand All @@ -274,7 +355,6 @@ func BenchmarkVar(b *testing.B) {
{name: "BufferPreAlloc", do: VarBufferPreAlloc},
}

// 다양한 입력 개수 테스트를 위한 케이스들
cases := []struct {
name string
ss []string
Expand Down Expand Up @@ -309,9 +389,11 @@ func randStringSlice(n int) []string {
}
```

### 테스트
인자 개수가 고정되어 있지 않은 상황에선 인자의 개수를 적절히 늘려가며 테스트해 줬는데 각 인자의 길이는 임의로 10으로 지정해 테스트했다. 이는 벤치마크 결과에 큰 영향을 주지 않았다.

벤치마크는 로컬 맥북 랩탑에서 진행했으며 아래 커맨드를 통해 재현해볼 수 있다.
## 벤치마크

벤치마크는 로컬 맥북 랩탑에서 진행했으며 아래 커맨드를 사용했다.

```sh
$ system_profiler SPHardwareDataType
Expand All @@ -331,6 +413,8 @@ $ benchstat result.txt
<details>
<summary>결과 raw 데이터</summary>

고정된 인자 개수 벤치마크 결과

```sh
goos: darwin
goarch: arm64
Expand Down Expand Up @@ -429,6 +513,8 @@ Fixed/BufferPreAlloc/100 2.000 ± 0%
geomean 2.030
```

가변 인자 개수 벤치마크 결과

```sh
goos: darwin
goarch: arm64
Expand Down Expand Up @@ -504,30 +590,50 @@ geomean 3.050

## 결과

> tl;dr 주어진 인자만큼 미리 capacity를 할당한 pre allocated `strings.Builder` 혹은 `strings.Join()`을 쓰자.
### 그래프로 비교해 보기 – 고정 인자

![고정된 인자 개수에서 실행 시간 비교](/images/20250105/fixed_sec.png)

인자의 길이 별로 실행 시간을 비교해 빠른 순서로 정렬했으며 pre allocated `strings.Builder` > `+` 연산자 > `strings.Join()` 순으로 빨랐음을 알 수 있다.
길이에 따라 빠른 방법이 바뀌기도 했으나 여기선 길이가 10일 때를 기준으로 정렬했다.

![고정된 인자 개수에서 메모리 사용량 비교](/images/20250105/fixed_memory.png)

로그 스케일이며 메모리 사용량은 위의 세 방식이 그대로 top 3였고 똑같이 사용량이 적었다.

![고정된 인자 개수에서 메모리 할당 횟수 비교](/images/20250105/fixed_allocs.png)

- 성능: pre allocated `strings.Builder` > `+` 연산자 > `strings.Join()` 순으로 빨랐다.
- 메모리 사용량: pre allocated `strings.Builder` = `+` 연산자 = `strings.Join()`으로 세 방식의 사용량이 똑같이 적었다.
- 메모리 할당 횟수: pre allocated `strings.Builder` = `+` 연산자 = `strings.Join()`으로 세 방식 모두 주어진 인자와 관계없이 최소한의 할당으로 수행했다.
메모리 할당 횟수도 세 방식 모두 주어진 인자와 관계없이 최소한의 할당으로 수행했다.

### 그래프로 비교해 보기 – 가변 인자

![가변 인자 개수에서 실행 시간 비교](/images/20250105/var_sec.png)

실행 시간은 pre allocated `strings.Builder` > `strings.Join()` 순으로 top2 결과를 보여준다.
로그 스케일임을 감안했을 때 `+=` 연산자 방식은 사용할 수 없는 수준이었다.

![가변 인자 개수에서 메모리 사용량 비교](/images/20250105/var_memory.png)

메모리 사용량도 top2가 똑같았다.

![가변 인자 개수에서 메모리 할당 횟수 비교](/images/20250105/var_allocs.png)

- 성능: pre allocated `strings.Builder` > `strings.Join()` 순으로 탑2 결과를 보여준다.
- 메모리 사용량: pre allocated `strings.Builder` = `strings.Join()`
- 메모리 할당 횟수: pre allocated `strings.Builder` = `strings.Join()`
- 로그 스케일임을 감안했을 때 `+=` 연산자를 사용하는 방식의 비효율이 눈에 띈다.
메모리 할당 횟수도 마찬가지며 로그 스케일임을 감안했을 때 `+=` 연산자를 사용하는 방식의 비효율이 눈에 띈다.

## 정리

결과를 정리해 보자면

1. `.Grow()` 메서드를 사용해 capacity를 미리 할당한 `strings.Builder` 혹은 `strings.Join()` 방법이 모든 상황에서 가장 빠르고 효율적인 방법이었다.
- 생각보다 `strings.Join()`도 빠른 방법이었다는 게 의외였다.
2. 인자 개수가 고정된 상황에선 `+`로 이어 쓰는 방법도 충분히 좋았다.
3. 느린 방법이 왜 느리고 빠른 방법은 왜 빠른가 알아보는 것도 의미 있겠으나 그 부분은 글을 읽는 분들의 재미로 남겨두고자 한다.

## 참고해볼만한
## 참고해 볼만한

* 2024년 [Max Hoffman](https://github.com/max-hoffman)의 글: [fmt.Sprintf vs String Concat](https://www.dolthub.com/blog/2024-11-08-sprintf-vs-concat/)
*`fmt.Sprintf`는 느리고 `+`는 빠른지 설명해준다.
* 2020년 [cloudrain21](https://github.com/cloudrain21)의 글 [Go – String 을 어떻게 빠르게 이어붙일까?(String Concatenation)](http://cloudrain21.com/go-how-to-concatenate-strings)
* 2020년 [cloudrain21](https://github.com/cloudrain21)의 글: [Go – String 을 어떻게 빠르게 이어붙일까?(String Concatenation)](http://cloudrain21.com/go-how-to-concatenate-strings)
* "golang string concat" 키워드로 검색했을 때 한국어 상위 결과로 나오는 글
* 엄밀한 벤치마크 결과는 아니라 결과 해석에 한계가 있다.
* `bytes.Buffer``strings.Join` 내부 동작을 설명해준다.
* 이 글에선 인자의 고정 여부를 엄밀히 구분하진 않았고, 벤치마크로 수행된 결과가 아니라 결과 해석에 한계가 있다.

0 comments on commit c0daffb

Please sign in to comment.