Skip to main content

vector와 동적 배열

Views 34약 11분생성형 AI 활용
Summary

가장 자주 만나게 될 컨테이너

C++을 배우기 시작하면 처음에는 배열을 떠올리기 쉽습니다. 정수 10개가 필요하면 int scores[10];처럼 만들고, 문자열 5개가 필요하면 std::string names[5];처럼 만들면 됩니다. 그런데 실제 프로그램은 그렇게 얌전하지 않습니다. 학생 수가 처음에는 3명이다가 30명이 될 수도 있고, 게임 인벤토리에 아이템이 추가되거나 사라질 수도 있습니다. 파일에서 몇 줄을 읽어 올지 미리 알 수 없는 경우도 많습니다.

이럴 때 C++에서 가장 먼저 떠올려야 할 컨테이너가 std::vector입니다.

std::vector는 크기가 변하는 배열입니다. 내부적으로는 배열처럼 원소를 연속된 메모리에 저장하지만, 필요하면 더 큰 공간을 새로 확보해 크기를 늘릴 수 있습니다. 그래서 배열의 빠른 인덱스 접근과 동적 크기 관리라는 두 장점을 함께 제공합니다.

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> scores;

    scores.push_back(90);
    scores.push_back(85);
    scores.push_back(100);

    std::cout << scores[0] << 'n'; // 90
    std::cout << scores[1] << 'n'; // 85
    std::cout << scores[2] << 'n'; // 100
}

std::vector<int>는 int를 여러 개 담는 동적 배열입니다. push_back은 벡터의 끝에 원소를 하나 추가합니다. scores[0]처럼 대괄호를 사용하면 배열처럼 원하는 위치의 원소에 접근할 수 있습니다.

여기서 중요한 감각이 하나 있습니다. std::vector는 "조금 더 편한 배열" 정도가 아닙니다. C++에서 목록을 다룰 때 가장 기본이 되는 도구입니다. 직접 new로 배열을 만들고, 크기가 부족하면 새 배열을 만들고, 기존 값을 복사하고, 옛 배열을 delete[]하는 코드를 직접 작성하지 않아도 됩니다. 이런 번거로운 작업은 std::vector가 대신 처리합니다.

vector와 동적 배열

vector 선언하기

std::vector를 사용하려면 <vector> 헤더를 포함해야 합니다.

#include <vector>

가장 기본적인 선언은 다음과 같습니다.

std::vector<int> numbers;
std::vector<double> temperatures;
std::vector<std::string> names;

꺾쇠괄호 안에는 벡터가 저장할 원소의 타입을 씁니다. std::vector<int>는 정수 목록이고, std::vector<std::string>은 문자열 목록입니다.

처음부터 값을 넣어 만들 수도 있습니다.

std::vector<int> scores{90, 85, 100};
std::vector<std::string> names{"Kim", "Lee", "Park"};

원소 개수와 초기값을 지정하는 방식도 있습니다.

std::vector<int> zeros(5);       // 0이 5개
std::vector<int> tens(5, 10);    // 10이 5개

주의할 점은 중괄호 {}와 소괄호 ()의 의미가 다를 수 있다는 것입니다.

std::vector<int> a{5, 10}; // 원소 2개: 5, 10
std::vector<int> b(5, 10); // 원소 5개: 10, 10, 10, 10, 10

처음에는 헷갈릴 수 있습니다. 값을 직접 나열할 때는 {}를 사용하고, "몇 개를 어떤 값으로 채울지" 지정할 때는 ()를 사용한다고 생각하면 됩니다.

원소 추가하기: push_back

벡터 끝에 원소를 추가할 때는 push_back을 사용합니다.

std::vector<int> numbers;

numbers.push_back(10);
numbers.push_back(20);
numbers.push_back(30);

이제 numbers에는 10, 20, 30이 차례대로 들어 있습니다. push_back은 벡터에서 가장 자주 사용하는 멤버 함수 중 하나입니다.

문자열도 마찬가지입니다.

std::vector<std::string> names;

names.push_back("Kim");
names.push_back("Lee");
names.push_back("Park");

std::vector는 타입만 다를 뿐 사용법은 거의 같습니다. 이것이 STL 컨테이너의 큰 장점입니다. int 벡터를 다룰 줄 알면 std::string 벡터도 자연스럽게 다룰 수 있고, 나중에는 직접 만든 Student, Monster, Task 같은 객체도 벡터에 담을 수 있습니다.

인덱스로 접근하기

벡터는 배열처럼 인덱스로 접근할 수 있습니다.

std::vector<int> scores{90, 85, 100};

std::cout << scores[0] << 'n';
std::cout << scores[1] << 'n';
std::cout << scores[2] << 'n';

인덱스는 0부터 시작합니다. 원소가 3개라면 사용할 수 있는 인덱스는 0, 1, 2입니다. scores[3]은 네 번째 원소를 의미하는데, 현재 네 번째 원소는 없습니다.

여기서 중요한 차이가 있습니다. operator[], 즉 대괄호 접근은 빠르지만 범위 검사를 하지 않습니다.

