Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Box<T>를 사용하여 힙에 있는 데이터 가리키기

가장 단순한 스마트 포인터는 박스(box)이며, 타입 표기는 Box<T> 입니다. 박스 는 스택이 아니라 힙에 데이터를 저장하게 해 줍니다. 스택에는 힙 데이터를 가리키는 포인터만 남습니다. 스택과 힙의 차이는 4장을 떠올려 보세요.

박스는 데이터를 스택이 아니라 힙에 저장한다는 점 외에는 특별한 성능 오버헤드가 없습니다. 그렇다고 특별한 기능이 많은 것도 아닙니다. 보통 다음과 같은 상황에서 가장 자주 사용합니다.

  • 컴파일 시점에 크기를 알 수 없는 타입이 있고, 그 타입의 값을 정확한 크기를 요구하는 문맥 안에서 사용하고 싶을 때
  • 큰 데이터를 가지고 있는데 소유권은 이전하고 싶되, 그 과정에서 데이터 복사는 피하고 싶을 때
  • 어떤 값을 소유하고 싶지만, 구체적인 타입보다 특정 트레이트를 구현한 타입이라는 사실에만 관심이 있을 때

첫 번째 상황은 “박스로 재귀 타입 가능하게 만들기” 에서 보여 줍니다. 두 번째 경우에는 큰 데이터를 스택 위에서 복사하며 소유권을 옮기는 일이 오래 걸릴 수 있습니다. 이런 상황에서는 큰 데이터를 박스 안에 넣어 힙에 저장하면 성능이 좋아집니다. 그러면 스택에서는 작은 포인터 데이터만 복사되고, 실제 데이터는 힙의 같은 위치에 그대로 남습니다. 세 번째 경우는 트레이트 객체 라고 부르며, 18장의 “공통 동작을 추상화하기 위해 트레이트 객체 사용하기” 절에서 자세히 다룹니다. 그러니 여기서 배우는 내용은 나중에 다시 그대로 활용하게 됩니다!

힙에 데이터 저장하기

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 를 출력합니다. 여기서는 박스 안의 데이터에 접근하는 방식이, 그 데이터가 스택에 있을 때와 거의 비슷합니다. 다른 어떤 소유 값과 마찬가지로, bmain 끝에서 스코프를 벗어나면 박스도 해제됩니다. 이때 스택에 저장된 박스 자체와, 박스가 가리키는 힙 데이터 모두가 함께 정리됩니다.

값 하나를 힙에 두는 것만으로는 그다지 유용하지 않아서, 실제로 이런 식으로 박스만 단독으로 쓰는 경우는 많지 않습니다. 단일 i32 같은 값은 기본적으로 스택에 저장되며, 대부분의 상황에서는 그것이 더 적절합니다. 이제 박스가 아니었다면 정의할 수 없었을 타입을 어떻게 가능하게 하는지 살펴보겠습니다.

박스로 재귀 타입 가능하게 만들기

재귀 타입(recursive type) 의 값은 자기 자신의 타입 값을 다시 일부로 포함할 수 있습니다. 재귀 타입은 러스트에 문제를 일으키는데, 러스트는 컴파일 시점에 어떤 타입이 차지하는 메모리 크기를 알아야 하기 때문입니다. 그러나 재귀 타입의 값은 이론상 무한히 중첩될 수 있으므로, 러스트는 그 값이 얼마만큼의 공간을 필요로 하는지 알 수 없습니다. 박스는 크기가 알려져 있으므로, 재귀 타입 정의 안에 박스를 넣어 주면 재귀 타입도 표현할 수 있게 됩니다.

재귀 타입의 예로 cons list를 살펴보겠습니다. cons list는 Lisp 계열 함수형 언어에서 자주 등장하는 자료구조입니다. 우리가 여기서 정의할 cons list 타입은 재귀 부분을 빼면 단순하므로, 이번 예제에서 배우는 개념은 더 복잡한 재귀 타입을 다룰 때도 유용하게 적용됩니다.

cons list 이해하기

