ReasonML의 폴리모픽 배리언트 알아보기

이 글은 ReasonML에 대한 약간의 이해가 필요합니다.
ReasonML에 대한 소개는 여기를,
ReasonML 문법에 대한 소개는 여기를 참고해주세요.


ReasonML에서 가장 유용한 기능을 꼽으라면 단연 배리언트와 패턴매칭일 것입니다. 여기에 더해 ReasonML에는 일반 배리언트보다 조금 특별한 폴리모픽 배리언트라는 것이 있습니다.

폴리모픽 배리언트는 bs-css 같은 프로젝트나 바인딩 라이브러리를 다루다 보면 빈번하게 마주하게 됩니다. 그래서 구체적으로 어떤 개념인지 잘 몰라도 일단 사용하는 것에는 큰 어려움이 없습니다.

그렇지만 실제 동작 원리를 알아두면 더 좋을 것입니다. 블로그를 작성하는 현시점까지 ReScript 공식 문서에는 폴리모픽 배리언트에 대한 설명이 나와있지 않기에, 이 글에서는 폴리모픽 배리언트의 개념과 이것이 언제 필요한지에 대해 다루어보겠습니다.

본문의 예시는 Real World OCaml1과 OCaml 공식 문서2를 참고하였으며, ReasonML 버전으로 재작성하였습니다.

기본적인 문법

폴리모픽 배리언트는 타입 선언 없이 바로 사용할 수 있는 것이 가장 큰 특징입니다.

일반 배리언트와 다른게 반드시 대문자로 시작할 필요가 없지만, 태그에 백틱(`)을 붙여주어야 합니다. 참고로 ReasonML과는 사촌지간인 리스크립트(ReScript)에서는 해시(#)를 붙이는 것으로 문법이 바뀌었습니다. 리스크립트는 최대한 자바스크립트와 비슷한 문법을 지향하는데, 자바스크립트의 문자열 인터폴레이션과 헷깔리기 때문에 바꾸었다고 합니다.

아래는 폴리모픽 배리언트의 예입니다.

1let three = `Int(3);
2/* [> `Int(int) ] */
3
4let four = `Float(4.0);
5/* [> `Float(float) ] */
6
7let nan = `Not_a_number;
8/* [> `Not_a_number ] */
9
10[three, four, nan];
11/* list( [> `Float(float) | `Int(int) | `Not_a_number ] ) */

보다시피 별도의 타입 선언 없이 쓸 수 있으며, 배리언트의 태그들이 대괄호([])로 감싸져 있는 것이 특징입니다. 그리고 리스트에 섞어 쓰면 그 결과에 대응되는 새로운 타입도 추론해 줍니다.

참고로 배열에 대해서는 타입 추론이 되지 않고 컴파일 에러가 발생합니다. 이는 OCaml🐫 구현의 영향을 받은 것인데, 배열은 성능을 위해 사용되는 자료구조이기 때문인 것으로 추측됩니다.

당연하지만 아래와 같이 태그를 혼용하는 것은 안됩니다.

1let five = `Int("five")
2/* [> `Int(string) ] */
3
4[three, four, five]
5
6/*
7This has type: [> `Int(string) ]
8Somewhere wanted: [> `Float(float) | `Int(int) ]
9Types for tag `Int are incompatible
10*/

폴리모픽 배리언트의 범위(bounds)

아까부터 자동 추론되는 타입 앞에는 > 문자가 보입니다.

1[three, four];
2/* [> `Float(float) | `Int(int) ] */

이런 식으로요.

>로 시작하는 타입의 의미는 다음과 같습니다.

열거된 태그들을 포함하기만 하면 기타 다른 태그를 포함해도 됨

위 예시는 "Float(float)Int(int)를 포함, 그리고 추가적으로 다른 태그도 포함할 수 있는 타입"의 폴리모픽 배리언트를 의미합니다. 이를 폴리모픽 배리언트의 하계(a lower bound)라고 합니다.