std::vector<int> scores{90, 85, 100};

std::cout << scores[10] << 'n'; // 위험: 범위를 벗어난 접근

이 코드는 컴파일될 수 있지만 실행 결과는 정의되지 않습니다. 운 좋게 이상한 값이 출력될 수도 있고, 프로그램이 멈출 수도 있고, 더 무서운 경우에는 한참 뒤에 엉뚱한 문제가 나타날 수도 있습니다.

범위 검사가 필요하면 at을 사용할 수 있습니다.

std::vector<int> scores{90, 85, 100};

std::cout << scores.at(1) << 'n'; // 85
std::cout << scores.at(10) << 'n'; // 예외 발생

at은 인덱스가 벗어나면 예외를 던집니다. 예외 처리는 뒤에서 더 자세히 다루지만, 지금은 "잘못된 접근을 조용히 지나치지 않게 해 주는 방법" 정도로 이해하면 충분합니다.

실전에서는 상황에 따라 선택합니다. 인덱스가 안전하다는 것이 명확하고 성능이 중요한 반복문에서는 []를 많이 씁니다. 사용자 입력처럼 잘못된 값이 들어올 가능성이 있는 곳에서는 at이나 직접 범위 검사를 사용하는 편이 안전합니다.

std::vector<int> scores{90, 85, 100};
int index = 2;

if (index >= 0 && index < static_cast<int>(scores.size())) {
    std::cout << scores[index] << 'n';
}

여기서 scores.size()는 벡터에 들어 있는 원소 개수를 반환합니다. 반환 타입은 부호 없는 정수 계열이므로, int와 비교할 때 경고가 날 수 있습니다. 입문 단계에서는 이런 경고를 만났을 때 "타입이 다르구나"라고 알아차리는 것이 중요합니다. 위 예제에서는 간단히 static_cast<int>로 맞추었습니다.

size: 현재 들어 있는 원소 개수

size()는 벡터에 실제로 들어 있는 원소 개수를 알려 줍니다.

std::vector<int> numbers;

std::cout << numbers.size() << 'n'; // 0

numbers.push_back(10);
numbers.push_back(20);

std::cout << numbers.size() << 'n'; // 2

empty()는 벡터가 비어 있는지 확인할 때 사용합니다.

if (numbers.empty()) {
    std::cout << "비어 있습니다.n";
}

size() == 0이라고 써도 되지만, 의도를 더 분명하게 드러내고 싶다면 empty()가 좋습니다. 코드는 컴퓨터에게 내리는 명령이면서 동시에 사람에게 보여 주는 설명서입니다. empty()는 "비었는지 확인한다"는 뜻을 바로 보여 줍니다.

벡터의 첫 번째 원소와 마지막 원소는 front(), back()으로 접근할 수 있습니다.

std::vector<int> numbers{10, 20, 30};

std::cout << numbers.front() << 'n'; // 10
std::cout << numbers.back() << 'n';  // 30

단, 비어 있는 벡터에서 front()나 back()을 호출하면 안 됩니다. 항상 비어 있지 않다는 조건이 필요합니다.

if (!numbers.empty()) {
    std::cout << numbers.back() << 'n';
}

벡터 순회하기

벡터에 들어 있는 모든 원소를 차례대로 처리하는 일을 순회라고 합니다. 가장 단순한 방법은 인덱스 기반 for문입니다.

std::vector<int> scores{90, 85, 100};

for (std::size_t i = 0; i < scores.size(); ++i) {
    std::cout << i << ": " << scores[i] << 'n';
}

std::size_t는 크기나 개수를 표현할 때 자주 쓰이는 타입입니다. scores.size()의 반환 타입도 보통 std::size_t입니다. 인덱스를 출력하거나, 특정 위치의 원소를 수정해야 한다면 이런 방식이 편합니다.

단순히 모든 원소를 읽기만 한다면 범위 기반 for문이 더 깔끔합니다.

std::vector<int> scores{90, 85, 100};

for (int score : scores) {
    std::cout << score << 'n';
}

이 코드는 scores의 원소를 하나씩 꺼내 score에 복사합니다. int처럼 작은 타입은 복사 비용이 거의 없으므로 괜찮습니다. 하지만 std::string이나 직접 만든 큰 객체라면 매번 복사하는 것이 부담될 수 있습니다. 읽기만 할 때는 const 참조를 사용하는 습관이 좋습니다.

std::vector<std::string> names{"Kim", "Lee", "Park"};

for (const std::string& name : names) {
    std::cout << name << 'n';
}

원소를 수정해야 한다면 일반 참조를 사용합니다.

std::vector<int> scores{90, 85, 100};

for (int& score : scores) {
    score += 5;
}

이제 모든 점수가 5점씩 올라갑니다. 여기서 int score라고 쓰면 복사본만 수정되고 원래 벡터의 값은 바뀌지 않습니다. 범위 기반 for문을 사용할 때는 항상 스스로에게 물어보면 좋습니다. "나는 복사해서 읽고 싶은가, 원본을 읽기만 할 것인가, 원본을 수정할 것인가?"

