My First Impressions of Gleam

Michael Lynch

Gleam 첫인상

올해 배울 새로운 프로그래밍 언어를 찾고 있는데, Gleam이 가장 재미있어 보입니다. Elixir와 비슷하면서 정적 타이핑을 지원하는 언어입니다.

언어 투어를 읽어 보니 이해는 됐지만, 프로그래밍 언어는 직접 무언가를 만들어 봐야 제대로 평가할 수 있습니다.

Gleam을 처음 몇 시간 써 보면서 정리한 메모를 공유합니다. Gleam을 배우는 분들이나 언어를 개발하는 팀에 조금이나마 도움이 되면 좋겠습니다.

내 프로젝트: 오래된 AIM 로그 파싱하기

1999년부터 2007년쯤까지 AOL Instant Messenger를 사용했습니다. 그 기간 대부분 대화 내용을 기록해 주는 AIM 클라이언트를 썼는데, 로그 형식이 제각각이었습니다. 대부분 XML이나 HTML 형식이라 다시 읽기가 꽤 불편합니다.

가장 단순한 형태는 이런 식으로 생긴 플레인텍스트 로그입니다:

Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 2005

10년에 한 번꼴로 모든 로그를 일관되고 읽기 쉬운 형식으로 바꾸는 범용 AIM 로그 파서를 만들어 보려 시도합니다. 안타깝게도 매번 하다가 지쳐 중도에 포기하곤 합니다. 마지막 시도는 7년 전, Python 2.7로 만들려던 때였습니다.

로그 파싱은 Gleam을 시험해 보기에 잘 맞습니다. 쉬운 부분(예: 플레인텍스트 로그 파싱)부터 시작해 Gleam에 익숙해지면서 점점 더 어려운 로그 형식이나 웹 프론트엔드 추가 같은 작업으로 확장해 나갈 수 있기 때문입니다.

함수형 언어가 파싱 작업에 특히 잘 맞는다는 얘기를 들은 적이 있는데, 왜 그런지 이해해 본 적이 없어서 배워 보기에 좋은 기회이기도 합니다.

프로그래밍 언어 경험

20년째 프로그래밍을 하고 있지만 언어 설계에 대한 식견이 깊은 사람은 아닙니다. Gleam을 쓰면서 직관적이지 않다고 느꼈거나 다루기 어려웠던 점을 공유하지만, 이는 언어에 대한 비판이라기보다 솔직한 소감입니다.

함수형 프로그래밍을 위해 설계된 언어는 다뤄 본 적이 없습니다. 그나마 가장 가까운 것이 JavaScript 정도입니다. 제가 가장 잘 다루는 언어는 Go와 Python입니다.

명령줄 인수는 어떻게 파싱할까?

가장 먼저 하고 싶었던 일은 명령줄 인수를 파싱하는 방법을 알아내서 앱을 이렇게 호출할 수 있게 만드는 것이었습니다:

./log-parser ~/logs/aim/plaintext

그런데 Gleam 표준 라이브러리에는 명령줄 인수를 읽는 모듈이 없습니다. glint라는 라이브러리를 찾았는데, 인수 하나를 읽기에는 너무 복잡하게 느껴졌습니다. 그러다 argv라는 더 단순한 서드파티 라이브러리가 있다는 걸 알게 됐습니다.

이렇게 명령줄 인수를 파싱할 수 있습니다:

pub fn main() {
  case argv.load().arguments {
    [path] -> io.println("command-line arg is " <> path)
    _ -> io.println("Usage: gleam run <directory_path>")
  }
}
$ gleam run ~/whatever
   Compiled in 0.01s
    Running log_parser.main
command-line arg is /home/mike/whatever

좋습니다, 충분히 쉽습니다!

gleam build는 무슨 일을 할까?

gleam run으로 프로그램은 실행했는데, go buildzig build처럼 실행 파일을 컴파일할 수 있는지 궁금했습니다.

$ gleam build
   Compiled in 0.01s

음, 무엇을 컴파일했다는 걸까요? 바이너리가 어디에도 보이지 않았습니다.

