Gleam의 `use` 표현식이 무엇인지, 언제 유용한지, 그리고 `result.map`/`result.try`와 컨텍스트 관리에서 어떻게 쓰는지 예제와 함께 설명합니다.
최근 동료가 Gleam의 language tour(https://tour.gleam.run/)를 살펴봤습니다. 마음에 들었지만, Gleam의 use 문법이 헷갈렸다고 하더군요. 저도 Gleam의 문법을 정말 좋아해서 Gleam의 문법에 관한 글까지 썼지만, 처음 use를 접했을 때는 저도 혼란스러웠습니다. 제가 use를 쓰는 방법을 소개합니다: use 표현식은 Gleam v0.25에서 도입되었으며, 그 이전에 있던 try 키워드를 더 일반화한 대체물입니다.
use anyway?use는 마지막 인자가 콜백 함수인 함수에 대해, 들여쓰기를 늘리지 않고 코드를 작성할 수 있게 해 주는 Gleam의 표현식입니다. 여기서 콜백 함수란 고계 함수의 인자로 전달되는 함수를 뜻합니다(고계 함수의 인자가 항상 콜백이라고 할 수 있는가에 대해 논쟁이 있기도 하지만, Gleam의 language tour에서 “callback function”이라는 용어를 쓰므로 여기서도 콜백이라고 부릅니다). 예를 들어:
import gleam/list
fn catify_without_use(strings: List(String)) -> List(String) {
list.map(strings, fn(string) {
string <> " cat"
})
}
여기서는 문자열 리스트의 각 원소 끝에 " cat"을 붙입니다. list.map은 첫 번째 인자로 리스트를, 두 번째 인자로 리스트 원소를 받아 새 원소를 반환하는 콜백 함수를 받습니다. 같은 함수를 use 표현식으로 쓰면 다음과 같습니다:
import gleam/list
fn catify_with_use(strings: List(String)) -> List(String) {
use string <- list.map(strings)
string <> " cat"
}
여기서 use 표현식은 세 가지 일을 합니다:
첫째, 콜백 함수의 인자를 화살표 왼쪽으로 옮깁니다. fn(string)이 use string <-로 바뀝니다. 여기서 string이라는 이름은 제가 임의로 고른 식별자이며, use to_be_catted <- ...처럼 아무 이름이어도 됩니다.
둘째, list.map을 원래보다 인자를 하나 덜 받는 함수처럼 씁니다. 이제 list.map(strings)처럼 마치 인자 하나만 받는 것처럼 보이죠. 실제로 list.map은 여전히 두 개의 인자를 받지만, use가 두 번째 인자를 쓰는 방식을 바꿉니다. 이 덕분에 콜백이 아닌 인자들이 눈에 더 잘 들어옵니다. 보통 콜백이 바깥 함수 호출 시그니처에서 가장 부피가 크니까요. 또한 use 문에는 정확히 하나의 식별자만 썼는데, 이는 list.map의 콜백 함수가 받는 인자 개수와 일치하기 때문입니다. 더 많은 예시는 이 노트를 참고하세요.
셋째, 콜백 함수의 본문은 이제 use 표현식 바로 아래 줄, 즉 use와 같은 들여쓰기 수준으로 이어집니다. catify_with_use가 끝날 때까지 use 아래의 모든 코드가 콜백 함수의 본문이 됩니다. 즉, use가 위치한 같은 블록 안의 끝까지가 콜백 본문입니다. 더 자세한 설명은 이 노트를 참고하세요.
여기까지는 문법 설탕을 소개한 셈입니다. use 표현식은 일반 호출과 익명 함수에 대한 문법 설탕입니다. 컴파일 시 use는 fn(arg1, arg2, ...) { body() } 형태로 전개됩니다. 즉, 표현을 다른 방식으로 쓰게 해 주는 도구일 뿐입니다. 하지만 위 예시에서는 큰 도움이 되지 않았습니다. 콜백 함수를 쓰고 있다는 사실이 덜 분명해졌고, list.map 호출 이후에 하고 싶은 다른 작업이 있어도 콜백 본문에 들어가 버려 따로 할 수 없습니다. list.map을 use로 쓸 수는 있지만, 대부분의 경우 기본 문법을 쓰는 편이 낫습니다. 여기서는 list.map이 널리 쓰이는 함수라 일부러 예시로 들었지만, use와는 썩 잘 맞지 않습니다. 그래도 list.map은 제가 가장 좋아하는 함수 중 하나라, 다른 Gleam 글에서 많이 다뤘습니다.
그렇다면 use는 언제 유용할까요?
result.unwrap와 조기 반환Gleam에는 예외가 없으며, 모든 오류는 함수에서 값으로 반환해야 합니다. 또한 Gleam에는 return 키워드도 없고, 블록의 마지막 표현식이 그 블록의 값으로 반환됩니다. 함수에서는 함수의 마지막 표현식이 곧 반환값입니다. 구체적으로, Gleam은 이를 위해 Result 관용을 사용합니다. 성공은 Ok(value), 실패는 Error(reason)로 나타냅니다.
그렇다면 실패할 수도 있는 작업을 한 뒤, 같은 함수 안에서 계속해서 다른 일을 하고 싶다면 어떻게 해야 할까요?
import gleam/result
fn outer() -> Result(success, failure) {
let id = parse_id() |> result.unwrap(0)
... // id를 사용하는 더 많은 코드
}
parse_id는 실패할 수 있으므로 Result를 반환합니다. 내부 값을 쓰려면 언래핑해야 합니다. 파싱에 성공하면 Ok(id) 형태가 되고, 이 코드는 result.unwrap으로 Ok인 경우 id를 꺼내고, Error인 경우에는 id를 0으로 둡니다.
하지만 id로서의 0은 근거 없는 값일 가능성이 크고, 우리 시스템에서 신뢰할 수 있는 의미를 갖지 않을 겁니다. 파싱이 실패했음을 전달하고 싶다면, 원래부터 있던 Error를 그대로 반환하는 편이 낫습니다.
result.unwrap 대신 result.map을 쓸 수 있습니다. list.map과 result.map이 둘 다 map이라는 이름인 이유는, 둘 다 [map](https://en.wikipedia.org/wiki/Map_(higher-order_function%29)이라는 고계 함수의 예시이기 때문입니다. result.map의 경우 "컬렉션"은 Ok 안의 값뿐이며, Error는 콜백 함수를 호출하지 않고 그대로 반환합니다. 즉, result.map은 첫 번째 인자로 Result를, 두 번째 인자로 콜백 함수를 받고, 그 Result가 Ok일 때만 콜백을 호출합니다. Result가 Error면 그 Error를 그대로 반환합니다. 이 방식으로 Gleam은 "조기" 반환을 구현합니다. result.map은 성공일 때만 콜백을 평가하므로, 실패한 경우 result.map 표현식(따라서 블록)의 값이 곧 그 Error가 되고, 이후 코드는 평가되지 않습니다. 콜백 함수는 언래핑된 내부 값을 하나의 인자로 받습니다.
따라서 다음처럼 쓸 수 있습니다:
import gleam/result
fn outer() -> Result(success, failure) {
result.map(parse_id(), fn(id) {
... // id를 사용하는 더 많은 코드
})
}
이렇게 result.map을 호출하면, 이제 outer 내부의 모든 내용이 콜백 함수 안으로 들여쓰기되어 들어갑니다. 여기서 use 표현식을 사용하면 불필요한 들여쓰기를 없애고, 성공 경로에 집중할 수 있습니다:
import gleam/result
fn outer() -> Result(success, failure) {
use id <- result.map(parse_id())
... // id를 사용하는 더 많은 코드
}
여전히 우리가 원한 바, 즉 parse_id가 실패하면 Error로 조기 반환한다는 점은 달라지지 않습니다. Rust의 ? 연산자에 익숙하다면, use id <- result.map(parse_id())는 Rust의 let id = parse_id()?;와 동등합니다. 이제 성공한 경우 언래핑된 id에 주의를 집중할 수 있습니다.
result.map으로 보일러플레이트 줄이기use 표현식은 보일러플레이트를 줄이는 데도 도움이 됩니다. 예를 들어, 파일에서 읽기는 실패할 수 있는 작업이므로 Result를 반환합니다. 파일에서 줄을 읽어 변환하고 싶다면 result.map을 쓸 수 있습니다:
import gleam/result
import gleam/list
fn transform_lines() {
read_file_lines()
|> result.map(list.filter(...))
|> result.map(list.map(...))
|> result.map(list.sort(...))
|> result.map(something_else())
}
하지만 result.map은 새 Result를 반환하므로, 필요한 모든 변환이 끝날 때까지 result.map 호출을 계속 체이닝해야 합니다.
use 표현식을 사용하면 실패를 우아하게 처리하면서, result.map을 연쇄 호출할 필요를 없앨 수 있습니다:
import gleam/result
import gleam/list
fn transform_lines() {
use lines <- result.map(read_file_lines())
lines
|> list.filter(...)
|> list.map(...)
|> list.sort(...)
|> something_else()
}
두 함수는 동등하지만, use 표현식 덕분에 우리가 관심 있는 변환에 집중할 수 있습니다. use가 문법 설탕일 뿐이므로, 파이프라인으로 연결한 변환들을 모두 result.map의 콜백 본문 안에 fn(lines) { ... } 형태로 써도 됩니다.
result.try 체이닝여러 종류의 실패 가능 작업을 연달아 수행할 때 use 표현식이 특히 빛납니다. result.try는 Result와, 또 다른 Result를 반환하는 콜백 함수를 받습니다. 첫 번째 것을 "시도"하고, 성공하면 두 번째 것을 다시 "시도"합니다. 이런 result.try 체이닝을 철도 지향 설계(railroad-oriented design)라고 부르는 것을 들은 적이 있습니다. 어떤 작업이든 실패하면 Error "선로"로 갈아타 그 Error를 반환하고, 그렇지 않으면 다음 실패 가능 작업까지 Ok 선로를 따라갑니다. 첫 번째 인자가 Error면 그 에러를 그대로 반환합니다. 그렇지 않으면 콜백 함수를 평가하고, 콜백이 반환하는 Result를 반환합니다. 예를 들면:
import gleam/result
fn handle_form(form_data: RegistrationForm) {
result.try(
unique_username(form_data.username),
fn(username) {
result.try(
validate_password(form_data.password),
fn(password) {
result.map(
register_user(username, password),
fn(user) {
"welcome " <> user.username <> "!"
}
)
}
)
}
)
}
각 작업이 별도로 실패할 수 있으므로, 앞서 result.map 보일러플레이트 예시처럼 단순 체이닝을 할 수 없습니다. 그 결과 콜백 함수가 층층이 들여쓰기되어, 무슨 일이 벌어지는지와 성공 시 최종 반환값이 무엇인지 파악하기 어려워집니다(일명 callback hell). use 표현식을 쓰면 의미가 훨씬 명확해집니다:
import gleam/result
fn handle_form(form_data: RegistrationForm) {
use username <- result.try(unique_username(form_data.username))
use password <- result.try(validate_password(form_data.password))
use user <- result.map(register_user(username, password))
"welcome " <> user.username <> "!"
}
이제 어떤 작업들을 수행하는지, 그리고 최종 반환값이 무엇인지 훨씬 알기 쉽습니다. 마지막 단계에서 result.map을 쓰는 이유는, 이제 더 이상 실패하지 않는 값을 최종적으로 반환하기 때문입니다. 첫 번째 예시에서도 그랬던 것, 눈치채셨나요?
use 표현식이 빛나는 또 다른 영역은 컨텍스트 관리(설정, 정리, 혹은 둘 다)입니다. Gleam에는 컨텍스트 관리를 위한 특별한 메커니즘이 없어서, 이런 함수들은 사용자 동작을 감싸기 위해 콜백 함수를 인자로 받습니다. 예를 들어, sqlight로 데이터베이스 연결을 여는 경우:
import sqlight
fn get_data() {
use conn <- sqlight.with_connection("my_database.db")
... // 데이터베이스를 질의
}
sqlight.with_connection는 데이터베이스 연결을 열고, 코드를 실행한 뒤, 끝나면 연결을 닫습니다. 함수의 나머지 부분에서 데이터베이스 연결은 conn으로 사용할 수 있습니다. 기술적으로는 사용자 코드 전체가 함수로 감싸져 있습니다:
import sqlight
fn get_data() {
sqlight.with_connection("my_database.db", fn(conn) {
... // 데이터베이스를 질의
})
}
하지만 use 표현식을 쓰면 데이터베이스 관리가 아니라, 우리가 쓰려는 쿼리에 집중할 수 있습니다.
이런 종류의 콜백 래퍼의 또 다른 예시는 Gleam의 웹 프레임워크인 wisp에서 볼 수 있습니다(예시는 wisp의 logging 예시에서 가져왔습니다):
import wisp
pub fn middleware(
req: wisp.Request,
handle_request: fn(wisp.Request) -> wisp.Response,
) -> wisp.Response {
let req = wisp.method_override(req)
use <- wisp.log_request(req)
use <- wisp.rescue_crashes
use req <- wisp.handle_head(req)
handle_request(req)
}
여기서 wisp.log_request는, 사용자가 어떤 방식으로 요청을 처리하든 상관없이, 요청 처리 후에 로깅이 일어나도록 콜백 함수를 사용합니다.
다른 wisp 함수들도 비슷한 패턴을 사용하여, 핵심 웹 애플리케이션 관심사들은 처리하면서도 애플리케이션의 커스터마이징을 가능하게 합니다.
아래는 use 없이 중첩 콜백의 폭주를 그대로 둔 예시로, use가 이를 어떻게 막아 주는지 보여 줍니다:
import wisp
pub fn middleware(
req: wisp.Request,
handle_request: fn(wisp.Request) -> wisp.Response,
) -> wisp.Response {
let req = wisp.method_override(req)
wisp.log_request(req, fn() {
wisp.rescue_crashes(fn() {
wisp.handle_head(req, fn(req) {
handle_request(req)
})
})
})
}
use가 없다면, 요청이 실제로 어떻게 처리되는지가 컨텍스트 관리를 위한 중첩 콜백들에 가려집니다. 이 예시에는 세 개의 컨텍스트 관리자 함수만 있지만, 8개 이상 쓰는 wisp 애플리케이션도 봤습니다. use 없이도 쓸 수는 있겠지만, use 표현식을 쓰면 사용자 정의 로직의 가독성이 훨씬 좋아집니다.
여러 예시를 통해 use 표현식이 오류 처리와 컨텍스트 관리 모두에서 코드의 명확성을 높일 수 있음을 보여 드렸습니다. list.map 예시에서 보았듯, use 표현식이 항상 도움이 되는 것은 아닙니다. 핵심은, 실패 처리나 로깅 같은 관심사를 처리하면서도 코드의 해피 패스를 더 잘 드러나게 해 줄 때 use를 쓰는 것입니다. 글을 쓰고 다듬는 데 도움을 주신 Nicole, Jeff Miller, Mark, 그리고 Gleam 디스코드에 감사드립니다.
use 표현식은 문법 설탕이므로, 언제나 use 없이도 Gleam 코드를 쓸 수 있습니다(다만 그만큼 명확하진 않을 수 있습니다). 왜 하필 use라는 키워드일까요? 이 이슈에서 더 일반적인 문법 설탕의 필요성이 제기되었습니다. koka의 with 문이 유사한 해법으로 언급되었지만, with는 이미 Elixir에서 널리 쓰이는 특수 형태여서, Elixir에서 넘어오는 BEAM 프로그래머를 혼란스럽게 하고 싶지 않았습니다. 많은 논의 끝에, let과 길이가 같고 어디에도 쓰이지 않던 use가 선택되었습니다.
use의 스코프에 대한 메모use 표현식 아래에 오는 모든 코드는 현재 블록의 끝까지 콜백 함수의 본문을 이룹니다. 기본적으로는 함수의 끝까지이지만, {}를 사용해 더 작은 블록을 만들 수 있습니다. language tour에서도 “use는 Gleam의 다른 것과 마찬가지로 표현식이기 때문에, 블록 안에 배치될 수 있다”고 설명합니다.
import gleam/result
fn example() {
let smaller_block = {
use value <- result.try(thing_that_might_fail())
... // value로 무언가를 수행
}
no_longer_in_use_callback(smaller_block)
}
이의 예시는 새 decode 라이브러리에서 볼 수 있습니다(예시는 decode의 README에서 가져왔습니다):
let decoder =
decode.into({
use name <- decode.parameter
use email <- decode.parameter
use is_admin <- decode.parameter
User(name, email, is_admin)
})
|> decode.field("name", decode.string)
|> decode.field("email", decode.string)
|> decode.field("is-admin", decode.bool)
decoder
|> decode.from(data)
여기서 decode.into는 {} 블록을 사용해, use와 decode.parameter를 조합하여 간결하게 디코더 함수를 만듭니다. 이것은 문법 설탕으로서의 use를 영리하게 활용한 예시입니다. decode.parameter는 단순히 인자를 그대로 반환하므로, 위 블록은 fn(name) { fn(email) { fn(is_admin) { User(name, email, is_admin) }}}로 전개됩니다. 특히 필드의 순서가 중요하기 때문에, 이렇게 전개된 형태는 훨씬 읽기 어렵습니다.
use 인자에 대한 메모use 표현식의 식별자 개수는, 대체되는 콜백 함수가 요구하는 인자 개수와 정확히 같습니다.
bool.guardbool.guard는 인자를 받지 않는 함수를 받습니다: fn() -> a
// `use` 없이
bool.guard(condition, "early return", fn() {
...
"late return"
})
// `use` 사용
use <- bool.guard(condition, "early return")
...
"late return"
list.foldlist.fold는 인자 두 개를 받는 함수를 요구합니다: fn(a, a) -> a
// `use` 없이
list.fold(numbers, 1, fn(accumulator, element) {
accumulator * element
})
// `use` 사용
use accumulator, element <- list.fold(numbers, 1)
accumulator * element