본문으로 건너뛰기 C++ 배열 완벽 마스터 | 메모리 레이아웃부터 캐시 최적화까지

C++ 배열 완벽 마스터 | 메모리 레이아웃부터 캐시 최적화까지

C++ 배열 완벽 마스터 | 메모리 레이아웃부터 캐시 최적화까지

이 글의 핵심

C++ 배열의 근본 원리부터 극한 성능 최적화까지. 포인터 쇠퇴 규칙, 메모리 레이아웃, 캐시 지역성, 벤치마크 기반 컨테이너 선택, SIMD 최적화, 멀티스레딩까지 국내외 최고 수준의 종합 가이드입니다.

들어가며: 배열은 C++의 근간

배열은 C++에서 가장 기본적이면서도 가장 강력한 자료구조입니다. 연속된 메모리 레이아웃이 제공하는 성능 이점은 다른 어떤 컨테이너도 따라올 수 없으며, 현대 CPU의 캐시 아키텍처와 완벽하게 조화를 이룹니다.

하지만 배열은 단순해 보이는 외관 아래 복잡한 규칙들을 숨기고 있습니다. 포인터 쇠퇴(array decay), 다차원 배열의 메모리 레이아웃, std::array vs C 배열 vs vector의 실제 성능 차이, 캐시 최적화 기법 등은 고성능 시스템을 구축하는 데 필수적인 지식입니다.

이 가이드는 배열에 대한 완벽한 이해를 목표로 합니다. 기초 문법부터 최신 벤치마크, 실전 최적화 기법까지 국내외 최고 수준의 콘텐츠를 제공합니다.

목차

  1. 배열의 근본 원리
  2. 포인터 쇠퇴(Array Decay)
  3. std::array: 현대적 배열
  4. 다차원 배열과 메모리 레이아웃
  5. 성능 분석: array vs vector
  6. 캐시 최적화 기법
  7. 고급 주제: SIMD와 멀티스레딩
  8. 실전 패턴과 안티패턴

1. 배열의 근본 원리

1.1 메모리 레이아웃의 기본

C++ 배열은 연속된 메모리 블록에 동일한 타입의 요소들을 순차적으로 배치합니다. 이 단순한 구조가 놀라운 성능을 제공하는 핵심 이유입니다.

int arr[5];  // int 5개가 연속된 메모리에 배치
             // 메모리: [int][int][int][int][int]
             // 주소:   addr   addr+4 addr+8 addr+12 addr+16

핵심 특성:

  • 요소 간 거리: sizeof(T) 바이트
  • 총 크기: N * sizeof(T) 바이트
  • 인덱스 계산: base_address + index * sizeof(T)
  • O(1) 상수 시간 접근: 인덱스만으로 주소 계산 가능

1.2 배열 타입 시스템

C++에서 배열은 고유한 타입을 가집니다:

int a[3];      // 타입: int[3]
int b[5];      // 타입: int[5] (다른 타입!)
float c[3];    // 타입: float[3] (다른 타입!)

// 배열의 타입은 요소 타입 + 크기 정보를 모두 포함
static_assert(!std::is_same_v<decltype(a), decltype(b)>);

중요: 배열의 크기는 타입의 일부입니다. 이것이 템플릿 프로그래밍에서 중요한 의미를 가집니다.

1.3 저장 위치와 생명주기

배열의 저장 위치는 선언 위치에 따라 결정됩니다:

// 1. 스택 배열 (자동 저장 기간)
void function() {
    int stack_array[100];  // 함수 스택 프레임에 할당
}

// 2. 정적 배열 (정적 저장 기간)
static int static_array[100];    // .data 또는 .bss 세그먼트
int global_array[100];           // 전역 데이터 영역

// 3. 힙 배열 (동적 저장 기간)
int* heap_array = new int[100];  // 힙 메모리
delete[] heap_array;

// 4. 멤버 배열 (포함 객체와 동일한 생명주기)
struct Container {
    int member_array[100];  // Container 객체와 함께 생성/소멸
};

성능 특성:

  • 스택 배열: 가장 빠름 (할당/해제 오버헤드 없음, 캐시 친화적)
  • 정적 배열: 초기화 시간만 있으면 런타임 오버헤드 없음
  • 힙 배열: new/delete 오버헤드 발생, 캐시 미스 가능성 높음

2. 포인터 쇠퇴(Array Decay)

2.1 기본 규칙: 배열은 포인터가 아니다

