Skip to content
그린랩스기술 블로그

앗, ReasonML의 분기문에서는 타입이 다른 데이터를 리턴할 수 없다고요?!

4 min read

자바스크립트나 다른 동적 타입을 가진 언어에서는, 함수나 분기문에서 타입이 다른 데이터를 리턴하는 것이 자연스럽습니다. 하지만 ReasonML에서는 항상 같은 데이터 타입이 리턴되어야 합니다. 처음 ReasonML을 접하게 되면 이런 타입 시스템의 특성 때문에 당황하실 수 있습니다. 코드 예시를 통해 바로 확인해보겠습니다.

자바스크립트 vs. ReasonML

아래의 test 함수는 인자가 숫자(number)일 때는 숫자를, 문자열일때는 문자열을, 그 외에는 {foo: "bar"}라는 객체를 반환합니다.

1let test = (input) => {
2 if (typeof input === "number") return input;
3 else if (typeof input === "string") return input;
4 else return { foo: "bar" };
5};
6
7test(1); /* 1 */
8test("hello world!"); /* hello world! */
9test([1 2 3 4]); /* {foo: "bar"} */

혹은 각 분기문마다 같은 타입의 다른 필드를 가진 객체를 리턴 할 수도 있습니다. 아래의 함수는 인자가 참일 때는 {foo: "bar"}를, 거짓일 때는 {hello: "world"}를 반환합니다.

1if (input === true) {
2 return { foo: "bar" };
3} else {
4 return { hello: "world" };
5}
6/* OK */

하지만 ReasonML에선 안전한(sound) 타입 시스템을 위해 모든 분기문의 리턴값은 반드시 한 타입으로 정해져 있습니다. 위의 {foo: "bar"}{hello: "world"}는 다른 타입으로 간주되어 컴파일이 실패합니다. 하나의 예시를 더 보겠습니다.

1if (input === true) {
2 2; /* ReasonML에선 마지막 표현식이 리턴으로 간주됩니다.*/
3} else {
4 "hello";
5}

결과는 아래와 같습니다.

1This has type: string
2Somewhere wanted: int
3
4You can convert string to int with Belt.Int.fromString.

타입이 객체인 경우 세부 모든 세부 필드의 값이 일치해야(구조적 타입) 오류가 나지 않습니다.

1switch ((input: bool)) {
2| true => {"foo": "bar"}
3| false => {"hello": "world"}
4}

결과는 마찬가지로 컴파일 오류입니다.

1This has type: {. "hello": string}
2 Somewhere wanted: {. "foo": string}
3 The second object type has no method hello

하지만 개발을 하다보면 다른 객체를 반환하는 것이 꼭 필요한 경우가 생깁니다. 아래의 예시처럼요.

현업에서 마주친 상황

그린랩스에선 프리즈마(Prisma)1 라는 DB 클라이언트를 사용하여 백엔드 개발을 하고 있습니다. 프리즈마는 ORM처럼 고유의 문법을 사용하며, 중첩된 객체들로 관계형 쿼리를 표현할 수 있습니다. 2 아래는 간략화된 실제 코드입니다. isHeavy 라는 bool 변수의 값에 따라 참일 경우 20kg보다 무거운 수박을, 거짓일 경우 20kg과 같거나 가벼운 수박을 조회하는 함수입니다.

1/*
2 수박 중에서, 조건(isHeavy)에 따라
3 20kg보다 무겁거나, 20kg과 같거나 작은 모든 데이터를 리턴해라
4*/
5await prisma.watermelon.findMany({
6 where: isHeavy
7 ? { weight: { gt: 20 } }
8 : { weight: { lte: 20 } };
9 });

위 코드를 ReasonML로 옮기면 아래와 같습니다.

1prisma.watermelon
2->Prisma.findMany({
3 "where": {
4 isHeavy
5 ? {
6 "weight": {
7 "gt": 20,
8 },
9 }
10 : {
11 "weight": {
12 "lte": 20,
13 },
14 };
15 },
16});

하지만 위의 ReasonML 코드는 컴파일이 실패하고 아래의 오류가 뜹니다.

