프로토콜

프로토콜 (Protocols)

프로토콜은 데이터 타입에 따라 동작을 다르게 하고 싶을 때 Elixir에서 다형성(polymorphism)을 구현하는 메커니즘이에요. 우리는 이미 이런 문제를 푸는 한 가지 방법, 패턴 매칭과 가드 절을 알고 있어요. 입력 변수의 타입을 알려주는 간단한 유틸리티 모듈을 생각해 볼게요.

출처: Elixir 공식 가이드

defmodule Utility do
  def type(value) when is_binary(value), do: "string"
  def type(value) when is_integer(value), do: "integer"
  # ... other implementations ...
end

이 모듈 사용이 자신의 프로젝트 안으로 한정되어 있다면, 새 데이터 타입마다 type/1 함수를 계속 정의하면 돼요. 그런데 이 코드가 여러 앱이 의존하는 라이브러리로 공유된다면 문제가 될 수 있어요. 기능을 확장할 쉬운 방법이 없기 때문이에요.

이럴 때 프로토콜이 도움을 줘요. 프로토콜은 우리가 필요한 만큼 많은 데이터 타입에 대해 원래 동작을 확장할 수 있게 해 줘요. 프로토콜 디스패치는 그 프로토콜을 구현한 모든 데이터 타입에 사용할 수 있고, 프로토콜은 누구나 언제든 구현할 수 있기 때문이에요.

같은 Utility.type/1 기능을 프로토콜로 쓰면 이렇게 돼요.

defprotocol Utility do
  @spec type(t) :: String.t()
  def type(value)
end

defimpl Utility, for: BitString do
  def type(_value), do: "string"
end

defimpl Utility, for: Integer do
  def type(_value), do: "integer"
end

프로토콜은 defprotocol/2로 정의해요. 그 함수와 스펙은 다른 언어의 인터페이스나 추상 베이스 클래스와 비슷해 보일 수 있어요. defimpl/2로 원하는 만큼 구현을 추가할 수 있어요. 결과는 여러 함수를 가진 단일 모듈과 완전히 같아요.

iex> Utility.type("foo")
"string"
iex> Utility.type(123)
"integer"

하지만 프로토콜을 쓰면 더 이상 점점 많은 데이터 타입을 지원하기 위해 같은 모듈을 계속 수정할 필요가 없어요. 예를 들어 위의 defimpl 호출을 여러 파일에 흩어놓을 수 있고, Elixir는 데이터 타입에 따라 적절한 구현에 실행을 디스패치해요. 프로토콜에 정의된 함수는 입력이 여러 개일 수 있지만, 디스패치는 항상 첫 번째 입력의 데이터 타입에 기반해요.

가장 흔히 만나는 프로토콜 중 하나는 String.Chars 프로토콜이에요. 커스텀 구조체에 대해 to_string/1 함수를 구현하면 Elixir 커널이 그 구조체를 문자열로 어떻게 표현할지 알게 돼요. 내장 프로토콜들은 나중에 모두 살펴볼게요. 지금은 나만의 프로토콜을 구현해 볼게요.

예제 (Example)

프로토콜이 어떤 유형의 문제를 해결하는지, 그리고 어떻게 해결하는지 예시를 봤으니, 이번에는 더 깊이 있는 예시를 살펴볼게요.

Elixir에는 데이터 구조에 항목이 몇 개 있는지 확인하는 관용구가 두 가지 있어요. lengthsize예요. length는 정보를 계산해야 한다는 뜻이에요. 예를 들어 length(list)는 길이를 계산하려고 목록 전체를 순회해야 해요. 반면 tuple_size(tuple)byte_size(binary)는 튜플과 바이너리의 크기에 의존하지 않는데, 크기 정보가 데이터 구조에 미리 계산되어(pre-computed) 저장되어 있기 때문이에요.

크기를 얻는 타입별 함수가 Elixir에 내장되어 있긴 하지만(예: tuple_size/1), 크기가 미리 계산된 모든 데이터 구조가 구현할 수 있는 일반적인 Size 프로토콜을 구현할 수도 있어요.

프로토콜 정의는 이렇게 생겼어요.

defprotocol Size do
  @doc "Calculates the size (and not the length!) of a data structure"
  def size(data)
end

