Go – String 을 어떻게 빠르게 이어붙일까?(String Concatenation)
무언가를 구현할 때 반드시 한가지 방법만 있는 경우는 거의 없다.
string 을 이어붙이는 방법도 마찬가지다.
가장 일반적으로 사용하는 4~5 가지 방법을 중심으로 사용방법과 성능에 대해 잠깐 생각해보는 시간을 가져보자.
- + 연산자를 이용하는 방법
- bytes.Buffer 의 WriteString() 함수를 이용하는 방법
- fmt.Sprintf() 함수를 이용하는 방법
- += 연산자를 이용하는 방법
- string slice 의 요소 string 들을 strings.Join() 함수로 이어붙이는 방법
package main import ( "bytes" "fmt" "strings" ) func main() { // 1. str1 := "Welcome " str2 := "Rain!" str := str1 + str2 fmt.Println(str) str = str1 + "my " + str2 fmt.Println(str) // 2. var b bytes.Buffer b.WriteString("R") b.WriteString("a") b.WriteString("i") b.WriteString("n") fmt.Println(b.String()) // 3. str = fmt.Sprintf("%s%s", str1, str2) fmt.Println(str) // 4. str = "" str += str1 str += str2 fmt.Println(str) // 5. mySlice := []string{"Welcome", "my", "Rain!"} str = strings.Join(mySlice, " * ") fmt.Println(str) }
string 이어붙이기 성능을 알아보기 위해 위의 방법들에 대해 간단히 시간 측정을 해보았다.
+ 와 += 를 이용한 방법은 동일하다고 볼 수 있으므로 아래 4 가지 방법에 대한 측정 결과를 보자.
- += 연산자를 이용하는 방법
- bytes.Buffer 의 WriteString() 함수를 이용하는 방법
- fmt.Sprintf() 함수를 이용하는 방법
- string slice 의 요소 string 들을 strings.Join() 함수로 이어붙이는 방법
package main import ( "bytes" "strings" //"bytes" "fmt" //"strings" "time" ) func main() { str := "" str1 := "AAAAAAAAAA" // 1. start := time.Now() for i := 0; i<100000; i++ { str += str1 } elapsed := time.Since(start) fmt.Printf("strlen(%d) : %v\n", len(str), elapsed) // 2. var b bytes.Buffer str = "" start = time.Now() for i := 0; i<100000; i++ { b.WriteString(str1) } str = b.String() elapsed = time.Since(start) fmt.Printf("strlen(%d) : %v\n", len(str), elapsed) // 3. str = "" start = time.Now() for i := 0; i<100000; i++ { str = fmt.Sprintf("%s%s", str, str1) } elapsed = time.Since(start) fmt.Printf("strlen(%d) : %v\n", len(str), elapsed) // 4. str = "" mySlice := []string{} for i := 0; i<100000; i++ { mySlice = append(mySlice, str1) } start = time.Now() str = strings.Join(mySlice, "") elapsed = time.Since(start) fmt.Printf("strlen(%d) : %v\n", len(str), elapsed) }
1. strlen(1000000) : 5.306749611s 2. strlen(1000000) : 1.309558ms 3. strlen(1000000) : 11.813286425s 4. strlen(1000000) : 1.644996ms
시험 결과를 빠른 순서대로 순위를 매겨보면 다음과 같다.
1 등 : bytes.Buffer 의 WriteString() 함수를 이용하는 방법
2 등 : string slice 의 요소 string 들을 strings.Join() 함수로 이어붙이는 방법
3 등 : += 연산자를 이용하는 방법
4 등 : fmt.Sprintf() 함수를 이용하는 방법
단지 string 을 이어붙이는 작업에서 왜 이런 큰 성능 차이가 발생할까?
이러한 부분을 간과하고 사용하면 성능에서 자칫 큰 손해를 볼 수 있으니 내부 구현을 좀 알고 사용하는 것이 좋겠다.
bytes.Buffer 의 WriteString()을 이용한 이어붙이기
go standard library 에 구현된 WriteString() 의 코드를 보면 다음과 같다.
type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] lastRead readOp // last read operation, so that Unread* can work correctly. } func (b *Buffer) tryGrowByReslice(n int) (int, bool) { if l := len(b.buf); n <= cap(b.buf)-l { b.buf = b.buf[:l+n] return l, true } return 0, false } func (b *Buffer) WriteString(s string) (n int, err error) { b.lastRead = opInvalid m, ok := b.tryGrowByReslice(len(s)) if !ok { m = b.grow(len(s)) } return copy(b.buf[m:], s), nil }
WriteString() 함수는 결국 Buffer 구조체가 가지고 있는 byte slice(buf)에 인자로 받은 string 을 이어붙이기 하는 함수이다.
그런데, 처음에는 이미 할당된 slice 공간을 이용하려고 시도(tryGrowByReslice)하지만, 공간이 모자라면 결국 slice 의 capacity 자체를 늘린 후(grow) string 을 복사하게 된다.
따라서, WriteString()함수는 slice 에 계속해서 데이터를 추가해나가는 작업의 속도라고 할 수 있다.
그러니까, 빠르다 !
Java 에서 빠른 이어붙이기를 할 때 StringBuilder 나 StringBuffer 를 사용하는 이유도 내부적으로 이와 유사하게 동작하기 때문이다.
strings.Join() 을 이용한 이어붙이기
strings.Join() 의 경우도 slice 에 존재하는 요소들을 모두 이어붙이는데 WriteString()을 이용한 위의 방법과 거의 유사한 성능이 나왔다. (1.644996ms)
Join() 함수의 구현을 살펴보면 그 이유를 알 수 있는데, 그 핵심은 내부적으로 결국 slice 의 공간을 확보하고 이어붙이는 방법은 거의 WriteString()과 유사하기 때문이다.
Join() 함수의 경우 separator 의 공간 확보를 위한 계산을 제외하고는 내부에서 Builder.WriteString() 함수를 사용하는데, 해당 함수는 내부적으로 Builder 구조체 내의 byte slice 에 string 을 append 하는 작업이다.
type Builder struct { addr *Builder // of receiver, to detect copies by value buf []byte } func Join(a []string, sep string) string { switch len(a) { case 0: return "" case 1: return a[0] } n := len(sep) * (len(a) - 1) for i := 0; i < len(a); i++ { n += len(a[i]) } var b Builder b.Grow(n) b.WriteString(a[0]) for _, s := range a[1:] { b.WriteString(sep) b.WriteString(s) } return b.String() } func (b *Builder) WriteString(s string) (int, error) { b.copyCheck() b.buf = append(b.buf, s...) return len(s), nil } func (b *Builder) String() string { return *(*string)(unsafe.Pointer(&b.buf)) }
+= 또는 + 를 이용한 이어붙이기
+= 를 이용하여 이어붙이기하는 성능은 slice 를 이용한 위의 두 방법에 비해 상당히 좋지 않은 성능이 나왔다.(5.306749611s)
수백배 이상의 차이다.
직관적으로 사용하기에는 좋아서 작은 갯수의 string 을 이어붙일 때는 전혀 상관이 없지만(오히려 좋은 결과가 나올 수도 있다), 아주 빈번하고 많은 양의 이어붙이기를 하는 경우에는 native plus operator 는 지양하는 것이 좋겠다.
+= 나 + 연산을 이용하는 것이 느린 이유는 string 을 이어붙일 때마다 새로운 공간을 할당하고 기존의 string 을 복사하는 작업을 수행하기 때문이다.
이러한 작업은 string 의 길이가 길어지면 길어질수록 기하급수적으로(O(N^2)) 느려질 수 밖에 없다.
fmt.Sprintf() 를 이용한 이어붙이기
fmt.Sprintf() 함수를 이용하는 방법이 압도적인 꼴찌를 기록했는데(11.813286425s), 그 이유는 formatting 을 위한 수많은 복잡한 연산을 수행하기 때문이다.
아래 코드 중 doPrintf() 함수가 이에 해당된다.
성능이 꼭 필요한 부분인데 format 을 굳이 사용하지 않아도 되는 경우라면 fmt.Sprintf() 를 이용하여 string 이어붙이기 작업을 할 필요는 없을 것이다.
func Sprintf(format string, a ...interface{}) string { p := newPrinter() p.doPrintf(format, a) s := string(p.buf) p.free() return s }
+, += 연산자나 Sprintf() 의 성능이 좋지 않기 때문에 모든 경우에서 배제하는건 어리석은 일이다.
분명한 사용 편의성이 존재하고 코드를 명확하고 아름답게 해주는 장점 또한 존재하기 때문이다.
이게 맞네 저게 맞네하면서 편가르고 싸우는 것 보다, 다양한 방법들을 정확히 이해하는 상태에서 상황에 맞게 다양한 방법을 사용할 줄 아는 것 또한 개발자의 역량이라는 것을 잊지 말자.
좋은 실험 감사합니다. string을 다루는 데에 도움이 되었습니다.
4번 실험에서 mySlice에 append 하는 과정도 측정 시간에 포함해야 공평한 비교가 될 것 같다는 생각이 드는데, 어떻게 생각하시나요?
mySlice 에 append 하는 과정은 Join 으로 이어붙이기 위한 문자열 사전준비 과정이라고 보고 시간측정에서는 뺐어요.
(slice 에 준비된 문자열들을) Join 으로 하나하나 모두 붙이는데 얼마나 걸리나를 측정하려는거죠.
좋은 정보 잘봤습니다~~
오…. 성능차이가 엄청나네요 그만큼 적재적소에 사용해야겠지만요
성능 측정이 잘못된 듯 합니다.
https://github.com/go101/go-benchmarks/blob/master/string-concatenation/readme.md
환경에 따라 다를 수 있을텐데, +연산과 sprintf가 느리고 bytesbuffer가 빠른 것은 대체로 맞습니다.
+와 sprintf는 정해진 갯수의 string을 붙일 때만 빠르거든요.
이건 정해진 string 갯수를 이어붙이는 시험이 아닙니다.
제 맥북에서 링크주신 코드로 benchmark를 돌리면 아래처럼 나옵니다.
어떤 부분이 잘못되었다고 지적하시는건지 구체적으로 말씀해주시면 제가 다시 확인해보겠습니다.
다만, bytes buffer 사용 시험에 대해 링크주신 코드에는 매번 b.String()을 해주고 있는데,
제 시험에서는 이어붙인 최종 결과만을 b.String() 하도록 하고 있어서 여기에서 성능차이가 날 수 있습니다.
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: test
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
Benchmark_AddStrings_KnownCount-16 27656432 44.02 ns/op
Benchmark_AddStrings_UnnownCount-16 9072488 116.4 ns/op
Benchmark_FmtSprintf_KnownCount-16 5525997 217.8 ns/op
Benchmark_StringsJoin_NoSliceAllocated-16 22859484 47.86 ns/op
Benchmark_StringsJoin_SliceAllocated-16 10264592 113.4 ns/op
Benchmark_BytesBuffer_New-16 15988022 69.00 ns/op
Benchmark_AddStrings_Buffer_Reuse-16 26252228 42.93 ns/op
Benchmark_AddStrings_Builder-16 20429253 56.88 ns/op