힙에 데이터를 저장하기 위해 Box<T> 사용하기

가장 기본적인 스마트 포인터는 _박스_이며, 타입은 Box<T>로 표기한다. 박스를 사용하면 데이터를 스택 대신 힙에 저장할 수 있다. 스택에 남는 것은 힙 데이터를 가리키는 포인터뿐이다. 스택과 힙의 차이에 대해 다시 확인하려면 4장을 참고한다.

박스는 데이터를 힙에 저장한다는 점 외에는 성능 오버헤드가 없다. 하지만 추가 기능도 많지 않다. 주로 다음과 같은 상황에서 사용한다:

  • 컴파일 시점에 크기를 알 수 없는 타입이 있고, 정확한 크기가 필요한 컨텍스트에서 해당 타입의 값을 사용하려는 경우
  • 대량의 데이터가 있고, 소유권을 이전하되 데이터가 복사되지 않도록 보장하고 싶은 경우
  • 특정 타입이 아닌 특정 트레잇을 구현하는 타입의 값을 소유하고 싶은 경우

첫 번째 상황은 “박스를 사용한 재귀 타입 구현”에서 설명한다. 두 번째 경우, 대량의 데이터를 스택에서 복사하면 소유권 이전에 시간이 오래 걸린다. 이때 박스를 사용해 데이터를 힙에 저장하면 성능을 개선할 수 있다. 그러면 스택에서는 작은 포인터 데이터만 복사되고, 힙에 있는 데이터는 그대로 유지된다. 세 번째 경우는 _트레잇 객체_라고 하며, 18장의 “다양한 타입의 값을 허용하는 트레잇 객체 사용”에서 자세히 다룬다. 여기서 배운 내용을 해당 섹션에서 다시 활용하게 될 것이다!

Box<T>를 사용해 힙에 데이터 저장하기

Box<T>가 힙 저장소를 사용하는 경우에 대해 논의하기 전에, 먼저 문법과 Box<T>에 저장된 값과 상호작용하는 방법을 살펴보자.

리스트 15-1은 i32 값을 힙에 저장하기 위해 박스를 사용하는 방법을 보여준다.

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: 박스를 사용해 i32 값을 힙에 저장하기

변수 b를 정의하고, 이 변수는 힙에 할당된 값 5를 가리키는 Box의 값을 갖는다. 이 프로그램은 b = 5를 출력한다. 이 경우, 박스 안의 데이터에 접근하는 방식은 스택에 데이터가 있을 때와 유사하다. 소유된 값과 마찬가지로, 박스가 스코프를 벗어나면 (예: main 함수의 끝에서 b가 스코프를 벗어나는 경우) 박스와 그 박스가 가리키는 데이터 모두 메모리에서 해제된다. 이때 박스는 스택에 저장되고, 데이터는 힙에 저장된다.

단일 값을 힙에 저장하는 것은 그리 유용하지 않기 때문에, 이런 방식으로 박스를 단독으로 사용하는 경우는 드물다. 기본적으로 스택에 저장되는 i32와 같은 값은 대부분의 상황에서 더 적합하다. 박스가 없으면 정의할 수 없는 타입을 박스를 통해 정의할 수 있는 경우를 살펴보자.

박스를 사용한 재귀 타입 활성화

_재귀 타입_은 자신의 일부로 동일한 타입의 값을 가질 수 있다. Rust는 컴파일 타임에 타입이 차지하는 공간을 알아야 하는데, 재귀 타입의 값이 이론적으로 무한히 중첩될 수 있기 때문에 Rust는 값이 필요한 공간을 알 수 없다. 박스는 크기가 정해져 있으므로, 재귀 타입 정의에 박스를 삽입함으로써 재귀 타입을 활성화할 수 있다.

재귀 타입의 예로 _cons 리스트_를 살펴보자. 이는 함수형 프로그래밍 언어에서 흔히 볼 수 있는 데이터 타입이다. 우리가 정의할 cons 리스트 타입은 재귀를 제외하면 간단하므로, 이 예제에서 다루는 개념은 재귀 타입과 관련된 더 복잡한 상황을 다룰 때 유용할 것이다.

콘스 리스트에 대한 추가 정보

