Rust의 Slice와 컬렉션 참조

Rust의 slice(슬라이스)는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입이고, 컬렉션 참조는 컬렉션(배열 포함) 전체에 대한 참조입니다. 그러나, 모두 소유권을 갖지 않고 데이터를 참조하는 것은 같지만, 그 목적과 내부 구조, 사용 방식에 중요한 차이가 있습니다.

1. 슬라이스(Slice)

가.기본 개념

  • 슬라이스는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입으로, &[T] 또는 &str(문자열 슬라이스)와 같이 참조 형태로 표현된다.
  • 슬라이스는 원본 데이터의 소유권을 이동시키지 않으며, 원본 데이터가 유효할 때만 사용할 수 있다.
  • 슬라이스는 연속된 메모리 블록의 시작 주소와 길이 정보를 함께 저장하는 fat pointer(두 개의 값: 포인터 + 길이)이다.
  • 슬라이스는 항상 연속적인 데이터만 참조할 수 있습니다.

나.문법과 사용법

  • 슬라이스는 [start..end] 범위 문법을 사용합니다.
    • start는 포함, end는 포함하지 않습니다(즉, 반 열린 구간).
      let a = [1, 2, 3, 4, 5];
      let slice = &a[1..4]; // [2, 3, 4]
  • start나 end를 생략하면 처음이나 끝을 의미합니다.
    &a[..3] → 처음부터 3번째 전까지
    &a[2..] → 2번째부터 끝까지(끝 포함)
    &a[..] → 전체

다. 문자열 슬라이스

  • 문자열 슬라이스는 String이나 문자열 리터럴의 일부를 참조하며 타입은 &str 입니다.
  • 문자열 일부 참조
    let s = String::from(“hello world”);
    let hello = &s[0..5]; // “hello”
    let world = &s[6..11]; // “world”
  • 문자열 리터럴 
    let s = “hello, world”;     // &’static str
    let part: &str = &s[0..5]; // “hello” 부분만 참조
    “hello” 자체도 &str 타입의 슬라이스이므로, 문자열 리터럴의 일부도 참조할 수 있습니다.

라. 특징과 주의점

  • 슬라이스는 참조이므로, 원본 데이터가 유효해야 한다.
    원본 데이터가 변경(예: String의 clear 등)되면 기존 슬라이스는 무효화되어 컴파일 에러가 발생합다.
  • 슬라이스는 소유권과 라이프타임 개념을 이해하는 데 중요한 역할을 합니다. -> 아래 ‘3. 슬라이스와 라이프타임’에서 설명
  • 슬라이스는 함수의 파라미터로 자주 사용됩니다.
fn main() {
    let s = "hello, world"; 
    println!("First two elements: {:?}", first_two(s));
}

fn first_two(slice: &str) -> &str {
    if slice.len() < 2 {
        panic!("Slice must have at least two elements");
    }
    &slice[0..2]
}

위 코드에서 s는 문자열 리터럴인 문자열 슬라이스이므로 함수로 넘길 때는 &를 안붙여도 되지만(붙여도 됨), 함수에서 받을 때는 타입을 &str으로 지정하고, 반환도 &slice[0..2]이므로&str 타입으로 지정해야 합니다.

실행 결과는 First two elements: “he”입니다.

2. 컬렉션 참조

가. 개념

  • 컬렉션 참조는 컬렉션 전체에 대한 참조입니다. 예를 들어, &Vec, &String, &[T; N] 등입니다. 여기서 &[T; N]은 배열 전체에 대한 참조입니다. 엄격하게 말하면 배열은 컬렉션은 아니지만 “배열 전체를 참조한다”는 측면에서 컬렉션 참조에 포함해서 설명합니다.
  • 이 참조는 해당 컬렉션 타입의 모든 메서드와 속성을 사용할 수 있게 해줍니다.
  • 컬렉션 참조는 그 자체가 소유한 데이터 전체를 가리키며, 슬라이스처럼 부분만을 가리키지는 않습니다.
  • 예시:
    let v = vec![1, 2, 3];
    let r = &v; // Vec 전체에 대한 참조

벡터 컬렉션 참조의 간단한 예시는 다음과 같습니다.

fn main() {
let v = vec![100, 32, 57]; // Vec<i32> 생성
// 벡터에 대한 불변 참조를 사용하여 각 요소 출력
for i in &v {
println!("{}", i);
}
}
  • 여기서 &v는 Vec 타입의 벡터에 대한 불변 참조(&Vec)입니다.
  • for i in &v 구문은 벡터의 각 요소에 대한 참조(&i32)를 순회하며 안전하게 접근합니다.
  • 벡터 자체를 소유하지않고 참조만 하므로, 함수 인자나 반복문에서 자주 활용됩니다.

