Goose 언어가 던지는 도발: 시스템 코드에 힙은 필요 없다
Goose는 힙도 GC도 수명 어노테이션도 없는 메모리 안전 시스템 언어입니다. 스택 중심 설계가 Rust와 리전에 주는 시사점을 짚어봅니다.

여러 언어 커뮤니티가 분명 불편해할 주장으로 시작하겠습니다. borrow checker는 우회로였다는 것입니다. 실수였다는 뜻은 아닙니다. Rust의 소유권 모델은 실제 문제를 풀었고 업계 전체를 앞으로 끌어올렸습니다. 다만 더 오래되고 단순한 아이디어를 돌아서 온 길이었고, 지금 우리는 그 아이디어를 다시 발견하는 중입니다. 그 아이디어가 바로 리전(region)입니다. 큰 스택 형태의 아레나에 메모리를 할당해 두었다가 스코프가 끝나면 한꺼번에 해제하는 방식입니다. 이번 주 화제가 된 새 시스템 언어 Goose는 제가 본 가장 과감한 현대적 시도이고, 그 설계는 불편한 질문을 던집니다. C++과 Rust에서 우리가 감수하는 것들 중 실제로 떠받치는 부분은 얼마나 되고, 그저 힙이 시끄럽게 떠드는 소리는 얼마나 될까요?
Goose에는 힙 할당기가 없습니다. 가비지 컬렉터도, 참조 카운팅도, 소멸자도, 수명 어노테이션도, unsafe도 없습니다. 모든 동적 값은 컴파일러가 정적으로 배정한 몇 개의 데이터 스택 중 하나에 인라인으로 놓입니다. 성장은 포인터를 올리는 것으로 끝나고, 백만 개 원소로 된 중첩 구조를 해제하는 것도 스택 꼭대기에 쓰기 한 번이면 됩니다. 제작자의 벤치마크에 따르면 Goose는 관용적인 C++보다 3.3배, 안전한 Rust보다 1.12배 빠르면서 메모리도 적게 씁니다. John Carmack도 공개적으로 반응했는데, 그가 짚은 부분이 제게 가장 흥미로웠습니다. 바로 자기 상대 참조 덕분에 데이터 구조 자체가 곧 파일 형식이 된다는 점입니다. Goose가 실제 프로덕션 소프트웨어에 쓰일지는 모르겠습니다. 하지만 힙을 지우고 그것을 사과하지 않을 때 시스템 언어가 어떤 모습이 되는지 보여주는 진지한 사고 실험인 것은 분명합니다.
메모리 모델이 곧 언어 전체입니다
대부분의 언어 기능은 협상 가능합니다. 문법, 모듈 시스템, 심지어 에러 처리까지도, 두 개의 합리적인 언어가 정반대로 선택하는 모습을 상상할 수 있습니다. 메모리 모델은 그렇지 않습니다. 하나를 고르는 순간 그 아래 모든 것이 강제됩니다. 자료구조, 동시성 전략, FFI, 에러 메시지까지 전부요. Goose는 이것을 이해하고 그 방향으로 끝까지 밀고 나갑니다.
규칙은 간단합니다. 프로그램에는 네이티브 호출 스택, 정적 데이터, 그리고 N개의 데이터 스택이 있고, N은 컴파일러가 계산합니다. 스택마다 크기가 변하는 값이 동시에 최대 하나만 살아 있으며, 그 값은 항상 꼭대기에 있습니다. 이 단일 불변식이 놀라울 만큼 많은 것을 가능하게 합니다. 커지는 값은 절대 이동하지 않습니다. 그 위에 재배치가 필요한 것이 없기 때문입니다. 따라서 커지는 배열을 가리키는 참조는 영원히 유효합니다. C++에서는 재할당되는 std::vector<T>가 그 안을 가리키던 모든 포인터와 이터레이터를 무효화합니다. 그래서 여러 세대의 C++ 개발자가 push_back 사이에 참조를 들고 있다가 데인 경험을 갖고 있습니다. 안전한 Rust는 아예 표현할 수 없게 만들어 이 문제를 피합니다. 대신 u32 인덱스를 쓰고, 그 대가로 경계 검사와 불편한 사용성을 감수해야 합니다. Goose는 그냥... 참조를 그대로 쥐고 있게 해줍니다. 타입과 수명 규칙은 컴파일 시점에 검사되며, 컴파일러가 각 참조가 무엇에 “뿌리를 두는지” 추론하고 소유자보다 오래 살 수 있는 참조는 거부합니다. 다만 어노테이션도, 앨리어싱 규칙도, 배타성 규칙도 없습니다.
struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later
Rust의 borrow checker를 써 본 사람이라면 이것이 마술처럼 보일 겁니다. 수명은 어디로 간 걸까요? 솔직한 답은 제약으로 갔다는 것입니다. Rust의 소유권 시스템은 그래프, 순환, 여러 스코프에 걸친 공유 가변 구조 등 임의의 공유 패턴에 대해 안전성을 증명할 수 있습니다. Goose는 훨씬 좁은 패턴 집합에 대해서만 안전성을 증명하고, 나머지는 아예 받아들이지 않습니다. 표현력을 단순함과 맞바꾸는 이 거래는 언어 설계에서 가장 오래된 거래이고, 여기서 제가 계속 파고들 실마리이기도 합니다. Rust borrow checker의 soundness를 다룬 글 Rust에서 soundness가 실제로 무엇을 뜻하는지를 읽으셨다면, Goose는 그 동전의 반대면입니다. 더 강력한 분석이 아니라 더 작은 언어로 건전성을 얻는 것이죠.
모든 것이 인라인: 일급 기능으로서의 데이터 레이아웃
두 번째 큰 아이디어는 단일한 배열 타입이 없다는 것입니다. Goose에는 크기 처리 방식만 다른 배열 계열이 있습니다. 컴파일 시점에 고정되거나, 생성 후 동결되거나, 인라인 용량 안에서 커질 수 있거나, 증가만 하거나, 증가와 축소가 모두 가능한 것들입니다. 모두 [metadata][elements...] 형태입니다. 연속적이고 빽빽하게 채워져 있으며, 원소 블록을 가리키는 포인터는 없습니다. 문자열은 그냥 u8 배열이므로, 구조체는 선언 순서대로 가변 크기 부분을 인라인으로 가질 수 있습니다. 가변 길이 문자열 필드와 가변 길이 하위 레코드 목록을 가진 레코드는 간접 참조 하나 없이 연속된 바이트 덩어리 하나가 됩니다.
제시된 수치는 눈길을 끕니다. Goose에서는 29바이트짜리 주문 레코드가 할당 0회로 만들어지지만, C++의 std::string + std::vector<Item>로 만들면 160바이트에 할당 1회, Rust로는 153바이트에 할당 4회가 듭니다. 그런데 토론에서 곧바로 나온 공정한 지적이 하나 있습니다. Goose 튜토리얼의 “문자열 벡터” 예제는 모든 주류 STL 구현이 하는 작은 문자열 최적화(SSO)를 무시했습니다. C++에서 짧은 문자열은 할당하지 않습니다. 이것이 논지를 무너뜨리지는 않습니다. SSO는 약 15~22바이트 이하 문자열에만 도움이 되고, 중첩된 가변 크기 데이터라는 일반적인 경우에는 아무 도움도 되지 않으니까요. 다만 언어 홈페이지의 벤치마크 표는 언어가 좋더라도 마케팅이라는 점을 다시 일깨워 줍니다. 제작자의 공은 손실 항목도 함께 공개했다는 데 있습니다. 대부분의 프로젝트가 그렇게 하지 않으니까요.
실제 비용도 있고, Goose는 이것을 숨기지 않습니다. 가변 크기 원소로 된 배열은 순차적입니다. 순회는 할 수 있지만 인덱싱은 할 수 없습니다. k번째 원소가 바이트 어디에 놓일지 알 수 없기 때문입니다. 임의 접근이 필요하면 참조들로 된 두 번째 배열을 인덱스로 따로 유지해야 합니다. 데이터 지향 설계를 하는 사람들이 수십 년 동안 손으로 해 온 종류의 트레이드오프이며(사실 컬럼형 포맷이나 FlatBuffers 류 레이아웃이 작동하는 방식과 같습니다), 다만 여기서는 그것이 기본값이고 벗어나려면 추가 작업이 필요합니다. 이것이 기능인지 구속복인지는 전적으로 접근 패턴에 달려 있습니다.
상대 참조: 데이터 구조가 곧 파일 형식이 됩니다
Goose에서 제가 가장 좋아하는 아이디어는 상대 참조이며, Carmack도 바로 이 부분에 주목했습니다. 일반 참조는 기계 주소입니다. 8바이트짜리 절대 주소이고, 프로세스 밖에서는 아무 의미가 없습니다. 상대 참조는 같은 링크를 1, 2, 4바이트 오프셋으로 저장합니다. 오프셋은 그 필드 자체로부터 재거나 이름 붙은 풀의 기준 주소에서 잽니다. 자기 상대 링크로 만들어진 구조는 위치 독립적입니다. 메모리 어디에 놓여도 같은 의미를 가집니다. 즉 데이터 구조를 저장하는 일이 write() 한 번이고, 불러오는 일이 read()와 검증 과정 하나입니다.
마지막 문장이 생각보다 중요합니다. 이 트릭의 순진한 버전, 즉 포인터로 가득한 파일을 메모리 매핑하는 방식은 고전적인 보안 재앙입니다. Goose의 버전은 검증을 합니다. from_bytes가 바이트를 값으로 바꾸기 전에 프레이밍, 모든 태그, 모든 길이, 모든 링크를 확인합니다. 따라서 손상되었거나 악의적인 파일은 와일드 포인터 대신 false를 돌려줍니다. mmap된 구조의 속도를 얻으면서, 파서 수준에 가까운 안전성도 함께 얻는 셈입니다.
Carmack의 지적은 정확했습니다. “파일과 네트워크 데이터의 ‘파싱’은 필요한 것보다 100배쯤 느린 경우가 많다.” 역직렬화 핫패스를 프로파일링해 본 사람이라면 다 알고 있는 사실입니다. 지금까지 업계의 답은 스키마 기반 제로 카피 포맷, 예를 들어 Cap'n Proto나 FlatBuffers였습니다. 잘 작동하지만 프로젝트에 스키마 컴파일러와 런타임 라이브러리를 덧붙여야 합니다. Goose의 답은, 언어가 애초에 절대 주소를 가리키는 포인터를 갖지 않기 때문에 메모리 내 레이아웃과 와이어 레이아웃이 구조적으로 같은 것이라는 것입니다. 이진 탐색 트리 예제는 거의 장난스러울 정도입니다. 링크를 포함해 노드 하나가 12바이트이고, 삽입은 push 한 번과 저장 한 번이며, 트리 전체를 바이트를 그대로 써서 영속화합니다. Lisp 개발자라면 이 철학적 움직임을 알아볼 것입니다. 모두가 근본적이라고 여겼던 구분을 무너뜨리는 것인데, 이것이 60년이 지나도 Lisp가 여전히 중요한 이유의 일부이자 설계의 기준점이 되는 이유이기도 합니다.
우리는 이미 여기 와 본 적이 있습니다: 리전, 아레나, 그리고 ML Kit
Goose에 대한 역사적으로 아는 사람다운 반응이 댓글에 제때 올라왔습니다. “ML 쪽 사람들이 이 길을 갔던 것 같은데, 리전이라고 불렀던 걸로 기억합니다.” 맞습니다. 리전 기반 메모리 관리는 1990년대 ML Kit 작업까지 거슬러 올라갑니다. Tofte와 Talpin의 리전 추론은 ML 컴파일러가 가비지 컬렉션을 정적으로 결정되는 스택 형태의 리전으로 대체할 수 있게 해 주었고, 객체별 회계 없이 리전 단위로 할당하고 해제했습니다. 역사적으로 정직한 평가는 이것이 일종의 막다른 길로 선언되었다는 것입니다. 리전 추론은 작동은 했지만, 실제 프로그램에서 병리적인 메모리 증가가 충분히 나타났습니다. 죽은 데이터가 리전 안에 너무 오래 붙잡혀 있었던 것이죠. 그래서 연구 커뮤니티는 대부분 다른 방향으로 넘어갔습니다. 가비지 컬렉터가 왕좌를 지켰습니다.
하지만 이 아이디어는 실제로 죽은 적이 없고, 직업만 바꾼 것입니다. 아레나 할당은 게임 엔진, 컴파일러, 고성능 서버에서 표준 관행입니다. Apache의 메모리 풀, 웹 서버의 요청별 아레나, 게임 루프에서 “프레임마다 임시 버퍼 초기화” 패턴, 이 모두가 다른 이름을 단 리전입니다. 언어가 도와주지 않기 때문에 수동으로 관리할 뿐입니다. Goose가 ML Kit과 다르게 하는 점은 정확히 짚어 둘 가치가 있습니다. 범용 함수형 언어에 리전을 추론하는 것(어렵고 예측하기 힘듭니다) 대신, 스택 모델을 직접 노출하고 프로그래머의 의도를 타입의 일부로 만듭니다. T[>..]는 “이것은 커지며, 참조는 유효하게 남는다”는 뜻입니다. 컴파일러의 일은 규율을 발견하는 것이 아니라 선언된 규율을 검사하는 것입니다. 훨씬 쉬운 문제이며, Rust가 이전의 자동 수명 체계들이 고전하던 곳에서 성공한 이유와 같습니다. 프로그래머는 눈에 보이는 계약은 기꺼이 감수하니까요.
프로덕션 언어가 연구를 흡수하는 방식에 대한 더 넓은 교훈도 있습니다. GC를 완전히 자동으로 대체하는 리전 추론은 실패했습니다. 수동 시스템 패턴으로서의 리전은 어디에나 있습니다. borrow checking은 엄청난 성공이며, 그 자체가 리전 아이디어와 선형 타입에서 많이 빌려왔습니다. Erlang 스타일의 공유 없는 프로세스는 입증되었고, Goose의 스레드가 정확히 그것입니다. 워커는 각자 메모리를 가진 별도 프로그램으로 컴파일되고, 값은 타입이 지정된 큐를 memcpy로 건너며, 데이터 레이스는 표현 자체가 불가능합니다. 처음부터 전체 그림을 제대로 맞힌 사람은 없습니다. 아이디어는 작동하는 형태로 자리 잡기까지 수십 년 동안 맴돕니다.
모델이 무너지는 지점
반대 주장으로 시작하겠다고 했으니, 어디서 깨지는지도 인정해야겠습니다. 프로그램의 공유 패턴이 정말 임의적이라면 borrow checker는 우회로가 아닙니다. 그리고 그런 모양의 프로그램은 엄청나게 많습니다. Goose 토론에서 가장 날카로운 질문은 동시에 가장 단순한 질문이었습니다. 프로그램이 임의의 시점에 임의의 문자열을 삽입하고 삭제하는 상황에서, 키를 문자열에 매핑하는 해시 테이블은 어떻게 만들까요? 삭제가 핵심 난제입니다. 증가만 하는 스택은 중간에 생긴 구멍을 회수할 수 없습니다. 제안된 우회 방법들, 즉 크기 클래스별 스택 하나, 주기적 압축, 그리고 “해제”가 단지 재사용 가능 표시를 의미하는 프리리스트 풀은 모두 작동합니다. 특히 풀 설계는 정말 영리합니다. 낡은 슬롯을 읽으면 메모리를 망가뜨리는 대신 올바른 타입의 다른 값을 읽게 됩니다. 하지만 각 방법은 결국 그 자료구조를 위해 프로그래머가 메모리 관리자의 한 조각을 손으로 다시 구현하는 것입니다.
메모리 사용도 비슷합니다. GC가 없으면 커진 스택은 스코프가 끝날 때까지 한 번이라도 커졌던 모든 데이터를 붙잡고 있습니다. 수명이 불규칙한 장기 실행 프로그램, 예를 들어 토론에서 나온 JSON 문서 문제, 캐시, 세션 상태 같은 것들은 스코프 중심의 패턴에 맞거나 맞지 않습니다. 맞지 않으면 아무리 우아해도 소용이 없습니다. ML Kit의 실패 양상, 즉 살아 있는 리전에 죽은 데이터가 고정되는 문제는 Goose에서도 사라지지 않습니다. 그것이 눈에 보이고 수동이 되는 것이라서 어쩌면 더 낫지만, 여전히 존재합니다. 한 댓글이 올바른 입장을 정리했습니다. Goose는 제한된 목적을 위한 언어이고, 그건 괜찮다는 것입니다. 데이터 지향 작업, 컴파일러, 게임 엔티티 시스템, 그리고 읽고, 변환하고, 내보내고, 초기화하는 파이프라인 형태의 모든 것은 아주 잘 맞습니다. 오래 살아 있는 가변 객체 그래프는 아닙니다.
헤드라인 수치에 대한 마지막 주의점도 있습니다. 자체 호스팅 벤치마크 모음은 알려진 실패 양상이 있는 장르입니다. 여러분의 최고의 Goose 코드와 누군가의 “관용적” C++을 비교하는 것이고, 관용이 바로 싸움이 시작되는 지점입니다. SSO 실수는 비교 코드가 적대적으로 튜닝되지 않았다는 것을 보여줍니다. 안전한 Rust 대비 1.12배라는 수치는 3.3배보다 오히려 흥미롭습니다. 작기 때문입니다. 이는 메모리 모델이 마법이 아니라 레이아웃과 할당 제거에서 이긴다는 뜻이고, 아레나와 인덱스를 쓰는 튜닝된 Rust 버전이라면 차이의 상당 부분을 메울 것입니다. 바로 그것이 요점입니다. 아레나 기반 Rust를 쓸 수는 있지만, 언어가 그것을 어색하게 만들고, 그 어색함이 실제로 어떤 코드가 쓰이는지를 결정합니다.

