C++ 동적 초기화와 정적 초기화 순서 문제: Meyers 싱글톤·constinit으로 해결하기

이 글의 핵심

컴파일 타임에 끝나는 정적 초기화와 런타임에 실행되는 동적 초기화의 차이, 번역 단위가 다른 전역 객체끼리 초기화 순서가 보장되지 않아 생기는 문제를 정리합니다. 함수 내 static의 스레드 안전 초기화와 constinit·지연 초기화 같은 해결 패턴도 비교합니다.

동적 초기화란?

동적 초기화(dynamic initialization)는 런타임에 함수 호출이나 표현식 평가를 통해 변수를 초기화하는 방법입니다. 초기값이 상수 식이 아닐 때 이 방식으로 초기화됩니다.

int getValue() { return 42; }

int x = getValue();  // 동적 초기화 (런타임)
constexpr int y = 42; // 상수 초기화 (컴파일 타임)

설정 파일이나 환경 변수처럼 실행해 봐야 알 수 있는 값으로 초기화하거나, constexpr이 아닌 생성자를 호출하거나, 다른 변수와 외부 상태에 의존하는 초기화는 모두 동적 초기화가 됩니다.

int port = loadConfigFromFile();  // 파일에서 읽기
std::string name = getUserInput();  // 사용자 입력
Database db("localhost", port);  // 생성자 호출
초기화 방법시점예시
상수 초기화컴파일 타임constexpr int x = 10;
동적 초기화런타임int x = getValue();
0 초기화프로그램 로드static int x;

정적 저장 기간을 가진 변수(전역, 네임스페이스 범위, static 멤버, 함수 안 static)는 두 단계로 초기화됩니다. 먼저 정적 초기화 단계에서 모든 변수가 0으로 채워지거나(0 초기화), 상수 식으로 값이 정해지는 경우 그 값이 들어갑니다(상수 초기화). 이 단계는 실행 파일의 .data/.bss 섹션에 값이 이미 들어 있는 형태라 실행 시간이 전혀 들지 않습니다. 그다음 상수 식으로 정할 수 없는 변수만 동적 초기화 단계에서 실제 코드를 실행해 값을 채웁니다. 함수 안 static의 동적 초기화는 이 단계가 아니라 실행 흐름이 선언을 처음 지날 때 일어납니다. 이 구분이 중요한 이유는 아래에서 볼 초기화 순서 문제가 동적 초기화끼리만 생기기 때문입니다. 상수 초기화된 변수는 어떤 동적 초기화보다도 먼저 값을 가지므로 순서 문제에서 안전합니다. 참고로 표준은 동적 초기화 대상이라도 결과가 같다면 컴파일러가 정적으로 미리 계산해 두는 것을 허용하므로, 위 int x = getValue();는 최적화 빌드에서 실제로는 상수 42로 채워질 수도 있습니다.

정적 변수 초기화

전역 변수, main, 정적 지역 변수의 초기화 순서 실행 결과

그림: 실제 실행 결과 — 전역 변수는 main 시작 전에, 함수 안의 정적 지역 변수는 처음 그 줄을 실행할 때 초기화된다. (환경: macOS 27.0.1 · Apple clang 21.0.0)

int compute() {
    return 42;
}

// 동적 초기화
int global = compute();

void func() {
    static int local = compute();  // 첫 호출 시
}

전역 변수는 보통 main() 실행 전에 초기화되고, 정적 지역 변수는 그 선언을 처음 지나갈 때 초기화됩니다(지연 초기화). 출력 순서로 확인해 보면 다음과 같습니다.

#include <iostream>

int initGlobal() {
    std::cout << "전역 변수 초기화\n";
    return 100;
}

int global = initGlobal();  // main() 전에 실행

void func() {
    static int local = []() {
        std::cout << "정적 지역 변수 초기화\n";
        return 200;
    }();
}

int main() {
    std::cout << "main 시작\n";
    func();  // "정적 지역 변수 초기화"
    func();  // 출력 없음 (이미 초기화됨)
}