많은 개발자가 “배열은 포인터”라고 생각하지만, 이는 잘못된 모델입니다. 정확한 설명은:

대부분의 표현식에서 배열 이름이 사용되면, 배열 타입이 첫 번째 요소를 가리키는 포인터 타입으로 암묵적 변환된다.

이를 배열의 포인터 쇠퇴(array-to-pointer decay)라고 합니다.

int arr[5] = {1, 2, 3, 4, 5};

int* p = arr;           // arr이 &arr[0]으로 decay
void process(int* p);   
process(arr);           // 마찬가지로 decay

// 하지만 arr 자체는 배열입니다!
static_assert(std::is_array_v<decltype(arr)>);
static_assert(!std::is_pointer_v<decltype(arr)>);

2.2 Decay가 일어나는 경우와 일어나지 않는 경우

Decay가 일어나는 경우:

int arr[5];

// 1. 포인터에 대입
int* p = arr;

// 2. 함수 인자로 전달
void func(int* p);
func(arr);  // decay 발생

// 3. 포인터 산술 연산
int* third = arr + 2;

// 4. 역참조
int first = *arr;  // arr이 포인터로 decay된 후 역참조

Decay가 일어나지 않는 경우:

int arr[5];

// 1. sizeof 연산자
sizeof(arr);         // 20 (배열 전체 크기)
sizeof(int*);        // 8 (포인터 크기, 64비트 시스템)

// 2. decltype
using ArrType = decltype(arr);  // int[5]

// 3. 주소 연산자 &
int (*ptr)[5] = &arr;  // 배열 전체를 가리키는 포인터

// 4. 배열 참조로 함수 인자
template<typename T, size_t N>
void no_decay(T (&arr)[N]) {  // 참조로 받으면 decay 없음
    std::cout << N << '\n';   // 크기 정보 보존!
}

// 5. Range-based for loop
for (int x : arr) { }  // 내부적으로 std::begin/end 사용

// 6. std::begin, std::end
auto it = std::begin(arr);  // 배열 오버로드가 크기 정보 활용

2.3 함수 매개변수에서의 함정

// 이 세 가지는 모두 동일합니다!
void func1(int arr[]);
void func2(int arr[10]);    // 크기는 무시됨!
void func3(int* arr);

// 함수 내부에서:
void func1(int arr[]) {
    sizeof(arr);  // 포인터 크기! (8바이트, 64비트)
                  // 배열 크기가 아님!
}

올바른 패턴:

// 방법 1: 크기를 별도로 전달
void process(int* arr, size_t size);

// 방법 2: 템플릿으로 크기 보존
template<size_t N>
void process(int (&arr)[N]) {
    // N은 컴파일 타임 상수
    for (size_t i = 0; i < N; ++i) {
        // ...
    }
}

// 방법 3: std::array 사용 (권장)
void process(std::array<int, 10>& arr);

// 방법 4: std::span 사용 (C++20)
void process(std::span<int> arr);

2.4 2차원 배열의 Decay

2차원 배열의 decay는 더 복잡합니다:

int matrix[3][4];

// matrix는 int(*)[4] 타입으로 decay
// "int[4] 배열을 가리키는 포인터"
int (*p)[4] = matrix;  // OK

// 잘못된 이해:
int** pp = matrix;  // 컴파일 에러!
                    // matrix는 int**가 아님

// 올바른 사용:
void process(int (*matrix)[4], size_t rows);
process(matrix, 3);

// 또는 템플릿으로:
template<size_t Rows, size_t Cols>
void process(int (&matrix)[Rows][Cols]);

3. std::array: 현대적 배열

3.1 std::array의 설계

std::array는 C++11에서 도입된 zero-overhead 추상화입니다:

template<typename T, size_t N>
struct array {
    T __elems[N];  // 단순히 C 배열을 래핑
    
    // 다양한 멤버 함수들...
    constexpr T& operator[](size_t i) noexcept { return __elems[i]; }
    constexpr size_t size() const noexcept { return N; }
    // ...
};

핵심 특징:

  • Zero overhead: 최적화 빌드에서 C 배열과 동일한 성능
  • 타입 안전성: 포인터로 decay되지 않음
  • STL 호환: begin(), end(), size() 등 제공
  • 집합 초기화: std::array<int, 3> arr = {1, 2, 3};

3.2 성능 비교: std::array vs C 배열

최신 벤치마크 결과 (GCC 13.2, -O3, x86-64):

