Binaries, strings, and charlists — 바이너리, 문자열과 케릭터 리스트

Binaries, strings, and charlists — 바이너리, 문자열과 케릭터 리스트

"Basic types"에서 우리는 문자열에 대해 조금 배우고 is_binary/1로 검사하는 법을 봤어요.

iex> string = "hello"
"hello"
iex> is_binary(string)
true

이번 장에서는 바이너리가 정확히 무엇인지, 그리고 문자열과 어떻게 연결되는지를 분명하게 다뤄볼게요. Erlang과의 상호 운용성(interoperability)에 자주 쓰이는 charlist(~c"like this")도 함께 배워요. 문자열은 프로그래밍 언어에서 가장 흔한 자료형 중 하나지만, 은근히 복잡하고 오해받기 쉬워요. 문자열을 이해하려면 먼저 유니코드와 문자 인코딩, 특히 UTF-8 인코딩부터 이야기해야 해요.

출처: Binaries, strings, and charlists

본문

유니코드와 코드 포인트

여러 언어로 컴퓨터 사이에 의미 있는 통신을 하려면, 한 머신의 0과 1이 다른 머신으로 전송됐을 때 똑같은 의미를 갖도록 하는 표준이 필요해요. 유니코드 표준(Unicode Standard)은 우리가 아는 거의 모든 문자 — 고전·역사 문헌의 문자, 이모지, 서식·제어 문자까지 — 를 공식적으로 등록한 레지스트리 역할을 해요. 유니코드는 모든 문자를 코드 차트에 정리하고, 각 문자에 고유한 숫자 인덱스를 부여하는데, 그 인덱스를 코드 포인트(Code Point) 라고 불러요.

Elixir에서는 문자 리터럴 앞에 ?를 붙이면 코드 포인트를 볼 수 있어요.

iex> ?a
97
iex> ?ł
322

대부분의 유니코드 코드 차트는 코드 포인트를 16진수(hex)로 표기해요. 예를 들어 97은 16진수로 0061이죠. 엘릭서 문자열에서 어떤 유니코드 문자든 \uXXXX 표기법과 코드 포인트의 16진수 표현으로 나타낼 수 있어요.

iex> "\u0061" == "a"
true
iex> 0x0061 = 97 = ?a
97

16진수 표현은 코드 포인트에 대한 정보를 찾는 데도 도움이 돼요. 예를 들어 https://codepoints.net/U+0061에는 소문자 a, 즉 코드 포인트 97에 대한 데이터 시트가 있어요.

UTF-8과 인코딩

유니코드 표준과 코드 포인트를 이해했으니 이제 인코딩을 이야기할 수 있어요. 코드 포인트는 "무엇을 저장하는가"라면, 인코딩은 "어떻게 저장하는가" 를 다뤄요. 인코딩은 하나의 구현(implementation)이에요. 다시 말해 코드 포인트 숫자를 메모리에 저장하고 디스크에 쓰기 위해 바이트로 변환하는 메커니즘이 필요한 거죠.

Elixir는 문자열을 인코딩할 때 UTF-8을 써요. 코드 포인트가 8비트 바이트의 연속으로 인코딩된다는 뜻이에요. UTF-8은 가변 폭(variable width) 문자 인코딩이라 코드 포인트 하나를 저장하는 데 1~4바이트를 써요. 모든 유효한 유니코드 코드 포인트를 인코딩할 수 있어요. 예시를 볼게요.

iex> string = "héllo"
"héllo"
iex> String.length(string)
5
iex> byte_size(string)
6

위 문자열은 문자 5개지만 바이트는 6개예요. é를 표현하는 데 2바이트가 쓰이기 때문이죠. 참고로 Windows에서 실행한다면 터미널이 기본적으로 UTF-8이 아닐 수 있어요. 그럴 땐 iex(iex.bat)에 들어가기 전에 chcp 65001을 실행해 세션 인코딩을 바꿔주면 돼요.

UTF-8은 문자를 정의하는 것 외에도 그래핌(grapheme) 개념을 제공해요. 그래핌은 여러 문자로 구성되지만 하나로 인식되는 경우가 많아요. 예를 들어 여성 소방관 이모지는 여성 이모지(👩), 보이지 않는 zero-width joiner, 소방차 이모지(🚒) 세 문자의 조합으로 표현돼요. 하지만 Elixir은 이것이 하나의 문자로 보인다는 걸 알아서 길이는 여전히 1이에요.

iex> String.codepoints("👩‍🚒")
["👩", "‍", "🚒"]
iex> String.graphemes("👩‍🚒")
["👩‍🚒"]
iex> String.length("👩‍🚒")
1

이런 규칙이 복잡해 보일 수 있지만, UTF-8 인코딩된 문서는 어디에나 있어요. 이 페이지 자체도 UTF-8로 인코딩됐죠. 인코딩 정보는 브라우저에 전달되고, 브라우저는 그 정보로 모든 바이트·문자·그래핌을 알맞게 렌더링해요.

파일에 저장될 문자열의 정확한 바이트를 보고 싶다면, null 바이트 <<0>>를 연결하는 게 흔한 방법이에요. 아니면 IO.inspect/2로 바이너리 표현을 볼 수도 있어요.

비트스트링