capacity: 벡터가 미리 확보한 공간

이제 std::vector에서 가장 중요한 내부 감각을 배워 봅시다. 바로 size와 capacity의 차이입니다.

size()는 현재 들어 있는 원소 개수입니다. capacity()는 벡터가 재할당 없이 담을 수 있도록 미리 확보해 둔 공간의 크기입니다.

식당을 떠올려 봅시다. 현재 손님이 3명 앉아 있다면 size는 3입니다. 그런데 식당에 의자가 8개 있다면 capacity는 8입니다. 손님이 1명 더 와도 의자를 새로 살 필요는 없습니다. 하지만 손님이 9명이 되면 의자가 부족하니 더 큰 공간이 필요합니다.

코드로 확인해 보겠습니다.

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> numbers;

    for (int i = 0; i < 10; ++i) {
        numbers.push_back(i);

        std::cout << "size: " << numbers.size()
                  << ", capacity: " << numbers.capacity()
                  << 'n';
    }
}

실행 결과는 컴파일러와 표준 라이브러리 구현에 따라 조금 다를 수 있지만, 대략 이런 식의 흐름을 볼 수 있습니다.

size: 1, capacity: 1
size: 2, capacity: 2
size: 3, capacity: 4
size: 4, capacity: 4
size: 5, capacity: 8
size: 6, capacity: 8
size: 7, capacity: 8
size: 8, capacity: 8
size: 9, capacity: 16
size: 10, capacity: 16

size는 원소가 추가될 때마다 1씩 증가합니다. 하지만 capacity는 매번 1씩 증가하지 않습니다. 공간이 부족해지는 순간 더 큰 공간을 한 번에 확보합니다.

왜 이렇게 할까요? 원소를 하나 추가할 때마다 메모리를 새로 할당하면 너무 느리기 때문입니다. 벡터는 보통 기존보다 더 큰 공간을 확보하고, 기존 원소들을 새 공간으로 옮긴 뒤, 새 원소를 추가합니다. 이런 방식 덕분에 push_back은 대부분의 경우 매우 빠르게 동작합니다. 다만 가끔 공간을 확장하는 순간에는 원소를 옮기는 비용이 발생합니다.

이 내부 동작을 이해하면 벡터가 갑자기 이상하게 보이지 않습니다. push_back을 했더니 내부 주소가 바뀌거나, 이전에 저장해 둔 포인터와 참조가 더 이상 안전하지 않을 수 있는 이유도 여기에서 나옵니다. 벡터가 더 큰 집으로 이사하면 예전 집 주소를 들고 있어 봐야 소용이 없는 셈입니다.

reserve: 미리 자리 맡아 두기

원소가 몇 개쯤 들어올지 대략 알고 있다면 reserve를 사용할 수 있습니다.

std::vector<int> numbers;
numbers.reserve(100);

for (int i = 0; i < 100; ++i) {
    numbers.push_back(i);
}

reserve(100)은 "원소 100개를 담을 수 있는 공간을 미리 확보해 달라"는 뜻입니다. 이때 size는 증가하지 않습니다. 아직 실제 원소가 들어간 것은 아니기 때문입니다.

std::vector<int> numbers;
numbers.reserve(100);

std::cout << numbers.size() << 'n';     // 0
std::cout << numbers.capacity() << 'n'; // 100 이상

반대로 resize는 실제 원소 개수를 바꿉니다.

std::vector<int> numbers;
numbers.resize(5);

std::cout << numbers.size() << 'n'; // 5

std::vector<int>에서 resize(5)를 호출하면 정수 5개가 실제로 생기고, 기본값인 0으로 채워집니다.

reserve와 resize는 이름이 비슷해서 자주 헷갈립니다. reserve는 자리만 맡아 둡니다. resize는 실제 원소 수를 바꿉니다.

std::vector<int> a;
a.reserve(5); // 공간만 확보, 원소 없음

std::vector<int> b;
b.resize(5);  // 원소 5개 생성

벡터를 처음 배우는 단계에서는 push_back을 기본으로 사용하면 됩니다. 성능을 신경 써야 하고 원소 개수를 미리 알고 있다면 reserve를 떠올리면 됩니다.

pop_back과 clear

마지막 원소를 제거할 때는 pop_back을 사용합니다.

std::vector<int> numbers{10, 20, 30};

numbers.pop_back();

for (int number : numbers) {
    std::cout << number << 'n';
}

이제 numbers에는 10, 20만 남습니다. pop_back은 마지막 원소를 제거하지만 그 값을 반환하지는 않습니다. 마지막 값을 사용해야 한다면 먼저 back()으로 읽고 나서 pop_back()을 호출해야 합니다.

if (!numbers.empty()) {
    int last = numbers.back();
    numbers.pop_back();

    std::cout << "제거한 값: " << last << 'n';
}

모든 원소를 제거하려면 clear를 사용합니다.

numbers.clear();