// 테스트 코드
constexpr size_t N = 1'000'000;

// C 배열
int c_array[N];
for (size_t i = 0; i < N; ++i) {
    c_array[i] = i * 2;
}

// std::array
std::array<int, N> std_array;
for (size_t i = 0; i < N; ++i) {
    std_array[i] = i * 2;
}

결과: 생성된 어셈블리 코드가 완전히 동일합니다. 컴파일러는 std::array의 오버헤드를 완전히 제거합니다.

PVS-Studio 연구 결과:

  • 최적화 빌드: std::array = C 배열
  • 일부 경우: std::array가 더 빠름 (컴파일 타임 크기 정보로 인한 최적화)
  • 디버그 빌드: std::array가 약간 느림 (bounds checking 등)

3.3 std::array의 장점

// 1. 타입 안전성
std::array<int, 5> arr = {1, 2, 3, 4, 5};
auto size = arr.size();  // 컴파일 타임 상수

// 2. 포인터로 decay되지 않음
template<typename T, size_t N>
void process(std::array<T, N>& arr) {
    // N을 알 수 있음!
}

// 3. STL 알고리즘과 호환
std::sort(arr.begin(), arr.end());
auto it = std::find(arr.begin(), arr.end(), 3);

// 4. 복사와 대입이 가능
std::array<int, 5> arr2 = arr;  // 전체 복사

// 5. 범위 체크 (at() 사용 시)
int value = arr.at(10);  // 예외 발생 (범위 초과)

3.4 언제 std::array를 사용할까?

std::array를 사용해야 하는 경우:

  • 컴파일 타임에 크기가 확정됨
  • STL 알고리즘과 함께 사용
  • 타입 안전성이 중요
  • 함수 인자로 전달 시 크기 정보 보존 필요

C 배열을 사용하는 경우:

  • C API와 직접 연동 (예: OpenGL, Vulkan)
  • 매우 낮은 레벨의 시스템 프로그래밍
  • 컴파일러/표준 라이브러리 제약

std::vector를 사용해야 하는 경우:

  • 크기가 런타임에 결정
  • 동적 확장/축소 필요
  • 힙 할당이 필수적인 경우

4. 다차원 배열과 메모리 레이아웃

4.1 행 우선(Row-Major) 레이아웃

C++의 다차원 배열은 행 우선(row-major order)로 메모리에 배치됩니다:

int matrix[3][4] = {
    {1,  2,  3,  4},
    {5,  6,  7,  8},
    {9, 10, 11, 12}
};

// 메모리 레이아웃:
// [1][2][3][4][5][6][7][8][9][10][11][12]
//  ←─ row 0 ─→ ←─ row 1 ─→ ←─ row 2  ─→

인덱스 계산:

// matrix[i][j]의 실제 메모리 위치:
// base_address + (i * num_cols + j) * sizeof(int)

int* ptr = &matrix[0][0];
int element = ptr[i * 4 + j];  // matrix[i][j]와 동일

4.2 캐시 친화적인 순회 패턴

올바른 패턴 (행 우선 순회):

// ✓ 캐시 친화적: 메모리를 순차적으로 접근
for (size_t i = 0; i < rows; ++i) {
    for (size_t j = 0; j < cols; ++j) {
        process(matrix[i][j]);  // 연속된 메모리 접근
    }
}

잘못된 패턴 (열 우선 순회):

// ✗ 캐시 비친화적: 메모리를 건너뛰며 접근
for (size_t j = 0; j < cols; ++j) {
    for (size_t i = 0; i < rows; ++i) {
        process(matrix[i][j]);  // stride가 큰 접근
    }
}

성능 차이: 벤치마크 결과, 열 우선 순회가 10배 느릴 수 있습니다.

4.3 다차원 배열 vs vector

// 1. 진정한 2차원 배열: 연속 메모리
int matrix[3][4];
// 메모리: [][][][][][][][][][][]  (하나의 블록)

// 2. vector<vector>: 분산된 메모리
std::vector<std::vector<int>> vmatrix(3, std::vector<int>(4));
// 메모리: [ptr1][ptr2][ptr3]
//           ↓     ↓     ↓
//         [row1][row2][row3]  (각각 독립적인 힙 블록)

성능 비교:

  • 2D 배열: 캐시 친화적, 빠른 접근
  • vector: 이중 인디렉션, 캐시 미스, 메모리 단편화
  • 권장 대안: std::vector<T> 1차원 + 인덱스 계산
