[C++] 배열과 컨테이너 (Array, Deque, Span)

chooha·2026년 1월 17일

C++

목록 보기
20/23

📚 C++ 배열과 컨테이너 (Array, Deque, Span)

🔸 배열과 컨테이너 비교

5가지 메모리 관리 방법 비교

#include <array>
#include <vector>

int main()
{
    // 1. C 정적 배열
    int cArray[5] = {1, 2, 3, 4, 5};  
    // 스택, 컴파일 타임 크기, 자동 관리
    
    // 2. C++ 동적 배열 (new/delete)
    int* cppDynArray = new int[5]{1, 2, 3, 4, 5};
    // 힙, 런타임 크기, 수동 할당/해제
    // ✅ 타입 안전
    // ❌ 메모리 누수 위험
    delete[] cppDynArray;
    
    // 3. std::array
    std::array<int, 5> stdArray{1, 2, 3, 4, 5};  
    // 스택, 컴파일 타임 크기, 자동 관리
    // ✅ 타입 안전, 크기 정보 유지
    
    // 4. std::vector (권장!)
    std::vector<int> vec{1, 2, 3, 4, 5};  
    // 힙, 런타임 크기, 자동 관리
    // ✅ 동적 크기 조정
    // ✅ 메모리 자동 해제
    // ✅ 타입 안전
    
    return 0;
}

메모리 할당 위치 정리

타입메모리 위치크기 결정관리 방식안전성
C 배열스택컴파일 타임자동⚠️
C++ 동적 배열 (new)런타임수동⚠️
std::array스택컴파일 타임자동
std::vector런타임자동

왜 std::vector를 사용해야 하는가?

// ❌ C++ 동적 배열의 문제점
int* arr2 = new int[5];
// delete[] 깜빡하면 메모리 누수
// 크기 변경 불가
delete[] arr2;

// ✅ std::vector의 장점
std::vector<int> vec(5);
// 자동 메모리 관리
// 크기 정보 포함 (vec.size())
// 동적 크기 조정 (vec.push_back())
// 타입 안전
// RAII (자동 소멸)

🔸 std::array

std::array란?

  • 컴파일 타임에 크기가 고정된 배열
  • 스택 메모리에 할당
  • C 스타일 배열을 안전하게 감싼 래퍼

std::array의 장점

std::array<int, 5> arr{1, 2, 3, 4, 5};

// 경계 검사
arr.at(10);  // std::out_of_range 예외 발생

// 크기 확인
std::cout << arr.size() << std::endl;  // 5

// 반복자 사용
for (auto it = arr.begin(); it != arr.end(); ++it)
{
    std::cout << *it << " ";
}

// Range-based for
for (const auto& elem : arr)
{
    std::cout << elem << " ";
}

std::array vs C 배열

// C 배열
int cArr[5];
// sizeof(cArr) == 20 (5 * 4)
// 함수에 전달 시 포인터로 decay
// 크기 정보 손실

void func(int arr[])  // 실제로는 int*
{
    // sizeof(arr) == 8 (포인터 크기)
}

// std::array
std::array<int, 5> stdArr;
// sizeof(stdArr) == 20 (5 * 4)
// 함수에 전달 시 크기 정보 유지

void func(std::array<int, 5>& arr)
{
    // sizeof(arr) == 20
    // arr.size() == 5
}

🔸 다차원 배열과 벡터

메모리 레이아웃 차이

1. 다차원 배열 (스택, 연속된 메모리)

int arr[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]
//  ↑ 스택에 한 번에 할당

2. 다차원 벡터 (힙, 분산된 메모리)

std::vector<std::vector<int>> vec = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// 메모리 레이아웃 (비연속적):
// vec (스택):
// [ptr1][ptr2][ptr3]
//   ↓     ↓     ↓
// 힙:
// [1][2][3][4]  (첫 번째 행)
// [5][6][7][8]  (두 번째 행)
// [9][10][11][12]  (세 번째 행)

연속된 메모리를 사용하는 Wrapper

벡터를 사용하되 연속된 메모리를 유지하려면 1차원 벡터로 관리하고 인덱스 변환을 사용:

class Matrix
{
public:
    Matrix(size_t rows, size_t cols)
        : mRows{rows}, mCols{cols}, mData(rows * cols)
    {
    }
    
    int& operator()(size_t row, size_t col)
    {
        return mData[row * mCols + col];  // 2D → 1D 인덱스 변환
    }
    
    const int& operator()(size_t row, size_t col) const
    {
        return mData[row * mCols + col];
    }
    
private:
    size_t mRows;
    size_t mCols;
    std::vector<int> mData;  // 1차원 벡터로 연속 메모리 보장
};