// 출력:
// 전역 변수 초기화
// main 시작
// 정적 지역 변수 초기화

그래서 실무에서는 동적 초기화가 필요한 전역 변수를 가능하면 피하고, 꼭 공유 객체가 필요하면 정적 지역 변수로 감싸는 방식을 씁니다.

“전역 변수는 main() 전에 초기화된다”는 설명은 대부분의 구현에서 맞지만, 표준은 조금 더 느슨합니다. 번역 단위의 동적 초기화를 그 번역 단위의 함수나 변수가 처음 사용되기 직전까지 미루는 것도 허용합니다. 주요 컴파일러는 실행 파일에서는 main 전에 초기화하지만, 이 여지 때문에 “다른 파일의 전역 생성자가 반드시 먼저 실행되었을 것”이라는 가정은 표준으로 보장되지 않습니다. 공유 라이브러리(.so, .dll)의 전역은 라이브러리가 로드될 때 초기화되므로, dlopen으로 늦게 로드되는 플러그인의 전역 생성자는 main 한참 뒤에 실행됩니다.

실전 예시

예시 1: 정적 지역 변수

#include <iostream>

class Database {
public:
    Database() {
        std::cout << "DB 연결" << std::endl;
    }
};

Database& getDB() {
    static Database db;  // 첫 호출 시 초기화
    return db;
}

int main() {
    getDB();  // "DB 연결"
    getDB();  // 출력 없음 (이미 초기화됨)
}

함수 지역 static은 실행 흐름이 처음 그 선언을 지나갈 때 초기화됩니다. 이 성질 덕분에 “필요해질 때까지 비싼 초기화를 미룬다”와 “사용하기 전에 반드시 초기화되어 있다”를 동시에 얻을 수 있습니다. 초기화 도중 예외가 발생하면 변수는 초기화되지 않은 상태로 남고, 다음 호출 때 다시 초기화를 시도합니다. DB 연결처럼 실패할 수 있는 초기화를 여기에 두면, 첫 시도가 실패해도 다음 요청에서 재시도되는 자연스러운 동작이 됩니다. 반대로 이 객체는 프로그램 종료 시 main이 끝난 뒤 소멸되므로, 소멸자에서 로그를 남기거나 다른 전역 객체를 쓰면 이미 파괴된 객체를 건드릴 수 있습니다.

예시 2: 싱글톤

#include <iostream>

class Singleton {
    Singleton() {
        std::cout << "생성" << std::endl;
    }
    
public:
    static Singleton& getInstance() {
        static Singleton instance;  // 스레드 안전 (C++11)
        return instance;
    }
};

int main() {
    auto& s1 = Singleton::getInstance();  // "생성"
    auto& s2 = Singleton::getInstance();  // 출력 없음
}

예시 3: 초기화 순서 문제

// file1.cpp
int x = compute1();

// file2.cpp
extern int x;
int y = x + 1;  // x가 초기화 안됐을 수 있음

// ✅ 함수 내 정적 변수로 해결
int& getX() {
    static int x = compute1();
    return x;
}

int y = getX() + 1;  // 순서 보장

이 해결책이 동작하는 이유는 전역 변수 x를 없애고 함수 호출로 바꿨기 때문입니다. 함수는 어느 파일에서 언제 호출되든 처음 호출되는 순간 내부 static을 초기화하므로, 다른 파일의 동적 초기화가 getX()를 부르면 그 자리에서 x가 만들어집니다. 흔히 이 관용구를 “Construct On First Use”라고 부릅니다. 단점은 모든 접근이 함수 호출이 되고, 매 호출마다 “초기화되었는지” 확인하는 가드 검사가 들어간다는 것입니다. 이 검사는 초기화 이후에는 원자적 읽기 한 번 수준이라 대부분 무시할 수 있지만, 아주 뜨거운 루프에서 반복 호출한다면 참조를 지역 변수에 받아 두고 쓰는 편이 좋습니다.

