Rust의 핵심 개념인 소유권 규칙 3가지, Move 시맨틱스, 불변/가변 빌림, 라이프타임 기초를 백엔드 개발자 관점에서 체계적으로 다룹니다.
&str, Vec vs Slice의 관계를 파악합니다다른 언어에서는 메모리 관리를 개발자(C/C++)나 런타임(Java, Go, Python)에게 맡깁니다. Rust는 제3의 방법을 선택했습니다. 컴파일러가 소유권 규칙을 검증하여 메모리 안전성을 보장하는 것입니다.
이것은 단순한 기술적 선택이 아닙니다. 메모리 안전성 버그는 모든 보안 취약점의 약 70%를 차지한다는 Microsoft와 Google의 보고가 있습니다. 소유권 시스템은 이 문제를 근본적으로 해결합니다.
Rust의 소유권 시스템은 세 가지 규칙으로 요약됩니다.
fn main() {
let s1 = String::from("hello"); // s1이 String의 소유자
let s2 = s1; // 소유권이 s1에서 s2로 이동(move)
// println!("{}", s1); // 컴파일 에러! s1은 더 이상 유효하지 않음
println!("{}", s2); // s2가 소유자이므로 사용 가능
} // s2가 스코프를 벗어남 → 메모리 자동 해제이 규칙들이 합쳐지면 다음을 보장합니다.
위 예제에서 let s2 = s1을 실행하면 소유권이 **이동(Move)**합니다. 이것은 복사가 아닙니다.
단, 스택에 저장되는 단순한 타입들은 Move 대신 **Copy(복사)**됩니다. 이를 **Copy 트레이트(trait)**를 구현한 타입이라 합니다.
fn main() {
// Copy 타입: 정수, 부동소수점, bool, char, 고정 크기 튜플
let x = 42;
let y = x; // Copy — x도 여전히 유효
println!("x={}, y={}", x, y); // 정상 동작
// Move 타입: String, Vec, Box 등 힙 데이터를 소유하는 타입
let s1 = String::from("hello");
let s2 = s1; // Move — s1은 무효
// println!("{}", s1); // 컴파일 에러
}규칙은 간단합니다. 스택에만 존재하는 고정 크기 타입은 Copy, 힙 데이터를 소유하는 타입은 Move입니다. 직접 만든 구조체도 모든 필드가 Copy이면 #[derive(Copy, Clone)]으로 Copy를 구현할 수 있습니다.
모든 곳에서 소유권을 이동시키면 코드가 매우 불편해집니다. 그래서 Rust는 **빌림(Borrowing)**을 제공합니다. 소유권을 넘기지 않고 값을 참조할 수 있는 방법입니다.
&T 형태로 불변 참조를 만듭니다. 동시에 여러 개의 불변 참조가 존재할 수 있습니다.
fn calculate_length(s: &String) -> usize {
s.len()
// s는 빌린 것이므로 여기서 해제되지 않음
}
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // s1을 빌려줌
println!("'{}'의 길이는 {}", s1, len); // s1 여전히 사용 가능
}&mut T 형태로 가변 참조를 만듭니다. 한 시점에 가변 참조는 단 하나만 존재할 수 있습니다.
fn append_world(s: &mut String) {
s.push_str(", world");
}
fn main() {
let mut s = String::from("hello");
append_world(&mut s);
println!("{}", s); // "hello, world"
}Rust의 빌림 규칙은 두 가지입니다.
&T 여러 개)&mut T 하나)fn main() {
let mut s = String::from("hello");
let r1 = &s; // 불변 참조 — OK
let r2 = &s; // 불변 참조 — OK (여러 개 가능)
// let r3 = &mut s; // 컴파일 에러! 불변 참조가 있는 동안 가변 참조 불가
println!("{} and {}", r1, r2);
// r1, r2는 여기서 마지막으로 사용됨 (NLL 덕분에 여기서 빌림 종료)
let r3 = &mut s; // 이제 가변 참조 가능
r3.push_str("!");
println!("{}", r3);
}**NLL(Non-Lexical Lifetimes)**은 Rust 2018 에디션부터 적용된 기능입니다. 참조의 수명이 마지막 사용 지점에서 끝나므로, 위 예제처럼 r1, r2를 마지막으로 사용한 후에는 가변 참조를 만들 수 있습니다.
이 규칙이 왜 중요할까요? 이 규칙이 **데이터 레이스(data race)**를 컴파일 타임에 방지하기 때문입니다. 데이터 레이스는 다음 세 조건이 동시에 충족될 때 발생합니다.
Rust의 빌림 규칙은 이 세 조건이 동시에 충족되는 것을 원천 차단합니다.
&str백엔드 개발에서 문자열은 가장 자주 다루는 타입입니다. Rust에는 두 가지 핵심 문자열 타입이 있습니다.
fn main() {
// String: 힙에 할당, 소유권 있음, 가변 가능
let mut owned = String::from("hello");
owned.push_str(", world");
// &str: 문자열 슬라이스, 빌림, 불변
let slice: &str = &owned; // String에서 &str로 빌림
let literal: &str = "hello"; // 문자열 리터럴은 &str
greet(&owned); // String → &str 자동 변환 (Deref coercion)
greet(literal); // &str 직접 전달
}
fn greet(name: &str) {
println!("Hello, {}!", name);
}| 특성 | String | &str |
|---|---|---|
| 저장 위치 | 힙 | 어디서든 빌림 |
| 소유권 | 있음 | 없음 (빌림) |
| 가변성 | 가변 가능 | 불변 |
| 크기 | 가변 | 고정 (포인터 + 길이) |
| 함수 매개변수 권장 | 소유권이 필요할 때 | 읽기만 할 때 |
함수의 매개변수로 문자열을 받을 때는 &str을 사용하는 것이 관례입니다. 호출자가 String이든 &str이든 모두 받을 수 있기 때문입니다. 소유권이 필요한 경우에만 String을 받으세요.
동일한 패턴이 벡터에도 적용됩니다.
fn sum(numbers: &[i32]) -> i32 {
numbers.iter().sum()
}
fn main() {
let v: Vec<i32> = vec![1, 2, 3, 4, 5];
let total = sum(&v); // Vec<i32> → &[i32] 자동 변환
println!("합계: {}", total);
let partial = sum(&v[1..4]); // 슬라이스: [2, 3, 4]
println!("부분 합계: {}", partial);
}Vec<T>가 소유하는 타입이라면, &[T]는 빌리는 타입(슬라이스)입니다. String과 &str의 관계와 동일합니다.
**라이프타임(Lifetime)**은 참조가 유효한 범위를 나타냅니다. 대부분의 경우 컴파일러가 자동으로 추론하지만, 때로는 명시적으로 지정해야 합니다.
// 두 문자열 참조 중 더 긴 것을 반환
// 반환값이 어떤 입력의 참조인지 컴파일러가 판단할 수 없으므로
// 라이프타임을 명시해야 합니다
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("xyz");
result = longest(&s1, &s2);
println!("더 긴 문자열: {}", result); // s2가 아직 유효하므로 OK
}
// println!("{}", result); // 에러! s2가 스코프를 벗어남
}'a라는 라이프타임 매개변수는 "반환값의 참조는 x와 y 중 더 짧은 수명을 따른다"는 것을 의미합니다. 이를 통해 댕글링 참조를 방지합니다.
라이프타임이 처음에는 복잡하게 느껴질 수 있지만, 백엔드 개발에서는 대부분 구조체에 참조를 저장할 때만 명시적으로 작성합니다. 함수의 경우 컴파일러의 **라이프타임 생략 규칙(Lifetime Elision Rules)**이 대부분 자동 처리합니다.
소유권 개념을 다른 언어의 경험에 빗대어 이해해 봅시다.
// Java에서 이렇게 작성하면:
// String s1 = new String("hello");
// String s2 = s1; // 같은 객체를 참조 (GC가 관리)
// Rust에서는:
let s1 = String::from("hello");
let s2 = s1.clone(); // 명시적 복제 — 깊은 복사
println!("{} {}", s1, s2); // 둘 다 사용 가능 (각자 소유)
// 또는 빌림으로:
let s3 = String::from("world");
let s4 = &s3; // 빌림 — 복사 없이 참조
println!("{} {}", s3, s4); // 둘 다 사용 가능| 개념 | Java/Go | Rust |
|---|---|---|
| 메모리 관리 | GC가 자동 회수 | 소유권 + 스코프 기반 자동 해제 |
| 값 전달 | 참조 복사 (얕은 복사) | Move (소유권 이동) |
| 깊은 복사 | clone() 명시 | clone() 명시 |
| 참조 공유 | 자유롭게 공유 | 빌림 규칙 준수 필요 |
| 데이터 레이스 | 런타임에 발견 | 컴파일 타임에 방지 |
백엔드 코드에서 소유권이 실제로 어떻게 적용되는지 미리 살펴봅시다.
use axum::{Json, extract::State};
use std::sync::Arc;
// AppState는 Arc로 감싸서 여러 핸들러가 공유
struct AppState {
db_pool: String, // 실제로는 sqlx::PgPool
}
// Json<CreateUser>: 요청 본문의 소유권을 핸들러가 가져감
// State<Arc<AppState>>: 공유 상태를 빌림
async fn create_user(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateUser>,
) -> Json<User> {
// payload는 이 핸들러가 소유 — 자유롭게 사용
// state는 Arc로 공유 — 여러 핸들러가 동시 접근 가능
let user = User {
name: payload.name, // Move: payload.name의 소유권이 user.name으로 이동
};
Json(user)
}
struct CreateUser { name: String }
struct User { name: String }이 패턴은 5장에서 자세히 다루겠지만, 소유권이 실제 백엔드 코드에서 어떻게 자연스럽게 녹아드는지 보여줍니다.
이 장에서는 Rust의 핵심 개념인 소유권과 빌림을 다루었습니다.
&str: 소유 vs 빌림의 대표적인 쌍입니다다음 장에서는 Rust의 타입 시스템과 에러 처리를 다룹니다. 구조체, 열거형, 트레이트를 배우고, Result와 Option을 활용한 안전한 에러 처리 패턴을 익힙니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
구조체, 열거형, 트레이트부터 Result/Option, thiserror/anyhow를 활용한 에러 처리 패턴까지 Rust 타입 시스템의 핵심을 다룹니다.
Rust의 고유 가치인 안전성과 성능, 백엔드 생태계 현황, GC 없는 메모리 관리, 2026년 채택 현황까지 백엔드 개발자 관점에서 Rust를 배워야 하는 이유를 살펴봅니다.
Rust의 비동기 프로그래밍 기초부터 Future 트레이트, Tokio 런타임, spawn/select/join, 채널 기반 동시성 패턴까지 체계적으로 다룹니다.