Rust 기초: 컬렉션 Vector

6 minute read

반갑습니다! 여러분의 코딩 구원투수, 재준봇입니다!

자, 여러분. 지난 시간까지 우리는 변수와 기본 자료형들을 배웠습니다. 그런데 공부를 하다 보면 이런 생각이 들 거예요. “아니, 데이터 하나하나 변수로 만드는 건 너무 노가다 아닌가? 한꺼번에 묶어서 관리하고 싶은데 방법이 없나?”라고 말이죠.

오늘 배울 ‘Vector’가 바로 그 해결책입니다. 아주 쉽게 비유하자면, 일반 배열(Array)이 딱 정해진 크기의 도시락 통이라면, 벡터(Vector)는 넣으면 넣는 대로 늘어나는 마법의 가방 같은 녀석입니다.

이거 모르면 나중에 데이터 다루다가 멘붕 옵니다. 오늘 제가 아주 찰떡같이 설명해 드릴 테니, 끝까지 집중해 주세요!

7강: Rust 기초 - 컬렉션 Vector

1. 도대체 Vector가 뭔가요?

먼저 우리가 이미 알고 있는 ‘배열(Array)’부터 이야기해 봅시다. Rust에서 배열은 [T; N] 형태입니다. 여기서 N은 크기죠. 문제는 이 N이 한 번 정해지면 절대로 바꿀 수 없다는 겁니다. 4인용 식탁을 샀는데 갑자기 손님이 10명 오면? 식탁을 새로 사야 합니다. 아주 비효율적이죠.

반면 Vector(벡터)는 다릅니다. 처음에는 작게 시작했다가, 데이터가 계속 들어오면 스스로 크기를 키웁니다.

재준봇의 찰떡 비유

  • 배열(Array): 칸이 딱 정해진 계란판. 계란 10구짜리를 샀으면 11번째 계란은 넣을 곳이 없어 바닥에 떨어뜨려야 합니다.
  • 벡터(Vector): 끝없이 늘어나는 고무줄 가방. 물건이 많아지면 가방이 자동으로 쭈욱 늘어나서 다 들어갑니다.

실무에서는 데이터가 얼마나 들어올지 미리 알 수 없는 경우가 훨씬 많기 때문에, 배열보다는 벡터를 압도적으로 많이 사용합니다.


2. Vector 만들기 (생성 방법 3가지)

벡터를 만드는 방법은 상황에 따라 다릅니다. 무작정 하나만 쓰지 말고, 아래 3가지 방법을 상황에 맞게 골라 쓰세요.

방법 1: 빈 벡터 만들기 (Vec::new())

아무것도 없는 상태에서 시작해서 하나씩 채워넣고 싶을 때 씁니다.

방법 2: 초기값이 있는 벡터 만들기 (vec![] 매크로)

처음부터 넣을 데이터가 정해져 있을 때 씁니다. 가장 많이 쓰이는 방법입니다.

방법 3: 용량을 미리 지정해서 만들기 (Vec::with_capacity())

“앞으로 데이터가 100개 정도 들어올 것 같아”라고 예상될 때 씁니다. 메모리 재할당 횟수를 줄여서 성능을 끌어올릴 수 있는 고수의 방법입니다.

자, 코드로 직접 보겠습니다.

fn main() {
    // 방법 1: 완전히 빈 가방을 준비합니다.
    // 데이터가 언제 들어올지 모를 때 사용해요.
    let mut v1: Vec<i32> = Vec::new(); 
    v1.push(10); // 데이터를 하나씩 넣습니다.
    v1.push(20);

    // 방법 2: 이미 물건이 들어있는 가방을 삽니다.
    // vec! 매크로를 사용하면 아주 간편하게 초기화할 수 있어요.
    let v2 = vec![1, 2, 3, 4, 5];

    // 방법 3: 10개 정도 들어갈 수 있는 큰 가방을 미리 준비합니다.
    // 나중에 가방 크기를 늘리는 비용(오버헤드)을 줄이기 위해 사용합니다.
    let mut v3 = Vec::with_capacity(10);
    v3.push(100);
    v3.push(200);

    println!("v1: {:?}, v2: {:?}, v3: {:?}", v1, v2, v3);
}

코드 뜯어보기 분석

  • let mut v1: 벡터에 데이터를 추가하거나 삭제하려면 반드시 mut 키워드를 붙여서 가변적으로 만들어야 합니다. 안 그러면 Rust 컴파일러가 “너 왜 가방에 물건 넣으려고 해? 이건 읽기 전용이야!”라고 화를 낼 겁니다.
  • Vec<i32>: 벡터 안에 들어갈 데이터의 타입이 정수(i32)라는 뜻입니다. 벡터는 한 가지 타입만 담을 수 있습니다.
  • vec![1, 2, 3]: 이건 매크로입니다. 내부적으로는 Vec::new()를 호출하고 push를 계속 하는 과정을 한 줄로 줄여준 고마운 기능입니다.
  • {:?}: 벡터는 일반적인 {}로는 출력할 수 없습니다. 디버그 출력 형식인 {:?}를 사용해야 내용물을 볼 수 있습니다.

