Menu

Golang rune과 byte: UTF-8 문자열 이해하기

Go에서 rune과 byte의 의미, 문자열이 UTF-8을 저장하는 방식, len이 바이트를 세는 이유, range가 룬을 디코딩하는 방식, string, []byte, []rune 사이의 변환, bytes 패키지 사용법을 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

Go 문자열은 바이트의 나열이고, Go 소스와 문자열 리터럴은 UTF-8입니다. byte는 그 바이트 하나입니다. rune은 유니코드 문자(코드 포인트) 하나이며, UTF-8은 이를 1에서 4바이트로 저장합니다. Go에서 문자열 때문에 헷갈리는 일은 대부분 이 둘을 혼동해서 생깁니다:

는 UTF-8로 3바이트이므로 "語Go"는 5바이트이지만 룬은 3개입니다. 인덱스 반복문은 바이트 다섯 개를 보며, 0, 1, 2번 바이트는 그 자체로는 의미가 없습니다. range 반복문은 UTF-8을 디코딩해서 인덱스 0, 3, 4에서 룬 세 개를 내놓습니다. i는 문자 개수가 아니라 항상 바이트 오프셋입니다.

byte와 rune은 별칭이다

이름같은 타입리터럴의미
byteuint8byte('A'), 0x41데이터 1바이트
runeint32'A', 'é', '世'유니코드 코드 포인트 하나

별칭이므로 byte는 곧 uint8이고 rune은 곧 int32입니다. 별칭과 기반 타입 사이에는 변환이 필요 없습니다. 작은따옴표는 룬을, 큰따옴표는 문자열을 만듭니다. 룬 리터럴은 정확히 한 문자만 담으므로 'ab'는 컴파일 오류입니다.

룬은 숫자라서 산술 연산을 할 수 있습니다:

'7' - '0'은 숫자 문자를 값으로 바꾸는 고전적인 방법입니다. unicode 패키지는 룬을 분류하고(IsLetter, IsDigit, IsSpace, IsUpper) ASCII가 아닌 글자의 대소문자도 올바르게 바꿉니다. %c는 룬을 문자로, %UU+4E16 형태로, %q는 따옴표로 감싼 룬 리터럴로 출력합니다.

UTF-8이 문자를 저장하는 방식

