Inko의 힙/스택(인라인) 타입 설계와 대여(차용) 모델, 필드 할당 제약의 이유, 고유 타입과 이스케이프 분석의 한계, 그리고 결국 컴파일 타임 대여 검사기의 필요성에 대한 탐구.
2025년 2월 5일
Inko에서 타입을 정의하면 기본적으로 힙에 할당됩니다(다가올 0.18.0 릴리스의 문법 사용):
type User {
let @id: Int
let @username: String
let @email: String
}
User(id: 42, username: 'Alice', email: 'alice@example.com')
여기서 User는 힙에 할당되는 타입이고, User(...) 표현식은 시스템 할당자(예: malloc())를 사용해 이 타입의 인스턴스를 할당합니다. 기본을 힙 할당으로 두면 많은 유연성이 생기는데, 예를 들어 값이 대여 중이더라도 값을 이동할 수 있습니다:
let a = User(id: 42, username: 'Alice', email: 'alice@example.com')
let b = ref a # 불변 대여를 생성
let c = a # 포인터만 이동하므로, 대여 b는 여전히 유효
b.username # => 'Alice'
대여는 일종의 참조 카운팅을 사용합니다. 각 힙 타입은 기본값이 0인 "대여 카운터"를 저장합니다. 대여를 만들면 카운터가 증가하고, 대여를 폐기하면 감소합니다. 소유된 값이 드롭되기 직전에 대여 카운터가 0인지 확인하며, 0이 아니면(즉 대여가 남아 있으면) 런타임 오류(잡을 수 없는 패닉)가 발생합니다.
대신 힙 할당 타입에는 비용이 따릅니다. 할당 자체의 비용뿐 아니라, 힙 타입이 초래할 수 있는 포인터 추적(pointer chasing), 스택 할당 타입만큼의 최적화를 하지 못할 가능성 등이 있습니다. 힙 타입은 대여 카운터와 메서드 테이블 포인터(동적 디스패치를 지원하기 위해서)도 저장해야 하므로, 각 힙 타입은 최소 16바이트의 메모리가 필요합니다.
지난 몇 주 동안 저는 스택에 할당되는/인라인 타입을 정의하는 기능을 추가하고 있었습니다. 이런 타입은 inline 한정자를 사용해 정의합니다:
type inline User {
let @id: Int
let @username: String
let @email: String
}
# 이제 인스턴스는 스택에 할당됩니다.
User(id: 42, username: 'Alice', email: 'alice@example.com')
이런 타입은 힙 할당 타입과 다른 인라인 타입을 필드로 담을 수 있습니다. 힙 타입과 마찬가지로, 인라인 타입도 이동 시맨틱스와 단일 소유권의 제약을 받습니다:
type inline User {
let @id: Int
let @username: String
let @email: String
}
let a = User(id: 42, username: 'Alice', email: 'alice@example.com')
let b = a
a.username # a가 b로 이동했기 때문에 컴파일 타임 에러가 발생
인라인 타입을 대여한 다음 대여가 살아 있는 동안 그 값을 이동하면 어떻게 될까요? 확인해 봅시다:
let a = User(id: 42, username: 'Alice', email: 'alice@example.com')
let b = ref a
let c = a
b.username # => 'Alice'
c.username # => 'Alice'
컴파일 타임이나 런타임 오류를 내지 않고 프로그램은 문제없이 컴파일되고 실행됩니다. 새 변수를 만드는 대신 a에 새 값을 대입하면 어떨까요?
let mut a = User(id: 42, username: 'Alice', email: 'alice@example.com')
let b = ref a
a = User(id: 10, username: 'Bob', email: 'bob@example.com')
a.username # => 'Bob'
b.username # => 'Alice'
"이게 무슨 마술이죠?"라고 생각할 수 있습니다. 답은 간단합니다. 인라인 타입을 대여하면 그 값이 _복사_됩니다. 인라인 타입이 힙 값을 포함한다면(예: 어떤 Array) 이 힙 값들은 각각 따로 대여됩니다. 이렇게 하면 대여가 항상 유효하게 유지되고, 인라인 타입에 저장된 힙 값을 너무 일찍 드롭하는 일을 방지합니다. 이 아이디어는 Swift가 클래스(힙, 참조 카운팅)와 더불어 구조체(스택)를 지원하는 방식에서 영감을 받았습니다.
인라인 타입에는 고유한 제약이 있어 항상 최선의 선택은 아닙니다. 첫째, 인라인 타입이 힙 타입을 담은 10개의 필드를 정의한다면, 인라인 타입을 대여하는 데에 필드마다 한 번씩 총 10회의 대여 카운터 증가가 필요합니다. 따라서 힙 타입을 담는 필드 수는 최소로 유지하는 편이 좋습니다. 둘째, 인라인 타입의 필드는 새 값으로 대입할 수 없습니다. 다만 여전히 가변 값을 담을 수는 있습니다(예: 배열에 대한 가변 대여).
두 번째 제약은 놀랍고 디버깅하기 어려운 동작을 막기 위해 강제됩니다. 대여가 데이터를 _복사_하기 때문에, 필드에 새 값을 대입해도 그 대입은 대입의 수신자로 사용된 참조에만 영향을 줍니다. 예를 들어 봅시다:
type inline User {
let @id: Int
let @email: String
}
이제 코드 어딘가에 변수 user에 User가 저장되어 있고 그 이메일 주소를 업데이트하려고 한다고 가정해 봅시다. 그래서 이렇게 합니다:
user.email = 'foo'
다른 곳에서는 같은 User에 대한 또 다른 대여(혹은 소유 참조)를 사용하고 있고, 이메일 주소가 "foo"이길 기대합니다. 만약 필드에 새 값을 대입할 수 있게 허용하면, 반드시 그렇게 되지는 않습니다. 예컨대 대여를 통해 대입을 수행하면 그 대여에만 새 값이 반영됩니다. 더 정확히 말하면: 대입 이전에 생성된 모든 별칭은 옛 값을 보게 되고, 대입 이후 생성된 별칭만 새 값을 보게 됩니다.
이 동작은 매우 혼란스럽고, 일반적인 코드(예: 제네릭 코드)를 통해 눈치채기 어려운 방식으로도 도입될 수 있습니다. 이런 이유로 컴파일러는 애초에 인라인 타입의 필드에 새 값을 대입하는 것을 허용하지 않습니다.
안타깝게도 이는 인라인 타입이, 필드에 새 값을 하길 원하지만 힙 타입이 수반하는 비용은 원하지 않는 경우에 적합하지 않다는 뜻이 됩니다. 이터레이터가 좋은 예입니다. 보통 필드에 새 값을 대입해야 합니다(예: 배열의 인덱스를 진전시키기 위해). 이터레이터를 스택에 할당할 수 있다면, 최적화를 통해 (최상의 경우) 일반적인 for 루프와 비교해 추가 오버헤드가 전혀 없게 만들 수도 있습니다.
그럼 어떻게 해야 할까요? 제가 검토했던 몇 가지 옵션이 있습니다. 하나씩 살펴봅시다!
첫 번째 접근은 그냥 필드에 새 값을 대입할 수 있게 두고, 가능한 곳에서는 복사 대신 포인터로 대여하도록 _시도_하는 것입니다. Swift가 mutating 메서드를 사용할 때와 비슷합니다. 불행히도 이 접근은 여러 문제를 야기합니다:
mut SomeInlineType 같은 대여의 의미가 문맥에 따라 두 가지가 될 수 있습니다. 인라인 값 자체이거나 그 값에 대한 포인터입니다(예: 인라인 타입의 대여를 인자로 사용할 때는 포인터여야 함). 둘 다 투명하게 처리하려면 이런 해석이 필요합니다.첫 번째 문제는 특히 강조할 가치가 있습니다. 다음 예를 보세요:
type Heap {
let @inline: Inline
}
type inline Inline {
let @number: Int
fn mut update(heap: mut Heap, number: Int) {
@number = number
heap.inline = Inline(100)
}
}
let heap = Heap(Inline(10))
heap.inline.update(mut heap, 200)
heap.inline.update가 모든 별칭에 대해 필드 대입이 적용되도록 heap.inline을 제자리(in-place)에서 변경할 수 있으려면, update에 heap.inline을 포인터로 전달해야 합니다. 그러면 위와 같이 update에서 반환하기 전에 inline 필드에 새 값을 대입함으로써 메모리 안전 문제가 발생할 수 있습니다. 이 인위적인 예제에서는 곧장 크래시로 이어지지 않을 수 있지만, 결과 동작은 사실상 정의되지 않습니다: 크래시할 수도, 그냥 계속 실행될 수도, 빨래를 먹어치울 수도 있습니다.
Swift의 접근은 컴파일 타임과 런타임 검사의 조합으로 보입니다. 작동은 하겠지만, 이런 경우에 런타임 검사를 쓰는 건 오버헤드가 생길 수 있고, 디버깅도 고통스러울 가능성이 높아 별로 선호하지 않습니다.
그럼 이걸 지원하는 게 가능하긴 할까요? 가능합니다. 다만 컴파일 타임/런타임 검사와 상당한 컴파일러 복잡성이 문제가 되지 않는다면요. 저는 이런 접근을 선호하지 않으므로, 다른 옵션을 보겠습니다.
두 번째 옵션은 인라인 타입에 대한 필드 대입은 허용하지 않고 기존처럼 유지하는 겁니다. 대신 고유(Unique) 타입을 도입합니다. 고유 타입은 단 하나의 참조만 존재할 수 있는 타입입니다. Inko는 단일 소유권을 사용하므로 그 참조는 소유 참조가 됩니다. 참조가 하나뿐이니까, 필드 대입은 항상 기대대로 관찰됩니다.
저는 이를 experimental/unique-types 브랜치에서 실험해 봤지만, 이전 옵션과 비슷하게 적절한 해법은 아니라고 생각합니다. 고유 타입의 핵심 문제는 곧 그들을 고유하게 만드는 부분, 즉 오직 하나의 참조만 가질 수 있다는 점입니다.
첫째, 고유 타입이 섞이면 합성이 더 어려워집니다. 버퍼링된 writer 타입이라는 다음의 인위적인 예를 봅시다:
type BufferedWriter[T: Write] {
let @inner: T
...
}
정확한 구현은 중요하지 않습니다. 중요한 건 inner 필드가 Write 트레이트를 구현하는 어떤 타입이든 될 수 있고, Inko에서 타입 매개변수는 타입과 소유권 모두에 대해 제네릭이므로 그 타입은 소유거나 대여일 수 있다는 점입니다. 이제 이렇게 사용해 본다고 합시다:
let file = File.new(...) # File이 고유 타입이라고 가정
let writer = BufferedWriter.new(file)
...
이 코드는 file을 BufferedWriter로 _이동_합니다. 지금까지는 괜찮습니다. 고유한 File에 추가 별칭을 만들지 않았으니까요. 그런데 BufferedWriter를 다 쓴 뒤 소유한 File을 다시 얻고 싶다면? BufferedWriter에서 밖으로 이동해야 하고, 패턴 매칭으로 할 수 있습니다:
let file = match writer {
case { @inner = file } -> file
}
# 이제 다시 file을 사용할 수 있습니다
하지만 문제가 있습니다. 사용자 정의 소멸자(destructor)를 정의하지 않은 타입에서만 필드를 밖으로 이동할 수 있습니다. 그렇지 않으면 소멸자를 실행하는 게 불건전하기 때문입니다. 즉, 이 패턴이 통하느냐는 타입마다 다릅니다. 예를 들어 Inko 표준 라이브러리가 이런 BufferedWriter를 제공하는데 소멸자를 정의하므로, 위 패턴을 적용할 수 없습니다. 이런 젠장!
또 다른 접근은 file을 BufferedWriter로 이동하지 말고 대신 대여하는 겁니다. 하지만 File이 고유 타입이라면 별칭이 생기므로 대여할 수 없습니다. 고유 타입은 별칭을 허용하지 않기 때문이죠.
고유 타입의 대여를 허용하되, 그 대여가 빌린 데이터보다 오래 살지 못하도록 만들 수는 있습니다. 가장 기초적인 형태는, 그런 대여를 변수에 대입하거나, 저장(예: 필드에), 클로저가 포착하거나, 반환하는 것을 금지하는 겁니다. Inko는 이미 어느 정도 이를 구현해 고유 _값_의 대여를 허용합니다. 안타깝게도 위 예시 같은 경우에는 충분치 않습니다. BufferedWriter 타입 안에 대여를 저장할 수 있어야 하기 때문입니다.
이제 제가 마법 지팡이를 휘둘러 이런 문제들을 없앤다고 해도, 또 다른 문제가 남아 있습니다. 바로 클로저, 특히 디폴트 트레이트 메서드 안의 클로저입니다. 다음 예를 보세요:
trait Example {
fn update_in_place
fn return_closure -> fn {
fn { update_in_place }
}
}
트레이트 Example이 있고, 필수 메서드 update_in_place와 클로저를 반환하는 디폴트 메서드 return_closure가 있습니다. 클로저는 self(Inko에선 암묵적)로 update_in_place를 호출하므로 self를 포착해야 합니다. 이 트레이트를 고유 타입에 대해 구현하면, 그 타입의 인스턴스에서 return_closure를 호출하는 순간 별칭이 생겨 버려 별칭 금지 규칙을 위반합니다.
이를 건전하게 만들려면, 디폴트 트레이트 메서드와 고유 타입에 대해 정의된 메서드 안의 클로저가 self를 포착하지 못하도록 금지해야 합니다. Inko는 클로저를 광범위하게 사용하므로 디폴트 트레이트 메서드의 가치가 크게 줄고, 고유 타입을 정의하는 경험도 좌절스럽게 될 수 있습니다.
아, 그리고 앞서 update 예시에서처럼 필드 대입으로 대여를 무효화할 수 있는 문제도 여전히 있습니다.
고유 타입이 초래할 수 있는 문제는 더 많이 열거할 수 있지만, 핵심은 다음과 같습니다. 고유 타입은 합성이 잘 되지 않고, 타입 시스템에 깊고(그리고 꼭 긍정적이지만은 않은) 영향을 미칩니다. 그래서 고유 타입에 관심이 없는 코드라 해도 어떤 방식으로든 그 영향권에 들게 됩니다.
타입 시스템을 확장하는 대신, 지금 상태를 유지하고 어느 정도의 이스케이프 분석을 도입해 가능한 곳에서는 힙 할당을 스택 할당으로 바꿀 수 있습니다. 표면적으로는 양쪽 세계의 최고를 얻는 듯 보입니다. 최대한의 유연성을 유지하면서, 과학의 힘으로 컴파일러가 마법처럼 모든 것을 빠르게 만들어 줄 것처럼요. 그럴까요? 글쎄요.
지난 몇 주 간 이를 가능한 해법으로 검토해 본 바, 충분하지 않을 것 같습니다. 문제는 이스케이프 분석이 보수적 분석이라는 점입니다. 즉, 실제로는 스택에 할당해도 괜찮은 경우에도 스택 할당을 못하는 경우가 생깁니다. 예컨대 동적 디스패치를 통해 메서드를 호출하면서 인자를 전달할 때, 그 인자들이 호출보다 오래 사는지 컴파일러가 판단하지 못할 수 있고, 그 경우 잘못된 코드를 생성하지 않으려면 이들이 이스케이프한다고 가정해야 합니다.
지난 30년간의 다양한 논문에 따르면, 대부분의 경우 스택 할당은 힙 할당 중 일부 작은 비율만 스택으로 바꿔 줄 수 있고, 때때로 예외적인 결과가 있을 뿐입니다. 대부분의 논문이 특정 벤치마크에 집중하기 때문에, 실제 애플리케이션에서는 비율이 더 낮을 것이라 의심합니다. 물론 애플리케이션의 성격에 따라 크게 달라질 수 있겠지만요.
Inko가 단일 소유권과 이동 시맨틱스를 사용한다는 점은 값이 이스케이프하는지의 판정을 더 쉽게 만들 수 있지만, 지금까지 이스케이프 분석을 실제로 어떻게 구현할지 파악하는 데 애를 먹었습니다.
그렇다 해도, 대부분의 애플리케이션에서 힙 할당을 스택 할당으로 바꿀 수 있는 비율은 30% 미만일 것이라 예상합니다. 물론 아주 반가운 개선이긴 하지만, 충분한지는 장담하기 어렵습니다. 이스케이프 분석의 효과는 인라인되는 코드의 양에도 좌우되므로, 작은 코드 변경이 스택으로 치환 가능한 힙 할당의 수에 큰 영향을 줄 수 있습니다.
런타임 검사를 원하지 않고, 건전하면서도 충분히 강력한 무언가를 원한다면, 유일한 접근은 컴파일 타임 대여 검사에 의존하는 것이라 봅니다. 이는 Rust에서 볼 수 있는 접근이거나, Austral에서 볼 수 있는 접근일 수 있습니다. 저는 대여를 명시적으로 스코프에 묶는 Austral과 유사한 접근을 실험했습니다. 예를 들어 BufferedWriter 예시는 다음처럼 보일 겁니다:
let file = File.new(...) # File이 고유 타입이라고 가정
borrow mut file {
let writer = BufferedWriter.new(file)
}
여기서 borrow mut는 file을 가변으로 대여하고, 스코프 안에서 대여로 섀도잉합니다. 그러면 컴파일러는 대여가 borrow 스코프 밖으로 빠져나갈 수 없도록 보장합니다. 반환값으로 내보내거나 borrow 스코프 밖에서 정의된 값에 써 넣는 것을 금지하는 방식이죠. 구현은 대략 각 스코프에 대여 스코프 ID를 부여하고 이를 타입의 일부로 저장하는 방식이 될 겁니다. 그래서 ref SomeUser 대신 ref(N) SomeUser처럼 N이라는 생성된 스코프 ID를 포함하는 식입니다. 대여의 탈출을 막기 위해, 타입 A를 타입 B에 대해 검사할 때 A의 대여 스코프 ID가 B보다 크다(= 더 깊게 중첩되었다)면 비교를 거부합니다. 그 결과 대여를 호출 스택 아래로는 넘길 수 있지만, borrow 스코프를 넘어 위로는 결코 넘길 수 없습니다.
불행히도 이 접근은, 메서드가 어떻게 대여를 반환할 수 있는지가 불명확하다는 문제를 야기합니다. 대여 스코프를 만들어야 하고, 그 스코프에서 대여를 반환할 수 없다면 호출자에게 대여를 어떻게 반환할 수 있을까요?
제가 아는 한, 건전한 대여 검사기를 구현하려면 컴파일러를 돕기 위한 어떤 형태의 명시적 수명/영역(region) 표기가 필요합니다. 이로 인해 Rust에서 겪는 문제와 비슷한 일들이 발생합니다. 예컨대 타입에 붙은 수명이 코드 전반에 새어 나와 그런 타입을 리팩터링하기 어렵게 만드는 문제입니다. 설령 그 문제를 해결하더라도, 구현은 복잡해지고 버그의 온상이 될 가능성이 큽니다.
그렇다면 이것이 Inko에 대해 의미하는 바는 무엇일까요? 글을 쓰는 지금으로서는 확신이 없습니다. 언젠가 대여 검사기가 등장하는 것은 불가피하다고 생각합니다. 하룻밤 새 제 뇌 용량이 기적적으로 커져 더 나은 대안을 떠올리지 않는 한 말이죠. 동시에 대여 검사기를 도입하는 것도 피하고 싶습니다. 단순하면 충분히 강력하지 못하고, 강력하면 복잡해져 유지 보수 악몽이 될 것이기 때문입니다.
그때까지는 인라인 타입의 필드 대입을 허용하지 않는 현재 방침을 유지할 생각이고, 커스텀 범프 할당기 사용 같은 예전 아이디어를 재검토해 볼지도 모르겠습니다.