C++ 전역 변수가 문제를 일으키는 5가지 경우와 대안: 초기화 순서, 스레드 안전성, 테스트

이 글의 핵심

C++ 전역 변수가 일으키는 다섯 가지 문제(초기화 순서, 스레드 안전성, 테스트, 네임스페이스 오염, 암묵적 의존성)와 함수 내 정적 변수·Meyer's Singleton·의존성 주입 같은 대안을 예제로 정리합니다.

들어가며

C++을 처음 배울 때 일찍 접하는 것 중 하나가 전역 변수입니다. 어디서든 접근할 수 있어 편리하지만, 규모가 있는 코드에서는 피해야 할 패턴으로 여겨집니다.

// ❌ 흔히 보는 전역 변수 남용
#include <string>
#include <vector>

std::string configPath = "/etc/config.txt";  // 전역 설정
std::vector<int> globalCache;                 // 전역 캐시
int connectionCount = 0;                       // 전역 카운터

void initialize() {
    // configPath가 초기화되었을까?
    // globalCache는 비어있을까?
    // 다른 스레드가 connectionCount를 수정하고 있지 않을까?
}

이 글은 전역 변수가 일으키는 다섯 가지 문제(번역 단위 간 초기화 순서, 데이터 레이스, 테스트 간 간섭, 이름 충돌, 함수 시그니처에 드러나지 않는 의존성)를 예제로 하나씩 보여 주고, 함수 내 정적 변수·Meyer’s Singleton·의존성 주입·constexpr·익명 네임스페이스라는 대안과 기존 코드에서 전역 변수를 걷어내는 리팩토링 순서를 정리합니다.

이 문제들이 다루기 까다로운 이유는 증상이 환경에 따라 나타났다 사라진다는 데 있습니다. 정적 초기화 순서 문제(Static Initialization Order Fiasco)로 인한 크래시는 특정 링크 순서나 빌드 구성에서만 재현되고, 전역 변수에 대한 데이터 레이스는 로컬에서는 드러나지 않다가 특정 부하 패턴에서만 나타나기도 합니다. 그래서 테스트 단계에서 걸러지지 않고 배포까지 가는 경우가 생깁니다.


문제 1: 초기화 순서 (Static Initialization Order Fiasco)

서로 다른 번역 단위(파일)에 있는 전역 변수의 동적 초기화 순서는 표준이 정하지 않습니다. 아직 생성되지 않은 전역 객체를 사용하면 정의되지 않은 동작(UB)이 됩니다.

전역 변수가 다른 파일의 전역 변수에 의존할 때 링크 순서에 따라 x가 1 또는 43이 되고 함수 안 static으로 바꾸면 순서와 무관하게 43이 되는 비교

그림: a.cpp의 x가 b.cpp의 y에 의존하면, 링크 순서에 따라 x가 1이 되기도 하고 43이 되기도 한다. 함수 안 static 변수로 바꾸면 처음 호출될 때 초기화되어 두 순서 모두 43이다(이 맥의 clang++로 파일 순서만 바꿔 링크해 확인).

// ❌ config.cpp
#include <string>

std::string configPath = "/etc/config.txt";

// ❌ logger.cpp
#include <fstream>
#include <string>

extern std::string configPath;

// 문제: configPath가 생성되기 전에 logFile이 초기화될 수 있음
std::ofstream logFile(configPath);  // ❌ UB 가능성

// ❌ main.cpp
#include <fstream>

extern std::ofstream logFile;

int main() {
    logFile << "Application started\n";
}

어떤 일이 일어나는지는 플랫폼과 링크 순서에 따라 다릅니다. config.cpp의 초기화가 먼저 실행되면 정상적으로 동작합니다. logger.cpp가 먼저 실행되면 logFile의 생성자는 아직 생성자가 실행되지 않은 std::string 객체를 받습니다. 이 객체는 “빈 문자열”이 아니라 0으로 채워진 메모리일 뿐이므로, 그 내용을 읽는 것 자체가 미정의 동작입니다. 실제로는 크래시가 나거나, 엉뚱한 경로로 파일을 열거나, 우연히 열기에 실패해 로그가 조용히 사라지는 식으로 나타납니다. 소스 코드는 그대로인데 오브젝트 파일의 링크 순서만 바꿔도 결과가 달라질 수 있습니다.

왜 발생하는가

// file1.cpp
int computeX();          // 런타임에 값을 계산하는 함수
int x = computeX();      // 동적 초기화

// file2.cpp
extern int x;
int y = x + 5;  // ❌ x가 아직 동적 초기화되지 않았을 수 있음

// 결과: file2가 먼저 초기화되면 x는 0이므로 y는 5

이 문제를 정확히 이해하려면 C++ 전역 변수의 초기화가 두 단계로 나뉜다는 점을 알아야 합니다. 먼저 정적 초기화가 일어납니다. 모든 전역 변수는 0으로 채워지고(zero-initialization), int x = 10;처럼 상수식으로 초기화되는 변수는 이 단계에서 값이 정해집니다(constant initialization). 그다음 동적 초기화에서 함수 호출이나 생성자 실행이 필요한 변수(int x = computeX();, std::string s = "...")가 초기화되는데, 이 단계의 순서가 번역 단위 사이에서 정해지지 않은 것이 문제의 핵심입니다.

그래서 int x = 10;이었다면 x는 정적 초기화로 이미 10이라 y는 항상 15가 됩니다. 문제는 동적 초기화가 필요한 경우뿐이고, 그때 int라면 0으로 채워진 값이 읽힐 뿐이지만, std::string이나 std::map처럼 생성자가 아직 실행되지 않은 객체를 사용하는 것은 미정의 동작이라 크래시로 이어지기 쉽습니다. C++20의 constinit을 붙이면 “이 변수는 반드시 정적 초기화되어야 한다”고 선언할 수 있고, 동적 초기화가 필요하면 컴파일 에러가 나므로 의도치 않은 순서 의존을 미리 막을 수 있습니다.