Cons list 는 Lisp 프로그래밍 언어와 그 방언에서 온 자료구조로, 중첩된 쌍들로 이루어져 있고, Lisp에서 연결 리스트에 해당하는 구조입니다. 이름은 Lisp의 cons 함수(construct function의 줄임말)에서 왔습니다. 이 함수는 두 인수로부터 새로운 쌍을 만듭니다. 값 하나와 또 다른 쌍으로 이루어진 쌍에 대해 cons 를 반복 호출하면, 재귀적인 쌍들로 구성된 cons list를 만들 수 있습니다.

예를 들어 리스트 1, 2, 3 을 가진 cons list를 괄호로 표현하면 다음과 비슷합니다.

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

cons list의 각 항목은 두 요소를 담습니다. 현재 항목의 값과, 다음 항목의 값입니다. 리스트 마지막 항목은 다음 항목 없이 Nil 이라는 값만 가집니다. cons list는 cons 함수를 재귀적으로 호출해 만들어집니다. 재귀의 바닥 조건을 나타내는 표준적인 이름이 Nil 입니다. 이것은 6장에서 다룬 “null” 혹은 “nil” 개념과는 다릅니다. 여기의 Nil 은 잘못된 값이나 부재를 뜻하는 것이 아닙니다.

cons list는 러스트에서 아주 흔한 자료구조는 아닙니다. 러스트에서 항목 목록을 다룰 때는 대부분 Vec<T> 가 더 적합합니다. 하지만 더 복잡한 재귀 자료구조는 여러 상황에서 유용합니다. 이 장에서는 cons list부터 시작하면, 박스가 재귀 자료구조를 어떻게 가능하게 하는지를 큰 방해 요소 없이 살펴볼 수 있습니다.

목록 15-2는 cons list를 표현하기 위한 enum 정의입니다. 이 코드는 아직 컴파일되지 않습니다. List 타입의 크기를 알 수 없기 때문이며, 곧 그 이유를 확인하겠습니다.

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

fn main() {}
Listing 15-2: i32 값을 담는 cons list 자료구조를 표현하려는 첫 번째 enum 정의 시도

Note: 이 예제에서는 단순화를 위해 i32 값만 담는 cons list를 구현합니다. 10장에서 논의한 제네릭을 사용했다면 어떤 타입이든 저장할 수 있는 cons list 타입으로 만들 수도 있었습니다.

1, 2, 3 리스트를 List 타입으로 저장하면 목록 15-3처럼 보이게 됩니다.

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

// --snip--

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

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

첫 번째 Cons 값은 1 과 또 다른 List 값을 담습니다. 그 List 값은 2 와 또 다른 List 를 담는 또 하나의 Cons 입니다. 그리고 그 List 값은 다시 3 과 마지막 List 값 하나를 담는 Cons 인데, 마지막 List 값은 재귀가 아닌 variant인 Nil 로, 리스트 끝을 표시합니다.

목록 15-3의 코드를 컴파일하려 하면 목록 15-4의 오류를 보게 됩니다.

$ 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: 재귀 enum을 정의하려 할 때 얻는 오류

오류는 이 타입이 “무한한 크기”를 가진다고 말합니다. 그 이유는 List 를 재귀적인 variant를 가진 타입으로 정의했기 때문입니다. 즉, 자기 자신 타입의 값을 직접 다시 안에 담고 있습니다. 그 결과 러스트는 List 값을 저장하는 데 얼마만큼의 공간이 필요한지 계산할 수 없습니다. 왜 이런 오류가 나는지 하나씩 뜯어 봅시다. 먼저는 비재귀 타입의 크기를 러스트가 어떻게 계산하는지 살펴보겠습니다.

비재귀 타입의 크기 계산하기

6장에서 enum을 설명할 때 목록 6-2에서 정의했던 Message enum을 떠올려 보세요.

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

fn main() {}

러스트가 Message 값에 얼마만큼의 공간을 할당할지 결정하려면, 먼저 각 variant를 살펴 가장 많은 공간이 필요한 variant가 무엇인지 확인합니다. Message::Quit 는 공간이 거의 필요 없고, Message::Move 는 두 개의 i32 를 담을 만큼의 공간이 필요합니다. 이런 식으로 각 variant를 비교합니다. 결국 어떤 순간에도 실제로 쓰이는 variant는 하나뿐이므로, Message 값 전체에 필요한 공간은 그중 가장 큰 variant를 저장하는 데 필요한 공간이면 됩니다.