int main()
{
    Matrix mat(3, 4);
    
    // 사용
    mat(0, 0) = 1;
    mat(0, 1) = 2;
    mat(1, 0) = 5;
    
    return 0;
}

🔸 캐시 최적화 (Cache Hit/Miss)

캐시 라인 (Cache Line)

  • CPU 캐시의 최소 단위 (보통 64 bytes)
  • 메모리를 읽을 때 캐시 라인 단위로 가져옴
  • 연속된 메모리는 캐시에 함께 로드됨

잘못된 루프 (Cache Miss 많음)

const size_t ROWS = 1000;
const size_t COLS = 1000;
int arr[ROWS][COLS];

// ❌ 열 우선 순회 (Column-major)
for (size_t col = 0; col < COLS; col++)
{
    for (size_t row = 0; row < ROWS; row++)
    {
        arr[row][col] = 0;  // 메모리 점프 발생!
    }
}

메모리 접근 패턴:

arr[0][0] → arr[1][0] → arr[2][0] → ...
               ↓         ↓ (COLS * 4 bytes 떨어짐)
            캐시 미스!

올바른 루프 (Cache Hit 많음)

// ✅ 행 우선 순회 (Row-major)
for (size_t row = 0; row < ROWS; row++)
{
    for (size_t col = 0; col < COLS; col++)
    {
        arr[row][col] = 0;  // 연속된 메모리 접근!
    }
}

메모리 접근 패턴:

arr[0][0] → arr[0][1] → arr[0][2] → ...
                ↓         ↓ (4 bytes 떨어짐, 같은 캐시 라인)
             캐시 히트!

핵심 규칙: Inner Loop는 Cache Line에 맞춰라

  • C/C++ 배열은 row-major 순서로 저장됨
  • Inner loop에서 연속된 메모리를 접근하도록 설계
  • 열(column) 순회를 inner loop에 배치

🔸 std::deque (Double-Ended Queue)

std::deque란?

  • 양쪽 끝에서 삽입/삭제가 O(1)인 컨테이너
  • 실무에서는 거의 사용하지 않음
  • Vector와 List의 중간 형태

std::deque vs std::vector

#include <deque>
#include <vector>

std::vector<int> vec;
vec.push_back(1);   // 뒤에 추가: O(1) (amortized)
// vec.push_front(0);  // ❌ vector는 push_front 없음

std::deque<int> deq;
deq.push_back(1);   // 뒤에 추가: O(1)
deq.push_front(0);  // ✅ 앞에 추가: O(1)

deque의 메모리 구조 (Chunk Array)

Deque:
┌─────────────────────┐
│ Chunk 포인터들      │ (중앙 배열)
│ [ptr1][ptr2][ptr3] │
└────┬─────┬─────┬────┘
     ↓     ↓     ↓
   청크   청크   청크 (힙)
[▢▢▢▢][■■■■][▢▢▢▢]
           ↑
         데이터

Vector의 재할당 vs Deque의 확장

Vector:

std::vector<int> vec{1, 2, 3, 4};
// 메모리: [1][2][3][4] (capacity: 4)

vec.push_back(5);  // capacity 부족!
// → 새 공간 할당 (capacity: 8)
// → 기존 데이터 전체 복사/이동
// → 기존 공간 해제

Deque:

std::deque<int> deq{1, 2, 3, 4};
// Chunk1: [1][2][3][4]

deq.push_back(5);  // 현재 청크가 가득 찬 경우
// → 새 청크만 할당
// → 기존 데이터는 그대로!
// Chunk1: [1][2][3][4]
// Chunk2: [5][▢][▢][▢]

Deque의 장점
1. push_front() / push_back() 모두 O(1)
2. 재할당 시 기존 데이터를 복사/이동하지 않음
3. Iterator invalidation이 적음 (중간 삽입 제외)

Deque의 단점

  1. 두 번의 포인터 역참조 필요

    deq[i];
    // 1. 어느 청크인지 계산
    // 2. 청크 포인터 배열에서 청크 주소 읽기
    // 3. 청크 내에서 오프셋 계산
    // 4. 실제 데이터 접근
  2. 캐시 미스 가능성

    • 청크들이 메모리상 분산되어 있음
    • 연속된 접근 시 캐시 효율 떨어짐
  3. 메모리 오버헤드

    • 청크 포인터 배열 관리
    • 각 청크마다 약간의 낭비 공간

성능 비교

// 랜덤 접근
vec[1000];   // O(1), 빠름 (한 번의 역참조)
deq[1000];   // O(1), 느림 (두 번의 역참조)

// 앞쪽 삽입
// vec.push_front()  // 없음 (구현하려면 O(n))
deq.push_front(x);   // O(1)

