String — UTF-8 문자열 다루기

String — UTF-8 문자열 다루기

Elixir에서 문자열(text)을 다룰 때 가장 먼저 만나게 되는 모듈이 String이에요. Elixir의 문자열은 UTF-8로 인코딩된 바이너리(binary) 라는 점을 기억하는 게 첫걸음이에요.

출처: String — Strings in Elixir are UTF-8 encoded binaries

본문

Elixir의 문자열은 유니코드 문자들의 시퀀스예요. 보통 큰따옴표로 감싸서 "hello""héllò" 처럼 써요. 문자열 안에 큰따옴표를 넣어야 한다면 백슬래시로 이스케이프하면 돼요: "this is a string with \"double quotes\"".

두 문자열을 이어 붙일 때는 <>/2 연산자를 써요.

iex> "hello" <> " " <> "world"
"hello world"

이 모듈의 함수들은 유니코드 표준(Unicode Standard) 버전 17.0.0 에 따라 동작해요.

보간(Interpolation)

Elixir 문자열은 보간(interpolation)도 지원해요. #{} 문법으로 문자열 중간에 값을 넣을 수 있어요.

iex> name = "joe"
iex> "hello #{name}"
"hello joe"

보간 안에는 어떤 Elixir 표현식이든 들어갈 수 있어요. 문자열이 주어지면 그대로 끼워 넣고, 다른 값이면 String.Chars 프로토콜을 이용해 문자열로 변환하려고 시도해요. 그래서 정수도 자연스럽게 출력할 수 있죠.

iex> "2 + 2 = #{2 + 2}"
"2 + 2 = 4"

만약 사람이 읽을 수 있는 텍스트 표현이 없는 값이라 변환에 실패하면 프로토콜 오류(protocol error) 가 발생해요.

이스케이프 문자

백슬래시로 큰따옴표를 이스케이프하는 것 외에도 문자열은 다음 이스케이프 문자들을 지원해요.

  • \0 — 널 바이트 (null byte)
  • \a — 벨 (bell)
  • \b — 백스페이스
  • \t — 가로 탭
  • \n — 개행 (new line)
  • \v — 세로 탭
  • \f — 폼 피드
  • \r — 캐리지 리턴
  • \e — 이스케이프
  • \s — 공백
  • \# — 보간을 건너뛰고 # 문자 자체를 반환
  • \\ — 백슬래시 하나
  • \xNN — 16진수 NN으로 표현된 바이트
  • \uNNNNNNNN으로 표현된 유니코드 코드 포인트
  • \u{NNNNNN}NNNNNN으로 표현된 유니코드 코드 포인트

일반적으로 Elixir 문자열에서 \xNN을 쓰는 것은 권장하지 않아요. 잘못된 바이트 시퀀스가 들어가면 문자열이 무효해질 수 있기 때문이에요. 16진수로 문자를 넣어야 한다면 \uNNNN 같은 유니코드 코드 포인트를 쓰는 게 좋아요.

유니코드와 코드 포인트

서로 다른 언어를 쓰는 컴퓨터들 사이에서 의미 있는 통신을 하려면, 어떤 기계의 0과 1이 다른 기계로 전송돼도 같은 의미를 갖도록 하는 표준이 필요해요. 그 역할을 하는 것이 유니코드 표준이에요. 유니코드는 거의 모든 문자(고전·역사 문헌 문자, 이모지, 서식·제어 문자)를 코드 차트(code charts) 로 정리하고, 각 문자에 고유한 숫자 인덱스를 부여해요. 이 숫자 인덱스를 코드 포인트(Code Point) 라고 불러요.

Elixir에서는 문자 리터럴 앞에 ?를 붙여 코드 포인트를 확인할 수 있어요.

iex> ?a
97
iex> ?ł
322

대부분의 유니코드 코드 차트는 코드 포인트를 16진수로 표기해요. 예를 들어 97은 16진수로 0061이죠. \u 이스케이프 다음에 코드 포인트 번호를 붙이면 Elixir 문자열 안에 어떤 유니코드 문자든 표현할 수 있어요.

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

숫자의 16진수 표현은 Integer.to_string/2로 얻을 수 있어요.

UTF-8 인코딩

코드 포인트가 무엇을 저장하는지라면, 인코딩(encoding)은 어떻게 저장하는지를 다뤄요. 메모리에 담거나 디스크에 쓸 수 있도록 코드 포인트 번호를 바이트로 바꾸는 매커니즘이 필요한데, Elixir는 이때 UTF-8을 사용해요. UTF-8은 가변 너비 문자 인코딩으로, 코드 포인트마다 1~4바이트를 사용하며 모든 유효한 유니코드 코드 포인트를 인코딩할 수 있어요.

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

위 문자열은 5글자지만 6바이트예요. é를 표현하는 데 2바이트가 쓰였기 때문이죠.

그래핌 클러스터(Grapheme clusters)

이 모듈은 그래핌(grapheme)이라는 개념도 다뤄요. 그래핌은 여러 코드 포인트로 구성되지만 독자가 하나의 문자로 인식하는 단위예요. é는 "with acute(e)" 코드 포인트 하나로도, "e" 뒤에 "combining acute accent"(두 코드 포인트)가 붙는 형태로도 표현될 수 있어요.

iex> string = "\u0065\u0301"
iex> String.length(string)
1
iex> String.codepoints(string)
["e", "́"]
iex> String.graphemes(string)
["é"]

시각적으로는 같아 보여도, 실제로는 두 문자(코드 포인트)로 이뤄져 있지만 사용자에게는 하나로 인식되는 경우예요. 어떤 언어는 "ch"를 한 문자로 취급하기도 하지만, 그건 로케일(locale)에 의존하는 정보라서 이 모듈은 반영하지 않아요. 이 모듈의 함수는 유니코드 표준을 따르되, 로케일 특화 동작은 담지 않아요.

문자열과 바이너리 연산

유니코드 표준에 맞춰 동작하기 위해, 이 모듈의 많은 함수는 문자열 전체를 훑으며 올바른 코드 포인트를 고려해야 해서 선형 시간이 걸려요. 예를 들어 String.length/1은 입력이 길어질수록 오래 걸려요. 반면 Kernel.byte_size/1은 입력 크기와 무관하게 상수 시간으로 동작해요.

그래서 바이너리를 직접 다루는 저수준 연산(Kernel.binary_part/3, Kernel.bit_size/1, Kernel.byte_size/1, :binary 모듈의 함수들)과 비교하면 이 모듈의 함수는 성능 비용이 따를 때가 있어요.

바이너리 문법 <<>> 안에는 utf8 수식어도 쓸 수 있어서, 문자열에서 코드 포인트를 매칭해 낼 수 있어요.

iex> <<eacute::utf8>> = "é"
iex> eacute
233

String.to_charlist/1로 문자열을 정수 코드 포인트의 리스트("charlist"라고 불러요)로 완전히 변환할 수도 있어요. 문자열의 바이트를 보고 싶다면, 널 바이트 <<0>>를 이어 붙이는 방법이나 IO.inspect/2binaries: :as_binaries 옵션을 주는 방법을 쓸 수 있어요.

자기 동기화(Self-synchronization)

UTF-8 인코딩은 자기 동기화(self-synchronizing) 특성이 있어요. 잘못된 데이터(정의상 불가능한 데이터)를 만나면 코드 포인트 하나만 거부하면 돼요. 이 모듈은 이 동작에 기대어 잘못된 문자를 무시해요. 예를 들어 length/1은 잘못된 코드 포인트가 들어와도 올바른 결과를 돌려주죠.

즉, 이 모듈은 잘못된 데이터는 다른 곳에서 검출될 것이라고 기대해요. 보통 외부 소스에서 데이터를 가져올 때 말이죠. 데이터베이스에서 문자열을 읽는 드라이버는 인코딩 유효성을 검사할 책임을 지고, String.chunk/2는 문자열을 유효한 부분과 무효한 부분으로 나누는 데 쓸 수 있어요.

컴파일된 바이너리 패턴

이 모듈의 많은 함수는 패턴(pattern) 과 함께 동작해요. String.split/3는 패턴을 기준으로 문자열을 여러 문자열로 나누는데, 패턴은 문자열, 문자열의 리스트, 혹은 컴파일된 패턴이 될 수 있어요.

iex> String.split("foo bar", " ")
["foo", "bar"]

iex> String.split("foo bar!", [" ", "!"])
["foo", "bar", ""]

iex> pattern = :binary.compile_pattern([" ", "!"])
iex> String.split("foo bar!", pattern)
["foo", "bar", ""]

컴파일된 패턴은 같은 매칭을 반복해서 수행할 때 유용해요. 다만 패턴이 런타임에 생성되므로 모듈 속성(module attribute)에 저장할 수 없고, 컴파일 시점까지 살아남지 못한다는 점만 기억하세요.

더 알아보기

  • 기본 연산: String.length/1, String.graphemes/1, String.codepoints/1, String.split/3, String.trim/1
  • 변환: String.to_charlist/1, String.to_atom/1, String.duplicate/2, String.reverse/1
  • 조회: String.contains?/2, String.starts_with?/2, String.ends_with?/2
  • 관련 모듈: String.Chars (문자열 변환 프로토콜), Erlang :unicode (인코딩 변환·정규화)