C++/Concepts

std::vector reallocation과 noexcept move constructor

Enchantée 2026. 7. 27. 09:00
728x90
반응형

C++에서 move constructor를 작성할 때 noexcept를 붙여야 하는지 고민되는 순간이 있습니다.

단순히 “예외를 던지지 않으니까 붙인다” 정도로만 이해하면 std::vector 같은 container에서 중요한 성능 차이를 놓칠 수 있습니다.

std::vector는 capacity가 부족해져 reallocation이 발생하면 기존 element를 새 memory buffer로 옮겨야 합니다.

이때 move constructor가 noexcept인지 여부는 기존 element를 move할지 copy할지 결정하는 중요한 힌트가 됩니다.

 

std::vector reallocation에서 noexcept move constructor는 “이 객체는 안전하게 move해도 된다”는 신호가 됩니다.

 

move constructor의 noexcept 여부는 std::vector가 reallocation 중 기존 element를 옮기는 방식에 영향을 줄 수 있습니다.

 


1. 들어가며

std::move를 배웠다면 move가 copy보다 항상 빠르다고 생각하기 쉽습니다.

하지만 standard container는 성능만 보지 않습니다.

Container는 element를 옮기는 중 예외가 발생했을 때 기존 상태를 얼마나 안전하게 유지할 수 있는지도 고려해야 합니다.

특히 std::vector는 연속된 memory를 사용하기 때문에 capacity가 부족해지면 새 buffer를 만들고 기존 element를 새 buffer로 옮깁니다.

이 과정에서 move가 예외를 던질 수 있다면, container 입장에서는 copy가 더 안전한 선택일 수 있습니다.

 

구분 의미 std::vector reallocation에서의 영향
copy constructor 기존 객체를 보존한 채 새 객체를 만듦 move가 위험하다고 판단되면 선택될 수 있음
move constructor 기존 객체의 resource를 새 객체로 이전 빠를 수 있지만 예외 가능성이 있으면 container가 조심함
noexcept move constructor move 중 예외를 던지지 않는다고 선언 기존 element 재배치에서 move가 선택되기 쉬움
reallocation capacity 부족으로 새 buffer를 할당하고 element를 옮기는 과정 많은 element에서 copy/move 차이가 누적됨

이 글에서는 C++17 기준으로 noexcept move constructor가 왜 중요한지, std::vector reallocation에서 어떤 차이를 만드는지 예제 코드로 확인해보겠습니다.

 


2. 왜 중요한가?

실무에서 class가 string, vector, unique_ptr, file handle, socket 같은 resource를 들고 있다면 move constructor를 직접 작성하거나 compiler-generated move에 의존하게 됩니다.

이때 move가 실제로 빠르게 사용되려면 container가 그 move를 믿고 사용할 수 있어야 합니다.

  1. std::vector는 reallocation 때 기존 element를 새 buffer로 옮깁니다.
  2. move constructor가 noexcept이면 기존 element를 move해도 rollback 위험이 작습니다.
  3. move constructor가 noexcept가 아니고 copy가 가능하면 copy가 선택될 수 있습니다.
  4. element 수가 많고 copy 비용이 크면 reallocation 비용 차이가 커집니다.
  5. 성능뿐 아니라 exception safety를 코드로 표현한다는 점에서도 중요합니다.

여기서 중요한 점은 noexcept가 단순한 최적화 장식이 아니라는 것입니다.

noexcept는 library에게 “이 move는 실패하지 않는다”는 contract를 제공하고, container는 그 contract를 바탕으로 더 공격적인 move 전략을 선택할 수 있습니다.

 


3. 핵심 개념

std::vector는 element를 연속된 memory에 저장합니다.

capacity가 부족한 상태에서 새 element가 들어오면 더 큰 memory buffer를 만들고, 기존 element를 새 buffer로 옮긴 뒤 기존 buffer를 정리합니다.

이때 기존 element를 move하다가 예외가 발생하면 vector는 중간 상태를 복구해야 합니다.

Copy는 원본 element를 그대로 둔 채 새 객체를 만들기 때문에 rollback 관점에서 더 다루기 쉬운 경우가 있습니다.

 

