Rust처럼 생겼지만 Go로 컴파일되는 언어, Lisette

22일 전 / , ,
简体中文한국어
요약 ·
Rust의 문법은 늘 좋아했지만 소유권은 버거웠고, 직접 언어를 만들어 보기도 했다. 최근 발견한 Lisette는 Rust 스타일로 작성한 코드를 Go로 컴파일한다. 생각해 보지 못했던 접근이다.

나는 Rust의 문법을 정말 좋아한다. 타입 시스템, 패턴 매칭, 메서드 체이닝, 추상화를 설계한 방식까지 모두 마음에 든다. 읽기도 쓰기도 편해서, 내가 생각하는 프로그래밍 언어의 이상적인 모습에 가깝다.

하지만 소유권은 개발 속도를 너무 자주 늦춘다. 물론 이제는 대부분 vibe coding을 하고, 많은 코드를 직접 한 줄씩 입력하지도 않으니 예전만큼 두드러지는 문제는 아니다. 그래도 가끔 손으로 코드를 쓰다 보면 이런 생각이 든다. Rust처럼 직관적인 문법에 패턴 매칭과 메서드 체이닝도 그대로 있고, 소유권만 없는 언어가 있다면 어떨까. 거기에 Go의 편리한 GC와 동시성까지 더해진다면 코드를 쓰는 일이 얼마나 즐거울까.

최근에 Lisette를 발견했다. 한 번도 생각해 보지 못했던 길을 택한 언어다.

내가 원했던 언어

내가 원했던 것은 두 부분으로 나눌 수 있다.

Rust에서 가장 좋아하는 것은 열거형과 패턴 매칭의 조합이다. 값이 여러 경우 중 하나라면 열거형으로 표현하고, 처리할 때는 match를 쓴다. 한 경우라도 빠뜨리면 컴파일러가 알려 준다. 여기에 Option과 Result를 더하면 값의 부재와 오류도 타입의 일부가 된다. 오류는 ?로 위쪽에 전달하면 되니 쓰기 편하다. 메서드 체이닝도 좋다. map, filter를 이어 쓰면 읽는 사람도 위에서 아래로 따라가면 되고, 머릿속에서 이리저리 오갈 필요가 없다.

Go에서 좋아하는 것은 GC와 동시성이다. GC가 있으니 누가 무엇을 소유하고 누구에게 빌려줬는지 신경 쓰지 않아도 된다. goroutine과 channel 덕분에 동시성 코드에도 거창한 준비가 필요 없다. 컴파일도 빠르고, 보통 배포하기 편한 독립 실행형 바이너리 하나로 만들 수 있다.

결국 양쪽에서 절반씩 가져오고 싶었다. 문법과 타입 시스템은 Rust에서, 런타임은 Go에서.

직접 언어를 만들어 보기

이미 있는 것이 없다면 직접 만들면 된다. 실제로 그렇게 해 보기도 했다.

그때 SOTA 모델로 십수 B의 토큰을 쓰면서 Zig로 수십만 줄의 코드를 작성했다. 하지만 완전한 언어를 만들려면 해야 할 일이 너무 많다. 렉싱과 파싱, 타입 추론과 타입 검사, 코드 생성, 런타임, GC, 표준 라이브러리까지, 이것만 해도 언어 자체에 필요한 부분일 뿐이다. 제대로 쓰려면 포매터, LSP, 패키지 관리도 있어야 한다. 없으면 사용하기 불편하고, 어느 하나만 떼어 놓아도 큰 작업이다.

코드는 많이 쌓였지만 쓸 만한 상태까지는 여전히 갈 길이 멀었고, 결국 점차 손을 놓게 됐다.

Lisette는 어떤 언어인가

Lisette는 아예 런타임을 만들지 않는 쪽을 택했다. 스스로를 Rust에서 영감을 받아 Go로 컴파일되는 작은 언어라고 소개한다. 저자의 표현을 빌리면, Rust로 쓴 Go에게 보내는 연애편지다. “A love letter to Go written in Rust.”

Rust 스타일의 문법으로 코드를 쓰고 파일 확장자는 .lis를 사용한다. 컴파일러 자체는 Rust로 작성되어 있다. Lisette 코드를 Go 코드로 변환한 뒤, Go 툴체인에 넘겨 컴파일한다. 따라서 최종적으로 실행되는 것은 평범한 Go 프로그램이다. Go의 런타임, GC, 스케줄러를 사용하고, 표준 라이브러리와 서드파티 Go 모듈도 그대로 쓸 수 있다.

이렇게 하면 내가 가장 어렵게 느꼈던 런타임, GC, 동시성, 표준 라이브러리가 한꺼번에 해결된다. 모두 누군가 이미 잘 만들어 둔 것들이다. 언어 쪽에서는 프런트엔드와 코드 생성만 만들면 된다. 그렇다고 컴파일러가 작은 것은 아니다. Rust 코드만 거의 20만 줄이고, 그중 Go 코드를 생성하는 부분이 4만 줄을 넘는다. 프런트엔드와 변환만 맡아도 작업량이 만만치 않은 모양이다.

