A Simple Example of Calling an Elixir Library from Gleam

Michael Lynch

Gleam에서 Elixir 라이브러리를 호출하는 간단한 예제

최근 새로운 프로그래밍 언어를 찾기 위한 탐색의 일환으로 Gleam과 Elixir를 조금씩 실험해 보고 있습니다.

Gleam의 대표적인 기능 중 하나는 Elixir 코드와 라이브러리를 호출할 수 있다는 점인데, 그 방법을 설명하는 예제를 찾을 수 없었습니다. Gleam/Elixir/Erlang 생태계에 대한 초심자 수준의 이해를 바탕으로, Gleam 프로젝트에서 Elixir 라이브러리를 호출하는 간단한 예제를 직접 작성해 보았습니다.

의존성 설치

이 예제에서는 다음 버전을 사용합니다.

  • Gleam 1.10.0
  • Erlang 27.3.4
  • Elixir 1.18.3

원하는 방식으로 의존성을 설치하면 됩니다. 저는 Nix로 의존성을 관리하므로, 아래와 같이 flake.nix를 만들어 한 번에 설치했습니다.

{
  description = "Dev environment for gleam-call-elixir-simple";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 27.3.4
    erlang-nixpkgs.url = "github:NixOS/nixpkgs/8406224e30c258025cb8b31704bdb977a8f1f0";

    # 1.10.0
    gleam-nixpkgs.url = "github:NixOS/nixpkgs/3866ad91cfc172f08a6839def503d8fc2923c603";
  };

  outputs = {
    self,
    flake-utils,
    erlang-nixpkgs,
    gleam-nixpkgs,
  } @ inputs:
    flake-utils.lib.eachDefaultSystem (system: let
      erlang = erlang-nixpkgs.legacyPackages.${system}.beam27Packages.erlang;
      elixir = erlang-nixpkgs.legacyPackages.${system}.beam27Packages.elixir;
      gleam = gleam-nixpkgs.legacyPackages.${system}.gleam;
      inotify-tools = erlang-nixpkgs.legacyPackages.${system}.inotify-tools;
    in {
      devShells.default =
        erlang-nixpkgs.legacyPackages.${system}.mkShell
        {
          packages = [
            erlang
            elixir
            gleam
            inotify-tools
          ];

          shellHook = ''
            echo "erlang" $(erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell)
            gleam --version
          '';
        };

      formatter = erlang-nixpkgs.legacyPackages.${system}.alejandra;
    });
}

flake.nix 다운로드

nix develop을 실행하면 셸에서 Erlang과 Gleam을 사용할 수 있음을 확인할 수 있습니다.

$ nix develop
erlang "27"
gleam 1.10.0

프로젝트 생성

gleam new로 프로젝트를 생성합니다. 프로젝트를 Github 외부에 호스팅하고 있어, Gleam이 기본으로 추가하는 Github 관련 파일을 생략하기 위해 --skip-github 옵션을 추가했습니다.

PROJECT_NAME='call_elixir'
gleam new --name "${PROJECT_NAME}" --skip-github .

사이드 퀘스트: Gleam 패키지 버그 우회하기

여기서 gleam new가 생성한 보일러플레이트 코드를 실행해 보았지만 실패했습니다.

$ gleam run
  Resolving versions
Downloading packages
 Downloaded 2 packages in 0.01s
  Compiling gleam_stdlib
error: Incompatible Gleam version

The package `gleeunit` requires a Gleam version satisfying 1.11.0 <= v but you are using v1.10.0.

문제는 gleam new가 의존성으로 추가한 gleeunit 1.4.0이 Gleam 1.11.0에 의존한다는 점인데, 제 로컬 Gleam 버전(1.10.0)보다 높기 때문입니다.

다른 분도 같은 문제를 겪고 제가 겪기 불과 한 시간 전에 Github에 버그를 등록했습니다.

이 문제는 Gleam이 gleeunit을 1.3.1로 다운그레이드하도록 강제하면 우회할 수 있습니다.

$ gleam add --dev [email protected]
  Resolving versions
Downloading packages
 Downloaded 1 package in 0.00s
      Added gleeunit v1.3.1

그러면 gleam rungleam test가 예상대로 동작합니다.

$ gleam run
  Compiling gleeunit
  Compiling call_elixir
   Compiled in 0.28s
    Running call_elixir.main
Hello from call_elixir!
$ gleam test
   Compiled in 0.01s
    Running call_elixir_test.main