조건 container가 보기 쉬운 선택 이유
move constructor가 noexcept move move 중 예외가 없다고 선언되어 rollback 부담이 작음
move constructor가 noexcept가 아님 copy copy가 가능하면 기존 element를 보존하는 쪽이 안전할 수 있음
copy constructor가 없음 move copy 선택지가 없으므로 move를 사용할 수밖에 없음
trivial type 또는 작은 type 차이가 작을 수 있음 copy/move 비용보다 allocation 비용이 더 클 수 있음

실제 standard library 구현은 이런 판단을 위해 std::move_if_noexcept 계열의 전략을 사용합니다.

즉, move가 noexcept이면 move하고, 그렇지 않고 copy가 가능하면 copy를 선택할 수 있습니다.

그래서 move constructor를 직접 작성했다면 noexcept를 붙일 수 있는지 반드시 검토해야 합니다.

 


4. 예제 코드

아래 예제는 move constructor에 noexcept가 없는 타입과 있는 타입을 비교합니다.

std::vector에 element 하나를 넣고 capacity를 1로 고정한 뒤, 두 번째 element를 넣어 reallocation을 강제로 발생시킵니다.

#include <iostream>
#include <string>
#include <utility>
#include <vector>

struct RiskyMove {
    static int copies;
    static int moves;

    std::string value;

    explicit RiskyMove(std::string text)
        : value(std::move(text))
    {
    }

    RiskyMove(const RiskyMove& other)
        : value(other.value)
    {
        ++copies;
    }

    RiskyMove(RiskyMove&& other)
        : value(std::move(other.value))
    {
        ++moves;
    }
};

int RiskyMove::copies = 0;
int RiskyMove::moves = 0;

struct SafeMove {
    static int copies;
    static int moves;

    std::string value;

    explicit SafeMove(std::string text)
        : value(std::move(text))
    {
    }

    SafeMove(const SafeMove& other)
        : value(other.value)
    {
        ++copies;
    }

    SafeMove(SafeMove&& other) noexcept
        : value(std::move(other.value))
    {
        ++moves;
    }
};

int SafeMove::copies = 0;
int SafeMove::moves = 0;

template <typename T>
void trigger_reallocation()
{
    std::vector<T> items;
    items.reserve(1);

    items.emplace_back("first");
    items.emplace_back("second");
}

int main()
{
    trigger_reallocation<RiskyMove>();
    std::cout << "RiskyMove copies: " << RiskyMove::copies
              << ", moves: " << RiskyMove::moves << '\n';

    trigger_reallocation<SafeMove>();
    std::cout << "SafeMove copies: " << SafeMove::copies
              << ", moves: " << SafeMove::moves << '\n';
}

 

실행 결과

RiskyMove copies: 1, moves: 0
SafeMove copies: 0, moves: 1

 

RiskyMove는 move constructor가 있지만 noexcept가 아닙니다.

그래서 reallocation 중 기존 element를 옮길 때 copy가 선택되었습니다.

SafeMove는 move constructor가 noexcept이므로 기존 element가 copy되지 않고 move되었습니다.

 


5. 실행 결과에서 봐야 할 것

예제의 핵심은 새로 들어오는 second element가 아니라 기존에 있던 first element입니다.

items.reserve(1) 이후 items.emplace_back("first")를 호출하면 capacity가 1인 vector에 element 하나가 들어갑니다.

다음 items.emplace_back("second")에서 capacity가 부족해져 reallocation이 발생하고, 기존 first element를 새 buffer로 옮겨야 합니다.

 

상태 변화

reserve(1)
capacity 1 확보

emplace_back("first")
기존 buffer에 first 생성

emplace_back("second")
capacity 부족
새 buffer 할당
기존 first element를 새 buffer로 재배치

RiskyMove
move constructor가 noexcept가 아님
copy constructor가 있으므로 copy 선택

SafeMove
move constructor가 noexcept
기존 element를 move

 

결과가 보여주는 것은 std::move 문법 자체가 아니라 container의 선택입니다.

move constructor가 존재해도 noexcept가 아니면 vector reallocation에서 copy가 선택될 수 있습니다.

 


6. 그림으로 이해하기