반대로 컴파일러가 배리언트의 타입을 <로 추론하는 경우가 있습니다. 아까와는 부등호 방향이 반대입니다. 눈치채셨겠지만 이를 폴리모픽 배리언트의 상계(an upper bound)라고 합니다. <로 시작하는 타입의 의미는 다음과 같습니다.

열거된 태그들로 선언되거나 혹은 그보다 부족하게만 선언되면 문제없음

예시를 들어보겠습니다.

1let isPositive = x =>
2 switch (x) {
3 | `Int(i) => i > 0
4 | `Float(f) => f > 0.
5 };
6/* [< `Float(float) | `Int(int) ] => bool */

위 함수의 반환 타입이 < 인 이유는, Float, Int 이외의 태그가 들어가면 함수 내부의 스위치 문에서 대응이 안되기 때문입니다. 이는 안전하지 못한 코드를 의미하므로 컴파일러가 통과시키지 않습니다.

참고로 상계와 하계가 동일한 경우에는 <, > 표기가 없어집니다.

1let exact = Belt.List.keep([three, four], isPositive);
2/* Belt.List.t( [ `Float(float) | `Int(int) ] ) */

상계와 하계가 다른 경우도 만들어낼 수 있습니다.

1let isPositive = x =>
2 switch (x) {
3 | `Int(i) => Ok(i > 0)
4 | `Float(f) => Ok(f > 0.)
5 | `Not_a_number => Error("not a number")
6 };
7
8let exact =
9 Belt.List.keep([three, four], x => {
10 switch (isPositive(x)) {
11 | Error(_) => false
12 | Ok(v) => v
13 }
14 });
15/* Belt.List.t( [< `Float(float) | `Int(int) | `Not_a_number > `Float `Int ] )
16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~ */

추론된 타입이 좀 복잡해 보이지만 천천히 살펴보면 그리 어렵지 않습니다. 이 타입은 Float, Int, NaN 이외의 것을 받을 수는 없지만, 적어도 Float와 Int는 포함해야 함을 의미합니다.

폴리모픽 배리언트가 필요한 순간

실전에서 폴리모픽 배리언트가 일반 배리언트보다 유용한 사례를 알아보겠습니다.

색상을 다루어야 하는 상황을 가정해 봅시다.

1/* 익숙한 일반 배리언트를 사용하여 타이핑 */
2type color =
3 | RGB(int, int, int) /* 6x6x6 color cube */
4 | Gray(int); /* 24 grayscale */
5
6let colorToInt = c =>
7 switch (c) {
8 | RGB(r, g, b) => 16 + b + g * 6 + r * 36
9 | Gray(i) => 232 + i
10 };

색상은 RGB 또는 흑백으로 표현할 수 있고, 이를 정수로 변환할 수 있는 로직입니다. 여기서 요구사항이 바뀌어 투명도를 포함한 색깔을 추가로 다루어야 한다고 해봅시다.

1type extColor =
2 | RGB(int, int, int)
3 | Gray(int)
4 | RGBA(int, int, int, int); /* 🆕 6x6x6x6 color space */

기존 로직에 영향을 주지 않기 위해 color 타입을 수정하지 않고 새롭게 선언했습니다. 여기까지는 좋습니다. 문제는 아까와 비슷하게 extColorToInt라는 함수를 작성하는 순간입니다. 단순한 타입이 아니라 로직의 구현이기 때문에, 기존 colorToInt 로직을 재사용하고자 합니다.

1let extColorToInt = c =>
2 switch (c) {
3 | RGBA(r, g, b, a) => 256 + a + b * 6 + g * 36 + r * 216
4 | (RGB(_) | Gray(_)) as c' => colorToInt(c')
5 };
6
7/*
8This has type: extColor
9Somewhere wanted: color
10*/

하지만 위와 같이 작성한 코드는 동작하지 않습니다.

사람이 보기에는 문제없는 코드 같지만, 컴파일러가 보기에는 extColorcolor가 서로 전혀 다른 타입이기 때문입니다. 에러의 의미도 그것입니다.

