Rust 기초: Vec으로 동적 배열 다루기

Rust 기초: Vec으로 동적 배열 다루기

Rust에서 배열([T; N])은 크기가 컴파일 시점에 고정됩니다. [i32; 5]라고 선언하면 딱 5개만 담을 수 있고, 나중에 6번째 요소를 추가할 수가 없죠.

fn main() {
    let numbers: [i32; 5] = [1, 2, 3, 4, 5];
    // numbers에 6을 추가하고 싶다면? 방법이 없습니다.
    println!("{:?}", numbers);
}

사용자 입력을 모으거나 파일에서 데이터를 읽어오거나 API 응답을 파싱하는 상황처럼 실행 시점에 데이터 개수가 정해지는 경우에는 고정 크기 배열로 해결이 안 됩니다. 이럴 때 필요한 것이 Vec<T>입니다.

Vec<T>는 힙에 데이터를 저장하는 동적 배열로, 요소를 자유롭게 추가하고 제거할 수 있습니다. Rust에서 가장 많이 쓰이는 컬렉션 타입이기도 하고요.

이 글에서는 Vec의 생성부터 내부 구조, 다양한 조작 방법, 그리고 String과 &str에서 다뤘던 소유-빌림 패턴이 Vec에서도 똑같이 적용되는 모습까지 살펴보겠습니다.

Vec 생성

Vec을 만드는 방법은 크게 세 가지입니다.

가장 흔한 방법은 vec! 매크로입니다. 초깃값을 나열하면 그 값들로 채워진 벡터가 만들어집니다.

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    println!("{:?}", numbers);

    // 같은 값으로 채우기
    let zeros = vec![0; 5];
    println!("{:?}", zeros);
}
결과
[1, 2, 3, 4, 5]
[0, 0, 0, 0, 0]

vec![0; 5]0을 5개 채운 벡터를 만듭니다. 배열의 [0; 5] 문법과 같은 형태죠.

빈 벡터를 먼저 만들고 나중에 요소를 추가하려면 Vec::new()를 씁니다.

fn main() {
    let mut fruits: Vec<&str> = Vec::new();
    fruits.push("사과");
    fruits.push("바나나");
    println!("{:?}", fruits);
}
결과
["사과", "바나나"]

타입 추론이 안 되는 상황에서는 Vec<&str>처럼 타입을 명시해야 합니다. push()로 요소를 넣으면 컴파일러가 타입을 알 수 있으니, 그때부터는 생략해도 됩니다.

마지막으로 Vec::with_capacity()는 초기 용량을 지정해서 벡터를 생성합니다.

fn main() {
    let mut buf = Vec::with_capacity(10);
    println!("길이: {}, 용량: {}", buf.len(), buf.capacity());

    buf.push(1);
    buf.push(2);
    buf.push(3);
    println!("길이: {}, 용량: {}", buf.len(), buf.capacity());
}
결과
길이: 0, 용량: 10
길이: 3, 용량: 10

길이는 0인데 용량은 10입니다. 아직 요소를 안 넣었지만 10개를 담을 공간은 미리 확보해 놓은 거죠. 대략적인 크기를 미리 알고 있다면 with_capacity()로 불필요한 재할당을 줄일 수 있습니다. 이 부분은 용량과 길이 섹션에서 더 자세히 다루겠습니다.

내부 구조

Vec<T>는 내부적으로 세 가지 정보를 스택에 저장합니다. 힙 데이터를 가리키는 포인터, 현재 요소 개수(길이), 할당된 버퍼 크기(용량)입니다.

fn main() {
    let v = vec![1, 2, 3];

    println!("포인터: {:p}", v.as_ptr());
    println!("길이: {}", v.len());
    println!("용량: {}", v.capacity());
}
결과
포인터: 0x600000cf4040
길이: 3
용량: 3

혹시 이 구조가 낯익지 않으신가요? String의 내부 구조와 정확히 같습니다. 실제로 StringVec<u8>을 감싼 타입이거든요.

// String의 실제 정의
pub struct String {
    vec: Vec<u8>,
}

String이 UTF-8 바이트의 동적 배열이라면, Vec<T>는 임의 타입 T의 동적 배열입니다. 포인터, 길이, 용량으로 이루어진 구조가 동일하고 데이터를 힙에 소유한다는 점도 같습니다.