.
Finished in 0.006 seconds
1 tests, 0 failures

Elixir 패키지 설치

의미가 단순한 패키지를 골라 보고 싶어 CSV 라이브러리를 선택했습니다.

hex 패키지 매니저에서 가장 인기 있는 CSV 패키지CSV이므로, 이를 설치했습니다.

$ gleam add csv
  Resolving versions
Downloading packages
 Downloaded 1 package in 0.00s
      Added csv v3.2.2

참고: gsv라는 Gleam 네이티브 CSV 라이브러리도 있지만, Gleam 외 라이브러리 호출을 연습하기 위해 일부러 Elixir 라이브러리를 사용합니다.

Elixir로 CSV 패키지 테스트하기

먼저 CSV 패키지 API 자체를 어떻게 호출하는지 이해한 뒤, 필요한 API에 대한 Elixir 래퍼를 작성해야 합니다.

호출하려는 CSV.encode 함수의 시그니처는 다음과 같습니다.

@spec encode(Enumerable.t(), [encode_options()]) :: Enumerable.t()

즉, encode는 두 개의 매개변수를 받습니다.

  1. Enumerable 프로토콜을 구현한 객체
  2. (선택) encode_options() 타입

그리고 Enumerable 프로토콜을 구현한 객체를 반환합니다.

Elixir를 잘 모르므로, 동작을 이해하기 위해 대화형 Elixir 셸인 iex를 실행했습니다.

iex

그런 다음 iex 안에서 CSV 패키지를 설치했습니다.

iex> Mix.install([:csv])
Resolving Hex dependencies...
Resolution completed in 0.008s
New:
  csv 3.2.2
* Getting csv (Hex package)

이제 CSV 패키지 문서의 예제 중 하나를 실행해 보았습니다.

iex> [~w(a b), ~w(c d)]
[["a", "b"], ["c", "d"]]
iex> |> CSV.encode
#Function<61.117496853/2 in Stream.transform/3>
iex> |> Enum.take(2)
["a,b\r\n", "c,d\r\n"]

아직 Elixir의 시길(sigil) 문법을 잘 모르겠고, “a”와 “b”를 쓰는 예제도 마음에 들지 않아 더 직관적으로 느껴지는 형태로 다시 작성해 보았습니다.

iex> CSV.encode([["movie", "rating"], ["The Godfather", 10], ["Gigli", 2]])
#Function<61.117496853/2 in Stream.transform/3>

iex> |> Enum.to_list()
["movie,rating\r\n", "The Godfather,10\r\n", "Gigli,2\r\n"]

iex> |> IO.puts()
movie,rating
The Godfather,10
Gigli,2

:ok

정리하면, CSV.encode는 문자열 리스트의 리스트를 받아 문자열의 Enumerable을 반환하는 것으로 보입니다.

Elixir 패키지를 위한 Gleam 래퍼 만들기

이제 CSV.encode의 동작을 이해했으니, Gleam에서 호출할 수 있도록 래퍼 함수를 작성해야 합니다.

Gleam 래퍼를 작성할 때 가장 큰 어려움은 두 언어의 타입이 일치하지 않는다는 점입니다. Gleam은 엄격한 정적 타이핑을 사용하는 반면, Elixir는 더 유연한 동적 타이핑을 사용합니다.

CSV.encode 래핑하기

호출하려는 CSV.encode 함수의 시그니처는 다시 보면 다음과 같습니다.

@spec encode(Enumerable.t(), [encode_options()]) :: Enumerable.t()

Gleam 표준 라이브러리에는 Elixir의 Enumerable에 해당하는 타입이 없는 것 같으므로, Enumerable을 Gleam이 이해할 수 있는 형태로 변환하려면 다른 Elixir API를 사용해야 합니다.

Enum.to_list가 가장 좋은 선택으로 보입니다. Elixir 내장 list 타입을 반환하고, Gleam에도 이에 대응하는 List 타입이 있기 때문입니다.

먼저 Elixir의 CSV.encode API를 위한 Gleam 래퍼 함수를 정의하겠습니다.

// Create a custom Gleam type to represent the type we receive from Elixir.
type ElixirEnumerable

@external(erlang, "Elixir.CSV", "encode")
fn csv_encode(data: List(List(String))) -> ElixirEnumerable

@external 속성을 이용하면 Gleam에서 Elixir 코드를 호출할 수 있습니다. Gleam, Elixir, Erlang은 모두 BEAM 가상 머신에서 동작하는 바이트코드로 컴파일됩니다. BEAM 안에서는 CSV.encode 함수가 Elixir.CSV 네임스페이스 아래에 존재하므로, @external 속성에서 Elixir를 명시해야 합니다.

