My First Impressions of Gleam

Michael Lynch

Gleam 첫인상

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

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

언어 투어를 읽어보니 이해는 됐지만, 프로그래밍 언어를 제대로 평가하려면 직접 뭔가 만들어봐야 한다.

Gleam을 처음 몇 시간 써보면서 정리한 노트를 공유한다. Gleam을 배우는 다른 사람이나 언어를 개발하는 팀에 도움이 되면 좋겠다.

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

나는 1999년쯤부터 2007년까지 AOL Instant Messenger를 사용했다. 그 기간 대부분 대화를 로그로 남기는 AIM 클라이언트를 썼는데, 포맷이 제각각이었다. 대부분 XML이나 HTML 형식이라 로그를 다시 읽기가 까다롭다.

가장 단순한 AIM 로그는 플레인텍스트 로그로, 이렇게 생겼다:

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 함수를 써서 : 문자를 기준으로 나누고 싶다. List(String)을 반환하는 string.split이 있는 걸 확인했다.

string.split_once 함수도 있는데, : (콜론 뒤 공백에 유의) 기준으로 한 번만 나누면 되니 잘 동작할 것 같다.

문제는 split_onceResult(#(String, String), Nil)을 반환한다는 점인데, 내게는 좀 더 무섭게 느껴지는 타입이다. Result로 감싼 2-튜플이라 함수가 실패 시 에러를 반환할 수 있다는 뜻이다. split_once는 실패할 수 있는데 split은 실패하지 않는다는 게 헷갈려서, 단순함을 위해 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 옵션은 빈 문자열을 반환하는 catchall이다.

빈 문자열 꼼수 없애기

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에서 느끼는 가장 큰 단점은 젊고 비교적 작은 팀이 만드는 언어라는 점이다. 이제 막 6년이 됐는데, 창시자가 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에게 감사드립니다.

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

댓글