Rust 기초: Deref 트레이트와 역참조 강제(Deref Coercion)
Rust로 코드를 작성하다 보면 신기한 장면을 목격할 때가 있습니다.
Box<String>을 넘겼는데 &str을 기대하는 함수가 아무 문제없이 호출된다거나, Rc<Vec<i32>>에 대고 .iter()를 바로 호출할 수 있다거나 하는 것들이죠.
분명 타입이 다른데 컴파일러가 알아서 잘 처리해줍니다.
이런 마법 같은 일이 가능한 건 Rust의 Deref 트레이트와 역참조 강제(Deref coercion)라는 메커니즘 덕분입니다.
이 글에서는 역참조가 무엇인지부터 시작해서 Deref 트레이트를 직접 구현해보고, 역참조 강제가 실제로 어떻게 동작하는지 살펴보겠습니다.
역참조란?
역참조(dereference)는 참조(reference)나 포인터가 가리키는 실제 값에 접근하는 것을 말합니다.
Rust에서는 * 연산자를 사용합니다.
fn main() {
let x = 5;
let r = &x; // x의 참조
println!("r = {}", r); // 참조 출력
println!("*r = {}", *r); // 역참조로 실제 값에 접근
assert_eq!(*r, 5);
}
r = 5
*r = 5
r은 x를 가리키는 참조이고, *r을 통해 r이 가리키는 값인 5에 접근할 수 있습니다.
println!에서는 Rust가 자동으로 참조를 따라가기 때문에 * 없이도 같은 결과가 나오지만, 명시적으로 값에 접근할 때는 * 연산자가 필요합니다.
Box와 역참조
Box* 연산자로 내부 값에 접근할 수 있다는 겁니다.
fn main() {
let x = 5;
let boxed = Box::new(x);
assert_eq!(*boxed, 5); // Box를 역참조하면 내부 값에 접근
println!("boxed 값: {}", *boxed);
}
boxed 값: 5
Box::new(x)로 값 5를 힙에 저장하고, *boxed로 그 값을 꺼냅니다.
참조에 *를 쓰는 것과 문법이 같죠. Box<T>가 Deref 트레이트를 구현하고 있기 때문입니다.
Deref 트레이트
Deref 트레이트는 * 연산자의 동작을 정의합니다.
표준 라이브러리에 다음과 같이 정의되어 있죠.
pub trait Deref {
type Target;
fn deref(&self) -> &Self::Target;
}
Target은 역참조했을 때 얻어지는 타입이고, deref() 메서드는 내부 값의 참조를 반환합니다.
실제로 *v라고 쓰면 Rust 컴파일러는 이를 *(v.deref())로 변환합니다.
Box<T>는 Deref 트레이트를 구현하고 있어서 Target = T이고, deref()는 내부 값의 참조 &T를 반환합니다.
그래서 *boxed가 내부 값을 돌려주는 것이죠.
직접 스마트 포인터를 만들면서 Deref 트레이트를 구현해보겠습니다.
use std::ops::Deref;
struct MyBox(i32); // 튜플 구조체
impl MyBox {
fn new(x: i32) -> MyBox {
MyBox(x)
}
}
impl Deref for MyBox {
type Target = i32;
fn deref(&self) -> &i32 {
&self.0 // 첫 번째 필드의 참조를 반환
}
}
fn main() {
let my = MyBox::new(42);
assert_eq!(*my, 42);
println!("MyBox 값: {}", *my);
}
MyBox 값: 42
MyBox는 단순히 i32 값 하나를 감싸는 튜플 구조체입니다.
Deref를 구현해서 Target을 i32로 설정하고, deref()가 내부 값의 참조를 반환하도록 했습니다.
이제 *my를 쓰면 컴파일러가 *(my.deref())로 변환하여 내부 값 42에 접근합니다.
역참조 강제(Deref Coercion)란?
역참조 강제는 Deref 트레이트를 구현한 타입의 참조를 다른 타입의 참조로 자동 변환해주는 메커니즘입니다.
함수 인자를 전달하거나 메서드를 호출할 때 컴파일러가 알아서 처리해줍니다.
가장 흔한 사례가 &String에서 &str로의 변환입니다.
fn greet(name: &str) {
println!("안녕하세요, {}님!", name);
}
fn main() {
let name = String::from("Rust");
greet(&name); // &String → &str 자동 변환
}
안녕하세요, Rust님!
greet 함수는 &str을 기대하는데, 우리는 &String을 넘기고 있습니다.
이게 되는 이유는 String이 Deref<Target = str>을 구현하고 있기 때문입니다.
컴파일러가 &String을 보고 “이걸 &str로 바꿀 수 있겠군” 하고 자동으로 변환해줍니다.
만약 역참조 강제가 없었다면 이렇게 써야 했을 것입니다.
greet(&(*name)[..]); // 역참조하고 슬라이스를 만들어서 참조 전달
// 또는
greet(name.as_str()); // 명시적 변환
역참조 강제가 있으니 &name만 쓰면 되고, 코드가 훨씬 깔끔해지죠.
역참조 강제의 연쇄
역참조 강제는 한 단계에서 끝나지 않습니다.
컴파일러는 타입이 맞을 때까지 Deref를 연쇄적으로 적용합니다.
Box<String>을 &str을 기대하는 함수에 넘기는 경우를 생각해봅시다.
fn print_length(s: &str) {
println!("길이: {}", s.len());
}
fn main() {
let boxed = Box::new(String::from("역참조 강제"));
print_length(&boxed); // &Box<String> → &String → &str
}
길이: 15
여기서 컴파일러는 두 단계의 역참조 강제를 수행합니다.
우선 &Box<String>에서 Box<String>의 Deref<Target = String> 구현을 통해 &String을 얻고, 다시 String의 Deref<Target = str> 구현을 통해 &str을 얻습니다.
Box<String> → String → str 순서로 역참조가 연쇄적으로 일어나는 셈이죠.
이 동작은 Rc나 Arc 같은 다른 스마트 포인터에서도 동일합니다.
use std::rc::Rc;
fn first_char(s: &str) -> char {
s.chars().next().unwrap()
}
fn main() {
let rc_string = Rc::new(String::from("스마트 포인터"));
let ch = first_char(&rc_string); // &Rc<String> → &String → &str
println!("첫 글자: {}", ch);
}
첫 글자: 스
Rc<String>에서 &str까지 자동으로 변환됩니다.
스마트 포인터를 감싸는 층이 얼마나 깊든 상관없이 Deref 체인을 따라 원하는 타입까지 도달할 수 있죠.
메서드 호출에서의 역참조 강제
역참조 강제는 함수 인자뿐 아니라 . 연산자를 사용한 메서드 호출에서도 일어납니다.
스마트 포인터로 감싼 값의 메서드를 바로 호출할 수 있는 것도 이 때문이죠.
fn main() {
let boxed_string = Box::new(String::from("hello, rust!"));
// Box<String>인데 String의 메서드를 바로 호출
let upper = boxed_string.to_uppercase();
println!("{}", upper);
// String의 메서드뿐 아니라 str의 메서드도 호출 가능
let contains = boxed_string.contains("rust");
println!("rust 포함: {}", contains);
// len()도 str의 메서드
println!("길이: {}", boxed_string.len());
}
HELLO, RUST!
rust 포함: true
길이: 12
boxed_string은 Box<String> 타입인데 to_uppercase(), contains(), len() 같은 메서드를 호출할 수 있습니다.
컴파일러가 메서드를 찾을 때 자동으로 역참조를 수행하기 때문입니다.
Box<String> → String → str 순서로 탐색하면서 해당 메서드가 정의된 타입을 찾아냅니다.
Vec<T>에서도 마찬가지입니다.
fn main() {
let boxed_vec = Box::new(vec![3, 1, 4, 1, 5]);
// Vec<i32>의 메서드
println!("길이: {}", boxed_vec.len());
// 슬라이스(&[i32])의 메서드
println!("첫 번째: {}", boxed_vec.first().unwrap());
// 이터레이터 사용
let sum: i32 = boxed_vec.iter().sum();
println!("합계: {}", sum);
}
길이: 5
첫 번째: 3
합계: 14
Box<Vec<i32>>에서 Vec의 메서드도, 슬라이스의 메서드도 직접 호출할 수 있습니다.
그래서 스마트 포인터를 사용해도 내부 데이터를 다루는 코드가 복잡해지지 않습니다.
DerefMut 트레이트
Deref가 불변 역참조를 담당한다면 DerefMut은 가변 역참조를 담당합니다.
값을 수정하려면 DerefMut이 필요하죠.
pub trait DerefMut: Deref {
fn deref_mut(&mut self) -> &mut Self::Target;
}
DerefMut은 Deref를 확장한 트레이트로, deref_mut() 메서드는 내부 값의 가변 참조를 반환합니다.
fn main() {
let mut boxed = Box::new(String::from("hello"));
// DerefMut을 통해 Box 안의 String을 직접 수정
boxed.push_str(", world!");
println!("{}", boxed);
// 가변 참조를 받는 함수에도 전달 가능
make_uppercase(&mut boxed);
println!("{}", boxed);
}
fn make_uppercase(s: &mut String) {
*s = s.to_uppercase();
}
hello, world!
HELLO, WORLD!
Box<String>에 대해 push_str()을 호출하면, DerefMut을 통해 내부 String의 가변 참조를 얻어서 수정합니다.
&mut Box<String>을 &mut String을 기대하는 함수에 넘길 때도 역참조 강제가 작동합니다.
역참조 강제에서 가변성은 다음 세 가지 규칙을 따릅니다.
&T에서 &U로 변환할 때는 T: Deref<Target = U>가 필요하고, &mut T에서 &mut U로 변환할 때는 T: DerefMut<Target = U>가 필요합니다.
그리고 &mut T에서 &U로도 변환이 가능한데, 가변 참조를 불변 참조로 바꾸는 것은 항상 안전하기 때문입니다.
반대로 &T에서 &mut U로의 변환은 불가능합니다. 빌림 규칙에 따라 불변 참조가 유일하다는 보장이 없으니까요.
커스텀 타입에 Deref 구현하기
자신만의 래퍼 타입을 만들 때 Deref를 구현하면 내부 값을 마치 직접 다루는 것처럼 쓸 수 있습니다.
대소문자를 구분하지 않는 문자열 래퍼를 예로 들어보겠습니다.
use std::ops::Deref;
struct CaseInsensitive(String);
impl CaseInsensitive {
fn new(s: &str) -> Self {
CaseInsensitive(s.to_lowercase())
}
fn equals(&self, other: &str) -> bool {
self.0 == other.to_lowercase()
}
}
impl Deref for CaseInsensitive {
type Target = String;
fn deref(&self) -> &String {
&self.0
}
}
fn main() {
let text = CaseInsensitive::new("Hello, Rust!");
// CaseInsensitive 자체의 메서드
println!("동일: {}", text.equals("HELLO, RUST!"));
// Deref를 통해 String의 메서드 사용
println!("길이: {}", text.len());
println!("대문자: {}", text.to_uppercase());
// &str을 기대하는 함수에도 전달 가능
print_text(&text);
}
fn print_text(s: &str) {
println!("텍스트: {}", s);
}
동일: true
길이: 12
대문자: HELLO, RUST!
텍스트: hello, rust!
CaseInsensitive는 내부에 String을 담고 있고, Deref를 구현해서 String으로 역참조됩니다.
자체 메서드인 equals()도 사용할 수 있고, len()이나 to_uppercase() 같은 String의 메서드도 바로 호출할 수 있습니다.
&str을 받는 함수에 넘기는 것까지 가능합니다.
다만 주의할 점이 있습니다.
Deref는 스마트 포인터 패턴에 사용하도록 설계된 트레이트입니다.
일반적인 타입 변환 용도로 남용하면 코드를 읽는 사람이 혼란스러울 수 있습니다.
래퍼 타입이 내부 값을 “소유하면서 투명하게 노출”하는 관계일 때만 Deref를 구현하는 것이 좋습니다.
표준 라이브러리의 Deref 활용
Rust 표준 라이브러리 곳곳에서 Deref 트레이트를 적극 활용하고 있습니다.
이미 살펴본 것을 포함해서 대표적인 구현을 정리해보겠습니다.
String—Deref<Target = str>:&String을&str처럼 사용Vec<T>—Deref<Target = [T]>:&Vec<T>를&[T]처럼 사용Box<T>—Deref<Target = T>:&Box<T>를&T처럼 사용Rc<T>—Deref<Target = T>:&Rc<T>를&T처럼 사용Arc<T>—Deref<Target = T>:&Arc<T>를&T처럼 사용Cow<'a, B>—Deref<Target = B>: 빌린 데이터와 소유한 데이터를 통일된 인터페이스로 사용MutexGuard<T>—Deref<Target = T>: 락 가드를 통해 내부 값에 직접 접근
그래서 함수를 작성할 때 가장 일반적인 타입으로 매개변수를 선언해두면 여러 타입을 자연스럽게 받을 수 있습니다.
// &str을 받으면 String, Box<String>, Rc<String> 등 모두 전달 가능
fn process(data: &str) {
println!("처리: {}", data);
}
// &[i32]를 받으면 Vec<i32>, Box<Vec<i32>> 등 모두 전달 가능
fn sum_all(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
fn main() {
let s = String::from("hello");
let boxed_s = Box::new(String::from("boxed"));
process(&s);
process(&boxed_s);
let v = vec![1, 2, 3];
let boxed_v = Box::new(vec![4, 5, 6]);
println!("합계: {}", sum_all(&v));
println!("합계: {}", sum_all(&boxed_v));
}
처리: hello
처리: boxed
합계: 6
합계: 15
매개변수를 &str이나 &[T] 같은 슬라이스 타입으로 선언하면 역참조 강제가 알아서 변환해주기 때문에 String, Box<String>, Vec<T>, Box<Vec<T>> 등을 그냥 넘길 수 있습니다.
Rust 코드에서 함수 매개변수로 &String보다 &str을, &Vec<T>보다 &[T]를 선호하는 이유이기도 합니다.
마치며
Deref 트레이트와 역참조 강제를 이해하고 나면 Rust 코드에서 타입이 “마법처럼” 맞아 들어가는 장면이 더 이상 신기하지 않을 겁니다.
* 연산자의 동작을 Deref로 정의하면 스마트 포인터를 일반 참조처럼 사용할 수 있고, 역참조 강제가 타입 변환을 알아서 해주니까요.
BoxString과 Vec<T> 같은 일상적인 타입도 Deref를 통해 슬라이스로 변환됩니다.
함수 매개변수를 &str이나 &[T]로 선언해두면 역참조 강제가 알아서 타입을 맞춰주니, 호출하는 쪽에서 번거로운 변환 코드를 쓸 필요가 없습니다.
커스텀 스마트 포인터를 만들 때도 Deref와 DerefMut을 구현하면 내부 값에 투명하게 접근할 수 있습니다.
다만 Deref는 스마트 포인터 패턴을 위한 트레이트이므로, 임의의 타입 변환 용도로는 From과 Into 트레이트를 사용하는 것이 적절합니다.
Rust의 소유권과 빌림 개념이 아직 익숙하지 않다면 해당 글을 먼저 읽어보시기를 권장합니다. Rust의 Deref와 역참조 강제에 대해 더 알고 싶으시다면 Rust 공식 문서를 참고하시기 바랍니다.
This work is licensed under
CC BY 4.0