C++ 정적 초기화 순서 문제(Static Initialization Order Fiasco): 재현과 함수 내 static·constinit 해결
이 글의 핵심
서로 다른 .cpp 파일의 전역 객체는 어느 쪽이 먼저 초기화될지 표준이 정하지 않아서, 링크 순서만 바꿔도 결과가 달라지는 Static Initialization Order Fiasco가 생깁니다. 직접 재현한 예제로 원인을 보이고, 함수 내 static(Meyers Singleton), inline constexpr, C++20 constinit, 명시적 초기화, Nifty Counter까지 해결책을 비교하며, 소멸 순서 문제와 멤버 초기화 순서도 함께 정리합니다.
들어가며: “전역 변수를 사용했더니 프로그램이 크래시해요”
C++에서 서로 다른 파일의 전역 변수를 사용하면, 초기화 순서가 정해지지 않아 초기화되지 않은 변수를 사용하는 Static Initialization Order Fiasco가 발생할 수 있습니다.
// ❌ file1.cpp
std::vector<int> globalVec = {1, 2, 3};
// ❌ file2.cpp
extern std::vector<int> globalVec;
int globalSize = globalVec.size(); // ❌ globalVec이 초기화 안 됐을 수 있음!
int main() {
std::cout << globalSize << '\n'; // 0 또는 쓰레기 값
}
이 글은 서로 다른 번역 단위의 전역 변수가 왜 초기화 순서를 보장받지 못하는지부터 설명하고, 같은 파일과 다른 파일 사이의 초기화 규칙, 함수 내 정적 지역 변수로 바꾸는 해결책, C++20 constinit, Meyer의 Singleton까지 차례로 다룹니다.
Static Initialization Order Fiasco란?
문제 발생
// config.cpp
#include <string>
std::string configPath = "/etc/config.txt";
// logger.cpp
#include <fstream>
extern std::string configPath;
std::ofstream logFile(configPath); // ❌ configPath가 초기화 안 됐을 수 있음!
// main.cpp
int main() {
logFile << "Hello\n"; // ❌ 크래시 또는 잘못된 파일 경로
}
문제:
configPath와logFile의 초기화 순서가 정해지지 않음logFile이 먼저 초기화되면 빈 문자열로 파일을 열려고 함- 크래시 또는 잘못된 동작
초기화 순서 규칙
규칙 1: 같은 번역 단위 내
// file.cpp
int a = 10;
int b = a + 5; // ✅ a가 먼저 초기화됨 (선언 순서)
int main() {
std::cout << b << '\n'; // 15
}
규칙: 같은 파일 내에서는 선언 순서대로 초기화.
규칙 2: 다른 번역 단위 간
// file1.cpp
int x = 10;
// file2.cpp
extern int x;
int y = x + 5; // ❌ x가 초기화 안 됐을 수 있음!
규칙: 다른 파일 간에는 순서가 정해지지 않음.
직접 재현: 링크 순서만 바꿨을 뿐인데
// config.cpp
#include <string>
std::string configName = std::string("production-") + "server"; // 동적 초기화
// logger.cpp
#include <cstdio>
#include <string>
extern std::string configName;
struct Logger {
std::string prefix;
Logger() : prefix("[" + configName + "] ") {} // 다른 파일의 전역에 의존
};
Logger logger;
int main() { std::printf("prefix='%s'\n", logger.prefix.c_str()); }
$ g++ config.cpp logger.cpp -o a && ./a
prefix='[] ' # configName이 아직 생성되기 전에 읽힘
$ g++ logger.cpp config.cpp -o b && ./b
prefix='[production-server] '
MinGW GCC 10.3에서 실제로 나온 결과입니다. 코드는 한 글자도 바꾸지 않고 링크 명령의 파일 순서만 바꿨는데 결과가 달라졌습니다. 첫 번째 경우 logger의 생성자가 아직 생성되지 않은 std::string을 읽었고, 이건 정의되지 않은 동작이라 빈 문자열이 나온 것은 운이 좋은 편이고 크래시가 날 수도 있습니다. 빌드 시스템이 파일을 정렬하는 방식이 바뀌거나(CMake 버전 업, 파일 추가) 정적 라이브러리로 묶는 순간 순서가 바뀌므로, “잘 되던 프로그램이 빌드 설정만 바꿨더니 시작하자마자 죽는다”는 형태로 나타납니다.
어느 링크 순서가 “안전한 쪽”인지도 외울 수 없습니다. 링커와 플랫폼이 전역 생성자 목록을 모으는 방식(예전의 .ctors 섹션과 요즘의 .init_array)에 따라 링크 순서대로 실행되기도 하고 역순이 되기도 하며, 공유 라이브러리(.so, .dll)가 섞이면 라이브러리 로드 순서까지 끼어듭니다. 그래서 “링크 순서를 맞춰서 고쳤다”는 수정은 다음 툴체인 업그레이드 때 다시 깨질 가능성이 높습니다. 제가 이 문제를 처음 만났을 때도 원인은 코드가 아니라 새로 추가한 .cpp 파일 하나가 빌드 목록의 정렬 순서를 바꾼 것이었고, 디버거로 보면 main에 들어가기도 전에 스택 트레이스가 __libc_csu_init이나 _GLOBAL__sub_I_... 같은 낯선 이름 아래에서 끝나 있었습니다. 크래시 스택에 _GLOBAL__sub_I_<파일명>이 보인다면 그 파일의 전역 생성자 안에서 죽은 것이므로, 그 생성자가 다른 파일의 무엇을 읽는지부터 확인하면 됩니다.
이런 버그는 재현이 불안정해서 코드 리뷰만으로 찾기 어려운데, AddressSanitizer에 전용 검사가 있습니다. Linux에서 -fsanitize=address로 빌드하고 ASAN_OPTIONS=check_initialization_order=1 ./a로 실행하면, 동적 초기화가 끝나지 않은 다른 번역 단위의 전역을 읽는 순간 ERROR: AddressSanitizer: initialization-order-fiasco를 보고하고 읽은 위치와 변수 이름을 알려 줍니다. 현재 링크 순서에서는 우연히 괜찮은 경우까지 잡으려면 strict_init_order=1을 함께 켭니다. 새 코드가 전역 생성자를 늘리지 않도록 막고 싶다면 Clang의 -Wglobal-constructors 경고가 동적 초기화가 필요한 전역마다 경고를 냅니다.
초기화의 세 단계
전역·정적 변수는 세 단계로 초기화됩니다.
- 0 초기화: 프로그램이 시작될 때 모든 정적 저장소는 먼저 0으로 채워집니다. 위 예제에서 빈 문자열이 보인 이유이기도 합니다(구현에 따라 다름).
- 상수 초기화: 초기화식이 상수 식이면(
int n = 42;,constexpr생성자) 컴파일 타임에 값이 정해져 실행 파일에 들어갑니다. 순서 문제가 없습니다. - 동적 초기화: 그 밖의 경우(함수 호출,
std::string생성 등)는main전에 실행되는데, 같은 파일 안에서는 선언 순서대로지만 파일 사이의 순서는 정해져 있지 않습니다. Fiasco는 전부 이 단계에서 생깁니다.
따라서 해결책은 두 방향입니다. 전역을 3단계(동적)에서 2단계(상수)로 옮기거나(constexpr, constinit), 초기화 시점을 main 이전의 불확정한 순간에서 처음 사용할 때로 미루는 것(함수 내 static)입니다.
해결책: 함수 내 정적 지역 변수
해결책 1: 함수로 감싸기
// config.cpp
#include <string>
std::string& getConfigPath() {
static std::string configPath = "/etc/config.txt";
return configPath;
}
// logger.cpp
#include <fstream>
std::ofstream& getLogFile() {
static std::ofstream logFile(getConfigPath()); // ✅ 첫 호출 시 초기화
return logFile;
}
// main.cpp
int main() {
getLogFile() << "Hello\n"; // ✅ 안전
}
장점:
- 첫 호출 시 초기화 (Lazy Initialization)
- C++11부터 스레드 안전 (Magic Statics)
- 초기화 순서 문제 해결
이 방법이 순서 문제를 해결하는 원리는 “의존하는 쪽이 필요한 순간에 직접 호출한다”는 데 있습니다. getLogFile()이 getConfigPath()를 부르는 순간 configPath가 아직 없다면 그 자리에서 만들어지므로, 링크 순서와 상관없이 의존 관계대로 생성됩니다. 대가도 있습니다. 컴파일러는 호출할 때마다 “이미 초기화되었는가”를 확인하는 가드 변수를 검사하는데, 초기화 이후에는 대개 원자적 읽기 한 번이라 비용이 작지만 아주 뜨거운 루프에서 반복 호출한다면 참조를 지역 변수로 한 번 받아 두는 편이 낫습니다.
조심할 함정은 재귀적 초기화입니다. getA()의 static 초기화 도중에 getB()를 부르고, getB()의 초기화가 다시 getA()를 부르면 순환 의존이 됩니다. 표준은 이를 정의되지 않은 동작으로 두고, 실제로는 libstdc++에서 __gnu_cxx::recursive_init_error 예외가 던져지거나 스레드 안전 초기화의 잠금 때문에 교착 상태(프로그램이 멈춤)로 나타납니다. 함수 내 static으로 바꿨는데 시작 시점에 프로그램이 조용히 멈춘다면 이 순환을 의심하세요. 초기화 순서 문제를 “숨기는” 것이 아니라 의존 관계를 드러내는 것이므로, 순환이 있다면 설계를 나눠야 합니다.
해결책 2: 상수 초기화로 옮기기 (constexpr, constinit)
// config.h — 헤더에 정의 (C++17 inline 변수)
inline constexpr const char* configPath = "/etc/config.txt";
// logger.cpp
#include "config.h"
std::ofstream logFile(configPath); // ✅ configPath는 컴파일 타임에 이미 값이 있음
예전 예제에 자주 보이는 extern constexpr const char* configPath;는 컴파일되지 않습니다(GCC 10: declaration of 'constexpr' variable 'configPath' is not a definition). constexpr 변수는 선언할 때 반드시 값을 줘야 하므로, 여러 파일에서 쓰려면 C++17의 inline constexpr로 헤더에 정의합니다. logFile은 여전히 동적 초기화지만, 의존하는 쪽(configPath)이 상수 초기화라 순서 문제가 없습니다.
C++20의 constinit은 “이 변수는 반드시 상수 초기화되어야 한다”를 컴파일러에게 검사시키는 키워드입니다.
constexpr int square(int x) { return x * x; }
constinit int tableSize = square(16); // ✅ 상수 초기화 보장
int compute();
constinit int bad = compute(); // ❌ error: 'constinit' variable 'bad' does not have a constant initializer
constexpr과 달리 constinit 변수는 런타임에 값을 바꿀 수 있습니다. “시작 값은 컴파일 타임에 정해지지만 이후에는 바뀌는 전역”(카운터, 기본 설정값)을 Fiasco 걱정 없이 만들 수 있고, 누군가 초기화식을 함수 호출로 바꾸면 컴파일 에러로 바로 알 수 있습니다.
해결책 3: 명시적 초기화 함수와 Nifty Counter
전역 객체들의 초기화 순서를 코드로 직접 정하고 싶다면 main 첫 줄에서 초기화 함수를 부르는 방법이 가장 단순합니다(std::optional<T> 전역을 두고 emplace하면 수동 new/delete도 필요 없습니다). 대신 초기화 호출을 빼먹으면 크래시하고, main 이전에 실행되는 다른 전역 생성자에서는 쓸 수 없습니다.
라이브러리처럼 main을 통제할 수 없을 때 쓰는 고전 기법이 Nifty Counter(Schwarz Counter)입니다. std::cout이 다른 파일의 전역 생성자 안에서도 안전하게 쓰이는 것이 이 방식 덕분입니다.
// logger.h — 이 헤더를 포함하는 모든 번역 단위에 초기화 객체가 하나씩 생김
struct LoggerInit { LoggerInit(); ~LoggerInit(); };
static LoggerInit loggerInit; // ← 반드시 헤더에 있어야 함
Logger& logger();
// logger.cpp
static int counter; // 0 초기화(1단계)라 어떤 동적 초기화보다 먼저 0
alignas(Logger) static unsigned char storage[sizeof(Logger)];
LoggerInit::LoggerInit() { if (counter++ == 0) new (storage) Logger(); }
LoggerInit::~LoggerInit() { if (--counter == 0) reinterpret_cast<Logger*>(storage)->~Logger(); }
Logger& logger() { return *std::launder(reinterpret_cast<Logger*>(storage)); }
핵심은 static LoggerInit loggerInit;이 헤더에 있어서, logger.h를 포함한 파일의 전역들보다 그 파일 안에서 먼저 생성된다는 점입니다(같은 파일 안은 선언 순서). 초기화 객체를 .cpp에만 두면 이 보장이 사라지는데, 인터넷의 예제에도 그렇게 잘못 적힌 경우가 있습니다. 구현이 까다로워 새 코드라면 함수 내 static을 먼저 고려합니다.
방법 비교
| 방법 | 순서 문제 | 비용 | 적합한 경우 |
|---|---|---|---|
| 함수 내 static (Meyers) | 해결 | 첫 호출 시 초기화 + 스레드 안전 검사 | 대부분의 전역 객체 (기본 선택) |
inline constexpr / constinit | 없음 (컴파일 타임) | 없음 | 상수, 상수로 시작하는 전역 |
main에서 명시적 초기화 | 해결 | 없음 | 애플리케이션 코드, 초기화 순서를 드러내고 싶을 때 |
| Nifty Counter | 해결 | 헤더를 포함할 때마다 초기화 객체 | main을 통제할 수 없는 라이브러리 |
소멸 순서와 멤버 초기화 순서
전역 객체의 소멸은 생성의 역순이라, 생성 순서가 불확정이면 소멸 순서도 불확정입니다. 한 전역의 소멸자에서 다른 파일의 전역(로거 등)을 쓰면 이미 파괴된 객체에 접근할 수 있습니다. 함수 내 static도 이 문제는 그대로 남으므로(아래 FAQ), 종료 시점에 로그가 꼭 필요한 로거는 일부러 해제하지 않는 static Logger& l = *new Logger; 형태(누수처럼 보이지만 프로세스 종료 때 OS가 회수)를 쓰기도 합니다.
같은 이름 때문에 혼동되는 것이 클래스 멤버 초기화 순서입니다. 멤버는 생성자 초기화 리스트에 적은 순서가 아니라 클래스 안에서 선언된 순서로 초기화되고, 베이스 클래스가 멤버보다 먼저입니다.
class Buffer {
int* data; // 먼저 선언됨 → 먼저 초기화됨
int size;
public:
Buffer(int n) : size(n), data(new int[size]) {} // ❌ data를 만들 때 size는 아직 쓰레기값
};
초기화 리스트만 보면 size를 먼저 정한 것 같지만 실제로는 data가 먼저라 new int[size]가 초기화되지 않은 값을 씁니다. 선언 순서를 size → data로 바꾸거나 new int[n]처럼 매개변수를 직접 쓰면 됩니다. GCC·Clang의 -Wreorder(-Wall에 포함)가 이 불일치를 경고합니다.
Singleton 패턴
Meyer의 Singleton (권장)
class Logger {
private:
Logger() {
file_.open("/var/log/app.log");
}
std::ofstream file_;
public:
// 복사·이동 금지
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
static Logger& getInstance() {
static Logger instance; // ✅ 첫 호출 시 초기화 (스레드 안전)
return instance;
}
void log(const std::string& msg) {
file_ << msg << '\n';
}
};
int main() {
Logger::getInstance().log("Hello"); // ✅ 안전
}
장점:
- 스레드 안전 (C++11 이후)
- 초기화 순서 문제 없음
- Lazy Initialization
해제하지 않는 Singleton (트레이드오프가 있는 선택)
class Logger {
// ...
};
Logger& getLogger() {
static Logger* instance = new Logger(); // 의도적으로 delete하지 않음
return *instance;
}
특징:
- 소멸자가 호출되지 않으므로, 버퍼를 비우거나 파일을 닫는 정리 작업이 소멸자에 있다면 그 작업이 일어나지 않음
- 대신 다른 전역 객체의 소멸자에서 호출해도 이미 파괴된 객체에 접근할 일이 없음
- 누수 검사 도구(Valgrind, LeakSanitizer)에는 “still reachable”로 보고될 수 있음
이 형태를 무조건 잘못된 코드로 볼 필요는 없습니다. 앞의 “소멸 순서” 절에서 말했듯이, 종료 과정의 다른 전역 소멸자에서도 쓰여야 하는 로거나 할당자 같은 객체는 일부러 이렇게 만드는 경우가 많고, Google C++ 스타일 가이드도 소멸자가 사소하지 않은(trivially destructible이 아닌) 전역 객체를 금지하고 이런 “해제하지 않는” 함수 내 static 포인터를 대안으로 제시합니다. 프로세스가 끝나면 메모리는 OS가 회수하므로 실제 누수는 아닙니다. 다만 소멸자에서 파일 플러시처럼 꼭 필요한 일을 한다면 이 방식은 맞지 않으므로, 그런 정리는 main 끝이나 std::atexit에 등록한 함수에서 명시적으로 하는 편이 안전합니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 함수 내 static 지역 변수로 바꾸면 소멸 순서 문제도 함께 해결되나요?
A. 초기화 순서 문제는 첫 호출 시점에 초기화되므로 해결되지만, 소멸은 프로그램 종료 시 생성의 역순으로 일어나기 때문에 소멸 순서 문제는 남습니다. 예를 들어 다른 전역 객체의 소멸자가 이미 파괴된 Meyer의 Singleton에 접근하면 종료 시점에 크래시가 날 수 있습니다. 종료 시 정리가 필요 없는 객체라면 static T* p = new T;처럼 의도적으로 해제하지 않는 방식을 쓰거나, 의존하는 쪽이 생성자에서 먼저 싱글톤을 호출해 생성 순서를 맞추는 방법이 있습니다.