_콘스 리스트_는 Lisp 프로그래밍 언어와 그 방언에서 유래한 데이터 구조다. 중첩된 쌍으로 구성되며, Lisp 버전의 연결 리스트라고 볼 수 있다. 이름은 Lisp의 cons 함수(_construct function_의 약어)에서 비롯됐다. 이 함수는 두 인자를 받아 새로운 쌍을 생성한다. 값과 다른 쌍으로 이루어진 쌍에 cons를 호출하면 재귀적인 쌍으로 구성된 콘스 리스트를 만들 수 있다.

예를 들어, 리스트 1, 2, 3을 포함하는 콘스 리스트를 의사 코드로 표현하면 다음과 같다. 각 쌍은 괄호로 묶여 있다:

(1, (2, (3, Nil)))

콘스 리스트의 각 항목은 두 요소를 포함한다: 현재 항목의 값과 다음 항목. 리스트의 마지막 항목은 Nil이라는 값만 포함하며, 다음 항목은 없다. 콘스 리스트는 cons 함수를 재귀적으로 호출해 생성한다. 재귀의 기본 사례를 나타내는 표준 이름은 Nil이다. 이는 6장에서 다룬 “null“이나 “nil” 개념과는 다르다. 그 개념은 유효하지 않거나 존재하지 않는 값을 의미한다.

콘스 리스트는 Rust에서 자주 사용되는 데이터 구조가 아니다. Rust에서 항목 리스트가 필요할 때는 대부분 Vec<T>를 사용하는 것이 더 나은 선택이다. 다른 복잡한 재귀 데이터 타입은 다양한 상황에서 유용하지만, 이 장에서는 콘스 리스트를 시작점으로 삼아 박스가 어떻게 재귀 데이터 타입을 정의할 수 있게 하는지 탐구한다.

Listing 15-2는 콘스 리스트를 표현하기 위한 열거형 정의를 보여준다. 이 코드는 아직 컴파일되지 않는다. List 타입의 크기를 알 수 없기 때문이다. 이 문제를 다음에 설명한다.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: i32 값을 담는 콘스 리스트 데이터 구조를 표현하기 위한 열거형 정의 시도

참고: 이 예제에서는 i32 값만 담는 콘스 리스트를 구현한다. 10장에서 논의한 제네릭을 사용해 어떤 타입의 값이라도 저장할 수 있는 콘스 리스트 타입을 정의할 수도 있다.

List 타입을 사용해 리스트 1, 2, 3을 저장하면 Listing 15-3과 같은 코드가 된다.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: List 열거형을 사용해 리스트 1, 2, 3 저장하기

첫 번째 Cons 값은 1과 또 다른 List 값을 담고 있다. 이 List 값은 2와 또 다른 List 값을 담고 있는 또 다른 Cons 값이다. 이 List 값은 3List 값을 담고 있는 또 하나의 Cons 값이며, 마지막으로 리스트의 끝을 나타내는 비재귀적인 변형인 Nil이 된다.

Listing 15-3의 코드를 컴파일하려고 하면 Listing 15-4와 같은 오류가 발생한다.

Filename: output.txt
$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: 재귀 열거형을 정의하려고 할 때 발생하는 오류

오류는 이 타입이 “무한한 크기를 가진다“고 알려준다. 그 이유는 List를 재귀적인 변형으로 정의했기 때문이다: 이 변형은 자신과 동일한 타입의 값을 직접 담고 있다. 결과적으로 Rust는 List 값을 저장하기 위해 얼마나 많은 공간이 필요한지 계산할 수 없다. 이 오류가 발생하는 이유를 자세히 살펴보자. 먼저 Rust가 비재귀 타입의 값을 저장하기 위해 필요한 공간을 어떻게 결정하는지 알아본다.

비재귀 타입의 크기 계산

6장에서 열거형 정의를 다룰 때 Listing 6-2에 정의한 Message 열거형을 떠올려 보자:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Message 값에 얼마나 많은 공간을 할당해야 하는지 결정하기 위해, Rust는 각 변형을 살펴보며 가장 많은 공간이 필요한 변형을 확인한다. Rust는 Message::Quit가 공간을 전혀 필요로 하지 않고, Message::Move는 두 개의 i32 값을 저장할 수 있는 충분한 공간이 필요하다는 것을 파악한다. 하나의 변형만 사용되기 때문에, Message 값이 필요로 하는 최대 공간은 가장 큰 변형을 저장하는 데 필요한 공간이 된다.