코드 포인트와 UTF-8 인코딩을 다뤘지만, 인코딩된 바이트를 정확히 어떻게 저장하는지 더 깊이 볼 필요가 있어요. 여기서 등장하는 게 비트스트링(bitstring) 이에요. 비트스트링은 Elixir의 기초 자료형이며 <<>> 문법으로 나타내요. 메모리에서 연속된 비트의 나열이죠. 기본적으로 비트스트링에서 각 숫자를 저장하는 데 8비트(즉 1바이트)를 쓰지만, ::n 수정자를 쓰면 n비트 크기를 직접 지정할 수 있어요. 더 자세한 형태로는 ::size(n)을 써요.

iex> <<42>> == <<42::8>>
true
iex> <<3::4>>
<<3::size(4)>>

예를 들어 십진수 3을 4비트로 표현하면 2진수로 0011인데, 각각 1비트에 저장된 값 0, 0, 1, 1과 같아요. 할당된 비트 수에 저장할 수 있는 값을 넘는 값은 잘려나가요(truncated). 257을 2진수로 쓰면 100000001이지만, 기본적으로 8비트만 예약되어 있어서 맨 왼쪽 비트는 무시되고 00000001, 즉 십진수 1로 잘려요.

바이너리

바이너리(binary) 는 비트 수가 8로 나누어떨어지는 비트스트링이에요. 따라서 모든 바이너리는 비트스트링이지만, 모든 비트스트링이 바이너리인 건 아니에요. is_bitstring/1is_binary/1로 확인해볼 수 있어요.

iex> is_bitstring(<<3::4>>)
true
iex> is_binary(<<3::4>>)
false
iex> is_binary(<<0, 255, 42>>)
true

바이너리/비트스트링을 패턴 매칭할 수도 있어요. 명시적으로 :: 수정자를 쓰지 않으면 바이너리 패턴의 각 항목은 한 바이트(정확히 8비트)와 매칭된다는 점을 기억해요. 크기를 모르는 바이너리에 매칭하고 싶다면 패턴 끝에 binary 수정자를 쓰면 돼요.

iex> <<0, 1, x::binary>> = <<0, 1, 2, 3>>
<<0, 1, 2, 3>>
iex> x
<<2, 3>>

패턴 매칭에서 유용한 수정자가 몇 가지 더 있어요. binary-size(n) 수정자는 바이너리에서 n바이트를 매칭해요.

iex> <<head::binary-size(2), rest::binary>> = <<0, 1, 2, 3>>
<<0, 1, 2, 3>>
iex> head
<<0, 1>>
iex> rest
<<2, 3>>

문자열은 UTF-8로 인코딩된 바이너리이고, 각 문자의 코드 포인트는 1~4바이트로 인코딩돼요. 그래서 모든 문자열은 바이너리지만, UTF-8 표준 인코딩 규칙 때문에 모든 바이너리가 유효한 문자열은 아니에요. 문자열 연결 연산자 <>는 사실 바이너리 연결 연산자예요. 문자열이 바이너리이므로 문자열에도 패턴 매칭을 쓸 수 있지만, 바이너리 패턴 매칭은 바이트 단위로 동작한다는 점을 조심해야 해요. "über" 같은 멀티바이트 문자가 포함된 문자열에 매칭하면 그 문자가 아니라 그 문자의 첫 번째 바이트에 매칭되거든요. 문자열을 패턴 매칭할 때는 utf8 수정자를 쓰는 게 중요해요.

iex> <<x::utf8, rest::binary>> = "über"
"über"
iex> x == ?ü
true
iex> rest
"ber"

케릭터 리스트

마지막으로 설명할 자료형은 charlist 예요. charlist는 모든 정수가 유효한 코드 포인트인 정수 리스트예요. 실제로는 자주 마주치진 않아요. 주로 바이너리를 인자로 받지 않는 오래된 Erlang 라이브러리와 연동하는 특정 시나리오에서 쓰여요. ~c 시길은 우리가 일반 문자열이 아니라 charlist를 다루고 있다는 뜻이에요(시길은 "Sigils" 장에서 자세히 다뤄요). 바이트 대신 정수 코드 포인트를 담지만, 모든 코드 포인트가 ASCII 범위 안에 있을 때만 시길 형태로 출력돼요.

iex> ~c"hello"
~c"hello"
iex> ~c"hełło"
[104, 101, 322, 322, 111]

Erlang과의 상호 운용을 쉽게 하기 위한 동작이라, 다소 놀라운 결과를 만들기도 해요. 예를 들어 0~127 사이의 정수로 된 리스트를 저장하면 기본적으로 IEx가 그것을 charlist로 해석해 대응하는 ASCII 문자를 보여줘요. inspect/2charlists: :as_list 옵션으로 언제든 리스트 형태로 출력을 강제할 수 있어요. to_string/1to_charlist/1로 charlist와 문자열을 서로 변환할 수도 있는데, 이 함수들은 다형적(polymorphic)이라 정수·원자 등 여러 형태도 받아요.

문자열(바이너리) 연결은 <> 연산자를 쓰지만, 리스트인 charlist는 리스트 연결 연산자 ++를 써요.

iex> ~c"this " ++ ~c"works"
~c"this works"
iex> "he" <> "llo"
"hello"

바이너리·문자열·charlist를 정리했으니, 이제 키-값 자료구조로 넘어갈 시간이에요.

더 알아보기

  • 비트스트링 생성자의 완전한 레퍼런스는 <<>> 문서를 참고하세요.
  • 다음 장에서는 키워드 리스트와 맵(key-value 자료구조)을 다뤄요.
  • String 모듈 문서에서 코드 포인트·그래핌·유효성(String.valid?/1) 관련 함수를 더 볼 수 있어요.