Rust의 타입 표현력, Go의 단순함, Erlang/OTP의 동시성을 겸비한 Gleam에 빠져든 이유를 소개합니다. 파이프라인과 use 같은 특징, 장단점과 우려, 그리고 Rust/Elixir/Go와의 비교를 다룹니다.
이전 블로그 글에서 개인 프로젝트에 기본으로 Rust를 쓰기 시작했다고 말했었습니다. Rust의 타입 시스템은 정말 좋아하지만, 학습 곡선은 그다지 팬이 아닙니다. 내가 Rust를 사랑한다고 말할 때마다 약간의 매몰비용과 한 꼬집의 스톡홀름 증후군이 섞여 있는 건 부정할 수 없죠.
종종 이렇게 농담하곤 했습니다. “Rust를 사랑하지만, 사실 원하는 건 C 같은 문법을 가진 더 단순한 Haskell이야” 혹은 “Rust를 사랑하지만, 가비지 컬렉터만 있었다면 Rust가 내 완벽한 언어였을 텐데.”
솔직히 말하면, 내가 Rust에서 진짜 사랑하는 건 대수적 데이터 타입이 있는 인기 있는 언어라는 점입니다. 배타성을 표현할 수 있는 합타입이 없는 언어는 타입 시스템이 불완전하다고 생각합니다. Rust의 다른 장점들, 예컨대 안전성과 속도는, 비즈니스 로직과 상태를 표현하기 위해 대수적 데이터 타입을 쓸 수 있다는 사실에 비하면 부차적입니다.
너무 깊게 들어가지는 않겠지만, 대수적 데이터 타입(Algebraic Data Types, ADT)에는 두 가지 핵심 개념이 있습니다. 곱타입(product types)과 합타입(sum types)입니다. 곱타입은 여러 타입을 하나의 타입으로 묶습니다. Rust, Go, C 등에서의 struct가 여기에 해당하죠.
반면 합타입은 유한한 값 집합 중 하나만 될 수 있는 타입입니다. 대부분의 언어에 내장된 합타입의 예로는 boolean 타입이 있습니다. 오직 true 아니면 false만 될 수 있죠. 사용자 정의 합타입을 작성할 수 있다는 것은 표현력의 세계를 열어줍니다.
예를 들어, 나는 내가 작성한 POTA 애플리케이션에서 UI 스레드에서 상태 컨트롤러 스레드로 보낼 수 있는 가능한 메시지들을 표현하기 위해 합타입을 사용합니다.
이 타입이 있으면, 상태 컨트롤러가 받을 수 있는 모든 메시지에 대해 패턴 매칭을 할 수 있습니다. 하나라도 빼먹으면 컴파일러가 그걸 추가하라고 알려줍니다.
합타입 없이도 이런 표현력을 흉내 낼 수는 있지만, 보통 매우 어색하고 군더더기 많은 코드가 됩니다. 예를 들어, enum을 사용하는 이 Go 코드를 보세요. 합타입이 제대로 없는 언어는 보통 더 풍부한 필드를 가진 변형(variant) 대신, 유한한 정수나 문자열 값 집합으로 한정되는 경우가 많습니다.
대수적 데이터 타입의 장점에 대해 깊게 파고들고 싶다면, 다음 세 글을 추천합니다.
10년 전쯤, 나는 Erlang이라는 언어에 푹 빠졌습니다. 매년 Ricon에 갔고, Erlang의 동시성을 벤치마크로 테스트하는 글을 써서 트위터에서 약간 유명세를 타기도 했습니다.
Erlang에서 패턴 매칭에 반해버렸죠. 임의의 깊이를 가진 어떤 Erlang 값이든 분해할 수 있다는 사실이 놀라웠습니다. 요즘은 Python과 JavaScript도 이 기능을 갖췄지만, 그 당시엔 혁명적으로 느껴졌습니다. Erlang의 바이너리 패턴 매칭은 거의 초능력 같습니다.
보기엔 암호처럼 보이지만, IP 패킷의 필드를 분해하고 있습니다. 4바이트, 또 4바이트, 그리고 8바이트… 이런 식으로요. 각 청크는 변수에 저장되고 부호 없는 정수로 디코드됩니다.
각 청크의 부호 여부, 엔디언, 비트 크기, 타입까지 지정할 수 있습니다. 누군가는 Lisp로도 구현했을지 모르겠지만, Erlang 생태계 밖의 다른 언어에서 이런 걸 본 적은 거의 없습니다.
Erlang에는 내가 생각하기에 가장 합리적인 동시성 모델인 액터 모델이 있습니다. 물론 스레드나 그린 스레드는 개념적으로 이해하기 쉽지만, 너무 원시적입니다. 데이터 공유 방식을 프로그래머에게 맡겨버리니까요.
액터 모델은 단일 스레드 방식으로 코드를 작성하게 해주면서, 메시지로 통신합니다. 각 작은 동시성 단위는 자기 자신만의 작은 세계와 상태에만 신경 씁니다. 상태 공유는 불변 메시지를 통해 이루어집니다.
메시지가 들어오면 액터는 그에 반응합니다. 응답으로 상태를 갱신합니다. Erlang의 액터는 제대로 구현된 마이크로서비스 같아요.
액터는 서로 협력해 공동체를 만들어가는 작은 자율 로봇들 같습니다. Erlang 애플리케이션은 살아 있고 협력적인 느낌입니다. 서비스 지향 개발자인 내게 이는 매우 자연스럽게 와닿습니다.
Erlang의 액터 모델 위에 구축된 동시성 프레임워크가 Open Telecom Platform(OTP)입니다. 매우 탄탄하고 동시성이 뛰어난 시스템을 일관된 방식으로 구축하기 위한 프레임워크죠. 애플리케이션을 어떻게 구조화하고, 액터를 어떻게 스폰하며, 무엇보다 장애가 발생해도 전체를 계속 가동시키는 방법을 정의합니다.
어떤 언어에 액터가 없다면, 나는 스레드와 큐로 이를 흉내 냅니다. 앞서 언급한 POTA 앱의 코드를 보면, 메시지로 통신하는 두 액터처럼 구조화되어 있습니다. UI와 비즈니스 로직의 관심사를 분리하면 반응형 애플리케이션을 더 쉽게 이해할 수 있다고 느낍니다.
Erlang이 아무리 훌륭해도, 어느 고용주도 프로덕션에 Erlang을 배포하도록 설득할 수는 없었습니다. 그들에게는 너무 낯설었죠. 안타깝지만, 표면적인 이유이긴 해도 문법이 사람들을 진입 전부터 물러서게 만든다고 생각합니다.
문법 얘기가 나온 김에, 예전에 버지니아 레스턴에서 Basho가 주최한 작은 Erlang 컨퍼런스에서 처음 Elixir를 봤던 기억이 납니다. 무척 매력적으로 보였죠. Elixir의 초기 시절이었습니다. 내 기억이 맞다면, 그 컨퍼런스가 Elixir가 공개 석상에서 처음 소개된 자리였을지도 모릅니다. (아마 기억이 틀렸을 겁니다)
Elixir에 익숙하지 않다면, 본질적으로 Ruby 같은 페인트를 Erlang의 가상 머신인 BEAM 위에 칠한 것이라 보면 됩니다.
수년간 멀찍이서 Elixir를 지켜봤습니다. Phoenix가 등장하는 것도 봤고, JavaScript 거의 없이 손쉽게 반응형 웹 앱을 만든다는 Phoenix의 LiveView에 대한 찬사가 줄줄이 이어지는 것도 봤습니다.
정말 Elixir를 좋아하고 싶었고, 가끔 올라오는 Elixir 채용 공고에 지원하고 싶은 유혹을 수없이 느꼈지만, 그때쯤 나는 이미 동적 타입 언어에서 마음이 떠난 상태였습니다.
Python의 런타임 에러에 여러 번 데이다 보니, 동적 타입은 지쳤습니다. 팀 단위로 타입을 일관되게 유지하기가 너무 어렵습니다. 규율에 의존하는 방식이 유지보수 가능한 코드를 쓰는 좋은 방법이 아니라는 걸 뼈저리게 배웠습니다. 우리는 약한 순간에 우리를 도와줄 자동화가 필요합니다.
솔직히 말해, Elixir의 동적 타입 시스템에 대한 거부감이 없었다면 지난 10년을 Go 대신 Elixir로 보냈을지도 모릅니다. 다만 Go의 가장 큰 장점, 단순함을 알게 되지는 못했을 수도 있겠죠.
Go의 단순함은 가장 큰 장점이자 가장 큰 약점입니다. if err != nil을 계속 써야 한다는 불평은 여기서는 넘어가겠습니다.
하지만 Option과 Result 타입의 존재를 알게 된 후로는, nil pointer dereference 패닉을 볼 때마다, 혹은 에러의 존재를 직접 확인해야 할 때마다 바늘로 콕콕 찌르는 느낌입니다.
장시간 실행되는 goroutine을 띄울 때마다, Go 안에서 OTP의 절반쯤 되는 것을 즉흥적이고 비공식적이며 버그투성이이고 느리게 구현해보고 싶은 생각이 듭니다.
Go에서 enum을 만들 때마다, 합타입과 패턴 매칭이 그립습니다.
그럼에도 불구하고, Go의 단순함을 사랑하게 됐습니다. 배우기 쉬워서 신입이 Go를 모른다고 해도 큰 장애물이 되지 않죠. 게다가 nil만 조심하면 자충수(foot gun)가 적습니다.
대개는 분명한 한 가지 방법이 존재합니다. 뭔가를 하는 명백한 방법이 없더라도 Go를 관용적으로 작성하는 법에 관한 문서가 잘 정리되어 있죠. 린팅과 자동 포매팅 문화 덕분에 끝없는 자전거 타기(bike-shedding)도 줄었습니다.
하지만 단순함에도 불구하고 Go를 내가 가장 좋아하는 언어로 꼽기는 어렵습니다. 솔직히 쓰기가 꽤 번거롭다고 느낍니다. 문법이 단순하긴 해도 잡음이 많아 읽기 힘들 때가 많습니다.
80년대식 타입 시스템에 집착하는 것도 못마땅합니다. Rob Pike와 Ken Thompson 같은 사람들이 ‘십억 달러짜리 실수(null)’의 해결책을 몰랐다고는 믿기 어렵습니다. 그런데도 nil 인터페이스와 참조 타입을 허용했죠. nil pointer dereference로 프로그램이 패닉할 때마다 Ken과 Rob을 원망합니다.
그래서 백엔드를 위한 내가 꿈꾸는 완벽한 언어는 Rust처럼 표현력 있는 타입 시스템, Go의 단순함, 그리고 더 친근한 문법을 가진 Erlang의 동시성 모델을 갖추었을 겁니다.
지난 6개월 동안 유튜브 피드에 떠도는 Sean Cribbs의 영상들을 스킵하면서도 전혀 눈치채지 못했던 그 언어가 있었습니다. 바로 Gleam입니다.
Gleam의 존재는 알고 있었지만, 솔직히 뭐 하는 언어인지 몰랐습니다. Elm이나 PureScript 같은 “JavaScript로 컴파일되는 함수형 언어” 정도로 생각했거든요. 둘 다 배워봤고, 프런트엔드 개발자를 TypeScript 대신 그걸 쓰게 설득할 수 없다는 걸 알기에, Gleam을 그냥 지나쳤습니다.
그러다 드디어 Dillon Mulroy의 영상, Your next favorite programming language: Gleam 을 보고는 눌러봤습니다. 맞습니다. Dillon 말이 옳았어요. Gleam은 내 다음으로 사랑하는 프로그래밍 언어가 되었습니다.
Gleam Language Tour와 Isaac Harris-Holt의 Gleam 영상들이 언어 소개는 훨씬 잘하니, 여기선 내가 좋아하는 점들만 휙휙 지나가겠습니다. 이 글도 이미 너무 길거든요.
Gleam에는 합타입과 패턴 매칭이 있습니다 ✔
심지어 Erlang의 멋진 바이너리 패턴 매칭도 계승했습니다(유니코드 지원 같은 현대화도 되어 있어요).
Gleam은 유명하게도 예약어가 22개뿐이고, 그중 15개만 현재 사용됩니다. 덕분에 언어가 매우 콤팩트하고 배우기 쉽습니다.
(슬라이드: Issac Harris-Holt 제공)
내가 마지막으로 이렇게 빨리 습득한 언어는 2009년에 배운 Go가 마지막이었습니다. Gleam Language Tour를 저녁에 한 번 훑고 나니 바로 Gleam으로 생산적인 일을 할 수 있었습니다.
물론 함수형 언어에 익숙하다는 점이 빠르게 익히는 데 도움이 되었을 겁니다.
이 언어가 얼마나 단순한지 보죠. 먼저, Gleam에는 if 문이 없습니다. case 표현식으로 해결합니다. 약간 장황할 수는 있지만, 배워야 할 문법 구성은 하나뿐입니다.
for나 while 루프도 없습니다. 루프는 재귀로 처리합니다. 처음에는 거부감이 들 수도 있습니다. 재귀는 컴퓨터 과학에서 악명이 높아서, _인셉션_의 한 장면이 떠오르기도 하죠.
하지만 구조적으로 보자면, 재귀 함수가 for 루프와 그리 다르지 않다는 점을 말하고 싶습니다.
각 double_all 호출은 리스트의 더 작은 부분을 처리하다가, 리스트의 끝에 도달할 때까지 진행합니다.
double_all([], [1,2,3])
double_all([2], [2,3])
double_all([4,2], [3])
double_all([6,4,2], []) // -> list.reverse([6,4,2]) -> [2,4,6]
문제는 약간 다르게 풉니다. 리스트에서 점점 더 작은 부분을 떼어내며 기저 사례에 도달하는 방식이죠. 하지만 메커니즘은 매우 비슷합니다. 컬렉션의 각 항목을 순회하며 상태를 갱신하고, 끝에 도달하면 반환합니다.
한 번 재귀를 “깨닫고” 나면, 같은 개념을 위해 3가지 구성(재귀, for, while)을 둘 필요가 없다고 느낄 것입니다.
아직도 재귀만으로 충분한지 의심된다면, Issac Harris-Holt의 영상 You don't need loops를 추천합니다.
Gleam은 Erlang으로 컴파일되므로, 액터를 스폰하고 훌륭한 OTP를 Gleam API로 사용할 수 있습니다. 내가 Erlang에 대해 칭찬했던 동시성의 장점이 Gleam에도 그대로 있습니다. Issac의 훌륭한 영상이 있으니, 여기서 더 잘 설명하려고 하지는 않겠습니다.
Gleam에서 정말 좋아하게 된 기능이 두 가지 있습니다. 첫째는 파이프라인입니다. 함수 호출을 체인으로 엮을 수 있게 해줍니다.
내가 Go에서 자주 쓰는 코드는 대략 이렇습니다:
Gleam에 비슷한 API가 있다면, 동등한 코드는 이렇게 생겼을 겁니다:
|>는 앞 함수 호출의 결과를 다음 함수 호출의 첫 번째 인자로 넘겨줍니다.
에러 처리를 위한 result.Result 타입과 함께 |>를 자주 보게 될 겁니다.
또한 |>는 이른바 “빌더 패턴”과 함께 쓰이는 걸 보게 될 텐데, 이는 다른 언어의 체인드 메서드 호출을 닮았습니다. glight 로깅 라이브러리의 예제 코드를 보세요.
이는 Go의 logrus API가 구조적 로깅을 하는 방식과 매우 비슷합니다:
오랜 리눅스 사용자로서, 나는 |>가 매우 자연스럽습니다. 본질적으로 타입이 붙은 유닉스 파이프니까요. 하나의 우아한 문법으로 여러 패턴을 표현할 수 있다는 점이 마음에 듭니다.
둘째로 Gleam의 새로운 기능은 use 키워드입니다. 이는 Gleam에서 “고급” 기능으로 분류되지만, 익숙해지면 코드가 정말 깔끔해집니다.
예시 없이는 설명이 어려우니, 바로 보여드리겠습니다. 위에서 본 예시처럼 Result(String, Nil)을 반환하는 함수가 있다고 해봅시다:
여러 환경 변수를 읽어 Env 레코드에 담고 싶다면 어떻게 할까요?
error: Type mismatch
┌─ /home/eric/src/gleam-scratch/test/blog_test.gleam:50:7
│
50 │ Env(database_url:, port:, secret_key:)
│ ^^^^^^^^^^^^^
Expected type:
String
Found type:
Result(String, Nil)
안 됩니다. DATABASE_URL이 Result(String, Nil) 안에 갇혀 있으니까요. 어떻게든 꺼내야 합니다.
가장 지저분한 방법은 패턴 매칭을 쓰는 겁니다:
제정신이라면 아무도 그렇게 하고 싶지 않을 겁니다. 대신 result.try라는 고차 함수를 써서 Ok(_) 분기에서는 계속 진행하고, Error(_)가 나오면 조기 종료하는 방식을 쓸 수 있습니다:
조금 나아지긴 했지만, 여전히 시끄러운 파멸의 피라미드입니다. use로 더 깔끔하게 만들 수 있습니다.
use는 extract_env_with_use()의 코드를 extract_env3()의 코드로 바꿔주는 문법 설탕입니다.
이 개념은 매우 강력합니다. use는 내가 “프로그래머블 세미콜론”이라고 부르는 기능을 제공합니다. 어떤 함수를 쓰느냐에 따라, 콜백 호출 전후에 약간의 로직을 자동으로 끼워 넣을 수 있습니다.
Gleam 코드가 JavaScript의 Promise와 상호작용하나요? 큰일이네요. Gleam에는 async/await가 내장되어 있지 않습니다. 어쩌죠?!?
아니요, 방법이 있습니다. use의 힘과 promise.await의 도움을 빌리면 됩니다.
405 Method Not Allowed로 응답하도록 요구하고, 요청 본문이 JSON임을 요구하는 웹 미들웨어 체인을, 번거로운 의식 없이 만들고 싶나요? use가 도와줍니다.
이 단순한 구성만으로, 에러 시 조기 종료, 프로미스 체이닝, 리스트 매핑 등 많은 동작을 표현할 수 있습니다.
함수형 프로그래밍 판을 기웃거려본 사람이라면 “프로그래머블 세미콜론”이라는 표현이 새 발명품이 아니라, 모나드를 설명할 때 흔히 쓰이는 말임을 알 겁니다.
오 이런! 무시무시한 FP 용어라 겁먹을 필요는 없습니다. use는 마치 프로메테우스가 올림푸스에서 모나드를 인간 세상으로 가져온 것과 같습니다. use는 Gleam의 목표—난해하고 비밀스러운 FP 개념을 쉬운 해법으로 단순화—를 잘 보여줍니다.
정적 타입 함수형 언어를 거의 20년 가까이 만지작거려온 입장에서, FP를 더 배우기 쉽게, 더 친절하고 접근 가능하게 만들겠다는 Gleam의 목표는 나를 정말 설레게 합니다.
이제 분위기를 조금 가라앉혀 보죠. 나는 전에 새로운 언어에 들떴던 적이 있습니다. Gleam 전도사들이 잘 설명해야 할 허들이 몇 가지 있습니다.
BEAM 런타임이 여전히 다소 난해하지 않을까 걱정합니다. 에러 메시지는 Erlang 특유의 괴상한 문법으로 나오겠죠. 도입하려면 운영팀이 BEAM VM을 운영하고 계측하며 관찰하는 법을 배워야 합니다.
BEAM은 프로덕션에서 흔한 Python이나 Go에 비하면 대부분에게 꽤 이질적입니다. 위험 회피적인 운영팀을 설득하려면 공을 들여야 할 겁니다. 다만 Erlang은 높은 가용성으로 유명하니, 내가 생각하는 것만큼 어려운 설득은 아닐지도 모르겠습니다.
불변 데이터 타입에는 학습 곡선이 있습니다. 대부분의 프로그래머는 명령형 배경을 가지고 있고, 불변 데이터를 다루는 방식은 가변 데이터를 다루는 방식과 다릅니다. 그래도 그만한 가치는 있습니다. 무엇이 내 데이터를 바꿀 수 있는지 걱정하지 않아도 된다는 점에서 불변성은 마음의 평화를 가져다줍니다.
메타프로그래밍보다 코드 생성을 고집하는 Gleam의 방향이 실수일 수도 있습니다. 확신하진 못하겠어요. Rust의 derive 매크로는 매우 편리하지만, Go의 코드 생성도 내겐 그다지 큰 문제가 아니었습니다. 시간이 말해주겠죠. 이 부분은 크게 걱정하지 않으며, Gleam 팀이 뭘 하는지 알고 있다고 믿습니다.
Elixir가 사람들을 멀어지게 할 수도 있습니다. 이미 Elixir를 쓰는 사람들은 정적 타이핑을 정말 원하지 않는 이상 Elixir에 머물 가능성이 큽니다. Elixir 성격의 문제를 풀고 싶은 사람들도, 성숙도 때문에 Gleam보다 Elixir를 택할 수 있겠죠. 뭐 시간이 말해주겠죠. Gleam은 빠르게 성장하고 있고, 생태계도 이미 꽤 좋습니다.
전반적으로, 이 언어에 엄청 설렙니다. 만져본 매 순간이 즐거웠고, 쓸 이유를 찾는 것도 기대됩니다. 다만 BEAM이 서비스 지향적이라는 특성 때문에 내게 완벽한 범용 언어는 아닙니다.
내가 하는 모든 일을 Rust로 대체하지는 않겠지만, Rust의 훌륭한 보완재입니다.
동시성이 많고 오래 실행되는 서비스 같은 “Gleam형” 문제라면 Gleam을 집어 들 겁니다. Rust 대비 생산성 향상을 놓치기 어렵거든요.
반대로 네이티브 GUI, 고성능, CLI 같은 “Rust형” 문제라면 여전히 Rust를 택하겠습니다. 두 세계를 결혼시키기 위해 Rustler를 사용하는 것도 궁금합니다. 특히 프로토콜 파서 분야에서요.
Issac에게 약간 미안한 마음이 듭니다. 이 글이 그의 영상을 표절한 것처럼 느껴질 수 있거든요. Gleam은 너무 작고 우리가 사랑하는 지점도 겹치다 보니, 다른 창작자를 베끼는 것처럼 들리지 않고는 쓰기 어려운 언어입니다. 그의 Gleam 재생목록을 꼭 보시길 권합니다. 정말 잘 만들었습니다.