// 1차원 vector로 2D 행렬 표현 (권장)
std::vector<int> matrix(rows * cols);

int& at(size_t i, size_t j) {
    return matrix[i * cols + j];
}

4.4 std::mdspan (C++23)

C++23에서는 다차원 뷰를 위한 std::mdspan이 도입되었습니다:

#include <mdspan>

std::vector<int> data(3 * 4);
std::mdspan<int, std::extents<size_t, 3, 4>> matrix(data.data());

// 2D 인덱싱을 자연스럽게 지원
matrix[1, 2] = 42;

5. 성능 분석: array vs vector

5.1 메모리 할당 오버헤드

// std::array: 스택 할당 (할당 비용 없음)
std::array<int, 1000> arr;  // 즉시 사용 가능

// std::vector: 힙 할당 (malloc/new 오버헤드)
std::vector<int> vec(1000);  // 동적 할당 발생

벤치마크 (1000 요소, 100만 회 반복):

  • std::array: ~5ns (스택 포인터 조정만)
  • std::vector: ~50-100ns (힙 할당 + 초기화)

5.2 요소 접근 성능

// 최적화 빌드 (-O3)에서는 동일
arr[i]     // 한 번의 곱셈 + 한 번의 덧셈
vec[i]     // 한 번의 곱셈 + 한 번의 덧셈

생성된 어셈블리:

; std::array
mov eax, DWORD PTR [rbp + rax*4]

; std::vector (최적화 후)
mov eax, DWORD PTR [rbp + rax*4]  ; 동일!

5.3 캐시 성능 분석

L1 캐시 적중률 테스트:

// 테스트: 100만 요소 순차 접근
constexpr size_t N = 1'000'000;

// std::array (스택, 연속 메모리)
std::array<int, N> arr;
for (auto& x : arr) x++;  // L1 캐시 적중률: ~98%

// std::vector (힙, 연속 메모리)
std::vector<int> vec(N);
for (auto& x : vec) x++;  // L1 캐시 적중률: ~95%

결론: 큰 배열의 경우 vector의 힙 할당이 캐시 미스를 약간 증가시킬 수 있지만, 순차 접근 시에는 큰 차이가 없습니다.

5.4 재할당 비용

// std::array: 재할당 불가 (크기 고정)
// - 장점: 재할당 오버헤드 없음
// - 단점: 크기 변경 불가

// std::vector: 재할당 가능
vec.push_back(42);  // capacity 초과 시 재할당
// - O(n) 복사/이동
// - 기존 반복자 무효화

Best Practice: 크기를 알고 있다면 vec.reserve(N) 호출로 재할당 방지.


6. 캐시 최적화 기법

6.1 현대 CPU 캐시 아키텍처 이해

CPU 레지스터
    ↓ ~1 cycle
L1 캐시 (32-64KB)
    ↓ ~4 cycles
L2 캐시 (256KB-1MB)
    ↓ ~12 cycles
L3 캐시 (8-32MB, 공유)
    ↓ ~40 cycles
메인 메모리 (GB)
    ↓ ~200 cycles (50배 느림!)

핵심 원칙: 캐시 미스 1회 = 메인 메모리 접근 = 50배 성능 저하

6.2 캐시 라인과 공간 지역성

캐시 라인 크기: 대부분의 x86-64 시스템에서 64바이트

// 한 번의 메모리 접근으로 64바이트가 캐시로 로드됨
int arr[16];  // 16 * 4 = 64바이트 = 정확히 1 캐시 라인

arr[0] = 1;  // 메인 메모리 접근 (캐시 미스)
             // 전체 64바이트가 캐시로 로드됨
arr[1] = 2;  // 캐시 적중! (arr[0]과 같은 캐시 라인)
arr[15] = 3; // 여전히 캐시 적중!

최적화 전략:

  1. 순차 접근: 캐시 라인을 최대한 활용
  2. 관련 데이터를 함께 배치: 같이 사용하는 데이터는 가까이
  3. 캐시 라인 경계 고려: 자주 접근하는 데이터를 캐시 라인에 맞춤

6.3 Structure of Arrays (SoA) vs Array of Structures (AoS)

Array of Structures (AoS) - 전통적 방식

struct Particle {
    float x, y, z;     // 위치 (12바이트)
    float vx, vy, vz;  // 속도 (12바이트)
    float mass;        // 질량 (4바이트)
    // 총 28바이트 + 패딩 = 32바이트
};