같은 파일 내에서는 순서가 보장됨

// file.cpp
#include <iostream>

int a = 10;
int b = a + 5;  // ✅ 같은 번역 단위에서는 정의 순서대로 초기화됨

int main() {
    std::cout << b << '\n';  // 15
}

복잡한 초기화 체인

// ❌ database.cpp
#include <string>
#include <memory>

extern std::string configPath;

class Database {
public:
    explicit Database(const std::string& path) {
        // path로 DB 연결
    }
    std::string query(const std::string& sql) { return {}; }
};

std::unique_ptr<Database> globalDB =
    std::make_unique<Database>(configPath);  // ❌ configPath 미생성 가능

// ❌ cache.cpp
extern std::unique_ptr<Database> globalDB;

class Cache {
public:
    Cache() {
        // globalDB를 사용하여 캐시 초기화
        if (globalDB) {  // ❌ globalDB가 아직 생성 전일 수 있음
            globalDB->query("...");
        }
    }
};

Cache globalCache;  // ❌ globalDB 미초기화 가능

여기서는 configPath → globalDB → globalCache라는 세 단계의 의존이 생기는데, 표준은 이 순서를 보장하지 않습니다. 어느 한 단계라도 순서가 어긋나면 크래시가 나고, 파일을 하나 추가하거나 제거하는 것만으로도 순서가 바뀔 수 있습니다.

실제로 순서를 결정하는 것은 대개 링커에 오브젝트 파일이 전달되는 순서입니다. GCC/Clang에서 g++ config.o logger.o main.o와 g++ logger.o config.o main.o의 결과가 다를 수 있고, CMake에서 소스 목록 순서를 바꾸거나 파일을 정적 라이브러리로 옮기기만 해도 증상이 생겼다 사라집니다. 이 문제가 까다로운 이유는 크래시가 main()보다 먼저 일어난다는 점입니다. 디버거에서 main에 중단점을 걸어도 거기까지 도달하지 못하고, 백트레이스는 __libc_start_main이나 _GLOBAL__sub_I_... 같은 컴파일러가 만든 초기화 함수를 가리킵니다. 이런 이름이 보이면 정적 초기화 순서 문제를 먼저 의심하세요. Clang/GCC의 AddressSanitizer는 ASAN_OPTIONS=check_initialization_order=1로 이 문제를 런타임에 탐지하는 기능도 제공합니다.

반대 방향의 소멸 순서 문제도 있습니다. 전역 객체는 main() 이후에 생성 역순으로 소멸되는데, 어떤 전역 객체의 소멸자가 이미 소멸된 다른 전역(예: 로거)을 사용하면 종료 시점에만 크래시가 납니다. “프로그램이 끝날 때만 가끔 죽는다”는 증상의 흔한 원인입니다.


문제 2: 스레드 안전성

전역 변수는 모든 스레드에서 접근할 수 있으므로, 동기화 없이 수정하면 데이터 레이스가 발생합니다.

// ❌ 스레드 안전하지 않음
#include <iostream>
#include <thread>

int globalCounter = 0;  // 전역 카운터