1Error:
2This expression has type {. "weight": {. "lte": int}}
3but an expression was expected of type {. "weight": {. "gt": int}}
4The second object type has no method lte

ReasonML의 객체 타입 추론은 재귀적으로 작동하여 중첩된 필드까지 전부 검사합니다. 얼핏 보면 똑같은 {"weight":...} 객체처럼 보일 수 있지만, 그 안에 하나는 gt라는 키를, 다른 하나는 lte라는 키를 가진 서로 다른 객체이기에 컴파일이 안되는 것이죠. 그럼 이 문제를 어떻게 해결할까요?

해결 방안 1 - [%raw]로 JS코드 직접 넣기

[%raw]를 쓰면 JS 코드를 직접 삽입하고 타입 검사를 피할 수 있습니다.

1[%raw {| console.log("hello world!") |}] /* Js.log("hello world!) 와 동일*/

아래는 [%raw]를 적용해본 예시입니다.

1prisma.watermelon -> Prisma.findMany{
2%raw {|
3 where: isHeavy
4 ? {"weight": {"gt": 20},}
5 : {"weight": {"lte": 20},}
6 |};
7};

위의 코드가 컴파일되면 %raw 안의 구문이 그대로 Js 코드가 되어 나옵니다.

1isHeavy ? { weight: { gt: 20 } } : { weight: { lte: 20 } };

해결 방안 2 - Obj.magic 컴파일러 속이기

Obj.magic은 ReasonML 내장함수 중 하나로, 마술(magic)이라는 이름처럼 교묘한 트릭으로 코드를 왜곡합니다. 아래처럼 사용할 수 있습니다.

1isHeavy
2 ? {weight: {gt: 20}}
3 : Obj.magic( /* 컴파일러가 체크하지 않음! */
4 {weight: {lte: 20}}
5 );

Obj.magic는 OCaml의 %identity의 바인딩인데요, %identity는 컴파일러에게 '값이 동일하더라도 타입을 다르게 인식해라'라고 말하는 역할을 합니다.

1external magic : 'a -> 'b = "%identity";

따라서 {lte ...} 는 사실상 {gt ...}와 같은 타입으로 인식되어 위의 문제를 회피할 수 있습니다. 그러나 위 두 방법은 안전하게(sound) 느껴지지 않습니다. 다른 언어를 직접 사용하거나 컴파일러를 속이지 않고, ReasonML 안전한 방법으로 해결할 수는 없을까요?

해결 방안 3 - Js.Dict

조금 더 나은 방법으로는 Js.Dict를 사용해 볼 수 있습니다.

Js.Dict는 ReasonML에서 사용할 수 있는 Dictionary 모듈입니다.

1let test = Js.Dict.empty();
2test->Js.Dict.set("foo", "bar");
3
4test->Js.log; /* => {foo: "bar"} */

공식문서에 다양한 쓰임이 나와 있으니 참고하세요.

Js.Dict를 사용하여 조건에 따라 gt 혹은 lte를 리턴하는 conditionFunc를 만들었습니다.

1let result = Js.Dict.empty();
2let conditionFunc = (isHeavy: bool) => {
3 isHeavy
4 ? result->Js.Dict.set("gt", 20)
5 : result->Js.Dict.set("lte", 20);
6 result;
7};

conditionFunc를 호출하여 콘솔에 찍어보면, 우리가 원했던 객체가 나옵니다.

1{ gt: '20' } //conditionFunc(true)
2{ lte: '20' } //conditionFunc(false)

위 표현이 가능한 이유는 아래와 같습니다. ReasonML-Dict-Example

Js.Dict로 감싼 두 표현은 Js.Dict.t 타입으로 인식되기 때문에 컴파일상 아무 문제가 없습니다. 마치 "foo"와 "bar"가 값은 다르지만 같은 string 타입인 것처럼요. raw나 obj.magic을 썼을 때 보다는 조금 더 타입 안정성이 확보된 것 같습니다. 하지만 Js.Dict.t 타입이면 다 받을 수 있기 때문에 gt, lte와 같은 키만 들어갈 수 있는지는 보장되지 않습니다. 만약 gt가 들어갈 자리에 gd를 치면 런타임에서 에러가 나겠죠?

해결 방안 4 - 배리언트(Variant)