clear를 호출하면 size()는 0이 됩니다. 하지만 capacity()는 그대로 남아 있을 수 있습니다. 벡터가 확보해 둔 공간을 나중에 다시 사용할 수 있기 때문입니다.

std::vector<int> numbers;
numbers.reserve(100);

numbers.push_back(1);
numbers.push_back(2);
numbers.clear();

std::cout << numbers.size() << 'n';     // 0
std::cout << numbers.capacity() << 'n'; // 여전히 100 이상일 수 있음

이 동작은 낭비처럼 보일 수 있지만, 같은 벡터에 다시 많은 원소를 넣을 예정이라면 오히려 유리합니다. 이미 확보한 공간을 재사용할 수 있기 때문입니다.

중간 원소 삭제하기: erase

벡터의 끝이 아닌 중간 원소를 삭제할 때는 erase를 사용합니다. erase에는 반복자가 필요합니다. 반복자는 다음 장에서 더 자세히 배우지만, 지금은 begin()이 첫 번째 원소의 위치를 가리킨다고 생각하면 됩니다.

std::vector<int> numbers{10, 20, 30, 40};

numbers.erase(numbers.begin() + 1); // 인덱스 1의 원소 20 삭제

for (int number : numbers) {
    std::cout << number << 'n';
}

출력 결과는 다음과 같습니다.

10
30
40

중간 원소를 삭제하면 뒤에 있던 원소들이 앞으로 한 칸씩 이동합니다. 이것이 벡터 삭제 비용의 핵심입니다.

예를 들어 10, 20, 30, 40, 50에서 20을 지우면 30, 40, 50이 모두 한 칸씩 앞으로 이동해야 합니다. 원소가 5개라면 별문제가 아니지만, 원소가 백만 개라면 중간 삭제가 꽤 비쌀 수 있습니다.

벡터는 끝에 추가하고 끝에서 제거하는 작업에 강합니다. 인덱스로 빠르게 접근하는 일에도 강합니다. 하지만 앞쪽이나 중간에 자주 삽입하고 삭제하는 작업에는 상대적으로 약합니다. 그렇다고 벡터를 피해야 한다는 뜻은 아닙니다. 대부분의 실전 코드에서는 벡터가 여전히 첫 번째 선택입니다. 다만 중간 삭제가 성능에 영향을 줄 정도로 자주 일어난다면 다른 자료구조를 고민해야 합니다.

인덱스로 삭제 함수를 만들면 다음처럼 작성할 수 있습니다.

#include <vector>

void removeAt(std::vector<int>& numbers, std::size_t index)
{
    if (index >= numbers.size()) {
        return;
    }

    numbers.erase(numbers.begin() + static_cast<std::ptrdiff_t>(index));
}

begin() + index는 벡터의 시작 위치에서 index만큼 이동한 위치를 뜻합니다. 여기서 형 변환이 조금 번거로워 보일 수 있습니다. 반복자의 차이를 표현하는 타입과 size_t가 다를 수 있기 때문입니다. 입문 단계에서는 "인덱스로 위치를 계산해 삭제할 수 있다"는 감각에 집중해도 좋습니다.

push_back과 emplace_back

객체를 벡터에 추가할 때는 push_back과 emplace_back을 모두 사용할 수 있습니다.

먼저 간단한 Student 클래스를 만들어 보겠습니다.

#include <iostream>
#include <string>
#include <vector>

class Student {
private:
    std::string name_;
    int score_;

public:
    Student(std::string name, int score)
        : name_{std::move(name)}, score_{score}
    {
    }

    void print() const
    {
        std::cout << name_ << ": " << score_ << "점n";
    }
};

std::move는 이동 의미론에서 자세히 다루겠지만, 여기서는 문자열을 효율적으로 멤버 변수에 넘기기 위한 표현이라고 이해하면 됩니다. 핵심은 Student가 이름과 점수를 가진 객체라는 점입니다.

이제 벡터에 학생을 넣어 보겠습니다.

std::vector<Student> students;

Student kim{"Kim", 90};
students.push_back(kim);

이 코드는 이미 만들어진 kim 객체를 벡터에 추가합니다. 이때 객체가 복사될 수 있습니다. kim은 여전히 바깥에 남아 있고, 벡터 안에는 그와 같은 내용을 가진 학생 객체가 하나 들어갑니다.

임시 객체를 만들어 바로 넣을 수도 있습니다.

students.push_back(Student{"Lee", 85});

이 경우 임시 Student 객체가 만들어지고, 그 객체가 벡터 안으로 이동될 수 있습니다.

emplace_back은 벡터 내부에서 직접 객체를 만들도록 인자를 전달합니다.

students.emplace_back("Park", 100);

이 코드는 Student("Park", 100)을 벡터 안에서 직접 생성하겠다는 뜻입니다. 객체를 따로 만든 뒤 넣는 것이 아니라, 생성자에 필요한 재료를 벡터에게 건네주는 방식입니다.