gleam build에 대한 문서는 그저 “Build the project”라고만 적혀 있을 뿐, 무엇을 빌드하고 빌드 산출물을 어디에 저장하는지는 설명하지 않습니다.

build 디렉터리는 생기지만, 눈에 띄는 실행 파일은 만들어지지 않습니다.

$ rm -rf build && gleam build
Downloading packages
 Downloaded 5 packages in 0.00s
  Compiling argv
  Compiling gleam_stdlib
  Compiling filepath
  Compiling gleeunit
  Compiling simplifile
  Compiling log_parser
   Compiled in 0.52s

$ ls -1 build/
dev
gleam-dev-erlang.lock
gleam-dev-javascript.lock
gleam-lsp-erlang.lock
gleam-lsp-javascript.lock
gleam-prod-erlang.lock
gleam-prod-javascript.lock
packages

여기저기 뒤져 보니 실행 파일로 보이는 것들은 build/dev/erlang/log_parser/ebin/ 아래에 있는 것 같습니다:

$ ls -1 build/dev/erlang/log_parser/ebin/
log_parser.app
log_parser.beam
log_parser@@main.beam
log_parser_test.beam
plaintext_logs.beam
plaintext_logs_test.beam

이 파일들은 BEAM 바이트코드로 보여서 직접 실행할 수는 없습니다. BEAM VM을 수동으로 띄워서 어떻게든 실행할 수는 있겠지만, 그다지 끌리는 방법은 아닙니다.

그래서 앱 실행은 gleam run을 계속 쓰기로 했습니다. 다만 gleam build가 무엇을 만들고 개발자가 그 결과물을 어떻게 활용할 수 있는지에 대해 더 명확한 설명이 있으면 좋겠습니다.

가장 단순한 파서부터 구현해 보자

먼저 플레인텍스트 로그를 기초적으로 파싱하는 함수를 작성하기로 했습니다.

그래서 원하는 동작을 담은 테스트를 작성했습니다.

pub fn parse_simple_plaintext_log_test() {
  "
Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 2005
"
  |> string.trim
  |> plaintext_logs.parse
  |> should.equal(["hi", "hey whats up"])
}

언젠가는 이름, 타임스탬프, 세션 정보 등 대화의 모든 메타데이터를 파싱하고 싶지만, 첫 단계로 함수가 할 일은 AIM 채팅 로그를 문자열로 받아 채팅 메시지들을 각각의 문자열로 이루어진 리스트로 반환하는 것뿐입니다.

그래서 실제 함수는 이렇게 생겼습니다:

pub fn parse(contents: String) -> List(String) {
  // Note: todo is a Gleam language keyword to indicate unfinished code.
  todo
}

우선 컴파일이 되게 하려고 더미 구현을 넣었습니다:

pub fn parse(contents: String) -> List(String) {
  ["fake", "data"]
}

그리고 이렇게 테스트할 수 있습니다:

$ gleam test
  Compiling log_parser
warning: Unused variable
  ┌─ /home/mike/code/gleam-log-parser2/src/plaintext_logs.gleam:1:14
  │