std::vector는 reallocation 중 기존 element를 새 buffer로 옮길 때 exception safety를 고려해 copy 또는 move를 선택할 수 있습니다.

 

그림에서 중요한 분기점은 move constructor가 noexcept인지 여부입니다.

noexcept가 없다면 container는 move 중 예외 가능성을 고려해야 하고, copy가 가능하면 copy 경로를 선택할 수 있습니다.

noexcept가 있다면 기존 element를 move해도 실패하지 않는다는 contract가 생기므로 reallocation 비용을 줄일 수 있습니다.

 


7. 자주 하는 오해

  1. move constructor가 있으면 std::vector는 항상 move한다.
    move constructor가 있어도 noexcept가 아니고 copy가 가능하면 reallocation에서 copy가 선택될 수 있습니다.
  2. noexcept는 성능과 관계없는 예외 명세일 뿐이다.
    noexcept는 library가 안전한 move를 선택할 수 있게 만드는 중요한 정보입니다.
  3. std::move를 쓰면 무조건 move가 발생한다.
    std::move는 cast입니다. 실제로 어떤 constructor가 호출되는지는 overload resolution과 container의 내부 전략에 따라 달라집니다.
  4. move가 copy보다 항상 빠르다.
    작은 type이나 trivial type에서는 차이가 거의 없을 수 있습니다. 하지만 resource를 가진 큰 object에서는 차이가 커질 수 있습니다.
  5. noexcept는 아무 move constructor에나 붙이면 된다.
    정말 예외를 던지지 않을 때만 붙여야 합니다. noexcept 함수에서 예외가 밖으로 나가면 std::terminate가 호출됩니다.

noexcept는 compiler와 standard library에게 보내는 약속입니다.

약속할 수 없는 경우에는 붙이면 안 되지만, 약속할 수 있는 move constructor라면 붙이지 않는 것도 비용이 될 수 있습니다.

 


8. 실무에서는 어떻게 볼까?

실무에서는 custom type을 container에 많이 넣는지부터 봐야 합니다.

std::vector<T>에 T가 많이 들어가고, T가 string, vector, unique_ptr, buffer 같은 resource를 갖고 있다면 move constructor의 noexcept 여부를 확인할 가치가 큽니다.

특히 성능 문제가 있는 code path에서 vector growth가 자주 발생한다면 copy/move count를 측정해보는 것이 좋습니다.

 

코드 리뷰 질문 확인할 내용 권장 방향
move constructor를 직접 작성했는가? 내부 member들의 move가 예외를 던질 수 있는지 확인 가능하면 noexcept 명시
std::vector에 많이 들어가는 type인가? reallocation이 자주 발생하는지 확인 reserve와 noexcept move를 함께 검토
copy 비용이 큰가? string, buffer, nested container, handle 보유 여부 확인 move 경로가 실제로 사용되는지 측정
member type의 move가 noexcept인가? 직접 작성하지 않은 member들의 noexcept 상태 확인 defaulted move constructor의 noexcept 추론 활용
noexcept를 거짓으로 선언했는가? 예외 가능성이 있는데 noexcept를 붙였는지 확인 잘못된 noexcept는 std::terminate 위험

좋은 기본 전략은 직접 move constructor를 작성하지 않아도 되는 구조를 먼저 만드는 것입니다.

Compiler-generated move constructor가 member들의 noexcept 상태를 바탕으로 적절히 noexcept를 추론할 수 있기 때문입니다.

직접 작성해야 한다면 함수 body에서 예외가 나갈 수 없는지 확인하고 noexcept를 명시하는 편이 좋습니다.

 


9. 정리

std::vector는 reallocation 때 기존 element를 새 buffer로 옮겨야 합니다.

move constructor가 noexcept이면 vector가 기존 element를 move하기 쉬워집니다.

move constructor가 noexcept가 아니고 copy가 가능하면 copy가 선택될 수 있습니다.

noexcept는 성능 최적화 힌트이면서 exception safety contract입니다.

Container에 많이 들어가는 custom type이라면 move constructor의 noexcept 여부를 코드 리뷰에서 확인해야 합니다.

 


728x90
반응형