입문 단계에서는 이렇게 기억하면 충분합니다. 이미 만들어진 객체를 넣을 때는 push_back이 자연스럽습니다. 생성자 인자를 바로 넘겨 벡터 안에서 만들고 싶을 때는 emplace_back이 자연스럽습니다.

Student kim{"Kim", 90};
students.push_back(kim);          // 이미 있는 객체 추가
students.emplace_back("Lee", 85); // 벡터 안에서 직접 생성

다만 emplace_back이 언제나 무조건 더 좋다고 외우면 위험합니다. 코드가 더 읽기 쉬운 쪽을 선택하는 것이 먼저입니다. 성능 차이가 실제로 중요해지는 순간은 생각보다 늦게 옵니다. 좋은 C++ 코드는 빠르기 전에 먼저 분명해야 합니다.

벡터가 커질 때 객체에는 무슨 일이 일어날까?

std::vector는 내부 공간이 부족하면 더 큰 공간을 새로 확보합니다. 이때 기존 원소들을 새 공간으로 옮겨야 합니다. 원소가 int라면 큰 고민이 없습니다. 하지만 원소가 Student 같은 객체라면 복사 또는 이동이 일어날 수 있습니다.

다음 코드를 보겠습니다.

std::vector<Student> students;

students.emplace_back("Kim", 90);
students.emplace_back("Lee", 85);
students.emplace_back("Park", 100);

처음 Kim을 넣을 때 벡터는 공간을 확보합니다. 다음 Lee를 넣을 때 공간이 부족하면 더 큰 공간으로 이사할 수 있습니다. 그 순간 기존의 Kim 객체가 새 메모리 공간으로 이동될 수 있습니다. 그다음 Park을 넣을 때도 같은 일이 일어날 수 있습니다.

대부분의 경우 우리는 이 과정을 직접 신경 쓰지 않아도 됩니다. std::vector가 알아서 안전하게 처리합니다. 하지만 다음 두 가지 감각은 꼭 가져야 합니다.

  • 벡터가 재할당되면 기존 원소의 주소가 바뀔 수 있습니다.

  • 객체를 벡터에 넣거나 벡터가 커질 때 복사 또는 이동 비용이 발생할 수 있습니다.

  • 원소 수를 미리 알면 reserve로 불필요한 재할당을 줄일 수 있습니다.

예를 들어 벡터 안 원소의 주소를 저장해 두는 코드는 조심해야 합니다.

std::vector<int> numbers;
numbers.push_back(10);

int* p = &numbers[0];

numbers.push_back(20);
numbers.push_back(30);
numbers.push_back(40);

// p는 더 이상 안전하지 않을 수 있습니다.

push_back을 반복하는 동안 벡터가 더 큰 공간으로 이사했다면, p는 예전 공간을 가리키고 있을 수 있습니다. 이것은 아주 위험합니다. 이런 문제는 반복자를 배울 때 다시 만나게 됩니다.

원소 개수를 어느 정도 알고 있다면 다음처럼 미리 공간을 확보할 수 있습니다.

std::vector<Student> students;
students.reserve(100);

students.emplace_back("Kim", 90);
students.emplace_back("Lee", 85);
students.emplace_back("Park", 100);

reserve(100)을 해 두면 학생 100명까지는 재할당 없이 추가할 가능성이 높습니다. 그래서 기존 원소를 옮기는 비용도 줄어들고, 주소가 바뀌는 상황도 줄어듭니다. 물론 reserve는 용량을 확보할 뿐 원소를 만드는 것은 아닙니다.

vector를 함수와 함께 사용하기

벡터는 함수의 매개변수로 자주 전달됩니다. 이때 값으로 전달하면 벡터 전체가 복사될 수 있습니다.

void printScores(std::vector<int> scores) // 복사 발생
{
    for (int score : scores) {
        std::cout << score << 'n';
    }
}

점수 3개라면 별일 아니지만, 원소가 백만 개라면 큰 비용입니다. 읽기만 할 때는 const 참조로 받는 것이 좋습니다.

void printScores(const std::vector<int>& scores)
{
    for (int score : scores) {
        std::cout << score << 'n';
    }
}

벡터를 수정해야 한다면 일반 참조로 받습니다.

void addBonus(std::vector<int>& scores, int bonus)
{
    for (int& score : scores) {
        score += bonus;
    }
}

새 벡터를 만들어 반환하는 것은 괜찮습니다.

std::vector<int> makeScores()
{
    std::vector<int> scores;
    scores.push_back(90);
    scores.push_back(85);
    scores.push_back(100);
    return scores;
}

현대 C++ 컴파일러는 이런 반환을 매우 효율적으로 처리할 수 있습니다. 지역 벡터를 반환한다고 해서 무조건 커다란 복사가 일어난다고 겁먹을 필요는 없습니다. 앞에서 함수 장에서 본 것처럼, 불필요하게 std::move를 붙이기보다 자연스럽게 반환하는 편이 좋습니다.

학생 목록 관리 프로그램 설계하기

이제 std::vector를 사용해 작은 학생 목록 관리 프로그램을 만들어 봅시다. 이 프로그램은 학생을 추가하고, 삭제하고, 이름으로 검색하고, 전체 목록을 출력합니다.