예시 4: 지연 초기화

#include <iostream>

class Resource {
public:
    Resource() {
        std::cout << "Resource 생성" << std::endl;
    }
};

Resource& getResource() {
    static Resource res;  // 첫 사용 시 생성
    return res;
}

int main() {
    const bool needResource = true;
    // Resource 사용 안하면 생성 안 됨
    if (needResource) {
        getResource();
    }
}

스레드 안전성

// C++11: 정적 지역 변수 초기화는 스레드 안전
void func() {
    static int x = compute();  // 한 스레드만 초기화
}

// C++03: 스레드 안전하지 않음

C++11부터 정적 지역 변수 초기화는 스레드 안전합니다. 여러 스레드가 동시에 선언에 도달해도 한 스레드만 초기화를 실행하고, 나머지는 초기화가 끝날 때까지 기다립니다.

#include <chrono>
#include <thread>
#include <iostream>

int expensiveInit() {
    std::cout << "초기화 시작 (스레드 " << std::this_thread::get_id() << ")\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "초기화 완료\n";
    return 42;
}

void func() {
    static int value = expensiveInit();  // 스레드 안전
    std::cout << "값: " << value << '\n';
}

int main() {
    std::thread t1(func);
    std::thread t2(func);
    std::thread t3(func);
    
    t1.join();
    t2.join();
    t3.join();
}

// 출력:
// 초기화 시작 (스레드 ...)
// 초기화 완료
// 값: 42
// 값: 42
// 값: 42
// (초기화는 한 번만 실행됨. "값: 42" 줄은 스레드끼리 섞여 출력될 수 있음)

개념적으로는 다음과 같이 동작한다고 생각할 수 있습니다.

// 컴파일러가 생성하는 코드 (개념적)
void func() {
    static bool initialized = false;
    static std::mutex init_mutex;
    static int value;

    std::lock_guard<std::mutex> lock(init_mutex);
    if (!initialized) {
        value = expensiveInit();
        initialized = true;
    }
}

위 코드는 이해를 위한 단순화이고, 실제 구현은 매 호출마다 뮤텍스를 잡지 않습니다. GCC와 Clang(Itanium C++ ABI)은 변수마다 가드 변수를 두고, 먼저 가드를 원자적으로 읽어 이미 초기화되었으면 곧바로 넘어갑니다. 초기화되지 않은 경우에만 __cxa_guard_acquire를 호출해 한 스레드만 초기화하게 하고, 나머지 스레드는 초기화가 끝날 때까지 기다립니다. 그래서 초기화 이후의 비용은 원자적 읽기 한 번 수준입니다. 임베디드처럼 스레드가 없는 환경에서는 -fno-threadsafe-statics로 이 가드 코드를 빼서 코드 크기를 줄이기도 하지만, 스레드가 있는 프로그램에서 끄면 초기화가 두 번 일어날 수 있습니다.

보장되는 것은 초기화뿐이라는 점에 주의해야 합니다. 전역 변수의 초기화는 보통 main 전 단일 스레드에서 끝나고, 정적 지역 변수의 초기화는 C++11 이상에서 스레드 안전하지만, 초기화된 객체를 여러 스레드가 읽고 쓰는 것은 어느 쪽이든 별도 동기화가 필요합니다.

std::call_once: 객체가 아니라 “동작”을 한 번만 실행할 때

함수 지역 static은 객체 하나를 만드는 데 가장 간단하지만, 객체가 아닌 동작(라이브러리 전역 초기화 호출, 여러 멤버를 한꺼번에 채우기)을 한 번만 실행하거나 객체마다 따로 1회 초기화를 두려면 <mutex>의 std::call_once와 std::once_flag를 씁니다.

#include <mutex>

class Connection {
    std::once_flag init_flag_;
    void handshake(const std::string& host);
public:
    void send(const std::string& msg) {
        std::call_once(init_flag_, &Connection::handshake, this, "localhost");
        // ... 이후에는 handshake가 끝난 상태가 보장됨
    }
};