// 뒤쪽 삽입
vec.push_back(x);    // O(1) amortized
deq.push_back(x);    // O(1)

언제 Deque를 사용할까?

  • 양쪽 끝에서 삽입/삭제가 빈번할 때
  • 중간 접근은 드물 때
  • 하지만 대부분의 경우 std::vector가 더 나음

🔸 push_back vs emplace_back

기본 차이

struct Student {
    std::string name;
    int age;
    Student(std::string n, int a) : name(n), age(a) {
        std::cout << "생성자 호출\n";
    }
    Student(const Student& other) : name(other.name), age(other.age) {
        std::cout << "복사 생성자 호출\n";
    }
    Student(Student&& other) : name(std::move(other.name)), age(other.age) {
        std::cout << "이동 생성자 호출\n";
    }
};

std::vector<Student> v;

// push_back: 임시 객체 생성 → 이동 → 소멸
v.push_back(Student("Alice", 20));
// 출력: 생성자 호출 → 이동 생성자 호출

// emplace_back: 컨테이너 내부에서 직접 생성
v.emplace_back("Bob", 21);
// 출력: 생성자 호출 (끝)

언제 emplace_back이 확실히 유리한가?

1. 복사/이동이 비용이 큰 경우

struct BigData {
    std::vector<int> data;
    BigData(int size) : data(size, 42) {}
};

std::vector<BigData> v;

// push_back: 생성 → 이동 (vector 내부 데이터도 이동)
v.push_back(BigData(10000));

// emplace_back: 바로 생성 (이동 없음)
v.emplace_back(10000);  // ✅ 더 효율적

2. 복사 생성자가 delete된 경우

struct NonCopyable {
    NonCopyable(int x) {}
    NonCopyable(const NonCopyable&) = delete;
    NonCopyable(NonCopyable&&) = delete;
};

std::vector<NonCopyable> v;

// v.push_back(NonCopyable(42));  // ❌ 컴파일 에러
v.emplace_back(42);               // ✅ 작동

3. 생성자 인자가 여러 개일 때 (코드 간결성)

struct Point3D {
    double x, y, z;
    Point3D(double x, double y, double z) : x(x), y(y), z(z) {}
};

std::vector<Point3D> points;

// push_back: 임시 객체 명시적 생성
points.push_back(Point3D(1.0, 2.0, 3.0));

// emplace_back: 인자만 전달 (더 간결)
points.emplace_back(1.0, 2.0, 3.0);

C++17 이후의 현실: Copy Elision

std::vector<Student> v;

// C++17 이후: 컴파일러가 임시 객체 생성을 최적화
v.push_back(Student("Alice", 20));
// → 실제로는 vector 내부에 바로 생성될 수 있음 (RVO)

v.emplace_back("Alice", 20);
// → 거의 동일한 코드로 컴파일됨

간단한 타입에서는 차이가 거의 없음:

std::vector<int> v;

v.push_back(42);     // 성능 차이 없음
v.emplace_back(42);  // 성능 차이 없음

주의사항: explicit 생성자

struct Widget {
    explicit Widget(int x) {}
};

std::vector<Widget> v;

// v.push_back(42);     // ❌ 컴파일 에러 (explicit 생성자)
v.emplace_back(42);     // ✅ 작동 (의도치 않은 변환 가능)

실전 가이드

// ✅ emplace_back 사용 권장
std::vector<ComplexType> v;
v.emplace_back(arg1, arg2, arg3);  // 생성자 인자 직접 전달

// ✅ push_back도 괜찮음 (C++17 이후)
std::vector<int> numbers;
numbers.push_back(42);  // 간단한 타입은 가독성 우선

// ✅ 이미 생성된 객체는 push_back
Student s("Alice", 20);
v.push_back(std::move(s));  // 명시적 의도

결론

  • 복잡한 객체나 여러 인자: emplace_back 사용
  • 간단한 타입이나 가독성 우선: push_back도 OK
  • 현대 C++에서는 큰 차이 없는 경우 많음
  • 일관성 있게 사용하는 것이 중요

🔸 std::span (C++20)

std::span이란?

  • 연속된 메모리 공간을 참조하는 경량 뷰
  • 소유권 없이 데이터를 참조만 함
  • 배열, vector, array 등을 통일된 인터페이스로 다룸

기본 사용법

#include <span>

void print(std::span<int> data)  // 배열, vector, array 모두 받음
{
    for (int val : data)
    {
        std::cout << val << " ";
    }
    std::cout << std::endl;
}