Size 프로토콜은 size라는, 인자 하나(크기를 알고 싶은 데이터 구조)를 받는 함수의 구현을 기대해요. 이제 이 프로토콜을 호환 구현을 가질 데이터 구조에 대해 구현할 수 있어요.

defimpl Size, for: BitString do
  def size(string), do: byte_size(string)
end

defimpl Size, for: Map do
  def size(map), do: map_size(map)
end

defimpl Size, for: Tuple do
  def size(tuple), do: tuple_size(tuple)
end

목록에는 미리 계산된 "size" 정보가 없고 목록의 길이는(length/1로) 계산해야 하기 때문에, Size 프로토콜을 목록에는 구현하지 않았어요.

이제 프로토콜이 정의되고 구현도 준비됐으니 사용해 볼게요.

iex> Size.size("foo")
3
iex> Size.size({:ok, "hello"})
2
iex> Size.size(%{label: "some label"})
1

프로토콜을 구현하지 않은 데이터 타입을 넘기면 오류가 발생해요.

iex> Size.size([1, 2, 3])
** (Protocol.UndefinedError) protocol Size not implemented for [1, 2, 3] of type List

모든 Elixir 데이터 타입에 대해 프로토콜을 구현할 수 있어요.

  • Atom
  • BitString
  • Float
  • Function
  • Integer
  • List
  • Map
  • PID
  • Port
  • Reference
  • Tuple

프로토콜과 구조체 (Protocols and structs)

Elixir 확장성의 강점은 프로토콜과 구조체가 함께 사용될 때 나와요.

이전 장에서, 구조체는 맵이지만 맵 프로토콜 구현을 공유하지는 않는다는 걸 배웠어요. 예를 들어 MapSet(맵 기반 집합)은 구조체로 구현돼요. Size 프로토콜을 MapSet과 함께 써 볼게요.

iex> Size.size(%{})
0
iex> set = %MapSet{} = MapSet.new
MapSet.new([])
iex> Size.size(set)
** (Protocol.UndefinedError) protocol Size not implemented for MapSet.new([]) of type MapSet (a struct)

구조체는 맵과 프로토콜 구현을 공유하는 대신 자기만의 프로토콜 구현을 필요로 해요. MapSet은 크기가 미리 계산되어 MapSet.size/1으로 접근할 수 있으므로, MapSet에 대한 Size 구현을 정의할 수 있어요.

defimpl Size, for: MapSet do
  def size(set), do: MapSet.size(set)
end

원한다면 구조체의 size에 대한 나만의 의미를 만들 수도 있어요. 그뿐 아니라 구조체를 사용해 큐(queue) 같은 더 견고한 데이터 타입을 만들고, 이 데이터 타입에 대해 Enumerable 같은 관련 프로토콜과 어쩌면 Size까지 구현할 수도 있어요.

defmodule User do
  defstruct [:name, :age]
end

defimpl Size, for: User do
  def size(_user), do: 2
end

Any 구현하기 (Implementing Any)

모든 타입에 대해 프로토콜을 수동으로 구현하는 것은 곧 반복적이고 지루해질 수 있어요. 이런 경우 Elixir는 두 가지 옵션을 제공해요. 타입에 대한 프로토콜 구현을 명시적으로 유도(derive)하거나, 모든 타입에 대해 프로토콜을 자동으로 구현하는 거예요. 두 경우 모두 Any에 대한 프로토콜을 구현해야 해요.

유도 (Deriving)

Elixir는 Any 구현에 기반하여 프로토콜 구현을 유도할 수 있게 해 줘요. 먼저 Any를 이렇게 구현해 볼게요.

defimpl Size, for: Any do
  def size(_), do: 0
end

위 구현은 사실 합리적이지 않아요. 예를 들어 PID나 Integer의 크기가 0이라고 말하는 건 말이 안 되죠.

그래도 Any에 대한 구현이 괜찮다면, 그 구현을 사용하기 위해 구조체에 Size 프로토콜을 명시적으로 유도하라고 알려줘야 해요.

defmodule OtherUser do
  @derive [Size]
  defstruct [:name, :age]
end

유도할 때 Elixir는 Any에 제공된 구현에 기반하여 OtherUser에 대한 Size 프로토콜을 구현해요.

Any로의 폴백 (Fallback to Any)