직접 구현하려고 bool initialized를 락 밖에서 먼저 확인하는 이중 검사 잠금을 쓰면, 일반 bool로는 “플래그가 true로 보이는데 데이터는 아직 안 채워진” 순서 뒤바뀜이 생길 수 있습니다. call_once는 성공한 실행의 완료가 같은 플래그에 대한 이후 call_once 반환보다 먼저 일어난다(synchronizes-with)고 표준이 보장하므로, 반환 뒤에 초기화 결과를 읽는 것이 안전합니다. 알아 둘 동작은 다음과 같습니다.

  • 함수가 예외로 끝나면 예외는 호출자에게 전파되고 플래그는 완료되지 않아, 다음 호출이나 대기 중이던 다른 스레드가 다시 실행합니다. 함수 지역 static의 생성자가 예외를 던졌을 때와 같은 규칙입니다. 다만 pthread_once 기반이던 예전 libstdc++에서는 예외 뒤 다음 호출이 멈추는 결함(GCC Bugzilla 66146)이 보고된 적이 있어, 오래된 툴체인에서 재시도 동작에 기대려면 직접 확인해야 합니다.
  • 인자는 std::thread와 달리 복사되지 않고 그대로 전달되며, 실제로 함수를 실행한 호출의 인자만 쓰입니다. 이후 호출에서 다른 인자를 넘겨도 경고 없이 무시됩니다.
  • once_flag는 복사와 이동이 모두 삭제되어 있어, 멤버로 두면 그 클래스도 기본 복사·이동 생성자를 잃습니다. std::vector에 넣으려다 use of deleted function 'std::once_flag::once_flag(const std::once_flag&)'를 만나는 것이 전형적인 증상입니다.
  • 초기화 함수 안에서 같은 플래그로 다시 call_once를 부르거나 두 플래그가 서로를 부르면 교착 상태가 됩니다. 함수 지역 static의 재진입 문제와 같은 성격이므로 의존 방향을 한쪽으로 정리해야 합니다.

임베디드 빌드에서 -fno-threadsafe-statics를 쓰거나 MSVC에서 /Zc:threadSafeInit-로 함수 지역 static의 스레드 안전 초기화가 꺼져 있는 환경이라면, 명시적인 call_once가 더 확실한 선택입니다.

자주 발생하는 문제

문제 1: 초기화 순서

// ❌ 순서 보장 안 됨
// file1.cpp
int a = compute();  // 동적 초기화 (int a = 10;이었다면 상수 초기화라 안전)

// file2.cpp
extern int a;
int b = a + 1;  // a가 아직 0일 수 있음

// ✅ 함수 내 정적 변수
int& getA() {
    static int a = 10;
    return a;
}

int b = getA() + 1;  // 순서 보장

이 문제에서 흔히 헷갈리는 부분이 있습니다. 만약 file1이 int a = 10;이라면 a는 상수 초기화되어 어떤 동적 초기화보다도 먼저 10을 가지므로, b는 항상 11이 되어 문제가 없습니다. 순서가 문제가 되는 것은 a가 함수 호출, 생성자 실행처럼 동적으로 초기화될 때뿐입니다. 이때 b의 초기화가 먼저 실행되면 a는 정적 초기화 단계의 값인 0을 가지고 있어서 b는 1이 됩니다. 크래시가 아니라 조용히 잘못된 값이 들어가는 경우가 많다는 점이 이 버그를 찾기 어렵게 만듭니다. GCC 같은 구현에서는 링크 순서(g++ file1.o file2.o와 g++ file2.o file1.o)를 바꾸면 결과가 달라지는 경우가 많아, 이 방법으로 문제를 재현해 볼 수 있습니다.

문제 2: 순환 의존성

// ❌ 순환 의존 (서로 다른 파일)
// file1.cpp
extern int y;
int x = y + 1;
// file2.cpp
extern int x;
int y = x + 1;

// ✅ 의존 방향을 한쪽으로 정리
int x = 0;
int y = x + 1;

