요약
힙 할당기도 가비지 컬렉터도 없이 메모리 안전을 노린 시스템 언어 Goose가 공개됐습니다.
Lobster 언어를 만든 바우터르 판오르트메르선이 오래전 구상한 설계를 바탕으로 했습니다. 컴파일러 구현과 벤치마크, 문서 작성은 대부분 Claude Fable이 맡았다고 밝혔습니다.
왜 중요한가
- 수명 표기(참조가 언제까지 유효한지 적는 표시)나 unsafe 없이 안전한 참조를 보장하겠다는 점에서 Rust와 다른 길을 택했습니다.
- 사람이 언어를 설계하고 AI가 약 3만 줄짜리 C++ 컴파일러를 거의 다 짠 사례라는 점도 눈길을 끕니다.
핵심 내용
- 모든 동적 값은 컴파일러가 관리하는 데이터 스택에 그대로 놓이고, 스코프를 벗어나면 저장 한 번으로 해제됩니다.
- 16개 벤치마크에서 평범한 C++보다 3.3배, 가장 잘 짠 안전한 Rust보다 1.12배 빠르다고 주장합니다.
- 예시 주문 레코드는 C++에서 160바이트와 할당 1번이 들지만 Goose에서는 할당 없이 29바이트라고 합니다.
- 스레드는 메모리를 공유하지 않고 참조 없는 값만 큐로 복사해 주고받으므로 락과 원자 연산이 필요 없습니다.
- 대신 데이터의 소유와 수명 범위를 직접 설계해야 하고, 함수 포인터나 동적 디스패치는 쓸 수 없습니다.
HN 반응
- 크기가 제각각인 문자열을 자유롭게 넣고 빼려면 할당기를 손수 짜는 셈이라는 지적에, 작성자는 풀 기능으로 대응한다고 답했습니다.
- 문서를 AI가 써서 어디까지가 작성자의 설계인지 알기 어렵다며 직접 다시 쓰라는 요구가 나왔습니다.
참고로 이 언어는 제가 만들었습니다. 뭐든 물어보세요. 저장소: https://github.com/aardappel/goose
저라면 문서를 전부 버리고 직접 쓰신 말로 처음부터 다시 쓰겠습니다. 기계가 코드 대부분, 심지어 전부를 쓰는 시대라는 건 압니다. 그건 받아들일 수 있습니다. 다만 이 프로젝트를 쓰고 확장할 생각이라도 해 보려면, 설계 중 어디까지가 당신의 의도이고 어디부터가 Claude가 우연히 만든 결과인지는 알아야 합니다. Claude 말투로 쓰인 글에서는 그걸 알 수가 없습니다.
인터넷에서나 그렇지 현실에서는 아닙니다. 저는 이런 도구들이 일을 제대로 해낸다고 믿지 않고 믿을 수도 없습니다. 제가 시킨 일 중에 성공한 경우가 단 한 번도 없었거든요. 제가 거래하는 회사 중에도 그렇다고 주장하는 곳은 없습니다.
저는 일주일에 한 번 Antigravity(3.8-flash)에게 지난주에 바뀐 코드 주석과 문서를 전부 다시 쓰게 합니다. GPT도 Claude도 사람처럼 쓰지는 못하니까요.
제가 정말 보고 싶은 문서는 문학적 프로그래밍(Literate Programming) 방식으로, 코드를 밑바닥부터 차근차근 설명해 주는 버전입니다.
이게 LLM에도 실제로 도움이 되는 모양입니다.
https://silly.business/blog/we-should-revisit-literate-programming-in-the-agent-era/
전에 여기서 논의된 적도 있고요.
https://news.ycombinator.com/item?id=47300747
링크에 있는 이 문장이 무슨 뜻인가요?
마지막 문장이 도무지 해석이 안 됩니다.
벡터나 문자열처럼 크기가 가변인 것을 다루려면 C++는 대개 그 내용을 동적으로 할당해야 한다는 뜻입니다. 그러면 여러 번의 할당에 걸쳐 64비트 포인터가 가득한 자료구조가 만들어집니다.
서로 다른 크기의 값을 같은 공간에 담을 수 있는 Rust의 "enum"조차 가장 작은 변형이 가장 큰 변형만큼 자리를 차지하는 대가를 치르고 그렇게 합니다.
Goose는 이 모든 걸 인라인(포인터 없음)으로 처리할 수 있을 뿐 아니라 크기도 가변이라서, 작은 항목은 그만큼 공간을 덜 씁니다. 직렬화된 데이터 표현과 비슷한데 순회할 수 있는 셈이죠. 이 언어들에 비해 공간을 크게 아낄 수 있습니다.
제가 이해한 바로는 동적 크기 변수마다 사실상 새 스택을 만드시는 것 같습니다(그렇지 않으면 이동 없이 범프 할당만으로 커질 수 있는 게 없을 테니까요). 그렇다면 객체마다 가상 메모리 할당을 따로 만든다는 뜻인데, 그러면 (아마도?) 크기가 OS의 최소 페이지 크기(4KiB 정도)로 올림될 겁니다.
공간 낭비가 크지 않나요? 아니면 제가 놓친 부분이 있나요?
또 이를 피하는 요령이 따로 없다면, 모든 할당이 OS 커널을 거쳐야 할 것 같습니다.
놓치고 계신 부분은, 일종의 생존 분석(liveness analysis)으로 동적 크기 변수가 동시에 최대 몇 개나 "깨어 있는지" 알아낼 수도 있다는 점 같습니다. 그러면 그 수만큼만 스택을 만들면 됩니다. "잠든" 변수의 스택은 재사용할 수 있으니까요("깨어 있는" 변수는 자기 블록의 스코프를 벗어나면 반드시 해제되고, 스레드끼리는 메모리를 공유하지 않으니 이 스택들에 대한 데이터 레이스도 걱정할 필요가 없습니다).
예를 들어 늘어나는 배열을 가진 함수가, 그 배열을 넘기지 않고 다른 함수를 호출한다고 해 봅시다. 호출된 함수도 임시 작업 공간으로 늘어나는 배열을 씁니다. 그러면 두 번째 함수가 실행되는 동안 첫 번째 함수의 배열은 쓰이지 않으니(잠든 상태이니), 안쪽 함수의 배열은 같은 스택을 안전하게 공유할 수 있습니다. 두 번째 함수가 끝날 때 포인터 하나만 되돌리면 전부 해제되니까요.
사실 저는 컴파일러가 어떻게 동작하는지 모릅니다. 다만 제 이해로는 필요한 스택 수를 최적화하려고 제 예시와 비슷한 일을 하는 것 같습니다(제 이해가 현실과 맞는지는 WvO가 확인해 주기를 기다려 보죠).
최상위 수준의 늘어나는 배열만 자기 "스택"을 가집니다. 배열의 배열을 만들면 스택은 하나만 씁니다.
안쪽 스택이 커지는 문제를 어떻게 다룰지는 경우에 따라 다릅니다. 이상적으로는 커지지 않게(생성 시에만 채우게) 하거나, 고정 크기로 만들거나, 용량을 지정할 수 있고, 최악의 경우엔 풀(pool)로 쓰면 됩니다. 언어에 이 모든 방법을 위한 기능이 있습니다.
"풀" 경우는 어떤 건가요? 함수가 일반 힙이나 가상 메모리에 늘어나는 아레나를 할당하는 건가요?
https://aardappel.github.io/goose/docs/reference.html#14-reusable-pools
AI가 생성한 이 문서는, 완전한 예시를 이루는 경우가 아니면 숫자를 넣지 말라고 지시하면 훨씬 이해가 잘 될 것 같습니다.
중첩된 가변 크기 자료구조의 메모리 레이아웃을 좀 더 명확하게 설명해 줬으면 좋겠습니다. 제가 파악한 바로는 최대 용량이 생성 시점에 정해지는데, 그러면 이 모든 게 훨씬 덜 흥미로워지거든요.
저는 "레코드가 ((160바이트와 할당 하나)(C++에서)) 이고 ((29바이트와 할당 없음)(Goose에서)) 이다"로 해석했습니다.
그러니까 "C++는 160바이트와 할당 하나를 쓰는데, Goose는 29바이트에 할당이 필요 없다"는 뜻으로 읽었습니다.
아토믹 없이 이 복사를 어떻게 하죠?
워커는 스레드로 돌아가나요, 프로세스로 돌아가나요?
그리고 Rust나 C++보다 빠르다고 주장하시는데, 어떤 종류의 소프트웨어에서는 분명히(그것도 상당히) 더 느리겠죠?
복사는 런타임이 하는데, 그 밑에서는 당연히 아토믹을 씁니다. 다만 사용자가 직접 쓸 일은 없습니다.
워커는 스레드입니다. 메모리를 분리하는 것이 동시성 모델이지만, 어디까지나 단일 프로세스에 여러 스레드입니다.
"더 빠르다"는 평균적으로 그렇다는 말입니다. 제 벤치마크에서도 독립적인 벤치마크에서도 더 느린 알고리즘 사례를 찾을 수 있지만 대체로 예외입니다. "상당히" 느린 경우는 아직 못 찾았습니다.
https://kostya.github.io/LangArena/#/top_tab
그 사이트는 AI가 얼마나 잘 구현하는지를 벤치마킹하는 것일 뿐이고, 언어가 얼마나 "빠른지"와는 아무 상관이 없습니다.
Goose에 더 익숙하신 분께 묻고 싶습니다. 임의 개수의 동적 크기 값은 어떻게 표현하나요? 스택 수가 고정이라면 한 번에 크기가 바뀔 수 있는 값도 정해진 개수뿐일 것 같은데요.
예를 들어 값이 문자열인 해시 테이블에서 프로그램이 임의의 문자열을 무작위로 넣었다 지웠다 한다면 어떻게 표현하죠?
쉽게 표현할 수 있는 게 아닌 것 같습니다. 제가 떠올릴 수 있는 유일한 방법은 모든 문자열을 담는 스택을 하나 두고 주기적으로 압축하는 것입니다.
수정: 크기별로 스택을 따로 둘 수도 있겠네요. 8바이트 미만, 16바이트 미만 등으로 각각 하나씩요. 하지만 그러면 사실상 malloc을 손으로 직접 구현하는 셈입니다.
수정: 참고로 Goose는 정말 멋지다고 생각합니다! 저도 메모리 관리 속도를 높이려고 Rust에서 비슷한 걸 손으로 많이 만들어야 했거든요.
맞습니다. 이 언어는 수명이 비슷한 것들을 배열 안에 함께 모아 두도록 데이터 사용을 구조화할 수 있을 때 이점이 가장 큽니다.
예를 들어 가변 크기 노드로 이루어진 트리 전체를 배열 하나 안에 쌓아 올릴 수 있습니다.
흥미로워지는 건 트리 노드를 임의로 삭제해야 하는데 스택 순서대로 삭제하도록 구조화할 수 없는 경우입니다. 이를 위해 언어에 여러 도구가 있습니다. 용량이 있는 고정 배열, 단일 요소나 연속된 요소 묶음의 풀 같은 것들이죠.
저는 Goose를 잘 모르지만, 스택을 하나만 정의해서 거기에 동적 크기 값을 넣으면 될 것 같습니다. 말하자면 Goose의 스택 위에 메모리 할당기를 다시 짜는 거죠. 여기서 그렇게 하고 있는 것 같고요: https://github.com/aardappel/goose/blob/master/samples/13_linked_list.goose
네, 프리 리스트(freelist) 방식으로 보입니다. 할당하는 값이 전부 고정 크기라면 그걸로 됩니다. 크기가 제각각인 값을 원한다면 훨씬 복잡한 게 필요하고요.
그 예제는 정수 연결 리스트를 구현한 것입니다. 문자열 연결 리스트는 어떻게 만들지 궁금합니다.
Goose에는 표준 라이브러리가 제공하는 dictionary<K, V>라는 내장 제네릭 해시 테이블이 있습니다.
https://aardappel.github.io/goose/docs/stdlib.html#dictionary
링크에 따르면
라고 하니, 제가 찾는 건 아닙니다.
문자열은 따로 저장하고 딕셔너리가 그 참조를 들고 있게 하면 됩니다.
개념적으로는 이렇습니다.
dictionary<i64, StringReference>
이건 그냥 인덱스 아레나 패턴을 다시 발명하는 것 아닌가요? 그러면 Rust 대비 이점의 상당 부분이 사라질 텐데요.
이 경우라면 값을 고정 길이 문자열로 만들 수도 있겠죠. 어디선가 성능과 메모리를 맞바꾸게 마련입니다.
"아무것도 공유하지 않는 스레드. 워커는 자기만의 메모리를 가진 별도의 프로그램으로 컴파일되며, 평평한(flat) 값은 타입이 있는 큐를 통해 memcpy로 넘어갑니다. 데이터 레이스, 락, 아토믹, 메모리 순서 같은 것은 언어에 존재하지 않습니다."
이걸 보니 Dart의 아이솔레이트(Isolate)가 떠오르네요. https://dart.dev/language/isolates
ML 쪽 사람들이 이 길을 갔던 걸로 기억합니다. 리전(region)이라고 불렀던 것 같습니다.
제가 문헌에서 조금 읽은 바로는, 메모리를 훨씬 많이 쓰는 프로그램이 꽤 있어서 이 접근법이 막다른 길이라고 결론 냈던 것으로 기억합니다.
더 자세한 내용을 아는 분 계십니까?
네, ML Kit의 리전 추론(region inference)이 아마 Goose에 가장 가까운 선행 사례일 겁니다.
다만 당시에는 Goose처럼 넓은 64비트 주소 공간에 기댈 수 없었습니다. 그래서 메모리 블록의 연쇄로 이 모든 것을 구현했고, 지금 Goose가 컴파일 타임에 하는 일의 대부분을 런타임에 추적했습니다.
https://github.com/aardappel/goose/blob/master/docs/design/prior_art.md