코드 포인트바이트예시
U+0000에서 U+007F1ASCII: a, 7, {
U+0080에서 U+07FF2é, ß, ж, ع
U+0800에서 U+FFFF3, ,
U+10000 이상4이모지, 드문 한자

ASCII 텍스트는 UTF-8에서도 똑같습니다. 그래서 바이트 기반 코드가 테스트에서는 잘 돌다가 악센트가 붙은 첫 이름이나 한글에서 깨지곤 합니다. unicode/utf8 패키지는 인코딩을 직접 다룹니다:

% x(공백 포함)는 바이트를 공백으로 구분한 16진수로 출력합니다. utf8.ValidString은 문자열이 올바른 UTF-8인지 알려 줍니다. Go 문자열에는 올바른 UTF-8뿐 아니라 어떤 바이트든 담을 수 있습니다. range가 잘못된 바이트를 만나면 utf8.RuneError(U+FFFD, 대체 문자)를 내놓고 1바이트 전진합니다.

string, []byte, []rune 사이의 변환

변환결과비용
[]byte(s)UTF-8 바이트복사
[]rune(s)디코딩된 코드 포인트디코딩 후 복사(룬당 4바이트)
string(b)바이트로 만든 문자열복사
r[]rune일 때 string(r)UTF-8로 인코딩복사
rrune일 때 string(r)한 글자짜리 문자열작음

바이트 단위로 뒤집으면 世界가 잘못된 UTF-8로 뒤섞이므로 reverse는 룬 단위로 동작합니다. 룬도 전부는 아닙니다. é 같은 문자는 e에 결합 악센트를 더한 두 룬으로 쓸 수도 있습니다. 사용자에게 보이는 문자(그래핌 클러스터) 단위가 필요하면 github.com/rivo/uniseg 같은 라이브러리를 쓰세요.

문자열은 불변이고 슬라이스는 그렇지 않으므로 변환할 때마다 복사가 일어납니다. 성능이 중요한 코드에서는 이리저리 변환하지 마세요. 컴파일러는 일부 경우(맵 키나 비교에 쓰이는 string(b) 등)에 복사를 생략하도록 최적화합니다.

인덱싱은 바이트를 준다

s[i]byte를 반환하고, s[i:j]는 바이트 오프셋으로 자릅니다:

s := "café"
fmt.Println(s[3])            // 195: the first byte of é
fmt.Println(string(s[3]))    // "Ã": that byte read as a code point
fmt.Println(s[:3])           // "caf"
fmt.Println(s[:4])           // "caf\xc3": half of é, invalid UTF-8

string(s[3])은 함정입니다. 바이트 하나를 string으로 변환하면 그 값을 코드 포인트 195로 취급하므로 바이트 자체가 아니라 Ã가 됩니다. 문자 경계를 찾으려면 range, strings.Index 계열 함수(항상 경계에 놓인 바이트 오프셋을 반환합니다), 또는 utf8.DecodeRuneInString을 쓰세요.

bytes 패키지

bytes[]bytestrings입니다: bytes.Contains, bytes.Split, bytes.Fields, bytes.TrimSpace, bytes.Equal, bytes.ToUpper 등. 데이터가 바이트로 들어올 때(파일 내용, HTTP 본문, 네트워크 버퍼) 이 패키지를 쓰면 문자열로 바꿨다가 되돌릴 필요가 없습니다:

bytes.Bufferio.Readerio.Writer를 구현하는, 크기가 늘어나는 바이트 버퍼입니다. 바이트를 쓰기도 하고 읽기도 할 때 알맞은 도구이며, 문자열을 만들 때는 strings.Builder가 조금 더 저렴합니다. ==는 슬라이스에 쓸 수 없으므로 바이트 슬라이스는 bytes.Equal로 비교하세요.

무엇을 고를까

  • 화면에 보여 주거나, 비교하거나, 검색하는 텍스트: string으로 두고, 문자 단위로 훑을 때는 range를 씁니다.
  • 문자 위치, 뒤집기, n글자로 자르기: []rune으로 변환합니다.
  • 입출력, 해싱, 인코딩, 바이너리 프로토콜: []bytebytes 패키지를 씁니다.
  • 문자 하나는 rune, 원시 바이트 하나는 byte.

자주 묻는 질문

Go에서 rune이란 무엇인가요?

runeint32의 별칭이며 유니코드 코드 포인트 하나를 나타냅니다. 룬 리터럴은 작은따옴표로 씁니다: 'a'는 97, 'é'는 233, '世'는 19990입니다. for i, r := range s로 문자열을 순회하면 문자열의 UTF-8 바이트에서 디코딩한 룬을 얻습니다.

Go에서 rune과 byte의 차이는 무엇인가요?

byte(uint8의 별칭)는 8비트의 원시 데이터입니다. rune(int32의 별칭)은 유니코드 문자 하나 전체입니다. UTF-8 문자열에서 ASCII 문자는 각각 1바이트를 차지하고, 다른 문자는 2에서 4바이트를 차지하지만 여전히 룬 하나입니다. len(s)는 바이트를 세고, utf8.RuneCountInString(s)는 룬을 셉니다.

Go에서 문자열의 문자를 하나씩 순회하려면 어떻게 하나요?

for i, r := range s를 씁니다. 반복마다 바이트 오프셋 i와 룬 r을 주므로 멀티바이트 문자도 올바르게 처리됩니다. 평범한 인덱스 반복문 for i := 0; i < len(s); i++는 바이트를 방문하므로 ASCII가 아닌 문자를 조각냅니다.

Go에서 문자열을 바이트 슬라이스로 바꾸려면 어떻게 하나요?

b := []byte(s)는 문자열의 바이트를 새 슬라이스에 복사합니다. string(b)는 다시 복사하면서 되돌립니다. 문자열을 뒤집거나 앞의 n글자를 자르는 것처럼 문자 단위로 다뤄야 한다면 []rune(s)를 쓰세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기