같은 파일 안에서 int x = y + 1; int y = x + 1;을 쓰면 x를 초기화하는 시점에 y가 아직 선언되지 않았으므로 컴파일 에러가 납니다. 순환 의존이 실제로 문제가 되는 것은 위처럼 다른 파일에 나뉜 경우입니다. 이때 두 변수는 정적 초기화 단계에서 이미 0이므로 결과는 미정의 동작이 아니라 어느 쪽이 먼저 초기화되느냐에 따라 달라지는 값입니다. file1이 먼저면 x = 1, y = 2, file2가 먼저면 y = 1, x = 2가 됩니다. 어느 쪽이든 설계 의도와 맞을 리 없으므로, 두 값 중 하나를 상수로 두거나 둘을 한 곳에서 계산하도록 의존 방향을 정리해야 합니다. 함수 지역 static끼리 서로를 호출하는 순환이라면 다음의 재진입 문제가 됩니다.

문제 3: 정적 지역 초기화 재진입

초기화가 끝나기 전에 같은 스레드가 그 선언에 다시 도달하면(초기화 코드가 돌고 돌아 같은 함수를 다시 호출하는 경우) 표준상 미정의 동작입니다. 실제로는 구현에 따라 데드락이 되거나, libstdc++처럼 __gnu_cxx::recursive_init_error 예외를 던집니다. 싱글턴 A의 생성자가 싱글턴 B를 부르고, B의 생성자가 다시 A를 부르는 구조에서 흔히 생기며, 의존 방향을 한쪽으로 정리하는 것 외에는 해결책이 없습니다.

문제 4: 예외

#include <stdexcept>

int compute() {
    throw std::runtime_error("에러");
}

// 초기화 실패
int global = compute();  // 예외 발생

int main() {
    // 프로그램 종료
}

전역 변수의 동적 초기화에서 예외가 빠져나오면 이를 잡을 try 블록이 없으므로 std::terminate가 호출되어 프로그램이 즉시 종료됩니다. 보통 terminate called after throwing an instance of 'std::runtime_error' 같은 메시지만 남고, main의 로그 설정이 아직 실행되지 않았으므로 로그 파일에는 아무것도 기록되지 않습니다. 설정 파일 누락처럼 운영 환경에서만 발생하는 초기화 실패가 “프로그램이 아무 로그 없이 바로 죽는다”는 증상으로 나타나는 이유입니다. 실패할 수 있는 초기화는 main 안으로 옮기거나 함수 지역 static으로 바꿔, 호출하는 쪽에서 예외를 처리할 수 있게 만드는 것이 원칙입니다.

문제 5: 소멸 순서

class Resource {
public:
    ~Resource() {
        std::cout << "소멸" << std::endl;
    }
};

Resource r1;
Resource r2;

// 소멸 순서: r2 -> r1 (생성 역순)

소멸은 생성(생성자 완료)의 역순이라는 규칙은 함수 지역 static까지 포함해 적용됩니다. 즉 먼저 생성된 것이 나중에 소멸됩니다. 이 때문에 생긴 유명한 함정이 로거 싱글턴입니다. 전역 객체 A가 생성될 때는 로거를 쓰지 않다가 소멸자에서 처음 로거를 쓰면, 로거는 A보다 나중에 생성되었으므로 A보다 먼저 소멸되어 A의 소멸자가 파괴된 로거를 사용하게 됩니다. 해결책은 A의 생성자에서 로거를 한 번 호출해 생성 순서를 강제하거나, 로거를 static Logger* logger = new Logger;처럼 힙에 만들고 일부러 해제하지 않는 것입니다. 후자는 누수 검사 도구에 “still reachable”로 잡히지만, 종료 순서 문제를 원천적으로 피하는 실용적인 관용구로 널리 쓰입니다.

최적화

// ❌ 동적 초기화
int getValue() { return 42; }
int x = getValue();

// ✅ 상수 초기화
constexpr int getValue() { return 42; }
constexpr int x = getValue();

