엘릭서의 패턴 매칭이 정말 사용하기 좋은 이유는?

패턴 매칭은 엘릭서에서 사용하기 가장 즐거운 기능 중 하나입니다. 하지만 도대체 왜 사용하기 즐거운지 설명하려고 하면 참 어렵습니다. 이는 단순히 패턴 매칭의 기능 때문만이 아니라 엘릭서의 특성과의 조화 때문에 발생하는 경험이기 때문입니다.

이 글에서는 엘릭서에서 패턴 매칭을 사용한 예시를 몇 가지 제시하고, 패턴 매칭이 왜 뛰어난 기능인지 설명하려 합니다. 대상 독자는 객체 지향 프로그래밍(OOP)에 익숙하지만 함수형 프로그래밍(FP)에 대해서는 잘 모르는 사람입니다. 엘릭서의 패턴 매칭과 타 언어의 패턴 매칭을 비교하기 위한 글은 아닙니다.

시작해볼까요?

패턴 매칭? 그게 뭔가요?

패턴 매칭이 무엇인지에 대해서는 다른 사람들이 잘 설명한 글이 이미 있으니 이 글에서는 직접 설명하지는 않겠습니다. 엘릭서 언어에 대해서 얘기하고 있으니 엘릭서 관련 자료를 두 개 링크합니다. 엘릭서 언어 공식 가이드의 설명(영문)과 엘릭서 스쿨에 있는 설명(국문)입니다. 패턴 매칭에 대해서 처음 들어본다면 한 번 읽어보세요. 이 글에서는 실제로 접하고 작성하게 될 코드를 살펴보겠습니다.

엘릭서 패턴 매칭 실제 사용 예시

얼핏 보기에는 패턴 매칭은 조금 더 복잡한 스위치문처럼 보일 수도 있습니다. 하지만 패턴 매칭의 기능은 훨씬 강력합니다. 패턴 매칭을 사용하는 전형적인 엘릭서 코드를 살펴봅시다.

def random(enumerable) do
  case Enumerable.count(enumerable) do
    {:ok, 0} ->
      raise Enum.EmptyError
    {:ok, count} ->
      at(enumerable, random_integer(0, count - 1))
    {:error, _} ->
      case take_random(enumerable, 1) do
        []     -> raise Enum.EmptyError
        [elem] -> elem
      end
  end
end

엘릭서 코어 라이브러리의 Enum 모듈에 들어 있는 함수입니다. 함수 이름에서 알 수 있듯이 주어진 enumerable에서 무작위로 요소를 선택해서 반환합니다. 먼저 Enumerable.count/1 함수를 호출합니다. 이 함수는 일반적인 엘릭서 컨벤션을 따라 {:ok, value} 또는 {:error, message}라는 요소 두개짜리 튜플을 반환합니다. 그리고 함수는 그 반환 값에 대해서 패턴 매칭을 하고, 매치 결과에 따라서 적절한 함수를 호출합니다.

첫 번째 케이스를 봅시다. {:ok, 0}는 첫번째 값으로 :ok, 두 번째 값으로 0을 가지고 있는 튜플 단 하나에만 매칭됩니다.

두 번째 케이스인 {:ok, count}는 첫 번째 값으로 :ok를 가지고 있는 모든 튜플에 매칭됩니다. 단 {:ok, 0}는 이미 이전 케이스에 매칭되었을 것이기 때문에 이 케이스에 도달하지 못합니다. 그리고 매치된 두 번째 값은 동시에 count라는 변수에 바인드 됩니다.

세 번째 케이스인 {:error, _}:error를 첫 번째 값으로 가진 값 두 개짜리 튜플 전부에 매칭됩니다. 두 번째 값은 무관합니다.

패턴 매칭 = 디스트럭쳐링 + 제어 구조

위 예시에 있는 9줄짜리 간결한 패턴 매칭은 여러 기능을 수행하고 있습니다. 어떤 일을 하는지 한 번 살펴봅시다.

첫째, 인풋 데이터의 구조를 확인합니다. 세 케이스 모두 값 두 개짜리 튜플에 매치합니다.

둘째, 데이터 내부에 접근해서 값을 확인합니다. 값 두 개짜리 튜플이 첫 번째 값으로 :ok:error를 가지고 있는지 확인합니다.

