Go utf8.RuneStart 표준 라이브러리의 우아함, 단 한 줄의 마법

Go utf8.RuneStart 표준 라이브러리의 우아함, 단 한 줄의 마법

Go 언어로 문자열을 다루다 보면 바이트(byte) 단위로 직접 처리해야 할 때가 종종 있는데요.

이럴 때 '한글'처럼 여러 바이트를 차지하는 문자들이 끼어 있으면 정말 골치 아파지죠.

파일 스트림을 읽거나 네트워크 패킷을 파싱할 때, 어디서부터 어디까지가 하나의 글자인지 구분해 내야 하거든요.

이 문제의 핵심은 결국 '이 바이트가 새로운 문자의 시작점인가?'를 알아내는 데 있습니다.

최근에 바로 이 판별 함수를 직접 한번 만들어 보다가, Go 표준 라이브러리가 얼마나 아름답게 설계되었는지 온몸으로 깨닫는 경험을 했거든요.

오늘은 그 경이로운 여정을 여러분과 함께 나눠보려고 합니다.

무식하지만 정직했던 첫 번째 시도

문제가 명확했으니, 일단 UTF-8 인코딩 규칙을 보면서 직접 함수를 만들어 보기로 했는데요.

UTF-8에서 문자의 첫 번째 바이트, 즉 '리드 바이트(Lead Byte)'는 정해진 패턴을 따르거든요.

  • 1바이트 문자(ASCII)는 0xxxxxxx로 시작하고
  • 2바이트 문자는 110xxxxx로 시작하고
  • 3바이트 문자는 1110xxxx로 시작하고
  • 4바이트 문자는 11110xxx로 시작합니다.

    이 규칙들을 그대로 코드로 옮기면 대략 이런 모양이 나오더라고요.
const (
    t1 = 0b00000000 // 0xxxxxxx 패턴 확인용
    tx = 0b10000000 // 상위 비트 확인용
    t2 = 0b00000110 // 110xxxxx 패턴 확인용
    t3 = 0b00001110 // 1110xxxx 패턴 확인용
    t4 = 0b00011110 // 11110xxx 패턴 확인용
)

func isUTF8LeadByte(tmp byte) bool {
    // 1바이트 문자(ASCII)인가? (상위 1비트가 0인가?)
    if tmp&tx == t1 {
        return true
    }
    // 4바이트 문자인가? (상위 5비트가 11110인가?)
    tmp >>= 3
    if tmp == t4 {
        return true
    }
    // 3바이트 문자인가? (상위 4비트가 1110인가?)
    tmp >>= 1
    if tmp == t3 {
        return true
    }
    // 2바이트 문자인가? (상위 3비트가 110인가?)
    tmp >>= 1
    if tmp == t2 {
        return true
    }
    return false
}

비트 마스크와 시프트 연산을 동원해서 각 패턴을 하나씩 검사하는 방식인데요.

네, 분명히 동작은 합니다.

하지만 솔직히 코드가 예쁘다는 생각은 전혀 들지 않죠.

뭔가 더 똑똑한 방법이 있을 것 같다는 찜찜함을 안고 있었는데, 역시나 답은 가까운 곳에 있었습니다.

충격과 감탄의 한 줄, utf8.RuneStart

혹시나 해서 Go 표준 라이브러리를 뒤져보니, 제가 하려던 것과 '완전히 똑같은' 기능을 하는 함수가 이미 존재하더라고요.

바로 unicode/utf8 패키지의 RuneStart 함수입니다.

그런데 진짜 놀라운 건 그 함수의 실제 구현부였는데요.

제가 10줄 넘게 끙끙대며 짰던 로직이, 단 한 줄로 끝나 있었습니다.

func RuneStart(b byte) bool { return b&0xC0 != 0x80 }

이 코드를 처음 보고 정말 한동안 멍하니 화면만 쳐다봤습니다.

0xC0? 0x80? 이게 어떻게 제가 만들었던 그 복잡한 if문들과 같은 역할을 한다는 걸까요?