요소 접근

벡터의 요소에 접근하는 방법을 알아보겠습니다.

가장 직관적인 방법은 인덱싱입니다.

fn main() {
    let colors = vec!["빨강", "초록", "파랑"];
    println!("첫 번째: {}", colors[0]);
    println!("두 번째: {}", colors[1]);
}
결과
 번째: 빨강
 번째: 초록

다만 범위를 벗어나는 인덱스를 쓰면 프로그램이 패닉을 일으킵니다. 안전하게 접근하려면 get() 메서드를 사용하세요. get()은 인덱스가 유효하면 Some(&T)을, 아니면 None을 반환합니다.

fn main() {
    let colors = vec!["빨강", "초록", "파랑"];

    match colors.get(1) {
        Some(color) => println!("인덱스 1: {}", color),
        None => println!("없음"),
    }

    match colors.get(10) {
        Some(color) => println!("인덱스 10: {}", color),
        None => println!("인덱스 10: 범위를 벗어남"),
    }
}
결과
인덱스 1: 초록
인덱스 10: 범위를 벗어남

first()last()로 첫 번째와 마지막 요소를 가져올 수도 있습니다. 역시 Option을 반환하기 때문에 빈 벡터에서도 안전합니다.

fn main() {
    let nums = vec![10, 20, 30, 40, 50];
    println!("첫 번째: {:?}", nums.first());
    println!("마지막: {:?}", nums.last());

    let empty: Vec<i32> = vec![];
    println!("빈 벡터의 first: {:?}", empty.first());
}
결과
 번째: Some(10)
마지막: Some(50)
 벡터의 first: None

요소 수정

Vec은 데이터를 소유하고 있기 때문에 mut으로 선언하면 자유롭게 변경할 수 있습니다.

push()는 끝에 요소를 추가하고, pop()은 마지막 요소를 꺼냅니다.

fn main() {
    let mut stack = vec![1, 2, 3];
    println!("초기: {:?}", stack);

    stack.push(4);
    println!("push(4) 후: {:?}", stack);

    let popped = stack.pop();
    println!("pop() 결과: {:?}, 벡터: {:?}", popped, stack);
}
결과
초기: [1, 2, 3]
push(4) 후: [1, 2, 3, 4]
pop() 결과: Some(4), 벡터: [1, 2, 3]

pop()Option<T>를 반환합니다. 빈 벡터에서 pop()을 호출하면 패닉 대신 None이 나오죠.

중간 위치에 넣거나 빼려면 insert()remove()를 씁니다.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    v.insert(2, 99); // 인덱스 2에 99 삽입
    println!("insert(2, 99) 후: {:?}", v);

    let removed = v.remove(2); // 인덱스 2의 요소 제거
    println!("remove(2) 결과: {}, 벡터: {:?}", removed, v);
}
결과
insert(2, 99) 후: [1, 2, 99, 3, 4, 5]
remove(2) 결과: 99, 벡터: [1, 2, 3, 4, 5]

insert()remove()는 해당 위치 뒤의 요소들을 전부 옮겨야 하므로 벡터가 클수록 비용이 커집니다. 끝에서 추가/제거하는 push()/pop()은 이런 비용이 없으니, 가능하면 끝에서 조작하는 게 효율적입니다.

조건에 맞는 요소만 남기고 싶을 때는 retain()이 유용합니다.

fn main() {
    let mut numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    numbers.retain(|&x| x % 2 == 0);
    println!("짝수만: {:?}", numbers);
}
결과
짝수만: [2, 4, 6, 8, 10]

클로저가 true를 반환하는 요소만 벡터에 남습니다. 반복문 돌면서 조건 확인하고 제거하는 코드를 한 줄로 줄일 수 있어서 상당히 편합니다.

용량과 길이

Vec에는 길이(length)와 용량(capacity)이라는 두 가지 크기 개념이 있습니다. 길이는 실제로 들어있는 요소의 개수이고 용량은 재할당 없이 담을 수 있는 공간의 크기입니다.