이를 Rust가 Listing 15-2의 List 열거형과 같은 재귀 타입의 크기를 결정하려 할 때의 동작과 비교해 보자. 컴파일러는 i32 타입의 값과 List 타입의 값을 포함하는 Cons 변형부터 살펴본다. 따라서 Consi32의 크기와 List의 크기를 합한 만큼의 공간이 필요하다. List 타입이 얼마나 많은 메모리를 필요로 하는지 알아내기 위해, 컴파일러는 Cons 변형부터 시작해 변형들을 살펴본다. Cons 변형은 i32 타입의 값과 List 타입의 값을 포함하며, 이 과정은 무한히 반복된다. 이는 그림 15-1에서 보여주는 것과 같다.

An infinite Cons list

그림 15-1: 무한한 Cons 변형으로 구성된 무한한 List

Box<T>를 사용해 크기가 정해진 재귀 타입 만들기

Rust는 재귀적으로 정의된 타입에 얼마나 많은 공간을 할당해야 하는지 계산할 수 없기 때문에, 컴파일러는 다음과 같은 유용한 제안과 함께 오류를 발생시킨다.

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

여기서 **간접 참조(indirection)**란 값을 직접 저장하는 대신, 값에 대한 포인터를 저장해 데이터 구조를 변경하는 것을 의미한다.

Box<T>는 포인터이기 때문에 Rust는 항상 Box<T>가 얼마나 많은 공간을 필요로 하는지 알고 있다. 포인터의 크기는 가리키는 데이터의 양에 따라 변하지 않는다. 이는 Cons 변형 안에 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있음을 의미한다. Box<T>는 힙에 위치한 다음 List 값을 가리킬 것이며, Cons 변형 안에 직접 들어가지 않는다. 개념적으로는 여전히 리스트가 리스트를 포함하는 방식으로 리스트를 생성하지만, 이 구현은 이제 아이템들이 서로 안에 들어가는 대신 나란히 배치되는 것과 더 유사하다.

Listing 15-2의 List 열거형 정의와 Listing 15-3의 List 사용을 Listing 15-5의 코드로 변경하면 컴파일이 가능해진다.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listing 15-5: Box<T>를 사용해 크기가 정해진 List 정의

Cons 변형은 i32의 크기에 더해 박스의 포인터 데이터를 저장할 공간이 필요하다. Nil 변형은 값을 저장하지 않으므로 Cons 변형보다 더 적은 공간이 필요하다. 이제 모든 List 값이 i32의 크기에 박스의 포인터 데이터 크기를 더한 만큼의 공간을 차지한다는 것을 알 수 있다. 박스를 사용함으로써 무한한 재귀 체인을 끊었기 때문에, 컴파일러는 List 값을 저장하는 데 필요한 크기를 계산할 수 있다. 그림 15-2는 이제 Cons 변형이 어떻게 보이는지를 보여준다.

A finite Cons list

그림 15-2: ConsBox를 포함함으로써 무한한 크기가 아닌 List

박스는 간접 참조와 힙 할당만 제공하며, 다른 스마트 포인터 타입에서 볼 수 있는 특별한 기능은 없다. 또한 이러한 특별한 기능으로 인한 성능 오버헤드도 없기 때문에, 간접 참조만 필요한 경우(예: cons 리스트)에 유용하다. 박스의 더 많은 사용 사례는 18장에서 살펴볼 것이다.

Box<T> 타입은 스마트 포인터이다. Deref 트레잇을 구현하기 때문에 Box<T> 값을 참조처럼 다룰 수 있다. Box<T> 값이 스코프를 벗어나면 박스가 가리키는 힙 데이터도 Drop 트레잇 구현 덕분에 정리된다. 이 두 트레잇은 이 장에서 다룰 다른 스마트 포인터 타입의 기능에서 더 중요하게 작용할 것이다. 이 두 트레잇을 더 자세히 살펴보자.