C++ vector 성능: 캐시 친화성, 용량 성장 전략, reserve 최적화
push_back이 반복될 때 드는 비용은 대부분 용량(capacity)이 부족해서 일어나는 재할당에서 나옵니다. 원소 개수를 대략 알면 reserve(n) 한 줄로 재할당을 없앨 수 있습니다. 이 글은 size와 capacity의 차이, 재할당이 실제로 얼마나 비싼지, vector가 list보다 빠른 이유인 캐시 지역성, 그리고 std::string의 SSO처럼 성능에 직접 영향을 주는 부분을 다룹니다.
들어가며: vector에 push_back만 했는데 왜 느릴까?
파일에서 읽은 100만 개를 vector에 넣는 코드
CSV 같은 텍스트 파일을 파싱해서 vector에 저장하는 코드가 있습니다. 데이터가 많아지면 느려지는데, 이때 가장 먼저 의심하게 되는 것이 push_back마다 일어날 수 있는 재할당입니다.
std::vector<int> loadData(const std::string& filename) {
std::vector<int> data; // 초기 capacity = 0
std::ifstream file(filename);
int value;
while (file >> value) {
data.push_back(value); // capacity가 부족할 때마다 재할당
}
return data;
}
빈 벡터는 capacity가 0에서 시작합니다. 벡터가 꽉 찬 상태에서 push_back을 하면 표준 라이브러리는 더 큰 버퍼를 새로 할당하고, 기존 원소를 옮기고, 이전 버퍼를 해제합니다. 2배씩 늘리는 구현이라면 100만 개를 넣는 동안 이 재할당이 약 20번(log₂ 100만 ≈ 20) 일어나고, 그때마다 그 시점까지의 원소를 모두 옮깁니다.
개수를 대략 알면 미리 공간을 잡아 둡니다.
std::vector<int> loadData(const std::string& filename) {
std::vector<int> data;
data.reserve(1000000); // 미리 공간 확보
std::ifstream file(filename);
int value;
while (file >> value) {
data.push_back(value); // 100만 개까지는 재할당 없음
}
return data;
}
재할당 횟수는 약 20번에서 0번으로 줄고, 재할당 순간 옛 버퍼와 새 버퍼가 함께 살아 있어 생기던 메모리 피크도 사라집니다. 다만 기대만큼 빨라지지 않을 수 있습니다. 2배 성장에서는 20번의 재할당 동안 옮기는 원소 수를 모두 더해도 1 + 2 + 4 + … ≈ 최종 크기 정도이므로, 100만 개의 int라면 수 MB를 몇 번 복사하는 수준입니다. 이 예제처럼 파일에서 읽는 경우 시간 대부분은 스트림 파싱이 차지하므로, reserve만으로 전체가 몇 배씩 빨라지지는 않습니다. 효과가 크게 드러나는 것은 원소가 크거나 이동 비용이 비쌀 때, 그리고 입력 파싱 없이 메모리 안에서만 대량으로 쌓을 때입니다. 실제로 어디가 느린지는 아래 벤치마크 코드처럼 나눠 재 보는 것이 가장 확실합니다.
정확한 개수를 모르면 “대략 최대치”만 예상해도 재할당 횟수가 크게 줄어듭니다. 너무 크게 잡으면 메모리만 낭비되므로, 실제 크기를 로그로 한 번 확인해 보는 것이 좋습니다.
이 글의 범위
std::vector의 초기화 방법(v(5)와 v{5}의 차이), 추가·삭제·접근 연산, 반복자 사용법 같은 기본은 C++ std::vector 기초: 생성·삽입·삭제와 배열 대신 쓰는 이유에서 다룹니다. 이 글은 그 기본을 안다고 보고, 왜 vector가 빠르거나 느려지는지와 std::string에서 성능에 영향을 주는 부분에 집중합니다.
size와 capacity는 다르다
size: 실제 원소 개수
size()는 현재 들어 있는 원소의 개수입니다. []로 접근할 수 있는 유효한 인덱스는 0부터 size()-1까지이고, 이 범위를 벗어나면 미정의 동작입니다. 범위 검사가 필요하면 at()을 씁니다.
std::vector<int> vec = {1, 2, 3};
std::cout << vec.size() << "\n"; // 3
capacity: 할당된 메모리 크기
capacity()는 재할당 없이 담을 수 있는 최대 원소 개수입니다. size는 push_back할 때마다 1씩 늘지만, capacity는 부족해질 때만 늘어납니다.
std::vector<int> vec;
std::cout << "size: " << vec.size() << ", capacity: " << vec.capacity() << "\n";
// size: 0, capacity: 0
vec.push_back(1);
std::cout << "size: " << vec.size() << ", capacity: " << vec.capacity() << "\n";
// size: 1, capacity: 1
vec.push_back(2);
std::cout << "size: " << vec.size() << ", capacity: " << vec.capacity() << "\n";
// size: 2, capacity: 2
vec.push_back(3);
std::cout << "size: " << vec.size() << ", capacity: " << vec.capacity() << "\n";
// size: 3, capacity: 4 (재할당 발생)
출력의 capacity 값은 libstdc++(GCC)와 libc++(Clang) 기준입니다. 이 두 구현은 capacity를 2배로 늘려 0 → 1 → 2 → 4 → 8 → 16 …으로 커지고, MSVC는 1.5배로 늘려 1 → 2 → 3 → 4 → 6 → 9 …처럼 커집니다. 표준은 성장 배율을 정하지 않고 push_back의 분할 상환(amortized) 상수 시간만 요구하므로, 코드가 특정 capacity 값에 의존해서는 안 됩니다.
flowchart LR
subgraph vec["vector (size=3, capacity=4)"]
direction TB
V0["[0] 10"]
V1["[1] 20"]
V2["[2] 30"]
V3["[3] (빈 공간)"]
V0 --> V1 --> V2 --> V3
end
size["size() = 3: 유효한 원소 개수"]
cap["capacity() = 4: 재할당 없이 담을 수 있는 최대 개수"]
size가 capacity에 도달한 상태에서 다음 push_back이 오면 재할당이 일어납니다. 반대로 reserve(100) 후에는 100번 push_back해도 재할당이 없습니다.
연속 메모리와 캐시 지역성: vector가 list보다 빠른 이유
vector의 가장 큰 성능 이점은 원소가 연속 메모리에 놓인다는 점입니다. 현대 CPU는 메모리를 보통 64바이트 캐시 라인 단위로 가져오므로, int 하나를 읽으면 같은 캐시 라인에 있는 이웃 원소들(최대 16개)도 함께 캐시에 올라옵니다. 순서대로 읽으면 하드웨어 프리페처가 다음 캐시 라인까지 미리 가져옵니다. 반면 list는 노드가 따로 할당되어 다음 노드의 주소를 읽기 전에는 어디로 갈지 알 수 없으므로, 노드가 메모리에 흩어져 있으면 거의 매 원소마다 메인 메모리까지 다녀오는 캐시 미스가 날 수 있습니다. deque는 고정 크기 블록 안에서는 연속이라 vector와 비슷하게 동작하지만, 블록 경계마다 다른 메모리로 건너갑니다.
아래 코드는 같은 개수의 정수를 vector와 list에 넣고 순회 시간을 잽니다. 절대 수치는 CPU와 원소 수에 따라 크게 달라지지만, 원소 수가 CPU 캐시 크기를 넘어설수록 차이가 커집니다. 노드를 방금 순서대로 할당한 이 실험에서는 할당자가 노드를 가까운 주소에 놓아 주어 차이가 예상보다 작게 나올 수 있고, 삽입·삭제를 오래 반복해 노드가 흩어진 실제 프로그램에서는 더 벌어집니다.
// 순회 시간 비교 (g++ -O2 -std=c++17)
#include <chrono>
#include <iostream>
#include <list>
#include <string>
#include <vector>
template<typename Container>
void benchmark(const std::string& name) {
Container c(1'000'000, 42);
auto start = std::chrono::steady_clock::now();
long long sum = 0;
for (const auto& val : c) {
sum += val;
}
auto end = std::chrono::steady_clock::now();
std::chrono::duration<double, std::milli> elapsed = end - start;
std::cout << name << ": " << elapsed.count() << "ms (sum=" << sum << ")\n";
}
int main() {
benchmark<std::vector<int>>("vector");
benchmark<std::list<int>>("list");
}
sum을 출력하는 이유는 결과를 쓰지 않으면 최적화기가 루프 전체를 지워 버릴 수 있기 때문입니다.
push_back의 비용 구조
push_back 한 번은 대부분의 경우 이미 확보된 공간 끝에 원소 하나를 만드는 것으로 끝납니다. 가끔 capacity가 부족할 때만 재할당이 일어나는데, capacity를 일정 배율로 늘리기 때문에 n개를 넣는 동안 재할당은 O(log n)번이고, 옮기는 원소 수의 총합은 O(n)입니다. 그래서 push_back 한 번의 비용은 분할 상환 O(1)입니다. 1씩 늘리는 방식이었다면 매번 전체를 옮겨 총 O(n²)이 되었을 것입니다.
재할당 때 원소를 옮기는 방법에는 조건이 있습니다. vector는 push_back이 실패해도 원래 상태를 유지한다는 강한 예외 보장을 지키기 위해, 원소 타입의 이동 생성자가 noexcept일 때만 이동하고 그렇지 않으면 복사합니다(std::move_if_noexcept). 직접 만든 클래스의 이동 생성자에 noexcept를 빠뜨리면 재할당 때마다 모든 원소가 복사되므로, 이동 생성자는 가능하면 noexcept로 선언해야 합니다.
정리하면, 개수를 대략이라도 알 때는 reserve가 가장 확실한 최적화이고, 원소가 수백 개 정도로 적다면 재할당이 몇 번 일어나는 것은 신경 쓸 필요가 없습니다. 개수를 전혀 모르는 대량 삽입이라도 기본 성장 전략이 이미 분할 상환 O(1)을 보장하므로, 직접 reserve(size() * 2) 같은 확장 로직을 짤 이유는 없습니다. 오히려 reserve(size() + 1)처럼 조금씩 늘리면 매번 재할당이 일어나 O(n²)이 됩니다.
reserve와 shrink_to_fit으로 재할당 줄이기
reserve: 미리 공간 확보
reserve(n)은 size는 그대로 두고 capacity를 최소 n으로 만듭니다. 그 안에서는 push_back을 해도 재할당이 일어나지 않습니다.
std::vector<int> vec;
vec.reserve(1000); // 1000개 공간 미리 확보
for (int i = 0; i < 1000; ++i) {
vec.push_back(i); // 재할당 없음
}
std::cout << "size: " << vec.size() << ", capacity: " << vec.capacity() << "\n";
// size: 1000, capacity: 1000
reserve로 공간을 잡은 뒤 vec[i] = …처럼 인덱스로 쓰는 것은 흔한 실수입니다. reserve는 size를 늘리지 않으므로 그 원소들은 아직 존재하지 않습니다. 원소를 만들어 두고 인덱스로 채우려면 resize를 써야 합니다.
shrink_to_fit: 남는 메모리 반환 요청
resize(n)이나 clear()로 size를 줄여도 capacity는 그대로라서, 메모리를 많이 쓰는 상태가 유지됩니다. shrink_to_fit()은 “지금 size에 맞게 capacity를 줄여도 된다”고 구현에 요청하는 함수입니다. 표준상 구속력 없는 요청이지만, 주요 구현은 실제로 줄여 줍니다.
std::vector<int> vec(1000);
vec.resize(10); // size는 10, capacity는 1000
std::cout << "Before: capacity = " << vec.capacity() << "\n"; // 1000
vec.shrink_to_fit();
std::cout << "After: capacity = " << vec.capacity() << "\n"; // 보통 10
줄일 때도 새 버퍼를 할당하고 원소를 옮기는 재할당이 일어나므로 공짜가 아닙니다. 크게 줄인 뒤 더 이상 늘어나지 않을 벡터에 한 번 호출하는 것이 적절하고, 다시 커질 벡터에 반복해서 호출하면 재할당만 늘어납니다.
실전에서 함께 쓰는 기법
여러 기법을 함께 적용하면 다음과 같습니다.
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
struct Record {
int id;
std::string name;
double value;
Record(int i, std::string n, double v) : id(i), name(std::move(n)), value(v) {}
};
std::vector<Record> loadRecords(const std::string& filename) {
std::vector<Record> records;
records.reserve(100000); // 예상 개수만큼 미리 확보
std::ifstream file(filename);
std::string line;
while (std::getline(file, line)) {
std::istringstream iss(line);
int id;
std::string name;
double value;
if (iss >> id >> name >> value) {
// 생성자 인자를 넘겨 vector 안에서 바로 생성
records.emplace_back(id, std::move(name), value);
}
}
return records; // NRVO 또는 이동, 복사는 일어나지 않음
}
reserve로 재할당을 막고, emplace_back으로 임시 Record를 만들지 않으며, std::move(name)으로 문자열 복사를 피합니다. 지역 변수를 반환할 때는 컴파일러가 복사 생략(NRVO)을 하거나, 그렇지 않더라도 자동으로 이동하므로 return std::move(records);라고 쓸 필요가 없습니다(오히려 NRVO를 막습니다).
조건에 맞는 원소만 골라낼 때도 결과 크기의 상한을 알면 reserve할 수 있습니다.
std::vector<int> result;
result.reserve(input.size()); // 상한: 전부 통과하는 경우
std::copy_if(input.begin(), input.end(), std::back_inserter(result),
[](int x) { return x % 2 == 0; });
result.shrink_to_fit(); // 결과가 훨씬 작고 오래 보관한다면
정렬된 고유 값 목록이 필요할 때는 sort + unique + erase 조합이 std::set에 넣는 것보다 대개 빠릅니다. 둘 다 O(n log n)이지만, 이 방식은 노드 할당 없이 연속 메모리 안에서 처리하기 때문입니다.
std::sort(vec.begin(), vec.end());
vec.erase(std::unique(vec.begin(), vec.end()), vec.end());
std::string과 SSO(Small String Optimization)
string도 vector처럼 size와 capacity를 가진다
std::string도 동적 버퍼, size와 capacity, 재할당이라는 점에서 std::vector<char>와 비슷하게 동작합니다. 차이는 짧은 문자열을 힙이 아니라 객체 안에 저장하는 SSO(Small String Optimization)입니다.
std::string str;
std::cout << "size: " << str.size() << ", capacity: " << str.capacity() << "\n";
// size: 0, capacity: 15 (libstdc++, MSVC) / 22 (libc++)
str = "Hello, World! This is a long string.";
std::cout << "size: " << str.size() << ", capacity: " << str.capacity() << "\n";
// size: 36, capacity: 36 이상
빈 문자열의 capacity가 0이 아닌 이유가 SSO입니다. 64비트 환경에서 libstdc++와 MSVC는 15자, libc++는 22자까지를 객체 내부 버퍼에 저장하고, 그보다 길어지면 힙에 버퍼를 할당해 vector처럼 동작합니다. 짧은 문자열이 많은 프로그램에서 할당 횟수를 크게 줄여 주는 최적화입니다.
flowchart LR
subgraph sso["짧은 문자열 (SSO)"]
S1["string 객체"]
S2["객체 내부 버퍼: 'Hi'"]
S1 --> S2
end
subgraph heap["긴 문자열 (힙 할당)"]
H1["string 객체"]
H2[포인터]
H3["힙 메모리: 'Very long string...'"]
H1 --> H2 --> H3
end
SSO 때문에 생기는 미묘한 결과도 있습니다. 짧은 문자열은 이동해도 문자 데이터를 복사해야 하므로(버퍼가 객체 안에 있으므로) 이동이 포인터 교환만큼 싸지 않고, 이동 후에도 원래 객체의 문자 데이터 주소가 바뀝니다.
string 연산
std::string str = "Hello";
// 추가
str += " World"; // "Hello World"
str.append("!"); // "Hello World!"
str.push_back('?'); // "Hello World!?"
// 삽입
str.insert(5, ","); // "Hello, World!?"
// 삭제
str.erase(5, 1); // "Hello World!?" (쉼표 제거)
str.pop_back(); // "Hello World!" (? 제거)
// 부분 문자열 (새 string으로 복사)
std::string sub = str.substr(0, 5); // "Hello"
// 찾기: 없으면 npos
size_t pos = str.find("World"); // 6
if (pos != std::string::npos) {
std::cout << "Found at: " << pos << "\n";
}
substr은 부분 문자열을 새로 할당해 복사하므로, 읽기만 할 거라면 C++17의 std::string_view로 같은 구간을 복사 없이 가리킬 수 있습니다(수명 주의점은 아래에서 다룹니다).
문자열을 반복해서 이어 붙일 때
루프 안에서 +=로 계속 붙이면 문자열 버퍼도 vector처럼 기하급수적으로 커지므로 재할당 횟수 자체는 O(log n)입니다. 더 큰 낭비는 std::to_string(i) + ","처럼 매번 임시 문자열을 만드는 쪽에서 나옵니다.
std::string result;
result.reserve(60000); // 대략적인 최종 길이
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i); // 임시 문자열 하나
result += ','; // 임시 없이 문자 하나 추가
}
최종 길이를 대략 알면 reserve로 재할당을 없애고, 구분자는 임시 문자열을 만들지 않도록 따로 붙입니다. std::ostringstream은 여러 타입을 섞어 포맷할 때 편리하지만, 스트림 자체의 오버헤드가 있어서 위처럼 reserve한 문자열에 직접 붙이는 것보다 빠르다는 보장은 없습니다. 성능이 중요하면 두 방식을 재 보고 고르는 것이 좋습니다.
여러 조각을 합칠 때는 총 길이를 먼저 계산해 정확히 한 번만 할당할 수도 있습니다.
std::string buildMessage(const std::vector<std::string>& parts) {
size_t total = 0;
for (const auto& p : parts) total += p.size();
std::string result;
result.reserve(total + 2 * parts.size()); // 구분자 ", " 몫 포함
for (size_t i = 0; i < parts.size(); ++i) {
if (i > 0) result += ", ";
result += parts[i];
}
return result;
}
emplace_back, 참조 순회, erase-remove, vector
emplace_back vs push_back
push_back(Point(1, 2))는 임시 Point를 만든 뒤 vector 안으로 이동(이동 생성자가 없으면 복사)합니다. emplace_back(1, 2)는 생성자 인자를 그대로 넘겨 vector 안에서 직접 객체를 만들므로, 임시 객체 생성과 이동이 한 번씩 줄어듭니다.
struct Point {
int x, y;
Point(int x, int y) : x(x), y(y) { std::cout << "Constructor\n"; }
Point(Point&& o) noexcept : x(o.x), y(o.y) { std::cout << "Move\n"; }
};
std::vector<Point> vec;
vec.reserve(2); // 재할당으로 인한 Move 출력을 배제
vec.push_back(Point(1, 2));
// Constructor (임시 객체)
// Move (vector 안으로 이동)
vec.emplace_back(1, 2);
// Constructor (vector 안에서 직접 생성)
이미 만들어 둔 객체를 넘길 때는 push_back(p)와 emplace_back(p)가 같은 일을 합니다. 또 emplace_back은 explicit 생성자도 호출해 버리므로, std::vector<std::unique_ptr<T>>에 emplace_back(raw_ptr)처럼 의도치 않은 변환을 허용할 수 있다는 점도 알아 두어야 합니다.
범위 기반 for에서 참조 사용
for (std::string str : vec)처럼 값으로 받으면 원소마다 복사가 일어납니다. string처럼 복사에 할당이 따르는 타입이면 읽기만 할 때 const auto&로 받습니다.
std::vector<std::string> vec = {"apple", "banana", "cherry"};
// ❌ 원소마다 복사
for (std::string str : vec) {
std::cout << str << "\n";
}
// ✅ 참조로 순회
for (const std::string& str : vec) {
std::cout << str << "\n";
}
erase-remove idiom
루프 안에서 erase를 반복하면 매번 뒤의 원소들이 한 칸씩 당겨지므로, 지울 원소가 많을수록 O(n²)에 가까워집니다. std::remove는 지우지 않을 원소만 앞으로 모으고 새 논리적 끝 반복자를 반환하므로, 그 뒤를 한 번에 erase하면 O(n)입니다.
std::vector<int> vec = {1, 2, 3, 2, 4, 2, 5};
// ❌ 루프에서 erase: 지울 때마다 뒤를 당김
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it == 2) {
it = vec.erase(it);
} else {
++it;
}
}
// ✅ erase-remove: 한 번에 처리
vec.erase(std::remove(vec.begin(), vec.end(), 2), vec.end());
// C++20부터는 한 줄로
std::erase(vec, 2);
data()로 C API 연동
C API가 const T*와 길이를 요구할 때는 vec.data()와 vec.size()를 넘깁니다. vector는 연속 메모리를 보장하므로 그대로 배열처럼 쓸 수 있습니다.
void process_array(const int* arr, size_t len);
std::vector<int> vec = {1, 2, 3, 4, 5};
process_array(vec.data(), vec.size());
재할당이 일어나면 data()가 반환했던 포인터는 무효가 됩니다. C 라이브러리가 포인터를 보관했다가 나중에 쓰는 구조라면, 그동안 vector에 원소를 추가하지 않도록 해야 합니다.
vector은 피하기
std::vector<bool>은 원소를 비트 단위로 압축하는 특수화라서 operator[]가 bool&가 아니라 프록시 객체를 반환합니다. bool&를 받는 함수에 넘길 수 없고, auto x = v[0];의 x는 bool이 아니라 프록시라서 원본을 계속 참조합니다.
std::vector<bool> flags = {true, false, true};
// bool* p = &flags[0]; // 컴파일 오류: 프록시의 주소
std::vector<char> flags2 = {1, 0, 1}; // 일반 bool 시퀀스가 필요할 때
npos 비교 누락과 string_view 수명 문제
vector subscript out of range, erase 루프에서의 반복자 무효화처럼 vector 쪽에서 흔한 에러는 C++ std::vector 기초: 생성·삽입·삭제와 배열 대신 쓰는 이유에 재현 코드와 함께 정리되어 있습니다. 여기서는 문자열 쪽에서 자주 생기는 두 가지를 봅니다.
string::find 결과를 npos와 비교하지 않음
find()가 찾지 못하면 std::string::npos(size_t의 최댓값)를 반환합니다. 이 값을 그대로 substr의 시작 위치로 넘기면 std::out_of_range 예외가 나고, 직접 인덱스로 쓰면 범위를 벗어난 접근이 됩니다.
std::string str = "Hello";
// ❌ npos 검사 없음
size_t pos = str.find("World");
// std::string sub = str.substr(pos, 5); // pos == npos → std::out_of_range
// ✅ npos 검사
if (pos != std::string::npos) {
std::string sub = str.substr(pos, 5);
} else {
std::cout << "Not found\n";
}
pos를 int에 담으면 npos가 대개 -1로 바뀌어 비교가 우연히 맞는 것처럼 보이다가, 다른 곳에서 부호 없는 값과 섞이며 깨집니다. find의 결과는 항상 size_t(또는 auto)로 받는 것이 안전합니다.
std::string_view가 임시 string을 가리킴
string_view는 문자열을 소유하지 않고 포인터와 길이만 들고 있습니다. 함수가 반환한 임시 std::string으로 string_view를 만들면, 그 문장이 끝나는 순간 임시 객체가 사라지고 뷰는 해제된 메모리를 가리킵니다. 짧은 문자열은 SSO 때문에 힙이 아니라 이미 사라진 객체의 내부 버퍼를 가리키게 되는데, 그 메모리가 아직 덮어써지지 않아 테스트에서는 멀쩡해 보이는 경우가 있어 문제가 늦게 드러납니다. 어느 쪽이든 미정의 동작입니다.
std::string makeName() { return "some fairly long generated name"; }
// ❌ 임시 string은 이 줄이 끝나면 소멸
std::string_view name = makeName();
std::cout << name << '\n'; // 해제된 메모리 읽기
// ✅ 소유가 필요하면 string으로 받는다
std::string owned = makeName();
std::string_view view = owned; // owned보다 오래 쓰지 않는다
string_view는 함수 인자로 받아서 그 함수 안에서만 쓰는 용도가 가장 안전합니다. 멤버 변수나 반환값으로 쓸 때는 원본 문자열의 수명을 누가 보장하는지부터 확인해야 합니다.
CSV 파싱, 로그 수집, 설정 파일 파싱 예시
CSV 파일 파싱 (행 단위)
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
std::vector<std::vector<std::string>> parseCSV(const std::string& filename) {
std::ifstream file(filename);
std::vector<std::vector<std::string>> rows;
std::string line;
while (std::getline(file, line)) {
std::vector<std::string> row;
std::istringstream iss(line);
std::string cell;
while (std::getline(iss, cell, ',')) {
row.push_back(cell);
}
rows.push_back(std::move(row)); // 행 벡터를 복사하지 않고 이동
}
return rows;
}
이 파서는 따옴표 안의 쉼표 같은 실제 CSV 규칙은 처리하지 않는 단순 분할기입니다. 성능 면에서는 행을 std::move로 넣어 복사를 피하고 있고, 파일 크기(std::filesystem::file_size)와 평균 줄 길이로 행 수를 추정할 수 있으면 rows.reserve(…)를, 열 개수가 일정하면 row.reserve(열_수)를 추가할 수 있습니다.
로그 메시지 수집
#include <chrono>
#include <string>
#include <vector>
std::vector<std::string> collectLogs(int max_entries) {
std::vector<std::string> logs;
logs.reserve(max_entries); // 상한을 알면 미리 확보
for (int i = 0; i < max_entries; ++i) {
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
std::string msg = "Log entry " + std::to_string(i) + " at " + std::to_string(time);
logs.push_back(std::move(msg));
}
return logs;
}
설정 파일 키-값 파싱
#include <string>
std::string parseConfigValue(const std::string& line) {
size_t pos = line.find('=');
if (pos == std::string::npos) return "";
size_t start = line.find_first_not_of(" \t", pos + 1);
if (start == std::string::npos) return "";
size_t end = line.find_last_not_of(" \t");
return line.substr(start, end - start + 1);
}
= 뒤의 값을 한 번 substr로 잘라낸 뒤 다시 공백을 다듬으면 문자열을 두 번 할당하므로, 원래 문자열에서 바로 시작·끝 위치를 찾아 한 번만 잘라냅니다. 반환값이 원본보다 오래 살 필요가 없다면 std::string_view를 반환해 할당을 아예 없앨 수도 있습니다.
reserve가 효과를 내는 조건
| 시나리오 | reserve가 줄여 주는 것 | 차이가 커지는 조건 |
|---|---|---|
| 100만 개 int push_back | 재할당 약 20회와 그때의 원소 이동 | 원소 수가 많고 루프만 따로 잴 때 |
| 10만 개 string push_back | 재할당 횟수 (문자 데이터는 이동이라 복사 안 함) | 이동 생성자가 noexcept가 아닌 타입일 때 |
| 1만 번 string += | 문자열 버퍼 재할당 | 최종 길이가 길 때 |
| emplace_back vs push_back | 임시 객체 생성 한 번 (생성자 인자를 넘길 때만) | 생성·이동 비용이 큰 타입일 때 |
reserve 유무와 재할당 횟수 벤치마크
#include <chrono>
#include <iostream>
#include <vector>
volatile int sink; // 결과를 사용해 최적화기가 루프를 지우지 못하게 함
template<typename Func>
double measure_ms(Func&& f) {
auto start = std::chrono::steady_clock::now();
f();
std::chrono::duration<double, std::milli> d = std::chrono::steady_clock::now() - start;
return d.count();
}
int main() {
const size_t N = 1'000'000;
double t1 = measure_ms([&] {
std::vector<int> v;
for (size_t i = 0; i < N; ++i) v.push_back(static_cast<int>(i));
sink = v.back();
});
double t2 = measure_ms([&] {
std::vector<int> v;
v.reserve(N);
for (size_t i = 0; i < N; ++i) v.push_back(static_cast<int>(i));
sink = v.back();
});
std::cout << "int " << N << ": no_reserve=" << t1 << "ms, reserve=" << t2 << "ms\n";
}
g++ -O2 -std=c++17로 컴파일해 여러 번 실행하고 평균을 봅니다. 결과를 sink에 쓰지 않으면, 최신 컴파일러는 쓰이지 않는 vector의 할당과 루프를 통째로 없애 0ms에 가까운 무의미한 결과를 낼 수 있습니다.
| 테스트 | 예상되는 경향 | 이유 |
|---|---|---|
| int 100만 push_back | reserve 쪽이 빠르지만 루프만 잴 때 차이가 뚜렷하고, 입력 처리와 섞이면 작아짐 | 재할당 약 20회 동안 옮기는 총량은 최종 크기 정도 |
| string 10만 push_back | 차이가 int보다 작을 수 있음 | std::string은 이동 생성자가 noexcept라 재할당 때 힙의 문자 데이터를 복사하지 않고 내부 포인터만 옮김 |
| 큰 객체 emplace vs push | 생성자 인자를 넘길 때만 emplace가 임시 객체 하나를 줄임 | 이미 만든 객체를 넘기면 둘은 같은 이동을 함 |
결과는 CPU 캐시, 메모리 할당자, OS에 따라 달라집니다. 재할당 횟수 자체를 확인하고 싶다면 루프 안에서 capacity()가 바뀔 때마다 세어 보면 됩니다.
vector와 다른 컨테이너 중 고르기
| 상황 | 추천 | 이유 |
|---|---|---|
| 인덱스 접근, 끝에 추가 | vector | 연속 메모리, O(1) 접근 |
| 앞에 삽입/삭제 | deque | vector는 앞 삽입이 O(n) |
| 중간 삽입이 잦고 반복자 유지가 필요 | list | 삽입·삭제가 다른 원소의 반복자를 무효화하지 않음 |
| 키로 검색 | unordered_map / map | vector의 find는 O(n) |
| 정렬된 상태를 계속 유지 | set / multiset | vector는 삽입마다 정렬 위치를 찾아 당겨야 함 |
원소가 수천 개 이하라면 중간 삽입이 O(n)이어도 연속 메모리 덕분에 vector가 list보다 빠른 경우가 많으므로, 표의 기준은 원소 수가 크고 그 연산이 실제로 병목일 때 적용하는 것이 좋습니다. 순차 처리, 인덱스 접근, 끝에서의 추가·삭제가 중심이라면 vector가 기본값입니다.