[C++] vector 완벽 정복: 기초부터 핵심 사용법, 장단점까지 한눈에!

C++ 프로그래밍에서 데이터를 유연하게 관리하고 싶을 때 가장 먼저 떠오르는 컨테이너 중 하나가 바로 std::vector입니다. C++ 표준 템플릿 라이브러리(STL)의 핵심 구성 요소인 vector는 동적으로 크기가 조절되는 배열로, 편리함과 강력한 기능을 제공합니다. 이 글에서는 C++ vector의 기본적인 사용법부터 주요 기능, 그리고 장단점까지 자세히 알아보겠습니다.

1. C++ vector란 무엇인가?

std::vector는 동일한 타입의 객체들을 연속된 메모리 공간에 저장하는 시퀀스 컨테이너입니다. 마치 자동으로 크기가 조절되는 배열과 같다고 생각할 수 있습니다.

1.1. 특징

  • 동적 메모리 할당: vector를 생성하면 내부적으로 힙(heap) 메모리에 데이터가 저장됩니다. 이로 인해 프로그램 실행 중에 크기를 늘리거나 줄일 수 있는 유연성을 갖습니다.

1.2. 장점

  • 자동 메모리 관리: 요소 추가 시 자동으로 메모리가 확장되고, 소멸 시 자동으로 해제되어 메모리 누수 걱정을 덜 수 있습니다.

  • 다양한 멤버 함수: 원소 접근, 추가, 삭제, 크기 확인 등 다양한 편의 기능을 제공합니다.

  • 임의 접근 가능: 인덱스를 통해 특정 위치의 원소에 빠르게 접근할 수 있습니다 (O(1) 시간 복잡도).

  • 다른 STL 알고리즘과의 호환성: <algorithm> 헤더의 다양한 함수들과 쉽게 연동하여 사용할 수 있습니다.


1.3. 단점

  • 배열 대비 약간의 성능 저하: 단순 접근 속도는 C-스타일 배열보다 약간 느릴 수 있습니다.

  • 재할당 비용: vector의 용량(capacity)을 초과하여 원소를 추가할 경우, 새로운 더 큰 메모리 공간을 할당하고 기존 원소들을 복사/이동하는 재할당(reallocation) 과정에서 성능 저하가 발생할 수 있습니다.

  • 중간 삽입/삭제 비용: vector의 중간에 원소를 삽입하거나 삭제할 경우, 해당 지점 이후의 모든 원소들을 이동시켜야 하므로 O(N)의 시간 복잡도를 가집니다.

2. Vector 생성 및 초기화

vector를 사용하기 위해서는 먼저 <vector> 헤더 파일을 포함해야 합니다.

#include <vector>
#include <iostream> // 예제 출력을 위해 사용

int main() {
    // 1. 빈 vector 생성
    std::vector<int> v1;

    // 2. 특정 크기로 생성 (모든 원소는 0 또는 기본값으로 초기화)
    std::vector<int> v2(5); // 크기가 5인 int형 vector, 모든 원소는 0으로 초기화

    // 3. 특정 크기로 생성하며 특정 값으로 초기화
    std::vector<int> v3(5, 100); // 크기가 5인 int형 vector, 모든 원소를 100으로 초기화

    // 4. 중괄호 초기화 (Initializer List, C++11 이상)
    std::vector<int> v4 = {1, 2, 3, 4, 5};
    std::vector<std::string> v_str = {"apple", "banana", "cherry"};

    // 5. 다른 vector로부터 복사하여 생성
    std::vector<int> v5 = v4; // v4의 모든 원소를 복사하여 v5 생성
    std::vector<int> v6(v4);  // 위와 동일

    // 6. 2차원 vector 생성 (행과 열 모두 가변)
    std::vector<std::vector<int>> matrix1; // 빈 2차원 vector
    std::vector<std::vector<int>> matrix2(3, std::vector<int>(4, 0)); // 3x4 크기의 2차원 vector, 모든 원소 0으로 초기화

    // 7. assign() 함수를 이용한 초기화 또는 재할당
    std::vector<int> v7;
    v7.assign(5, 7); // v7에 5개의 7을 할당 (output: 7 7 7 7 7)

    return 0;
}