이것을 목록 15-2의 List 같은 재귀 타입과 비교해 봅시다. 컴파일러는 먼저 Cons variant를 보는데, 이것은 i32 값 하나와 List 값 하나를 담습니다. 따라서 Consi32 크기 + List 크기만큼의 공간이 필요합니다. 그런데 List 타입이 얼마나 큰지 알아내려면, 컴파일러는 다시 List 의 variant를 들여다봐야 하고, 다시 Cons variant를 보게 됩니다. 그런데 Cons 는 또 i32List 를 포함하고 있으므로, 이 과정은 그림 15-1처럼 무한히 반복됩니다.

An infinite Cons list: a rectangle labeled 'Cons' split into two smaller rectangles. The first smaller rectangle holds the label 'i32', and the second smaller rectangle holds the label 'Cons' and a smaller version of the outer 'Cons' rectangle. The 'Cons' rectangles continue to hold smaller and smaller versions of themselves until the smallest comfortably sized rectangle holds an infinity symbol, indicating that this repetition goes on forever.

그림 15-1: 무한한 Cons variant로 이루어진 무한한 List

알려진 크기를 가진 재귀 타입 만들기

러스트가 재귀적으로 정의된 타입에 필요한 공간을 계산할 수 없기 때문에, 컴파일러는 도움이 되는 다음 제안을 담은 오류를 냅니다.

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

여기서 간접화(indirection) 란, 값을 직접 저장하는 대신 그 값을 가리키는 포인터를 저장하라는 뜻입니다.

Box<T> 는 포인터이므로, 러스트는 Box<T> 가 차지하는 공간이 얼마인지 항상 알 수 있습니다. 포인터 크기는 포인터가 가리키는 데이터 양에 따라 달라지지 않기 때문입니다. 따라서 Cons variant 안에 또 다른 List 값을 직접 넣는 대신, Box<T> 를 넣을 수 있습니다. 그러면 Box<T>Cons variant 안이 아니라 힙에 있는 다음 List 값을 가리키게 됩니다. 개념적으로는 여전히 “리스트가 다른 리스트를 담고 있는” 구조이지만, 구현 차원에서는 이제 값을 서로 안에 넣어 중첩하는 대신, 포인터로 연결 하게 된 것입니다.

목록 15-2의 List 정의와 목록 15-3의 사용 코드를 목록 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 variant는 i32 크기와 박스 포인터 데이터 크기만큼의 공간이 필요합니다. Nil variant는 아무 값도 저장하지 않으므로 Cons 보다 적은 스택 공간만 필요합니다. 이제 우리는 어떤 List 값이든 i32 하나와 박스 포인터 하나의 크기를 차지한다는 사실을 알 수 있습니다. 박스를 사용함으로써 무한 재귀 사슬을 끊었기 때문에, 컴파일러는 마침내 List 값에 필요한 크기를 계산할 수 있게 됩니다. 그림 15-2는 Cons variant가 이제 어떤 형태로 보이는지 보여 줍니다.

A rectangle labeled 'Cons' split into two smaller rectangles. The first smaller rectangle holds the label 'i32', and the second smaller rectangle holds the label 'Box' with one inner rectangle that contains the label 'usize', representing the finite size of the box's pointer.

그림 15-2: ConsBox 를 들고 있으므로 더 이상 무한 크기가 아닌 List

박스는 오직 간접 참조와 힙 할당만 제공합니다. 뒤에서 볼 다른 스마트 포인터들처럼 추가적인 특별 기능은 없습니다. 따라서 그런 특별 기능에 따르는 성능 오버헤드도 없습니다. 그래서 cons list처럼 “간접 참조” 자체만 필요할 때 유용합니다. 18장에서는 박스의 다른 활용 사례도 보게 됩니다.

Box<T> 타입은 Deref 트레이트를 구현하기 때문에 스마트 포인터입니다. Deref 덕분에 Box<T> 값은 참조처럼 취급될 수 있습니다. 또한 Box<T> 값이 스코프를 벗어나면, Drop 트레이트 구현 덕분에 박스가 가리키던 힙 데이터도 함께 정리됩니다. 이 두 트레이트는 이 장의 나머지 부분에서 다룰 다른 스마트 포인터들에게는 더욱 중요합니다. 이제 이 두 트레이트를 자세히 살펴보겠습니다.