마치 마법처럼 느껴졌지만, 그 안에는 UTF-8의 본질을 꿰뚫는 깊은 통찰이 숨어있었습니다.

마법이 아니라 수학입니다

이 한 줄의 비밀을 풀기 위해서는 UTF-8의 전체 그림을 다시 한번 봐야 하는데요.

아까는 문자의 '시작'을 알리는 리드 바이트만 봤었죠.

하지만 2바이트 이상의 문자에는 첫 바이트 뒤에 따라오는 '연속 바이트(Continuation Byte)'라는 것들이 있습니다.

그리고 이 연속 바이트들은 모두 똑같은 규칙을 따르거든요.

바로 10xxxxxx 형태로 시작한다는 겁니다.

자, 이제 모든 조각이 맞춰졌습니다.

  • 1바이트 문자 (ASCII): 0xxxxxxx (상위 2비트는 00, 01...)
  • 멀티바이트 문자의 리드 바이트: 110xxxxx, 1110xxxx, 11110xxx (상위 2비트는 항상 11)
  • 멀티바이트 문자의 연속 바이트: 10xxxxxx (상위 2비트는 항상 10)


    감이 오시나요?

    어떤 바이트가 '새로운 문자의 시작'이라는 것은, 반대로 말하면 그 바이트가 '연속 바이트가 아니다'라는 말과 정확히 동의어입니다.

    그리고 '연속 바이트'의 유일한 특징은 상위 2비트가 10이라는 것이죠.

    이제 아까 그 마법의 코드를 다시 한번 해부해 봅시다.

    return b&0xC0 != 0x80

  1. 0xC0는 16진수이고, 2진수로는 11000000입니다.

    어떤 바이트 b& 0xC0 연산을 한다는 건, 하위 6비트는 모두 0으로 만들고 오직 상위 2비트만 남기겠다는 뜻이죠.

  2. 0x80은 16진수이고, 2진수로는 10000000입니다.

  3. 결국 b&0xC0 != 0x80 이라는 조건문은...

    "바이트 b의 상위 2비트를 추출했더니, 그게 10이 아니더라" 라는 의미가 됩니다.

    상위 2비트가 `10`인 경우는 오직 '연속 바이트' 뿐이니까, 이 조건이 참이라는 것은 곧 해당 바이트가 '연속 바이트가 아님', 즉 '새로운 문자의 시작(리드 바이트 또는 ASCII)'이라는 뜻이 되는 거죠.

    모든 경우의 수를 단 하나의 비트 연산과 비교만으로 완벽하게 걸러내는, 그야말로 발상의 전환이었습니다.

단순함이 곧 성능이다

이 우아한 해결책은 단순히 코드 라인 수만 줄여주는 게 아닌데요.

성능 면에서도 제가 만들었던 함수와는 비교가 되지 않을 정도로 빠를 겁니다.

제가 짠 코드는 여러 번의 if 분기문을 거치면서 CPU가 다음에 실행할 명령어를 예측하기 어렵게 만드는 '분기 예측 실패'를 유발할 수 있거든요.

하지만 RuneStart 함수는 단 하나의 비트 AND 연산과 비교 연산만으로 끝나죠.

이건 CPU 레벨에서는 거의 한두 사이클 만에 처리될 수 있는, 더 이상 최적화할 여지가 없는 수준의 코드입니다.

마치며

이번 경험을 통해 다시 한번 깨달았는데요.

우리가 어떤 문제에 부딪혔을 때, 성급하게 '바퀴를 재발명'하기 전에 표준 라이브러리라는 거인의 어깨 위에 올라서는 것이 얼마나 중요한지 말입니다.

표준 라이브러리에 담긴 함수 하나하나는 수많은 전문가들의 깊은 고민과 최적화의 결정체죠.

utf8.RuneStart의 그 간결한 한 줄은, 복잡한 문제의 본질을 꿰뚫었을 때 비로소 도달할 수 있는 단순함의 경지를 보여주는 것 같네요.

혹시 지금 풀고 있는 문제의 해답도, 이미 표준 라이브러리 어딘가에서 여러분을 기다리고 있지는 않을까요?