문자열, 룬, 바이트
이 챕터에서 다루는 것
Go의 string은 "문자의 배열"이 아니라 불변 바이트열이다. 이 한 문장에서 len("한글")이
2가 아닌 이유, 인덱싱과 range가 다른 값을 주는 이유가 전부 따라 나온다. 한글을 다루는
이상 피해 갈 수 없는 주제라 정면으로 다룬다.
문제: "문자열의 길이"란 무엇인가
s := "Go 언어"
이 문자열의 길이는 몇인가? 답이 세 개다.
- 9 — UTF-8로 인코딩했을 때의 바이트 수
- 5 — 유니코드 코드 포인트(룬) 개수
- 5 — 사람이 세는 글자 수 (이 경우 룬 개수와 우연히 같다)
언어마다 어느 것을 len으로 부를지 다르게 정했다. Python 3의 len은 코드 포인트를 세고,
JavaScript의 .length는 UTF-16 코드 유닛을 세며, Java의 String.length()도 마찬가지다.
Go는 바이트를 센다.
Go가 이렇게 정한 이유는 string을 파일·네트워크에서 읽은 바이트열과 같은 것으로 두기
위해서다. 인코딩을 감추는 대신 드러내서, 어디서 비용이 드는지 코드에 보이게 했다.
string의 실제 구조
string은 내부적으로 바이트 배열을 가리키는 포인터 + 길이 두 워드짜리 값이다.
그리고 불변이다. 문자열을 대입하거나 함수에 넘겨도 바이트가 복사되지 않고 포인터만
복사되므로, "문자열을 넘기면 느리다"는 걱정은 하지 않아도 된다.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "Go 언어"
fmt.Println("len:", len(s))
fmt.Println("RuneCountInString:", utf8.RuneCountInString(s))
// 인덱싱은 바이트를 준다. 문자가 아니다.
fmt.Printf("s[0]=%v %c\n", s[0], s[0])
fmt.Printf("s[3]=%v %c\n", s[3], s[3])
fmt.Println("바이트 전체:", []byte(s))
// range는 UTF-8 디코딩을 하며 (바이트 오프셋, 룬) 쌍을 준다.
for i, r := range s {
fmt.Printf("i=%d r=%U %c (%d바이트)\n", i, r, r, utf8.RuneLen(r))
}
// []rune으로 바꾸면 코드 포인트 단위로 인덱싱할 수 있다. 다만 새로 할당된다.
rs := []rune(s)
fmt.Println("룬 개수:", len(rs), "마지막 룬:", string(rs[len(rs)-1]))
// 슬라이싱은 바이트 오프셋 기준이다. 경계를 잘못 잡으면 깨진다.
fmt.Printf("%q\n", s[:3])
fmt.Printf("%q\n", s[:4])
}
go run ./03-string-internals
len: 9
RuneCountInString: 5
s[0]=71 G
s[3]=236 ì
바이트 전체: [71 111 32 236 150 184 236 150 180]
i=0 r=U+0047 G (1바이트)
i=1 r=U+006F o (1바이트)
i=2 r=U+0020 (1바이트)
i=3 r=U+C5B8 언 (3바이트)
i=6 r=U+C5B4 어 (3바이트)
룬 개수: 5 마지막 룬: 어
"Go "
"Go \xec"
여기에 이 챕터의 거의 모든 내용이 들어 있다.
len(s)는 9. ASCII 3개(G,o, 공백) + 한글 2개 × 3바이트 = 9.s[0]은byte(=uint8) 이다. 71은'G'의 ASCII 코드다.%c로 찍으면 문자로 보이지만 타입은 숫자다.s[3]은 236.언의 첫 바이트다.%c로 찍으면ì라는 엉뚱한 문자가 나온다. 숫자 236을 하나의 룬으로 해석했기 때문이다. 인덱싱으로는 한글 한 글자를 얻을 수 없다.range는 다르다. UTF-8을 디코딩하며(바이트 오프셋, rune)을 준다. 오프셋이 0, 1, 2, 3, 6으로 뛴다. 한글이 3바이트를 차지하기 때문이다.- 슬라이싱도 바이트 단위다.
s[:4]는언의 첫 바이트만 가져와서"Go \xec"라는 깨진 문자열을 만든다.
:::info %U, %c, %q
fmt의 서식 동사 중 문자열 디버깅에 특히 유용한 것들이다. %U는 U+C5B8 형태의 코드
포인트, %c는 그 룬이 나타내는 문자, %q는 이스케이프를 살린 따옴표 문자열을 출력한다.
깨진 바이트가 있는지 확인할 때는 %q가 가장 빠르다.
:::
byte와 rune
byte | rune | |
|---|---|---|
| 실제 타입 | uint8 | int32 |
| 의미하는 것 | UTF-8 바이트 하나 | 유니코드 코드 포인트 하나 |
| 얻는 방법 | s[i], []byte(s) | range s, []rune(s) |
| 리터럴 | 'A'를 byte로 대입 | '가' |
'가'처럼 작은따옴표로 쓰는 것이 룬 리터럴이다. 큰따옴표 "가"는 문자열이다.
룬 리터럴의 기본 타입은 rune이다.
[]byte(s)와 []rune(s)는 둘 다 새로 할당하고 복사한다. 문자열이 불변이라 그럴 수밖에
없다. 긴 문자열을 반복해서 []rune으로 바꾸는 코드는 조용히 느려진다.
불변성이 만드는 것들
package main
import (
"fmt"
"strings"
)
func main() {
s := "hello"
// s[0] = 'H' 는 컴파일 에러다. 문자열은 불변이다.
// 바꾸려면 새 문자열을 만든다.
t := "H" + s[1:]
fmt.Println(s, t)
// []byte로 복사해서 고치고 다시 문자열로 만드는 방법도 있다.
b := []byte(s)
b[0] = 'H'
fmt.Println(s, string(b))
// 반복 연결은 매번 새 문자열을 만든다. Builder를 쓰자.
var sb strings.Builder
for i := range 5 {
fmt.Fprintf(&sb, "%d,", i)
}
fmt.Println(sb.String())
// 문자열 비교는 바이트 사전순이다.
fmt.Println("apple" < "banana", strings.Compare("b", "a"))
// 부분 문자열은 원본과 메모리를 공유한다. 복사가 아니다.
long := strings.Repeat("x", 1000) + "TAIL"
tail := long[1000:]
fmt.Println(len(long), len(tail), tail)
}
go run ./03-immutable
hello Hello
hello Hello
0,1,2,3,4,
true 1
1004 4 TAIL
s[0] = 'H'는cannot assign to s[0] (neither addressable nor a map index expression)컴파일 에러다. 불변성은 타입 시스템으로 강제된다.- 문자열 비교는
==,<로 그냥 된다. Java의equals같은 별도 메서드가 필요 없다. 단, 비교는 바이트 사전순이라 한글 가나다순이나 로케일 정렬과는 다르다. - 부분 문자열 슬라이싱은 복사하지 않는다.
long[1000:]은 원본의 바이트 배열을 그대로 가리킨다. 4바이트 문자열이 1004바이트를 붙잡고 있는 셈이라, 거대한 문자열의 일부만 오래 들고 있어야 한다면strings.Clone으로 잘라 낸다.
:::tip 문자열 연결은 Builder로
루프 안에서 s += x를 하면 반복마다 새 문자열을 할당하고 전체를 복사한다. n번 반복이면
O(n²)이다. strings.Builder는 내부 버퍼를 키워 가며 쌓고, String()에서 한 번만
문자열로 만든다. Builder는 제로값이 바로 쓸 수 있는 상태라 var sb strings.Builder로
시작하면 된다.
:::
유효하지 않은 UTF-8
Go의 string은 바이트열이므로, UTF-8이 아닌 바이트도 담긴다. 외부에서 들어온 데이터를
다룰 때는 이 가능성을 계산에 넣어야 한다.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
// 유효하지 않은 UTF-8 바이트열도 string에 담긴다. Go의 string은 그냥 바이트열이다.
bad := string([]byte{0x48, 0xff, 0x69}) // 'H', 잘못된 바이트, 'i'
fmt.Println("len:", len(bad), "valid:", utf8.ValidString(bad))
// range는 깨진 바이트를 U+FFFD(대체 문자) 하나로 바꿔서 넘긴다.
for i, r := range bad {
fmt.Printf("i=%d r=%U ok=%v\n", i, r, r != utf8.RuneError)
}
// 직접 디코딩하면 size==1 && r==RuneError 조합으로 진짜 오류를 구분할 수 있다.
for i := 0; i < len(bad); {
r, size := utf8.DecodeRuneInString(bad[i:])
broken := r == utf8.RuneError && size == 1
fmt.Printf("offset=%d rune=%U size=%d broken=%v\n", i, r, size, broken)
i += size
}
// 입력을 정리하려면 변환 한 번이면 된다. 깨진 바이트가 U+FFFD로 치환된다.
fixed := string([]rune(bad))
fmt.Printf("%q valid=%v\n", fixed, utf8.ValidString(fixed))
}
go run ./03-invalid-utf8
len: 3 valid: false
i=0 r=U+0048 ok=true
i=1 r=U+FFFD ok=false
i=2 r=U+0069 ok=true
offset=0 rune=U+0048 size=1 broken=false
offset=1 rune=U+FFFD size=1 broken=true
offset=2 rune=U+0069 size=1 broken=false
"H�i" valid=true
range는 깨진 바이트를 만나면 에러를 내지 않고 U+FFFD(대체 문자)를 하나 내보내고
1바이트 전진한다. 조용히 넘어가기 때문에, 입력 검증이 필요하면 utf8.ValidString으로
먼저 확인해야 한다.
U+FFFD가 진짜 오류인지 아니면 원본에 있던 정상적인 대체 문자인지 구분하려면
utf8.DecodeRuneInString의 size를 봐야 한다. RuneError이면서 size == 1이면 오류다.
strings 패키지
문자열 조작은 거의 다 strings에 있다. 메서드가 아니라 함수라는 점이 객체지향 언어에서 온
사람에게는 낯설다.
package main
import (
"fmt"
"strings"
"unicode"
)
func main() {
s := " Go, 언어, 재미 "
fmt.Printf("%q\n", strings.TrimSpace(s))
fmt.Println(strings.Split(strings.TrimSpace(s), ", "))
fmt.Println(strings.Contains(s, "언어"), strings.Index(s, "언어"))
fmt.Println(strings.ReplaceAll(s, ",", ";"))
fmt.Println(strings.ToUpper("go"), strings.EqualFold("Go", "gO"))
// Cut은 "구분자로 한 번만 자르기"를 명확하게 표현한다.
before, after, found := strings.Cut("key=value=more", "=")
fmt.Printf("%q %q %v\n", before, after, found)
// Fields는 연속된 공백을 하나로 취급한다.
fmt.Printf("%q\n", strings.Fields("a b\tc\nd"))
// 룬 단위 판별은 unicode 패키지에 있다.
for _, r := range "aA1 가" {
fmt.Printf("%c letter=%v digit=%v space=%v hangul=%v\n",
r, unicode.IsLetter(r), unicode.IsDigit(r),
unicode.IsSpace(r), unicode.Is(unicode.Hangul, r))
}
}
go run ./03-strings-pkg
"Go, 언어, 재미"
[Go 언어 재미]
true 6
Go; 언어; 재미
GO true
"key" "value=more" true
["a" "b" "c" "d"]
a letter=true digit=false space=false hangul=false
A letter=true digit=false space=false hangul=false
1 letter=false digit=true space=false hangul=false
letter=false digit=false space=true hangul=false
가 letter=true digit=false space=false hangul=true
주의할 점 두 가지.
strings.Index는 바이트 오프셋을 반환한다." Go, 언어, 재미 "에서언어의 위치가 6이라고 나오는데, 이건 여섯 번째 글자가 아니라 여섯 번째 바이트다. 이 값을 그대로 슬라이싱에 쓰면 맞고, 글자 수로 해석하면 틀린다.strings.Cut을 기억하자.Split이나Index+슬라이싱으로 하던 "한 번만 자르기"를 한 줄로 표현한다.key=value파싱에 특히 잘 맞는다.
unicode 패키지는 룬 하나에 대한 판별을 담당한다. IsLetter, IsDigit, IsSpace,
IsUpper와 스크립트 테이블(unicode.Hangul, unicode.Han, unicode.Latin)이 있다.
한글 가는 unicode.Hangul에 속하고 unicode.Han(한자)에는 속하지 않는다.
전체 목록은 언제나 go doc strings가 가장 빠르다.
strconv — 숫자와 문자열 사이
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("Atoi 실패:", err)
return
}
fmt.Println(n + 1)
// 실패했을 때의 에러 메시지를 확인해 두면 디버깅이 빨라진다.
if _, err := strconv.Atoi("42a"); err != nil {
fmt.Println("Atoi 실패:", err)
}
f, err := strconv.ParseFloat("3.14", 64)
if err != nil {
fmt.Println("ParseFloat 실패:", err)
return
}
fmt.Println(f * 2)
b, err := strconv.ParseBool("true")
if err != nil {
fmt.Println("ParseBool 실패:", err)
return
}
fmt.Println(b)
// 진법을 지정한 파싱과 포매팅
hex, err := strconv.ParseInt("ff", 16, 64)
if err != nil {
fmt.Println("ParseInt 실패:", err)
return
}
fmt.Println(hex, strconv.FormatInt(hex, 2))
fmt.Println(strconv.Itoa(255), strconv.Quote("탭\t줄바꿈\n"))
// 숫자 코드 포인트를 문자열로 만들 때는 rune을 거친다.
fmt.Println(string(rune(65)), strconv.Itoa(65))
}
go run ./03-strconv
43
Atoi 실패: strconv.Atoi: parsing "42a": invalid syntax
6.28
true
255 11111111
255 "탭\t줄바꿈\n"
A 65
strconv의 함수는 전부 에러를 반환한다. strconv.Atoi("42a")는 0과 에러를 준다.
에러 메시지에 어떤 함수가 어떤 입력에서 실패했는지가 그대로 들어 있다.
fmt.Sprintf("%d", n)으로도 숫자를 문자열로 만들 수 있지만, strconv.Itoa(n)이 훨씬
빠르다. fmt는 리플렉션을 거치기 때문이다. 반대 방향은 fmt로 할 수 없으니 항상
strconv다.
흔히 하는 실수
1. string(숫자)로 숫자를 문자열로 바꾸려 한다
n := 65
fmt.Println(string(n))
컴파일은 된다. 그런데 출력은 65가 아니라 A다. string(정수)는 그 정수를 코드
포인트로 보고 문자를 만드는 변환이기 때문이다.
go vet ./...
main.go:7:14: conversion from int to string yields a string of one rune, not a string of digits
go vet의 stringintconv 분석기가 정확히 이걸 잡는다. 의도가 숫자 표기라면
strconv.Itoa(n), 코드 포인트라면 의도를 드러내게 string(rune(n))이라고 쓴다.
2. 인덱싱으로 한글을 다룬다
s := "한글"
first := s[0] // 236. '한'이 아니다
reversed := ... // 바이트를 뒤집으면 문자열이 깨진다
- 한 글자 가져오기:
[]rune(s)[0] - 글자 수 세기:
utf8.RuneCountInString(s) - 앞 n글자 자르기:
string([]rune(s)[:n]) - 뒤집기:
[]rune으로 바꿔서 뒤집고 다시string
다만 []rune 변환은 할당을 동반한다. 순회만 하면 되는 경우에는 range를 쓰는 것이
훨씬 낫다.
:::warning 룬 개수도 "사람이 세는 글자 수"는 아니다
é는 하나의 룬(U+00E9)일 수도 있고 e + 결합 악센트(U+0301) 두 룬일 수도 있다.
이모지 조합(👨👩👧)은 룬 여러 개가 하나의 글자로 보인다. 진짜 "사용자가 인식하는 글자"
단위가 필요하면 golang.org/x/text/unicode/norm이나 grapheme cluster 라이브러리가 필요하다.
다행히 완성형 한글은 각각 하나의 룬이라 이 문제를 거의 겪지 않는다.
:::
3. Printf 서식 문자열과 인자가 어긋난다
name := "세연"
age := 30
fmt.Printf("%s는 %s살\n", name, age)
fmt.Printf("%d\n", name)
fmt.Printf("%s %s\n", name)
fmt.Printf("%s\n", name, age)
세연는 %!s(int=30)살
%!d(string=세연)
세연 %!s(MISSING)
세연
%!(EXTRA int=30)
컴파일도 되고 실행도 된다. 로그에 %!s(int=30) 같은 게 찍혀 있으면 이 실수다.
go vet의 printf 분석기가 전부 잡아 준다.
go vet ./...
main.go:8:20: fmt.Printf format %s has arg age of wrong type int
main.go:9:14: fmt.Printf format %d has arg name of wrong type string
main.go:10:17: fmt.Printf format %s reads arg #2, but call has 1 arg
main.go:11:2: fmt.Printf call needs 1 arg but has 2 args
1-6에서 본 것처럼 go test는 실행 전에
printf를 포함한 일부 분석기를 자동으로 돌린다. 테스트가 있는 패키지라면 이 실수는
테스트 실행 시점에 걸린다.
:::tip 서식이 필요 없으면 Println을 쓴다
fmt.Printf(msg)처럼 변수를 서식 문자열 자리에 넣으면, 그 변수에 %가 들어 있을 때
깨진다. msg := "100% done"을 fmt.Printf(msg)로 찍으면 100%!d(MISSING)one이 나온다.
vet도 non-constant format string in call to fmt.Printf로 경고한다.
서식이 필요 없으면 fmt.Print(msg)를 쓴다.
:::
4. +=로 문자열을 반복 연결한다
앞서 본 strings.Builder 이야기다. 수십 개 정도면 차이가 없지만, 수만 번 반복하면
O(n²)이 실측으로 드러난다. 슬라이스에 모아서 strings.Join을 쓰는 것도 좋은 방법이다.
정리
string은 불변 UTF-8 바이트열이다.len은 바이트 수를 센다.s[i]는byte,range s는(바이트 오프셋, rune). 인덱싱으로 한글 한 글자를 못 얻는다.[]byte(s)와[]rune(s)는 복사를 동반한다. 부분 문자열 슬라이싱은 복사하지 않는다.- 유효하지 않은 UTF-8도
string에 담긴다.utf8.ValidString으로 확인한다. strings.Index가 주는 값은 바이트 오프셋이다.string(n)은 코드 포인트 변환이다. 숫자 표기는strconv.Itoa.go vet의printf와stringintconv가 이 챕터의 실수 대부분을 잡는다.
연습문제
-
문자열을 뒤집는
reverse(s string) string을 써 보자."Go 언어"와"hello"에 대해 동작해야 한다. 힌트:[]byte로 하면 왜 깨지는지 먼저 확인하고 시작하라. -
문자열의 앞 n글자만 남기고 나머지를
…로 대체하는truncate(s string, n int) string을 써 보자. 바이트가 아니라 글자 기준이어야 한다. 그다음[]rune변환 없이range의 바이트 오프셋만으로 같은 일을 하도록 고쳐 보자. -
한글 문자열의 초성만 뽑아내 보자. 완성형 한글은
U+AC00부터 시작하고,(코드포인트 - 0xAC00) / 588이 초성 인덱스다. 힌트: 룬 리터럴'가'가 곧0xAC00이다. 한글이 아닌 룬은 그대로 남기자.