Rust 기초: Box로 힙에 데이터 저장하기
Rust에서 대부분의 값은 스택에 저장됩니다. 스택은 빠르고 효율적이지만 컴파일 시점에 크기를 알 수 있는 데이터만 다룰 수 있다는 제약이 있죠. 그런데 프로그래밍을 하다 보면 크기를 미리 알 수 없는 데이터를 다루거나 큰 데이터를 복사 없이 전달하고 싶을 때가 있습니다.
이럴 때 쓰는 게 바로 Box입니다.
Box는 Rust에서 가장 단순하면서도 자주 쓰이는 스마트 포인터로, 데이터를 힙 메모리에 저장하고 그 포인터를 스택에 두는 방식으로 동작해요.
이 글에서는 Box가 무엇이고 언제 필요한지, 실전에서 어떻게 활용하는지 예제와 함께 알아보겠습니다.
스택과 힙 메모리의 차이는 소유권과 빌림 글에서 다루고 있으니 참고해보세요.
Box란?
Box<T>는 타입 T의 값을 힙에 저장하고 그 힙 데이터를 가리키는 포인터를 스택에 유지하는 스마트 포인터입니다.
Box::new()로 생성하면 값이 힙에 할당되고, Box가 스코프를 벗어나면 힙 메모리가 자동으로 해제돼요.
fn main() {
let num = Box::new(42);
println!("값: {num}");
}
값: 42
Box::new(42)는 정수 42를 힙에 저장하고, num은 그 힙 메모리를 가리키는 포인터입니다.
여기서 눈여겨볼 점은 num을 일반 정수처럼 사용할 수 있다는 건데요, Rust의 역참조 강제(Deref coercion) 덕분입니다.
Box가 Deref 트레이트를 구현하고 있어서 내부 값에 자동으로 접근되는 거죠.
Box는 다른 스마트 포인터와 달리 런타임 오버헤드가 없습니다.
참조 카운팅 같은 추가 메커니즘 없이 순수하게 힙에 데이터를 올려놓는 것이 전부이기 때문이에요.
왜 Box를 사용할까?
단순히 정수 하나를 힙에 올리는 건 별로 유용하지 않아 보이죠.
그렇다면 Box는 실제로 언제 필요할까요?
컴파일 시점에 크기를 알 수 없는 타입을 다룰 때, 큰 데이터의 소유권을 복사 없이 이전할 때, 그리고 트레이트 객체를 사용할 때가 대표적입니다. 하나씩 살펴보겠습니다.
재귀 타입 만들기
Box가 반드시 필요한 대표적인 상황은 재귀적인 자료구조를 정의할 때입니다.
연결 리스트를 한번 생각해볼까요? 각 노드가 값과 다음 노드를 가리키는 포인터로 이루어진 구조인데요. 이걸 Rust의 열거형으로 표현하면 이렇습니다.
enum List {
Node(i32, List), // 컴파일 오류!
Empty,
}
이 코드는 컴파일되지 않습니다.
List 안에 List가 들어가 있으니 컴파일러가 이 타입의 크기를 계산할 수 없기 때문이죠.
Node는 i32 + List이고 그 List는 또다시 i32 + List이고… 이런 식으로 끝없이 커져서 크기가 무한대가 됩니다.
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Node(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
오류 메시지가 친절하게 Box를 쓰라고 알려주고 있네요.
Box로 감싸면 재귀 부분이 포인터가 되어 크기가 고정됩니다.
enum List {
Node(i32, Box<List>),
Empty,
}
fn main() {
let list = List::Node(1,
Box::new(List::Node(2,
Box::new(List::Node(3,
Box::new(List::Empty)
))
))
);
print_list(&list);
}
fn print_list(list: &List) {
match list {
List::Node(val, next) => {
print!("{val} -> ");
print_list(next);
}
List::Empty => println!("끝"),
}
}
1 -> 2 -> 3 -> 끝
Box<List>는 포인터이기 때문에 크기가 항상 8바이트(64비트 시스템 기준)로 고정됩니다.
덕분에 컴파일러가 Node의 크기를 i32(4바이트) + Box<List>(8바이트)로 계산할 수 있게 되는 거죠.
이진 트리도 같은 원리로 만들 수 있어요.
enum Tree {
Leaf(i32),
Branch(Box<Tree>, Box<Tree>),
}
fn sum(tree: &Tree) -> i32 {
match tree {
Tree::Leaf(val) => *val,
Tree::Branch(left, right) => sum(left) + sum(right),
}
}
fn main() {
let tree = Tree::Branch(
Box::new(Tree::Branch(
Box::new(Tree::Leaf(1)),
Box::new(Tree::Leaf(2)),
)),
Box::new(Tree::Leaf(3)),
);
println!("합계: {}", sum(&tree));
}
합계: 6
연결 리스트나 이진 트리, 그래프 같은 재귀적 자료구조에서 Box는 사실상 필수입니다.
큰 데이터 이동하기
큰 구조체를 함수에 전달할 때 소유권이 이동하면서 데이터가 스택에서 복사될 수 있는데요.
Box를 사용하면 데이터는 힙에 한 번만 저장되고 포인터만 이동하니까 훨씬 효율적입니다.
struct LargeData {
buffer: [u8; 10000],
}
fn process(data: Box<LargeData>) {
println!("버퍼 첫 번째 값: {}", data.buffer[0]);
}
fn main() {
let data = Box::new(LargeData {
buffer: [42; 10000],
});
// 8바이트 포인터만 이동, 10000바이트 복사 없음
process(data);
}
Box가 없다면 LargeData(10000바이트)가 통째로 스택에 복사될 수 있습니다.
Box를 쓰면 8바이트 포인터만 이동하면 되니까 훨씬 효율적이죠.
물론 일반적인 크기의 구조체에서는 이런 차이가 미미합니다. 하지만 배열이나 큰 버퍼를 다루는 시스템 프로그래밍에서는 꽤 유의미한 차이가 될 수 있어요.
트레이트 객체
Box의 또 다른 중요한 용도는 트레이트 객체(trait object)입니다.
Rust에서 트레이트를 구현하는 여러 타입을 하나의 컬렉션에 담으려면 Box<dyn Trait> 형태를 사용해야 해요.
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
fn name(&self) -> &str {
"원"
}
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn name(&self) -> &str {
"직사각형"
}
}
Circle과 Rectangle은 모두 Shape 트레이트를 구현하지만 크기가 서로 다릅니다.
Vec<Shape>처럼 트레이트를 직접 쓸 수는 없는데 컴파일러가 각 원소의 크기를 알 수 없기 때문이죠.
Box<dyn Shape>를 사용하면 각 도형이 힙에 저장되고 벡터에는 동일한 크기의 포인터만 들어갑니다.
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rectangle { width: 4.0, height: 6.0 }),
Box::new(Circle { radius: 3.0 }),
];
for shape in &shapes {
println!("{}: 넓이 = {:.2}", shape.name(), shape.area());
}
}
원: 넓이 = 78.54
직사각형: 넓이 = 24.00
원: 넓이 = 28.27
여기서 dyn 키워드는 동적 디스패치(dynamic dispatch)를 의미하는데요.
컴파일 시점이 아닌 런타임에 실제 타입의 메서드를 호출한다는 뜻입니다.
플러그인 시스템이나 이벤트 핸들러, 전략 패턴처럼 다형성이 필요한 상황에서 유용하게 쓸 수 있어요.
Box와 소유권
Box도 소유권 규칙을 따릅니다.
Box에 담긴 값은 Box가 소유하고, Box가 drop되면 내부 값도 함께 drop돼요.
fn main() {
let boxed = Box::new(String::from("안녕하세요"));
// 소유권 이동
let moved = boxed;
// println!("{boxed}"); // 오류! 소유권이 이미 이동됨
println!("{moved}");
}
안녕하세요
참조를 통해 빌려서 사용할 수도 있습니다.
fn peek(data: &Box<String>) {
println!("엿보기: {data}");
}
fn main() {
let boxed = Box::new(String::from("비밀 메시지"));
peek(&boxed);
println!("여전히 사용 가능: {boxed}");
}
엿보기: 비밀 메시지
여전히 사용 가능: 비밀 메시지
실제로는 &Box<String> 대신 &String이나 &str을 매개변수 타입으로 쓰는 게 더 관용적(idiomatic)인데요.
Rust의 역참조 강제 덕분에 Box<String>에서 &String이나 &str로 자동 변환이 이루어지기 때문이죠.
fn peek(data: &str) {
println!("엿보기: {data}");
}
fn main() {
let boxed = Box::new(String::from("비밀 메시지"));
peek(&boxed); // Box<String> → String → str 자동 변환
println!("여전히 사용 가능: {boxed}");
}
Box에서 값 꺼내기
Box에 저장된 값을 다시 스택으로 꺼내고 싶을 때는 역참조 연산자 *를 사용합니다.
fn main() {
let boxed = Box::new(42);
let unboxed: i32 = *boxed;
println!("꺼낸 값: {unboxed}");
}
꺼낸 값: 42
String처럼 Copy 트레이트를 구현하지 않는 타입은 역참조할 때 소유권이 이동합니다.
fn main() {
let boxed = Box::new(String::from("hello"));
let unboxed: String = *boxed; // 소유권 이동
// println!("{boxed}"); // 오류! 소유권이 이동됨
println!("{unboxed}");
}
hello
Box vs 다른 스마트 포인터
Rust에는 Box 외에도 여러 스마트 포인터가 있습니다.
각각 목적이 다르기 때문에 상황에 맞게 골라 써야 해요.
Box<T>는 가장 단순한 형태로, 단일 소유자가 힙에 데이터를 저장할 때 씁니다.
추가 런타임 비용이 없고 스코프를 벗어나면 자동으로 메모리가 해제됩니다.
Rc는 단일 스레드 환경에서 여러 소유자가 같은 데이터를 공유해야 할 때 씁니다. 참조 카운팅을 통해 마지막 소유자가 drop될 때 메모리를 해제하죠.
Arc는 멀티스레드 환경에서의 Rc입니다.
원자적 연산으로 참조 카운트를 관리하여 스레드 간 안전하게 공유할 수 있어요.
정리하면 이렇습니다.
소유자가 하나고 단순히 힙에 저장하고 싶다면 Box를 사용하세요.
단일 스레드에서 공유 소유권이 필요하면 Rc를, 멀티스레드에서 공유 소유권이 필요하면 Arc를 사용하면 됩니다.
마치며
Box는 Rust에서 가장 기본적인 스마트 포인터입니다.
힙에 데이터를 저장한다는 단순한 역할이지만 재귀 타입 정의나 큰 데이터 이동, 트레이트 객체 활용처럼 꼭 필요한 장면이 많습니다.
핵심만 짚어보면 이렇습니다.
Box::new()로 힙에 값을 저장하고 스코프를 벗어나면 자동으로 해제됩니다.
재귀 타입에서는 Box로 감싸서 크기를 고정해야 하고요.
dyn Trait와 함께 쓰면 서로 다른 타입을 하나의 컬렉션에 담을 수 있습니다.
소유권 규칙은 동일하게 적용되며 *로 내부 값을 꺼낼 수도 있어요.
Box에 익숙해지면 Rc나 Arc 같은 다른 스마트 포인터도 자연스럽게 이해할 수 있을 겁니다.
소유권과 빌림이 아직 익숙하지 않다면 소유권과 빌림 글을 먼저 읽어보시는 걸 추천드려요.
This work is licensed under
CC BY 4.0