1 │ pub fn parse(contents: String) -> List(String) {
  │              ^^^^^^^^^^^^^^^^ This variable is never used

Hint: You can ignore it with an underscore: `_contents`.

   Compiled in 0.22s
    Running log_parser_test.main
F
Failures:

  1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
     Values were not equal
     expected: ["hi", "hey whats up"]
          got: ["fake", "data"]
     output:

Finished in 0.008 seconds
1 tests, 1 failures

좋습니다, 예상대로입니다. 하드코딩된 더미 값을 반환하니 테스트가 실패합니다.

함수형 언어에 맞춰 사고 전환하기

자, 이제 진짜 파싱을 구현할 차례입니다. 이 함수를 구현해야 합니다:

pub fn parse(contents: String) -> List(String) {
  todo
}

이 지점에서 머리가 잠시 멈췄습니다. Gleam에는 다른 언어에서 익숙하게 쓰던 도구들이 많이 없다는 사실이 와닿았기 때문입니다:

  • There are no if statements
  • There are no loops
  • There’s no return keyword
  • There are no list index accessors
    • e.g., you can’t access the n-th element of a List

도대체 뭘 해야 할까요? 문자열을 토큰으로 쪼개고 뭔가 처리해야 할까요?

결국 단순한 구현을 위해서는 문자열을 줄 단위로 나누면 되겠다는 걸 깨달았습니다. 그래서 이렇게 하고 싶었습니다:

pub fn parse(contents: String) -> List(String) {
  string.split(contents, on: "\n")
}

다시 테스트해 보면 이렇게 나옵니다:

$ gleam test
  Compiling log_parser
   Compiled in 0.21s
    Running log_parser_test.main
F
Failures:

  1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
     Values were not equal
     expected: ["hi", "hey whats up"]
          got: ["Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005", "[18:44] Jane: hi", "[18:55] Me: hey whats up", "Session Close (Jane): Mon Sep 12 18:56:02 2005"]
     output:

Finished in 0.009 seconds
1 tests, 1 failures

좋습니다, 조금 더 가까워졌습니다.

반복문이 없는 언어에서 리스트를 어떻게 순회할까?

로그를 줄 단위 리스트로 바꿨는데, 여기서 또 막혔습니다.

for 루프에 너무 익숙해져서 자꾸 “요소를 순회하려면 for 문을 어떻게 써야 하지?”라는 생각만 들었습니다.

list.map을 호출해야 한다는 걸 깨달았습니다. 리스트의 각 요소에 적용할 함수를 정의해야 합니다.

import gleam/list
import gleam/string

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> line
  }
}

pub fn parse(contents: String) -> List(String) {
  string.split(contents, on: "\n")
  |> list.map(parse_line)
}

어떤 언어에서든 패턴 매칭을 써 보는 건 이번이 처음인데, 멋지긴 하지만 아직은 너무 낯설어서 언제 써야 할지 감이 잘 안 옵니다.

패턴 매칭 부분을 조금 확대해 보면 여기입니다:

  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> line
  }

이 코드는 line 변수를 평가해 중괄호 안의 패턴 중 하나와 매칭합니다. 줄이 "Session Start"로 시작하면(<>는 앞의 문자열이 접두사임을 의미합니다) Gleam은 -> 뒤의 코드를 실행하는데, 여기서는 빈 문자열을 반환합니다. "Session Close"도 마찬가지입니다.

줄이 "Session Start""Session Close" 패턴에 해당하지 않으면, Gleam은 case의 마지막 줄을 실행합니다. 이 줄은 어떤 문자열이든 매칭되며, 같은 문자열을 그대로 반환합니다. 즉 "hi"는 그대로 "hi"로 평가됩니다.

여기서 return 키워드가 없다는 게 얼마나 낯설게 느껴지는지 실감했습니다. 제가 아는 다른 언어들은 모두 return 키워드로 함수에서 값을 명시적으로 반환해야 하지만, Gleam에서는 함수가 마지막으로 실행한 줄의 값이 곧 반환값이 됩니다.

테스트를 실행하면 이렇게 나옵니다:

$ gleam test
  Compiling log_parser
   Compiled in 0.22s
    Running log_parser_test.main
F
Failures:

  1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
     Values were not equal
     expected: ["hi", "hey whats up"]
          got: ["", "[18:44] Jane: hi", "[18:55] Me: hey whats up", ""]
     output:

Finished in 0.009 seconds
1 tests, 1 failures

역시 예상한 결과이고, 목표에 조금 더 가까워졌습니다.

"Session Start""Session End" 줄은 빈 문자열로 바꿨고, 리스트의 가운데 두 요소는 AIM 메시지가 담긴 줄입니다.

남은 작업은 다음과 같습니다:

  • 로그 줄에서 시간과 발신자 부분을 제거하기
  • 빈 문자열 걸러내기

한 줄에서 AIM 메시지 추출하기

이 시점에서 이런 문자열이 있습니다:

[18:55] Me: hey whats up

여기서 발신자 이름 뒤의 부분만 이렇게 추출해야 합니다:

hey whats up