하지만 우리가 원하는 것은 저 여지껏 사용된 태그가 적절히 재사용되는 것입니다.

폴리모픽 배리언트로

단순히 일반 배리언트를 폴리모픽 배리언트로 바꿔보겠습니다.

  1. 기존 코드에서 type 으로 선언된 부분은 날려버리고
  2. 패턴매칭에서 백틱(`)만 찍어주면 됩니다.
1let colorToInt = c =>
2 switch (c) {
3 | `RGB(r, g, b) => 16 + b + g * 6 + r * 36
4 | `Gray(i) => 232 + i
5 };
6/* [< `Gray(int) | `RGB(int,int,int) ] => int */
7
8let extColorToInt = c =>
9 switch (c) {
10 | `RGBA(r, g, b, a) => 256 + a + b * 6 + g * 36 + r * 216
11 | (`RGB(_) | `Gray(_)) as c' => colorToInt(c')
12 };
13/* [< `Gray(int) | `RGB(int,int,int) | `RGBA(int,int,int,int) ] => int */

이번에는 문제없이 extColorToInt가 컴파일됐습니다. 그리고 컴파일이 되었으니 문제가 없는 것은 분명합니다. 원했던 대로 c'의 타입이 절절히 축소되어(extColor -> color) colorToInt의 인자로 전달되었습니다.

그런데 매칭 구문이 조금 지저분해 보이는데, 차라리 catch-all로 받아버리면 어떨까요?

1let extColorToInt = c =>
2 switch (c) {
3 | `RGBA(r, g, b, a) => 256 + a + b * 6 + g * 36 + r * 216
4 | c' => colorToInt(c')
5 };
6
7/*
8This has type: [> `RGBA(int, int, int, int) ]
9Somewhere wanted: [< `Gray(int) | `RGB(int, int, int) ]
10The second variant type does not allow tag(s) `RGBA
11*/

아쉽게도 컴파일은 되지 않습니다. 에러가 친절히 알려주다시피 c'의 타입이 굉장히 느슨하게 추론되기 때문입니다.

그 이유는 어렵지 않게 추측할 수 있습니다. 타입을 별도로 선언하지 않았기 떄문에 c'는 어떤 태그도 될 수 있는 반면, colorToInt는 상계가 있기 때문입니다.


⚠️ Catch-all 매칭 ⚠️

앞서 isPositive에서 보았듯이, 함수 본문의 패턴매칭은 배리언트의 상계에 영향을 미칩니다. 그렇지만 패턴매칭에 catch-all을 넣는 순간 반대로 아래와 같이 하계만 결정되고 위로는 제약이 풀려버립니다.

1let isPositivePermissive = x =>
2 switch (x) {
3 | `Int(i) => Ok(i > 0)
4 | `Float(f) => Ok(f > 0.)
5 | _ => Error("Unknown number type")
6 };
7/* [> `Float(float) | `Int(int) ] => ... */
8
9isPositivePermissive(`Int(0));
10/* Ok(false) */
11isPositivePermissive(`Ratio(3, 4));
12/* Error("Unknown number type") */

Catch-all 케이스는 일반 배리언트에서도 에러를 일으키지 쉽지만, 폴리모픽 배리언트에서는 더 심합니다. 왜냐하면 위 예시처럼 코드가 작성된 경우 실수로 오타를 입력해도 컴파일러가 어떤 불평도 하지 않기 때문입니다.

1isPositivePermissive(`Floot(3.05));
2/* Error("Unknown number type") */

만약 일반 배리언트였다면 이러한 문제는 없었을 것입니다. 그러니 폴리모픽 배리언트를 쓸 때에는 catch-all 사용에 주의해야 합니다.

일반 배리언트 vs. 폴리모픽 배리언트

지금까지의 설명으로는 폴리모픽 배리언트가 일반 배리언트의 상위 호환처럼 느껴집니다. 약간만 주의하면 일반처럼 쓸 수도 있고, 더 유연하면서 간결하고요.

