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의 내부 구조와 정확히 같습니다.
실제로 String은 Vec<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