본능적으로 문자열 split 함수를 써서 : 문자를 기준으로 나누고 싶었습니다. string.split이 있는데 List(String)을 반환합니다.

string.split_once 함수도 있는데, : (콜론 뒤 공백에 유의)에 대해 한 번만 나누면 될 것 같아 유용해 보였습니다.

문제는 split_onceResult(#(String, String), Nil)을 반환한다는 점인데, 제게는 좀 더 무섭게 느껴지는 타입입니다. Result로 감싼 2-튜플이라 함수가 실패 시 에러를 반환할 수 있다는 의미입니다. split은 실패하지 않는데 split_once는 실패할 수 있다는 점이 헷갈려서, 단순하게 가려고 split을 쓰기로 했습니다.

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
      echo string.split(line, on: ": ")
      todo
    }
  }
}

테스트를 실행하면 이렇게 나옵니다:

$ gleam test
warning: Todo found
   ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
   │
10 │       todo
   │       ^^^^ This code is incomplete

This code will crash if it is run. Be sure to finish it before
running your program.

Hint: I think its type is `String`.


   Compiled in 0.01s
    Running log_parser_test.main
src/plaintext_logs.gleam:9
["[18:44] Jane", "hi"]

좋습니다. 원하는 대로 "hi" 부분을 분리해 냈으니 이제 반환하기만 하면 됩니다.

리스트의 마지막 요소에는 어떻게 접근할까?

이제 승리가 가까워진 느낌입니다. 한 줄을 문자열 리스트로 바꿨고, 원하는 문자열이 리스트의 마지막 요소라는 것도 아는데, 어떻게 꺼내야 할까요?

다른 언어라면 그냥 line_parts[1]이라고 쓰면 되겠지만, Gleam의 리스트에는 인덱스로 접근하는 방법이 없습니다.

gleam/list 모듈을 보니 list.last 함수가 있어서 시도해 봤습니다:

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
       string.split(line, on: ": ")
       |> list.last
       |> echo
       |> todo
    }
  }
}

실행해 보면 이렇게 나옵니다:

$ gleam test
  Compiling log_parser
warning: Todo found
   ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:12:11
   │
12 │        |> todo
   │           ^^^^ This code is incomplete

This code will crash if it is run. Be sure to finish it before
running your program.

Hint: I think its type is `fn(Result(String, Nil)) -> String`.


   Compiled in 0.24s
    Running log_parser_test.main
src/plaintext_logs.gleam:11
Ok("hi")

조금 더 가까워졌습니다! 리스트의 마지막 요소를 꺼내 "hi"를 찾긴 했는데, 이제 Result 타입으로 감싸져 있습니다.

result.unwrap으로 풀 수 있습니다

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
       string.split(line, on: ": ")
       |> list.last
       |> result.unwrap("")
    }
  }
}

gleam test를 다시 실행하면 이렇게 나옵니다:

$ gleam test
  Compiling log_parser
   Compiled in 0.22s
    Running log_parser_test.main
F
Failures:

  1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
     Values were not equal
     expected: ["hi", "hey whats up"]
          got: ["", "hi", "hey whats up", ""]
     output:

Finished in 0.008 seconds
1 tests, 1 failures

훌륭합니다! 원하는 대로 메시지 줄을 메시지 내용만 남기도록 줄였습니다.

빈 문자열 걸러내기

이제 리스트에서 빈 문자열만 걸러내면 됩니다. list.filter로 간단히 할 수 있습니다:

pub fn parse(contents: String) -> List(String) {
  string.split(contents, on: "\n")
  |> list.map(parse_line)
  |> list.filter(fn(s) { !string.is_empty(s) })
}

그리고 테스트를 다시 실행합니다:

$ gleam test
  Compiling log_parser
   Compiled in 0.22s
    Running log_parser_test.main
.
Finished in 0.007 seconds
1 tests, 0 failures

짜잔! 이제 테스트를 통과합니다!

문자열 나누기 다듬기

이제 테스트가 통과하니 이론상으로는 초기 목표를 달성했습니다.

여기서 승리를 선언하고 마무리할 수도 있습니다. 아니면, 리팩터링을 할 수도 있습니다!