프로덕션 언어가 Goose에게서 실제로 훔쳐 갈 것들
앞으로 몇 년 안에 프로덕션 코드로 Goose를 쓰는 사람은 거의 없을 겁니다. 단일 C 백엔드를 통해 컴파일되는 1인 언어가 그 단계에 빨리 도달하지는 못하고, 제작자도 그 상태를 스스로 그렇게 말합니다. 그건 흥미로운 질문이 아닙니다. 흥미로운 질문은 어떤 아이디어가 우리가 이미 쓰는 언어로 빠져나가느냐입니다. 이런 일은 늘 그런 식으로 진행되니까요. 제 예측을 확신이 높은 순서대로 적어 보겠습니다.
- 위치 독립 구조와 검증된 로딩. 검증 단계가 있는 제로 카피 직렬화는 틈새에 머물기에는 너무 좋습니다. FlatBuffers가 어색하게 하던 일을 언어에 통합한 버전이 더 많이 나올 것입니다. 특히 데이터 중심 서비스들이 계속 파싱 비용에 부딪히면서 더욱 그럴 것입니다.
- 타입이 아니라 계열로서의 배열. “크기에는 무슨 일이 생기나?”는 “스택이냐 힙이냐?”보다 나은 조직 원칙입니다. 고정, 동결, 용량 제한, 증가 전용 같은 구분은 모든 코드베이스에 비공식 관례로 이미 존재합니다. 이것을 타입으로 만드는 비용은 낮고, 실제 버그를 잡아냅니다.
- 컴파일 타임 함수 값. Goose는 함수 값을 컴파일 타임 엔티티로 다루므로
xs.filter() { it > 0 }는 생긴 그대로의 루프로 컴파일됩니다. C++ 템플릿과 Rust 단형화도 이미 이렇게 하지만, 차이는 Goose에서는 이것이 유일한 방법이라서 런타임 함수 포인터의 함정이 완전히 사라진다는 점입니다. - 정적으로 검사되는 다중 프레임 반환.
return err from load는 N개 프레임 위의 함수에서 반환하며, 컴파일 시점에 검사됩니다. 언와인더도,Result피라미드도, 모든 호출에 붙는?도 없습니다. 이펙트 시스템도 반대 방향에서 같은 아이디어를 맴돌고 있습니다. 그중 하나는 이번 10년 안에 주류 언어에 들어갈 것입니다.
공유 없는 스레딩 모델도 언급할 가치가 있습니다. 가장 새롭지 않지만 가장 입증된 것이기 때문입니다. Erlang의 프로세스이고, Dart의 isolate이며, 이념 없는 액터 모델입니다. 이것이 계속 재발명된다는 사실은, 공유 가변 상태에 대한 업계의 합의가 이론에서는 정해졌지만 실무에서는 무시되고 있다는 뜻입니다. Goose는 적어도 그 무시를 불가능하게 만듭니다.
진짜 논점: 제약을 통한 단순함
한 발 물러서 보면 Goose의 설계는 하나의 움직임이 반복되는 것입니다. 안전성이 증명하기 싸질 때까지 표현 가능한 프로그램의 집합을 줄이는 것입니다. 힙이 없으니 힙을 가리키는 댕글링 포인터도 없습니다. 증가 전용 스택이라서 참조가 무효화될 수 없습니다. 워커를 분리한 스레드라서 레이스가 존재할 수 없습니다. 복사 없는 구성이라서 의도치 않은 임시 객체도 없습니다. 각 제약은 한 범주의 버그를 지우는 동시에, 그 버그를 관리하기 위해 존재하던 한 범주의 언어 장치를 지웁니다. 앨리어싱 규칙, 소멸자, 언와인더 같은 것들입니다.
이것은 Rob Pike의 “less is exponentially more” 주장을 장갑을 벗고 밀어붙인 것이며, Pike의 Go가 직면했던 것과 같은 긴장에 닿습니다. 제약은 그 제약이 여러분의 프로그램을 배제하기 전까지는 해방처럼 느껴지고, 배제하는 순간 벽처럼 느껴집니다. Go는 10년 동안 제네릭을 배제했고 살아남았습니다. 동시에 올바른 프로그램도 많이 배제했습니다. Goose는 훨씬 더 많이 배제합니다. 그래도 주목할 이유는, 단순함에 관한 Pike의 오래된 규칙이 잘 늙어 온 이유가 복잡성 예산이 실재하기 때문이기 때문입니다. 언어가 자동으로 제공하는 모든 보장은, 그 언어를 쓰는 모든 프로그래머가 머릿속에 들고 있지 않아도 되는 보장입니다.
그래서 borrow checker는 우회로였을까요? 범용 시스템 프로그래밍에서는 아닙니다. 임의 공유 사례에는 Rust의 표현력이 실제로 필요하고, 그 성공 덕분에 업계는 소유권을 이야기할 어휘라도 얻었습니다. 하지만 파이프라인 형태, 아레나 형태, 요청 형태의 크고 조용한 다수의 시스템 코드는 어떨까요? Goose는 의식을 훨씬 적게 치르고도 메모리 안전성, 속도, 작은 바이너리를 가질 수 있었다는 증거입니다. 대신 더 많은 프로그램에 “아니오”라고 말할 의지가 있었다면 말이죠. 언어 설계는 어떤 프로그램을 금지할지 고르는 기술입니다. Goose는 우리가 익숙한 것과 다른 프로그램을 금지하고, 그 결과는 제가 몇 년 동안 읽은 것 중 가장 흥미로운 시스템 언어 스케치입니다. 제가 이것을 실제로 배포하지는 않을 것 같습니다. 하지만 계속 여기서 훔쳐 오게 될 것이라고는 확신합니다.
Goose의 베팅은, 언어가 Rust보다 더 많은 프로그램을 금지하고도 여전히 쓸모 있을 수 있다는 것입니다. 벤치마크는 손실까지 포함해서 진지하게 고려할 만하다고 시사합니다.