입력 매개변수는 Gleam의 문자열 리스트의 리스트(List(List(String))))로 정의했습니다. 이는 Erlang의 Enumerable 타입과 호환됩니다.

CSV.encode는 Elixir의 Enumerable을 반환하지만, 이에 대응하는 Gleam 타입을 알지 못하므로 ElixirEnumerable이라는 커스텀 타입을 정의했습니다.

Gleam 코드는 ElixirEnumerable을 네이티브로 접근할 방법을 알지 못하므로 아무 것도 할 수 없습니다. 따라서 ElixirEnumerable을 Gleam 네이티브 타입으로 변환할 방법이 필요합니다.

Elixir Enumerable을 Gleam List로 변환하기

Elixir의 Enumerable 타입을 Gleam의 List로 변환하기 위해, 결과를 변환하는 또 다른 external 함수를 선언합니다.

@external(erlang, "Elixir.Enum", "to_list")
fn enum_to_list(elixir_enum: ElixirEnumerable) -> List(String)

여기서는 Elixir의 Enum.to_list 함수를 사용해 ElixirEnumerableElixir List 타입으로 변환합니다. 이 타입은 Gleam List 타입과 대응하는 것으로 보입니다.

Gleam 친화적인 래퍼 만들기

원하는 Elixir API를 래핑하는 함수들을 작성했으니, 이제 이를 하나로 묶어 이 모듈에서 Gleam 앱에 노출할 함수를 만들어 보겠습니다.

pub fn encode(data: List(List(String))) -> List(String) {
  data
  |> csv_encode
  |> enum_to_list
}

이 함수는 간단합니다. 문자열 리스트의 리스트 형태의 데이터를 받아 Gleam 파이프 연산자를 이용해 csv_encode에 전달하고, 다시 파이프 연산자로 결과를 Gleam과 호환되는 문자열 리스트로 변환합니다.

csv 모듈에서 public으로 선언한 함수는 이것뿐입니다. 다른 래퍼들은 너무 저수준이라 이 모듈을 사용하는 Gleam 클라이언트에게는 유용하지 않기 때문입니다.

전체 모듈은 다음과 같습니다.

// Create a custom Gleam type to represent the type we receive from Elixir.
type ElixirEnumerable

@external(erlang, "Elixir.CSV", "encode")
fn csv_encode(data: List(List(String))) -> ElixirEnumerable

@external(erlang, "Elixir.Enum", "to_list")
fn enum_to_list(elixir_enum: ElixirEnumerable) -> List(String)

pub fn encode(data: List(List(String))) -> List(String) {
  data
  |> csv_encode
  |> enum_to_list
}

csv.gleam 다운로드

래퍼 함수 호출하기

이제 CSV.encode 함수를 위한 Gleam 네이티브 래퍼가 생겼으니, 이 래퍼를 호출하는 간단한 Gleam 앱을 작성할 수 있습니다.

import gleam/io
import gleam/string
import csv

pub fn main() {
  [
    ["Title", "Author", "Release Year"],
    ["Infinite Jest", "David Foster Wallace", "1996"],
    ["Emma", "Jane Austen", "1815"],
    ["Catch-22", "Joseph Heller", "1961"]
  ] |> csv.encode()
    |> string.concat()
    |> io.print()
}

call_elixir.gleam 다운로드

문자열 리스트의 리스트를 만들고 이를 csv.encode Gleam 래퍼에 전달합니다. 반환값은 문자열 리스트이므로, Gleam의 string.concat 표준 라이브러리 함수를 호출해 문자열들을 하나의 문자열로 합칩니다. 마지막으로 io.print를 호출해 결과를 콘솔에 출력합니다.

$ gleam run
  Compiling call_elixir
   Compiled in 0.20s
    Running call_elixir.main
Title,Author,Release Year
Infinite Jest,David Foster Wallace,1996
Emma,Jane Austen,1815
Catch-22,Joseph Heller,1961

성공입니다! 간단한 Gleam 애플리케이션에서 Elixir CSV 라이브러리를 성공적으로 호출했습니다.

소스 코드

이 예제의 전체 소스는 아래에서 확인할 수 있습니다.

Louis PilfordDisguisedPigeon에게 이 글에 대한 유용한 피드백을 주셔서 감사드립니다.

원문은 Michael Lynch님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.