나. 예제

fn print_array(arr: &[i32; 3]) {
println!("{:?}", arr);
}

fn main() {
let data = [10, 20, 30];
print_array(&data); // &[i32; 3] 타입의 참조 전달
}
  • 위 예제에서 print_array 함수는 크기가 3인 i32 배열에 대한 참조(&[i32; 3])를 인자로 받습니다.
  • &data는 [i32; 3] 타입 배열 전체에 대한 참조입니다.

다. 슬라이스와 주요 차이점 비교

구분슬라이스
(&[T], &str)
컬렉션 참조 (&Vec<T>, &String)
대상컬렉션의 일부(연속 구간)컬렉션 전체
내부 구조포인터 + 길이 (fat pointer)포인터 (컬렉션 구조체 전체)
메서드 사용슬라이스 관련 메서드만 사용 가능컬렉션의 모든 메서드 사용 가능(벡터의 len(), push() 등
용도부분 참조, 함수 인자 등전체 참조, 컬렉션의 메서드 활용
동적 크기길이가 런타임에 결정됨컬렉션 타입에 따라 다름
예시&arr[1..3], &vec[..], &s[2..]&vec, &arr, &String

&[T; N]은 배열 전체를 참조하고, &[T]는 슬라이스로, 배열의 일부 구간(혹은 전체)을 참조할 수 있습니다.
예를 들어, &arr[1..3]은 &[T] 타입(슬라이스)이지만, &arr은 &[T; N] 타입(컬렉션 참조)입니다. 이처럼 &[T; N]은 정확히 N개 원소를 가진 배열 전체에 대한 참조입니다.

또한 &str과 &String은 문자열 슬라이스와 문자열 참조로 다르지만, &String이 &str으로 자동 변환이 가능하기 때문에 함수 인자로는 &str이 더 범용적이고 권장됩니다.

3. 슬라이스와 라이프타임

가. 개념

Rust에서 라이프타임(lifetime)은 참조가 유효한 기간을 명확하게 지정하는 개념으로, 참조의 유효성에 직접적인 영향을 미칩니다. 라이프타임이 중요한 이유와 그 영향은 다음과같습니다.

  • 댕글링 참조 방지
    라이프타임의 가장 큰 목적은 댕글링 참조(dangling reference)를 방지하는 것입니다. 댕글링 참조란, 이미 스코프를 벗어나 소멸된 데이터를 참조하는 상황을 의미합니다. Rust는 각 참조자의 라이프타임을 추적하여, 참조가 원본 데이터보다 오래 살아남을 수 없도록 컴파일 타임에 강제합니다.
  • 안전한 메모리 접근 보장
    라이프타임을 통해 참조자가 항상 유효한 메모리만 가리키도록 보장합니다. 예를 들어, 아래와 같은 코드는 컴파일되지 않습니다.

    let r; // r의 라이프타임 시작
    {
    let x = 5; // x의 라이프타임
    r = &x; // r이 x를 참조
    } // x의 라이프타임 종료, r은 더 이상 유효하지 않음
    println!(“{}”, r); // 컴파일 에러!
    Rust는 위 코드에서 r이 x보다 오래 살아남으려 하므로 컴파일 에러를 발생시킵니다.
  • 함수와 구조체에서의 명확한 라이프타임 관계
    여러 참조가 얽히는 함수나 구조체에서는, 각각의 참조가 얼마나 오래 유효해야 하는지 명확히 지정해야 합니다. 라이프타임 파라미터를 명시함으로써, 함수가 반환하는 참조가 입력 참조자 중 어느 것과 연관되어 있는지 컴파일러가 알 수 있습니다.
  • 암묵적 추론과 명시적 지정
    대부분의 경우 Rust는 라이프타임을 자동으로 추론하지만, 여러 참조가 얽히거나 복잡한 관계가 있을 때는 명시적으로 라이프타임을 지정해야 합니다. 이를 통해 참조 유효성에 대한 컴파일러의 보장이 더욱 강력해집니다.

나. 예제 1

Rust에서 슬라이스의 라이프타임이 중요한 이유를 보여주는 대표적인 예제는, 두 개의 문자열 슬라이스 중 더 긴 쪽을 반환하는 함수입니다. 이 예제를 통해 슬라이스의 라이프타임을 명확히 지정하지 않으면 컴파일 에러가 발생하고, 올바르게 지정하면 안전하게 참조를 반환할 수 있음을 알 수 있습니다.

fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}