fn main() {
    let mut v = Vec::with_capacity(4);
    println!("초기 — 길이: {}, 용량: {}", v.len(), v.capacity());

    for i in 1..=4 {
        v.push(i);
    }
    println!("4개 추가 — 길이: {}, 용량: {}", v.len(), v.capacity());

    v.push(5); // 용량 초과!
    println!("5번째 추가 — 길이: {}, 용량: {}", v.len(), v.capacity());
}
결과
초기 길이: 0, 용량: 4
4개 추가 길이: 4, 용량: 4
5번째 추가 길이: 5, 용량: 8

처음에 용량을 4로 잡았는데 5번째 요소를 넣자 용량이 8로 늘어났습니다. 기존 버퍼에 공간이 부족하면 Vec은 더 큰 메모리를 새로 할당하고 기존 데이터를 옮겨 담습니다. 이때 용량은 보통 두 배로 커지죠.

이 동작은 String에서 push_str()로 문자열을 이어붙일 때 용량이 늘어나는 것과 같은 원리입니다. String이 내부적으로 Vec<u8>이니까 당연하죠.

재할당은 힙 메모리를 새로 잡고 데이터를 복사해야 하니 비용이 듭니다. 크기를 대략 예상할 수 있다면 Vec::with_capacity()로 미리 잡아두는 게 좋습니다.

Vec<T>와 &[T]의 관계

String과 &str의 관계를 기억하시나요? String은 문자열을 소유하는 타입이고 &str은 빌려보는 슬라이스였습니다. Vec<T>&[T]도 정확히 같은 구조입니다.

특성Vec<T>&[T]
소유권데이터를 소유빌려서 참조만
저장 위치힙 (데이터) + 스택 (메타)스택 (포인터+길이만)
변경 가능mut이면 가능불가능
크기런타임에 변경 가능고정
메모리 구성포인터 + 길이 + 용량포인터 + 길이

String과 &str 비교표와 완전히 대응되죠. String → &str 관계가 Vec<T> → &[T] 관계와 같은 겁니다.

이 변환이 자연스럽게 되는 이유는 Vec<T>Deref<Target = [T]>를 구현하고 있기 때문입니다. Deref 트레이트의 역참조 강제 덕분에 &Vec<T>는 자동으로 &[T]로 변환됩니다.

fn main() {
    let v = vec![10, 20, 30, 40, 50];

    // Vec에서 슬라이스 만들기
    let all: &[i32] = &v;        // 전체
    let slice: &[i32] = &v[1..4]; // 일부분

    println!("전체: {:?}", all);
    println!("슬라이스: {:?}", slice);
}
결과
전체: [10, 20, 30, 40, 50]
슬라이스: [20, 30, 40]

&v만 써도 &[i32]가 되고, 범위를 지정하면 일부분만 빌려볼 수 있습니다.

이터레이터

Vec을 순회하는 방법은 세 가지입니다. 각각 소유권을 어떻게 다루느냐에 따라 쓰임새가 달라집니다.

iter()는 요소의 불변 참조(&T)를 반환합니다. 원본 벡터를 건드리지 않기 때문에 순회 후에도 벡터를 계속 쓸 수 있죠.

fn main() {
    let names = vec!["Alice", "Bob", "Charlie"];

    for name in names.iter() {
        print!("{} ", name);
    }
    println!();

    println!("names는 여전히 사용 가능: {:?}", names);
}
결과
Alice Bob Charlie
names는 여전히 사용 가능: ["Alice", "Bob", "Charlie"]

into_iter()는 벡터의 소유권을 가져가면서 요소 자체(T)를 반환합니다. 순회가 끝나면 원본 벡터는 소비되어 더 이상 사용할 수 없고요.

fn main() {
    let names = vec![String::from("Alice"), String::from("Bob")];

    for name in names.into_iter() {
        print!("{} ", name);
    }
    println!();

    // println!("{:?}", names); // 컴파일 에러! names는 소비됨
}
결과
Alice Bob

iter_mut()는 요소의 가변 참조(&mut T)를 반환해서 순회하면서 값을 수정할 수 있습니다.

fn main() {
    let mut prices = vec![100, 200, 300];

    for price in prices.iter_mut() {
        *price += 50;
    }

    println!("가격 인상 후: {:?}", prices);
}
결과
가격 인상 후: [150, 250, 350]

정리하면 읽기만 하면 iter(), 소유권을 넘기며 순회하면 into_iter(), 수정하면서 순회하면 iter_mut()입니다.

유용한 패턴