리팩터링을 해 보겠습니다.

문자열을 나누는 로직이 Gleam답지 않게 느껴져서 조금 부끄럽습니다. result 언래핑 없이 할 수 없을까요?

다시 읽어 보니 이 새로운 패턴 매칭이라는 걸로 해결할 수 있겠더군요. 문자열이 두 요소짜리 리스트로 나뉜다는 걸 아니까, 두 요소 리스트에 대한 패턴을 만들면 됩니다:

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
       case string.split(line, on: ": ") {
          [_, message] -> message
          _ -> ""
       }
    }
  }
}

그게 result.last를 호출하는 것보다 조금 더 우아하게 느껴집니다.

더 다듬을 수 있을까요? 타입이 너무 헷갈려서 string.split_once를 피했는데, 한 번만 나누는 경우라면 그게 더 나은 선택일 수 있습니다. 어떻게 생겼을까요?

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
       echo string.split_once(line, on: ": ")
       todo
    }
  }
}

데이터를 확인하려고 테스트를 다시 실행했습니다:

$ gleam test
[...]
src/plaintext_logs.gleam:9
Ok(#("[18:44] Jane", "hi"))

생각보다 무섭지 않습니다. 첫 본능은 에러를 언래핑하고 튜플의 마지막 요소에 접근하는 것(튜플에서는 리스트와 달리 실제로 쉽습니다)이었지만, 이제는 아마도 패턴 매칭으로 하는 방법이 있을 거라는 걸 압니다. 실제로 있습니다:

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
       case string.split_once(line, on: ": ") {
        Ok(#(_, message)) -> message
        _ -> ""
       }
    }
  }
}

