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)로 인한 크래시가 특정 빌드 환경이나 링크 순서에서만 재현되는 것을 한두 번 겪고 나면 태도가 바뀌는 경우가 많습니다.

특히 멀티스레드 환경에서 전역 변수에 대한 데이터 레이스는 재현 자체가 어렵다는 점이 다른 버그와 다릅니다. 로컬에서는 며칠씩 돌려도 재현되지 않다가 프로덕션의 특정 부하 패턴에서만 드러나는 경우가 흔하고, 전역 설정 객체가 다른 모듈보다 늦게 초기화되는 문제는 플랫폼·컴파일러·링크 순서에 따라 증상이 달라 QA 단계에서 걸러지지 않고 그대로 배포되는 경우도 많습니다.

이 글에서는 이런 함정들과, 함수 정적 변수·Singleton·의존성 주입 같은 실전 대안 패턴을 정리합니다.


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

가장 치명적인 문제

서로 다른 번역 단위(파일)의 전역 변수 초기화 순서는 정해지지 않습니다. 초기화되지 않은 전역 변수를 사용하면 정의되지 않은 동작(UB)이 발생합니다.

// ❌ 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
extern std::ofstream logFile;

int main() {
    logFile << "Application started\n";  // ❌ 크래시 가능
}

실행 결과 (플랫폼/컴파일러마다 다름):

  • 경우 1: configPath가 먼저 초기화 → 정상 동작
  • 경우 2: logFile이 먼저 초기화 → 빈 문자열로 파일 열기 시도 → 크래시 또는 잘못된 동작
  • 경우 3: 디버그 빌드에서는 정상, 릴리스 빌드에서 크래시

왜 발생하는가

C++ 표준은 다른 번역 단위 간 초기화 순서를 보장하지 않습니다.

// 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가 됩니다. 문제는 동적 초기화가 필요한 경우뿐이고, 그때 읽히는 값은 “쓰레기 값”이 아니라 0으로 채워진 상태입니다. int라면 0으로 읽히고 끝나지만, std::string이나 std::map처럼 생성자가 아직 실행되지 않은 객체를 0으로 채워진 메모리 그대로 쓰면 내부 포인터가 null이라 크래시하거나 미정의 동작이 됩니다. C++20의 constinit을 붙이면 “이 변수는 반드시 정적 초기화되어야 한다”고 선언할 수 있고, 동적 초기화가 필요하면 컴파일 에러가 나므로 의도치 않은 순서 의존을 미리 막을 수 있습니다.

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

// file.cpp
int a = 10;
int b = a + 5;  // ✅ a가 먼저 초기화됨 (선언 순서)

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

복잡한 초기화 체인

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

extern std::string configPath;

class Database {
public:
    Database(const std::string& path) {
        // configPath를 사용하여 DB 연결
    }
};

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가 nullptr일 수 있음!
            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 <thread>
#include <vector>

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
    // 실제: 123456 (랜덤한 값, 데이터 레이스)
}

실행 결과:

// 여러 번 실행하면 매번 다른 결과
Run 1: 187234
Run 2: 192456
Run 3: 178901

왜 발생하는가

++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();
    
    // 크래시 또는 데이터 손실
}

초기화 시점도 위험

// ❌ 전역 변수 초기화도 스레드 안전하지 않음
#include <string>

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

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

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

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


문제 3: 테스트 어려움

단위 테스트가 불가능해짐

전역 변수를 사용하면 함수 간 독립성이 깨져 테스트가 매우 어려워집니다.

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

// 전역 설정
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() {
    // ❌ 문제 1: 전역 상태 설정 필요
    globalConfig["base_price"] = "100";
    globalConfig["discount"] = "0.1";
    
    int result = calculatePrice(5);
    assert(result == 450);
    
    // ❌ 문제 2: 테스트 후 전역 상태 정리 필요
    globalConfig.clear();
    
    // ❌ 문제 3: 다른 테스트가 globalConfig를 수정하면?
    // 테스트 간 의존성 발생!
}

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";
    EXPECT_EQ(calculatePrice(5), 500);
}  // globalConfig가 그대로 남음!

// ❌ 테스트 2 - 이전 테스트의 영향을 받음
TEST(PriceTest, WithDiscount) {
    // base_price가 이미 설정되어 있다고 가정
    globalConfig["discount"] = "0.1";
    EXPECT_EQ(calculatePrice(5), 450);
    // 테스트 1이 실행되지 않으면 실패!
}

목(Mock) 객체 사용 불가

// ❌ 전역 변수는 Mock 불가능
class Database {
public:
    virtual std::string query(const std::string& sql) {
        // 실제 DB 접근
    }
};

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. Mock DB로 교체 불가능
// 4. 테스트가 느림 (실제 DB I/O)

테스트 간 간섭은 테스트를 병렬로 돌리기 시작할 때 더 심해집니다. ctest -j8처럼 테스트 바이너리를 여러 개 동시에 돌리는 것은 프로세스가 분리되어 괜찮지만, 한 바이너리 안에서 테스트를 섞은 순서로 실행하는 --gtest_shuffle을 켜면 순서 의존 테스트가 무작위로 실패합니다. 저는 “혼자 돌리면 통과하는데 전체로 돌리면 가끔 실패하는” 테스트를 보면 가장 먼저 전역 상태를 의심하는데, 대부분 이 경우였습니다. 전역을 당장 없앨 수 없다면 테스트 픽스처의 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;  // 내 코드의 전역 변수

// 충돌!

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

// ✅ 네임스페이스 사용
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 connectionLimit;

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

명시적 의존성의 장점

