이미지 로딩 중...
AI Generated
2025. 11. 13. · 3 Views
Rust 입문 가이드 25 함수와 소유권 완벽 이해
Rust에서 함수에 값을 전달하고 반환할 때 소유권이 어떻게 이동하는지 배웁니다. 매개변수와 반환값의 소유권 규칙을 실전 예제로 익히고, 실무에서 흔히 겪는 문제와 해결 방법을 다룹니다.
목차
1. 함수와_소유권의_기본
시작하며
여러분이 Rust로 첫 프로젝트를 만들다 보면 이런 에러를 마주할 때가 있습니다. 함수에 변수를 전달했는데, 함수 호출 후 그 변수를 다시 사용하려니 컴파일러가 "value borrowed here after move"라는 에러를 뱉어냅니다.
이런 문제는 Rust의 소유권 시스템을 이해하지 못해서 발생합니다. 다른 언어에서는 함수에 값을 전달해도 원본 변수를 계속 쓸 수 있지만, Rust는 다릅니다.
함수 호출 하나하나가 메모리 안전성과 직결되어 있기 때문입니다. 바로 이럴 때 필요한 것이 함수와 소유권의 관계에 대한 명확한 이해입니다.
이를 알면 언제 값이 이동하고, 언제 복사되며, 언제 빌려야 하는지 정확히 판단할 수 있습니다.
개요
간단히 말해서, Rust에서 함수에 값을 전달하면 소유권이 함수로 이동합니다. 이는 Rust의 핵심 철학인 "메모리 안전성을 컴파일 타임에 보장"하기 위한 설계입니다.
값의 소유자가 명확해야 언제 메모리를 해제할지 결정할 수 있고, 이로 인해 use-after-free나 double-free 같은 버그를 원천적으로 차단합니다. 예를 들어, 대용량 String 데이터를 함수에 전달할 때 복사본을 만들지 않고 소유권만 이동시켜 성능을 최적화합니다.
기존 C++에서는 포인터를 전달하고 누가 메모리를 해제할지 개발자가 기억해야 했다면, Rust는 소유권 규칙으로 이를 자동화합니다. 함수 호출 시 소유권 이동의 핵심 특징은 세 가지입니다.
첫째, 값이 함수로 이동하면 호출한 쪽에서는 더 이상 사용할 수 없습니다. 둘째, 함수 실행이 끝나면 매개변수가 스코프를 벗어나 자동으로 drop됩니다.
셋째, 이 모든 검사가 런타임이 아닌 컴파일 타임에 이루어집니다. 이러한 특징들이 Rust를 메모리 안전하면서도 빠른 언어로 만듭니다.
코드 예제
fn takes_ownership(s: String) {
// s의 소유권이 이 함수로 이동됨
println!("함수 안에서: {}", s);
} // 여기서 s가 drop되어 메모리 해제
fn main() {
let my_string = String::from("hello");
takes_ownership(my_string);
// my_string은 더 이상 유효하지 않음
// println!("{}", my_string); // 컴파일 에러!
}
설명
이것이 하는 일: 위 코드는 String 타입의 값을 함수에 전달할 때 소유권이 어떻게 이동하는지 보여줍니다. 소유권 이동은 Rust의 메모리 관리 전략의 핵심입니다.
첫 번째로, main 함수에서 my_string 변수가 생성되고 힙에 "hello" 문자열이 할당됩니다. 이 시점에서 my_string이 이 데이터의 유일한 소유자입니다.
String은 힙 메모리를 사용하는 타입이므로 소유권 규칙이 엄격하게 적용됩니다. 그 다음으로, takes_ownership(my_string)이 호출되면서 소유권이 완전히 함수의 매개변수 s로 이동합니다.
이는 단순히 스택의 포인터, 길이, 용량 정보만 복사하는 것이지만, 의미상으로는 소유권이 완전히 넘어가는 것입니다. 이제 my_string은 "이동됨(moved)" 상태가 되어 더 이상 유효하지 않습니다.
세 번째 단계로, 함수 실행이 끝나면 매개변수 s가 스코프를 벗어나면서 자동으로 drop 함수가 호출됩니다. 이때 힙에 할당된 "hello" 문자열 메모리가 해제됩니다.
만약 main에서 my_string을 다시 사용하려고 하면, 이미 해제된 메모리에 접근하는 것이 되므로 컴파일러가 에러를 발생시킵니다. 여러분이 이 코드 패턴을 이해하면 메모리 누수나 댕글링 포인터 걱정 없이 안전한 코드를 작성할 수 있습니다.
컴파일러가 모든 소유권 이동을 추적하고 검증하므로, 런타임 오버헤드 없이 메모리 안전성을 보장받습니다. 특히 대용량 데이터를 처리하는 서버 애플리케이션이나 임베디드 시스템에서 이런 명확한 소유권 규칙은 예측 가능한 성능을 제공합니다.
실전 팁
💡 함수에 값을 전달한 후에도 계속 사용해야 한다면, 참조자(&)를 사용하거나 clone()으로 복사본을 만드세요. 참조자가 더 효율적입니다.
💡 컴파일 에러 메시지에서 "move occurs because..."를 주의깊게 읽으세요. 어떤 값이 어디로 이동했는지 정확히 알려줍니다.
💡 디버깅할 때 변수 뒤에 .clone()을 붙여보면 소유권 문제인지 빠르게 확인할 수 있습니다. 에러가 사라지면 소유권 이슈입니다.
💡 함수 설계 시 소유권을 가져가야 할지, 빌려야 할지 먼저 결정하세요. 일반적으로 읽기만 한다면 빌리고, 소비하거나 저장한다면 소유권을 가져갑니다.
2. 매개변수로_소유권_이동
시작하며
여러분이 사용자 입력 문자열을 검증하는 함수를 만들었다고 상상해보세요. 함수에 문자열을 넘겼더니 검증 후 원본 문자열을 로그에 남기려 할 때 "value used here after move" 에러가 발생합니다.
이런 문제는 실제 개발 현장에서 초보자들이 가장 자주 겪는 실수입니다. 함수가 매개변수의 소유권을 가져가면, 그 값은 함수 안에서만 존재하고 호출자는 접근할 수 없게 됩니다.
이는 다른 언어의 "pass by value"와는 근본적으로 다른 동작입니다. 바로 이럴 때 필요한 것이 매개변수 소유권 이동의 정확한 이해입니다.
언제 함수가 소유권을 가져가고, 이후 어떻게 되는지 알면 이런 실수를 피할 수 있습니다.
개요
간단히 말해서, 함수의 매개변수로 값을 전달하면 대입과 동일하게 소유권이 이동합니다. 이는 Rust에서 "이동(move)"이 변수 간 대입, 함수 호출, 반환 등 모든 곳에서 일관되게 적용되기 때문입니다.
함수 매개변수는 사실상 함수 내부의 새로운 변수에 값을 대입하는 것과 같습니다. 예를 들어, Vec<T>나 String 같은 힙 할당 타입을 함수에 전달하면, 호출자는 즉시 그 값에 대한 접근 권한을 잃습니다.
기존 Python이나 JavaScript에서는 함수에 객체를 전달해도 참조가 복사되어 원본에 접근할 수 있었다면, Rust는 소유권을 완전히 이전시켜 명확한 단일 소유자를 유지합니다. 매개변수 소유권 이동의 핵심 특징은 다음과 같습니다.
첫째, 힙 데이터를 가진 타입(String, Vec, Box 등)은 항상 소유권이 이동합니다. 둘째, 함수가 종료되면 매개변수가 drop되어 메모리가 자동 해제됩니다.
셋째, 호출 후 원본 변수를 사용하면 컴파일 타임에 에러가 발생합니다. 이러한 특징들이 메모리 안전성을 컴파일러 수준에서 강제합니다.
코드 예제
fn calculate_length(s: String) -> usize {
// s의 소유권이 이 함수로 이동
let length = s.len();
println!("문자열 '{}'의 길이: {}", s, length);
length
// s가 여기서 drop됨
}
fn main() {
let text = String::from("Rust Programming");
let len = calculate_length(text);
// text는 이미 이동됨 - 사용 불가
// println!("원본: {}", text); // 에러!
println!("길이만 반환받음: {}", len);
}
설명
이것이 하는 일: 이 코드는 String을 매개변수로 받는 함수에서 소유권이 어떻게 이동하고, 호출자가 왜 원본에 접근할 수 없는지를 명확히 보여줍니다. 첫 번째로, main에서 text 변수에 "Rust Programming" 문자열이 할당됩니다.
이 String은 힙에 실제 문자 데이터를 저장하고, text는 스택에서 그 힙 메모리를 가리키는 포인터, 길이, 용량 정보를 가집니다. 이 시점에서 text가 명확한 소유자입니다.
그 다음으로, calculate_length(text)를 호출하면 소유권이 매개변수 s로 완전히 이동합니다. 내부적으로는 스택의 포인터 정보만 복사되지만(얕은 복사), Rust는 이를 소유권 이동으로 간주합니다.
함수 내에서 s.len()으로 길이를 계산하고 출력하는 것은 가능합니다. s가 소유자이기 때문입니다.
세 번째 단계에서, 함수가 length 값(usize 타입)만 반환합니다. usize는 Copy 트레이트를 구현한 원시 타입이므로 복사되어 전달됩니다.
그러나 String s는 반환되지 않으므로 함수 종료 시 drop되어 힙 메모리가 해제됩니다. 이때 main의 text는 이미 무효화된 상태이므로, 컴파일러가 접근을 차단합니다.
여러분이 이 패턴을 이해하면 함수가 데이터를 "소비"하는 경우와 단순히 "사용"하는 경우를 구분할 수 있습니다. 데이터를 소비하는 함수는 소유권을 가져가지만, 대부분의 경우 참조자를 사용하는 것이 더 효율적입니다.
이런 판단 능력이 생기면 불필요한 clone()을 줄이고 성능을 최적화할 수 있습니다.
실전 팁
💡 함수가 값을 읽기만 한다면 &str이나 &T를 매개변수로 사용하세요. 소유권을 가져갈 필요가 없습니다.
💡 println! 매크로는 참조자를 받으므로 소유권을 이동시키지 않습니다. 하지만 일반 함수는 명시적으로 &를 붙여야 합니다.
💡 "expected &str, found String" 에러가 나면 &를 붙이거나 as_str()을 호출하세요. 소유권 vs 빌림 문제입니다.
💡 함수 설계 시 "이 함수가 데이터를 소비하는가?"를 먼저 질문하세요. 소비한다면 소유권을 가져가고, 아니라면 빌려야 합니다.
💡 테스트 코드에서는 .clone()을 자유롭게 사용해도 됩니다. 프로덕션 코드에서만 최적화에 신경쓰세요.
3. 함수에서_소유권_반환하기
시작하며
여러분이 데이터 변환 함수를 만들었는데, 입력값을 가공한 후 호출자가 결과를 사용할 수 없다면 어떻게 될까요? 함수가 소유권을 가져가기만 하고 돌려주지 않으면 데이터가 사라져버립니다.
이런 문제는 Rust 초심자들이 흔히 겪는 딜레마입니다. 함수에 값을 전달하면 소유권이 이동하는데, 그 값을 다시 사용하려면 어떻게 해야 할까요?
매번 clone()으로 복사본을 만들면 비효율적입니다. 바로 이럴 때 필요한 것이 반환값을 통한 소유권 반환입니다.
함수가 소유권을 가져갔다가 다시 돌려주면, 호출자는 계속해서 데이터를 사용할 수 있습니다.
개요
간단히 말해서, 함수는 반환값을 통해 소유권을 호출자에게 다시 이전할 수 있습니다. 이는 Rust에서 소유권을 "빌려주고 돌려받는" 가장 기본적인 패턴입니다.
함수가 작업을 수행한 후 결과를 반환하면, 그 소유권이 호출자로 이동합니다. 예를 들어, 문자열을 대문자로 변환하는 함수는 String을 받아서 변환 후 다시 String을 반환함으로써 호출자가 계속 사용할 수 있게 합니다.
기존 C에서는 포인터를 반환하고 메모리 관리를 수동으로 해야 했다면, Rust는 소유권 반환으로 안전하고 명확한 메모리 관리를 제공합니다. 반환을 통한 소유권 이전의 핵심 특징은 다음과 같습니다.
첫째, 함수 내에서 생성된 값이나 매개변수로 받은 값 모두 반환할 수 있습니다. 둘째, 반환되는 값은 호출자의 변수로 이동하여 새로운 소유자를 갖습니다.
셋째, 반환되지 않은 지역 변수는 함수 종료 시 자동으로 drop됩니다. 이러한 특징들이 명확한 소유권 체인을 만들어 메모리 누수를 방지합니다.
코드 예제
fn process_and_return(s: String) -> String {
// s의 소유권을 받아옴
let mut result = s;
result.push_str(" - 처리완료");
// 수정된 String의 소유권을 반환
result
// result가 반환되므로 drop되지 않음
}
fn main() {
let original = String::from("데이터");
let processed = process_and_return(original);
// original은 이동됨 - 사용 불가
// 하지만 processed로 소유권을 받아서 사용 가능
println!("결과: {}", processed);
}
설명
이것이 하는 일: 이 코드는 함수가 매개변수의 소유권을 받아 작업을 수행한 후, 반환을 통해 소유권을 다시 호출자에게 넘기는 패턴을 보여줍니다. 첫 번째로, main에서 original 변수에 "데이터"라는 String이 생성됩니다.
process_and_return 함수를 호출할 때 이 String의 소유권이 매개변수 s로 이동합니다. 이 시점부터 original은 더 이상 유효하지 않습니다.
그 다음으로, 함수 내부에서 s를 result로 이동시킵니다(변수 대입). 이는 소유권을 새로운 변수로 옮기는 것입니다.
result.push_str로 문자열을 수정하는데, 이는 result가 소유자이므로 가능합니다. mut 키워드가 있어야 수정 가능하다는 점을 주목하세요.
세 번째 단계에서, 함수가 result를 반환합니다. 이때 result의 소유권이 호출자로 이동하여 processed 변수에 바인딩됩니다.
반환문에서 소유권이 이동하므로 result는 drop되지 않습니다. 이제 processed가 새로운 소유자가 되어 main에서 자유롭게 사용할 수 있습니다.
여러분이 이 패턴을 사용하면 데이터를 함수 체인을 통해 전달하면서도 각 단계에서 명확한 소유권을 유지할 수 있습니다. 예를 들어, 데이터 처리 파이프라인에서 각 함수가 입력을 받아 변환하고 반환하는 방식으로 구성할 수 있습니다.
이는 함수형 프로그래밍 스타일과도 잘 맞으며, 컴파일러 최적화도 잘 됩니다.
실전 팁
💡 함수가 입력을 변환만 한다면 소유권을 받아서 반환하는 패턴이 효율적입니다. clone() 없이 제로 코스트로 동작합니다.
💡 메서드 체이닝을 위해 self를 반환하는 builder 패턴에서 이 방식을 많이 씁니다. 예: string.to_uppercase().trim()
💡 반환 타입을 명시하면 코드 가독성이 높아집니다. 특히 제네릭 함수에서는 필수입니다.
💡 여러 값을 반환해야 한다면 튜플을 사용하세요. (String, usize) 형태로 소유권과 추가 데이터를 함께 반환할 수 있습니다.
4. 소유권_이동과_복사_타입
시작하며
여러분이 정수를 매개변수로 받는 함수를 만들었는데, 이상하게도 함수 호출 후에도 원본 변수를 계속 사용할 수 있습니다. String은 이동되는데 정수는 왜 그대로 사용 가능할까요?
이런 혼란은 Rust의 Copy 트레이트를 이해하지 못해서 생깁니다. 모든 타입이 소유권 이동 규칙을 따르는 것은 아닙니다.
일부 타입은 복사되어 전달되므로 원본이 그대로 유지됩니다. 바로 이럴 때 필요한 것이 Copy 트레이트의 이해입니다.
어떤 타입이 복사되고 어떤 타입이 이동하는지 알면, 예상치 못한 소유권 에러를 피할 수 있습니다.
개요
간단히 말해서, Copy 트레이트를 구현한 타입은 소유권 이동 대신 값이 복사됩니다. 이는 Rust가 성능과 안전성 사이의 균형을 맞추는 방법입니다.
스택에만 저장되는 단순한 타입(정수, 불린, 문자 등)은 복사 비용이 매우 저렴하므로 자동으로 복사됩니다. 반면 힙 메모리를 사용하는 타입(String, Vec 등)은 복사 비용이 크므로 명시적으로 clone()을 호출해야 합니다.
예를 들어, i32를 함수에 전달해도 원본 변수를 계속 사용할 수 있는 이유가 바로 이것입니다. 기존 C++의 복사 생성자는 암묵적으로 모든 타입에 작동할 수 있어 성능 문제를 일으켰다면, Rust는 Copy 트레이트로 명시적으로 구분합니다.
Copy 트레이트의 핵심 특징은 다음과 같습니다. 첫째, Copy 타입은 스택에만 데이터를 저장해야 합니다.
둘째, Copy를 구현하면 Clone도 자동으로 구현됩니다. 셋째, 정수, 부동소수점, bool, char, 튜플(모든 요소가 Copy인 경우)이 Copy입니다.
이러한 특징들이 코드 작성을 단순화하면서도 예측 가능한 성능을 제공합니다.
코드 예제
fn makes_copy(x: i32) {
// x는 복사되어 전달됨 (Copy 트레이트)
println!("복사된 값: {}", x);
} // x가 drop되지만 원본에 영향 없음
fn takes_ownership(s: String) {
// s는 이동되어 전달됨 (Copy 아님)
println!("이동된 값: {}", s);
} // s가 drop되어 메모리 해제
fn main() {
let num = 42;
makes_copy(num);
println!("여전히 사용 가능: {}", num); // OK!
let text = String::from("hello");
takes_ownership(text);
// println!("{}", text); // 에러! 이동됨
}
설명
이것이 하는 일: 이 코드는 Copy 트레이트를 구현한 타입과 그렇지 않은 타입의 동작 차이를 명확하게 보여줍니다. 첫 번째로, i32 타입의 num 변수를 makes_copy 함수에 전달할 때를 봅시다.
i32는 Copy 트레이트를 구현했으므로, 값이 비트 단위로 복사되어 매개변수 x로 전달됩니다. 내부적으로는 스택의 4바이트가 그대로 복사되는 것입니다.
원본 num과 복사본 x는 완전히 독립적인 값이 됩니다. 그 다음으로, 함수가 종료되면 매개변수 x가 스코프를 벗어나지만, i32는 drop 동작이 없으므로 그냥 사라집니다.
중요한 점은 원본 num은 전혀 영향을 받지 않는다는 것입니다. 따라서 함수 호출 후에도 main에서 num을 계속 사용할 수 있습니다.
세 번째 단계로, String 타입의 text를 takes_ownership에 전달하는 경우를 봅시다. String은 Copy를 구현하지 않았으므로(힙 메모리 포인터 때문), 소유권이 완전히 이동합니다.
함수 종료 시 s가 drop되면서 힙 메모리가 해제되고, text는 무효화됩니다. 이후 text를 사용하려 하면 컴파일 에러가 발생합니다.
여러분이 이 차이를 이해하면 타입별로 적절한 전달 방식을 선택할 수 있습니다. 단순한 값 타입은 그냥 전달하고, 복잡한 타입은 참조자를 고려하세요.
특히 구조체를 설계할 때 Copy를 구현할지 말지 결정하는 것이 중요합니다. Copy를 구현하면 사용이 편하지만, 내부에 힙 데이터가 있으면 구현할 수 없습니다.
실전 팁
💡 Copy와 Clone의 차이: Copy는 암묵적이고 저렴한 비트 복사, Clone은 명시적이고 비용이 클 수 있는 복사입니다.
💡 자신의 구조체에 #[derive(Copy, Clone)]을 붙이려면 모든 필드가 Copy여야 합니다. String이나 Vec 필드가 있으면 불가능합니다.
💡 (i32, bool) 같은 튜플은 Copy지만, (i32, String)은 Copy가 아닙니다. 한 요소라도 Copy가 아니면 전체가 이동합니다.
💡 성능이 중요하고 타입이 작다면(16바이트 이하) Copy 구현을 고려하세요. 하지만 의미상 복사가 명시적이어야 한다면 Clone만 구현하세요.
💡 함수 시그니처를 볼 때 매개변수가 Copy 타입인지 확인하는 습관을 들이세요. IDE가 타입 정보를 보여줍니다.
5. 튜플로_여러_값_반환하기
시작하며
여러분이 문자열을 처리하는 함수를 만들었는데, 처리된 문자열과 함께 그 길이도 반환하고 싶습니다. 하지만 함수는 하나의 값만 반환할 수 있어 고민이 됩니다.
이런 문제는 함수가 여러 정보를 전달해야 할 때 자주 발생합니다. 소유권을 가져간 값을 반환하면서 동시에 추가 데이터도 함께 전달하려면 어떻게 해야 할까요?
구조체를 만들기엔 너무 간단한 경우가 많습니다. 바로 이럴 때 필요한 것이 튜플 반환입니다.
튜플을 사용하면 여러 값을 하나로 묶어 반환하면서도 각각의 소유권을 적절히 관리할 수 있습니다.
개요
간단히 말해서, 튜플을 사용하면 소유권과 함께 추가 데이터를 동시에 반환할 수 있습니다. 이는 Rust에서 함수가 여러 값을 반환하는 가장 일반적인 패턴입니다.
튜플은 서로 다른 타입의 값들을 하나로 묶을 수 있으므로, String과 usize처럼 타입이 다른 값들을 함께 반환할 수 있습니다. 예를 들어, 파일을 읽는 함수가 파일 내용(String)과 바이트 수(usize)를 함께 반환하는 경우에 유용합니다.
기존 Python에서는 다중 반환이 자연스럽지만 타입 안전성이 약했다면, Rust는 튜플로 타입 안전하게 여러 값을 반환합니다. 튜플 반환의 핵심 특징은 다음과 같습니다.
첫째, 각 요소가 독립적인 소유권을 가집니다. 둘째, 구조체보다 가볍고 임시 데이터 반환에 적합합니다.
셋째, 패턴 매칭으로 쉽게 분해할 수 있습니다. 이러한 특징들이 간결하면서도 표현력 있는 코드를 가능하게 합니다.
코드 예제
fn calculate_stats(s: String) -> (String, usize, usize) {
// 문자열 통계 계산
let length = s.len();
let word_count = s.split_whitespace().count();
// String의 소유권과 통계를 튜플로 반환
(s, length, word_count)
}
fn main() {
let text = String::from("Rust is memory safe");
// 튜플을 분해하여 각 값 받기
let (original, len, words) = calculate_stats(text);
println!("문자열: {}", original);
println!("길이: {}, 단어 수: {}", len, words);
}
설명
이것이 하는 일: 이 코드는 함수가 매개변수의 소유권을 받아 작업한 후, 원본과 함께 계산된 통계를 튜플로 반환하는 패턴을 보여줍니다. 첫 번째로, calculate_stats 함수가 String의 소유권을 매개변수 s로 받습니다.
함수 내부에서 s.len()과 s.split_whitespace().count()로 통계를 계산합니다. 이 메서드들은 참조자를 사용하므로 s의 소유권에는 영향을 주지 않습니다.
계산된 length와 word_count는 usize 타입으로 Copy입니다. 그 다음으로, 함수가 (s, length, word_count) 튜플을 반환합니다.
이때 String s의 소유권은 튜플의 첫 번째 요소로 이동하고, 나머지 두 usize 값은 복사됩니다. 튜플 전체가 호출자로 반환되면서, 각 요소의 소유권도 함께 이동합니다.
세 번째 단계에서, main 함수가 튜플을 받아 패턴 매칭으로 분해합니다. let (original, len, words) = ... 구문은 튜플의 각 요소를 개별 변수에 바인딩합니다.
이제 original은 원래의 String 소유권을 가지고, len과 words는 통계 값을 가집니다. 모든 값을 자유롭게 사용할 수 있습니다.
여러분이 이 패턴을 사용하면 함수 호출 하나로 여러 정보를 효율적으로 가져올 수 있습니다. 특히 데이터 변환과 메타데이터를 함께 반환해야 하는 경우(예: 파싱 결과와 에러 위치, 압축 데이터와 원본 크기 등)에 매우 유용합니다.
구조체를 정의하는 것보다 간결하면서도 타입 안전성은 유지됩니다.
실전 팁
💡 튜플 요소가 3개를 넘어가면 구조체를 고려하세요. 가독성과 유지보수성이 더 좋습니다.
💡 필요 없는 튜플 요소는 언더스코어로 무시할 수 있습니다: let (result, _, _) = calculate_stats(text);
💡 튜플 인덱싱도 가능하지만 비권장입니다. tuple.0보다는 패턴 매칭이 명확합니다.
💡 Result<T, E>와 Option<T>도 내부적으로 튜플과 유사합니다. 에러 처리에서 값과 에러를 함께 다루는 원리가 같습니다.
💡 함수가 여러 값을 반환하면 메서드 체이닝이 어려워집니다. 필요하다면 빌더 패턴이나 구조체를 고려하세요.
6. 참조자_매개변수
시작하며
여러분이 데이터를 여러 함수에서 읽어야 하는 상황을 생각해보세요. 첫 함수에 전달하면 소유권이 이동해서 두 번째 함수에 전달할 수 없습니다.
매번 튜플로 반환받는 것도 번거롭습니다. 이런 문제는 실무에서 매우 자주 발생합니다.
데이터를 읽기만 하고 수정하지 않는 함수가 소유권을 가져간다면, 코드가 불필요하게 복잡해지고 성능도 떨어집니다. 모든 함수가 소유권을 주고받아야 한다면 유지보수가 악몽이 됩니다.
바로 이럴 때 필요한 것이 참조자(reference)입니다. 참조자를 사용하면 소유권을 이동시키지 않고 값을 빌려서 사용할 수 있습니다.
개요
간단히 말해서, 참조자(&T)를 사용하면 소유권 없이 값을 읽을 수 있습니다. 이는 Rust의 "빌림(borrowing)" 시스템의 핵심입니다.
참조자는 값을 가리키는 포인터지만, 소유권을 가지지 않으므로 스코프를 벗어나도 값이 drop되지 않습니다. 예를 들어, String의 길이를 계산하는 함수는 &String을 매개변수로 받아 읽기만 하고, 호출자는 계속해서 원본 String을 사용할 수 있습니다.
기존 C++의 포인터는 null이 될 수 있고 수명을 추적하지 않아 위험했다면, Rust의 참조자는 항상 유효한 값을 가리키고 컴파일러가 수명을 검증합니다. 참조자 매개변수의 핵심 특징은 다음과 같습니다.
첫째, 참조자는 읽기 전용이므로 값을 수정할 수 없습니다(불변 참조자). 둘째, 동시에 여러 개의 불변 참조자가 존재할 수 있습니다.
셋째, 참조자의 수명은 항상 원본보다 짧아야 합니다. 이러한 특징들이 안전한 데이터 공유를 가능하게 합니다.
코드 예제
fn calculate_length(s: &String) -> usize {
// s는 String의 참조자 - 소유권 없음
s.len()
// s가 drop되지 않음 (빌린 것이므로)
}
fn display_text(s: &String) {
// 같은 값을 여러 함수에서 빌릴 수 있음
println!("텍스트: {}", s);
}
fn main() {
let text = String::from("Rust ownership");
let len = calculate_length(&text); // 참조자 전달
display_text(&text); // 여전히 사용 가능
println!("원본: {}, 길이: {}", text, len); // OK!
}
설명
이것이 하는 일: 이 코드는 참조자를 매개변수로 사용하여 소유권 이동 없이 여러 함수에서 같은 데이터를 안전하게 공유하는 방법을 보여줍니다. 첫 번째로, main에서 text 변수가 String의 소유자입니다.
calculate_length(&text)를 호출할 때 & 연산자로 참조자를 생성합니다. 이 참조자는 text가 가리키는 힙 메모리의 주소를 담고 있지만, 소유권은 가지지 않습니다.
함수 시그니처 &String은 "String의 참조자"를 의미합니다. 그 다음으로, 함수 내부에서 s.len()을 호출할 수 있습니다.
참조자를 통해 읽기는 가능하지만, 수정은 불가능합니다(불변 참조자이므로). 함수가 종료될 때 s는 단순한 포인터일 뿐이므로 drop 동작이 없고, 원본 text는 전혀 영향을 받지 않습니다.
세 번째 단계로, 같은 text를 display_text(&text)에도 전달할 수 있습니다. Rust는 동시에 여러 개의 불변 참조자를 허용합니다.
각 참조자는 독립적으로 값을 읽을 수 있고, 원본 소유자인 text도 계속 유효합니다. 모든 함수 호출 후에도 text를 main에서 자유롭게 사용할 수 있습니다.
여러분이 참조자를 사용하면 불필요한 소유권 이동과 데이터 복사를 피할 수 있습니다. 특히 대용량 데이터 구조(Vec, HashMap 등)를 다룰 때 참조자는 필수입니다.
읽기 전용 작업에는 항상 참조자를 고려하세요. 이는 성능과 코드 간결성 모두를 향상시킵니다.
실전 팁
💡 &String보다 &str을 매개변수로 사용하면 더 유연합니다. String, &String, 문자열 리터럴 모두 받을 수 있습니다.
💡 함수가 값을 수정하지 않는다면 무조건 참조자를 사용하세요. 이것이 Rust의 관용적 패턴입니다.
💡 참조자를 반환할 때는 수명(lifetime) 문제를 주의하세요. 매개변수보다 오래 사용될 수 없습니다.
💡 println! 매크로는 자동으로 참조자를 받으므로 &를 명시하지 않아도 됩니다. 하지만 일반 함수는 명시해야 합니다.
💡 메서드 호출 시 .은 자동으로 역참조하므로 (*s).len() 대신 s.len()만 써도 됩니다.
7. 가변_참조자와_함수
시작하며
여러분이 데이터를 수정하는 함수를 만들려는데, 참조자로는 읽기만 가능하다는 에러를 만납니다. 소유권을 가져가지 않으면서 값을 수정할 수는 없을까요?
이런 문제는 데이터를 제자리에서 수정해야 하는 상황에서 발생합니다. 소유권을 이동시키면 원본 변수를 잃고, 불변 참조자로는 수정이 불가능합니다.
매번 새로운 값을 만들어 반환하는 것도 비효율적입니다. 바로 이럴 때 필요한 것이 가변 참조자(mutable reference)입니다.
가변 참조자를 사용하면 소유권 없이도 값을 안전하게 수정할 수 있습니다.
개요
간단히 말해서, 가변 참조자(&mut T)를 사용하면 소유권 없이 값을 수정할 수 있습니다. 이는 Rust의 빌림 시스템에서 쓰기 권한을 부여하는 방법입니다.
가변 참조자는 불변 참조자와 달리 값을 수정할 수 있지만, 엄격한 제약이 있습니다. 한 번에 하나의 가변 참조자만 존재할 수 있고, 가변 참조자가 있을 때는 불변 참조자를 만들 수 없습니다.
예를 들어, 벡터에 요소를 추가하는 함수는 &mut Vec<T>를 받아 직접 수정합니다. 기존 C++에서는 여러 포인터가 동시에 같은 메모리를 수정해 데이터 경쟁이 발생했다면, Rust는 가변 참조자 규칙으로 컴파일 타임에 이를 방지합니다.
가변 참조자의 핵심 특징은 다음과 같습니다. 첫째, 특정 스코프에서 가변 참조자는 단 하나만 존재할 수 있습니다.
둘째, 가변 참조자와 불변 참조자는 동시에 존재할 수 없습니다. 셋째, 원본 변수도 mut로 선언되어야 합니다.
이러한 특징들이 데이터 경쟁을 컴파일 타임에 완전히 제거합니다.
코드 예제
fn append_exclamation(s: &mut String) {
// 가변 참조자로 값 수정 가능
s.push_str("!!!");
}
fn to_uppercase(s: &mut String) {
// 원본을 직접 수정
*s = s.to_uppercase();
}
fn main() {
let mut text = String::from("hello"); // mut 필수!
append_exclamation(&mut text); // 가변 참조자 전달
println!("수정됨: {}", text); // "hello!!!"
to_uppercase(&mut text);
println!("대문자: {}", text); // "HELLO!!!"
}
설명
이것이 하는 일: 이 코드는 가변 참조자를 사용하여 소유권을 이동시키지 않고 원본 데이터를 직접 수정하는 방법을 보여줍니다. 첫 번째로, main에서 text 변수를 mut로 선언합니다.
이는 필수입니다. 불변 변수는 가변 참조자를 만들 수 없기 때문입니다.
append_exclamation(&mut text)를 호출할 때 &mut로 가변 참조자를 생성합니다. 이 참조자는 text의 메모리를 가리키면서 쓰기 권한을 가집니다.
그 다음으로, 함수 내부에서 s.push_str("!!!")로 String에 문자를 추가합니다. 가변 참조자이므로 메서드를 통한 수정이 가능합니다.
함수가 종료되면 가변 참조자 s의 수명이 끝나고, main의 text가 다시 접근 가능해집니다. 중요한 점은 소유권이 이동한 것이 아니라 일시적으로 빌렸다가 반환한 것입니다.
세 번째 단계로, to_uppercase 함수에서는 *s = ...로 역참조 후 대입합니다. s.to_uppercase()는 새로운 String을 반환하는데, 이를 *s에 대입하여 원본을 완전히 교체합니다.
이는 가변 참조자의 강력한 기능입니다. 함수 호출 후 main에서 text를 출력하면 수정된 값이 나타납니다.
여러분이 가변 참조자를 사용하면 대용량 데이터를 복사 없이 효율적으로 수정할 수 있습니다. 예를 들어, 수백만 개의 요소를 가진 Vec을 정렬하는 함수는 &mut Vec<T>를 받아 제자리에서 정렬합니다.
새로운 Vec을 만들어 반환하는 것보다 훨씬 빠릅니다. 이것이 Rust가 성능을 희생하지 않고 안전성을 보장하는 핵심 메커니즘입니다.
실전 팁
💡 가변 참조자는 한 번에 하나만 가능하므로, 함수 호출이 끝나면 다시 빌릴 수 있습니다. 스코프가 중요합니다.
💡 "cannot borrow as mutable more than once" 에러가 나면 스코프를 나누거나 참조자 사용을 순차적으로 바꾸세요.
💡 self를 수정하는 메서드는 &mut self를 받습니다. 예: vec.push(), string.clear()
💡 가변 참조자와 불변 참조자를 섞어 쓸 때는 불변 참조자를 먼저 사용 완료해야 합니다. 컴파일러가 안내합니다.
💡 함수가 여러 가변 참조자를 받는 것은 OK입니다. 다만 같은 값에 대한 중복은 안 됩니다: fn f(a: &mut i32, b: &mut i32)는 가능.
8. 소유권_패턴_실전
시작하며
여러분이 실제 프로젝트에서 문자열 처리 로직을 만든다고 생각해보세요. 입력 검증, 변환, 포맷팅 등 여러 단계를 거치는데, 각 단계에서 소유권을 어떻게 관리해야 할지 막막합니다.
이런 문제는 실무에서 가장 흔히 겪는 소유권 설계 고민입니다. 어떤 함수는 소유권을 가져가고, 어떤 함수는 빌리고, 어떤 함수는 가변으로 빌려야 합니다.
이 모든 것을 조화롭게 설계하는 것이 Rust 프로그래밍의 핵심입니다. 바로 이럴 때 필요한 것이 소유권 패턴의 실전 적용입니다.
읽기, 수정, 소비를 구분하여 각 상황에 맞는 패턴을 적용하면 효율적이고 안전한 코드를 작성할 수 있습니다.
개요
간단히 말해서, 함수의 역할에 따라 소유권, 불변 참조자, 가변 참조자를 적절히 선택해야 합니다. 이는 Rust 설계의 핵심 원칙입니다.
함수가 데이터를 "소비"하는지(소유권), "읽기"만 하는지(불변 참조자), "수정"하는지(가변 참조자)를 명확히 구분해야 합니다. 예를 들어, 입력 검증 함수는 &str로 읽기만 하고, 정규화 함수는 &mut String으로 수정하며, 최종 저장 함수는 String을 소비하는 식입니다.
기존 언어에서는 이런 구분이 명시적이지 않아 버그가 발생했다면, Rust는 타입 시스템으로 의도를 강제합니다. 실전 소유권 패턴의 핵심 특징은 다음과 같습니다.
첫째, 검증/계산 함수는 대부분 불변 참조자를 사용합니다. 둘째, 변환/수정 함수는 가변 참조자를 사용합니다.
셋째, 소비/저장 함수는 소유권을 가져갑니다. 이러한 구분이 코드의 의도를 명확하게 만들고 실수를 방지합니다.
코드 예제
// 검증: 읽기만 - 불변 참조자
fn is_valid_email(email: &str) -> bool {
email.contains('@') && email.len() > 3
}
// 정규화: 수정 - 가변 참조자
fn normalize_email(email: &mut String) {
*email = email.to_lowercase().trim().to_string();
}
// 저장: 소비 - 소유권 이동
fn save_email(email: String) {
println!("DB에 저장: {}", email);
// email이 여기서 소비됨
}
fn main() {
let mut user_input = String::from(" User@Example.COM ");
if is_valid_email(&user_input) {
normalize_email(&mut user_input);
save_email(user_input); // 소유권 이동
// user_input은 더 이상 사용 불가
}
}
설명
이것이 하는 일: 이 코드는 실전 문자열 처리 시나리오에서 소유권, 불변 참조자, 가변 참조자를 각각 언제 사용하는지 보여주는 완전한 예제입니다. 첫 번째로, is_valid_email 함수는 &str을 매개변수로 받습니다.
이메일 형식을 검증하는 것은 읽기 전용 작업이므로 불변 참조자가 적합합니다. &str은 &String보다 더 유연하여 문자열 리터럴, String 참조, 문자열 슬라이스 모두 받을 수 있습니다.
검증 함수는 원본을 전혀 건드리지 않고 단순히 조건만 확인합니다. 그 다음으로, normalize_email 함수는 &mut String을 받아 이메일을 정규화합니다.
to_lowercase().trim().to_string()으로 새로운 String을 만들고, 역참조 *email로 원본을 교체합니다. 이는 원본 데이터를 제자리에서 수정하는 효율적인 방법입니다.
가변 참조자를 사용하므로 소유권은 이동하지 않고, 호출 후에도 main에서 user_input을 계속 사용할 수 있습니다. 세 번째 단계로, save_email 함수는 String의 소유권을 가져갑니다.
이메일을 데이터베이스에 저장하거나 다른 곳으로 이동시키는 것은 "소비" 동작입니다. 저장 후에는 더 이상 원본 데이터가 필요 없으므로 소유권을 넘기는 것이 자연스럽습니다.
함수 호출 후 user_input은 무효화되어 실수로 재사용하는 것을 방지합니다. 여러분이 이 패턴을 적용하면 각 함수의 의도가 시그니처만으로도 명확해집니다.
&T는 "읽기만 함", &mut T는 "수정함", T는 "소비함"을 의미합니다. 이는 API 설계에서 매우 중요합니다.
라이브러리 사용자가 함수 시그니처만 보고도 어떤 동작을 예상할 수 있기 때문입니다. 또한 컴파일러가 이 규칙을 강제하므로 런타임 버그가 크게 줄어듭니다.
실전 팁
💡 API 설계 시 "이 함수가 데이터를 소유해야 하는가?"를 먼저 질문하세요. 대부분의 경우 대답은 "아니오"입니다.
💡 문자열 매개변수는 &str을 기본으로 하세요. String이 필요한 경우에만 소유권을 가져가거나 &String을 씁니다.
💡 빌더 패턴에서는 &mut self를 받는 메서드로 체이닝하다가, 마지막 build()에서 self로 소비합니다.
💡 함수가 실패할 수 있다면 Result<T, E>를 반환하세요. 소유권과 에러 처리를 동시에 해결할 수 있습니다.
💡 실전에서는 대부분 불변 참조자(70%), 가변 참조자(20%), 소유권 이동(10%) 정도의 비율로 사용됩니다.