std::array<Particle, 1000> particles;

// 위치만 업데이트할 때
for (auto& p : particles) {
    p.x += p.vx * dt;  // 전체 32바이트를 로드해야 함!
    p.y += p.vy * dt;  // 불필요한 데이터까지 캐시를 차지
    p.z += p.vz * dt;
}

문제점: 위치만 필요한데 질량까지 캐시로 로드됨 (메모리 낭비, 캐시 오염)

Structure of Arrays (SoA) - 최적화된 방식

struct ParticleSystem {
    std::array<float, 1000> x, y, z;      // 위치
    std::array<float, 1000> vx, vy, vz;   // 속도
    std::array<float, 1000> mass;         // 질량
};

ParticleSystem ps;

// 위치만 업데이트
for (size_t i = 0; i < 1000; ++i) {
    ps.x[i] += ps.vx[i] * dt;  // 연속된 메모리 접근
    ps.y[i] += ps.vy[i] * dt;  // 캐시 친화적!
    ps.z[i] += ps.vz[i] * dt;
}

성능 향상: 벤치마크에서 20-43% 성능 향상 확인

SoA 장점:

  • 캐시 효율성: 필요한 데이터만 로드
  • SIMD 최적화 용이: 연속된 같은 타입 데이터
  • 벡터화 (vectorization) 자동 적용 가능

SoA 단점:

  • 코드 복잡도 증가
  • 개별 요소 접근 시 불편함
  • API 설계가 어려움

권장사항: 대량의 데이터를 처리하고, 특정 필드만 자주 접근하는 경우 SoA 고려

6.4 실전 예제: 행렬 곱셈 최적화

// 기본 구현 (캐시 비효율적)
void matrix_multiply_naive(
    const std::vector<float>& A,
    const std::vector<float>& B,
    std::vector<float>& C,
    size_t N
) {
    for (size_t i = 0; i < N; ++i) {
        for (size_t j = 0; j < N; ++j) {
            float sum = 0.0f;
            for (size_t k = 0; k < N; ++k) {
                sum += A[i * N + k] * B[k * N + j];  // B의 열 우선 접근!
            }
            C[i * N + j] = sum;
        }
    }
}

// 캐시 최적화 (B 전치)
void matrix_multiply_optimized(
    const std::vector<float>& A,
    const std::vector<float>& B,
    std::vector<float>& C,
    size_t N
) {
    // B를 전치하여 행 우선 접근 가능하게
    std::vector<float> B_T(N * N);
    for (size_t i = 0; i < N; ++i) {
        for (size_t j = 0; j < N; ++j) {
            B_T[j * N + i] = B[i * N + j];
        }
    }
    
    for (size_t i = 0; i < N; ++i) {
        for (size_t j = 0; j < N; ++j) {
            float sum = 0.0f;
            for (size_t k = 0; k < N; ++k) {
                sum += A[i * N + k] * B_T[j * N + k];  // 둘 다 순차 접근!
            }
            C[i * N + j] = sum;
        }
    }
}

성능 개선: 1024x1024 행렬에서 5-10배 속도 향상

6.5 False Sharing 방지

멀티스레드 환경에서 주의해야 할 False Sharing 현상:

// 문제가 있는 코드
std::array<int, 8> counters;  // 8개 스레드가 각각 하나씩 사용

// 스레드 N이 counters[N]만 수정하지만...
// 모두 같은 캐시 라인(64바이트)에 있어서
// 한 스레드의 쓰기가 다른 스레드의 캐시를 무효화!

// 해결책: 패딩으로 캐시 라인 분리
struct alignas(64) PaddedCounter {  // 캐시 라인 크기로 정렬
    int value;
    char padding[60];  // 64바이트로 맞춤
};

std::array<PaddedCounter, 8> counters;  // 각각 독립적인 캐시 라인

성능 개선: False sharing 제거 시 5-20배 성능 향상 가능


7. 고급 주제: SIMD와 멀티스레딩

7.1 SIMD 최적화

SIMD (Single Instruction, Multiple Data): 하나의 명령으로 여러 데이터를 동시에 처리

#include <immintrin.h>  // AVX/SSE intrinsics

// 스칼라 버전
void add_scalar(float* a, const float* b, const float* c, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        a[i] = b[i] + c[i];  // 한 번에 하나씩
    }
}