셋째, 접근한 데이터 내부의 값을 다른 변수에 바인드합니다. 두 번째 케이스를 보면 매치된 튜플의 두 번째 값을 변수에 바인드하는 것을 볼 수 있습니다.

FP에서는 이 세 가지 작업을 묶어서 디스트럭쳐링(destructuring) 혹은 디컨스트럭팅(deconstructing)이라고 불립니다.

넷째, 매치된 케이스에 어떻게 대응할 지 명시합니다. 각 케이스마다 추후 작업을 지시할 수 있는데, 해당 케이스에서 디스트럭쳐링을 통해서 바인드한 변수를 넘겨받을 수 있습니다.

정리하자면 패턴 매칭은 원하는 데이터 구조를 명시하고, 그 안에서 값을 추출하고, 추출한 값을 다른 함수에 넘겨줄 수 있습니다. 추상적인 논리를 코드로 구현하는 과정에서 이런 작업은 끊임 없이 하게되는데, 패턴 매칭은 반복적인 코드를 조금만 사용해도 이런 코드를 매우 효율적이고 간결하게 작성할 수 있도록 해줍니다.

하지만 이게 다가 아닙니다.

패턴 매칭 관련 OOP와 FP의 이론적 차이에 관심이 있다면 글 마지막 부분에 이를 다루는 추가적인 문단이 있습니다.

코드 정리와 구성을 위한 Multi-clause Functions

multi-clause function은 패턴 매칭과 관련된 유용한 편의성 문법입니다. 앞서 소개한 Enum.random/1 함수의 전체 정의는 사실 다음과 같습니다.

@spec random(t) :: element | no_return
def random(enumerable)

def random(first..last),
  do: random_integer(first, last)

def random(enumerable) do
  case Enumerable.count(enumerable) do
    {:ok, 0} ->
      raise Enum.EmptyError
    {:ok, count} ->
      at(enumerable, random_integer(0, count - 1))
    {:error, _} ->
      case take_random(enumerable, 1) do
        []     -> raise Enum.EmptyError
        [elem] -> elem
      end
  end
end

첫 두 줄은 함수의 타입 스펙이며 이 글과는 무관하니 생략하고 나머지 코드를 봅시다.

동일한 이름으로 정의된 함수가 두 개 있습니다. 이는 패턴 매칭 케이스를 multi-clause function으로 분리할 수 있게 해주는 편의성 문법입니다. 실제로 다음과 같이 작성한 코드도 위의 코드와 동일하게 동작합니다.

@spec random(t) :: element | no_return
def random(enumerable)

def random(enumerable) do
  case enumerable do
    first..last -> random_integer(first, last)
    enumerable -> 
      case Enumerable.count(enumerable) do
        {:ok, 0} ->
          raise Enum.EmptyError
        {:ok, count} ->
          at(enumerable, random_integer(0, count - 1))
        {:error, _} ->
          case take_random(enumerable, 1) do
            []     -> raise Enum.EmptyError
            [elem] -> elem
          end
      end
  end
end

코드를 비교해보면, 가장 상위 단계의 패턴 매칭을 여러 개의 함수 정의로 분리했을 뿐이라는 것을 알 수 있습니다. 얼핏 보기에는 그리 중요하지 않은 이 기능에 어떤 의의가 있을까요? 바로 함수를 시각적으로 명확히 구분할 수 있는 작은 부분들로 함수를 분리할 수 있게 해준다는 것입니다. 코드를 분리하면 읽는 이가 머리 속에 담아두어야 할 코드의 전체 양이 줄어들어서 코드를 이해하고 유지보수하기 더 쉬워집니다.

패턴 매칭을 사용해서 이미 데이터를 디스럭쳐링하고 제어 구조도 다루고 있었다는 것을 생각해보세요. 그 뿐만 아니라 패턴 매칭을 사용해서 코드베이스를 관리하는 일까지 할 수 있는 것입니다.

재귀와 패턴 매칭

패턴 매칭을 사용한 실제 예시를 하나 더 살펴봅시다. 재귀는 함수형 프로그래밍의 기본적인 기법으로, 패턴 매칭과 매우 잘 맞습니다. 아래 코드는 엘릭서 코어 라이브러리의 Enum.reverse/1 함수로 enumerable을 받은 뒤 리스트 안의 요소를 역순으로 뒤집어서 반환합니다.