배리언트를 사용해 타입 안정성을 더 높힐 수 있습니다. 배리언트는 다른 언어의 enum 과 비슷한 기능인데, 임의의 데이터를 배리언트로 감싸 표현할 수 있습니다. 또한 패턴 매칭 되기 때문에 다양한 방법으로 활용 가능한 자료형입니다. 간단한 예로 account라는 타입을 보겠습니다.

1type account =
2 | None
3 | Instagram(string)
4 | Facebook(string, int)

account는 None, Instagram, Facebook 중 하나의 타입이 될 수 있는 배리언트입니다. 패턴매칭으로 안의 값을 가져 올 수 있습니다.

1let myAccount = Facebook("Josh", 26);
2let friendAccount = Instagram("Jenny");
3
4switch (myAccount) {
5| Facebook(name, age) => "my name is " ++ name ++ " and, " ++ string_of_int(age) ++ " years old."
6| Instagram(name) => "my name is " ++ name ++ "."
7};
8/* my name is Josh and I am 26 years old. 출력!! */

공식문서에 다양한 쓰임새가 나와있습니다. 실제로는 아래처럼 쓰일 수 있습니다.

1/* allType 배리언트 선언 */
2type allType =
3 | GT(int)
4 | LTE(int);
5
6/* 배리언트로 감싸진 객체 반환 */
7let conditionFunc = (isHeavy: bool) => {
8 isHeavy ? GT(20) : LTE(20);
9};
10
11
12conditionFunc(true)->Js.log; /* { TAG: 0, _0: 20 } */

conditionFunc를 실행하면 원하는 객체가 아닌 { TAG: 0, _0: 20 }와 같은 배리언트로 둘러싼 값이 나오기 때문에, 원하는 값으로 만들기 위해 앞서 설명한 Js.Dict를 활용하겠습니다.

1let toData = (input: allType) => {
2 let ret = Js.Dict.empty();
3 switch (input) {
4 | GT(v) => ret->Js.Dict.set("gt", v)
5 | LTE(v) => ret->Js.Dict.set("lte", v)
6 };
7 ret;
8};
9
10conditionFunc(true)->toData->Js.log; /* { gt: '20' } */

이렇게 해서 배리언트를 사용하여 서로 다른 객체를 반환하는데 성공했습니다! 그림으로 다시 보겠습니다.

ReasonML-Variant-Example

이렇게 allType 배리언트를 선언하고, 안에 20을 넣었습니다.

ReasonML-Variant-Example 그 후 배리언트 안의 값을 꺼내어, toData 함수를 사용하여 객체를 재구성했습니다.

배리언트를 통한 작업은 Js.Dict만 사용한 이전의 해법보다 더 엄밀한 타이핑이 적용되었습니다. 이제 conditionFunc는 GT, LTE만 리턴하며 allType에 정의되지 않은 타입을 절대 리턴하지 않습니다. Js.Dict가 key-value 쌍이 있는 모든 객체를 리턴했던것과 차별되는 부분입니다. 하지만 코드가 길고 배리언트 안의 값을 뽑아서 Js.Dict로 재구성하는 등의 부수적인 절차가 필요했습니다.

해결 방안 5 - 배리언트와 언박싱 GADT(심화)

마지막으로, GADT(General Algebraic Data Types)와 언박싱 태그(@unboxed)를 이용한 좀 더 고차원적인 해법도 있습니다. 그 중에서도 GADT는 난이도가 높은 개념이라 다른 포스팅에서 추가로 다루도록 하고, 간략히 언박싱에 대해서만 먼저 다루겠습니다. 설명은 BuckleScript를 개발한 Hongbo님이 작성한 리스크립트 블로그 글을 참고했습니다.

