C++ C 배열 vs std::array vs vector: 성능과 안전성 비교
이 글의 핵심
배열이 vector보다 빠르다는 말이 최적화 빌드에서도 맞는지 확인하는 데서 시작합니다. 큰 배열을 스택에 잡아 생기는 스택 오버플로우, 범위 오류, 불필요한 vector 복사 같은 문제를 짚고, 파티클 시스템·픽셀 버퍼·패킷 버퍼·행렬 연산 사례로 언제 무엇을 쓸지 판단 기준을 제시합니다.
들어가며: “배열을 써야 할까, vector를 써야 할까?”
C++는 C 스타일 배열, std::array, std::vector 세 가지 배열 타입을 제공합니다. 각각 메모리 위치, 크기 변경 가능 여부, 안전성이 다릅니다.
비유로 말씀드리면, C 배열·std::array는 자리 수가 정해진 고정 좌석, vector는 필요하면 줄을 늘리는 가변 좌석에 가깝습니다. 크기가 런타임에 바뀌면 vector 쪽이 자연스럽습니다.
언제 고정 배열(std::array/C 배열)을, 언제 vector를 쓰나요?
| 관점 | 고정 크기(스택·std::array 등) | vector |
|---|---|---|
| 성능 | 스택 할당은 힙보다 가벼울 수 있음(작을 때) | 재할당·용량 관리 비용이 있으나 크기 가변 |
| 사용성 | 크기가 컴파일 타임 상수일 때 단순 | push_back 등으로 동적 확장 |
| 적용 시나리오 | 작은 버퍼, 행렬 크기 고정 | 입력 개수를 모를 때, 컨테이너로서 STL과 연동 |
// C 스타일 배열 (스택, 고정 크기)
int arr1[5] = {1, 2, 3, 4, 5};
// std::array (스택, 고정 크기, 안전)
std::array<int, 5> arr2 = {1, 2, 3, 4, 5};
// std::vector (힙, 동적 크기, 안전)
std::vector<int> vec = {1, 2, 3, 4, 5};
세 타입의 차이를 한 문장으로 줄이면 “요소를 담는 메모리를 누가, 언제 결정하는가”입니다. C 배열과 std::array는 요소가 객체 자체 안에 들어 있어서, 변수를 지역 변수로 선언하면 스택에, 다른 구조체의 멤버로 두면 그 구조체 안에, 전역으로 두면 정적 영역에 놓입니다. 크기는 컴파일 시점에 정해져야 합니다. std::vector는 객체 자체에는 포인터 세 개(시작, 끝, 용량 끝) 정도만 두고 요소는 항상 힙에 할당하므로, 크기를 실행 중에 정하고 바꿀 수 있는 대신 할당 비용이 생깁니다. 아래 비교표의 “스택/힙” 구분도 지역 변수로 선언했을 때를 기준으로 읽어 주세요.
3가지 배열 타입 비교
비교표
| 항목 | C 배열 | std::array | std::vector |
|---|---|---|---|
| 메모리 | 스택 | 스택 | 힙 |
| 크기 | 고정 (컴파일 타임) | 고정 (컴파일 타임) | 동적 (런타임) |
| 범위 체크 | 없음 | at() 제공 | at() 제공 |
| 크기 조회 | sizeof/수동 | size() | size() |
| STL 호환 | 부분적 | 완전 | 완전 |
| 함수 전달 | 포인터로 decay | 값 또는 참조 | 참조 |
| 안전성 | 낮음 | 높음 | 높음 |
C 스타일 배열
int arr[5] = {1, 2, 3, 4, 5};
// ❌ 범위 체크 없음
arr[10] = 99; // 미정의 동작
// ❌ 크기 조회 번거로움
size_t size = sizeof(arr) / sizeof(arr[0]);
// ❌ 함수 전달 시 크기 정보 손실
void foo(int arr[]) { // int* 로 decay
// sizeof(arr)는 포인터 크기 (8바이트)
}
void foo(int arr[])와 void foo(int arr[5])는 모두 void foo(int* arr)와 완전히 같은 선언입니다. 대괄호 안의 5는 컴파일러가 무시하므로, 크기가 다른 배열을 넘겨도 오류가 나지 않고 함수 안의 sizeof(arr)는 포인터 크기가 됩니다. GCC와 Clang은 이런 sizeof에 -Wsizeof-array-argument 경고를 내 줍니다. C 배열의 크기를 유지한 채 넘기고 싶다면 void foo(int (&arr)[5])처럼 배열 참조로 받거나 template <size_t N> void foo(int (&arr)[N])로 크기를 추론하게 할 수 있습니다. C++17부터는 std::size(arr)가 sizeof 나눗셈을 대신해 주는데, 포인터에 쓰면 컴파일 에러가 나므로 실수로 decay된 배열에 쓰는 것을 막아 준다는 장점도 있습니다.
std::array (C++11)
#include <array>
std::array<int, 5> arr = {1, 2, 3, 4, 5};
// ✅ 범위 체크
arr.at(10); // 예외 발생: std::out_of_range
// ✅ 크기 조회
size_t size = arr.size(); // 5
// ✅ STL 알고리즘
std::sort(arr.begin(), arr.end());
// ✅ 함수 전달 (크기 정보 유지)
void foo(const std::array<int, 5>& arr) {
std::cout << arr.size() << '\n'; // 5
}
std::array는 C 배열 하나를 멤버로 가진 집합체(aggregate)라서 메모리 배치와 성능은 C 배열과 같고, 여기에 값 의미론이 더해집니다. 대입(a = b)과 비교(a == b)가 요소 단위로 동작하고, 함수에서 값으로 반환할 수 있으며, 포인터로 decay하지 않습니다. 대신 크기가 타입의 일부라서 std::array<int, 5>와 std::array<int, 6>은 서로 다른 타입이고, 위 foo는 크기 5짜리만 받습니다. 크기가 다른 배열을 모두 받는 함수를 만들려면 템플릿으로 만들거나, C++20의 std::span<const int>로 받는 편이 깔끔합니다. std::span은 C 배열, std::array, std::vector를 모두 받을 수 있는 가벼운 뷰라서, 요소를 읽기만 하는 함수의 매개변수로 가장 범용적인 선택입니다.
std::array<char, 1024> buffer;처럼 초기화 없이 선언하면 C 배열과 마찬가지로 요소가 초기화되지 않는다는 점도 주의하세요. 0으로 채우려면 std::array<char, 1024> buffer{};처럼 빈 중괄호를 붙여야 합니다.
std::vector
#include <vector>
std::vector<int> vec = {1, 2, 3, 4, 5};
// ✅ 동적 크기 변경
vec.push_back(6);
vec.resize(10);
// ✅ 범위 체크
vec.at(10); // 예외 발생
// ✅ 자동 메모리 관리
// 소멸 시 자동 해제
vector를 쓸 때 가장 흔한 버그는 재할당에 의한 무효화입니다. push_back으로 크기가 용량(capacity)을 넘으면 vector는 더 큰 메모리를 새로 할당하고 요소를 옮긴 뒤 옛 메모리를 해제합니다. 그 순간 이전에 받아 둔 포인터, 참조, 반복자가 모두 해제된 메모리를 가리키게 되므로, auto& first = vec[0]; vec.push_back(x); use(first); 같은 코드는 가끔만 크래시하는 까다로운 버그가 됩니다. 요소 수를 미리 안다면 reserve()로 재할당을 막을 수 있고, 요소의 주소가 절대 바뀌면 안 되는 설계라면 std::deque나 std::vector<std::unique_ptr<T>>를 고려해야 합니다. 또 std::vector<bool>은 비트 단위로 압축된 특수화라서 &vec[0]로 bool*을 얻을 수 없고 요소 참조가 프록시 객체라는 점에서 다른 vector와 다르게 동작합니다.
성능 벤치마크
테스트 1: 접근 속도
// 100만 번 접근
template <typename Container>
void benchAccess(Container& c) {
long long sum = 0;
for (int i = 0; i < 1000000; ++i) {
sum += c[i % c.size()];
}
}
예상 결과: 최적화 빌드(-O2 이상)에서는 세 타입의 접근 시간이 사실상 같게 나옵니다. 구체적인 수치는 CPU와 컴파일러에 따라 달라지므로 직접 측정해 보세요.
접근 속도가 같은 이유는 생성되는 기계어가 같기 때문입니다. std::array::operator[]와 std::vector::operator[]는 인라인되어 “시작 주소 + 인덱스 × 요소 크기”라는 C 배열과 동일한 주소 계산이 됩니다. vector는 시작 주소를 객체 안의 포인터에서 한 번 읽어 와야 하지만, 루프 안에서는 컴파일러가 이 값을 레지스터에 올려 두므로 차이가 사라집니다. 이 벤치마크 코드에는 측정상의 함정도 있습니다. sum을 사용하지 않으므로 최적화 컴파일러가 루프 전체를 제거할 수 있고, i % c.size()의 나눗셈 비용이 실제 메모리 접근보다 커서 무엇을 재는지 흐려집니다. 제대로 비교하려면 결과를 반환하거나 출력하고, Google Benchmark의 DoNotOptimize 같은 장치를 쓰는 것이 좋습니다.
테스트 2: 생성/소멸
// 100만 번 생성/소멸
void benchCreation() {
for (int i = 0; i < 1000000; ++i) {
// 테스트 대상
}
}
예상 결과: 루프 안에서 작은 컨테이너를 매번 만들고 버리면 vector 쪽이 확연히 느립니다. 차이의 크기는 할당자 구현과 요소 수에 따라 크게 달라지므로 고정된 배수로 말하기는 어렵습니다.
스택 배열의 “생성”은 스택 포인터를 조정하는 명령 하나이거나, 함수 진입 시 한꺼번에 처리되어 사실상 비용이 없습니다. vector는 생성할 때마다 operator new로 힙 메모리를 요청하고 소멸할 때 해제하는데, 할당자는 적절한 크기의 빈 블록을 찾고 멀티스레드 환경에서는 동기화까지 해야 하므로 수십~수백 나노초가 드는 경우가 흔합니다. 그래서 핫 루프 안에서 임시 vector를 반복해서 만드는 코드가 성능 문제의 흔한 원인이 됩니다. 해결책은 반드시 std::array로 바꾸는 것만이 아닙니다. vector를 루프 밖에 한 번 만들어 두고 매 반복마다 clear()하면 용량은 유지되므로 재할당 없이 재사용할 수 있고, 크기의 상한만 알고 있다면 스택 버퍼를 쓰는 boost::container::small_vector 같은 대안도 있습니다. 참고로 최적화 컴파일러는 C++14부터 허용된 할당 생략 규칙에 따라 사용되지 않는 new를 제거할 수 있어서, 벤치마크에서 vector를 만들고 아무것도 하지 않으면 할당 자체가 사라질 수도 있습니다.
테스트 3: 순회
template <typename Container>
void benchIteration(const Container& c) {
long long sum = 0;
for (const auto& x : c) {
sum += x;
}
}
결과: 모두 동일 (최적화 빌드).
순회는 요소가 메모리에 연속으로 놓여 있는지가 성능을 결정합니다. 세 타입 모두 연속 메모리를 쓰므로 캐시 라인과 하드웨어 프리페처를 최대로 활용하고, 컴파일러가 SIMD 명령으로 벡터화하기도 좋습니다. 같은 순회를 std::list로 하면 노드가 힙 곳곳에 흩어져 있어 캐시 미스가 늘어나므로 훨씬 느려집니다. “vector냐 배열이냐”보다 “연속 메모리냐 아니냐”가 성능 차이에 훨씬 큰 영향을 준다고 기억해 두면 컨테이너 선택이 쉬워집니다.
메모리 안전성
범위 체크
// C 배열: 범위 체크 없음
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10]; // ❌ 미정의 동작 (크래시 또는 쓰레기 값)
// std::array: at()으로 범위 체크
std::array<int, 5> arr2 = {1, 2, 3, 4, 5};
try {
int x = arr2.at(10); // ✅ 예외 발생
} catch (const std::out_of_range& e) {
std::cerr << "Out of range\n";
}
// std::vector: at()으로 범위 체크
std::vector<int> vec = {1, 2, 3, 4, 5};
try {
int x = vec.at(10); // ✅ 예외 발생
} catch (const std::out_of_range& e) {
std::cerr << "Out of range\n";
}
at()의 범위 검사는 매 접근마다 비교 한 번을 추가하는 작은 비용이지만, 더 중요한 차이는 실패를 어떻게 알리느냐입니다. operator[]로 범위를 벗어나면 조용히 다른 메모리를 읽거나 덮어써서 문제가 한참 뒤에 엉뚱한 곳에서 드러나지만, at()은 그 자리에서 예외를 던지므로 원인을 바로 찾을 수 있습니다. 모든 접근을 at()으로 바꾸기보다는, 외부 입력에서 온 인덱스처럼 범위가 보장되지 않는 곳에서 at()이나 명시적 검사를 쓰고, 개발·테스트 빌드에서는 libstdc++의 _GLIBCXX_ASSERTIONS나 MSVC의 디버그 반복자 검사를 켜서 operator[]의 범위 오류도 잡는 조합이 실용적입니다. _GLIBCXX_ASSERTIONS는 오버헤드가 작아 일부 리눅스 배포판은 패키지 빌드에 기본으로 켜 두기도 합니다.
함수 전달 시 크기 정보
// ❌ C 배열: 크기 정보 손실
void foo(int arr[]) { // int* 로 decay
// sizeof(arr)는 8 (포인터 크기)
}
int arr[5] = {1, 2, 3, 4, 5};
foo(arr);
// ✅ std::array: 크기 정보 유지
void foo(const std::array<int, 5>& arr) {
std::cout << arr.size() << '\n'; // 5
}
// ✅ std::vector: 크기 정보 유지
void foo(const std::vector<int>& vec) {
std::cout << vec.size() << '\n'; // 5
}
상황별 선택 가이드
결정 트리
Q1. 크기가 컴파일 타임에 고정되어 있는가?
Yes → Q2
No → vector
Q2. 스택(또는 소유 객체 안)에 두어도 부담 없는 크기인가?
Yes → std::array
No → vector (스택 오버플로우 방지)
Q2의 “작다”는 요소 개수가 아니라 바이트 크기와 놓이는 위치로 판단해야 합니다. std::array<double, 100>은 800바이트라 지역 변수로 전혀 문제가 없지만, std::array<Packet, 100>처럼 요소 하나가 1KB라면 100KB가 되어 작은 스택을 가진 스레드에서는 위험해집니다. 재귀 함수 안의 지역 배열은 호출 깊이만큼 곱해진다는 점도 기억해야 합니다. 또 std::array를 멤버로 가진 클래스의 객체를 vector에 담거나 make_unique로 만들면 배열도 함께 힙에 놓이므로, “std::array는 스택”이라는 공식은 지역 변수에만 해당합니다.
상황별 권장
| 상황 | 권장 | 이유 |
|---|---|---|
| 기본 선택 | vector | 안전, 동적 크기 |
| 크기 고정 + 작음 | std::array | 스택 할당, 안전 |
| 빈번한 생성/소멸 | std::array | 힙 할당 비용 없음 |
| 큰 배열 | vector | 스택 오버플로우 방지 |
| 크기 변경 필요 | vector | 동적 크기 |
| C API 연동 | C 배열 | 호환성 |
실전 예제
예제 1: 고정 크기 버퍼
// 요구사항: 크기 고정, 빈번한 생성
// 권장: std::array
std::array<char, 1024> buffer;
readData(buffer.data(), buffer.size());
예제 2: 동적 크기 리스트
// 요구사항: 크기 변경, 안전성
// 권장: vector
std::vector<int> numbers;
numbers.reserve(100); // 재할당 방지
for (int i = 0; i < n; ++i) {
numbers.push_back(i);
}
예제 3: 좌표 (x, y, z)
// 요구사항: 크기 3 고정, 빈번한 생성
// 권장: std::array
struct Position {
std::array<float, 3> coords; // x, y, z
float& x() { return coords[0]; }
float& y() { return coords[1]; }
float& z() { return coords[2]; }
};
실무 사례
사례 1: 게임 엔진 - 파티클 시스템
#include <array>
#include <iostream>
#include <vector>
struct Particle {
std::array<float, 3> position; // x, y, z (고정 크기)
std::array<float, 3> velocity;
float lifetime;
};
class ParticleSystem {
private:
std::vector<Particle> particles_; // 동적 크기
public:
void emit(const Particle& particle) {
particles_.push_back(particle);
}
void update(float deltaTime) {
for (auto& particle : particles_) {
particle.position[0] += particle.velocity[0] * deltaTime;
particle.position[1] += particle.velocity[1] * deltaTime;
particle.position[2] += particle.velocity[2] * deltaTime;
particle.lifetime -= deltaTime;
}
// 수명이 다한 파티클 제거
particles_.erase(
std::remove_if(particles_.begin(), particles_.end(),
[](const Particle& p) { return p.lifetime <= 0; }),
particles_.end()
);
}
size_t count() const {
return particles_.size();
}
};
int main() {
ParticleSystem system;
Particle p = {{0, 0, 0}, {1, 1, 0}, 5.0f};
system.emit(p);
system.update(0.016f); // 60 FPS
std::cout << "파티클 수: " << system.count() << std::endl;
return 0;
}
이 사례는 “고정 크기 부분은 std::array, 개수가 변하는 부분은 std::vector”라는 조합의 전형입니다. 좌표 3개는 절대 바뀌지 않으므로 Particle 안에 직접 들어가고, 파티클들은 vector 안에서 연속으로 배치되어 update의 순회가 캐시 친화적입니다. 제거에 쓴 erase-remove 관용구는 살아남은 파티클을 앞으로 당긴 뒤 끝부분을 한 번에 지우므로, 루프 안에서 erase를 하나씩 호출하는 것(매번 뒤쪽 요소 전체를 이동)보다 훨씬 효율적입니다. C++20부터는 std::erase_if(particles_, pred) 한 줄로 같은 일을 할 수 있습니다. 파티클 수가 수만 개로 많아지면 위치·속도·수명을 각각 별도 배열로 두는 SoA(Structure of Arrays) 구조가 SIMD에 더 유리하다는 점도 게임 엔진에서 자주 쓰는 최적화입니다.
사례 2: 이미지 처리 - 픽셀 버퍼
#include <array>
#include <iostream>
#include <vector>
struct Pixel {
std::array<uint8_t, 3> rgb; // 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(const 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;
}
사례 3: 네트워크 - 패킷 버퍼
#include <array>
#include <iostream>
#include <vector>
constexpr size_t MAX_PACKET_SIZE = 1024;
struct Packet {
std::array<char, MAX_PACKET_SIZE> data; // 고정 크기
size_t length;
};
class PacketQueue {
private:
std::vector<Packet> queue_; // 동적 크기
public:
void enqueue(const Packet& packet) {
queue_.push_back(packet);
}
Packet dequeue() {
Packet packet = queue_.front();
queue_.erase(queue_.begin()); // O(n): 뒤의 모든 패킷(각 1KB+)을 한 칸씩 이동
return packet;
}
bool empty() const {
return queue_.empty();
}
};
int main() {
PacketQueue queue;
Packet packet;
packet.length = 5;
std::copy_n("Hello", 5, packet.data.begin());
queue.enqueue(packet);
if (!queue.empty()) {
Packet received = queue.dequeue();
std::cout << "수신: " << std::string(received.data.begin(), received.data.begin() + received.length) << std::endl;
}
return 0;
}
이 코드는 컨테이너 선택의 잘못된 예로도 읽을 수 있습니다. vector 앞에서 요소를 꺼내는 erase(begin())은 남은 요소를 모두 한 칸씩 당기므로 O(n)이고, 여기서는 요소 하나가 1KB가 넘는 Packet이라 큐가 길어질수록 복사량이 급격히 늘어납니다. 앞에서 꺼내고 뒤에 넣는 FIFO 큐라면 std::deque(또는 이를 감싼 std::queue)가 양쪽 끝 연산을 상수 시간에 처리하므로 맞는 선택입니다. 최대 개수가 정해져 있다면 std::array 기반의 원형 버퍼(ring buffer)로 할당 없이 구현할 수도 있습니다. 또 Packet을 값으로 주고받을 때마다 1KB 배열 전체가 복사된다는 점도 비용이므로, 패킷이 크다면 std::move로 넘기거나 버퍼 풀에서 빌려 쓰는 구조를 고려합니다.
사례 4: 행렬 연산
#include <array>
#include <iostream>
#include <vector>
// 고정 크기 행렬 (3x3)
using Matrix3x3 = std::array<std::array<float, 3>, 3>;
Matrix3x3 multiply(const Matrix3x3& a, const Matrix3x3& b) {
Matrix3x3 result = {{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}}};
for (int i = 0; i < 3; ++i) {
for (int j = 0; j < 3; ++j) {
for (int k = 0; k < 3; ++k) {
result[i][j] += a[i][k] * b[k][j];
}
}
}
return result;
}
// 동적 크기 행렬
class Matrix {
private:
int rows_;
int cols_;
std::vector<float> data_;
public:
Matrix(int rows, int cols) : rows_(rows), cols_(cols) {
data_.resize(rows * cols, 0.0f);
}
float& at(int row, int col) {
return data_[row * cols_ + col];
}
};
int main() {
// 고정 크기 행렬
Matrix3x3 m1 = {{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}};
Matrix3x3 m2 = {{{2, 0, 0}, {0, 2, 0}, {0, 0, 2}}};
Matrix3x3 m3 = multiply(m1, m2);
// 동적 크기 행렬
Matrix m4(10, 10);
m4.at(5, 5) = 42.0f;
return 0;
}
동적 크기 행렬을 std::vector<std::vector<float>>가 아니라 1차원 vector 하나와 row * cols_ + col 인덱스 계산으로 만든 것은 의도된 선택입니다. 중첩 vector는 행마다 별도의 힙 할당이 일어나 행들이 메모리에 흩어지고, 행 길이가 서로 다를 수 있다는 불필요한 자유도까지 생깁니다. 1차원 배치는 할당이 한 번뿐이고 전체가 연속 메모리라 순회와 캐시 효율이 좋습니다. 이 at()은 이름과 달리 범위 검사를 하지 않으므로, 표준 컨테이너의 at()처럼 검사를 기대하는 사람이 혼동하지 않게 이름을 operator()로 바꾸거나 디버그 빌드에서 assert를 넣는 편이 좋습니다. C++23에서는 std::mdspan으로 이런 다차원 인덱싱을 표준 방식으로 표현할 수 있습니다.
트러블슈팅
문제 1: 스택 오버플로우
증상: 크래시
// ❌ 큰 배열을 스택에 할당
int main() {
int arr[10000000]; // 약 40MB: 기본 스택 크기(리눅스 보통 8MB, Windows 1MB)를 넘음
return 0;
}
// ✅ vector로 힙에 할당
int main() {
std::vector<int> vec(10000000); // 힙 할당
return 0;
}
스택 오버플로우는 증상이 헷갈리기 쉽습니다. 리눅스에서는 Segmentation fault로만 표시되어 널 포인터 역참조와 구별되지 않고, Windows에서는 0xC00000FD(Stack overflow) 예외 코드로 나타납니다. 게다가 배열을 선언만 하고 쓰지 않으면 최적화 컴파일러가 배열을 없애 버려 크래시가 재현되지 않기도 합니다. 크기가 조금만 커도 문제가 되는 곳은 메인이 아닌 스레드입니다. 새로 만든 스레드의 스택은 메인 스레드보다 작은 경우가 많아서, 메인에서 잘 돌던 함수를 워커 스레드로 옮기자마자 크래시하는 일이 생깁니다. ulimit -s로 스택 크기를 늘리는 것은 임시방편이고, 큰 버퍼는 힙에 두는 것이 근본적인 해결책입니다.
문제 2: 범위 오류
증상: 미정의 동작
// ❌ C 배열: 범위 체크 없음
int arr[5] = {1, 2, 3, 4, 5};
int x = arr[10]; // ❌ 미정의 동작
// ✅ vector: at()으로 범위 체크
std::vector<int> vec = {1, 2, 3, 4, 5};
try {
int x = vec.at(10);
} catch (const std::out_of_range& e) {
std::cerr << "범위 오류" << std::endl;
}
문제 3: 함수 전달 시 크기 손실
증상: 크기 정보 손실
// ❌ C 배열: 크기 정보 손실
void foo(int arr[]) { // int* 로 decay
// sizeof(arr)는 8 (포인터 크기)
}
int arr[5] = {1, 2, 3, 4, 5};
foo(arr);
// ✅ std::array: 크기 정보 유지
void foo(const std::array<int, 5>& arr) {
std::cout << arr.size() << std::endl; // 5
}
std::array<int, 5> arr2 = {1, 2, 3, 4, 5};
foo(arr2);
// ✅ std::vector: 크기 정보 유지
void foo(const std::vector<int>& vec) {
std::cout << vec.size() << std::endl; // 5
}
std::vector<int> vec = {1, 2, 3, 4, 5};
foo(vec);
문제 4: vector 복사 비용
증상: 성능 저하
// ❌ vector 값 전달 (복사)
void process(std::vector<int> vec) { // 복사 발생
// ...
}
std::vector<int> vec(1000000);
process(vec); // 100만 개 복사
// ✅ const 참조 전달
void process(const std::vector<int>& vec) { // 복사 없음
// ...
}
std::vector<int> vec2(1000000);
process(vec2); // 복사 없음
값 전달이 항상 나쁜 것은 아닙니다. 함수가 어차피 벡터의 복사본을 저장해야 한다면(member_ = vec;) 값으로 받고 member_ = std::move(vec);로 옮기는 것이 관용적인 방식입니다. 호출하는 쪽이 임시 객체나 std::move로 넘기면 복사가 한 번도 일어나지 않고, 일반 변수를 넘기면 정확히 한 번 복사되기 때문입니다. 읽기만 하는 함수라면 const std::vector<int>&가 기본이고, C 배열이나 std::array도 함께 받고 싶다면 앞서 말한 std::span<const int>가 더 유연합니다. 반대로 const&로 받은 벡터를 수정하고 싶어서 함수 안에서 복사본을 만든다면, 처음부터 값으로 받는 편이 호출자에게 이동 최적화의 기회를 줍니다.
마무리
배열과 vector의 선택은 크기 고정 여부와 안전성 요구사항에 달려 있습니다.
핵심 요약
-
3가지 배열 타입
- C 배열: 스택, 고정 크기, 안전성 낮음
- std::array: 스택, 고정 크기, 안전
- std::vector: 힙, 동적 크기, 안전
-
선택 기준
- 기본: vector (안전, 동적 크기)
- 크기 고정 + 작음: std::array
- C API 연동: C 배열 (불가피)
-
성능
- 접근 속도: 동일 (최적화 빌드)
- 생성/소멸: std::array >>> vector
- 안전성: vector ≈ std::array >>> C 배열
-
주의사항
- 큰 배열은 스택 오버플로우 주의
- C 배열은 범위 체크 없음
- vector는 reserve로 재할당 방지
선택 가이드
| 상황 | 권장 | 이유 |
|---|---|---|
| 기본 선택 | vector | 안전, 동적 크기 |
| 크기 고정 + 작음 | std::array | 스택 할당 |
| 빈번한 생성/소멸 | std::array | 힙 할당 비용 없음 |
| 큰 배열 | vector | 스택 오버플로우 방지 |
| 크기 변경 필요 | vector | 동적 크기 |
| C API 연동 | C 배열 | 호환성 |
코드 예제 치트시트
// C 배열
int arr1[5] = {1, 2, 3, 4, 5};
// std::array
std::array<int, 5> arr2 = {1, 2, 3, 4, 5};
arr2.at(0); // 범위 체크
// std::vector
std::vector<int> vec = {1, 2, 3, 4, 5};
vec.push_back(6); // 동적 크기
vec.at(0); // 범위 체크
// 함수 전달
void foo(const std::vector<int>& vec); // 참조
다음 단계
- vector 기초: std::vector 제대로 쓰기
- 메모리 기초: C++ 메모리 기초
- 스택 오버플로우: C++ 스택 오버플로우
참고 자료
- “Effective STL” - Scott Meyers
- “C++ Primer” - Stanley Lippman
- cppreference: https://en.cppreference.com/w/cpp/container
한 줄 정리: 대부분의 경우 vector를 사용하며, 크기가 고정되고 작으면 std::array를 고려하며, C 배열은 피합니다.
자주 묻는 질문 (FAQ)
Q. vector에서 operator[]와 at() 중 무엇을 써야 하나요?
A. operator[]는 범위를 검사하지 않아 빠르지만 잘못된 인덱스를 넣으면 C 배열처럼 미정의 동작이 되고, at()은 범위를 벗어나면 std::out_of_range 예외를 던집니다. 인덱스가 외부 입력에서 오거나 범위가 확실하지 않은 곳에서는 at()을 쓰고, 루프 경계가 명확해 범위가 보장된 반복에서는 operator[]를 써도 됩니다. 개발 중에는 표준 라이브러리의 디버그 모드나 AddressSanitizer를 켜 두면 operator[]의 범위 오류도 잡아낼 수 있습니다.
같이 보면 좋은 글
- C++ malloc vs new vs make_unique 비교
- C++ std::variant vs union: 타입 안전성과 비용 비교
- C++ 코드 리뷰 | ‘체크리스트’ 20가지 [실무 필수]