@spec reverse(t) :: list
def reverse([]), do: []
def reverse([_] = l), do: l
def reverse([a, b]), do: [b, a]
def reverse([a, b | l]), do: :lists.reverse(l, [b, a])
def reverse(enumerable), do: reduce(enumerable, [], &[&1 | &2])

다음은 동일한 함수를 multi-clause function을 사용하지 않고 작성한 코드입니다.

@spec reverse(t) :: list
def reverse(enumerable) do
  []         -> []
  [_] = l    -> l
  [a, b]     -> [b, a]
  [a, b | l] -> :lists.reverse(l, [b, a])
  enumerable -> reduce(enumerable, [], &[&1 | &2])
end

이 예시에서 재귀 함수의 베이스 케이스는 첫 세 케이스입니다. Multi-clause function을 사용하면 베이스 케이스가 더 명확히 분리됩니다. 재귀 함수를 이해하려면 베이스 케이스를 이해하는 것이 필수적임을 생각해보면, 이를 시각적으로 돋보이게 해주는 패턴 매칭은 도움이 됩니다.

간단한 데이터 타입

엘릭서는 동적 타입 언어인기 때문에 정교한 타입 시스템을 만들기 어렵습니다. 커스텀 타입을 정의하고, 함수 시그니처를 명시하고, dialyzer 같은 정적 코드 분석 툴을 사용할 수는 있지만, 하스켈 같은 정적 타입 언어에는 비할 수 없습니다

하지만 대신 엘릭서는 훨씬 간단하게 읽고 작성할 수 있습니다. 함수형 언어에서는 데이터 구조를 디스트럭쳐링하는 문법과 생성하는 문법이 동일합니다. 그리고 엘릭서 프로그래머들은 대부분 언어에서 제공하는 기본 타입만을 사용하기 때문에 데이터 구조 대여섯개만 배워도 엘릭서 프로그램을 이해하고 작성할 수 있습니다. 반면 정적 타입 언어를 사용하려면 계속해서 늘어나는 커스텀 타입과 해당 타입에 대한 인터페이스를 배워야만 합니다.

덕분에 엘릭서 프로그램은 매우 가볍고 부담 없이 다룰 수 있습니다.

주연을 맡은 패턴 매칭

하지만 곰곰히 생각해보면 여기서 이야기한 패턴 매칭의 기능 중 정말 새로운 것은 없습니다.

스위치문은 명령형 언어에서 오랜 세월 동안 프로그램 흐름을 제어하기 위해 사용되어 왔습니다. 디스럭쳐링은 루비, 스위프트, ES6 자바스크립트 등 FP 요소를 보다 적극적으로 받아들인 OO 언어에서도 이제 지원합니다. 하지만 해당 언어에 구현된 패턴 매칭은 여전히 제한적인 기능만을 수행할 수 있을 뿐 아니라, 일부 OO 원칙 및 방법론과 상충하기 때문에 부차적인 도구로만 사용되고 있습니다.

엘릭서에서는 패턴 매칭이 주연을 맡습니다. 언어 전체가 패턴 매칭을 위해 만들어진 것처럼 느껴질 정도로 패턴 매칭이 그 기능을 전적으로 발휘할 수 있습니다. 저는 엘릭서에서 패턴 매칭을 사용하는 것이 즐겁게 느껴지는 것은 이 때문이라고 봅니다.

이는 실제적으로는 프로그래밍 과정의 대부분을 패턴 매칭이라는 관점에서 바라볼 수 있다는 의미입니다. 디스트럭쳐링? 패턴 매칭으로 해결. 제어 흐름? 패턴 매칭으로 해결. 코드 구성 정리? 패턴 매칭으로 해결. 덕분에 어떤 상황에서 어떤 도구를 사용해야할 지 고민할 필요 없이, 하나로 연결된 끊임없는 흐름 상에서 코드를 작성하고 관리할 수 있습니다.

마무리

엘릭서에서 패턴 매칭을 사용하는 경험은 패턴 매칭의 기능적 요소만으로는 설명할 수 없습니다. 엘릭서의 특징과 패턴 매칭이라는 도구가 조화를 이루어 그런 좋은 경험을 만드는 것입니다. 엘릭서를 아직 사용해본 경험이 없다면, 엘릭서에서 패턴 매칭이 어떻게 동작하는지를 경험하기 위해서라도 한 번 엘릭서를 살펴보세요.