// ✅ 의존성을 명시
void connectToServer(
    std::ofstream& log,           // 명시적 의존성
    const std::string& config,    // 명시적 의존성
    int limit                     // 명시적 의존성
) {
    // 함수 시그니처만 봐도 필요한 것들을 알 수 있음
    if (connectionCount >= 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의 타입을 바꾸려면?
// → 모든 사용처를 찾아서 수정해야 함
// → IDE의 리팩토링 도구가 제대로 작동하지 않음

해결책 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) {
    //         logger = "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());  // ✅ 안전!
    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)

스레드 안전한 Singleton

함수 정적 변수를 사용한 Meyer’s Singleton 패턴이 가장 안전합니다.

// ✅ Meyer's Singleton (C++11 이후 스레드 안전)
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의 장단점

장점:

  • 전역 접근 가능 (필요한 곳에서 쉽게 사용)
  • 지연 초기화 (사용할 때 생성)
  • 스레드 안전 (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 std::string query(const std::string& sql) {
        // 실제 구현
        return "";
    }
    
    // ✅ 테스트용 인터페이스
    virtual ~Database() = default;
};

// ✅ 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);

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

가장 깔끔한 해결책

의존성 주입은 전역 상태를 완전히 제거하는 방법입니다.

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

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();
    
    EXPECT_EQ(mockLogger.messages.size(), 1);
    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();
}

해결책 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];  // ✅ 배열 크기로 사용 가능

// ✅ 타입 안전
enum class ConnectionLimit {
    MAX = 100
};

// ❌ 전역 변수는 컴파일 타임에 사용 불가
int maxConnections = 100;
// char buffer[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 접근 불가

익명 네임스페이스 vs static:

// 전통적 방법: static
static int counter = 0;  // 파일 스코프 static
static void helper() {}  // 파일 스코프 static

// 현대적 방법: 익명 네임스페이스
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";

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

// ✅ 로거는 전역 접근이 흔히 허용되는 예외 (단, log() 내부는 스레드 안전해야 함)
namespace logging {
    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>

std::cout << "Hello\n";  // std::cout은 전역 객체
std::cerr << "Error\n";  // std::cerr도 전역 객체

피해야 하는 경우

1. 가변 전역 상태

// ❌ 가변 전역 변수
int globalCounter = 0;
std::vector<int> globalData;

2. 복잡한 전역 객체

// ❌ 복잡한 초기화가 필요한 전역 객체
Database globalDB("connection_string");
HttpClient globalClient("http://api.example.com");

3. 설정/구성 정보

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

// ✅ 의존성 주입 또는 Singleton

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

단계별 리팩토링 전략

단계 1: 전역 변수 식별

// Before: 전역 변수 사용
std::string configPath = "/etc/config.txt";
int maxConnections = 100;
Logger globalLogger("app.log");

단계 2: 함수로 감싸기

// Step 1: 함수로 감싸기
const std::string& getConfigPath() {
    static const std::string configPath = "/etc/config.txt";
    return configPath;
}

int getMaxConnections() {
    return 100;  // 또는 static int maxConnections = 100; return maxConnections;
}

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

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

// Step 2: 클래스로 묶기
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_; }
};

// Step 3: 의존성 주입
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: 테스트 추가

// Step 4: 테스트
TEST(ApplicationTest, StartsWithConfig) {
    MockConfig config;
    MockLogger logger;
    
    Application app(config, logger);
    app.run();
    
    EXPECT_TRUE(logger.hasMessage("Started"));
}

대규모 프로젝트 리팩토링

// 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);
}

실전 베스트 프랙티스

함수 정적 변수 사용

// ✅ 가장 추천: 함수 정적 변수
const Config& getConfig() {
    static const Config config("/etc/config.txt");
    return config;
}

// 사용
void initialize() {
    const Config& cfg = getConfig();
    // ...
}

const 참조로 전달

// ✅ 수정 불가능하게 만들기
const std::string& getAppName() {
    static const std::string appName = "MyApp";
    return appName;  // const 참조 반환
}

// 사용
const std::string& name = getAppName();
// name = "OtherApp";  // ❌ 컴파일 에러

초기화 함수 제공

// ✅ 명시적 초기화
class AppContext {
private:
    static AppContext* instance_;
    
    AppContext() = default;
    
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();
}

스레드 로컬 저장소

// ✅ 스레드별 전역 변수
#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
}

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 소멸 시 자동으로 정리
}

정리 및 결론

전역 변수의 5가지 문제점

문제설명해결책
초기화 순서다른 파일 간 초기화 순서 미보장함수 정적 변수
스레드 안전성데이터 레이스 발생초기화는 함수 정적 변수(C++11), 이후 수정은 atomic·mutex
테스트 어려움전역 상태로 인한 테스트 간 간섭의존성 주입
네임스페이스 오염이름 충돌네임스페이스, 익명 네임스페이스
암묵적 의존성코드 이해 및 유지보수 어려움명시적 파라미터

권장 대안

// 1순위: 함수 정적 변수
const Config& getConfig() {
    static const Config config;
    return config;
}

// 2순위: Singleton (필요한 경우)
class Database {
public:
    static Database& getInstance() {
        static Database instance;
        return instance;
    }
};

// 3순위: 의존성 주입 (가장 깔끔)
class Application {
public:
    Application(Config& config, Logger& logger)
        : config_(config), logger_(logger) {}
};

// 4순위: constexpr 상수 (단순 상수)
constexpr int MAX_SIZE = 1024;

체크리스트

전역 변수를 만들기 전에 자문하세요:

  • 정말 전역이어야 하나?
  • 함수 정적 변수로 대체 가능한가?
  • const 또는 constexpr로 선언 가능한가?
  • 의존성 주입으로 전달 가능한가?
  • 초기화 순서 문제가 발생하지 않는가?
  • 스레드 안전한가?
  • 테스트 가능한가?

같이 보면 좋은 글