먼저 학생 한 명을 표현하는 클래스를 설계합니다.

class Student {
private:
    int id_;
    std::string name_;
    int score_;

public:
    Student(int id, std::string name, int score)
        : id_{id}, name_{std::move(name)}, score_{score}
    {
    }

    int id() const
    {
        return id_;
    }

    const std::string& name() const
    {
        return name_;
    }

    int score() const
    {
        return score_;
    }

    void print() const
    {
        std::cout << "[" << id_ << "] "
                  << name_ << " - "
                  << score_ << "점n";
    }
};

학생에게는 id, name, score가 있습니다. name()은 const std::string&를 반환합니다. 문자열을 복사하지 않고 읽기 전용 참조로 돌려주기 위해서입니다. 다만 내부 데이터를 수정할 수 있는 참조를 외부에 내보내지는 않습니다. 이전에 배운 캡슐화 감각을 계속 유지하는 것입니다.

학생 목록은 다음처럼 만들 수 있습니다.

std::vector<Student> students;

학생을 추가하는 함수는 벡터를 수정해야 하므로 일반 참조로 받습니다.

void addStudent(std::vector<Student>& students, int id, const std::string& name, int score)
{
    students.emplace_back(id, name, score);
}

여기서는 emplace_back을 사용했습니다. Student 생성자에 필요한 id, name, score를 넘기면 벡터 안에 학생 객체가 생성됩니다.

전체 목록 출력 함수는 벡터를 읽기만 하므로 const 참조로 받습니다.

void printStudents(const std::vector<Student>& students)
{
    if (students.empty()) {
        std::cout << "등록된 학생이 없습니다.n";
        return;
    }

    for (const Student& student : students) {
        student.print();
    }
}

이름으로 학생을 검색하는 함수도 만들어 보겠습니다. 일단 첫 번째로 찾은 학생의 인덱스를 반환하겠습니다. 찾지 못하면 -1을 반환합니다.

int findStudentIndexByName(const std::vector<Student>& students, const std::string& name)
{
    for (std::size_t i = 0; i < students.size(); ++i) {
        if (students[i].name() == name) {
            return static_cast<int>(i);
        }
    }

    return -1;
}

찾지 못했을 때 -1을 반환하는 방식은 C와 C++에서 오래전부터 자주 쓰인 방식입니다. 나중에 std::optional을 배우면 "값이 없을 수 있음"을 더 명확하게 표현할 수 있습니다. 지금은 벡터 연습에 집중합시다.

인덱스로 학생을 삭제하는 함수는 다음과 같이 작성할 수 있습니다.

bool removeStudentByIndex(std::vector<Student>& students, int index)
{
    if (index < 0 || index >= static_cast<int>(students.size())) {
        return false;
    }

    students.erase(students.begin() + index);
    return true;
}

삭제에 성공하면 true, 실패하면 false를 반환합니다. erase를 호출하면 해당 위치의 학생이 제거되고, 뒤의 학생들이 앞으로 이동합니다.

학생 목록 관리 프로그램 전체 코드

이제 전체 코드를 하나로 묶어 보겠습니다.

#include <iostream>
#include <limits>
#include <string>
#include <utility>
#include <vector>

class Student {
private:
    int id_;
    std::string name_;
    int score_;

public:
    Student(int id, std::string name, int score)
        : id_{id}, name_{std::move(name)}, score_{score}
    {
    }

    int id() const
    {
        return id_;
    }

    const std::string& name() const
    {
        return name_;
    }

    int score() const
    {
        return score_;
    }

    void print() const
    {
        std::cout << "[" << id_ << "] "
                  << name_ << " - "
                  << score_ << "점n";
    }
};

void addStudent(std::vector<Student>& students, int id, const std::string& name, int score)
{
    students.emplace_back(id, name, score);
}

void printStudents(const std::vector<Student>& students)
{
    if (students.empty()) {
        std::cout << "등록된 학생이 없습니다.n";
        return;
    }

    for (const Student& student : students) {
        student.print();
    }
}

int findStudentIndexByName(const std::vector<Student>& students, const std::string& name)
{
    for (std::size_t i = 0; i < students.size(); ++i) {
        if (students[i].name() == name) {
            return static_cast<int>(i);
        }
    }

    return -1;
}

bool removeStudentByIndex(std::vector<Student>& students, int index)
{
    if (index < 0 || index >= static_cast<int>(students.size())) {
        return false;
    }

    students.erase(students.begin() + index);
    return true;
}

void clearInputLine()
{
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), 'n');
}

void printMenu()
{
    std::cout << "n==== 학생 목록 관리 ====n";
    std::cout << "1. 학생 추가n";
    std::cout << "2. 전체 출력n";
    std::cout << "3. 이름으로 검색n";
    std::cout << "4. 이름으로 삭제n";
    std::cout << "0. 종료n";
    std::cout << "선택: ";
}