아직 신생 프로젝트지만 저자는 아주 자주 업데이트하고 적극적으로 관리한다. 내가 본 개인 언어 프로젝트 중에서도 가장 꾸준히 유지보수되는 편이다.

코드는 어떻게 생겼나

먼저 열거형과 패턴 매칭을 보자.

Rust
enum Shape {
  Circle(float64),
  Rectangle { width: float64, height: float64 },
}

fn area(shape: Shape) -> float64 {
  match shape {
    Circle(r) => 3.14 * r * r,
    Rectangle { width, height } => width * height,
  }
}

보기에는 거의 Rust다. float64처럼 타입 이름만 Go 쪽을 따른다. match에서 변형 하나를 빠뜨리면 컴파일러가 오류를 내는 것도 같다.

메서드 체이닝에는 파이프 연산자 |>를 도입했다. Go에서는 여러 겹으로 감싸야 했던 함수 호출을 위에서 아래로 읽을 수 있다.

Rust
let slug = "  Hello World  "
  |> strings.TrimSpace
  |> strings.ToLower
  |> strings.ReplaceAll(" ", "-")  // "hello-world"

또 nil이 없다. 없을 수도 있는 값은 Option<T>, 실패할 수 있는 연산은 Result<T, E>로 표현한다. ?와 let ... else도 쓸 수 있다. 예를 들면 이렇다.

Rust
let Ok(bytes) = os.ReadFile(path) else {
  fmt.Println("could not read")
  return
}

기본적으로 불변이고 let mut로 선언해야 수정할 수 있다. impl 블록, 제네릭, 표현식 형태의 if와 match, f-string도 갖추고 있어서 읽는 느낌이 Rust와 비슷하다.

생성되는 Go 코드

그러면 변환된 Go 코드는 어떤 모습일까. 세 가지 변형을 가진 열거형과 가드가 붙은 match로 살펴봤다.

Rust
enum Shape { Circle(int), Square(int), Tri(int) }

fn test(s: Shape) -> int {
  match s {
    Circle(r) => r,
    Square(w) => w,
    Tri(h) if h > 10 => h * 2,
    Tri(h) => h,
  }
}

생성되는 Go 코드는 다음과 같다. MakeShapeCircle 같은 생성 함수는 생략했다.

GO
type ShapeTag uint8

const (
	ShapeCircle ShapeTag = iota
	ShapeSquare
	ShapeTri
)

type Shape struct {
	Tag    ShapeTag
	Circle int
	Square int
	Tri    int
}

func test(s Shape) int {
	if s.Tag == ShapeCircle {
		return s.Circle
	}
	if s.Tag == ShapeSquare {
		return s.Square
	}
	h := s.Tri
	if h > 10 {
		return h * 2
	}
	return h
}

열거형은 태그가 붙은 구조체가 되고, match는 if가 된다. 분기가 잘 맞아떨어지면 switch로 바뀌기도 한다. 전체적으로 Go에 익숙한 사람이 직접 작성한 코드처럼 보인다. 최근 두 버전의 새 기능도 대부분 생성되는 Go 코드를 더 자연스럽게 만드는 데 집중하고 있다. 예를 들어 or-pattern을 switch의 여러 case로 바꾼다. 저자가 이 부분에 꽤 공을 들이고 있는 듯하다.

소유권 대신 GC

Lisette에는 소유권도 빌림도 없다. 그래서 나를 괴롭히던 컴파일 오류도 자연히 사라진다. 그렇다면 Rust와 구체적으로 무엇이 다를까?

예를 들어 Rust에서는 String처럼 Copy가 아닌 값을 대입하면 이동이 일어난다. Lisette에는 이런 이동 의미론이 없어서 대입한 뒤에도 원래 변수를 계속 쓸 수 있다.

Rust
let s1 = "hello"
let s2 = s1
fmt.Println(s1)  // 没问题,s1 还能用

참고로 Rust에서도 문자열 리터럴의 타입인 &str는 Copy이므로 위와 같은 대입 후에도 원래 값을 쓸 수 있다. 앞에서 말한 이동이 일어나는 쪽은 String 같은 타입이다.

String과 &str는 하나의 string으로 합쳐져서 소유한 문자열과 빌린 문자열을 구분할 필요가 없다. 클로저는 참조로 캡처하고, 캡처한 값의 수명은 GC가 관리한다. 따라서 move 키워드도 빌림 규칙도 없다. trait은 Go 스타일의 interface로 바뀌었다. 메서드만 맞으면 그 인터페이스를 구현한 것으로 간주하므로 impl Trait for를 따로 쓰지 않아도 된다. 덧붙이면 unwrap()도 없다.

참조 자체는 남아 있다. Ref<T>와 mut Ref<T>로 쓰며, 대략 Rust의 &T, &mut T에 대응한다. 다만 mut Ref<T>가 동시에 여러 개 존재할 수 있다. Rust처럼 배타적일 필요는 없고, 쓸 수 있는지는 바인딩이나 필드 자체의 선언에 달려 있다.

