Go의 문자열 내부: UTF-8 및 일반적인 연산
Emily Parker
Product Engineer · Leapcell

Go의 문자열 처리 방식은 우아하고 실용적입니다. 문자열을 단순한 바이트 배열로 취급하거나 암묵적으로 ASCII를 가정하는 일부 언어와 달리, Go는 네이티브로 UTF-8을 채택합니다. 이러한 디자인 선택은 다국어 텍스트 작업을 단순화하고 문자 인코딩과 관련된 일반적인 함정을 피합니다. 이 문서는 Go가 UTF-8을 사용하여 문자열을 내부적으로 어떻게 표현하는지 철저히 살펴보고, 이를 조작하는 일반적이고 효율적인 방법을 시연합니다.
Go 문자열의 불변성
무엇보다 먼저, **Go 문자열은 불변(immutable)**하다는 것을 이해하는 것이 중요합니다. 문자열이 생성되면 해당 내용을 변경할 수 없습니다. 연결 또는 트리밍과 같이 문자열을 수정하는 것처럼 보이는 모든 연산은 실제로 새로운 문자열을 생성합니다. 이러한 불변성은 동시성을 단순화하고 데이터 무결성을 보장하여, 여러 고루틴이 수정에 대한 두려움 없이 동일한 문자열을 안전하게 읽을 수 있도록 합니다.
Go 문자열은 본질적으로 읽기 전용 바이트 슬라이스입니다. 이의 내부 표현은 두 단어 데이터 구조입니다. 즉, 문자열 내용을 보유하는 바이트 배열에 대한 포인터와 해당 길이를 나타내는 정수입니다.
// 문자열의 내부 표현 (개념적이며 직접 접근할 수 없습니다) type StringHeader struct { Data uintptr // 기본 바이트 배열에 대한 포인터 Len int // 바이트 단위 문자열 길이 }
UTF-8: Go의 네이티브 인코딩
Go의 UTF-8에 대한 약속은 기본입니다. Go 소스 코드의 모든 문자열 리터럴은 UTF-8로 인코딩됩니다. 이는 중국어, 일본어, 한국어 또는 이모티콘과 같은 다양한 언어의 문자를 직접 작업하는 것이 원활하다는 것을 의미합니다.
UTF-8은 가변 길이 인코딩입니다. 즉, 다른 문자가 다른 수의 바이트를 차지할 수 있습니다.
- ASCII 문자(U+0000 ~ U+007F)는 1바이트를 차지합니다.
- 대부분의 유럽 문자('é', 'ñ' 등)는 2바이트를 차지합니다.
- 일반적인 CJK 문자(중국어, 일본어, 한국어)는 3바이트를 차지합니다.
- 일부 희귀 문자 또는 이모티콘은 4바이트를 차지할 수 있습니다.
이를 예제로 설명해 보겠습니다.
package main import ( "fmt" "unicode/utf8" ) func main() { s1 := "hello" // ASCII 전용 s2 := "你好世界" // 중국어 문자 s3 := "Go Gopher 🤘" // 이모티콘 포함 유니코드 fmt.Printf("String: \"%s\", Length (bytes): %d\n", s1, len(s1)) fmt.Printf("String: \"%s\", Length (bytes): %d\n", s2, len(s2)) fmt.Printf("String: \"%s\", Length (bytes): %d\n", s3, len(s3)) fmt.Println("\n--- Counting Runes (Characters) ---") fmt.Printf("String: \"%s\", Length (runes): %d\n", s1, utf8.RuneCountInString(s1)) fmt.Printf("String: \"%s\", Length (runes): %d\n", s2, utf8.RuneCountInString(s2)) fmt.Printf("String: \"%s\", Length (runes): %d\n", s3, utf8.RuneCountInString(s3)) }
출력:
String: "hello", Length (bytes): 5
String: "你好世界", Length (bytes): 12
String: "Go Gopher 🤘", Length (bytes): 13
--- Counting Runes (Characters) ---
String: "hello", Length (runes): 5
String: "你好世界", Length (runes): 4
String: "Go Gopher 🤘", Length (runes): 11
len(s)
와 utf8.RuneCountInString(s)
사이의 차이를 주목하세요.
len(s)
는 문자열의 바이트 수를 반환합니다.utf8.RuneCountInString(s)
는 문자열의 룬(rune) (유니코드 코드 포인트 또는 문자) 수를 반환합니다. 이것이 일반적으로 문자열의 "길이"라고 말할 때 의미하는 바입니다.
문자열 반복
문자열은 UTF-8로 인코딩된 바이트 시퀀스이기 때문에, for
루프를 사용하여 직접 반복하면 개별 바이트가 생성되고 문자가 생성되지 않습니다.
str := "你好" for i := 0; i < len(str); i++ { fmt.Printf("Byte at index %d: %x\n", i, str[i]) } // 출력: // Byte at index 0: e4 // Byte at index 1: bd // Byte at index 2: a0 // Byte at index 3: e5 // Byte at index 4: a5 // Byte at index 5: bd
유니코드 코드 포인트(룬)를 반복하려면 Go는 문자열에 대한 특수 for...range
루프 구문을 제공합니다.
str := "你好Go 🌎" for i, r := range str { fmt.Printf("Code point '%c' (U+%04X) at byte index %d\n", r, r, i) } // 출력: // Code point '你' (U+4F60) at byte index 0 // Code point '好' (U+597D) at byte index 3 // Code point 'G' (U+0047) at byte index 6 // Code point 'o' (U+006F) at byte index 7 // Code point ' ' (U+0020) at byte index 8 // Code point '🌎' (U+1F30E) at byte index 9
for...range
루프는 UTF-8 시퀀스를 rune
값으로 올바르게 디코딩합니다. i
는 룬의 시작 바이트 인덱스가 되고, r
은 rune
(int32
의 별칭)이 됩니다.
일반적인 문자열 연산
Go의 표준 라이브러리, 특히 strings
및 strconv
패키지는 문자열 조작을 위한 풍부한 함수 세트를 제공합니다.
1. 문자열 변환
-
문자열을 바이트 슬라이스로: 문자열은
[]byte
슬라이스로 변환할 수 있으며, 이는 수정할 수 있습니다. 이는 암묵적으로 새로운 기본 배열을 생성합니다.s := "Hello" b := []byte(s) b[0] = 'h' // 바이트 슬라이스 수정 fmt.Println(string(b)) // 문자열로 다시 변환 (새 문자열 생성) -> "hello"
-
바이트 슬라이스를 문자열로:
[]byte
를string
으로 변환하면 바이트를 복사하여 새 문자열을 생성합니다.b := []byte{'G', 'o'} s := string(b) fmt.Println(s) // "Go"
-
문자열을 룬 슬라이스로:
string
을[]rune
슬라이스로 변환하면 개별 문자를 직접 조작할 수 있습니다. 이것도 새로운 슬라이스를 생성합니다.s := "你好" r := []rune(s) r[0] = '您' // 첫 번째 문자 변경 fmt.Println(string(r)) // 문자열로 다시 변환 -> "您好"
2. 연결
Go의 문자열 연결은 새 문자열을 생성합니다. +
연산자는 적은 수의 연결에 편리하지만, 반복적인 메모리 할당과 복사로 인해 많은 연산에 비효율적일 수 있습니다.
비효율적인 연결:
var s string for i := 0; i < 1000; i++ { s += "a" // 각 +=는 새 문자열을 생성합니다 } // 이는 1000번의 문자열 할당 및 복사를 수행합니다.
strings.Builder
를 사용한 효율적인 연결:
문자열을 반복적으로 빌드하려면 strings.Builder
가 매우 권장됩니다. 이는 내부 바이트 버퍼를 유지하여 재할당을 최소화합니다.
import ( "strings" "fmt" ) func main() { var sb strings.Builder sb.Grow(1000) // 선택 사항: 최종 크기를 알면 용량을 미리 할당 for i := 0; i < 1000; i++ { sb.WriteString("a") } finalString := sb.String() fmt.Println("Length of built string:", len(finalString)) // 이는 훨씬 적은 할당과 복사를 수행하여 성능을 향상시킵니다. }
3. 부분 문자열 추출
문자열은 바이트 시퀀스이므로 슬라이싱하면 기본 바이트 배열을 공유하는 새 문자열이 생성됩니다. 그러나 멀티바이트 룬을 다룰 때는 바이트 인덱스에 주의해야 합니다.
s := "你好世界" // 12 바이트, 4 룬 sub1 := s[0:6] // "你好" - 첫 두 룬(각 3바이트)에 대해 올바름 sub2 := s[0:7] // "你好" - 잘못됨, 멀티바이트 룬 분리, 대체 문자 '' 발생 fmt.Println(sub1) fmt.Println(sub2) // 룬 수로 부분 문자열을 얻거나 안전하게 슬라이스하기 위해 []rune으로 변환: r := []rune(s) subRune1 := string(r[0:2]) // "你好" subRune2 := string(r[2:]) // "世界" fmt.Println(subRune1) fmt.Println(subRune2)
중요: 직접적인 슬라이싱 s[start:end]
는 항상 바이트 인덱스를 기준으로 작동합니다. start
또는 end
가 멀티바이트 UTF-8 시퀀스의 중간에 떨어지면 결과 부분 문자열은 잘못된 UTF-8을 포함하고 대체 문자가 표시됩니다. 강력하고 문자를 인식하는 슬라이싱을 위해서는 먼저 []rune
으로 변환하세요.
4. 검색 및 바꾸기
strings
패키지는 검색 및 바꾸기를 위한 다양한 함수를 제공합니다:
import "strings" func main() { text := "Go is a great language. Go is simple." // Contains fmt.Println("Contains 'Go':", strings.Contains(text, "Go")) // true // Index fmt.Println("Index of 'great':", strings.Index(text, "great")) // 8 fmt.Println("Last index of 'Go':", strings.LastIndex(text, "Go")) // 24 // HasPrefix, HasSuffix fmt.Println("Starts with 'Go':", strings.HasPrefix(text, "Go")) // true fmt.Println("Ends with 'simple.':", strings.HasSuffix(text, "simple.")) // true // Replace newText := strings.Replace(text, "Go", "Golang", 1) // 첫 번째 발생 대체 fmt.Println("Replaced once:", newText) // Golang is a great language. Go is simple. newTextAll := strings.ReplaceAll(text, "Go", "Golang") // 모든 발생 대체 fmt.Println("Replaced all:", newTextAll) // Golang is a great language. Golang is simple. }
5. 대소문자 변환
import "strings" func main() { s := "Hello World" fmt.Println(strings.ToLower(s)) // hello world fmt.Println(strings.ToUpper(s)) // HELLO WORLD // 유니코드 인식 대소문자 폴딩(예: 터키어 'i')을 위해 Go의 strings.ToUpper/ToLower가 모든 엣지 케이스를 처리하지 못하므로 unicode.ToUpper/ToLower를 사용하세요. }
6. 트리밍
선행/후행 공백 또는 지정된 문자를 제거합니다.
import "strings" func main() { s := " Hello World \n" fmt.Printf("Trimmed space: \"%s\"\n", strings.TrimSpace(s)) // "Hello World" s2 := "abccbaHelloabccba" // 컷셋 기반으로 시작 및 끝 문자 제거 fmt.Printf("Trimmed cutset: \"%s\"\n", strings.Trim(s2, "abc")) // "Hello" }
성능 고려 사항
Go는 문자열 작업을 쉽게 하지만, 내부 메커니즘을 이해하면 성능이 뛰어난 코드를 작성하는 데 도움이 됩니다.
- 불변성 및 복사: 거의 모든 문자열 연산(연결, 슬라이싱, 변환)은 새 문자열(및 잠재적으로 새 기본 바이트 배열)을 생성합니다. 성능이 중요한 루프에서 자주 수행되는 경우 메모리 할당 및 가비지 컬렉션 오버헤드가 발생할 수 있습니다.
- 문자열 빌드를 위한
strings.Builder
: 많은 작은 조각으로 문자열을 구성할 때는 항상strings.Builder
를 선호하세요. []byte
대string
변환:string
과[]byte
간의 변환에는 데이터 복사가 포함됩니다. 바이트로만 처리하면 되는 문자열을 빌드하는 경우, 작업 전반에 걸쳐[]byte
를 사용하는 것을 고려하세요.- 룬 인식 대 바이트 단위 연산:
[]rune
슬라이스에 대한 연산은 UTF-8 디코딩 및 인코딩을 포함하기 때문에 종종 기본 바이트 단위 연산보다 계산 비용이 더 많이 듭니다. 작업에 맞는 도구를 선택하세요. 바이트만 필요하다면(예: 네트워크 프로토콜, 파일 직렬화),[]byte
를 사용하세요. 문자를 조작해야 하는 경우[]rune
또는 문자 반복을 위해for...range
를 사용하세요. - 벤치마킹: 성능이 가장 중요하다면, 실제 영향을 이해하기 위해 항상 문자열 연산을 벤치마킹하세요.
package main import ( "bytes" "fmt" "strings" "testing" ) func benchmarkConcatenation(b *testing.B, strategy string) { s := "some_string_part_" num := 1000 // 연결 횟수 b.ResetTimer() for i := 0; i < b.N; i++ { switch strategy { case "plus": result := "" for j := 0; j < num; j++ { result += s } case "strings.Builder": var sb strings.Builder sb.Grow(len(s) * num) // 사전 할당으로 최적화 for j := 0; j < num; j++ { sb.WriteString(s) } _ = sb.String() case "bytes.Buffer": // 또 다른 대안, 순수 문자열에는 덜 일반적 var buf bytes.Buffer buf.Grow(len(s) * num) for j := 0; j < num; j++ { buf.WriteString(s) } _ = buf.String() } } } func BenchmarkConcatenationPlus(b *testing.B) { benchmarkConcatenation(b, "plus") } func BenchmarkConcatenationStringsBuilder(b *testing.B) { benchmarkConcatenation(b, "strings.Builder") } func BenchmarkConcatenationBytesBuffer(b *testing.B) { benchmarkConcatenation(b, "bytes.Buffer") } // 이 벤치마크를 실행하는 방법: // go test -bench=. -benchmem -run=none // 예제 출력 (기계에 따라 다름): // goos: darwin // goarch: arm64 // pkg: example/string_bench // BenchmarkConcatenationPlus-8 162 7077677 ns/op 799981 B/op 1000 allocs/op // BenchmarkConcatenationStringsBuilder-8 19782 59114 ns/op 4088 B/op 4 allocs/op // BenchmarkConcatenationBytesBuffer-8 18042 67073 ns/op 4088 B/op 4 allocs/op
벤치마크 결과는 특히 할당 및 메모리 사용 측면에서 반복적인 +
연결에 비해 strings.Builder
(bytes.Buffer
포함)의 상당한 성능 이점을 명확하게 보여줍니다.
결론
Go의 문자열 처리는 단순성, 안전성, 효율성이라는 디자인 철학을 강력하게 증명합니다. UTF-8을 표준화함으로써 국제화의 많은 일반적인 함정을 피해갑니다. 문자열이 내부적으로 불변이고 바이트 지향적인 슬라이스라는 것을 이해하고, 룬 반복을 위해 for...range
루프 또는 효율적인 구성을 위해 strings.Builder
를 신중하게 사용하면 Go 개발자는 모든 텍스트 데이터에 대해 강력하고 성능이 뛰어난 코드를 작성할 수 있습니다. Go의 문자열 모델을 채택하면 텍스트 작업이 훨씬 더 즐거운 경험이 될 것입니다.