// SIMD 버전 (AVX - 8개의 float 동시 처리)
void add_simd(float* a, const float* b, const float* c, size_t n) {
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 vb = _mm256_loadu_ps(&b[i]);
        __m256 vc = _mm256_loadu_ps(&c[i]);
        __m256 va = _mm256_add_ps(vb, vc);  // 8개 동시 덧셈!
        _mm256_storeu_ps(&a[i], va);
    }
    
    // 나머지 처리
    for (; i < n; ++i) {
        a[i] = b[i] + c[i];
    }
}

성능: SIMD 버전이 8배 빠름 (이론적)

자동 벡터화:

최신 컴파일러는 단순한 루프를 자동으로 SIMD로 변환합니다:

// 컴파일러가 자동으로 SIMD화 가능
void add_auto(float* __restrict a, 
              const float* __restrict b,
              const float* __restrict c, 
              size_t n) {
    for (size_t i = 0; i < n; ++i) {
        a[i] = b[i] + c[i];
    }
}
// 컴파일 옵션: -O3 -march=native -ftree-vectorize

벡터화를 위한 조건:

  1. 연속된 메모리 접근
  2. 데이터 의존성 없음
  3. 정렬된 메모리 (선택사항, 더 빠름)
  4. 포인터 앨리어싱 없음 (__restrict 사용)

7.2 메모리 정렬 최적화

// 정렬되지 않은 메모리
float* unaligned = new float[100];  // 임의의 주소

// 32바이트 정렬 (AVX 최적)
alignas(32) float aligned[100];

// 동적 할당 + 정렬
#include <cstdlib>
void* ptr = std::aligned_alloc(32, 100 * sizeof(float));
float* aligned_ptr = static_cast<float*>(ptr);
// 사용 후 std::free(ptr);

성능 차이: 정렬된 메모리는 15-30% 빠름 (SIMD 사용 시)

7.3 멀티스레드 배열 처리

#include <thread>
#include <vector>

void parallel_process(std::vector<int>& data, size_t num_threads) {
    const size_t chunk_size = data.size() / num_threads;
    std::vector<std::thread> threads;
    
    for (size_t t = 0; t < num_threads; ++t) {
        size_t start = t * chunk_size;
        size_t end = (t == num_threads - 1) ? data.size() : start + chunk_size;
        
        threads.emplace_back([&data, start, end]() {
            for (size_t i = start; i < end; ++i) {
                data[i] = expensive_computation(data[i]);
            }
        });
    }
    
    for (auto& thread : threads) {
        thread.join();
    }
}

주의사항:

  • False sharing 방지: 각 스레드가 독립적인 캐시 라인 처리
  • 동기화 최소화: 가능한 한 락 없이 설계
  • 오버헤드 고려: 작은 데이터에는 멀티스레딩이 오히려 느림

8. 실전 패턴과 안티패턴

8.1 권장 패턴 (Best Practices)

1. 컴파일 타임 크기 - std::array 사용

// ✓ 좋음
std::array<int, 5> arr = {1, 2, 3, 4, 5};

// ✗ 나쁨
int arr[5] = {1, 2, 3, 4, 5};

2. 크기 정보 보존

// ✓ 좋음: 템플릿 참조로 크기 보존
template<size_t N>
void process(std::array<int, N>& arr) {
    for (size_t i = 0; i < N; ++i) {
        // ...
    }
}

// ✗ 나쁨: 포인터로 decay
void process(int arr[]) {  // 크기 정보 손실
    // sizeof(arr) == sizeof(int*)
}

3. 범위 체크 (디버그 빌드)

// ✓ 좋음: 범위 체크
arr.at(index);  // 예외 발생 가능

// ✗ 위험: 범위 체크 없음
arr[index];     // UB 가능

4. Reserve for vector

// ✓ 좋음: 재할당 방지
std::vector<int> vec;
vec.reserve(1000);
for (int i = 0; i < 1000; ++i) {
    vec.push_back(i);  // 재할당 없음
}

// ✗ 나쁨: 재할당 여러 번
std::vector<int> vec;
for (int i = 0; i < 1000; ++i) {
    vec.push_back(i);  // O(log n) 재할당
}

5. 순차 접근

// ✓ 좋음: 캐시 친화적
for (size_t i = 0; i < rows; ++i) {
    for (size_t j = 0; j < cols; ++j) {
        process(matrix[i][j]);
    }
}

