Odin의 내장형에만 적용되는 이른바 ‘축복받은 문법’이 왜 실수나 단순한 문법 설탕이 아니라, 공통 사례를 잘 해결하고 방언과 나쁜 기본값을 줄이기 위한 의도적인 설계인지 설명한다.
2026-04-29
Odin을 그다지 좋아하지 않는 사람들에게서 흔히 듣는 말이 있는데, 대개 Odin은 “설탕투성이”이며 그건 “축복받은 타입”에만 작동하고 사용자 수준 타입으로는 대체하거나 구현할 수 없다는 이야기다. 우선 나는 이것이 꼭 “설탕”의 예라고는 생각하지 않는다. 그 용어는 보통 어떤 구성 / 기능 / 아이디어를 더 작은 형태로 줄여 쓰는 것을 뜻하는데, 그런 사람들이 Odin의 “축복받은 문법”이라고 부를 만한 많은 아이디어는 사실상 다른 누구에게도 “설탕”으로 분류되지 않을 것이다.
내장 타입을 “축복받은 문법”으로 “축복”하기로 한 이 결정은 실수가 아니었다.
나는 대안이 무엇인지, 그리고 그것을 가질 때 어떤 문제가 생기는지 알고 있었기 때문에 그것을 명시적으로 원했다. Odin은 모든 사용자 정의 데이터 타입에 임의의 문법을 허용함으로써 다음 C++이나 Rust가 되려는 것이 아니다. 그보다는 “모든 것”을 형편없이 해결할 수 있게 허용하여 방언과 나쁜 기본값 문제를 낳기보다, 평균적인 공통 사례를 아주 잘 해결하려고 할 뿐이다.
기본 문자열 타입을 교체해야 할 필요의 구체적인 예부터 시작해 보겠다. 그런 사례 중 하나는 작은 문자열에 대해서는 인라인 데이터를 가지면서도 다른/기본 문자열 타입과 동일한 상호작용 문법을 갖는 문자열 타입이 필요할 때다. 나는 이것이 애초에 발생하지 말았어야 할 문제에 대한 “최적화”라고 쉽게 주장할 수 있다.
이것은 자동 메모리 관리(즉, 기본값이 “힙”이 된다는 뜻) 그리고 문자열을 문자열 값, 문자열 빌더, 문자열의 백킹 버퍼를 한데 뭉친 과적재된 구성으로 취급하는 데서 오는 간접적인 결과다. 나는 이것에 대해서도 글을 쓴 적이 있다: String Type Distinctions. Facebook이 그들의 문자열에 대해 비슷한 “최적화”를 했을 때를 기억한다.CppCon 2016: Nicholas Ormrod “The strange details of std::string at Facebook” 그리고 메모리 사용량이 약 1% 줄었다는 것을 발견했는데, 그들의 규모에서는 결코 작은 수치가 아니지만, 동시에 문자열이 어떻게 사용되고 있었는지를 보여주기도 했다. 그 이유는 그들의 문자열 타입이 빌더와 값과 백킹(ptr+len+cap)의 혼합체였기 때문이다. 하지만 그들이 많은 추가 필드를 “겹치게” 둘 수 있었기 때문에, 인라인 저장량이 더 많은 내장을 허용하기도 했다.
Rust에서 인기 있는 사용자 라이브러리로는 cold_string::ColdString이 있다. 이 “최적화”에 대한 그 접근은 조금 더 흥미로운데, 태그된 포인터 하나만 사용해서 그것이 인라인 ASCII 또는 UTF-8 문자열이거나 Pascal String을 가리키도록 선택하기 때문이다. 문자열이 UTF-8 또는 ASCII 기준으로 8바이트 이하라면(64비트 머신에서), ColdString은 힙 메모리 사용량을 줄여 준다(일반적인 문자열 상당수가 작다고 가정하면).
Odin의 string 타입이 기본값으로 선택된 이유는 많아야 추가로 8-9바이트 정도만 “낭비”하고, 부분 문자열을 아주 쉽게 만들 수 있기 때문이다. 하지만 Odin은 수동 메모리 관리 언어이므로, 그 메모리를 어떻게 할당할지 자유롭게 선택할 수 있다. 반면 Rust에서는 대체로 많은 것이 “자동”이라고 가정되며, 그래서 일반적인 기본값은 힙이 된다. Rust도 전역 사용자 정의 할당자나 일반적인 수동 메모리 관리를 허용하긴 하지만, 그것은 언어 설계 자체가 장려하는 방향은 아니다.
Odin에는 구조체 필드를 직렬화기/서식 출력에서 사용하고 싶을 때 런타임에 문자열인 것처럼 해석되게 하는 방법도 있으며, 이것 역시 사용성에 도움이 된다. Rust에도 비슷한 것이 있지만 훨씬 더 특정한 매크로 방식이라서(따라서 컴파일 타임이 선호된다) 타입 자체와 타입의 직렬화 측면을 분리한다.
흥미로운 점은 사람들이 이른바 “축복받은 문법”을 원한다고 말하는 사용자 수준 데이터 구조의 흔한 예가 사실상 거의 언제나 어떤 종류의 사용자 정의 배열 타입이라는 점이다. 배열과 비슷한 데이터 구조가 아닌 것에 대해 이런 것을 원하는 경우는 드물고, 만약 다른 무언가를 원하더라도, 나는 그것에 어떤 형태로든 “축복받은 문법”을 주는 것은 항상 나쁜 생각이라고 주장하고 싶다. LISP 애호가들은 분명 여기서 나와 의견이 다르겠지만, 우리는 프로그래밍 전반에 대해 다른 철학적 접근을 갖고 있고, 따라서 이런 불일치가 생긴다.
하지만 이것은 무엇을 사용자 정의로 허용해야 하고 무엇은 허용하지 말아야 하는가라는 질문을 제기한다. 첫 번째는 인덱싱을 위한 연산자 오버로딩이다. Odin에서는 최소한 []를 세 가지 다른 형태로 오버로딩할 수 있어야 함을 뜻한다:
[] (예: x[i])&[] (예: &x[i])[]= (예: x[i] = y)안타깝게도 C++은 (2)와 (3)을 구분하지 않으며, 그 잘못된 결정으로 크게 고통받아 왔다. 하지만 Odin에는 슬라이싱(x[:], x[lo:hi] 등)이나 행렬 인덱싱(m[row, col]) 같은 다른 배열 인덱싱 연산도 있기 때문에, 오버로딩 가능한 항목 목록에 더 많은 것을 추가해야 하며, 결국 대략 7가지 다른 오버로딩 연산으로 이어진다(실제로는 그중 4개 정도만 쓰이겠지만).
배열류 데이터 구조를 초기화하기 위한 복합 리터럴의 문제도 있다. 즉, 오버로딩 동작이 이제 훨씬 더 복잡해져야 하며, 일반 문법보다는 매크로 시스템에서 하는 편이 더 “쉬울” 수도 있다.
Odin을 설계할 때 나는 실제로 대다수 사람이 연산자 오버로딩을 어디에 쓰는지 생각했고, 그것을 그냥 언어에 직접 구현했다. 즉, 일반 사례를 해결하기보다 공통 사례를 해결한 것이다. 그리고 내가 조사한 결과, 일반적인 사용 사례는 두 범주로 나뉘었다:
수학 타입에 대해 Odin은 배열 프로그래밍을 네이티브로 지원하고, 복소수, 행렬을 지원하며, 행렬은 Odin에서 “밀집” 형태이며 “스택 위에” 인라인으로 할당된다. 이 결정은 일정 크기보다 큰 일반화된 행렬은 다른 메모리 레이아웃과 접근 패턴으로 더 잘 최적화되기 때문에 내려졌다. 또한 #simd 연산도 지원한다. 전반적으로 이것은 언어에서 수학 타입에 대한 필요 대부분을 해결했으며, 언어가 그것들에 대한 네이티브 의미론을 가지기 때문에 그런 경우 기본적으로 더 나은 최적화까지 얻는다.
배열류 데이터 구조에 대해 Odin은 공통 사례를 포괄하는 많은 내장 데이터 구조를 제공한다:
[N]T[]T[dynamic]T[dynamic; N]T#soa 변형[^]T#simd[N]Tmap[K]V[Enum]Tmatrix[R, C]T 고정 길이 배열, simd 벡터, 행렬은 수학 타입에도 적용된다는 점에서 다소 겹치는 부분이 있다.[N]T를 제외하면, 나머지는 모두 사용자 정의 문법에 대해 “더 많은 제어”가 있는 언어라면 가설적으로 구현할 수 있겠지만, 많은 측면에서 손해를 볼 것이다.
n.b. Odin 컴파일러가 C가 아니라 C++로 작성된 아주 큰 이유 중 하나는 런타임 경계 검사가 있는 제대로 된 배열 타입을 원했기 때문이다. 그것과 몇 가지 아주 사소한 것들을 제외하면, 내 C++ 스타일은 매우 C에 가깝다.
첫 번째 예는 슬라이스 []T다. 슬라이싱 연산의 상당수는 그것이 사용자 수준에서 정의된 타입이었다면 컴파일러가 쉽게 최적화할 수 없었을 것이고, 특히 개발 빌드에서는 더더욱 그랬을 것이다. 좋은 예가 x[off:][:len] 관용구인데, 이는 C류 언어에서 (경계가 있는) 포인터 산술이 맡는 역할의 많은 부분을 대체한다. Odin 컴파일러에서 이 연산은 x[off:off+len] 하나로 최적화되는데, 이런 일은 일반화된 접근에서는 쉽게 이뤄지지 않는다. 이것이 언어 수준에 있기 때문에 수행해야 하는 런타임 경계 검사 수도 줄어든다. 컴파일러가 그런 검사들을 두 번에서 한 번으로(혹은 연쇄될 경우 그보다 더 많이) 쉽게 최적화할 수 있기 때문이다.
해시 맵 map[K]V 역시 키가 어떻게 정의되는가라는 점에서 의미론을 쉽게 처리할 수 없는 또 다른 사례다. Odin에서 해시 맵은 비교 가능한 어떤 타입이든 키로 받을 수 있다. 비교 가능한 것은 무엇이든 _해시 가능_하기 때문이다. 이것이 사용자 정의 가능했다면, 사용자는 다음 중 하나를 해야 했을 것이다:
Odin의 접근은 수동 메모리 관리 언어라는 점을 고려하여 너무 많은 가정을 하지 않으면서 공통 사례를 처리하는 것이다. 좋은 예가 map[string]V다. map은 string 기반 키의 메모리를 “관리”하지 않는다. 그 메모리 관리는 사용자의 책임이다. 만약 그것까지 관리했다면, 다른 것들(예: 비교 가능한 구조체 안의 문자열)에 대해 일관되지 않은 일반 규칙의 예외가 되었을 것이다. Odin의 방식은 비교 가능한 타입이 map의 키로 사용될 때마다 그 타입에 대한 기본 해시 함수가 생성되도록 하는 것이다(이 해시 함수는 설계상 재정의할 수 없다). 이것은 모든 사용 사례에 대해 해시 함수가 최적은 아닐 수도 있다는 뜻이지만, 대다수 경우에는 충분하고도 남을 것이다. 사용자가 정말로 해시 맵에 대해 더 특화된 무언가가 필요하다면, 기본 것을 쓰지 말고 사용자 정의 변형을 쓰는 것이 권장되며, 그 문법 역시 그것이 사용자 정의라는 점이 분명해야 한다.
마지막으로 열거 배열은 사용자 수준에서 쉽게 구현되지 않는 무언가의 훌륭한 예다. 열거 배열은 enum의 값을 고정 길이 배열의 인덱스로 사용할 수 있게 해 준다. 이것은 C에서 매우 흔한 관용구이지만 컴파일러가 강한 타입 검사를 해 주지는 않는다.
Direction :: enum{North, East, South, West}
Direction_Vectors :: [Direction][2]int {
.North = { 0, -1 },
.East = { +1, 0 },
.South = { 0, +1 },
.West = { -1, 0 },
}
assert(Direction_Vectors[.North] == { 0, -1 })
assert(Direction_Vectors[.East] == { 1, 0 })
assert(Direction_Vectors[cast(Direction) 2] == { 0, 1 })
인덱싱 문법은 연산자 오버로딩으로 쉽게 허용할 수 있겠지만, 복합 리터럴 문법은 그렇지 않다. 기본적으로 열거 배열의 복합 리터럴은 완전해야 하며, 즉 모든 경우가 초기화되어야 하고 key=value 쌍만 사용해야 한다. Odin의 다른 배열은 위치 기반 값(이 경우 완전해야 함)과 key=value 쌍(이 경우 “부분적”일 수 있음)을 모두 허용하지만, 열거 배열은 매우 다르다.
arr: [enum {A, B, C}]int
arr = #partial { // partial이 없으면 컴파일러가 불평한다
.A = 42,
}
fmt.println(arr) // [.A = 42, .B = 0, .C = 0]
말했듯이, 물론 이 모든 것은 가상의 언어에서는 가능하겠지만, 어느 것도 “쉽게” 되지는 않으며 반드시 좋은 생각인 것도 아니다.
Odin은 사용자 수준에서 “가설적으로 구현 가능”할 수 있는 다른 데이터 타입들도 지원하는데, 그 좋은 예가 bit_set이다. 이것들은 인덱싱을 지원하지 않기 때문에 배열류 범주에 딱 들어맞지는 않지만, 순회는 가능하다. 그리고 매우 유용하기 때문에 “축복받은 문법”을 갖는다. 아이러니가 아니라 진짜로 bit_set은 대개 많은 사람들이 Odin에서 가장 좋아하는 요소가 된다. enum 자체의 사용을 enum의 플래그라는 개념으로 과적재하지 않고도 플래그용 enum을 사용하는 매우 흔한 문제를 해결해 주기 때문이다.
비트 집합은 아주 오래된 아이디어로, 원래 Pascal에서도 볼 수 있다. C에 이런 타입이 한 번도 없었다는 것은 매우 안타까운 일인데, 아마 C에는 enum 타입이라는 개념 자체가 별로 없었고, 그저 1씩 암묵적으로 증가하는 상수들의 묶음만 있었기 때문일 것이다.
for 루프와 “축복받은 문법”for 루프에 관해서는, 나는 이전에 이것에 대해 쓴 적이 있다. Odin에서 매크로가 있었다면 push 기반 반복자를 통한 사용자 정의 순회를 허용할 수 있는 가능성이 있다: If Odin Had Macros. 사용자 정의 반복자에 대한 Odin의 현재 접근은 단순히 여러 반환값을 갖는 프로시저를 호출하는 것이다(pull 기반 반복자). 이런 사용자 정의 연산에서 생성, 순회, 파괴는 명시적으로 이루어진다. 그렇다, 기본 타입에서 새로운 사용자 정의 타입으로 “리팩터링”하는 일이 이제 “더 많은 작업”이 되긴 한다. 하지만 그것은 아마 다른 무언가가 벌어지고 있다는 더 나은 신호이기도 하다. 특히 그 사용자 정의 데이터 타입에 대한 순회 방식이 “자명하지” 않을 때는 더 그렇다.
나는 C류 프로그래머와 C++/Rust류 프로그래머 사이에는 일반적인 구분이 있다고 생각한다. 여기서 언어들은 예시일 뿐이며, 포괄적인 목록이 아니다. LISP 같은 다른 언어나 매크로 또는 연산자 오버로딩이 많은 어떤 언어든 충분히 포함될 수 있다. 나는 이를 각각 “실용주의 진영”과 “일반화 가능성 진영”이라고 부르겠다:
이 두 진영은 “사용성”이 무엇을 뜻하는지에 대해 서로 다른 관점을 갖고 있으며, 나는 그것이 단지 _문법_의 문제만은 아니라고 본다. 사용성은 인간을 위한 설계이며, 그것은 단지 “문법”이나 “타이핑” 따위만을 위한 설계를 의미하지 않는다. 심지어 사람들의 속도를 늦추거나 다른 무언가를 하도록 유도하는 설계일 수도 있다. 어떤 일을 언어에서 어렵게 만드는 행위는 후자 진영에게는 아마 “끔찍한 사용성”으로 분류되겠지만, 흥미롭게도 전자 진영에게는 “훌륭한 사용성”으로 보일 수 있다. 결국 사용성이라는 영역에서 무엇을 _최적화_하려고 하느냐의 문제다.
사용성 그 자체가 Design as a Human Endevaour다.
나는 분명히 _실용주의 진영_에 속해 있고, 특히 Odin의 설계에서 그렇다. 그리고 Odin을 싫어하는 많은 사람들(전부는 아니지만)은 일반화 가능성 진영에 속해 있다. 누군가가 Odin을 싫어하는 것은 완전히 괜찮고, 이 영역에서 이제 C, C++, Ada, 그리고 더 오래된 Pascal류 언어들 말고도 고를 수 있는 다른 언어들이 생긴 것이 나는 매우 기쁘다. 하지만 흥미로운 점은 일반화 가능성 진영의 많은 사람들이 다른 진영의 사고방식을 이해하려 하지 않는다는 것이다. 그 입장을 마치 어리석은 사고방식인 것처럼 쉽게 일축한다. 보통 사람들이 그런 태도를 취할 때는, 그들이 다른 사람의 입장은 고사하고 자기 자신의 입장이 가진 상충관계와 타협조차 제대로 이해하지 못하고 있다는 뜻이다.
n.b. _실용주의 진영_은 New Jersey Model of “Worse is Better”와 동치가 아니다. 나는 그것도 MIT 모델도 지지하지 않는다. 둘 다에 대해 큰 문제의식을 갖고 있으며, Odin의 설계를 보면 그것은 자명해야 한다.
Odin의 주요 설계 목표 중 일부는 방언의 가능성을 최소화하는 것과 동시에 많은 사람에게 _Joy of Programming_을 되돌려 주는 것이었다. 이 둘 모두 사용성의 문제이며, “일반화 가능성” 측면을 최소화하려는 시도의 직접적인 결과다. 모든 것이 일반화될 수 있고 기본 “관용구”가 일반적이지 않으면, 많은 사람이 The Curse of Common Lisp라고 부르는 상태로 이어진다. 제발 그 글을 계속 Hacker News에 올리지는 말아 달라! 언어 설계에 대한 _일반화 가능성 진영_의 접근이 주는 이 “힘”이 바로 그 접근의 몰락이다. 관용적 데이터 구조가 기본적으로 장려/강제되지 않으면, 사람들은 자기 것을 직접 구현하려 들거나, 심지어 이 광기를 더 키우는 쪽으로 기본값이 흘러가기도 한다. 일반화 가능한 접근은 컴파일러 오류 메시지를 더 나쁘게 만드는 경향이 있고, 최적화도 더 어렵게 만든다.
방언은 어떤 언어에서 무언가를 하는 선택지가 너무 많거나 기능이 지나치게 일반화되어, 서로 다른 사람 집단이 각기 다른 접근을 선호하게 된 결과다. 최악의 경우 모두가 자기만의 방언을 갖게 되어, 다른 사람의 코드를 사용할 수 없게 된다. Clojure 같은 일부 LISP 파생 언어는 많은 내장 관용구(데이터 구조와 함수)를 제공함으로써 리스프의 저주의 상당 부분을 완화했지만, 완전히 해결하지는 못한다. 해결할 수 없기 때문이다. C 언어 공동체에도 가장 호환성이 높도록 특정 방언을 고수하는 사람들이 있지만, 그렇다고 해서 그것이 모두에게 도움이 되는 것은 아니다.
이것이 Odin이 더 일반화된 접근으로 그것들을 구현하게 두기보다, 이렇게나 많은 서로 다른 언어 수준 내장 데이터 타입을 갖는 이유다. 그것들의 일반적인 사용을 장려하고, 모두가 이해하고 그 위에 쌓아 올릴 수 있는 공통의 관용적 토대를 갖기 위해서다. 이것이 내가 보기에 좋은 언어 설계의 징표다. 언어의 토대가 혼자 코딩하는 사람뿐 아니라 프로젝트가 커지고 더 많은 사람이 함께 일하게 될 때 어떻게 확장되는지까지 최적화하는 것이다. 혼자 프로그래밍하는 경우와 팀 사이의 균형은 설계하기 어려운 문제이며, 양쪽 모두에서 꽤 성가신 결정을 낳을 수 있다.
Go는 다양한 숙련도를 가진 대규모 프로그래머 팀을 위해 명시적으로 설계된 언어의 훌륭한 예다. 그 결정들 중 다수는 그것이 달성하려던 목표에 비추어 볼 때 훌륭하다. 하지만 바로 그 이유 때문에 개인들은 그것을 싫어하기도 한다. 사용하지 않는 변수를 허용하지 않는다든가, 1TBS 중괄호 스타일을 강제하는 것 같은 점들 말이다. 코딩 스타일을 별도의 검사 단계가 아니라 언어 수준에서 강제하는 것이다. Google 같은 큰 회사에서는 이런 사용성 결정이 말이 되지만, 더 작은 팀(혹은 혼자 작업하는 사람)에게는 이것이 극도로 비사용적인 설계 선택이 된다.
당신은 설계 선택으로 모두를 만족시킬 수 없고, 그렇게 하려고 해서도 안 된다. 그것은 서로 다른 집단 사이의 상충관계를 부드럽게 조율하는 일이며, 모두를 최대한 만족시키려 하면 아무도 만족시키지 못한다. 근본적으로 설계에서 모든 것은 상충관계이며, 그것이 바로 언어 설계자의 핵심 역할이다. 당신의 불완전한 언어를 위해 어떤 상충관계와 타협을 선택할지 정하는 것.
무엇을 선택하는지 조심하라. 그 결과가 마음에 들지 않을 수도 있으니까.