캡슐화는 없고, 제어 흐름은 명시적으로

디스트럭쳐링을 통해서 데이터 구조 내부의 값에 자유롭게 접근할 수 있는데, 이는 캡슐화를 완전히 위반하는 것처럼 보일 수도 있습니다. 사실 맞습니다. FP에는 캡슐화라는 개념이 없다는 것만 제외하면요.

OOP에서 캡슐화의 가장 중요한 목적 중 하나는 데이터 접근 권한을 제한하는 것입니다. 데이터를 보유한 객체만이 해당 데이터를 읽고 쓸 수 있습니다. 그 외에는 해당 객체에 요청해야만 그 데이터를 다룰 수 있습니다. 데이터의 온전함과 일관성을 보증하기 위해서입니다.

반면 FP는 쓰기 권한 문제를 완전히 다른 방식으로 해결합니다. 모든 데이터는 변경이 불가능하기 때문에 아무도 쓰기 권한이 없는 셈입니다.

쓰기 권한을 제한할 필요가 없어지면 접근 권한 관리를 위해 데이터를 객체 안에 캡슐화할 필요도 없어집니다. 따라서 FP는 객체에 캡슐화되어 있었을 데이터와 메서드를 데이터 타입과 함수 모듈로 분리합니다. OOP 용어로 표현하자면 복잡한 객체를 보다 단순한 data transfer object와 service object로 분리하는 셈입니다.

읽기 권한의 경우 각 FP 언어마다 다루는 방식이 다릅니다. 얼랭/엘릭서, 클로져 등의 동적 타입 언어는 대부분의 데이터를 언어에서 기본적으로 제공하는 데이터 타입을 사용해서 다룹니다. 프로그래머들은 당연히 이런 기본 데이터 타입을 다룰 줄 아는 만큼, 거의 대부분의 데이터에 접근할 수 있다고 볼 수 있습니다. 달리 말해 해당 언어에서는 프로그래머들이 완전한 읽기 권한을 가지고 있는 셈입니다.

반면 하스켈과 ML 계통 언어처럼 정적 타입 언어는 커스텀 타입을 활용하는 강력한 타입 시스템을 갖추고 있습니다. 프로그래머들은 구현하는 로직에 적합한 자신만의 타입을 정의해서 사용하며, 그런 커스텀 타입들의 인터페이스를 모르는 사람은 해당 타입의 자료를 읽을 수가 없습니다. 이는 읽기 권한을 실질적으로 제한합니다.

OOP와 FP 양쪽의 요소를 모두 활용하는 멀티패러다임 언어의 경우 캡슐화 문제를 각기 다른 방식으로 해결합니다. 스칼라는 객체 캡슐화를 유지하면서도 디스트럭쳐링을 할 수 있도록 extractor object를 제공합니다. 루비는 자주 사용되는 데이터 객체를 디스트럭쳐링할 수 있는 간편한 문법을 코어 라이브러리에서 제공하되, 흐름 제어는 분리합니다. 스위프트는 디스트럭쳐링을 튜플에만 제한하되, 옵셔널 언래핑이나 타입 캐스팅 가능여부 확인 등 스위프트에 특화된 기능을 지원합니다. ES6 자바스크립트는 강력한 디스트럭쳐링 기능을 지원하지만 제어 흐름은 거기서 분리해두었습니다.

또한 데이터 구조에 따라서 데이터를 어떻게 처리할 지 패턴 매칭을 사용해서 명시적으로 정의할 수 있습니다. 이는 OOP의 수동 메서드 디스패치, 즉 객체의 타입에 스위치문을 돌려서 수동으로 실행할 메서드를 지정하는 것과 유사하게 보일 수도 있습니다. 이는 OOP에서 사용하면 비판 받는 방식이죠. OOP에서는 대신 함수 오버로드서브타입 다형성 등의 기법을 사용해서 메서드 선택을 객체에 위임하는 것을 선호합니다. 반면 FP는 이를 함수 수준에서 명확히 다루는 것을 선호합니다.

하지만 이 둘은 다른 방식으로 접근해야 합니다. OOP의 스위치문은 특정 상황에서 무엇을 할 지 지시하는 것으로, 명령형 언어 관점에서 봐야합니다. 반면 FP의 패턴 매칭은 각 케이스마다 규칙을 정립하는 것으로, 선언형 언어 관점에서 봐야합니다.