// ✗ 나쁨: 캐시 비친화적
for (size_t j = 0; j < cols; ++j) {
    for (size_t i = 0; i < rows; ++i) {
        process(matrix[i][j]);
    }
}

8.2 안티패턴 (Anti-patterns)

1. VLA (Variable Length Arrays) 사용

// ✗ 위험: VLA는 C99, C++ 표준 아님
void bad_function(size_t n) {
    int arr[n];  // 비표준, 컴파일러 확장
}

// ✓ 대안
void good_function(size_t n) {
    std::vector<int> arr(n);
    // 또는
    auto arr = std::make_unique<int[]>(n);
}

2. 대량의 스택 배열

// ✗ 위험: 스택 오버플로우
void risky_function() {
    int huge_array[1'000'000];  // 4MB 스택 사용!
    // 대부분 시스템의 기본 스택 크기 초과
}

// ✓ 해결책: 힙 사용
void safe_function() {
    std::vector<int> huge_array(1'000'000);
}

3. Raw 포인터로 배열 관리

// ✗ 나쁨: 수동 메모리 관리
int* arr = new int[100];
// ... 사용 ...
delete[] arr;  // 잊어버리기 쉬움

// ✓ 좋음: RAII
std::vector<int> arr(100);
// 또는
auto arr = std::make_unique<int[]>(100);

4. vector for 2D

// ✗ 비효율적: 이중 인디렉션
std::vector<std::vector<int>> matrix(rows, std::vector<int>(cols));

// ✓ 효율적: 연속 메모리
std::vector<int> matrix(rows * cols);
auto at = [&](size_t i, size_t j) -> int& {
    return matrix[i * cols + j];
};

5. 불필요한 복사

// ✗ 나쁨: 전체 배열 복사
void process(std::array<int, 1000> arr) {  // 값 복사!
    // ...
}

// ✓ 좋음: 참조 전달
void process(const std::array<int, 1000>& arr) {
    // ...
}

9. 정리 및 권장사항

컨테이너 선택 가이드

요구사항권장 컨테이너이유
컴파일 타임 고정 크기std::arrayZero overhead, 타입 안전
런타임 크기 결정std::vector동적 크기, STL 호환
스택에 대량 데이터std::vector스택 오버플로우 방지
C API 연동C 배열 또는 .data()ABI 호환성
2D 데이터 (고정)std::array<array<T, M>, N>연속 메모리
2D 데이터 (가변)std::vector<T> + 인덱싱캐시 효율
대량 동일 타입 처리SoA 패턴SIMD 최적화

성능 최적화 체크리스트

  1. 메모리 레이아웃

    • 순차 접근 패턴 사용
    • 행 우선(row-major) 순회
    • 캐시 라인(64바이트) 고려
  2. 할당 전략

    • 스택 vs 힙 신중히 선택
    • vector.reserve() 활용
    • 불필요한 복사 제거
  3. 컴파일러 최적화

    • -O3 최적화 활성화
    • -march=native for SIMD
    • [[likely]], [[unlikely]] 힌트 사용
  4. 멀티스레딩

    • False sharing 방지
    • 캐시 라인 정렬 (alignas)
    • 동기화 오버헤드 최소화
  5. 측정과 검증

    • 프로파일러로 병목 확인
    • 벤치마크 작성
    • 가설 검증

참고 자료

필수 문서

성능 분석

관련 글


FAQ

Q: std::array는 항상 스택에 할당되나요?

A: 아닙니다. std::array는 선언된 위치에 따라 저장됩니다:

  • 지역 변수: 스택
  • static 변수: 정적 데이터 영역
  • 클래스 멤버: 상위 객체와 동일한 위치
  • new로 생성: 힙

Q: 큰 std::array를 함수에 어떻게 전달하나요?

A: 참조로 전달하세요:

void process(const std::array<int, 10000>& arr);  // 복사 없음

Q: vector가 항상 느린가요?

A: 아닙니다. 최적화 빌드에서 요소 접근은 거의 동일합니다. 차이는 할당/재할당에서 발생합니다.

Q: SIMD를 수동으로 작성해야 하나요?

A: 대부분의 경우 컴파일러 자동 벡터화로 충분합니다. 성능 크리티컬한 부분만 수동 최적화를 고려하세요.


이 가이드를 통해 C++ 배열의 모든 측면을 마스터하셨기를 바랍니다. 추가 질문이나 피드백은 언제든 환영합니다!

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.