동적 초기화를 줄이는 방법은 세 가지입니다.

// 1. constexpr로 전환 (가능한 경우)
// Before
int square(int x) { return x * x; }
int result = square(10);  // 런타임 계산

// After
constexpr int square(int x) { return x * x; }
constexpr int result = square(10);  // 컴파일 타임 계산

// 2. 정적 지역 변수로 지연 초기화
// Before
Database globalDB("localhost");  // 프로그램 시작 시 연결

// After
Database& getDB() {
    static Database db("localhost");  // 첫 사용 시 연결
    return db;
}

// 3. constinit로 상수 초기화를 강제 (C++20)
// Before
int config = 100;              // 지금은 상수 초기화지만, 초기값이 바뀌면 조용히 동적 초기화가 됨

// After
constinit int config = 100;    // 상수 초기화가 아니면 컴파일 에러

상수 초기화는 constexpr을 붙여야만 일어나는 것이 아닙니다. 정적 저장 기간 변수의 초기값이 상수 식이면 int limit = 10;처럼 평범하게 써도 컴파일러는 상수 초기화를 해야 합니다. 문제는 누군가 초기값을 loadDefault() 같은 비-constexpr 함수 호출로 바꾸면 조용히 동적 초기화로 넘어가 순서 문제에 노출된다는 점이고, constinit은 바로 이 회귀를 빌드 에러('constinit' variable does not have a constant initializer)로 바꿔 줍니다. 다른 번역 단위의 생성자에서 참조될 수 있는 전역(로그 레벨, 기능 플래그, 뮤텍스)에 붙여 두면 효과가 큽니다. std::mutex의 기본 생성자는 constexpr이라 constinit std::mutex g_mtx;도 가능합니다. 단, 다른 전역 변수를 읽는 식은 상수 식이 아니므로 constinit int b = a + 1;(a가 const가 아닌 전역)은 허용되지 않습니다. 공유할 값은 inline constexpr 상수로 헤더에 두고 양쪽에서 그 상수를 쓰면 됩니다.

thread_local 변수에 constinit을 붙이는 것은 성능에도 의미가 있습니다. 동적 초기화가 필요한 thread_local은 접근할 때마다 “이 스레드에서 초기화되었는가”를 확인하는 래퍼 호출이 붙을 수 있는데, 상수 초기화가 보장되면 이 검사가 사라집니다. 어떤 전역이 동적 초기화되는지 목록이 필요하면 Clang의 -Wglobal-constructors가 전역 생성자를 호출해야 하는 변수마다 declaration requires a global constructor 경고를 냅니다.

전역 동적 초기화는 프로그램 시작 시간에 비용으로 더해집니다. 다음 코드로 이를 재려고 하기 쉽습니다.

#include <chrono>
#include <iostream>

// 동적 초기화: 프로그램 시작 시 비용
long long expensiveGlobal = []() {
    long long sum = 0;  // 0부터 999999까지의 합은 약 5천억이라 int로는 넘친다
    for (int i = 0; i < 1000000; ++i) {
        sum += i;
    }
    return sum;
}();

int main() {
    auto start = std::chrono::steady_clock::now();
    // expensiveGlobal은 이미 초기화됨 (main 전)
    auto elapsed = std::chrono::steady_clock::now() - start;
    std::cout << "main 시작 시간: " << elapsed.count() << "ns\n";
}
// 프로그램 시작 시간이 느려짐

이 코드의 측정값은 전역 초기화 비용을 보여 주지 않는다는 점에 주의하세요. start와 elapsed는 모두 main 안에서 재므로, 이미 끝난 main 이전의 작업은 측정에 포함되지 않습니다. 실제 비용을 보려면 셸에서 time ./app으로 프로세스 전체 실행 시간을 재거나, perf record로 main 이전 구간의 샘플을 확인해야 합니다. 또 이 람다의 계산은 결과가 상수이므로 최적화 컴파일러가 컴파일 시점에 계산해 버려 실제로는 비용이 0일 수도 있습니다. 대규모 C++ 애플리케이션에서 시작 시간이 느린 원인을 찾아보면, 수많은 번역 단위의 전역 std::string, std::map 생성자와 정규식 컴파일 같은 작업이 main 전에 쌓여 있는 경우가 흔합니다.