@derive의 또 다른 대안은, 구현을 찾을 수 없을 때 프로토콜이 Any로 폴백하도록 명시적으로 알려주는 거예요. 이건 프로토콜 정의에서 @fallback_to_anytrue로 설정해서 달성해요.

defprotocol Size do
  @fallback_to_any true
  def size(data)
end

이전 절에서 말했듯이 Any에 대한 Size 구현은 어떤 데이터 타입에도 적용할 수 있는 게 아니에요. 그래서 @fallback_to_any가 선택(opt-in) 동작인 것도 그 이유 중 하나예요. 대부분의 프로토콜에서는 프로토콜이 구현되지 않았을 때 오류를 발생시키는 것이 올바른 동작이에요. 그렇지만 이전 절에서처럼 Any를 구현했다고 가정해 볼게요.

defimpl Size, for: Any do
  def size(_), do: 0
end

이제 Size 프로토콜을 구현하지 않은 모든 데이터 타입(구조체 포함)은 크기가 0인 것으로 간주돼요.

유도와 Any 폴백 중 어느 기법이 더 좋은지는 사용 사례에 따라 다르지만, Elixir 개발자들은 명시적인 것(implicit)보다 명시적인 것(explicit)을 선호하는 경향이 있어서, 많은 라이브러리가 @derive 접근 방식을 지향하는 것을 볼 수 있어요.

내장 프로토콜 (Built-in protocols)

Elixir에는 몇 가지 내장 프로토콜이 들어 있어요. 이전 장들에서, Enumerable 프로토콜을 구현한 모든 데이터 구조와 함께 동작하는 다양한 함수를 제공하는 Enum 모듈을 논의했어요.

iex> Enum.map([1, 2, 3], fn x -> x * 2 end)
[2, 4, 6]
iex> Enum.reduce(1..3, 0, fn x, acc -> x + acc end)
6

또 다른 유용한 예시는 String.Chars 프로토콜이에요. 데이터 구조를 사람이 읽을 수 있는 문자열 표현으로 변환하는 방법을 지정하며, to_string 함수로 접근해요.

iex> to_string(:hello)
"hello"

Elixir의 문자열 보간(interpolation)이 to_string 함수를 호출한다는 점을 눈여겨보세요.

iex> "age: #{25}"
"age: 25"

위 코드가 동작하는 이유는 숫자가 String.Chars 프로토콜을 구현하기 때문이에요. 예를 들어 튜플을 넘기면 오류가 발생해요.

iex> tuple = {1, 2, 3}
{1, 2, 3}
iex> "tuple: #{tuple}"
** (Protocol.UndefinedError) protocol String.Chars not implemented for {1, 2, 3} of type Tuple

더 복잡한 데이터 구조를 "출력"해야 할 때는 Inspect 프로토콜에 기반한 inspect 함수를 사용할 수 있어요.

iex> "tuple: #{inspect(tuple)}"
"tuple: {1, 2, 3}"

Inspect 프로토콜은 모든 데이터 구조를 읽을 수 있는 텍스트 표현으로 변환하는 데 사용되는 프로토콜이에요. IEx 같은 도구가 결과를 출력할 때 사용하는 것도 바로 이것이에요.

iex> {1, 2, 3}
{1, 2, 3}
iex> %User{}
%User{name: "john", age: 27}

관례상, 검사된(inspect) 값이 #로 시작하면 그것은 유효하지 않은 Elixir 문법으로 데이터 구조를 표현한 것이라는 점을 기억하세요. 즉 도중에 정보가 손실될 수 있으므로 inspect 프로토콜은 되돌릴 수 없어요.

iex> inspect &(&1+2)
"#Function<6.71889879/1 in :erl_eval.expr/5>"

Elixir에는 다른 프로토콜들도 있지만, 이것이 가장 흔한 것들을 다룬 거예요. 프로토콜과 구현에 대해 더 자세히 알고 싶다면 Protocol 모듈을 확인해 보세요.

더 알아보기

  • Protocol 모듈 문서 — 프로토콜과 구현에 대한 자세한 내용을 확인할 수 있어요.
  • Enum 모듈 문서Enumerable 프로토콜 기반 함수들을 확인할 수 있어요.
  • to_string/1 문서String.Chars 프로토콜의 진입점인 함수예요.
  • Structs — 이 장이 이어받는 이전 장이에요. 구조체와 프로토콜 구현의 관계를 다뤄요.