// 라이프타임 명시가 반드시 필요!
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
  • 이 함수 시그니처에서 ‘a라는 라이프타임 파라미터를 명시했습니다.
  • 이 뜻은, x와 y 모두 최소한 ‘a만큼 살아 있어야 하며, 반환되는 슬라이스도 ‘a만큼 살아 있어야 한다는 의미입니다.
  • 실제로 함수가 호출될 때, Rust는 x와 y의 라이프타임 중 더 짧은 쪽에 맞춰 반환값의 라이프타임을 제한합니다. 따라서, ‘a와 ‘b를 사용해서
    fn longest<‘a, ‘b>(x: &’a str, y: &’b str) -> &’a str
    라고 하면 x가 반환될 수도 있고 y가 반환될 수도 있기 때문에 에러가 발생합니다.
  • 또한 라이프타임을 명시하지 않아도, Rust는 반환되는 참조가 입력 참조자 중 어느 쪽과 연관되어 있는지 알 수 없어 컴파일 오류가 발생합니다. 따라서, 라이프타임을 ‘a로 통일한 것입니다.

이처럼, 슬라이스의 라이프타임을 명확히 지정하지 않으면 댕글링 참조가 생길 수 있고, Rust는 이를 컴파일 타임에 방지합니다.
따라서 라이프타임 명시는 슬라이스가 안전하게 사용될 수 있는 핵심적인 장치입니다.

위 코드에서 as_str()은 String 타입을 &str 타입으로 변환하는 메소드입니다.

다. 예제2

fn main() {
let result;
{
let string1 = String::from("abc");
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
// string1, string2는 여기서 소멸
}
println!("The longest string is {}", result); // 컴파일 에러!
}
  • 위 코드는 result가 내부 스코프의 데이터인 string1 또는 string2의 슬라이스를 참조하게 되므로, 컴파일 에러를 발생시켜 댕글링 참조를 차단합니다.

라이프타임(Lifetime)

Rust는 메모리 안전성을 보장하기 위해 소유권과 함께 라이프타임이라는 개념을 도입합니다. Rust는 라이프타임을 자동으로 추론하지만, 필요한 경우 참조가 유효한 범위인 라이프타임을 컴파일러에게 명시적으로 알려주어 댕글링 참조(dangling reference)를 방지합니다.

댕글링 참조는 메모리가 해제된 이후에도 해당 메모리 위치를 가리키는 참조를 말합니다. 즉, 참조자가 가리키는 메모리가 더 이상 유효하지 않은 상태를 의미합니다. 이러한 댕글링 참조는 프로그램 충돌이나 데이터 손상을 유발할 수 있는 잠재적인 위험을 가지고 있습니다.

1. 라이프타임이 필요한 이유

다음 코드를 보세요.

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // ❌ x는 여기서 소멸됨
    }
    println!("{}", r); // 컴파일 에러!
}
  • x는 내부 블록에서 생성되어 그 블록이 끝나면 해제되는데,
  • r은 그보다 오래 살아 남으므로 유효하지 않은 x를 참조가 됩니다. 이것을 댕글링 참조라고 합니다.
  • Rust는 댕글링 참조를 컴파일 타임에 잡아냅니다.
댕글링 참조(dangling reference)

2. 기본 라이프타임 추론

위와 같이 대부분의 경우, Rust는 라이프타임을 자동으로 추론합니다. 그러나 두 개 이상의 참조가 관련되면 명시적 표기가 필요합니다.

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("world");
    let result = longest(&s1, &s2);
    println!("The longest string is: {}", result);
}

이 함수는 x, y 두 개 중 어떤 것이 수명이 긴지 알 수가 없어서 컴파일 에러 발생 가능성이 있으므로 라이프타임 명시가 필요합니다.

lifetime 에러

3. 라이프타임 명시하기

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
  • ‘a는 라이프타임 매개변수입니다.
  • x, y, 반환값에 모두 ‘a를 붙여 “동일한 수명을 가진다”는 것을 명시해야 합니다.
  • 입력 참조가 없어진 상태에서 반환값이 살아 있으면, 다시 말해 반환값이 입력 참조보다 수명이 길면 오류가 발생하기 때문입니다.

