Go – String 을 어떻게 빠르게 이어붙일까?(String Concatenation)

무언가를 구현할 때 반드시 한가지 방법만 있는 경우는 거의 없다.
string 을 이어붙이는 방법도 마찬가지다.
가장 일반적으로 사용하는 4~5 가지 방법을 중심으로 사용방법과 성능에 대해 잠깐 생각해보는 시간을 가져보자.

  1. + 연산자를 이용하는 방법
  2. bytes.Buffer 의 WriteString() 함수를 이용하는 방법
  3. fmt.Sprintf() 함수를 이용하는 방법
  4. += 연산자를 이용하는 방법
  5. 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 가지 방법에 대한 측정 결과를 보자.

  1. += 연산자를 이용하는 방법
  2. bytes.Buffer 의 WriteString() 함수를 이용하는 방법
  3. fmt.Sprintf() 함수를 이용하는 방법
  4. 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() 의 성능이 좋지 않기 때문에 모든 경우에서 배제하는건 어리석은 일이다.
분명한 사용 편의성이 존재하고 코드를 명확하고 아름답게 해주는 장점 또한 존재하기 때문이다.
이게 맞네 저게 맞네하면서 편가르고 싸우는 것 보다, 다양한 방법들을 정확히 이해하는 상태에서 상황에 맞게 다양한 방법을 사용할 줄 아는 것 또한 개발자의 역량이라는 것을 잊지 말자.

You may also like...