3. Vector 원소 접근

vector의 원소에 접근하는 주요 방법은 다음과 같습니다.

  • operator[]: 배열처럼 인덱스를 사용하여 원소에 접근합니다. 범위를 벗어난 인덱스 접근 시 정의되지 않은 행동(undefined behavior)을 유발하므로 주의해야 합니다.
  • at(): 인덱스를 사용하여 원소에 접근합니다. 범위를 벗어난 인덱스 접근 시 std::out_of_range 예외를 발생시켜 더 안전합니다.
  • front(): 첫 번째 원소에 대한 참조를 반환합니다. 비어있는 vector에 사용 시 정의되지 않은 행동을 유발합니다.
  • back(): 마지막 원소에 대한 참조를 반환합니다. 비어있는 vector에 사용 시 정의되지 않은 행동을 유발합니다.
#include <vector>
#include <iostream>
#include <stdexcept> // std::out_of_range 사용

int main() {
    std::vector<int> v = {10, 20, 30, 40, 50};

    std::cout << "v[1]: " << v[1] << std::endl;          // output: 20
    std::cout << "v.at(2): " << v.at(2) << std::endl;      // output: 30

    std::cout << "v.front(): " << v.front() << std::endl;  // output: 10
    std::cout << "v.back(): " << v.back() << std::endl;    // output: 50

    try {
        std::cout << v.at(10) << std::endl; // 범위를 벗어난 접근
    } catch (const std::out_of_range& oor) {
        std::cerr << "Out of Range error: " << oor.what() << std::endl;
    }

    return 0;
}

operator[]는 속도가 약간 빠르지만 안전성이 떨어지고, at()은 안전하지만 예외 처리 비용이 발생할 수 있습니다. 따라서 접근하려는 인덱스가 유효하다는 것이 확실할 때는 operator[]를, 그렇지 않다면 at()을 사용하는 것이 좋습니다.

4. Vector 반복자 (Iterators)

반복자(iterator)는 vector 내부의 원소들을 가리키는 객체로, 포인터와 유사하게 동작합니다. vector의 원소들을 순회하거나 특정 알고리즘에 전달할 때 사용됩니다.

  • begin(): vector의 첫 번째 원소를 가리키는 반복자를 반환합니다.
  • end(): vector의 마지막 원소 바로 다음 위치(존재하지 않는 원소)를 가리키는 반복자를 반환합니다. end()가 가리키는 위치는 실제 원소가 아니므로 역참조하면 안 됩니다.
  • rbegin(): vector의 마지막 원소를 가리키는 역반복자(reverse iterator)를 반환합니다. (역순 순회의 시작점)
  • rend(): vector의 첫 번째 원소 바로 앞 위치를 가리키는 역반복자를 반환합니다. (역순 순회의 끝점)
#include <vector>
#include <iostream>
#include <algorithm> // for_each 사용

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};

    // 일반 반복자를 사용한 순회
    std::cout << "Forward iteration: ";
    for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << " "; // output: 1 2 3 4 5
    }
    std::cout << std::endl;

    // 범위 기반 for문 (C++11 이상, 내부적으로 반복자 사용)
    std::cout << "Range-based for loop: ";
    for (int val : v) {
        std::cout << val << " "; // output: 1 2 3 4 5
    }
    std::cout << std::endl;

    // 역반복자를 사용한 순회
    std::cout << "Reverse iteration: ";
    for (std::vector<int>::reverse_iterator rit = v.rbegin(); rit != v.rend(); ++rit) {
        std::cout << *rit << " "; // output: 5 4 3 2 1
    }
    std::cout << std::endl;

    return 0;
}

5. Vector 원소 추가 및 삭제