int main()
{
    std::vector<Student> students;
    students.reserve(100);

    int nextId = 1;

    while (true) {
        printMenu();

        int choice = 0;
        std::cin >> choice;

        if (!std::cin) {
            std::cout << "입력이 올바르지 않습니다. 프로그램을 종료합니다.n";
            break;
        }

        clearInputLine();

        if (choice == 0) {
            std::cout << "프로그램을 종료합니다.n";
            break;
        }

        if (choice == 1) {
            std::string name;
            int score = 0;

            std::cout << "이름: ";
            std::getline(std::cin, name);

            std::cout << "점수: ";
            std::cin >> score;
            clearInputLine();

            addStudent(students, nextId, name, score);
            ++nextId;

            std::cout << "학생을 추가했습니다.n";
        } else if (choice == 2) {
            printStudents(students);
        } else if (choice == 3) {
            std::string name;

            std::cout << "검색할 이름: ";
            std::getline(std::cin, name);

            int index = findStudentIndexByName(students, name);

            if (index == -1) {
                std::cout << "해당 이름의 학생을 찾지 못했습니다.n";
            } else {
                students[index].print();
            }
        } else if (choice == 4) {
            std::string name;

            std::cout << "삭제할 이름: ";
            std::getline(std::cin, name);

            int index = findStudentIndexByName(students, name);

            if (removeStudentByIndex(students, index)) {
                std::cout << "학생을 삭제했습니다.n";
            } else {
                std::cout << "해당 이름의 학생을 찾지 못했습니다.n";
            }
        } else {
            std::cout << "알 수 없는 메뉴입니다.n";
        }
    }
}

이 프로그램은 크지 않지만 지금까지 배운 여러 감각이 들어 있습니다. 학생 목록은 std::vector<Student>로 관리합니다. 학생을 추가할 때는 emplace_back을 사용합니다. 전체 출력에서는 범위 기반 for문과 const 참조를 사용합니다. 검색은 인덱스를 순회하며 수행하고, 삭제는 erase로 처리합니다. 처음에 reserve(100)을 호출해 학생 100명 정도는 재할당을 줄이도록 했습니다.

이제 이 코드는 단순한 예제가 아니라 작은 프로그램의 뼈대입니다. 나중에 파일 입출력을 배우면 학생 목록을 파일에 저장할 수 있습니다. 알고리즘 라이브러리를 배우면 점수순 정렬도 쉽게 추가할 수 있습니다. map을 배우면 이름이나 ID로 더 빠르게 검색하는 구조도 만들 수 있습니다. 하나의 vector에서 시작한 코드가 점점 실전 프로그램으로 자라나는 것입니다.

삭제와 검색의 성능 감각

현재 학생 검색 함수는 처음부터 끝까지 차례대로 비교합니다.

for (std::size_t i = 0; i < students.size(); ++i) {
    if (students[i].name() == name) {
        return static_cast<int>(i);
    }
}

학생이 10명이라면 충분히 빠릅니다. 학생이 1,000명이어도 대부분의 경우 별문제가 없습니다. 하지만 학생이 1,000만 명이라면 이야기가 달라집니다. 이름 하나를 찾기 위해 최악의 경우 1,000만 번 비교해야 할 수 있습니다.

벡터는 인덱스로 접근할 때 빠릅니다. 끝에 추가할 때도 대체로 빠릅니다. 하지만 특정 값을 찾으려면 기본적으로 앞에서부터 훑어야 합니다. 이 성능 감각은 다음 장에서 map, set, unordered_map을 배울 때 중요한 기준이 됩니다.

삭제도 마찬가지입니다. 벡터 중간에서 원소 하나를 지우면 뒤의 원소들이 앞으로 이동합니다. 학생 목록처럼 원소 수가 적거나 삭제가 자주 일어나지 않는 경우에는 괜찮습니다. 그러나 매우 큰 데이터에서 중간 삭제가 반복된다면 비용이 커질 수 있습니다.

그래도 입문자에게 가장 먼저 권하고 싶은 컨테이너는 여전히 std::vector입니다. 이유는 단순합니다. 빠르고, 메모리 배치가 효율적이며, 사용법이 직관적이고, 표준 알고리즘과도 잘 어울립니다. 많은 경우에는 다른 컨테이너를 고민하기 전에 벡터로 충분합니다.

vector 사용 시 자주 하는 실수

std::vector는 안전한 도구이지만, 아무렇게나 사용해도 된다는 뜻은 아닙니다. 특히 처음 배울 때 자주 만나는 실수들이 있습니다.

첫 번째는 범위를 벗어난 인덱스 접근입니다.

std::vector<int> numbers{1, 2, 3};

std::cout << numbers[3] << 'n'; // 잘못된 접근

원소가 3개이면 마지막 인덱스는 2입니다. 인덱스와 개수를 혼동하지 않아야 합니다.

두 번째는 빈 벡터에서 front(), back(), pop_back()을 호출하는 것입니다.

std::vector<int> numbers;

numbers.pop_back(); // 위험

항상 먼저 비어 있는지 확인하는 습관이 좋습니다.