4. 구조체에서의 라이프타임

아래와 같이 구조체 필드의 형식이 String인 경우는 Lifetime을 명시하지 않아도 되나,

struct Excerpt {
    part: String,
}

fn main() {
    let text = String::from("Rust는 안전하다.");
    let excerpt = Excerpt { part: text };
    println!("{}", excerpt.part);
}

구조체 필드의 타입이 참조인 경우, 여기서는 &str(문자열 슬라이스), 반드시 라이프타임을 명시해야 합니다.

라이프타임 지정시 구조체명 오른쪽의 <> 안에 라이프타임 매개변수를 ‘a라고 명시했고, 필드의 형식에도 라이프타임 매개변수 ‘a를 추가했습니다.

struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let text = String::from("Rust는 안전하다.");
    let excerpt = Excerpt { part: &text[0..4] };
    println!("{}", excerpt.part);
}
  • Excerpt<‘a>는 part 필드가 ‘a 동안 유효함을 의미합니다. 이 말은 결국, “Excerpt 구조체는 자신이 가리키는 문자열(&str)보다 더 오래 살아있을 수 없다”는 제약을 의미하며, “Excerpt 구조체 내의 part 필드는 text가 살아 있는 동안만 유효하다”는 의미도 됩니다.
  • 출력 결과는 Rust입니다.

5. 함수 내 수명 충돌 예시

가. 문제

fn return_ref<'a>(x: &'a str, y: &str) -> &'a str {
    // x // ✔ OK
    return y; // ❌ y는 'a보다 짧은 수명일 수 있음
}
fn main() {
    let s1 = String::from("hello");
    let s2 = String::from("world");
    let r = return_ref(&s1, &s2);
    println!("r = {}", r);
}
  • 함수의 반환값이 ‘a 수명을 가진 참조여야 하므로,
    라이프타임 매개변수 ‘a를 갖지 않은 y는 반환할 수 없습니다.

나. 해결

fn return_ref<'a>(x: &'a str, y: &'a str) -> &'a str {
    // x // ✔ OK
    return y; // ❌ y는 'a보다 짧은 수명일 수 있음
}
  • y를 출력하고자 하는 경우는 y: &str을 y: &’a str로 수정해야 합니다.
  • x를 출력하려고 하는 경우는 x 앞의 주석을 제거하고, return y;를 주석 처리하면 됩니다.

6. ‘static 라이프타임

가. 반환 형식이 String인 경우 문제 없음

아래와 같이 반환 타입이 String인 경우는 문제가 없는데, 출력이 잘 됩니다.

fn get_static_str() -> String {
    String::from("나는 프로그램 전체에서 살아있다!")
}

fn main() {
    let msg = get_static_str();
    println!("{}", msg);
}

나. 반환 형식이 &str인 경우 문제 있음

아래와 같이 반환 형식이 &str인 경우는 실행 시

fn get_static_str() -> &str {
    "나는 프로그램 전체에서 살아있다!"
}

fn main() {
    let msg = get_static_str();
    println!("{}", msg);
}

“라이프타임 매개변수 지정이 기대된다”고 하면서 빌려올 값이 없으므로 ‘static을 추가하거나, 반환 형식을 String으로 하라고 제안합니다.

'static 라이프타임 에러
  • 'static프로그램 전체 수명을 의미
  • 문자열 리터럴 등 컴파일 타임 상수에 주로 사용

다. 수정 코드

&str을 &’static str로 수정하면 문제 없이 출력됩니다.

fn get_static_str() -> &'static str {
    "나는 프로그램 전체에서 살아있다!"
}

fn main() {
    let msg = get_static_str();
    println!("{}", msg);
}
'static을 추가하여 정상적으로 출력된 화면

7. 요약 정리

개념설명
라이프타임 ‘a참조가 얼마나 오래 유효한지 표시하는 표기
함수 수명 명시여러 참조 중 어느 것이 반환되는지(라이프타임)를 명확히 지정
구조체 + 참조참조 필드가 있으면 명시적 라이프타임 필요
‘static프로그램 전체 수명 (전역 문자열 등)
오류 예방 목적댕글링 참조를 컴파일 타임에 방지함

8. 결론

라이프타임은 Rust의 가장 강력하면서도 헷갈릴 수 있는 개념입니다. 하지만 “누가 누구보다 오래 살아야 하는가”를 생각하면서 설계하면, 오히려 런타임 오류 없이 안전한 코드를 보장받을 수 있습니다.