int main()
{
    // C 배열
    int arr[] = {1, 2, 3, 4, 5};
    print(arr);
    
    // std::array
    std::array<int, 5> stdArr{1, 2, 3, 4, 5};
    print(stdArr);
    
    // std::vector
    std::vector<int> vec{1, 2, 3, 4, 5};
    print(vec);
    
    return 0;
}

std::span의 구조

template<typename T>
class span
{
    T* ptr;      // 데이터 시작 주소 (8 bytes)
    size_t size; // 원소 개수 (8 bytes)
};

// sizeof(std::span<int>) == 16 bytes

메모리 레이아웃

Vector:
┌──────────┐
│ vec      │ (스택)
│ ptr ─────┼───────→ [1][2][3][4][5] (힙)
│ size: 5  │
│ cap: 5   │
└──────────┘

Span:
┌──────────┐
│ span     │ (스택)
│ ptr ─────┼───────→ [1][2][3][4][5] (vec의 데이터를 참조)
│ size: 5  │
└──────────┘
  ↑ 소유권 없음!

주의사항: Dangling Span

std::span<int> createSpan()
{
    std::vector<int> vec{1, 2, 3, 4, 5};
    return std::span{vec};  // ❌ 위험!
}  // vec 소멸

int main()
{
    auto sp = createSpan();
    // sp는 이미 소멸된 vec의 메모리를 가리킴 (Dangling)
    std::cout << sp[0] << std::endl;  // ❌ Undefined Behavior
    
    return 0;
}

재할당 시 문제

std::vector<int> vec{1, 2, 3};
std::span<int> sp{vec};

std::cout << sp[0] << std::endl;  // ✅ 1

vec.push_back(4);
vec.push_back(5);  // capacity 부족 → 재할당!

// sp는 여전히 옛날 메모리를 가리킴
std::cout << sp[0] << std::endl;  // ❌ Undefined Behavior

안전한 사용법

void process(std::span<int> data)
{
    // data 사용
    for (int& val : data)
    {
        val *= 2;
    }
}  // span 소멸, 하지만 원본 데이터는 유지

int main()
{
    std::vector<int> vec{1, 2, 3, 4, 5};
    
    // ✅ 안전: process 내부에서만 사용
    process(vec);
    
    // vec는 여전히 유효
    for (int val : vec)
    {
        std::cout << val << " ";  // 2 4 6 8 10
    }
    
    return 0;
}

std::span의 장점
1. 통일된 인터페이스

  • 배열, vector, array를 동일하게 처리
  • 함수 오버로딩 불필요
  1. 효율적

    • 복사 비용 없음 (16 bytes만)
    • 소유권 없이 참조만
  2. 부분 뷰 생성

    std::vector<int> vec{1, 2, 3, 4, 5};
    
    std::span<int> all{vec};                    // 전체
    std::span<int> first3 = all.first(3);       // 처음 3개
    std::span<int> last2 = all.last(2);         // 마지막 2개
    std::span<int> middle = all.subspan(1, 3);  // 중간 3개

std::span vs const T&

// ❌ 배열은 받을 수 없음
void process(const std::vector<int>& data) { }

int arr[] = {1, 2, 3};
// process(arr);  // 컴파일 에러

// ✅ 배열도 받을 수 있음
void process(std::span<int> data) { }
process(arr);  // OK

🔸 핵심 정리

배열과 컨테이너 선택 가이드

  • C 배열: 레거시 코드나 성능이 극도로 중요한 경우만
  • C++ 동적 배열 (new/delete): ❌ 사용하지 말 것 (std::vector 사용 권장)
  • std::array: 컴파일 타임 고정 크기, 스택 할당 필요시
  • std::vector: 대부분의 경우 이것을 사용 (권장)

다차원 배열 vs 벡터

  • 배열: 연속 메모리 (캐시 효율 좋음)
  • 벡터: 분산 메모리 (유연함)
  • Wrapper로 연속 메모리 유지 가능

캐시 최적화

  • Inner loop는 연속된 메모리 접근
  • Row-major 순서 (행 우선)
  • 10배 이상 성능 차이 가능

std::deque

  • 양쪽 끝 O(1) 삽입/삭제
  • 재할당 시 복사 불필요
  • 두 번의 포인터 역참조, 캐시 비효율
  • 실무에서 거의 사용 안 함

push_back vs emplace_back

  • 복잡한 객체: emplace_back 권장
  • 간단한 타입: 둘 다 OK
  • C++17 이후 성능 차이 줄어듦
  • 일관성 있게 사용

std::span (C++20)

  • 연속 메모리 참조 뷰
  • 소유권 없음 (16 bytes)
  • Dangling 주의
  • 재할당 시 무효화

<참고 자료>

코드없는 프로그래밍
C++ std::array
C++ std::deque
C++ std::span

0개의 댓글