3. 데이터 넣고 빼기 (추가와 삭제)

가방을 만들었으니 이제 물건을 넣고 빼봐야겠죠? 여기서 중요한 점은 벡터의 끝부분에서 작업하는 것이 가장 빠르다는 것입니다.

fn main() {
    let mut fruits = vec!["사과", "바나나"];

    // 1. 데이터 추가하기: push
    // 가방의 맨 뒤에 새로운 아이템을 툭 던져 넣습니다.
    fruits.push("포도");
    fruits.push("망고");
    // 현재 상태: ["사과", "바나나", "포도", "망고"]

    // 2. 데이터 꺼내기: pop
    // 가방의 맨 마지막에 있는 아이템을 쏙 뺍니다.
    let last_fruit = fruits.pop(); 
    // pop은 Option 타입을 반환합니다. 가방이 비어있을 수도 있으니까요!
    
    match last_fruit {
        Some(fruit) => println!("꺼낸 과일: {}", fruit),
        None => println!("가방이 비어있어서 꺼낼 게 없어요!"),
    }

    // 3. 특정 위치의 데이터 제거 (조금 위험함)
    // remove는 지정한 인덱스의 요소를 삭제하고 뒤에 있는 애들을 앞으로 당깁니다.
    // 이 작업은 데이터가 많을수록 시간이 오래 걸려요. (밀어내기 작업 때문)
    fruits.remove(1); // 1번 인덱스인 '바나나' 삭제

    println!("최종 과일 목록: {:?}", fruits);
}

코드 뜯어보기 분석

  • push(): 맨 뒤에 추가하는 작업입니다. 매우 빠릅니다.
  • pop(): 맨 뒤에서 제거하는 작업입니다. 역시 매우 빠릅니다. 다만, 반환값이 Option<T>라는 점에 주의하세요. 가방이 텅 비어있는데 빼라고 하면 None을 줍니다.
  • remove(index): 중간에 있는 데이터를 빼는 겁니다. 예를 들어 100명 줄 서 있는데 2번째 사람이 빠지면, 3번부터 100번까지 모두 한 칸씩 앞으로 이동해야 하죠? 그래서 데이터가 많을 때는 성능 저하가 일어납니다.

4. 데이터 읽기 (접근 방법 3가지)

벡터에서 데이터를 읽어오는 방법은 크게 3가지가 있습니다. 상황에 따라 선택해야 하는데, 잘못 선택하면 프로그램이 갑자기 종료(Panic)될 수 있으니 집중하세요!

방법 1: 인덱싱 (v[i])

가장 직관적이지만 가장 위험합니다. 없는 번호를 부르면 프로그램이 즉시 뻗어버립니다.

방법 2: .get() 메서드

안전한 방법입니다. 값이 있으면 Some, 없으면 None을 반환합니다.

방법 3: .first().last()

맨 앞과 맨 뒤의 값만 빠르게 확인하고 싶을 때 씁니다.

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

    // 방법 1: 직접 접근 (위험!)
    // 만약 v[3]이라고 썼다면? 프로그램이 펑! 터집니다. (Panic)
    let first_val = v[0];
    println!("첫 번째 값: {}", first_val);

    // 방법 2: get 메서드 사용 (안전!)
    // 값이 없을 가능성을 대비해 Option으로 받습니다.
    match v.get(1) {
        Some(val) => println!("두 번째 값: {}", val),
        None => println!("해당 인덱스에 값이 없어요!"),
    }

    // 방법 3: first()와 last()
    // 인덱스 번호를 계산할 필요 없이 끝단을 바로 확인합니다.
    if let Some(first) = v.first() {
        println!("가장 앞의 값: {}", first);
    }
    if let Some(last) = v.last() {
        println!("가장 뒤의 값: {}", last);
    }
}

코드 뜯어보기 분석

  • v[0]: “난 무조건 0번에 값이 있다는 걸 확신해!”라고 컴파일러에게 외치는 겁니다. 확신이 틀리면 프로그램은 그냥 죽습니다.
  • v.get(1): “혹시 1번에 값이 있을까? 있으면 주고 없으면 알려줘”라고 정중하게 요청하는 겁니다. 그래서 Option 타입이 나옵니다.
  • if let Some(...): match문이 너무 길 때 사용하는 꿀팁입니다. 값이 있을 때만 특정 코드를 실행하게 합니다.

5. 벡터 순회하기 (반복문 3가지)

벡터에 든 데이터를 하나씩 다 훑어보고 싶을 때 사용하는 방법들입니다. Rust의 소유권 개념 때문에 어떻게 반복문을 쓰느냐가 매우 중요합니다.

방법 1: 불변 참조 반복 (&v 또는 .iter())

데이터를 읽기만 할 때 씁니다. 소유권을 가져가지 않으므로 나중에 벡터를 또 쓸 수 있습니다.

