티스토리 뷰
go를 사용하다 보면 문자열과 바이트 슬라이스를 상호 변환하여 사용해야 되는 경우가 자주 발생합니다. 특히 io.Writer 인터페이스의 Write 메서드가 인수로 바이트 슬라이스를 넘겨받기 때문에 더 그런 것 같습니다.
문자열을 바이트 슬라이스로 변환하거나 바이트 슬라이스를 문자열로 변환하는 방법은 여러 가지가 있습니다. 이번 게시물에서는 각 방법들을 살펴보고 벤치마킹을 통해 성능을 비교해 보겠습니다.
1. string -> []byte 변환
1.1 Type Conversion
// 1. 타입 컨버젼을 사용하는 방법
func StringToBytesConversion(s string) []byte {
return []byte(s)
}
타입 컨버젼을 사용하는 방법은 문자열을 []byte()로 감싸주면 됩니다.
1.2 copy 함수 사용
// 2. copy() 함수를 사용하는 방법
func StringToBytesCopy(s string) []byte {
b := make([]byte, len(s))
copy(b, s)
return b
}
문자열과 길이가 동일한 바이트 슬라이스를 생성한 뒤에 copy 함수를 사용하여 문자열을 바이트 슬라이스로 복사하는 방법입니다.
1.3 reflect 패키지 사용
// 3. reflect 패키지를 사용하는 방법
func StringToBytesReflect(s string) []byte {
return reflect.ValueOf(s).Convert(reflect.TypeOf([]byte(nil))).Bytes()
}
reflect 패키지를 사용하여 문자열을 바이트 슬라이스로 컨버팅하여 반환하는 방법입니다.
1.4 unsafe 패키지 사용
// 4. unsafe 패키지를 사용하는 방법
func StringToBytesUnsafe(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
unsafe 패키지의 unsafe.Slice와 unsafe.StringData를 사용하여 문자열을 바이트 슬라이스로 변환하는 방법입니다.
1.5 벤치마킹
func BenchmarkStringToBytesConversion(b *testing.B) {
for i := 0; i < b.N; i++ {
StringToBytesConversion("hello world")
}
}
func BenchmarkStringToBytesCopy(b *testing.B) {
for i := 0; i < b.N; i++ {
StringToBytesCopy("hello world")
}
}
func BenchmarkStringToBytesReflect(b *testing.B) {
for i := 0; i < b.N; i++ {
StringToBytesReflect("hello world")
}
}
func BenchmarkStringToBytesUnsafe(b *testing.B) {
for i := 0; i < b.N; i++ {
StringToBytesUnsafe("hello world")
}
}
$ go test -bench='StringToBytes*' -benchmem
goos: linux
goarch: amd64
pkg: string_to_bytes
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkStringToBytesConversion-8 205739142 5.972 ns/op 0 B/op 0 allocs/op
BenchmarkStringToBytesCopy-8 45552547 22.58 ns/op 16 B/op 1 allocs/op
BenchmarkStringToBytesReflect-8 5649340 180.0 ns/op 56 B/op 3 allocs/op
BenchmarkStringToBytesUnsafe-8 1000000000 0.6051 ns/op 0 B/op 0 allocs/op
PASS
ok string_to_bytes 4.785s
성능상으로는 reflect 패키지를 사용하는 방법이 가장 느리면서도 루프마다 힙 할당이 세 번이나 발생하므로 가장 비효율적입니다. 반면에 unsafe 패키지를 사용한 방법은 굉장히 빠르면서도 힙 할당이 발생하지 않는 것을 확인할 수 있습니다.
그렇다면 당연히 압도적인 성능을 보이는 unsafe 패키지를 사용해서 문자열을 바이트 슬라이스로 변환하는 것이 좋아 보이지만, unsafe 패키지를 사용하는 방법은 변환을 위해 값을 복사해서 사용하지 않기 때문에 발생하는 문제점들을 가지고 있습니다. 이와 관련해서는 바이트 슬라이스를 문자열로 변환하는 과정을 살펴본 뒤에 아래에서 다뤄보겠습니다.
2. []byte -> string 변환
2.1 Type Conversion
// 1. 타입 컨버젼을 사용하는 방법
func BytesToStringConversion(b []byte) string {
return string(b)
}
타입 컨버젼을 사용하는 방법은 바이트 슬라이스를 string()로 감싸주면 됩니다.
2.2 reflect 패키지 사용
// 2. reflect 패키지를 사용하는 방법
func BytesToStringReflect(b []byte) string {
return reflect.ValueOf(b).Convert(reflect.TypeOf("")).String()
}
reflect 패키지를 사용하여 바이트 슬라이스를 문자열로 컨버팅하여 반환하는 방법입니다.
2.3 unsafe 패키지 사용
// 3. unsafe 패키지를 사용하는 방법
func BytesToStringUnsafe(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
usafe 패키지의 unsafe.String과 unsafe.SliceData를 사용하여 바이트 슬라이스를 문자열로 변환하는 방법입니다.
2.4 벤치마킹
func BenchmarkBytesToStringConversion(b *testing.B) {
for i := 0; i < b.N; i++ {
BytesToStringConversion([]byte{104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100})
}
}
func BenchmarkBytesToStringReflect(b *testing.B) {
for i := 0; i < b.N; i++ {
BytesToStringReflect([]byte{104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100})
}
}
func BenchmarkBytesToStringUnsafe(b *testing.B) {
for i := 0; i < b.N; i++ {
BytesToStringUnsafe([]byte{104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100})
}
}
$ go test -bench='BytesToString*' -benchmem
goos: linux
goarch: amd64
pkg: string_to_bytes
cpu: Intel(R) Core(TM) i5-10210U CPU @ 1.60GHz
BenchmarkBytesToStringConversion-8 127944001 9.490 ns/op 0 B/op 0 allocs/op
BenchmarkBytesToStringReflect-8 5061721 240.8 ns/op 72 B/op 4 allocs/op
BenchmarkBytesToStringUnsafe-8 522856016 2.053 ns/op 0 B/op 0 allocs/op
PASS
ok string_to_bytes 4.940s
이번에는 reflect 패키지를 사용하는 방법에서 힙 할당이 네 번이나 발생했습니다. unsafe 패키지를 사용하는 방법은 앞서 문자열을 바이트 슬라이스로 변환할 때와 마찬가지로 가장 빠르면서 힙 할당이 발생하지 않았습니다.
3. unsafe 패키지는 unsafe 하다
go에서 문자열은 기본적으로 immutable 합니다. 그러나 unsafe 패키지를 사용하여 바이트 슬라이스를 문자열로 변환할 경우, 값이 복사되지 않고 바이트 슬라이스의 원소들을 문자열이 그대로 가리키기 때문에 슬라이스의 원소를 변경하게 되면 아래와 같이 문자열이 변경되는 상황이 발생합니다.
func main() {
b := StringToBytesConversion("hello world") // 바이트 슬라이스를 생성한다.
s := BytesToStringUnsafe(b) // unsafe 패키지를 사용하여 바이트 슬라이스를 문자열로 변환한다.
fmt.Println(s) // 문자열이 출력된다. (hello world)
b[0] = 'H' // 바이트 슬라이스의 첫 번째 요소를 변경한다.
fmt.Println(s) // 문자열이 변경되어 출력된다. (Hello world)
}
$ go run .
hello world
Hello world
이렇게 문자열이 immutable 하다는 기본 원칙이 지켜지지 않음으로 인해 의도치 않은 버그가 발생할 수 있습니다. 단순히 성능만 보고 이 방법을 선택하기에는 안전성이 부족하므로 변경이 발생하지 않을 것이라는 나름의 확신이 뒷받침되었을 때 사용하는 것이 적절해 보입니다.
4. 마치며
타입 컨버젼을 사용해 변환하는 방법이 가장 쉽고 친숙해서 많이 사용했던 방법인데, 이번에 처음으로 다른 방법들을 알아보고 벤치마킹을 통해 성능 비교도 해보았습니다. 그동안 성능 개선에 대한 고민은 너무 제쳐놓고 기능 구현에만 급급하지 않았나 되돌아보게 되는 시간이었습니다.
📖 참고자료
글에서 수정이 필요한 부분이나 설명이 부족한 부분이 있다면 댓글로 남겨주세요!
'Go > 코딩 하기' 카테고리의 다른 글
[Go] gRPC 파헤치기 - 프로토콜 버퍼 (Protocol Buffers) (0) | 2023.10.12 |
---|---|
[Go] gRPC 파헤치기 - gRPC란? (0) | 2023.10.11 |
[Go] 잠자는 이발사 문제 (0) | 2023.09.30 |
[Go] 식사하는 철학자들 문제 (0) | 2023.09.29 |
[Go] 깃허브 프로필 최신 블로그 글 목록 자동으로 업데이트하기 (0) | 2023.09.06 |