1/* 언박싱 */
2[@unboxed]
3/* GADT 정의 */
4type t =
5 | Any ('a) : t;
6
7/* GADT 배리언트의 배열 정의 */
8let array = [|Any(3), Any("a")|];

1/* 컴파일 결과 */
2var array = [3, "a"];

3, "a"처럼 서로 타입이 다른 데이터를 Any 로 감싸 한 배열에 넣었습니다. 컴파일 결과 배열 안에는 Any의 실 값 3과 "a"가 잘 들어 있습니다. 여기서 만약 언박싱 태그가 없었다면 컴파일 결과는 어땠을까요? 아래와 같습니다.

1/* @unboxed가 없을 때 */
2var array = [
3 /* Any */ {
4 _0: "hello",
5 },
6 /* Any */ {
7 _0: 3,
8 },
9];

이렇듯 _0 같은 메타 정보가 붙어 안의 값을 직접 불러오는 것이 번거로워집니다. 하지만 언박싱을 이용하면 값을 바로 가져오기 때문에 서로 다른 타입의 데이터를 한 배열에서 직접 사용할 수 있습니다. 이제 실제 코드에서 사용해보겠습니다.

1[@unboxed]
2type t =
3 | Any(Js.t({..})): t;
4 /*
5 - t는 GADT의 개념으로 사용되었습니다.
6 - Any 안의 값을 JSON으로 제한합니다.
7 */
8
9[...]
10"weight":
11 if (isHeavy) {
12 Any({"gt": 20}); /* Any에 @unboxed가 있으므로 실 값이 바로 리턴됩니다. */
13 } else {
14 Any({"lte": 20});
15 }

1type t =
2 | Any(Js.t({..})): t;

위의 코드는 Any 안의 데이터를 JS 객체로 한정하는 역할을 합니다. 다른 원시타입(int, string 등)은 올 수 없습니다. 아래는 JS로 컴파일된 결과입니다.

1"where": {
2 "weight":
3 if (isHeavy) {
4 return {"gt": 20};
5 } else {
6 return {"lte": 20};
7 }
8},

무엇을 한 것인지 다시 한번 짚어보겠습니다.

ReasonML-Not-Compile-Example

  • 하나의 분기문에서 리턴하는 객체의 구조가 다르면, 컴파일이 되지 않습니다.
  • 따라서 GADT를 활용하여 JSON이 올 수 있는 Any타입을 만들었습니다.

ReasonML-Compile-Example

  • 이후 언박싱 태그를 붙여 Any 안의 값을 바로 꺼내올 수 있도록 했습니다.

비교

ReasonML의 타입 시스템을 상대하는 다섯가지 방법에 대해 알아보았습니다. 흥미롭게도, 타입 안정성과 코드의 간결함은 반비례 관계에 있습니다. 모든 데이터에 타입을 적용하면 안정성은 높아지는 대신 코드가 길어지고, 반대로 타입 시스템을 무시하고 코드를 작성하면 코드는 짧지만 안전하지 않습니다. 그림으로 이 느낌을 표현해보았습니다.

ReasonML-Comparison-Graph

이처럼 트레이드 오프가 명확한 상황에선 어떤 부분의 안정성을 중요시 할 것인가가 중요한 질문입니다. 그랜랩스는 비즈니스 데이터는 완전한 타이핑으로 오류 없는 코드를, 라이브러리 혹은 기존의 API를 사용할 때는 선택적인 타이핑으로 개발의 효율을 추구하고 있습니다. 위의 프리즈마 쿼리를 작성하는 예시는 후자에 해당하므로 5번(언박싱과 GADT) 방식을 사용하였습니다.

맺으며

이번 글에서는 ReasonML의 타입시스템을 적용하며 만날 수 있는 기본적인 문제에 대한 대처방안을 알아보았습니다.

  1. [%raw] 를 통해 직접 JS 코드 넣기
  2. Obj.magic을 이용한 컴파일러 속이기
  3. Js.Dict 로 감싸서 리턴하기
  4. 배리언트를 이용한 해법
  5. 언박싱과 GADT을 이용한 해법

이번에 설명하지 못했던 GADT의 개념과 배리언트의 더 자세한 사용법은 앞으로의 포스팅에서 다루도록 하겠습니다. 또 다른 글에서 만나길 기대하며, Merry ReasonML, Happy new year!





참고자료

Gravatar for tlonist.sang@gmail.com

김상현

백엔드 개발자


  1. https://www.prisma.io/
  2. https://www.prisma.io/docs/concepts/components/prisma-client/filtering
© 2022 by 그린랩스. All rights reserved.
🍅 진행중인 채용