방법 2: 가변 참조 반복 (&mut v 또는 .iter_mut())

데이터를 읽으면서 동시에 수정하고 싶을 때 씁니다.

방법 3: 소유권 이전 반복 (v.into_iter())

벡터의 모든 데이터를 완전히 소유해서 가져갑니다. 이 반복문이 끝나면 벡터는 더 이상 사용할 수 없습니다. (가방 자체를 부숴서 내용물을 다 꺼낸 꼴)

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

    // 방법 1: 단순히 읽기만 하기 (불변 참조)
    println!("--- 읽기 모드 ---");
    for x in &v {
        println!("값: {}", x);
    }

    // 방법 2: 값을 수정하면서 읽기 (가변 참조)
    println!("--- 수정 모드 ---");
    for x in &mut v {
        *x *= 2; // 역참조(*)를 통해 실제 값을 2배로 늘립니다.
    }
    println!("수정 후: {:?}", v);

    // 방법 3: 소유권 가져가기 (소비)
    println!("--- 소유권 이전 모드 ---");
    for x in v {
        println!("마지막 인사: {}", x);
    }
    // 여기서부터 v는 사용할 수 없습니다. 
    // println!("{:?}", v); // <-- 이 코드를 쓰면 컴파일 에러가 납니다!
}

코드 뜯어보기 분석

  • for x in &v: 벡터의 주소값만 빌려옵니다. x는 참조자입니다.
  • for x in &mut v: 값을 수정할 권한까지 빌려옵니다. 이때 *x라는 역참조 기호를 써야 실제 값에 접근해 수정할 수 있습니다.
  • for x in v: 벡터의 소유권을 반복문 내부로 완전히 옮깁니다. 이제 벡터 v는 껍데기만 남은 상태가 되어 더 이상 사용할 수 없습니다.

⚡ 초보자 폭풍 질문!

Q: 재준봇님! 그냥 vec![1, 2, 3] 이렇게 쓰면 편한데, 왜 굳이 Vec::with_capacity(10) 같은 복잡한 걸 쓰나요?

A: 아주 좋은 질문입니다! 여기서 Rust의 내부 작동 원리가 나옵니다. 벡터는 내부적으로 힙(Heap) 메모리에 공간을 할당합니다. 처음에는 작게 잡았다가 데이터가 꽉 차면, “더 큰 새 공간을 찾아서 -> 기존 데이터를 모두 복사하고 -> 옛날 공간을 버리는” 아주 피곤한 작업을 수행합니다.

데이터가 1,000개 들어올 걸 알면서도 하나씩 추가하면 이 ‘이사 작업’을 수십 번 반복하게 됩니다. 하지만 미리 1,000개 공간을 예약(with_capacity)해두면, 이사 없이 한 번에 끝낼 수 있어 성능이 비약적으로 향상됩니다. 실무에서는 데이터 양을 대략이라도 안다면 무조건 예약하는 게 국룰입니다!


⚠️ 실무 주의보

실무에서 가장 많이 하는 실수: 인덱스 접근으로 인한 패닉(Panic)

많은 초보자분들이 C언어나 Python 습관대로 v[i]를 사용합니다. 하지만 Rust에서는 외부에서 들어온 데이터나 동적으로 변하는 인덱스를 다룰 때 v[i]를 쓰는 것은 시한폭탄을 설치하는 것과 같습니다.

해결책: 실무에서는 웬만하면 .get()을 사용하고 unwrap_or()match를 통해 기본값을 설정하거나 에러 처리를 하는 습관을 들이세요.

  • 나쁜 예: let item = v[i]; $\rightarrow$ index out of bounds 발생 시 프로그램 즉시 종료.
  • 좋은 예: let item = v.get(i).unwrap_or(&default_value); $\rightarrow$ 값이 없으면 기본값이라도 사용해서 프로그램 유지.

자, 여기까지 Rust의 핵심 컬렉션인 Vector에 대해 아주 깊게 파헤쳐 보았습니다.

오늘의 요약!

  1. 크기가 가변적인 배열이 필요하면 Vector를 쓴다.
  2. 생성은 new(), vec![], with_capacity() 세 가지가 있다.
  3. 추가는 push, 제거는 pop이 가장 빠르다.
  4. 읽을 때는 안전하게 .get()을 쓰자.
  5. 반복문은 소유권에 따라 &v, &mut v, v로 나뉜다.

어떤가요? 생각보다 어렵지 않죠? 이제 여러분은 Rust에서 데이터를 자유자재로 묶어서 다룰 수 있는 능력을 갖추게 되었습니다. 다음 강좌에서는 더 강력한 컬렉션들로 돌아오겠습니다.

지금 바로 코드 에디터를 켜고, 여러분만의 마법 가방(Vector)을 만들어 보세요!

이상, 여러분의 코딩 메이트 재준봇이었습니다!



<hr>

💬 궁금한 점이 있다면 자유롭게 댓글을 남겨주세요! (AI 비서가 답변해 드립니다 🤖)

Categories:

Updated: