C++ vector reserve vs resize: capacity와 size의 차이와 쓰임
이 글의 핵심
push_back 전에 reserve로 재할당을 줄일지, resize로 미리 채우고 인덱스로 쓸지는 불필요한 초기화 비용과 안전성 사이의 선택입니다. 파일 읽기, DP 테이블, 그래프 인접 리스트, 픽셀 버퍼 사례와 shrink_to_fit으로도 capacity가 줄지 않을 수 있는 이유까지 다룹니다.
들어가며
C++에서 vector의 reserve와 resize는 완전히 다른 동작을 합니다. reserve는 capacity만 늘리고, resize는 size를 변경하고 요소를 초기화합니다.
이 차이를 이해하려면 vector가 size(실제로 들어 있는 원소 수)와 capacity(재할당 없이 담을 수 있는 원소 수)라는 두 숫자를 따로 관리한다는 점부터 알아야 합니다. push_back으로 원소를 넣다가 size가 capacity에 닿으면 vector는 더 큰 메모리 블록을 새로 할당하고, 기존 원소를 모두 옮긴 뒤 옛 블록을 해제합니다. 이 재할당은 원소 수에 비례하는 비용이 들고, 무엇보다 기존 원소를 가리키던 포인터·참조·반복자를 모두 무효화합니다. reserve는 이 재할당을 미리 한 번에 해 두는 도구이고, resize는 원소 자체를 만들어 size를 바꾸는 도구입니다.
reserve vs resize 차이
핵심 차이
| 항목 | reserve(n) | resize(n) |
|---|---|---|
| size | 변경 없음 | n으로 변경 |
| capacity | 최소 n | 최소 n |
| 초기화 | 없음 | 새 원소를 값 초기화(int는 0) |
새로 확보한 칸에 [] 접근 | 불가 (size 밖이므로 미정의 동작) | 가능 |
| 이후 push_back | 재할당 없이 앞에서부터 채움 | size 뒤에 이어 붙임 |
두 함수 모두 n이 현재 capacity보다 크면 그 자리에서 한 번 재할당합니다. 차이는 그 뒤에 있습니다. reserve는 이후의 push_back이 capacity 안에서 재할당 없이 진행되도록 공간만 마련하고, resize는 원소를 실제로 만들어 둡니다.
시각적 비교
std::vector<int> vec;
// reserve: capacity만 늘림
vec.reserve(5);
// size: 0, capacity: 5
// [?, ?, ?, ?, ?] (메모리만 확보, 요소 없음)
// resize: size 변경 + 초기화
vec.resize(5);
// size: 5, capacity: 5
// [0, 0, 0, 0, 0] (요소 생성 + 초기화)
실전 구현
reserve: capacity만 늘림
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
vec.reserve(5); // capacity를 5로 늘림
std::cout << "size: " << vec.size() << std::endl; // 0
std::cout << "capacity: " << vec.capacity() << std::endl; // 5
// vec[0] = 42; // 잘못: 미정의 동작 (size가 0)
vec.push_back(10); // 올바름: OK
std::cout << "size: " << vec.size() << std::endl; // 1
return 0;
}
출력:
size: 0
capacity: 5
size: 1
resize: size 변경 + 초기화
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
vec.resize(5); // size를 5로 늘리고 0으로 초기화
std::cout << "size: " << vec.size() << std::endl; // 5
std::cout << "capacity: " << vec.capacity() << std::endl; // 5 (최소)
vec[0] = 42; // 올바름: OK
std::cout << vec[0] << std::endl; // 42
std::cout << vec[1] << std::endl; // 0 (초기화됨)
return 0;
}
출력:
size: 5
capacity: 5
42
resize: 초기값 설정
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
vec.resize(5, 42); // size를 5로, 모두 42로 초기화
for (int val : vec) {
std::cout << val << " ";
}
std::cout << std::endl;
return 0;
}
출력:
42 42 42 42 42
push_back vs 인덱스 접근
#include <iostream>
#include <vector>
int main() {
// reserve: push_back으로 추가
std::vector<int> vec1;
vec1.reserve(3);
vec1.push_back(10);
vec1.push_back(20);
vec1.push_back(30);
std::cout << "vec1 size: " << vec1.size() << std::endl; // 3
// resize: 인덱스로 직접 접근
std::vector<int> vec2;
vec2.resize(3);
vec2[0] = 10;
vec2[1] = 20;
vec2[2] = 30;
std::cout << "vec2 size: " << vec2.size() << std::endl; // 3
return 0;
}
고급 활용
재할당 횟수 측정
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
int realloc_count = 0;
size_t last_capacity = vec.capacity();
for (int i = 0; i < 1000; ++i) {
vec.push_back(i);
if (vec.capacity() != last_capacity) {
++realloc_count;
std::cout << "재할당 " << realloc_count << ": "
<< last_capacity << " → " << vec.capacity() << std::endl;
last_capacity = vec.capacity();
}
}
std::cout << "총 재할당 횟수: " << realloc_count << std::endl;
return 0;
}
출력 예시 (GCC·Clang의 libstdc++/libc++ 기준):
재할당 1: 0 → 1
재할당 2: 1 → 2
재할당 3: 2 → 4
재할당 4: 4 → 8
...
재할당 11: 512 → 1024
총 재할당 횟수: 11
capacity가 두 배씩 늘어나는 것은 표준이 정한 값이 아니라 구현의 선택입니다. libstdc++와 libc++는 2배, MSVC는 1.5배로 늘리므로 같은 코드도 MSVC에서는 재할당 횟수가 더 많고 capacity 값도 다르게 찍힙니다. 표준이 요구하는 것은 push_back의 상각(amortized) O(1) 뿐이고, 이를 위해 capacity를 일정 비율로 곱해 늘리는 것입니다. 재할당이 1000개 원소에 11번 정도로 적은데도 reserve가 의미 있는 이유는 횟수보다 무효화와 최대 메모리 사용량에 있습니다. 재할당 순간에는 옛 블록과 새 블록이 동시에 존재하므로, 큰 벡터에서는 일시적으로 필요한 메모리의 두세 배를 쓰게 됩니다.
2D 벡터 초기화
#include <iostream>
#include <vector>
int main() {
// resize: 2D 벡터 초기화
std::vector<std::vector<int>> matrix;
matrix.resize(10); // 10개 행
for (auto& row : matrix) {
row.resize(20, 0); // 각 행을 20개 열로, 0으로 초기화
}
matrix[5][10] = 42;
std::cout << matrix[5][10] << std::endl; // 42
return 0;
}
shrink_to_fit: capacity 줄이기
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec;
vec.resize(1000);
std::cout << "size: " << vec.size() << std::endl; // 1000
std::cout << "capacity: " << vec.capacity() << std::endl; // 1000
vec.resize(10); // size 줄임
std::cout << "size: " << vec.size() << std::endl; // 10
std::cout << "capacity: " << vec.capacity() << std::endl; // 1000 (그대로)
vec.shrink_to_fit(); // capacity 줄임
std::cout << "capacity: " << vec.capacity() << std::endl; // 10 (보장은 아님)
return 0;
}
shrink_to_fit은 표준상 구속력 없는 요청(non-binding request)이라, 구현이 capacity를 줄이지 않아도 규칙 위반이 아닙니다. 주요 구현은 실제로 줄여 주지만, 이 동작도 결국 새 블록을 할당하고 원소를 옮기는 재할당이므로 반복자가 무효화되고 비용도 듭니다. 확실하게 메모리를 돌려받아야 하는 C++11 이전 코드에서는 std::vector<int>(vec).swap(vec);처럼 임시 벡터와 교환하는 관용구를 썼습니다. 반대로 clear()는 size만 0으로 만들고 capacity는 그대로 두므로, 재사용할 버퍼라면 오히려 이 성질이 재할당을 줄여 줍니다.
성능 비교
벤치마크: push_back
#include <chrono>
#include <iostream>
#include <vector>
void benchNoReserve() {
std::vector<int> vec;
for (int i = 0; i < 1000000; ++i) {
vec.push_back(i);
}
}
void benchReserve() {
std::vector<int> vec;
vec.reserve(1000000); // 미리 공간 확보
for (int i = 0; i < 1000000; ++i) {
vec.push_back(i);
}
}
void benchResize() {
std::vector<int> vec;
vec.resize(1000000); // 초기화
for (int i = 0; i < 1000000; ++i) {
vec[i] = i;
}
}
int main() {
auto start1 = std::chrono::high_resolution_clock::now();
benchNoReserve();
auto end1 = std::chrono::high_resolution_clock::now();
auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
auto start2 = std::chrono::high_resolution_clock::now();
benchReserve();
auto end2 = std::chrono::high_resolution_clock::now();
auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
auto start3 = std::chrono::high_resolution_clock::now();
benchResize();
auto end3 = std::chrono::high_resolution_clock::now();
auto time3 = std::chrono::duration_cast<std::chrono::milliseconds>(end3 - start3).count();
std::cout << "reserve 없이: " << time1 << "ms" << std::endl;
std::cout << "reserve 사용: " << time2 << "ms" << std::endl;
std::cout << "resize 사용: " << time3 << "ms" << std::endl;
return 0;
}
결과 해석: 구체적인 수치는 컴파일러, 최적화 옵션, CPU, 할당자에 따라 크게 달라지므로 직접 실행해 확인하는 것이 좋습니다. 일반적인 경향은 다음과 같습니다.
- reserve 없이: 100만 개면 재할당이 약 20번(2배 성장 기준) 일어나고, 매번 기존 원소를 새 블록으로 옮기므로 가장 느린 편입니다. 다만 옮기는 원소 수의 합은 1 + 2 + 4 + … 로 최종 원소 수와 비슷한 정도라서, 생각보다 격차가 크지 않을 때도 많습니다.
- reserve 사용: 재할당은 사라지지만
push_back마다 capacity 검사와 size 증가가 남습니다. - resize 사용: 0으로 한 번 채우는 비용이 추가되지만, 루프가 단순한 대입이라 컴파일러가 벡터화하기 쉬워 reserve와 비슷하거나 더 빠르게 나오기도 합니다.
이 측정 코드에는 함정도 있습니다. 함수 안에서 만든 벡터를 아무 데도 쓰지 않으므로, -O2 이상에서 컴파일러가 루프 전체를 제거해 0ms가 찍힐 수 있습니다. 또 -O0에서 측정하면 push_back의 함수 호출이 인라인되지 않아 실제 배포 빌드와 전혀 다른 결론이 나옵니다. 신뢰할 수 있는 비교가 필요하면 결과를 benchmark::DoNotOptimize로 보호하는 Google Benchmark 같은 도구를 쓰는 편이 안전합니다.
실무 사례
사례 1: 파일 읽기
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
std::vector<std::string> readLines(const std::string& filename) {
std::vector<std::string> lines;
lines.reserve(1000); // 예상 크기 (넘어도 자동으로 늘어남)
std::ifstream file(filename);
std::string line;
while (std::getline(file, line)) {
lines.push_back(line);
}
return lines;
}
int main() {
auto lines = readLines("data.txt");
std::cout << "읽은 줄 수: " << lines.size() << std::endl;
return 0;
}
사례 2: 동적 프로그래밍 - DP 테이블
#include <iostream>
#include <vector>
int fibonacci(int n) {
if (n <= 1) return n;
std::vector<int> dp;
dp.resize(n + 1); // 크기 확정, 0으로 초기화
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
int main() {
std::cout << fibonacci(10) << std::endl; // 55
return 0;
}
사례 3: 그래프 인접 리스트
#include <iostream>
#include <vector>
class Graph {
private:
std::vector<std::vector<int>> adj_;
public:
Graph(int n) {
adj_.resize(n); // n개 노드
}
void addEdge(int u, int v) {
adj_[u].push_back(v);
adj_[v].push_back(u);
}
void print() const {
for (std::size_t i = 0; i < adj_.size(); ++i) {
std::cout << i << ": ";
for (int neighbor : adj_[i]) {
std::cout << neighbor << " ";
}
std::cout << std::endl;
}
}
};
int main() {
Graph g(5);
g.addEdge(0, 1);
g.addEdge(0, 4);
g.addEdge(1, 2);
g.addEdge(1, 3);
g.addEdge(1, 4);
g.addEdge(2, 3);
g.addEdge(3, 4);
g.print();
return 0;
}
사례 4: 이미지 처리 - 픽셀 버퍼
#include <iostream>
#include <vector>
struct Pixel {
unsigned char r, g, b;
};
class Image {
private:
int width_;
int height_;
std::vector<Pixel> pixels_;
public:
Image(int width, int height) : width_(width), height_(height) {
pixels_.resize(width * height, {0, 0, 0}); // 검은색으로 초기화
}
Pixel& at(int x, int y) {
return pixels_[y * width_ + x];
}
void fill(Pixel color) {
for (auto& pixel : pixels_) {
pixel = color;
}
}
};
int main() {
Image img(800, 600);
img.at(100, 100) = {255, 0, 0}; // 빨간색 픽셀
img.fill({255, 255, 255}); // 흰색으로 채우기
return 0;
}
트러블슈팅
문제 1: reserve 후 인덱스 접근
reserve만 하고 인덱스로 쓰면 미정의 동작입니다. 우연히 동작하더라도 size는 그대로 0입니다.
// 잘못: 잘못된 사용
std::vector<int> vec;
vec.reserve(10);
vec[0] = 42; // 잘못: 미정의 동작 (size가 0)
// 올바름: 올바른 사용 1: push_back
std::vector<int> vec1;
vec1.reserve(10);
vec1.push_back(42); // 올바름: OK
// 올바름: 올바른 사용 2: resize
std::vector<int> vec2;
vec2.resize(10);
vec2[0] = 42; // 올바름: OK
문제 2: resize 후 push_back
resize로 원소를 만든 뒤 push_back을 하면 그 뒤에 원소가 추가되어 크기가 의도보다 커집니다.
// 잘못: 혼동하기 쉬운 패턴
std::vector<int> vec;
vec.resize(10); // size: 10
vec.push_back(42); // size: 11 (늘어남)
std::cout << vec.size() << std::endl; // 11
std::cout << vec[10] << std::endl; // 42
// 올바름: 의도한 동작
std::vector<int> vec2;
vec2.resize(10);
vec2[0] = 42; // size: 10 (그대로)
문제 3: 루프 안에서 reserve 호출
reserve를 썼는데 오히려 느려지는 경우입니다.
// 잘못: push_back마다 재할당 → 전체 O(n²)
std::vector<int> vec;
for (int i = 0; i < n; ++i) {
vec.reserve(vec.size() + 1); // "딱 필요한 만큼" 늘리기
vec.push_back(i);
}
// 올바름: 최종 크기를 알면 루프 전에 한 번
std::vector<int> vec2;
vec2.reserve(n);
for (int i = 0; i < n; ++i) {
vec2.push_back(i);
}
push_back이 상각 O(1)인 이유는 capacity를 배수로 늘려 재할당 횟수를 로그 수준으로 억제하기 때문입니다. 그런데 reserve(k)는 “최소 k”만 보장하고, 주요 구현은 요청한 값을 그대로 할당하므로 이 배수 성장을 우회합니다. 그래서 한 칸씩 reserve하는 코드는 매번 전체를 새 블록으로 옮기게 되어, 원소 수가 늘수록 급격히 느려집니다. 여러 번에 나눠 원소를 추가하는 함수(예: append(const std::vector<int>& more))에서 호출마다 reserve(size() + more.size())를 부르는 코드도 같은 문제를 가질 수 있으니, 호출 횟수가 많다면 reserve를 빼고 vector의 기본 성장 전략에 맡기는 편이 낫습니다. 반대로 reserve(n)에서 n이 현재 capacity보다 작으면 아무 일도 일어나지 않으므로, capacity를 줄이는 용도로는 쓸 수 없습니다.
문제 4: 불필요한 초기화
resize로 원소를 만든 뒤 곧바로 전부 덮어쓰면 첫 초기화가 낭비됩니다.
// 잘못: resize로 초기화 후 다시 채우기 (비효율)
std::vector<int> vec;
vec.resize(1000); // 0으로 초기화
for (int i = 0; i < 1000; ++i) {
vec[i] = i; // 다시 채우기
}
// 올바름: reserve로 재할당만 방지
std::vector<int> vec2;
vec2.reserve(1000);
for (int i = 0; i < 1000; ++i) {
vec2.push_back(i); // 초기화 없이 바로 추가
}
int 같은 단순 타입에서는 0으로 채우는 비용이 memset 수준이라 두 방식의 차이가 작지만, 원소가 std::string이나 생성자가 무거운 클래스라면 resize는 기본 생성자를 원소 수만큼 호출한 뒤 대입으로 다시 덮어쓰므로 낭비가 커집니다. 기본 생성자가 없는 타입은 아예 resize(n)을 쓸 수 없다는 점도 알아 둘 만합니다. 이런 경우에는 reserve 후 emplace_back으로 원소를 제자리에서 생성하는 것이 정석입니다.
이 두 함수와 관련해 성능보다 더 자주 문제가 되는 것은 포인터 무효화입니다. 벡터 원소의 주소나 반복자를 저장해 두고 이후에 push_back을 계속하면, capacity를 넘는 순간 재할당이 일어나 저장해 둔 포인터가 해제된 메모리를 가리키게 됩니다. 작은 테스트에서는 capacity가 남아 있어 멀쩡하다가 데이터가 늘어난 운영 환경에서만 이상한 값이나 크래시가 나기 때문에 원인을 찾기 어렵습니다. 원소 수의 상한을 알고 있다면 reserve로 재할당을 막을 수 있지만, 근본적으로는 포인터 대신 인덱스를 저장하거나, 주소가 바뀌지 않아야 하는 원소라면 std::deque나 std::vector<std::unique_ptr<T>>를 고려하는 편이 안전합니다. 디버그 빌드에서 MSVC의 반복자 디버깅이나 GCC의 -D_GLIBCXX_DEBUG를 켜면 무효화된 반복자 사용을 즉시 잡아 줍니다.
다음 단계
- vector 기초: C++ vector 기초
- 컨테이너 비교: C++ vector vs list vs deque
- 성능 최적화: C++ 성능 최적화