이터레이터를 벡터로 모아주는 collect()부터 보겠습니다. map()이나 filter() 같은 이터레이터 어댑터와 조합하면 데이터를 변환하면서 새 벡터를 만들 수 있죠.

fn main() {
    let doubled: Vec<i32> = vec![1, 2, 3].iter().map(|x| x * 2).collect();
    println!("두 배: {:?}", doubled);

    let sentence = "Rust는 정말 재미있습니다";
    let words: Vec<&str> = sentence.split_whitespace().collect();
    println!("단어들: {:?}", words);
}
결과
 배: [2, 4, 6]
단어들: ["Rust는", "정말", "재미있습니다"]

collect()는 반환 타입을 보고 어떤 컬렉션으로 모을지 결정합니다. Vec<i32>라고 써주면 벡터로, String이라고 써주면 문자열로 모아주죠.

기존 벡터에 다른 이터레이터의 요소를 추가하는 extend()도 자주 쓰입니다.

fn main() {
    let mut a = vec![1, 2, 3];
    a.extend([4, 5, 6]);
    println!("extend 후: {:?}", a);

    let extra = vec![7, 8, 9];
    a.extend(extra.iter());
    println!("한 번 더 extend: {:?}", a);
}
결과
extend 후: [1, 2, 3, 4, 5, 6]
 extend: [1, 2, 3, 4, 5, 6, 7, 8, 9]

drain()은 좀 특이한데, 벡터에서 지정한 범위의 요소를 꺼내면서 동시에 제거합니다. 꺼낸 요소는 이터레이터로 반환되고 원본 벡터에서는 사라지죠.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];
    let drained: Vec<i32> = v.drain(1..3).collect();

    println!("꺼낸 요소: {:?}", drained);
    println!("남은 벡터: {:?}", v);
}
결과
꺼낸 요소: [2, 3]
남은 벡터: [1, 4, 5]

인덱스 1부터 2까지(3 미포함)의 요소를 꺼냈고, 원본에는 나머지만 남았습니다.

함수 매개변수는 &[T]로

String 글에서 함수 매개변수를 &str로 받으라고 했던 것 기억하시나요? 같은 이유로, 함수가 벡터를 읽기만 한다면 매개변수를 &[T]로 받는 것이 좋습니다.

fn contains_target(haystack: &[i32], target: i32) -> bool {
    haystack.contains(&target)
}

fn main() {
    let v = vec![10, 20, 30, 40, 50];
    println!("30 포함? {}", contains_target(&v, 30));
    println!("99 포함? {}", contains_target(&v, 99));

    // 고정 크기 배열도 전달 가능
    let arr = [1, 2, 3, 4, 5];
    println!("3 포함? {}", contains_target(&arr, 3));
}
결과
30 포함? true
99 포함? false
3 포함? true

&[i32]로 받으면 Vec<i32>뿐 아니라 고정 크기 배열 [i32; N]도 넘길 수 있습니다. 역참조 강제가 &Vec<T>&[T]로, &[T; N]&[T]로 자동 변환해주니까요.

&Vec<T> 대신 &[T]로 받으면 호출하는 쪽의 자유도가 높아지고 불필요하게 Vec 타입에 묶이지 않습니다. &String 대신 &str을 선호하는 것과 완전히 같은 이유죠.

반대로 함수 안에서 벡터를 만들어 반환해야 한다면 Vec<T>를 쓰면 됩니다. “읽기만 하면 &[T], 만들어서 돌려줄 때는 Vec<T>”가 기본 원칙입니다.

마치며

Vec<T>는 Rust에서 가장 기본적이면서도 강력한 컬렉션입니다. 포인터, 길이, 용량으로 이루어진 내부 구조는 String과 동일하고, Deref<Target = [T]> 구현 덕분에 &[T] 슬라이스로 자연스럽게 변환됩니다.

String&str의 소유-빌림 관계를 이해했다면 Vec<T>&[T]도 이미 파악한 셈입니다. 함수 매개변수는 &[T]로 받아서 유연성을 확보하고, 데이터를 소유해야 할 때만 Vec<T>를 쓰는 패턴을 기억해두세요.

나중에 as_deref()를 배우면 Option<Vec<T>>Option<&[T]>로 변환하는 것도 한 줄이면 된다는 걸 알게 될 겁니다.

더 자세한 내용은 std::vec::Vec - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord