Uploaded avatar of PercyGrunwald

Elixir에서의 유니코드 매칭

@PercyGrunwald
7년 초과 전

Exercism의 연습 문제는 작고, 인위적이고, 겉보기에는 사소해 보이기까지 해요. 숙련된 개발자라면 이런 문제에서 배울 게 없으리라고 생각하기 쉬워요. 하지만 이런 인위적인 문제를 풀다 보면, 미처 살펴보지 못했던 언어의 기능을 배우고 적용하게 돼요. 이렇게 새로 배운 것은 실제 문제를 더 효율적으로, 혹은 더 표현력 있게 해결하는 데 도움이 돼요.

Parallel Letter Frequency는 Exercism의 Elixir 트랙에 있는 중간 난이도 연습 문제인데, 의외로 많은 흥미로운 교훈을 담고 있어요. 이 문제를 푸는 데 핵심적인 어려움은 여러 언어의 글자를 다루는 것이에요. 테스트 케이스 중 하나가 독일어이고, 영어 알파벳 밖의 문자를 포함하고 있거든요. 주로 영어 사용자를 위한 애플리케이션을 개발해 왔다면, 이런 요구 사항을 다뤄 보는 게 이번이 처음일 수도 있어요. 이 연습 문제에서 얻는 것은 다국어/비영어 애플리케이션을 만드는 사람에게 분명 도움이 되지만, 더 견고한 사용자 이름과 비밀번호 검증 같은 여러 다른 영역에도 도움이 될 수 있어요.

이 문제를 제대로 풀려면, 어떤 언어로 되어 있을 수 있는 문자열 목록에서 글자 빈도를 구하는 함수, Frequency.frequency/2를 구현해야 해요:

iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
  "c" => 1, 
  "d" => 1, 
  "e" => 5, 
  ...
  "ö" => 2
}

이 함수가 풀어야 할 근본적인 문제부터 시작해서 완전한 구현까지 차근차근 나아가 봐요.

Elixir에서 문자가 글자인지 판별하기

Elixir로 "a"가 글자인지 아닌지 어떻게 판별할까요?

대부분은 /[a-z]/ 같은 정규 표현식을 떠올릴 거예요:

iex> String.match?("a", ~r/^[a-z]$/)
true

"A"는 어떨까요?

i (대소문자 무시) 수정자를 붙이는 게 아마 가장 쉬운 방법일 거예요:

iex> String.match?("A", ~r/^[a-z]$/i)
true

자, 그럼 "ö"는 어떨까요?

제가 이 문제를 처음 접했을 때는 최선의 방법이 떠오르지 않았어요. /[a-z]/i로는 확실히 안 된다는 것만 알았죠:

iex> String.match?("ö", ~r/^[a-z]$/i)
false

"ö"가 글자인지 판별하는 것은 이 Exercism 문제를 푸는 핵심 부분이에요. 테스트에 들어 있는 텍스트 중 하나가 독일어거든요:

# parallel_letter_frequency_test.exs
...
# Poem by Friedrich Schiller. The corresponding music is the European Anthem.
@ode_an_die_freude """
Freude schöner Götterfunken
...
"""

문자가 특수 문자가 아닌지 확인하는 정규 표현식을 쓸 수도 있겠지만, 길고 투박하고 깨지기 쉬울 가능성이 커요. 함수에 입력으로 들어올 수 있는 특수 문자를 정말 빠짐없이 다뤘다고 얼마나 자신할 수 있을까요? 더 나은 방법이 있다고 생각해요.

Elixir의 유니코드 정규 표현식

이 문제에 대한 더 나은 접근은 Elixir Regex 모듈의 u 수정자를 사용하는 거예요:

unicode (u) - \p 같은 유니코드 전용 패턴을 사용할 수 있게 해주고, \w, \W, \s 등의 수정자도 유니코드에서 일치하도록 바꿔줘요.

알고 보면 u 수정자, 특히 \p 패턴은 정말 우아한 해결책이에요. \p 패턴을 쓰면 유니코드 문자 범주 중 어디에 속하든 문자소(유니코드 문자 하나를 가리키는 또 다른 이름)와 일치시킬 수 있어요. Ll(글자, 소문자)이나 Sc(기호, 통화) 같은 구체적인 범주뿐 아니라, L(글자)이나 S(기호) 같은 상위 범주도 포함해요.

\p{L} 패턴을 쓰면 유니코드가 다루는 자연어에서 어떤 언어의 어떤 대소문자든 가리지 않고 어떤 글자든 일치시킬 수 있어요. 덕분에 꽤 강력한 매칭이 가능해요.

영어의 기본 라틴 문자는 평소처럼 잘 동작해요:

iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true

움라우트나 악상 기호가 붙은 라틴 문자 변형도 문제없어요:

iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true

아무 문자나 무조건 일치한다고 반환하는 건 아닌지도 확인해 봐요. 글자처럼 보이지만 글자가 아닌 문자들은 어떨까요:

iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false

아주 좋아요. 그런데 어떤 언어라고 했던 거 기억하죠? 문제없어요:

# Chinese character for "you"
iex> String.match?("你", ~r/^\p{L}$/u)
true

# Cyrillic capital letter "zhe"
iex> String.match?("Ж", ~r/^\p{L}$/u)
true

유니코드 매칭을 지금 문제에 적용하기

이제 문자소가 글자인지 판별하는 데 도움이 되는 도구를 얻었으니, 이걸 문제 해결에 적용해 봐요. Frequency.frequency/2 함수의 첫 구현은 이렇게 생겼을 수 있어요:

def frequency(texts, _workers) do
  texts
  |> get_all_graphemes()
  |> count_letters()
end

defp get_all_graphemes(texts) do
  texts
  |> Enum.join()
  |> String.graphemes()
end

count_letters/1이 할 일은 위에서 알아낸 String.match?(grapheme, ~r/^\p{L}$/u) 패턴을 적용해서 graphemes 목록에 있는 각 글자의 개수를 늘리는 것뿐이에요. 다음은 이 Exercism 문제에 대한 제 풀이에서 가져온 예시 구현이에요:

defp count_letters(graphemes) do
  Enum.reduce(graphemes, %{}, fn grapheme, acc ->
    if String.match?(grapheme, ~r/^\p{L}$/u) do
      downcased_letter = String.downcase(grapheme)
      Map.update(acc, downcased_letter, 1, fn count -> count + 1 end)
    else
      acc
    end
  end)
end

이 함수는 ["a", "A", "ö", "$"] 같은 문자소 목록을 받아서, 대소문자는 무시하고 글자만 세는 맵을 반환해요. %{"a" => 2, "ö" => 1}처럼요. 어떤 언어의 입력이든 처리할 수 있다는 걸 생각하면, 아홉 줄짜리 코드치고 꽤 강력하다고 할 만해요.

마무리

유니코드 매칭에 대해 알고 나면 영어가 아닌 글자를 다루는 일은 꽤 간단해져요. 다행히도 이건 Elixir의 Regex 모듈에 있는 핵심 기능이에요. 이 Exercism 문제를 풀기 전에는 이 기능을 거의 몰랐는데, 이제는 제 Elixir 도구함에서 없어서는 안 될 부분이라고 생각해요.

이 새 도구는 여러 곳에 쓸 수 있는데, 지금 떠오르는 몇 가지는 더 견고한 비밀번호와 사용자 이름 검증, 그리고 가능한 모든 통화 기호를 일일이 나열하지 않고도 입력 문자열이 올바른 통화 문자열인지 판별하는 것이에요:

iex> currency_string_regex = ~r/\p{Sc}\d+\.\d{2}/u
~r/\p{Sc}\d+\.\d{2}/u

iex> ["$1.00", "£1.00", "¥1.00", "€1.00", "&1.00"] \
...> |> Enum.filter(&String.match?(&1, currency_string_regex))
["$1.00", "£1.00", "¥1.00", "€1.00"]
Translation missing: ko.number.nth.ordinalized Mar 2019 · 유용했나요?