그렇지만 대부분의 설계에서는 일반 배리언트가 더 좋은 선택이라고 합니다. 폴리모픽 배리언트는 그 유연함의 대가로 아래와 같은 단점을 가집니다.

  • 더 높은 복잡도
    • 동작 방식이 일반 배리언트보다 복잡하기 때문에, 이것을 마구 쓰다가 컴파일이 잘 안되는 상황이 발생했을 때 컴파일러가 뱉어내는 에러메세지가 굉장히 난해해지곤 합니다. 일반적으로 값 수준에서 구현을 단순하게 할수록 타입 수준에서 구현 복잡도가 증가하는 경향이 있습니다.
  • 더 어려운 디버깅
    • 타입 시스템이 유연할수록 프로그램의 버그를 찾기 어렵다고 합니다. 폴리모픽 배리 역시 타입 안전하긴 하지만, 그 유연함 때문에 에러를 찾기가 좀 더 어려워지는 경향이 있습니다.
  • 낮은 성능
    • 크게 심각한 것은 아니지만 폴리모픽 배리언트는 일반보다 구현이 좀 복잡합니다. 또한 컴파일러가 패턴매칭을 일반 배리언트만큼 효율적으로 처리하는 코드를 생성할 수 없습니다.

이런 단점에도 폴리모픽 배리언트는 여전히 매우 강력하고 유용합니다. 장점을 다시 정리해보자면,

  • 재사용성

    • 생성자가 매우 다양한 경우에 유용합니다. 예를 들어 문자열 인코딩을 종류별로 다뤄야 한다고 했을 때, 이를 미리 선언하지 않고 사용할 수 있어 편리합니다.
      1type encoding = [ |`base64 | `ascii | `latin1 | `utf8 | `hex ... ]
      2/* 수 많은 인코딩을 일일히 나열하고 싶지 않습니다. 😕 */
  • 디커플링

    • 명시적인 타입 선언을 해야 한다면 이를 사용하는 모든 곳에서 의존성을 갖게 되므로 모듈 관리 비용이 들어갑니다. 하지만 타입 선언이 필요치 않기 때문에 태그에 대한 약속은 공유하되 완전히 디커플링된 설계를 할 수 있습니다.
  • 확장성

    • 배리언트가 추후 확장되도록 설계할 수 있습니다. 위의 extColorToInt가 그 간단한 예입니다. 현실적인 예는 graphql-ppx에서도 찾아볼 수 있습니다. graphql-ppx는 스키마의 enum에 대해 폴리모픽 배리언트를 자동 생성하는데, 생성되는 타입은 암묵적으로 `FutureAddedValue(_)라는 태그를 포함하게 됩니다.

      1type t_someQuery_enumField = [
      2 | `FutureAddedValue(string)
      3 | `FIRST
      4 | `SECOND
      5];

      따라서 클라이언트에서는 위와 같이 `FutureAddedValue 매칭 코드 작성이 강제되는데, 덕분에 서버 스키마가 조용히 변경되어 새로운 enum 값이 전달되더라도 클라이언트 런타임에러가 발생하지 않도록 해줍니다.

  • 간결함

    • 생성자가 전역 네임스페이스에 있기 때문에 별도로 모듈을 open할 필요가 없습니다. 또, 별로 중요하지 않은 곳에서 적당히 태그를 만들어 사용하고 버리기에 편리합니다.

맺음말

지금까지 살펴본 내용만으로도 폴리모픽 배리언트를 사용하는 것에는 큰 지장이 없습니다. 하지만 타입의 상계와 하계가 어떻게 결정되는지 그 동작 방식에 대해 이해하는 것도 좋을 것입니다. 다음 글에서는 폴리모픽 타입의 추론 과정(unification)이 어떻게 동작하는지 다루어보겠습니다.



Gravatar for hw.nam@greenlabs.co.kr
남현우소프트웨어 엔지니어
2020. 12. 01.


참고자료


1
Real World OCaml
2
Polymorphic Variants


추천 콘텐츠