그러니 Rust에서 내가 가장 좋아하는 부분은 거의 다 남아 있고, 소유권만 GC로 바뀐 셈이다.

동시성

동시성은 Go의 모델을 그대로 따른다. 언어에 Channel과 task가 내장되어 있고, task는 goroutine에 해당한다. task { ... }는 go func() { ... }()로 변환된다.

Rust
let (tx, rx) = Channel.new<string>().split()
task {
  tx.send("hello")
  tx.close()
}
match rx.receive() {
  Some(msg) => fmt.Println(msg),
  None => fmt.Println("closed"),
}

rx.receive()는 Option을 반환하고, channel이 닫히면 None이 된다. Go에서는 v, ok := <-ch로 확인해야 했던 일을 여기서는 match에 맡길 수 있다. select도 지원한다.

Go와의 상호 운용

Go와 연동하는 방식은 특히 마음에 들었다. Go 패키지를 import "go:..."로 가져오면, 사용하는 느낌은 Go에서와 비슷하다.

Rust
import "go:os"
import "go:encoding/json"
import "go:fmt"

fn load_config(path: string) -> Result<Cfg, error> {
  let data = os.ReadFile(path)?
  let mut config = Cfg { port: 8080, debug: false }
  json.Unmarshal(data, &config)?
  Ok(config)
}

Go에서 os.ReadFile은 ([]byte, error)를 반환하지만 Lisette에서는 자동으로 Result가 된다. 그래서 곧바로 ?를 쓸 수 있다. nil이 될 수 있는 Go의 포인터, map, 인터페이스도 경계를 넘어오면서 Option으로 바뀐다. 예를 들면 이렇다.

Rust
// Go:  func find(id int) *Person
// Lis: fn find(id: int) -> Option<Ref<Person>>

match find(1) {
  Some(person) => fmt.Println(person.name),
  None => fmt.Println("no person"),
}

이렇게 Lisette에서는 nil이 완전히 사라진다. None이거나, 값이 반드시 존재하거나 둘 중 하나다. 변환에는 .d.lis라는 타입 선언 파일을 사용한다. 서드파티 Go 모듈이 필요하면 lis add google/uuid처럼 추가하면 된다. 모듈을 내려받고 대응하는 선언을 생성한 뒤, 의존성을 lisette.toml에 기록해 준다.

컴파일 시점의 검사

Go에서 흔히 발생하는 런타임 오류와 잠재적인 문제를 컴파일 시점에 검사한다. 진단 종류만 500개가 넘는다. 앞서 이야기한 nil 외에도 match의 빠진 변형, 반환된 Result나 Option을 그대로 버리는 경우, os.Exit를 호출해 defer를 건너뛰는 경우, context의 cancel 함수를 호출하지 않은 경우, task 안에서 뒤늦게 WaitGroup.Add를 호출하는 경우 등을 검사한다. 심지어 파일 권한을 십진수로 적어도 알려 준다.

Go 코드를 쓸 때 빠지기 쉬운 함정을 컴파일러와 문법이 상당 부분 막아 주는 셈이다.

툴체인

명령줄 도구의 이름은 lis이고, 필요한 것은 거의 다 갖추고 있다. lis new로 프로젝트를 만들면 src/main.lis, lisette.toml, AI를 위한 AGENTS.md가 생성된다. lis run과 lis build는 컴파일과 실행을 맡고, 후자는 --target으로 크로스 컴파일도 할 수 있다. lis emit은 Go 코드만 생성하므로 변환 결과를 보고 싶을 때 쓰면 된다. 타입 검사와 lint를 하는 lis check는 --fix로 자동 수정도 지원한다. 이 밖에 lis format, #[test]를 실행하는 lis test, 앞서 소개한 lis add도 있다.

단일 파일 스크립트도 바로 실행할 수 있다. 맨 위에 shebang을 넣으면 스크립트처럼 실행된다.

Rust
#!/usr/bin/env -S lis run

import "go:fmt"
import "go:os"

fn main() {
  fmt.Println("hello,", os.Args[1])
}

에디터는 VS Code, Zed, JetBrains의 확장 마켓에서 Lisette를 검색해 설치하면 된다. Neovim과 Helix는 lis lsp에 연결하도록 직접 설정해야 한다. 브라우저에서 쓸 수 있는 playground도 있어서, 가볍게 살펴보고 싶다면 설치하지 않아도 된다.

직접 써 보기

최종 컴파일은 Go에 맡기므로 먼저 Go 1.26 이상을 설치해야 한다. Lisette는 다음 방법으로 설치할 수 있다.

Shell
curl -fsSL https://lisette.run/install.sh | sh
# 或者
brew install lisette
mise use -g lisette
cargo install lisette

설치한 뒤 새 프로젝트를 만들어 실행한다.

Shell
lis new hello-world
cd hello-world
lis run

프로젝트는 ivov/lisette, 문서는 lisette.run에 있다.

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...