Ok(#(_, message)) 패턴은 split_once의 성공 결과와 매칭됩니다. 이 결과는 Ok로 감싼 String 2-튜플입니다. 다른 case 분기는 빈 문자열을 반환하는 캐치올입니다.

빈 문자열 꼼수 없애기

Gleam의 매력 중 하나가 정적 타이핑인데, 특정 줄에 메시지가 없다는 걸 나타내려고 빈 문자열을 남용하는 건 꼼수처럼 느껴집니다. 빈 문자열을 센티넬 값으로 쓰는 대신 타입 시스템을 활용할 수 없을까요?

Gleam에서 뭔가 실패할 수는 있지만 그 실패가 반드시 에러는 아닐 때 쓰는 패턴이 Result(<type>, Nil)이므로, 그 방식으로 다시 써 보겠습니다:

import gleam/list
import gleam/result
import gleam/string

fn parse_line(line: String) -> Result(String, Nil) {
  case line {
    "Session Start" <> _ -> Error(Nil)
    "Session Close" <> _ -> Error(Nil)
    line -> {
       case string.split_once(line, on: ": ") {
        Ok(#(_, message)) -> Ok(message)
        _ -> Error(Nil)
       }
    }
  }
}

pub fn parse(contents: String) -> List(String) {
  string.split(contents, on: "\n")
  |> list.map(parse_line)
  |> result.values
}

좋습니다! 메시지가 없는 줄이 빈 문자열 대신 Error(Nil)을 반환하도록 더 명시적으로 만든 게 마음에 듭니다. 또 result.values가 이전의 list.filter(fn(s) { !string.is_empty(s) })보다 빈 줄을 걸러내는 데 훨씬 간결합니다.

총평

Gleam과 몇 시간을 보내고 나니 꽤 즐겁습니다. 편안한 영역에서 적당히 벗어나 프로그래밍에 대해 새로운 사고방식을 배우는 느낌이 들면서도, 너무 압도되어 아무것도 배우지 못할 정도는 아닙니다.

지금까지 느낀 가장 큰 단점은 Gleam이 비교적 작은 팀이 만드는 젊은 언어라는 점입니다. 이제 막 여섯 살이 됐는데, 창시자가 1년 전까지 혼자 작업해 온 것으로 보입니다. 지금은 소수의 핵심 메인테이너가 있지만, 그중 누구도 Gleam을 전업으로 하는지는 모르겠고, 그래서 생태계가 다소 제한적입니다. 앞으로 HTML과 XML로 된 다른 로그 형식도 파싱해야 하는데, Gleam용 HTML/XML 파서는 있지만 널리 쓰이는 것 같지 않아 얼마나 잘 동작할지 확신이 서지 않습니다.

가장 좋았던 점: 파이프라인

Gleam의 파이프라인 문법을 정말 정말 좋아합니다. 테스트에서 |> 문자로 사용하는 모습을 볼 수 있습니다:

 "..."
  |> string.trim
  |> plaintext_logs.parse
  |> should.equal(["hi", "hey whats up"])

파이프라인을 쓰지 않은 동일 테스트는 이렇게 생겼을 겁니다:

pub fn parse_simple_plaintext_log_test() {
  let input = "..."
  let trimmed = string.trim(input)
  let parsed = plaintext_logs.parse(trimmed)

  should.equal(parsed, ["hi", "hey whats up"])
}

비교해 보면 젖은 쓰레기처럼 보입니다.

파이프라인을 보고 나니 너무나 당연해 보여서, 제가 쓰는 다른 언어들에 없다는 게 오히려 이상하게 느껴집니다.

bash에서는 파이프라이닝을 즐겨 썼지만, 다른 프로그래밍 언어들이 이를 채택하지 않았다는 게 얼마나 이상한 일인지 미처 생각해 본 적이 없었습니다.

좋았던 점: 예제 중심 문서

Gleam 문서는 다소 간결하지만 예제가 풍부해서 좋습니다.

저는 예제를 읽으면서 가장 잘 배우는 편이라, Gleam 표준 라이브러리의 많은 부분이 각 API 함수의 단순한 사용법을 보여 주는 예제와 함께 문서화되어 있다는 점이 마음에 듭니다.

좋았던 점: 미사용 심볼 경고 내장

Gleam 컴파일러가 미사용 함수, 변수, 임포트에 대해 기본적으로 경고를 주는 점이 좋습니다. 그리고 그게 에러가 아니라 경고라는 점도 마음에 듭니다.

Go에서는 디버깅 중에 뭔가를 잠시 주석 처리하면, 컴파일러가 그 멍청한 임포트를 고칠 때까지 고집스럽게 아무것도 하지 않으려 해서 답답할 때가 있습니다. 디버깅이 끝나면 다시 되돌려야 하는데 말이죠.

좋았던 점: todo 키워드

제가 가장 좋아하는 어리석은 프로그래밍 농담 중 하나는 약 15년 전 첫 직장에서 있었던 일입니다. 여러 C++ 개발자가 참여한 단체 이메일 스레드에서 친구가 C++ 개발에 대한 꿀팁을 공유했습니다.

난해한 C++ 컴파일 에러에 지쳤을 때 소스 코드에 특별한 한 줄만 추가하면 올바르지 않은 C++ 코드도 성공적으로 컴파일된다는 것이었습니다:

#pragma always_compile

스포일러: 실제로 존재하는 C++ 전처리기 지시문이 아닙니다.

하지만 개발 중간에 컴파일러가 막아 주려는 버그 따위는 신경 쓰지 않을 때, 언어에 이런 기능이 있으면 좋겠다고 가끔 생각하곤 했습니다.

Gleam의 todo는 거의 #pragma always_compile과 같습니다. 코드가 올바르지 않더라도 Gleam 컴파일러는 “좋아요, 알겠습니다. 그냥 실행하겠습니다.”라고 말합니다.

parse_line을 구현하던 중의 모습을 보면 알 수 있습니다:

fn parse_line(line: String) -> String {
  case line {
    "Session Start" <> _ -> ""
    "Session Close" <> _ -> ""
    line -> {
      echo string.split(line, on: ": ")
      todo
    }
  }
}

여기서 todo를 빼면 Gleam은 코드 실행 자체를 거부합니다:

$ gleam test
  Compiling log_parser
error: Type mismatch
   ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:8:5
   │
 8 │ ╭     line -> {
 9 │ │       echo string.split(line, on: ": ")
10 │ │     }
   │ ╰─────^

This case clause was found to return a different type than the previous
one, but all case clauses must return the same type.

Expected type:

    String

Found type:

    List(String)

그렇습니다, 잘못된 타입을 반환하고 있으니 컴파일러가 왜 협조하겠습니까?

하지만 todo를 추가하면 아직 구현을 끝내지 않았더라도 함수를 실행해 볼 수 있어서, 코드가 무슨 일을 하는지 이해하는 데 도움이 됩니다:

$ gleam test
warning: Todo found
   ┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
   │
10 │       todo
   │       ^^^^ This code is incomplete

This code will crash if it is run. Be sure to finish it before
running your program.

Hint: I think its type is `String`.


  Compiling log_parser
   Compiled in 0.21s
    Running log_parser_test.main
src/plaintext_logs.gleam:9
["[18:44] Jane", "hi"]
F
[...]
Finished in 0.007 seconds
1 tests, 1 failures

좋았던 점: 패턴 매칭

패턴 매칭은 우아하고 간결하다고 생각하지만, Gleam에서 가장 적응하기 어려운 부분이기도 합니다. 제가 아는 다른 언어들의 절차적 프로그래밍 스타일과 너무 다르게 느껴집니다.

단점은 패턴 매칭이 적절한 도구인지 알아채기 어렵고, 읽기도 더 어렵게 느껴진다는 점입니다. 하지만 그건 그냥 경험 부족 때문이라고 생각하고, 연습을 더 하면 패턴 매칭으로 사고할 수 있게 될 것 같습니다.

아쉬운 점: 에러 처리

Gleam의 에러 처리는 꽤 어색하게 느껴지는데, 특히 에러 때문에 깔끔한 파이프라인의 아름다움이 망가지기 때문입니다.

예를 들어 이런 문자열 처리 파이프라인이 있다고 하면:

string.split(line, on: "-")
|> list.last
|> result.unwrap("") // Ugly!
|> string.uppercase

result.unwrap 줄이 너무 안 어울리고 보기 싫게 느껴집니다. 문법이 이렇게 됐으면 좋겠습니다:

string.split(line, on: ": ")
|> try list.last
|> string.uppercase
|> Ok

여기서 tryZig에서처럼 함수가 에러를 반환하게 만드는 역할을 합니다.

아쉬운 점: 작은 코어 언어

이게 장기적인 설계 선택인지, 아니면 인디 개발 언어라서 아직 작은 것뿐인지는 모르겠지만, Gleam에서 가장 먼저 눈에 띈 점은 내장 기능이 얼마나 적은가 하는 것이었습니다.

예를 들어 List 타입의 요소를 순회하는 내장 기능이 없고, 타입 자체도 순회 함수를 제공하지 않아서 표준 라이브러리의 the gleam/list 모듈을 써야 합니다.

마찬가지로 실패할 수 있는 함수는 Result 타입을 반환하는데, Result를 다루는 내장 함수가 없어서 함수가 성공했는지 확인하려면 gleam/result 모듈을 써야 합니다.

제게는 그 기능들이 너무나 핵심적이어서 표준 라이브러리가 아니라 언어 자체의 일부여야 할 것 같이 느껴집니다.

아쉬운 점: 제한적인 표준 라이브러리

언어가 작게 느껴지는 데 더해, 표준 라이브러리도 꽤 제한적으로 느껴집니다.

현재 Gleam 표준 라이브러리에는 모듈이 19개뿐입니다. 파일 시스템 작업을 위한 모듈이 눈에 띄게 빠져 있는데(사실상의 표준은 서드파티 simplifile 모듈인 것으로 보입니다).

비교하자면 PythonGo의 표준 라이브러리에는 각각 약 250개의 모듈이 있습니다. 물론 공정하게 말하자면 그 언어들은 Gleam보다 1000배쯤 더 많은 리소스를 가지고 있습니다.

소스 코드

이 프로젝트의 소스 코드는 Codeberg에서 볼 수 있습니다:

커밋 291e6d가 이 블로그 글에 해당하는 버전입니다.


이 글에 유용한 피드백을 준 Isaac Harris-Holt에게 감사드립니다.

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

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