C++ vector 성능 | "100만 개 넣는데 10초" 문제와 reserve
이 글의 핵심
C++ vector 성능에 대한 실전 가이드입니다.
들어가며: vector에 push_back만 했는데 왜 이렇게 느릴까?
“100만 개 데이터를 넣는데 10초나 걸렸어요”
CSV(Comma-Separated Values, 쉼표로 구분된 값) 파일을 파싱해서 vector에 저장하는 코드를 작성했습니다. 하지만 데이터가 많아지자 급격히 느려졌습니다.
문제의 코드에서 std::vector<int> data는 빈 벡터로 시작하므로 초기 capacity(내부 버퍼 크기)는 0입니다. while (file >> value)로 파일에서 정수를 하나씩 읽어 올 때마다 push_back(value)를 호출하는데, 벡터가 꽉 찰 때마다 컴파일러는 내부적으로 “더 큰 메모리를 새로 할당 → 기존 원소를 전부 복사 → 이전 메모리 해제”를 반복합니다. 데이터가 100만 개라면 이 재할당이 약 20번 정도 일어나고, 매번 기존 원소 전체를 복사하므로 시간이 크게 늘어납니다.
문제의 코드:
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); // 재할당이 반복됨!
}
return data;
}
위 코드 설명: 빈 vector는 초기 capacity가 0이라, push_back이 반복될 때마다 용량이 부족하면 재할당(더 큰 버퍼 할당 → 기존 원소 복사/이동 → 이전 버퍼 해제)이 발생합니다. 100만 개를 넣으면 재할당이 약 20번 정도 일어나며, 매번 기존 원소 전체를 옮기므로 시간이 크게 늘어납니다.
원인:
vector는 용량이 부족하면 메모리를 재할당 비유하면 vector는 STL(Standard Template Library, 표준 템플릿 라이브러리—C++ 표준이 제공하는 컨테이너·반복자·알고리즘 모음)의 “연속된 칸이 있는 서랍”인데, 칸이 꽉 차면 “더 큰 서랍으로 이사”합니다. 이사할 때마다 기존 물건을 전부 새 서랍으로 옮겨야 하므로, 이사 횟수가 많으면 느려집니다.- 재할당 시 기존 데이터를 새 메모리로 복사
- 100만 개 데이터면 재할당이 약 20번 발생
개수가 미리 대략 정해져 있으면 reserve()로 한 번에 공간을 잡아 두면 재할당과 복사를 줄일 수 있습니다. size()와 capacity()의 차이를 알아 두면, “왜 지금 느린지”를 로그만으로도 추정할 때 도움이 됩니다.
vector가 커지는 방식: 대부분의 구현에서는 capacity가 부족해지면 기존 capacity의 약 2배로 재할당합니다. 그래서 원소를 계속 push_back하면 재할당 횟수가 로그에 비례해 늘어나고, 100만 개처럼 많을 때는 reserve 없이 하면 20번 안팎의 재할당이 일어날 수 있습니다. 한 번 재할당할 때마다 기존 원소 전체를 새 버퍼로 복사(또는 이동)하므로, reserve로 재할당 횟수를 줄이는 것이 성능에 큰 영향을 줍니다.
실무 팁: 정확한 개수를 모르면 “대략 최대치”만 예상해도 reserve(예상_개수)를 호출해 두면 재할당 횟수가 크게 줄어듭니다. 너무 크게 잡으면 메모리만 많이 쓰므로, 로그나 프로파일로 한 번 확인해 보는 것이 좋습니다.
해결 후에서는 data.reserve(1000000)으로 “최대 100만 개까지 넣을 공간”을 한 번에 미리 잡아 둡니다. 그러면 push_back을 반복해도 내부 버퍼가 부족해지지 않아 재할당이 일어나지 않고, 기존 원소를 복사하는 비용이 사라집니다. 나머지 로직(파일 열기, 값 읽기, push_back)은 동일합니다.
해결 후:
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); // 재할당 없음!
}
return data;
}
위 코드 설명: reserve(1000000)으로 “최대 100만 개” 공간을 한 번에 잡아 두므로, 그 안에서 push_back을 해도 재할당이 일어나지 않습니다. 기존 원소를 옮기는 비용이 없어져 대량 삽입 시 훨씬 빨라지고, 실행 시간이 10초에서 0.5초 수준으로 줄어드는 효과를 얻을 수 있습니다.
결과: 10초 → 0.5초 (20배 빠름)
재할당 성능 문제 시나리오
실무에서 자주 겪는 재할당 성능 문제: 로그 수집(초당 1만 건), 게임 엔티티(60fps×100개), 패킷 버퍼 등에서 reserve 없이 push_back을 반복하면 재할당이 병목이 됩니다. 100만 개 기준 20회 재할당 × 평균 50만 개 복사 ≈ 10억 번 원소 이동. 핵심: “개수를 대략이라도 알면 reserve”가 가장 효과적입니다.
이 글을 읽으면:
vector와string의 내부 동작을 이해할 수 있습니다.capacity와size의 차이를 알 수 있습니다.- 메모리 재할당을 최소화하는 방법을 익힐 수 있습니다.
- 실전에서 성능을 최적화할 수 있습니다.
목차
- std::vector 기초
- capacity vs size
- 메모리 재할당 최적화
- std::string 완벽 가이드
- 실전 성능 팁
- 자주 발생하는 문제와 해결법
- 실전 예시
- 성능 벤치마크
- 프로덕션 패턴
vector 재할당 흐름도
flowchart TD
subgraph reserve없이["reserve 없이 push_back"]
A1["빈 vectorbr/capacity=0"] --> A2["push_back 1회br/재할당"]
A2 --> A3["push_back 2회br/재할당"]
A3 --> A4["push_back 4회br/재할당"]
A4 --> A5["... 20회 재할당br/100만 개"]
end
subgraph reserve사용["reserve 사용"]
B1[vector.reserve 100만] --> B2["push_back 100만 회br/재할당 0회"]
end
위 다이어그램 설명: reserve 없이 push_back을 반복하면 capacity가 부족할 때마다 2배로 재할당이 발생합니다. 100만 개를 넣으면 약 20번의 재할당이 일어나며, 매번 기존 원소 전체를 새 버퍼로 복사합니다. 반면 reserve(1000000)으로 미리 공간을 잡아 두면 재할당이 한 번도 일어나지 않습니다.
1. std::vector 기초
vector란?
std::vector는 동적 배열입니다:
- 크기가 자동으로 늘어남
- 연속된 메모리에 저장 (캐시 친화적)
- 랜덤 액세스 O(1)
- 끝에 추가 O(1) (amortized)
vector를 쓰는 이유: C 스타일 배열 int arr[100]은 크기가 고정되어 있고, new[]/delete[]는 직접 관리해야 합니다. vector는 크기가 자동으로 늘어나고, RAII로 메모리가 자동 해제되며, size()·capacity()·reserve()로 성능을 제어할 수 있습니다. 대부분의 경우 “동적 배열이 필요하면 vector”가 기본 선택입니다.
아래 예는 빈 vector를 만든 뒤 push_back으로 끝에 원소를 넣고, []로 인덱스 접근과 size()로 개수를 확인하는 기본 흐름입니다. 내부적으로는 필요할 때마다 capacity가 늘어나며, 그때마다 재할당이 일어날 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o vec_basic vec_basic.cpp && ./vec_basic
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
vec.push_back(10);
vec.push_back(20);
vec.push_back(30);
std::cout << vec[0] << "\n"; // 10
std::cout << vec.size() << "\n"; // 3
}
위 코드 설명: 빈 vector에 push_back으로 10, 20, 30을 넣으면 size는 3이 되고, vec[0]으로 첫 원소에 접근할 수 있습니다. 내부적으로 capacity가 부족할 때마다 버퍼가 늘어나며, 연속 메모리라서 인덱스 접근은 O(1)입니다.
실행 결과: 10 한 줄, 3 한 줄이 출력됩니다.
초기화 방법
vector를 만드는 방법은 여러 가지입니다. v2(5)는 크기만 지정해 기본값(정수면 0)으로 채우고, v3(5, 42)는 크기와 초기값을 함께 줍니다. v4처럼 중괄호 리스트는 원소 개수와 값을 직접 나열할 때 쓰고, v5는 복사 생성, v6는 다른 컨테이너의 반복자 범위로 복사할 때 사용합니다.
// 빈 vector
std::vector<int> v1;
// 크기 지정 (기본값으로 초기화)
std::vector<int> v2(5); // {0, 0, 0, 0, 0}
// 크기 + 초기값
std::vector<int> v3(5, 42); // {42, 42, 42, 42, 42}
// 초기화 리스트
std::vector<int> v4 = {1, 2, 3, 4, 5};
// 다른 vector 복사
std::vector<int> v5 = v4;
// 범위 복사
std::vector<int> v6(v4.begin(), v4.end());
위 코드 설명: v2(5)는 크기 5, 기본값(0)으로 채우고, v3(5, 42)는 5개를 42로 채웁니다. v4는 초기화 리스트로 값을 직접 나열하고, v5는 복사, v6는 반복자 범위 [begin, end)로 복사합니다. 용도에 맞는 초기화를 선택하면 됩니다.
주요 연산
끝에 추가할 때는 push_back(값) 또는 생성자 인자를 직접 넘기는 emplace_back(...)을 씁니다. emplace_back은 임시 객체 없이 컨테이너 안에서 바로 생성하므로 복사/이동 비용이 없을 수 있습니다. erase(반복자)는 해당 위치 원소를 지우고, 그 다음 원소를 가리키는 반복자를 반환하므로 루프에서 지울 때는 반환값을 받아서 사용해야 합니다. at(i)는 범위를 검사해서 벗어나면 예외를 던집니다.
std::vector<int> vec = {1, 2, 3};
// 추가
vec.push_back(4); // 끝에 추가
vec.emplace_back(5); // 생성자 인자로 추가 (더 효율적)
// 삭제
vec.pop_back(); // 끝에서 제거
vec.erase(vec.begin()); // 특정 위치 제거
vec.clear(); // 모두 제거
// 접근
int first = vec.front();
int last = vec.back();
int second = vec[1];
int third = vec.at(2); // 범위 체크 (예외 던짐)
// 크기
size_t sz = vec.size();
bool empty = vec.empty();
위 코드 설명: push_back은 끝에 값을 넣고, emplace_back은 생성자 인자만 넘겨 컨테이너 안에서 직접 생성해 복사/이동을 줄입니다. erase(반복자)는 그 자리 원소를 제거하고 다음 반복자를 반환하므로, 루프에서 지울 때는 it = vec.erase(it)처럼 받아야 합니다. at(i)는 범위를 검사해 벗어나면 예외를 던집니다.
2. capacity vs size
size: 실제 원소 개수
size()는 현재 들어 있는 원소의 개수입니다. []로 접근할 수 있는 유효한 인덱스는 0부터 size()-1까지입니다.
std::vector<int> vec = {1, 2, 3};
std::cout << vec.size() << "\n"; // 3
위 코드 설명: size()는 현재 들어 있는 원소 개수이고, 유효한 인덱스는 0부터 size()-1까지입니다. []로 접근할 때 이 범위를 벗어나면 미정의 동작이 되므로, 필요하면 at()으로 범위 검사가 있는 접근을 쓸 수 있습니다.
capacity: 할당된 메모리 크기
capacity()는 재할당 없이 담을 수 있는 최대 원소 개수입니다. size는 push_back할 때마다 1씩 늘지만, capacity는 부족해질 때만 (보통 2배로) 늘어납니다. 아래처럼 하나씩 넣어 보면, 세 번째 push_back 시점에 capacity가 2에서 4로 바뀌는 것을 확인할 수 있습니다.
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()는 재할당 없이 담을 수 있는 최대 원소 개수입니다. push_back을 할 때마다 size는 1씩 늘고, capacity가 부족해지면 대부분 구현에서 2배로 늘리며 재할당이 일어납니다. 위 예에서는 세 번째 push_back 시 capacity가 2에서 4로 바뀝니다.
재할당 전략:
- 대부분의 구현에서 capacity가 부족하면 2배로 증가
- 예: 0 → 1 → 2 → 4 → 8 → 16 → 32 …
size vs 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() = 3br/유효한 원소 개수"]
cap["capacity() = 4br/재할당 없이 담을 수 있는 최대 개수"]
위 다이어그램 설명: size는 실제로 들어 있는 원소 개수(3개)이고, capacity는 현재 할당된 버퍼가 담을 수 있는 최대 개수(4개)입니다. size가 capacity에 도달하면 다음 push_back 시 재할당이 발생합니다.
reserve vs capacity 성장 다이어그램
reserve 없이 push_back 시 capacity 변화:
flowchart LR
subgraph growth["capacity 2배 성장"]
G0["0"] --> G1["1"]
G1 --> G2["2"]
G2 --> G3["4"]
G3 --> G4["8"]
G4 --> G5["16"]
G5 --> G6["32"]
G6 --> G7["..."]
end
위 다이어그램 설명: reserve 없이 push_back을 반복하면 capacity가 0→1→2→4→8→16→32…처럼 2배씩 증가합니다. 각 화살표 시점에 재할당이 발생합니다.
reserve 사용 시: reserve(100) 후 push_back 100회 → 재할당 0회. reserve 없이는 5개 넣을 때까지 3번 재할당(0→1→2→4→8)이 발생합니다.
3. 메모리 재할당 최적화
reserve: 미리 공간 확보
원소 개수를 대략 알고 있으면 reserve(n)으로 미리 n개만큼 공간을 잡아 두면 됩니다. 그러면 그 안에서 push_back을 할 때 재할당이 일어나지 않아서, 대량 삽입 시 훨씬 빠르고 메모리 단편화도 줄어듭니다. reserve는 size를 바꾸지 않고 capacity만 늘립니다.
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(1000)은 size는 그대로 두고 capacity만 최소 1000이 되게 합니다. 그 다음 1000번 push_back해도 재할당이 없어 대량 삽입이 빠르고, 메모리 단편화도 줄어듭니다. 개수를 대략이라도 알면 reserve를 호출하는 것이 좋습니다.
shrink_to_fit: 불필요한 메모리 해제
resize(n)으로 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
위 코드 설명: resize(10)으로 size를 줄여도 capacity는 그대로라서, 1000개치 메모리가 남을 수 있습니다. shrink_to_fit()은 “지금 size에 맞게 capacity를 줄여도 된다”고 요청하는 것이며, 구현이 따라줄 경우 불필요한 메모리를 줄일 수 있습니다. 반드시 줄어든다는 보장은 없습니다.
재할당 비용 측정
같은 개수만큼 push_back할 때, reserve 없이 하면 재할당이 여러 번 일어나고, reserve를 한 번 해 두면 재할당이 없어서 시간 차이가 큽니다. 아래 코드는 각각 100만 개 삽입에 걸리는 시간을 재서, reserve 사용 시 얼마나 빨라지는지 확인하는 예입니다.
#include <chrono>
void testWithoutReserve() {
auto start = std::chrono::high_resolution_clock::now();
std::vector<int> vec;
for (int i = 0; i < 1000000; ++i) {
vec.push_back(i);
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "Without reserve: " << duration.count() << " ms\n";
}
void testWithReserve() {
auto start = std::chrono::high_resolution_clock::now();
std::vector<int> vec;
vec.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
vec.push_back(i);
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "With reserve: " << duration.count() << " ms\n";
}
int main() {
testWithoutReserve(); // 약 50ms
testWithReserve(); // 약 10ms (5배 빠름)
}
위 코드 설명: reserve 없이 100만 번 push_back하면 재할당이 여러 번 일어나고, reserve(1000000) 후에는 재할당이 없어 시간이 크게 줄어듭니다. 같은 연산이라도 reserve 사용 여부에 따라 수 배 차이가 나므로, 대량 삽입 전에 reserve를 호출하는 것이 좋습니다.
Vector 최적화 완전 예제
실무에서 자주 쓰는 최적화 기법을 모두 적용한 예제입니다.
예제 1: 대량 데이터 로드 (reserve + emplace_back + 이동)
#include <vector>
#include <string>
#include <fstream>
#include <sstream>
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) {}
};
// ✅ 최적화 적용: reserve + emplace_back + 이동
std::vector<Record> loadRecords(const std::string& filename) {
std::vector<Record> records;
records.reserve(100000); // 1. 개수 예상해 reserve
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) {
// 2. emplace_back: 임시 객체 없이 직접 생성
records.emplace_back(id, std::move(name), value);
}
}
return records; // 3. RVO로 이동 반환
}
위 코드 설명: reserve로 재할당을 막고, emplace_back으로 복사/이동을 줄이며, std::move(name)으로 string 복사를 피합니다. 반환 시 RVO(Return Value Optimization)로 이동이 발생합니다.
예제 2: 조건부 필터링
std::vector<int> result;
result.reserve(input.size());
std::copy_if(input.begin(), input.end(), std::back_inserter(result),
{ return x % 2 == 0; });
result.shrink_to_fit(); // 선택
reserve로 재할당을 막고, copy_if로 O(n) 필터링합니다.
예제 3: 중복 제거
std::sort(vec.begin(), vec.end());
vec.erase(std::unique(vec.begin(), vec.end()), vec.end());
vec.shrink_to_fit(); // 선택
sort + unique + erase는 O(n log n)이며, set보다 캐시 친화적입니다.
4. std::string 완벽 가이드
string도 vector와 비슷
std::string은 std::vector<char>와 비슷하게 동작합니다:
- 동적 메모리 할당
- capacity와 size 개념
- 재할당 발생
string도 size/capacity를 가지므로, 빈 문자열은 보통 작은 capacity(SSO(Small String Optimization, 작은 문자열 최적화) 구간)를 가지고, 길이가 늘어나면 힙에 버퍼를 할당합니다. 구현에 따라 짧은 문자열은 객체 안에 그대로 들어가서 힙 할당이 0번일 수 있습니다.
std::string str;
std::cout << "size: " << str.size() << ", capacity: " << str.capacity() << "\n";
// size: 0, capacity: 15 (SSO: Small String Optimization)
str = "Hello, World! This is a long string.";
std::cout << "size: " << str.size() << ", capacity: " << str.capacity() << "\n";
// size: 38, capacity: 38 이상
위 코드 설명: string도 size()와 capacity()를 가지며, 빈 문자열은 구현에 따라 작은 capacity(SSO 구간)를 가질 수 있습니다. 짧은 문자열은 객체 안에 그대로 들어가 힙 할당이 없고, 길이가 늘면 힙에 버퍼를 할당해 vector와 비슷하게 동작합니다.
SSO (Small String Optimization)
짧은 문자열은 힙 할당 없이 객체 내부에 저장:
std::string short_str = "Hi"; // 힙 할당 없음 (SSO)
std::string long_str = "This is a very long string that exceeds SSO limit"; // 힙 할당
위 코드 설명: SSO(Small String Optimization)로 짧은 문자열은 객체 내부 버퍼에 저장되어 힙 할당이 일어나지 않습니다. 일정 길이(보통 15~23바이트)를 넘으면 힙에 할당됩니다. 구현마다 한계가 다르지만, 짧은 문자열이 많을 때 할당 비용을 줄이는 최적화입니다.
SSO 한계: 보통 15~23바이트 (구현마다 다름)
SSO 동작 방식
flowchart LR
subgraph sso["짧은 문자열 (SSO)"]
S1["string 객체"]
S2["객체 내부 버퍼br/'Hi' 저장"]
S1 --> S2
end
subgraph heap["긴 문자열 (힙 할당)"]
H1["string 객체"]
H2["포인터"]
H3["힙 메모리br/'Very long string...'"]
H1 --> H2 --> H3
end
위 다이어그램 설명: 짧은 문자열(보통 15~23바이트 이하)은 string 객체 내부에 그대로 저장되어 힙 할당이 없습니다. 길이가 한계를 넘으면 포인터로 힙 메모리를 가리키며, vector와 비슷하게 동작합니다.
string 연산
+=와 append는 끝에 문자열을 붙이고, insert(위치, 문자열)은 지정한 인덱스에 삽입합니다. erase(시작, 길이)는 해당 구간을 지우고, substr(시작, 길이)는 부분 문자열을 복사해 반환합니다. find는 부분 문자열이나 문자가 나오는 첫 위치를 반환하며, 없으면 std::string::npos를 반환하므로 비교해서 사용해야 합니다.
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!" (? 제거)
// 부분 문자열
std::string sub = str.substr(0, 5); // "Hello"
// 찾기
size_t pos = str.find("World"); // 6
if (pos != std::string::npos) {
std::cout << "Found at: " << pos << "\n";
}
// 비교
if (str == "Hello World!") {
std::cout << "Equal\n";
}
위 코드 설명: +=와 append는 끝에 붙이고, insert(위치, 문자열)는 지정 인덱스에 삽입합니다. erase(시작, 길이)는 그 구간을 지우고, substr은 부분 문자열을 새 string으로 복사해 반환합니다. find는 부분 문자열/문자의 첫 위치를 돌려주며, 없으면 npos를 반환하므로 비교해서 사용해야 합니다.
string 최적화
나쁜 예: 반복 연결
문자열에 +=로 반복해서 붙이면, 길이가 늘어날 때마다 재할당과 복사가 반복되어 비효율적입니다. 루프 안에서 수천 번 연결하면 시간이 눈에 띄게 늘어납니다.
std::string result;
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i) + ","; // 재할당 반복
}
위 코드 설명: 루프 안에서 +=로 계속 붙이면 길이가 늘어날 때마다 재할당과 복사가 반복됩니다. 1만 번 연결하면 재할당도 여러 번 일어나 시간이 많이 걸리므로, 대량 연결 시에는 reserve나 다른 방식(예: ostringstream)을 쓰는 것이 좋습니다.
좋은 예: reserve 사용
대략 필요한 길이를 알면 reserve로 한 번에 버퍼를 잡아 두면, 반복 연결 시 재할당 횟수가 줄어듭니다. 정확한 길이를 모르더라도 여유 있게 잡아 두면 효과가 있습니다.
std::string result;
result.reserve(100000); // 미리 공간 확보
for (int i = 0; i < 10000; ++i) {
result += std::to_string(i) + ",";
}
위 코드 설명: result.reserve(100000)으로 미리 공간을 잡아 두면, 루프 안에서 +=를 반복해도 재할당 횟수가 줄어듭니다. 정확한 최종 길이를 모르더라도 여유 있게 reserve해 두면 재할당으로 인한 비용을 크게 줄일 수 있습니다.
더 나은 예: stringstream
많은 조각을 한 번에 이어 붙일 때는 std::ostringstream을 쓰는 편이 낫습니다. 스트림이 내부 버퍼를 관리하고, 마지막에 str()로 한 번에 string을 꺼내면 재할당이 string에서 반복되는 것보다 효율적일 수 있습니다.
#include <sstream>
std::ostringstream oss;
for (int i = 0; i < 10000; ++i) {
oss << i << ",";
}
std::string result = oss.str();
위 코드 설명: ostringstream에 <<로 여러 조각을 넣으면 스트림이 내부 버퍼를 관리하고, 마지막에 str()로 한 번에 string을 꺼냅니다. string에 +=를 반복하는 것보다 재할당이 적게 일어나거나 한 번에 처리될 수 있어, 많은 조각을 이어 붙일 때 유리합니다.
5. 실전 성능 팁
팁 1: emplace_back vs push_back
push_back(Point(1, 2))는 임시 Point를 만든 뒤 vector 안으로 복사 또는 이동합니다. emplace_back(1, 2)는 vector가 내부에서 생성자 인자만 받아서 그 자리에 직접 객체를 만들므로, 임시 생성과 한 번의 이동이 없어집니다. 복사/이동 비용이 있는 타입일수록 emplace_back이 유리합니다.
struct Point {
int x, y;
Point(int x, int y) : x(x), y(y) {
std::cout << "Constructor\n";
}
};
std::vector<Point> vec;
// push_back: 임시 객체 생성 후 복사/이동
vec.push_back(Point(1, 2));
// Constructor (임시 객체)
// Move constructor (vector로 이동)
// emplace_back: 직접 생성
vec.emplace_back(1, 2);
// Constructor (vector 내부에서 직접 생성)
위 코드 설명: push_back(Point(1,2))는 임시 Point를 만든 뒤 vector로 복사 또는 이동하므로, 생성자와 이동 생성자가 호출됩니다. emplace_back(1, 2)는 vector 내부에서 생성자 인자만 받아 직접 객체를 만들므로 임시가 없고, 복사/이동 비용이 있는 타입일수록 emplace_back이 유리합니다.
팁 2: 범위 기반 for에서 참조 사용
for (std::string str : vec)처럼 값으로 받으면 원소마다 복사가 일어납니다. string처럼 복사 비용이 있는 타입이면 불필요한 할당이 반복되므로, 읽기만 할 때는 const std::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";
}
위 코드 설명: for (std::string str : vec)처럼 값으로 받으면 매 반복마다 원소가 복사됩니다. string처럼 복사 비용이 큰 타입이면 불필요한 할당이 반복되므로, 읽기만 할 때는 const 참조(const std::string& 또는 const auto&)로 받아 복사를 피하는 것이 좋습니다.
팁 3: erase-remove idiom
루프 안에서 erase를 반복하면, 매번 뒤 원소들이 앞으로 당겨지고 반복자도 갱신해야 해서 O(n²)에 가깝습니다. std::remove는 “지울 값이 아닌 것”만 앞쪽으로 모은 뒤 새 논리적 끝 반복자를 반환하고, 그 구간을 한 번에 erase하면 O(n)으로 같은 결과를 낼 수 있습니다. 이 조합을 erase-remove idiom이라고 부릅니다.
std::vector<int> vec = {1, 2, 3, 2, 4, 2, 5};
// ❌ 나쁜 예: 루프에서 erase (O(n²))
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it == 2) {
it = vec.erase(it);
} else {
++it;
}
}
// ✅ 좋은 예: erase-remove (O(n))
vec.erase(std::remove(vec.begin(), vec.end(), 2), vec.end());
위 코드 설명: 루프에서 매번 erase를 하면 그 뒤 원소들이 앞으로 당겨지고 반복자도 갱신해야 해서 O(n²)에 가깝습니다. remove는 “지울 값이 아닌 것”만 앞으로 모은 뒤 새 논리적 끝 반복자를 반환하고, erase(그 반복자, end())로 한 번에 지우면 O(n)으로 같은 결과를 낼 수 있습니다.
팁 5: data()로 C API 연동
C API가 T* 또는 const T*를 요구할 때는 vec.data()를 사용합니다. vector는 연속 메모리이므로 data()가 유효한 포인터를 반환하며, size()와 함께 data(), data() + size()로 범위를 넘길 수 있습니다.
// C API 호출 예
void process_array(const int* arr, size_t len);
std::vector<int> vec = {1, 2, 3, 4, 5};
process_array(vec.data(), vec.size()); // C API에 직접 전달
주의: push_back/insert/erase 등으로 vector가 수정되면 data()가 반환한 포인터가 무효화될 수 있습니다. C API 호출 중에는 vector를 수정하지 않도록 합니다.
팁 5: vector은 피하기
std::vector<bool>은 공간 절약을 위해 비트 단위로 압축되어 있어서, operator[]가 bool 참조가 아니라 프록시 객체를 반환합니다. 그래서 bool&를 기대하는 코드나 주소를 넘기는 API와 맞지 않을 수 있어, 일반적인 bool 시퀀스가 필요하면 std::vector<char>나 std::vector<uint8_t>를 쓰는 편이 안전합니다.
// ❌ vector<bool>은 특수화되어 있음 (비트 압축)
std::vector<bool> flags = {true, false, true};
// bool&를 반환하지 않음 (프록시 객체 반환)
// ✅ 대안: vector<char> 또는 vector<uint8_t>
std::vector<char> flags = {1, 0, 1};
위 코드 설명: vector<bool>은 비트로 압축되어 operator[]가 bool이 아니라 프록시를 반환합니다. bool&를 요구하는 코드나 주소를 넘기는 API와 맞지 않을 수 있어, 일반적인 bool 시퀀스가 필요하면 vector<char>나 vector<uint8_t>를 쓰는 편이 안전합니다.
6. 자주 발생하는 문제와 해결법
문제 1: “vector subscript out of range” 또는 segmentation fault
원인: []로 접근할 때 인덱스가 size() 범위를 벗어남. vec[vec.size()]처럼 “마지막 다음” 위치에 접근하거나, 빈 vector에 vec[0] 접근 시 발생합니다.
해결법:
// ❌ 나쁜 예: 범위 검사 없음
std::vector<int> vec = {1, 2, 3};
int value = vec[10]; // 미정의 동작! segfault 가능
// ✅ 좋은 예 1: at() 사용 (예외 발생)
for (size_t i = 0; i < vec.size(); ++i) {
int value = vec.at(i); // 범위 벗어나면 std::out_of_range
}
// ✅ 좋은 예 2: 접근 전 검사
if (index < vec.size()) {
int value = vec[index];
}
문제 2: erase 루프에서 반복자 무효화
원인: vec.erase(it) 후 it는 무효화됩니다. ++it를 하면 미정의 동작이 됩니다. erase는 삭제된 원소의 다음 원소를 가리키는 반복자를 반환하므로, 반환값을 받아야 합니다.
해결법:
// ❌ 나쁜 예: erase 후 it 그대로 사용
for (auto it = vec.begin(); it != vec.end(); ++it) {
if (*it == 2) {
vec.erase(it); // it 무효화! ++it 시 미정의 동작
}
}
// ✅ 좋은 예: erase 반환값 반영
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it == 2) {
it = vec.erase(it); // 다음 유효한 반복자 받기
} else {
++it;
}
}
문제 3: reserve 후 size는 그대로
원인: reserve(n)은 capacity만 늘립니다. size()는 그대로입니다. vec[vec.size()]처럼 접근하면 안 됩니다. push_back이나 resize로 원소를 채워야 합니다.
해결법:
// ❌ 나쁜 예: reserve 후 인덱스로 직접 접근
std::vector<int> vec;
vec.reserve(100);
vec[0] = 42; // 미정의 동작! size는 0
// ✅ 좋은 예: push_back 또는 resize로 채우기
vec.reserve(100);
for (int i = 0; i < 100; ++i) {
vec.push_back(i); // size가 1씩 증가
}
// 또는
vec.resize(100); // size를 100으로, 기본값 0으로 채움
vec[0] = 42; // OK
문제 4: string::find가 없을 때 npos 비교 누락
원인: find()가 찾지 못하면 std::string::npos를 반환합니다. 이 값을 그대로 인덱스로 쓰면 substr 등에서 잘못된 동작이 발생합니다.
해결법:
// ❌ 나쁜 예: npos 검사 없음
std::string str = "Hello";
size_t pos = str.find("World");
std::string sub = str.substr(pos, 5); // pos가 npos면 매우 큰 값! 미정의 동작
// ✅ 좋은 예: npos 검사
size_t pos = str.find("World");
if (pos != std::string::npos) {
std::string sub = str.substr(pos, 5);
} else {
std::cout << "Not found\n";
}
문제 5: push_back/insert 중 반복자 무효화
push_back/insert로 vector가 수정되면 기존 반복자가 무효화됩니다. 루프 안에서 수정할 때는 인덱스(for (size_t i = 0; i < vec.size(); ++i))를 사용하세요.
문제 6: reserve 후 resize 혼동
원인: reserve(n)은 capacity만 늘리고 size는 그대로입니다. resize(n)은 size를 n으로 만들고, 부족하면 기본값으로 채웁니다. 둘을 혼동해 reserve 후 vec[i]로 접근하면 미정의 동작입니다.
해결법:
// ❌ 나쁜 예: reserve 후 인덱스 접근
std::vector<int> vec;
vec.reserve(100);
vec[0] = 42; // size=0인데 [0] 접근! 미정의 동작
// ✅ 좋은 예 1: resize 사용 (기본값으로 채움)
vec.resize(100);
vec[0] = 42; // OK
// ✅ 좋은 예 2: reserve + push_back
vec.reserve(100);
for (int i = 0; i < 100; ++i) {
vec.push_back(i);
}
문제 7: signed/unsigned 비교
vec.size()는 size_t이므로 for (size_t i = 0; i < vec.size(); ++i) 또는 for (const auto& x : vec) 사용.
문제 8: data() 포인터 무효화
vec.data()로 얻은 포인터는 push_back/insert/erase 후 무효화됩니다. C API 호출 중에는 vector를 수정하지 마세요.
7. 실전 예시
예시 1: CSV 파일 파싱 (행 단위)
#include <fstream>
#include <sstream>
#include <vector>
#include <string>
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 파일을 한 줄씩 읽어 vector<string>으로 쪼개고, std::move로 행을 vector에 넣어 복사를 줄입니다. 행 개수를 미리 알면 rows.reserve(예상_행수)를 추가하면 더 효율적입니다.
추가 최적화: 파일 크기를 std::filesystem::file_size로 미리 알 수 있으면, 한 줄 평균 길이를 가정해 rows.reserve(파일크기 / 평균_줄_길이)로 행 벡터를 reserve할 수 있습니다. 셀 개수가 대략 일정하면 row.reserve(예상_컬럼_수)도 도움이 됩니다.
예시 2: 로그 메시지 수집 (reserve 활용)
#include <vector>
#include <string>
#include <chrono>
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;
}
설명: 로그 개수 상한을 알 때 reserve로 재할당을 막고, std::move로 string 복사를 줄입니다.
예시 3: 설정 파일 키-값 파싱 (string 최적화)
#include <string>
#include <sstream>
#include <vector>
std::string parseConfigValue(const std::string& line) {
size_t pos = line.find('=');
if (pos == std::string::npos) return "";
std::string value = line.substr(pos + 1);
// 앞뒤 공백 제거
size_t start = value.find_first_not_of(" \t");
size_t end = value.find_last_not_of(" \t");
if (start == std::string::npos) return "";
return value.substr(start, end - start + 1);
}
설명: find로 = 위치를 찾고, substr로 값 부분을 추출합니다. npos 검사를 꼭 해야 합니다.
성능 비교 표
| 시나리오 | reserve 없음 | reserve 사용 | 개선 |
|---|---|---|---|
| 100만 개 int push_back | 50ms | 10ms | 5배 |
| 10만 개 string push_back | 120ms | 25ms | 4.8배 |
| 1만 번 string += | 15ms | 3ms | 5배 |
| emplace_back vs push_back (복사 비용 큰 타입) | 2배 | 1배 | 2배 |
참고: 위 수치는 환경에 따라 다릅니다. 실제 프로젝트에서는 프로파일러로 측정하는 것이 좋습니다.
8. 성능 벤치마크
실제 측정 가능한 벤치마크 코드와 결과 해석입니다.
벤치마크 코드 (복사해 실행 가능)
#include <vector>
#include <chrono>
#include <iostream>
template<typename Func>
long long measure_ms(Func&& f) {
auto start = std::chrono::high_resolution_clock::now();
f();
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::high_resolution_clock::now() - start).count();
}
int main() {
const size_t N = 1'000'000;
auto t1 = measure_ms([&] {
std::vector<int> v;
for (size_t i = 0; i < N; ++i) v.push_back(static_cast<int>(i));
});
auto 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));
});
std::cout << "int " << N << ": no_reserve=" << t1 << "ms, reserve=" << t2 << "ms\n";
return 0;
}
위 코드 설명: g++ -O2 -std=c++17로 컴파일해 실행합니다. string, emplace_back 벤치마크도 동일한 measure_ms 패턴으로 추가할 수 있습니다.
벤치마크 결과 해석 (참고)
| 테스트 | 예상 결과 | 원인 |
|---|---|---|
| int 100만 push_back | reserve 3~5배 빠름 | 재할당 20회 vs 0회 |
| string 10만 push_back | reserve 4~6배 빠름 | string 복사 비용 + 재할당 |
| Big emplace vs push | emplace 1.5~2배 빠름 | 임시 객체 생성/이동 제거 |
주의: CPU 캐시, 메모리 대역폭, OS 스케줄링에 따라 결과가 달라집니다. -O2 이상 최적화를 켜고, 여러 번 실행해 평균을 보는 것이 좋습니다.
재할당 횟수 확인
capacity가 바뀔 때마다 재할당이 발생합니다. 100만 개 push_back 시 약 20회, reserve(1000000) 후에는 0회입니다.
9. 프로덕션 패턴
실무에서 검증된 vector·string 사용 패턴입니다.
패턴 1: 버퍼 풀 (재사용)
// clear()는 size만 0, capacity 유지 → 재사용 시 재할당 감소
class RequestHandler {
std::vector<char> read_buffer_;
public:
void handle(const char* data, size_t len) {
read_buffer_.clear();
read_buffer_.reserve(std::max(read_buffer_.capacity(), len));
read_buffer_.assign(data, data + len);
}
};
패턴 2: 예상 크기 추정
std::vector<Record> loadFromFile(const std::string& path) {
auto size = std::filesystem::file_size(path);
size_t estimated = std::max(size / sizeof(Record), size_t(1000));
std::vector<Record> records;
records.reserve(estimated);
// ... 로드
return records;
}
패턴 3: string 빌더
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 + parts.size());
for (size_t i = 0; i < parts.size(); ++i) {
if (i > 0) result += ", ";
result += parts[i];
}
return result;
}
총 길이를 미리 계산해 reserve하면 += 반복 시 재할당을 피할 수 있습니다.
프로덕션 체크리스트
- 대량 삽입 전
reserve(예상_개수)호출 - 반복 사용하는 vector는
clear()후 재사용 (capacity 유지) - 복사 비용 큰 타입은
emplace_back또는push_back(std::move(x)) -
data()포인터 사용 중 vector 수정 금지 - 프로파일러로 실제 병목 확인 후 최적화
예시 4: vector vs 다른 컨테이너 선택
| 상황 | 추천 | 이유 |
|---|---|---|
| 인덱스 접근, 끝에 추가 | vector | 연속 메모리, O(1) 접근 |
| 앞에 삽입/삭제 | deque 또는 list | vector는 앞 삽입이 O(n) |
| 키로 검색 | map / unordered_map | vector는 find가 O(n) |
| 정렬 유지 | set / multiset | vector는 sort 후 별도 관리 |
| 중복 제거 | vector + sort + unique | 또는 set으로 직접 |
vector가 유리한 경우: 대량의 데이터를 순차적으로 읽거나, 인덱스로 자주 접근하거나, 끝에만 추가·삭제할 때. 캐시 지역성이 좋아서 대용량 처리에서도 성능이 뛰어납니다.
shrink_to_fit 사용 시점
shrink_to_fit()은 “capacity를 size에 맞게 줄여도 된다”고 구현에 요청하는 것입니다. 다음 상황에서 고려할 수 있습니다:
- 대량 삭제 후:
vec.resize(100)으로 1000개에서 100개로 줄인 뒤, 메모리를 반환하고 싶을 때 - 장기 실행 프로세스: 메모리 사용량을 줄여야 하는 서버 등
- 벤치마크/테스트: reserve 없이 삽입한 뒤, 최종 size에 맞게 메모리를 정리할 때
주의: shrink_to_fit()은 요청일 뿐, 구현이 반드시 줄인다는 보장은 없습니다. 또한 재할당이 일어나므로 비용이 듭니다. 자주 호출하지 말고, “한 번 크게 줄인 뒤 더 이상 수정하지 않을” 벡터에 사용하는 것이 좋습니다.
정리
| 항목 | vector | string |
|---|---|---|
| 내부 구조 | 동적 배열 | 동적 문자 배열 |
| 메모리 | 연속된 힙 메모리 | SSO + 힙 메모리 |
| 재할당 | capacity 부족 시 2배 증가 | 동일 |
| 최적화 | reserve() | reserve() |
| 추가 | push_back(), emplace_back() | +=, append() |
| 접근 | [], at() | [], at() |
핵심 원칙:
- 크기를 알면
reserve()사용 emplace_back()선호- 범위 기반 for에서 참조 사용
erase-removeidiom 활용
실무 적용 시점
다음 상황에서 이 글의 내용을 적용해 보세요:
- 파일 파싱: CSV, JSON, 로그 파일을
vector<string>또는vector<T>로 로드할 때 →reserve로 재할당 감소 - 버퍼 수집: 네트워크 패킷, 센서 데이터를 모을 때 →
reserve+emplace_back또는push_back(std::move(...)) - 문자열 조합: 로그 메시지, HTML/JSON 문자열을 만들 때 →
ostringstream또는reserve++= - 컨테이너 반복:
vector<string>등을 순회할 때 →for (const auto& x : vec)로 복사 방지
구현 체크리스트
vector·string 사용 시 다음을 확인해 보세요:
- 대량 삽입 전
reserve(예상_개수)호출 - 복사 비용이 큰 타입은
emplace_back사용 - 범위 기반 for에서 읽기만 할 때
const auto&사용 - erase 루프에서
it = vec.erase(it)반환값 반영 -
[]대신 범위 검사가 필요하면at()사용 -
string::find결과는npos와 비교 후 사용 -
vector<bool>대신vector<char>또는vector<uint8_t>고려
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 기술 면접 질문 30선 | “포인터와 참조의 차이는?” 실전 답변 정리
- C++ STL 알고리즘 | sort·find·transform 람다와 함께 쓰기 (실전 패턴)
- C++ 코딩 테스트 | “백준·프로그래머스” 알고리즘 유형별 STL 활용법
이 글에서 다루는 키워드 (관련 검색어)
C++ vector, std::vector reserve, capacity size 차이, vector 성능 최적화, emplace_back push_back, std::string SSO, shrink_to_fit, vector 메모리 재할당, STL 컨테이너 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ STL vector·string 완벽 가이드. 내부 동작 원리, capacity vs size 차이, 메모리 재할당 비용, reserve·shrink_to_fit 최적화, push_back vs emplace… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. reserve를 너무 크게 잡으면 어떻게 되나요?
A. 메모리만 더 사용합니다. reserve는 “최소 이만큼” 공간을 요청하는 것이므로, 100만 개 넣을 곳에 1000만으로 reserve해도 동작에는 문제 없지만, 사용하지 않는 900만 개치 메모리가 남습니다. 대략적인 상한만 예상해 두는 것이 좋습니다.
Q. emplace_back을 항상 써야 하나요?
A. 복사/이동 비용이 있는 타입(예: string, 사용자 정의 클래스)일 때 유리합니다. int처럼 단순 타입은 push_back과 차이가 거의 없습니다. emplace_back은 생성자 인자를 직접 넘기므로, vec.emplace_back(1, 2)처럼 쓰면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: vector·string은 reserve로 재할당을 줄이고, capacity와 size 차이를 알면 성능을 잡을 수 있습니다. 다음으로 map·set(#10-2)를 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #10-2: map, set, unordered_map
참고 자료
관련 글
- C++ map vs unordered_map (STL 시리즈) |
- C++ STL 알고리즘 | sort·find·transform 람다와 함께 쓰기 (실전 패턴)
- C++ 람다 기초 완벽 가이드 | 캡처·mutable·제네릭 람다와 실전 패턴
- C++ 람다 심화 | 초기화 캡처·완벽 전달·IIFE·재귀 람다와 실전 패턴
- C++ vector 기초 완벽 가이드 | 초기화·연산·용량 관리와 실전 패턴