void incrementCounter() {
    for (int i = 0; i < 100000; ++i) {
        ++globalCounter;  // ❌ 데이터 레이스
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

    t1.join();
    t2.join();

    std::cout << globalCounter << '\n';
    // 기대: 200000
    // 실제: 실행할 때마다 200000보다 작은 값이 나올 수 있음 (미정의 동작)
}

왜 발생하는가

++globalCounter;  // 개념적으로는 3단계

// 1. 메모리에서 값 읽기
int temp = globalCounter;

// 2. 증가
temp = temp + 1;

// 3. 메모리에 쓰기
globalCounter = temp;

두 스레드가 이 과정을 동시에 실행하면 한쪽의 증가가 사라질 수 있습니다.

초기값: globalCounter = 0

Thread 1               Thread 2
--------               --------
temp1 = 0 (읽기)
                       temp2 = 0 (읽기)
temp1 = 1 (증가)
                       temp2 = 1 (증가)
globalCounter = 1
                       globalCounter = 1

결과: 2가 아니라 1

위 설명은 직관을 위한 것이고, 표준의 관점은 더 엄격합니다. 두 스레드가 동기화 없이 같은 변수에 접근하고 그중 하나라도 쓰기라면 그 자체로 데이터 레이스이고 미정의 동작입니다. “값이 조금 틀린다”로 끝난다는 보장도 없어서, 컴파일러는 레이스가 없다고 가정하고 루프 안의 ++globalCounter를 레지스터에 모았다가 마지막에 한 번만 쓰는 식으로 최적화할 수 있습니다. 그러면 결과가 100000 근처로 나오기도 합니다. 카운터라면 std::atomic<int>로 바꾸는 것이 가장 간단한 해결이고, 여러 변수를 함께 일관되게 바꿔야 한다면 std::mutex로 묶어야 합니다. 전역 변수 자체가 레이스를 만드는 것은 아니지만, 어디서든 접근할 수 있다는 성질 때문에 “이 변수에 어떤 스레드가 접근하는가”를 코드만 보고 답하기 어려워지는 것이 진짜 문제입니다.

복잡한 타입은 더 위험

// ❌ vector는 스레드 안전하지 않음
#include <vector>
#include <thread>

std::vector<int> globalData;  // 전역 벡터

void addData(int value) {
    globalData.push_back(value);  // ❌ 데이터 레이스
    // push_back은 내부적으로:
    // 1. 크기 확인
    // 2. 메모리 재할당 (필요시)
    // 3. 데이터 복사
    // 4. 크기 업데이트
    // → 여러 단계에서 레이스 가능
}

int main() {
    std::thread t1(addData, 1);
    std::thread t2(addData, 2);

    t1.join();
    t2.join();

    // 크래시 또는 데이터 손실
}

초기화 시점도 위험

class ExpensiveResource {
public:
    ExpensiveResource() {
        // 복잡한 초기화 (DB 연결, 파일 읽기 등)
    }
};

ExpensiveResource globalResource;  // ⚠️ main() 전에 초기화됨

// 전역 초기화는 보통 main() 전 단일 스레드에서 일어나지만,
// 다른 전역 객체의 생성자가 스레드를 띄우면 초기화 중인 객체에 접근할 수 있음
// → 정의되지 않은 동작

일반적인 경우 전역 변수의 동적 초기화는 main()이 시작되기 전 메인 스레드에서 차례로 실행되므로 스레드 경쟁은 드뭅니다. 위험은 두 가지 상황에서 생깁니다. 하나는 어떤 전역 객체의 생성자가 백그라운드 스레드를 띄우는 경우이고(스레드 풀, 로깅 스레드), 다른 하나는 공유 라이브러리를 dlopen으로 런타임에 불러올 때 그 라이브러리의 전역 초기화가 이미 돌고 있는 다른 스레드와 겹치는 경우입니다. 또 초기화에 DB 연결이나 파일 읽기처럼 실패할 수 있는 작업이 들어가면, 예외가 main() 밖에서 발생해 std::terminate()로 즉시 종료되고 에러를 처리할 기회가 없습니다.


문제 3: 테스트 어려움

전역 변수를 사용하면 함수의 결과가 인자만이 아니라 숨은 전역 상태에도 좌우되므로, 테스트를 서로 독립적으로 유지하기 어려워집니다.

// ❌ 테스트하기 어려운 코드
#include <cassert>
#include <map>
#include <string>

// 전역 설정
std::map<std::string, std::string> globalConfig;

int calculatePrice(int quantity) {
    // 전역 변수에 의존
    int basePrice = std::stoi(globalConfig["base_price"]);
    double discount = std::stod(globalConfig["discount"]);

    return quantity * basePrice * (1.0 - discount);
}

// 테스트 코드
void testCalculatePrice() {
    // 전역 상태를 직접 설정해야 함
    globalConfig["base_price"] = "100";
    globalConfig["discount"] = "0.1";

    int result = calculatePrice(5);
    assert(result == 450);

    // 다음 테스트에 영향을 주지 않으려면 정리도 직접 해야 함
    globalConfig.clear();
}

void testCalculatePriceWithDifferentDiscount() {
    globalConfig["base_price"] = "100";
    globalConfig["discount"] = "0.2";  // 다른 할인율

    int result = calculatePrice(5);
    assert(result == 400);

    // 정리를 빠뜨렸으므로, 이후에 실행되는 테스트가 이 값을 물려받음
}

테스트 간 간섭

정리를 빠뜨리거나, 앞선 테스트가 남긴 상태에 기대는 테스트가 생기면 실행 순서에 따라 결과가 달라집니다.

// ❌ 테스트 1: 두 값을 설정하고 정리하지 않음
TEST(PriceTest, BasicCalculation) {
    globalConfig["base_price"] = "100";
    globalConfig["discount"] = "0";
    EXPECT_EQ(calculatePrice(5), 500);
}  // globalConfig가 그대로 남음

// ❌ 테스트 2: 테스트 1이 남긴 base_price에 기대고 있음
TEST(PriceTest, WithDiscount) {
    globalConfig["discount"] = "0.1";
    EXPECT_EQ(calculatePrice(5), 450);
    // 이 테스트만 단독으로 돌리거나 테스트 1보다 먼저 실행되면
    // globalConfig["base_price"]가 빈 문자열로 삽입되고
    // std::stoi("")가 std::invalid_argument를 던져 실패함
}

std::map::operator[]는 키가 없으면 기본값(빈 문자열)을 삽입한다는 점도 눈여겨볼 만합니다. 테스트 2가 실패하면서 globalConfig에 빈 base_price를 남기므로, 그 뒤에 실행되는 다른 테스트까지 연쇄적으로 영향을 받을 수 있습니다.

목(Mock) 객체로 교체하기 어려움

// ❌ 전역 객체는 테스트에서 바꿔 끼우기 어려움
class Database {
public:
    virtual ~Database() = default;
    virtual std::string query(const std::string& sql) {
        // 실제 DB 접근
        return {};
    }
};

Database globalDB;  // 전역 DB 연결

void processUser(int userId) {
    // 전역 DB에 의존
    std::string name = globalDB.query("SELECT name FROM users WHERE id = " +
                                      std::to_string(userId));
    // ...
}

// 테스트 시 문제:
// 1. 실제 DB 연결 필요
// 2. 테스트 데이터 준비 필요
// 3. processUser가 globalDB를 직접 참조하므로 Mock DB로 교체할 수 없음
// 4. 실제 DB I/O 때문에 테스트가 느림

테스트 간 간섭은 실행 순서를 바꿀 때 드러납니다. ctest -j8처럼 테스트 바이너리를 여러 개 동시에 돌리는 것은 프로세스가 분리되어 괜찮지만, 한 바이너리 안에서 테스트를 섞은 순서로 실행하는 --gtest_shuffle을 켜거나 --gtest_filter로 하나만 돌리면 순서에 의존하던 테스트가 실패합니다. “혼자 돌리면 실패하는데 전체로 돌리면 통과하는” 테스트, 또는 그 반대가 보이면 전역 상태부터 의심해 볼 만합니다. 전역을 당장 없앨 수 없다면 테스트 픽스처의 SetUp()에서 매번 전역 상태를 알려진 값으로 되돌리는 것이 최소한의 방어이고, 근본적인 해결은 아래의 의존성 주입입니다.


문제 4: 네임스페이스 오염

이름 충돌

전역 변수는 프로그램 전체가 공유하는 이름 공간을 차지합니다.

// ❌ utils.cpp
int count = 0;  // 전역 카운터

void incrementCount() {
    ++count;
}

// ❌ database.cpp
int count = 0;  // 또 다른 전역 카운터 (다른 용도)

void recordAccess() {
    ++count;
}

// 링크 에러:
// multiple definition of `count'

링크 에러가 나는 경우는 오히려 다행입니다. 더 위험한 것은 타입이 다른 같은 이름이 서로 다른 라이브러리에 있을 때입니다. 공유 라이브러리 사이에서는 동적 링커가 먼저 찾은 심볼 하나로 통일해 버리는 경우가 있어서, 한쪽은 int count로, 다른 쪽은 double count로 같은 메모리를 해석하는 상황이 에러 없이 만들어질 수 있습니다. 이런 문제는 C++의 ODR(One Definition Rule) 위반으로, 진단이 필요 없는(no diagnostic required) 미정의 동작이라 컴파일러와 링커가 알려 주지 않습니다.

라이브러리 사용 시 문제

// ❌ my_library.h
extern int status;  // 라이브러리 전역 변수

// ❌ my_code.cpp
int status = 0;  // 내 코드의 전역 변수

// 라이브러리도 status를 정의하고 있다면 충돌

네임스페이스로는 부분적 해결

// ✅ 네임스페이스 사용
namespace utils {
    int count = 0;
}

namespace database {
    int count = 0;
}

// 사용 (함수 안에서)
utils::count++;
database::count++;

// 하지만 초기화 순서, 스레드 안전성 문제는 여전히 존재

문제 5: 암묵적 의존성

전역 변수는 함수 시그니처에 드러나지 않는 의존성을 만들어 코드를 읽기 어렵게 합니다.

// ❌ 전역 변수에 의존
#include <string>
#include <fstream>

extern std::string configPath;
extern std::ofstream logFile;
extern int connectionCount;
extern int connectionLimit;

void connectToServer() {
    // 어떤 전역 변수를 사용하는지 함수 시그니처로 알 수 없음
    // 코드를 읽어봐야 알 수 있음
    if (connectionCount >= connectionLimit) {
        logFile << "Connection limit reached\n";
        return;
    }

    std::string server = configPath + "/server";
    // ...
}

의존성을 명시한 버전

// ✅ 의존성을 명시
void connectToServer(
    std::ofstream& log,
    const std::string& config,
    int currentConnections,
    int limit
) {
    // 함수 시그니처만 봐도 필요한 것들을 알 수 있음
    if (currentConnections >= limit) {
        log << "Connection limit reached\n";
        return;
    }

    std::string server = config + "/server";
    // ...
}

리팩토링 어려움

// ❌ 전역 변수 사용 시
void functionA() {
    globalData.push_back(1);  // 전역 변수 수정
}

void functionB() {
    globalData.push_back(2);  // 같은 전역 변수 수정
}

// globalData의 타입을 바꾸려면 프로그램 전체에서 사용처를 찾아 고쳐야 함

해결책 1: 함수 내 정적 지역 변수

함수 내 정적 지역 변수는 초기화 순서 문제를 해결하고, C++11부터는 초기화가 스레드 안전합니다.

// ✅ 함수 내 정적 지역 변수
#include <string>

const std::string& getConfigPath() {
    static std::string configPath = "/etc/config.txt";
    return configPath;
}

// 사용
void initialize() {
    const std::string& path = getConfigPath();
    // configPath는 처음 호출되는 이 시점에 초기화됨 (지연 초기화)
}

왜 안전한가

// C++11부터 스레드 안전
std::string& getLogger() {
    static std::string logger = "global.log";
    // 컴파일러가 대략 다음과 같은 코드를 생성:
    // if (!initialized) {
    //     lock_guard<mutex> lock(internal_mutex);
    //     if (!initialized) {
    //         new (&logger) std::string("global.log");
    //         initialized = true;
    //     }
    // }
    return logger;
}

이 방식이 초기화 순서 문제를 푸는 원리는 “처음 사용할 때 초기화”입니다. 전역 변수는 프로그램이 정한 어떤 시점에 초기화되지만, 함수 안의 static 변수는 실행이 그 선언을 처음 지나갈 때 초기화됩니다. 누군가 getConfigPath()를 호출하는 순간 초기화가 끝나 있으므로, 다른 파일의 전역 초기화 도중에 호출되더라도 순서가 문제 되지 않습니다. C++11부터는 여러 스레드가 동시에 처음 호출해도 초기화가 정확히 한 번만 일어나도록 표준이 보장합니다(“magic statics”). GCC는 __cxa_guard_acquire로 이를 구현하며, 초기화가 끝난 뒤의 호출은 guard 변수 하나를 확인하는 비용뿐입니다.

주의할 점은 이 보장이 초기화에만 해당한다는 것입니다. 반환된 참조로 값을 수정하는 것은 일반 전역 변수와 똑같이 동기화가 필요합니다. getCache().push_back(x)를 여러 스레드에서 부르면 여전히 데이터 레이스입니다. 또 초기화 도중 같은 함수를 다시 호출하는 재귀(초기화 코드가 자기 자신을 간접적으로 부르는 경우)는 미정의 동작이고, 구현에 따라 교착에 빠지거나 예외가 납니다.

복잡한 객체도 안전

// ✅ 복잡한 초기화도 안전
#include <vector>
#include <string>

std::vector<std::string>& getDefaultPaths() {
    static std::vector<std::string> paths = {
        "/usr/local/bin",
        "/usr/bin",
        "/bin"
    };
    return paths;
}

// ✅ 다른 함수의 결과를 사용하는 초기화
#include <fstream>

std::ofstream& getLogFile() {
    static std::ofstream logFile(getConfigPath());  // getConfigPath()가 먼저 초기화를 끝냄
    return logFile;
}

참조 반환 주의사항

// ⚠️ 지역 변수 참조 반환 - 댕글링 참조
const std::string& getBadString() {
    std::string temp = "bad";
    return temp;  // ❌ 댕글링 참조
}

// ✅ 정적 지역 변수 참조 반환 - 안전
const std::string& getGoodString() {
    static std::string str = "good";
    return str;
}

해결책 2: Singleton 패턴 (Meyer’s Singleton)

함수 정적 변수로 인스턴스를 만드는 Meyer’s Singleton은 생성 시점의 문제를 위와 같은 원리로 해결합니다.

// ✅ Meyer's Singleton (C++11 이후 초기화가 스레드 안전)
#include <iostream>
#include <map>
#include <string>

class Config {
private:
    std::string configPath_;
    std::map<std::string, std::string> settings_;

    // private 생성자
    Config() : configPath_("/etc/config.txt") {
        loadSettings();
    }

    void loadSettings() {
        // 파일에서 설정 읽기
    }

public:
    // 복사/이동 금지
    Config(const Config&) = delete;
    Config& operator=(const Config&) = delete;
    Config(Config&&) = delete;
    Config& operator=(Config&&) = delete;

    static Config& getInstance() {
        static Config instance;
        return instance;
    }

    const std::string& getConfigPath() const {
        return configPath_;
    }

    const std::string& getSetting(const std::string& key) const {
        static const std::string empty;  // "" 리터럴을 쓰면 임시 string의 참조를 반환하게 됨
        auto it = settings_.find(key);
        return (it != settings_.end()) ? it->second : empty;
    }
};

// 사용
void initialize() {
    Config& config = Config::getInstance();
    std::cout << config.getConfigPath() << '\n';
}

getSetting의 반환부는 흔한 댕글링 참조 함정을 피하도록 고친 형태입니다. cond ? it->second : ""처럼 쓰면 두 피연산자의 타입이 달라 조건 연산자의 결과가 임시 std::string이 되고, 그 임시의 참조를 반환하게 됩니다. GCC는 returning reference to temporary 경고를 내지만 빌드는 통과하고, 키가 없을 때만 해제된 메모리를 읽으므로 테스트에서 놓치기 쉽습니다. 참조를 반환하는 함수에서는 “없음”을 나타낼 값도 수명이 충분한 객체여야 하며, std::optional<std::string>이나 값 반환이 더 안전한 대안입니다.

Singleton의 장단점

Singleton은 필요한 곳 어디서나 getInstance()로 꺼내 쓸 수 있고, 처음 사용할 때 생성되며, C++11 이후로는 그 생성이 스레드 안전합니다. 그래서 초기화 순서 문제는 해결됩니다. 하지만 여전히 전역 상태이므로 테스트에서 다른 구현으로 바꿔 끼우기 어렵고, 함수 시그니처에 의존성이 드러나지 않으며, 여기저기서 쓰이기 시작하면 모듈 간 결합도가 높아집니다.

“초기화 순서 문제 해결”도 생성 쪽에만 해당합니다. Meyer’s Singleton도 main()이 끝난 뒤 다른 정적 객체들과 함께 생성 역순으로 소멸되므로, 다른 Singleton의 소멸자에서 이 Singleton을 사용하면 이미 소멸된 객체에 접근할 수 있습니다. 로거처럼 “마지막까지 살아 있어야 하는” 객체는 일부러 static Logger* logger = new Logger(...); return *logger;처럼 힙에 만들고 해제하지 않는 방식(의도적 누수)을 쓰기도 합니다. 누수 검사 도구의 경고를 감수하는 대신 소멸 순서 문제를 없애는 트레이드오프입니다.

테스트 가능한 Singleton

// ✅ 테스트 가능한 Singleton
class Database {
protected:
    Database() { /* ... */ }  // private이면 MockDatabase가 상속·생성할 수 없음

public:
    static Database& getInstance() {
        static Database instance;
        return instance;
    }

    virtual ~Database() = default;

    virtual std::string query(const std::string& sql) {
        // 실제 구현
        return "";
    }
};

// ✅ Mock Database
class MockDatabase : public Database {
public:
    std::string query(const std::string& sql) override {
        return "mocked result";
    }
};

// ✅ 사용하는 쪽은 참조로 받음
void processData(Database& db) {
    std::string result = db.query("SELECT * FROM users");
    // ...
}

// 프로덕션
processData(Database::getInstance());

// 테스트
MockDatabase mockDb;
processData(mockDb);

Singleton을 쓰더라도 사용하는 함수가 getInstance()를 직접 부르지 않고 참조를 인자로 받게 하면, 테스트에서는 Mock을 넘길 수 있습니다. 이것이 다음 해결책인 의존성 주입의 출발점입니다.


해결책 3: 의존성 주입 (Dependency Injection)

의존성 주입은 필요한 객체를 전역에서 꺼내 쓰는 대신 생성자나 함수 인자로 받는 방식입니다. 전역 상태 자체가 사라지고, 객체를 만드는 순서는 main()에서 명시적으로 정해집니다.

// ✅ 의존성 주입
#include <fstream>
#include <map>
#include <string>

class Logger {
private:
    std::ofstream file_;

public:
    explicit Logger(const std::string& filename)
        : file_(filename) {}
    virtual ~Logger() = default;

    virtual void log(const std::string& message) {  // 테스트에서 재정의할 수 있도록 virtual
        file_ << message << '\n';
    }
};

class Config {
private:
    std::map<std::string, std::string> settings_;

public:
    explicit Config(const std::string& path) {
        // 설정 파일 로드
    }
    virtual ~Config() = default;

    virtual std::string get(const std::string& key) const {
        auto it = settings_.find(key);
        return (it != settings_.end()) ? it->second : "";
    }
};

class Application {
private:
    Logger& logger_;
    Config& config_;

public:
    // ✅ 의존성을 생성자로 주입
    Application(Logger& logger, Config& config)
        : logger_(logger), config_(config) {}

    void run() {
        logger_.log("Application started");
        std::string dbPath = config_.get("database_path");
        // ...
    }
};

// ✅ main에서 조립
int main() {
    Logger logger("app.log");
    Config config("/etc/config.txt");

    Application app(logger, config);
    app.run();
}

테스트가 쉬워짐

// ✅ 테스트용 Mock 객체
class MockLogger : public Logger {
public:
    std::vector<std::string> messages;
    MockLogger() : Logger("/dev/null") {}  // 기반 클래스에 기본 생성자가 없으므로 명시

    void log(const std::string& message) override {
        messages.push_back(message);
    }
};

class MockConfig : public Config {
public:
    std::map<std::string, std::string> mockSettings;
    MockConfig() : Config("") {}

    std::string get(const std::string& key) const override {
        auto it = mockSettings.find(key);
        return (it != mockSettings.end()) ? it->second : "";
    }
};

// ✅ 테스트 코드
TEST(ApplicationTest, Logging) {
    MockLogger mockLogger;
    MockConfig mockConfig;
    mockConfig.mockSettings["database_path"] = "/test/db";

    Application app(mockLogger, mockConfig);
    app.run();

    ASSERT_EQ(mockLogger.messages.size(), 1u);
    EXPECT_EQ(mockLogger.messages[0], "Application started");
}

이 테스트가 동작하려면 Logger::log와 Config::get이 virtual이어야 합니다. 가상 함수가 아니면 Application이 Logger&로 받은 객체의 log()를 호출할 때 MockLogger의 것이 아니라 Logger의 것이 불리고, override 키워드를 붙였다면 marked 'override', but does not override 컴파일 에러가 납니다. 실무에서는 구체 클래스를 상속해 Mock을 만드는 것보다 ILogger 같은 순수 가상 인터페이스를 따로 두는 편이 깔끔합니다. Mock이 실제 파일을 열 필요도 없고, 생성자 인자를 맞출 필요도 없기 때문입니다.

가상 호출 비용이 부담스러운 핫 경로라면 템플릿으로 의존성을 주입하는 방법도 있습니다(template<class Log> class Application { Log& logger_; ... }). 호출이 정적으로 결정되어 인라인이 가능하지만, 구현이 헤더로 올라가고 타입마다 코드가 따로 생성된다는 대가가 있습니다.

스마트 포인터와 함께 사용

// ✅ 스마트 포인터로 소유권 관리
#include <memory>

class Application {
private:
    std::unique_ptr<Logger> logger_;
    std::shared_ptr<Config> config_;

public:
    Application(
        std::unique_ptr<Logger> logger,
        std::shared_ptr<Config> config
    ) : logger_(std::move(logger)),
        config_(std::move(config)) {}

    void run() {
        logger_->log("Started");
        // ...
    }
};

// main
int main() {
    auto logger = std::make_unique<Logger>("app.log");
    auto config = std::make_shared<Config>("/etc/config.txt");

    Application app(std::move(logger), config);
    app.run();
}

참조로 받으면 수명 관리는 호출자(main)의 책임이고, 스마트 포인터로 받으면 Application이 소유권을 가져갑니다. Logger처럼 한 객체만 쓰는 의존성은 unique_ptr로, 여러 객체가 공유하는 설정은 shared_ptr로 받는 식으로 소유 관계를 타입에 드러낼 수 있습니다.


해결책 4: constexpr 상수

값이 바뀌지 않는 단순 상수라면 constexpr을 사용하세요.

// ❌ 수정 가능한 전역 변수
int MAX_CONNECTIONS = 100;
std::string DEFAULT_PATH = "/etc/config.txt";

// ✅ constexpr 상수 (컴파일 타임)
constexpr int MAX_CONNECTIONS = 100;

// ✅ const 상수 (읽기 전용이지만 초기화는 런타임)
const std::string DEFAULT_PATH = "/etc/config.txt";

// ✅ 함수로 제공
constexpr int getMaxConnections() {
    return 100;
}

inline const std::string& getDefaultPath() {
    static const std::string path = "/etc/config.txt";
    return path;
}

constexpr의 장점

// ✅ 컴파일 타임 계산
constexpr int square(int x) {
    return x * x;
}

constexpr int BUFFER_SIZE = square(256);  // 컴파일 타임에 계산
char buffer[BUFFER_SIZE];  // ✅ 배열 크기로 사용 가능

// ❌ 일반 전역 변수는 상수식이 아니므로 배열 크기로 쓸 수 없음
int maxConnections = 100;
// char buffer2[maxConnections];  // ❌ 컴파일 에러

헤더 파일에 상수를 둘 때는 링키지를 알아 두면 좋습니다. 네임스페이스 스코프의 const/constexpr 변수는 기본적으로 내부 링키지라서, 헤더를 포함한 번역 단위마다 별도의 사본이 생깁니다. int처럼 작은 상수는 컴파일러가 값으로 대체해 문제가 없지만, const std::string DEFAULT_PATH를 헤더에 두면 이 헤더를 포함한 파일 수만큼 문자열 객체가 만들어지고 각각 동적 초기화됩니다. C++17의 inline constexpr(또는 inline const std::string)을 쓰면 프로그램 전체에 하나만 존재하도록 할 수 있고, 문자열 상수는 constexpr std::string_view DEFAULT_PATH = "/etc/config.txt";로 두면 동적 초기화 자체가 사라집니다.


해결책 5: 네임스페이스와 익명 네임스페이스

이름 충돌 방지

// ✅ 네임스페이스 사용
namespace app {
    namespace config {
        inline const std::string& getPath() {
            static const std::string path = "/etc/config.txt";
            return path;
        }
    }

    namespace database {
        inline int getMaxConnections() {
            return 100;
        }
    }
}

// 사용
std::cout << app::config::getPath() << '\n';
std::cout << app::database::getMaxConnections() << '\n';

익명 네임스페이스 (파일 내부 전용)

// ✅ utils.cpp
namespace {  // 익명 네임스페이스
    // 이 파일 내부에서만 접근 가능
    int internalCounter = 0;

    void helperFunction() {
        ++internalCounter;
    }
}  // namespace

// public 함수
void publicFunction() {
    helperFunction();  // ✅ 같은 파일 내에서 사용 가능
}

// 다른 파일에서는 internalCounter, helperFunction 접근 불가

익명 네임스페이스 안의 이름은 내부 링키지를 가지므로 다른 파일의 같은 이름과 충돌하지 않습니다. 예전부터 쓰던 파일 스코프 static도 같은 효과를 내지만, 익명 네임스페이스는 클래스나 타입 정의에도 적용할 수 있어 현대 C++에서는 이쪽이 더 많이 권장됩니다.

// 전통적 방법: static
static int counter = 0;
static void helper() {}

// 익명 네임스페이스
namespace {
    int counter = 0;
    void helper() {}
}

언제 전역 변수를 써도 되는가?

허용 가능한 경우

1. 진짜 상수

// ✅ 수학 상수
constexpr double PI = 3.14159265358979323846;
constexpr double E = 2.71828182845904523536;

// ✅ 애플리케이션 상수
constexpr int MAX_BUFFER_SIZE = 4096;
constexpr const char* APP_NAME = "MyApp";

C++20 이상이라면 <numbers> 헤더의 std::numbers::pi, std::numbers::e를 쓰는 편이 낫습니다.

2. 로깅 (단, 조심스럽게)

// ✅ 로거는 전역 접근이 흔히 허용되는 예외 (단, log() 내부는 스레드 안전해야 함)
namespace logging {
    inline Logger& getGlobalLogger() {
        static Logger logger("app.log");
        return logger;
    }
}

// 사용
logging::getGlobalLogger().log("message");

로거가 예외로 인정받는 이유는 로깅이 프로그램 동작에 영향을 주지 않는 부수 기능이기 때문입니다. 모든 함수에 Logger& 매개변수를 추가하면 시그니처가 지저분해지는 반면, 테스트에서 로그 출력을 바꿀 일은 많지 않습니다. 다만 로거는 여러 스레드가 동시에 쓰는 대표적인 객체이므로 log() 내부에서 락이나 큐로 쓰기를 직렬화해야 합니다. spdlog 같은 라이브러리가 _mt(multi-thread) 싱크와 _st(single-thread) 싱크를 구분해 제공하는 것도 이 때문입니다.

3. 표준 라이브러리 전역 객체

// ✅ 표준 라이브러리 전역 객체는 사용 가능
#include <iostream>

int main() {
    std::cout << "Hello\n";  // std::cout은 전역 객체
    std::cerr << "Error\n";  // std::cerr도 전역 객체
}

std::cout 같은 표준 스트림은 <iostream>을 포함한 번역 단위라면 그 안의 전역 객체 생성자·소멸자에서도 쓸 수 있도록 표준이 초기화 시점을 보장합니다(std::ios_base::Init 메커니즘). 직접 만든 전역 객체에는 이런 보장이 없다는 점이 차이입니다.

피해야 하는 경우

수정 가능한 전역 상태(int globalCounter, std::vector<int> globalData), 생성자에서 DB 연결이나 네트워크 설정 같은 복잡한 초기화를 하는 전역 객체(Database globalDB("connection_string")), 그리고 전역 std::map에 담긴 설정 정보는 앞에서 본 문제를 대부분 그대로 가지고 있습니다. 설정은 의존성 주입으로 넘기거나, 꼭 전역 접근이 필요하다면 함수 정적 변수로 감싸는 편이 낫습니다.


리팩토링 가이드: 전역 변수 제거하기

단계별 리팩토링 전략

단계 1: 전역 변수 식별

// 단계 1: 현재 전역 변수
std::string configPath = "/etc/config.txt";
int maxConnections = 100;
Logger globalLogger("app.log");

단계 2: 함수로 감싸기

// 단계 2: 함수로 감싸 초기화 순서 문제부터 제거
const std::string& getConfigPath() {
    static const std::string configPath = "/etc/config.txt";
    return configPath;
}

int getMaxConnections() {
    return 100;
}

Logger& getLogger() {
    static Logger logger("app.log");
    return logger;
}

이 단계는 호출하는 쪽을 configPath에서 getConfigPath()로 바꾸는 기계적인 치환이라 위험이 적고, 초기화 순서 문제는 여기서 이미 사라집니다.

단계 3: 의존성 주입으로 변경

// 단계 3-1: 관련 설정을 클래스로 묶기
class AppConfig {
private:
    std::string configPath_;
    int maxConnections_;

public:
    AppConfig()
        : configPath_("/etc/config.txt")
        , maxConnections_(100) {}

    const std::string& getConfigPath() const { return configPath_; }
    int getMaxConnections() const { return maxConnections_; }
};

// 단계 3-2: 생성자로 주입
class Application {
private:
    AppConfig& config_;
    Logger& logger_;

public:
    Application(AppConfig& config, Logger& logger)
        : config_(config), logger_(logger) {}

    void run() {
        logger_.log("Started with config: " + config_.getConfigPath());
    }
};

단계 4: 테스트 추가

// 단계 4: 테스트
// AppConfig는 단순한 값 객체라 그대로 쓰고, Logger만 앞에서 만든 MockLogger로 바꿈
TEST(ApplicationTest, StartsWithConfig) {
    AppConfig config;
    MockLogger logger;

    Application app(config, logger);
    app.run();

    ASSERT_EQ(logger.messages.size(), 1u);
    EXPECT_EQ(logger.messages[0], "Started with config: /etc/config.txt");
}

AppConfig처럼 부작용 없는 값 객체는 Mock으로 바꿀 필요 없이 실제 객체를 쓰면 됩니다. 파일이나 네트워크에 접근하는 Logger 같은 의존성만 가상 함수나 인터페이스로 교체 가능하게 만들어 두면 충분합니다.

대규모 프로젝트 리팩토링

// Before: 전역 변수 의존
// main.cpp
Database globalDB;
Cache globalCache;
Logger globalLogger;

void processRequest(const Request& req) {
    globalLogger.log("Processing request");
    auto data = globalDB.query(req.getSql());
    globalCache.store(req.getId(), data);
}

// After: 의존성 주입
// main.cpp
class RequestProcessor {
private:
    Database& db_;
    Cache& cache_;
    Logger& logger_;

public:
    RequestProcessor(Database& db, Cache& cache, Logger& logger)
        : db_(db), cache_(cache), logger_(logger) {}

    void process(const Request& req) {
        logger_.log("Processing request");
        auto data = db_.query(req.getSql());
        cache_.store(req.getId(), data);
    }
};

int main() {
    // 한 곳에서 모든 의존성 생성
    Database db("connection_string");
    Cache cache(1024);
    Logger logger("app.log");

    RequestProcessor processor(db, cache, logger);

    Request req;
    processor.process(req);
}

main() 안에서 지역 변수로 만든 객체는 선언 순서대로 생성되고 역순으로 소멸되므로, 전역 객체에서 문제가 되던 생성·소멸 순서가 코드에 그대로 드러납니다.


실행해서 확인한 결과

이 글의 설명은 Ubuntu 24.04 aarch64 컨테이너(g++ 13.3, GNU ld 2.42)에서 확인했습니다. 앞에 나온 clang++(macOS) 확인과 같은 결론이 GNU ld에서도 나왔습니다.

  • 초기화 순서와 링크 순서: a.cpp의 int x = y + 1;이 b.cpp의 int y = f();(f()는 42)에 의존하게 만들고 g++ a.o b.o m.o로 링크하면 x=1 y=42, g++ b.o a.o m.o로 오브젝트 순서만 바꾸면 x=43 y=42였습니다. 같은 변수를 함수 안 static으로 바꾸면 두 순서 모두 x=43이었습니다.
  • std::string 의존: 글의 config.cpp·logger.cpp·main.cpp는 config logger main 순서에서는 로그 파일이 열렸고(is_open=1), logger config main 순서에서는 크래시 없이 열리지 않았습니다(is_open=0). 이 환경에서는 조용히 실패하는 쪽으로 나왔고, 크래시나 엉뚱한 경로가 나올 수 있다는 설명은 다른 환경·순서에서 확인하지 못했습니다.
  • 데이터 레이스: 두 스레드가 ++globalCounter를 10만 번씩 실행하는 예제는 -O0에서 113,500이 출력됐습니다. 기대값 200,000보다 작은 값이 나온다는 설명과 맞고, 값은 실행마다 달라집니다.

글의 다른 예제는 앞의 두 조각처럼 외부 파일이나 앞 코드를 전제로 해서 단독으로는 컴파일되지 않는 것이 있었고(Logger, Database 정의가 빠진 조각), 11개 중 9개는 컴파일과 실행이 됐습니다. 테스트 간 간섭 같은 설계 조언은 실행으로 확인할 성격이 아닙니다.

그 밖의 패턴

명시적 초기화 함수

// ✅ 명시적 초기화
#include <stdexcept>
#include <string>

class AppContext {
private:
    static AppContext* instance_;
    std::string configPath_;

    AppContext() = default;

    void loadConfig(const std::string& path) {
        configPath_ = path;
        // 설정 파일 읽기
    }

public:
    static void initialize(const std::string& configPath) {
        if (!instance_) {
            instance_ = new AppContext();
            instance_->loadConfig(configPath);
        }
    }

    static AppContext& getInstance() {
        if (!instance_) {
            throw std::runtime_error("AppContext not initialized");
        }
        return *instance_;
    }

    static void shutdown() {
        delete instance_;
        instance_ = nullptr;
    }
};

AppContext* AppContext::instance_ = nullptr;

// 사용
int main() {
    AppContext::initialize("/etc/config.txt");

    AppContext& ctx = AppContext::getInstance();
    // ...

    AppContext::shutdown();
}

Meyer’s Singleton과 달리 초기화 시점과 인자를 main()에서 직접 정할 수 있고, 초기화 전에 접근하면 예외로 바로 드러납니다. 대신 initialize()와 shutdown()은 스레드 안전하지 않으므로, 다른 스레드를 띄우기 전에 호출하고 모든 스레드가 끝난 뒤에 정리해야 합니다.

스레드 로컬 저장소

// ✅ 스레드별 전역 변수
#include <iostream>
#include <thread>

thread_local int threadCounter = 0;  // 각 스레드마다 별도 인스턴스

void incrementThreadCounter() {
    ++threadCounter;  // 스레드마다 다른 변수라 데이터 레이스 없음
}

int main() {
    std::thread t1([]() {
        incrementThreadCounter();
        std::cout << "Thread 1: " << threadCounter << '\n';  // 1
    });

    std::thread t2([]() {
        incrementThreadCounter();
        incrementThreadCounter();
        std::cout << "Thread 2: " << threadCounter << '\n';  // 2
    });

    t1.join();
    t2.join();

    std::cout << "Main: " << threadCounter << '\n';  // 0
}

두 스레드가 std::cout에 동시에 쓰므로 출력 줄의 순서나 섞임은 실행마다 다를 수 있습니다. 각 스레드가 보는 값 자체는 주석과 같습니다.

RAII로 전역 상태 관리

// ✅ RAII로 전역 상태 초기화/정리
class GlobalStateGuard {
private:
    static int refCount_;

public:
    GlobalStateGuard() {
        if (refCount_++ == 0) {
            // 첫 번째 인스턴스: 전역 상태 초기화
            initializeGlobalState();
        }
    }

    ~GlobalStateGuard() {
        if (--refCount_ == 0) {
            // 마지막 인스턴스: 전역 상태 정리
            cleanupGlobalState();
        }
    }

private:
    void initializeGlobalState() {
        // 전역 리소스 초기화
    }

    void cleanupGlobalState() {
        // 전역 리소스 정리
    }
};

int GlobalStateGuard::refCount_ = 0;

// 사용
int main() {
    GlobalStateGuard guard;  // 초기화

    // 작업 수행

}  // guard 소멸 시 자동으로 정리

라이브러리 초기화 함수(curl_global_init 같은)를 감쌀 때 쓰는 형태입니다. refCount_가 일반 int이므로 여러 스레드에서 가드를 만들 수 있다면 std::atomic<int>나 뮤텍스로 보호해야 합니다.