vector는 다양한 방법으로 원소를 추가하거나 삭제할 수 있는 멤버 함수를 제공합니다.

  • push_back(value): vector의 맨 뒤에 원소를 추가합니다.
  • pop_back(): vector의 맨 뒤 원소를 제거합니다. 비어있는 vector에 사용 시 정의되지 않은 행동을 유발합니다.
  • insert(position, value): 지정된 position(반복자) 앞에 value를 삽입합니다.
  • insert(position, count, value): 지정된 position 앞에 valuecount개 삽입합니다.
  • insert(position, first, last): 지정된 position 앞에 다른 컨테이너의 [first, last) 범위의 원소들을 삽입합니다.
  • emplace_back(args...): vector의 맨 뒤에 원소를 생성하여 추가합니다. push_back과 달리 객체를 직접 생성하므로 임시 객체 생성 및 복사/이동 비용을 줄일 수 있습니다 (C++11 이상).
  • emplace(position, args...): 지정된 position 앞에 원소를 생성하여 삽입합니다 (C++11 이상).
  • erase(position): 지정된 position(반복자)의 원소를 제거합니다.
  • erase(first, last): [first, last) 범위의 원소들을 제거합니다.
  • clear(): vector의 모든 원소를 제거합니다 (size는 0이 되지만 capacity는 유지될 수 있습니다).
  • resize(new_size): vector의 크기를 new_size로 변경합니다. 크기가 커지면 기본값으로 초기화된 원소가 추가되고, 작아지면 뒤쪽 원소들이 제거됩니다.
  • swap(other_vector): 현재 vectorother_vector의 내용을 교환합니다.

5.1. push_back vs emplace_back

push_back은 이미 생성된 객체를 vector로 복사하거나 이동시킵니다. 반면 emplace_backvector 내부 메모리 공간에 직접 객체를 생성합니다.

struct MyData {
    int id;
    std::string name;
    MyData(int i, std::string s) : id(i), name(s) {
        std::cout << "MyData constructor called for " << name << std::endl;
    }
    MyData(const MyData& other) : id(other.id), name(other.name) {
        std::cout << "MyData copy constructor called for " << name << std::endl;
    }
    MyData(MyData&& other) noexcept : id(other.id), name(std::move(other.name)) {
         std::cout << "MyData move constructor called for " << name << std::endl;
    }
};

std::vector<MyData> vec;
MyData obj1(1, "obj1");

std::cout << "--- push_back ---" << std::endl;
vec.push_back(obj1); // 복사 생성자 호출 (또는 경우에 따라 이동 생성자)

std::cout << "--- emplace_back ---" << std::endl;
vec.emplace_back(2, "obj2"); // 생성자 직접 호출. 불필요한 임시 객체 생성 및 복사/이동 방지

emplace_back (및 emplace)은 인자를 받아 vector 내부에서 직접 객체를 생성하므로, 특히 복잡한 객체의 경우 복사/이동 생성자 호출을 줄여 성능상 이점을 가질 수 있습니다.

6. Vector 용량 관리 (Capacity Management)