실무 패턴

패턴 1: Meyers 싱글톤

class Logger {
    Logger() { /* 초기화 */ }
    
public:
    static Logger& instance() {
        static Logger logger;  // 스레드 안전, 지연 초기화
        return logger;
    }
    
    void log(const std::string& msg) {
        // 로깅 로직
    }
};

// 사용
Logger::instance().log("Hello");

패턴 2: 지연 초기화 (Lazy Initialization)

class ResourceManager {
    static std::unique_ptr<Database> db_;
    
public:
    static Database& getDB() {
        if (!db_) {
            db_ = std::make_unique<Database>("localhost");
        }
        return *db_;
    }
};
// 클래스 밖 정의 필요: std::unique_ptr<Database> ResourceManager::db_;

// 사용하지 않으면 초기화 안 됨

이 방식은 함수 지역 static과 달리 스레드 안전하지 않습니다. 두 스레드가 동시에 getDB()를 처음 호출하면 둘 다 !db_를 참으로 보고 각자 객체를 만들 수 있고, 한쪽이 만든 객체는 다른 쪽의 대입으로 덮어써져 파괴됩니다. 이미 그 객체의 참조를 받은 스레드는 파괴된 객체를 쓰게 됩니다. 이 패턴이 필요한 경우(나중에 reset()으로 다시 만들 수 있어야 하는 등)에는 std::call_once와 std::once_flag로 보호하거나 뮤텍스를 써야 하고, 그런 요구가 없다면 함수 지역 static이 더 짧고 안전합니다.

패턴 3: 초기화 순서 보장

// config.cpp
int& getPort() {
    static int port = loadPortFromFile();
    return port;
}

// server.cpp
Server& getServer() {
    static Server server(getPort());  // getPort() 먼저 초기화됨
    return server;
}

컴파일러·링크 관점

표준은 번역 단위 사이의 동적 초기화 순서를 보장하지 않습니다. GCC의 __attribute__((init_priority(N)))처럼 순서를 지정하는 비표준 확장이 있지만 이식성이 없고, MSVC는 #pragma init_seg처럼 전혀 다른 방식을 쓰므로 의존하지 않는 편이 좋습니다.

구현은 보통 동적 초기화가 필요한 전역마다 초기화 함수를 만들어 ELF의 .init_array 같은 섹션에 등록하고, 프로그램이나 라이브러리를 로드할 때 이 목록을 차례로 호출합니다. 여기서 정적 라이브러리(.a, .lib)와 관련된 함정이 하나 있습니다. 링커는 정적 라이브러리에서 참조되는 심볼이 있는 오브젝트 파일만 가져오므로, “전역 객체의 생성자에서 자기 자신을 레지스트리에 등록”하는 플러그인·테스트 등록 패턴은 그 파일을 아무도 참조하지 않으면 링크에서 빠져 생성자가 아예 실행되지 않습니다. GCC/Clang의 -Wl,--whole-archive나 CMake의 오브젝트 라이브러리로 강제로 포함시키거나, 명시적인 등록 함수를 호출하는 방식으로 바꿉니다.

constinit과 constexpr의 차이

constexpr 변수는 컴파일 타임에 초기화될 뿐 아니라 값을 바꿀 수 없는 상수입니다. C++20의 constinit은 “이 변수는 반드시 상수 초기화되어야 한다”는 것만 강제하고, 이후에 값을 바꾸는 것은 허용합니다. 그래서 초기화 순서 문제에서 안전한, 변경 가능한 전역 변수가 필요할 때 constinit을 씁니다. 함수 지역 변수에는 쓸 수 없고, 정적·스레드 저장 기간 변수에만 붙일 수 있습니다.

참고 자료