if (!numbers.empty()) {
    numbers.pop_back();
}

세 번째는 벡터가 커지는 동안 기존 원소의 주소나 참조를 오래 보관하는 것입니다.

std::vector<std::string> names;
names.push_back("Kim");

std::string& first = names[0];

names.push_back("Lee");
names.push_back("Park");

// first가 여전히 안전하다고 가정하면 위험할 수 있습니다.

벡터가 재할당되면 내부 원소들이 새 위치로 이동할 수 있습니다. 이때 이전 참조나 포인터는 더 이상 안전하지 않을 수 있습니다. 이 문제는 특히 복잡한 프로그램에서 디버깅하기 어렵습니다.

네 번째는 벡터를 함수에 값으로 넘겨 불필요한 복사를 만드는 것입니다.

void printNames(std::vector<std::string> names) // 복사
{
    for (const std::string& name : names) {
        std::cout << name << 'n';
    }
}

읽기만 한다면 다음처럼 작성합니다.

void printNames(const std::vector<std::string>& names)
{
    for (const std::string& name : names) {
        std::cout << name << 'n';
    }
}

다섯 번째는 reserve와 resize를 혼동하는 것입니다.

std::vector<int> numbers;
numbers.reserve(10);

numbers[0] = 5; // 잘못된 코드: size는 여전히 0

reserve(10)은 공간만 확보할 뿐 원소를 만들지 않습니다. numbers[0]에 접근하려면 실제 원소가 있어야 합니다.

std::vector<int> numbers;
numbers.resize(10);

numbers[0] = 5; // 가능

이 차이는 매우 중요합니다. "공간이 있다"와 "원소가 있다"는 다른 말입니다.

배열보다 vector를 먼저 생각하라

C++에는 여전히 C 스타일 배열이 있습니다.

int scores[3] = {90, 85, 100};

고정 크기의 작은 데이터라면 배열도 쓸 수 있습니다. 하지만 일반적인 프로그램에서 크기가 변할 수 있는 목록을 다룬다면 std::vector를 먼저 생각하는 편이 좋습니다.

std::vector는 크기를 스스로 관리합니다. 원소 개수를 size()로 확인할 수 있습니다. 범위 기반 for문과 잘 어울립니다. 함수에 넘길 때도 const std::vector<T>& 형태로 명확하게 표현할 수 있습니다. 무엇보다 직접 메모리를 할당하고 해제하는 위험한 코드를 크게 줄여 줍니다.

물론 std::vector가 모든 상황의 정답은 아닙니다. 크기가 컴파일 시점에 고정되어 있고 아주 작은 배열이라면 std::array가 더 적절할 수 있습니다. 키로 빠르게 찾는 구조라면 map이나 unordered_map이 더 좋을 수 있습니다. 중간 삽입과 삭제가 매우 잦다면 다른 컨테이너를 고려할 수도 있습니다.

하지만 시작점은 대개 std::vector입니다. C++에서 데이터를 여러 개 담아야 하는 순간, 먼저 이렇게 질문해 보세요.

"이 목록은 순서가 있고, 끝에 추가하는 일이 많고, 인덱스로 접근하면 편한가?"

그렇다면 높은 확률로 std::vector가 좋은 선택입니다.

이번 장에서 얻어야 할 감각

std::vector는 현대 C++의 기본 체력 같은 컨테이너입니다. 배열처럼 빠르게 접근할 수 있으면서도 크기를 동적으로 관리합니다. 원소 추가, 삭제, 순회, 검색 같은 기본 작업을 안전하고 간결하게 표현할 수 있습니다.

이번 장의 핵심은 단순히 push_back을 외우는 것이 아닙니다. 벡터가 내부적으로 연속된 메모리를 사용한다는 것, size와 capacity가 다르다는 것, 공간이 부족하면 재할당과 원소 이동이 일어날 수 있다는 것을 이해하는 것이 중요합니다. 이 감각이 있어야 나중에 반복자 무효화, 이동 의미론, 스마트 포인터, 알고리즘 라이브러리를 배울 때 코드의 움직임이 눈에 보이기 시작합니다.

학생 목록 관리 프로그램은 작지만 중요한 출발점입니다. 현실의 프로그램은 대부분 "무언가의 목록"을 다룹니다. 학생 목록, 할 일 목록, 주문 목록, 몬스터 목록, 접속자 목록, 로그 목록처럼 이름만 바뀔 뿐 구조는 비슷합니다. 그 중심에 std::vector가 있습니다.

다음 장에서는 순서대로 나열된 목록에서 한 걸음 더 나아가, 키를 기준으로 데이터를 찾고 관리하는 컨테이너를 배웁니다. 이름으로 학생을 더 빠르게 찾고 싶다면 어떻게 해야 할까요? 단어가 몇 번 나왔는지 세고 싶다면 어떤 구조가 좋을까요? 그 질문의 답이 map, set, unordered_map으로 이어집니다.

책 목록

처음 만나는 C++: 객체지향에서 현대 C++까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기