vector는 내부적으로 실제 저장된 원소의 개수(size)와 할당된 메모리 공간의 크기(capacity)를 관리합니다.

  • size(): vector에 저장된 원소의 개수를 반환합니다.
  • capacity(): vector에 재할당 없이 저장할 수 있는 최대 원소의 개수를 반환합니다. capacity()는 항상 size()보다 크거나 같습니다.
  • empty(): vector가 비어있는지 (size가 0인지) 여부를 반환합니다 (true/false).
  • max_size(): 시스템이 vector에 할당할 수 있는 이론적인 최대 원소 수를 반환합니다.
  • reserve(new_cap): vectorcapacity를 최소 new_cap으로 설정합니다. 만약 new_cap이 현재 capacity보다 작으면 아무 동작도 하지 않을 수 있습니다 (C++ 표준에 따라 다름). 이 함수는 원소를 많이 추가할 것이 예상될 때 미리 공간을 확보하여 재할당 횟수를 줄이는 데 유용합니다.
  • shrink_to_fit(): vectorcapacitysize에 맞게 줄이도록 요청합니다 (C++11 이상). 메모리 사용량을 줄이고 싶을 때 사용하지만, 반드시 요청이 받아들여지는 것은 아닙니다.
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v;
    std::cout << "Initial size: " << v.size() << ", capacity: " << v.capacity() << std::endl;

    v.reserve(10); // 미리 용량을 10으로 확보
    std::cout << "After reserve(10) -> size: " << v.size() << ", capacity: " << v.capacity() << std::endl;

    for (int i = 0; i < 5; ++i) {
        v.push_back(i);
        std::cout << "push_back(" << i << ") -> size: " << v.size() << ", capacity: " << v.capacity() << std::endl;
    }
    // output: (컴파일러 및 환경에 따라 capacity 증가 방식이 다를 수 있음)
    // push_back(0) -> size: 1, capacity: 10
    // ...
    // push_back(4) -> size: 5, capacity: 10

    v.shrink_to_fit(); // 사용하지 않는 여유 공간을 해제하도록 요청
    std::cout << "After shrink_to_fit() -> size: " << v.size() << ", capacity: " << v.capacity() << std::endl;
    // output: After shrink_to_fit() -> size: 5, capacity: 5 (보장되지는 않음)

    v.clear(); // 모든 원소 제거
    std::cout << "After clear() -> size: " << v.size() << ", capacity: " << v.capacity() << std::endl;
    // output: After clear() -> size: 0, capacity: 5 (capacity는 유지될 수 있음)

    // capacity까지 완전히 해제하려면 (swap trick)
    std::vector<int>().swap(v);
    std::cout << "After swap trick -> size: " << v.size() << ", capacity: " << v.capacity() << std::endl;
    // output: After swap trick -> size: 0, capacity: 0

    return 0;
}

vectorsizecapacity를 초과하면, 더 큰 메모리 블록이 할당되고 모든 기존 원소들이 새 위치로 복사(또는 이동)됩니다. 이 재할당 과정은 비용이 크므로, reserve()를 적절히 사용하면 성능을 향상시킬 수 있습니다.

7. Vector 사용 시 주의사항

  • 중간 삽입/삭제 성능: vector의 중간에 원소를 삽입하거나 삭제하는 작업은 그 뒤의 모든 원소를 이동시켜야 하므로 성능에 부담을 줄 수 있습니다. 이러한 작업이 빈번하다면 std::liststd::deque 같은 다른 컨테이너를 고려하는 것이 좋습니다.
  • 반복자 무효화: vector의 특정 연산(예: push_back으로 인한 재할당, insert, erase)은 기존 반복자를 무효화시킬 수 있습니다. 무효화된 반복자를 사용하면 예기치 않은 동작이나 프로그램 충돌을 일으킬 수 있으므로 주의해야 합니다.
  • clear()와 메모리: clear() 함수는 vector의 모든 원소를 제거하여 size()를 0으로 만들지만, capacity()는 변경하지 않을 수 있습니다. 즉, 할당된 메모리는 그대로 남아있을 수 있습니다. 메모리까지 완전히 해제하고 싶다면 위에서 언급된 "swap trick" (std::vector<T>().swap(my_vector))을 사용하거나, C++11 이상에서는 shrink_to_fit()을 사용할 수 있습니다.

8. 결론

std::vector는 C++에서 가장 유용하고 널리 사용되는 컨테이너 중 하나입니다. 동적 크기 조절, 편리한 멤버 함수, 다른 STL 구성 요소와의 호환성 등 많은 장점을 제공합니다. 하지만 재할당 비용, 중간 삽입/삭제 성능, 반복자 무효화와 같은 특징들을 잘 이해하고 사용해야 최적의 성능을 얻을 수 있습니다. 상황에 맞는 적절한 사용법을 익힌다면 vector는 여러분의 C++ 프로그래밍을 더욱 강력하고 효율적으로 만들어 줄 것입니다.그래밍을 더욱 강력하고 효율적으로 만들어 줄 것입니다.