std::initializer_list 사용시 주의점

Shell·2026년 4월 24일

std::initializer_list란?

C++의std::initializer_list는 C++11에 추가된 클래스로, 같은 타입의 여러 값을 중괄호 리스트로 받기 위한 프록시 객체다. 주된 용도는 함수 인자다.

template<class T>
struct S
{
    std::vector<T> v;
    
    S(std::initializer_list<T> l) : v(l)
    {
         std::cout << "constructed with a " << l.size() << "-element list\n";
    }
}
S s{ 1, 2, 3, 4 };

컨테이너 생성자에 { 1, 2, 3 } 처럼 쓸 수 있는 것도 이 클래스 덕분이다. 단, 이름에서 풍기는 것과 달리 initializer_list는 타입을 저장하는 배열 그 자체가 아니라 배열을 가리키는 뷰다. 즉, 데이터를 소유하지 않는다.

cpptemplate<typename T>
class initializer_list {
    const T* _begin; // 스택에 생성된 임시 배열을 가리키는 포인터
    size_t   _size;
};

std::initializer_list의 내부 구현은 대략 이렇다.
이 데이터를 소유하지 않는다는 점을 모르고 사용했다가 이번에 정말 골치 아픈 버그를 마주쳤다.

이하 내용은 ShellEngine의 코드를 수정하던 중 initializer_list를 잘못 사용해 발생하던 버그 내용이다.

겪었던 버그

렌더링 패스에서 버퍼의 usage를 필터링하는 코드가 있었다.

std::initializer_list<UniformStructLayout::Usage> usages = {
    UniformStructLayout::Usage::Camera,
    UniformStructLayout::Usage::Material
};

if (bPerObject)
    usages = { UniformStructLayout::Usage::Object };

// ...

if (std::find(usages.begin(), usages.end(), uniformLayout.usage) == usages.end())
    continue;

디버그 빌드에서는 아무 문제 없이 동작했는데, 릴리즈 빌드에서는 bPerObject가 true인 경우에도 usages가 어떤 값도 매칭하지 못하고 전부 continue 로 빠지는 버그였다. 그래서 디버깅을 시작했다.

디버깅 과정

1단계: RelWithDebInfo로 재현

릴리즈 빌드는 코드가 최적화 되고 디버그 정보를 남기지 않기 때문에 바로 원인을 추적하기는 어렵다.
먼저 CMake 설정을 다음과 같이 바꿔서 최적화는 유지하되 디버그 심볼을 포함한 빌드를 만들었다.

"cacheVariables": {
    "CMAKE_BUILD_TYPE": "RelWithDebInfo"
}

이 상태에서 중단점을 찍어보니 bPerObject == true인 경우 std::find가 항상 end()를 반환하는 것이 보였다.
usages가 { Object }를 담고 있어야 하는데, 무언가 이상한 값을 가리키고 있었다.

2단계: 로깅으로 범위 좁히기

중단점만으로는 값이 불규칙하게 보여서 로깅을 추가했다.

const uint32_t set = static_cast<uint32_t>(uniformLayout.usage);
SH_INFO_FORMAT("usage: {}", set);

Object에 해당하는 값인 1이 단 한 번도 출력되지 않았다.
usages가 { Object }로 재대입된 이후에도, usages 안에 Object가 없는 것처럼 동작하고 있었다.

3단계: usages 자체를 의심

이 시점에서 usages 변수 자체에 문제가 있다고 판단했다. 타입을 std::vector로 바꿔보기로 했다.

std::vector<UniformStructLayout::Usage> usages = {
    UniformStructLayout::Usage::Camera,
    UniformStructLayout::Usage::Material
};

if (bPerObject)
    usages = { UniformStructLayout::Usage::Object };

즉시 정상 동작했다. 범인은 std::initializer_list였다.

왜 이런 일이 생겼나?

제일 위에서 말했듯이, std::initializer_list는 데이터를 소유하지 않는다.
실제 원소들은 컴파일러가 스택에 생성하는 임시 배열에 존재한다.
initializer_list는 그 배열의 주소와 크기를 들고 있는 뷰일 뿐이다.

다시 처음의 코드를 봐보자.

std::initializer_list<Usage> usages = { Camera, Material };
if (bPerObject)
    usages = { Object }; // if문을 벗어나면 스택에서 해제

처음에 usages가 담는 값은 { Camera, Material }로 이루어진 배열의 스택 주소다. 여긴 문제없다.
그런데 bPerObject가 true가 되는 경우 if문 내부에서 usages가 가르키고 있는 값이 { Object }로 바뀐다.

여기서 문제점은, { Object }는 if문을 빠져나가는 즉시 스택 메모리에서 제거된다.
결과적으로 usages는 이미 소멸된 스택 메모리를 가르키게 되는 것이라 UB(Undefined Behavior)다.

그럼 디버그 빌드에서는 왜 작동했지?

MSVC 디버그 빌드는 해제된 스택 메모리를 특정 값으로 채우는 관습이 있다.
이 때문에 댕글링 포인터를 역참조하더라도 그럴듯한 값이 남아있어서 우연히 동작하는 것처럼 보인다.

릴리즈 빌드는 스택을 적극적으로 재사용하므로 임시 배열이 있던 자리가 즉시 다른 데이터로 덮어씌워진다.
이것이 usage 값이 1(Object)로 절대 읽히지 않았던 이유다.

마무리

std::initializer_list는 함수 인자로 읽기 전용으로 받을 때가 사용 목적에 맞는 형태다.

void SetUsages(std::initializer_list<Usage> usages) { ... }

SetUsages({ Camera, Material });

변수에 저장하고 재대입하는 것은 C++ 표준에서 명시적으로 보장하지 않는 동작이다.
컨테이너처럼 재사용할 생각이라면 처음부터 std::vector나 std::array를 쓰는 것이 맞다.

디버그 모드에서는 잘 작동하는데 릴리즈 모드에서는 이상하게 동작하는 경우는, 매우 높은 확률로 이번에 겪은 문제처럼 UB동작 때문일 가능성이 높다.
따라서 어떤 것이 UB인지 제대로 파악하고, 처음부터 그러한 코드를 작성하지 않는 것이 좋을 것 같다.

profile
개발하며 배웠던 것들 기록용 블로그

0개의 댓글