C++ 링키지와 저장 기간: external·internal 연결, static·thread_local, 초기화 순서

이 글의 핵심

링키지(linkage)는 어떤 이름이 여러 번역 단위(.cpp 파일)에서 같은 대상을 가리키는지에 대한 규칙이고, 저장 기간(storage duration)은 객체가 실제로 메모리에 살아있는 기간을 결정합니다. 이 둘은 독립적인 축이지만 static이라는 하나의 키워드가 문맥에 따라 둘 다에 영향을 줄 수 있어 혼동하기 쉽습니다. 이 글은 두 개념을 명확히 구분하고, extern 중복 정의나 static 초기화 순서 문제 같은 실전 함정을 정리합니다.

Linkage 종류

static 키워드가 이 글 전체에서 혼란의 근원이 되는 이유는, 쓰이는 위치에 따라 링키지에 영향을 주기도 하고(파일 범위) 저장 기간에 영향을 주기도 하기 때문입니다(함수 내부). 먼저 링키지 세 가지부터 구분합니다.

External Linkage

// file1.cpp
int globalVar = 10;  // external linkage

void func() {  // external linkage
    cout << globalVar << endl;
}

// file2.cpp
extern int globalVar;  // 선언
extern void func();    // 선언

int main() {
    cout << globalVar << endl;  // 10
    func();
}

전역 범위에 선언된 변수/함수는 기본적으로 외부 링키지를 가집니다. 이는 file2.cpp가 extern으로 그 이름을 다시 선언하기만 하면 file1.cpp에 정의된 바로 그 globalVar를 링크 단계에서 찾아 연결한다는 뜻입니다.

함수 선언 앞의 extern은 생략해도 의미가 같습니다. 함수 선언은 원래 외부 링키지를 가지므로 void func();만 써도 됩니다. 변수는 다릅니다. int globalVar;는 초기값이 없어도 정의이므로, extern 없이 쓰면 두 파일에 같은 변수가 두 번 정의됩니다. 이 차이가 헤더에 전역 변수를 둘 때 가장 많이 걸려 넘어지는 지점입니다.

컴파일러는 파일 하나씩 따로 보기 때문에 이 연결이 맞는지 확인할 수 없고, 문제는 링크 단계에서야 드러납니다. file2.cpp에 선언만 있고 정의가 어디에도 없으면 GNU 링커는 undefined reference to 'globalVar'를, MSVC는 LNK2001: unresolved external symbol을 냅니다. 선언의 타입이 정의와 다르면(extern double globalVar;) 더 위험합니다. 변수 이름은 C++에서도 대개 타입 정보 없이 맹글링되므로 링크가 그대로 성공하고, 실행 중에 int 4바이트를 double 8바이트로 읽는 미정의 동작이 됩니다. 선언을 공용 헤더 하나에 두고 정의하는 .cpp도 그 헤더를 include하게 하면, 타입이 어긋났을 때 컴파일러가 잡아 줍니다.

Internal Linkage

// file1.cpp
static int internalVar = 10;  // internal linkage

static void internalFunc() {  // internal linkage
    cout << internalVar << endl;
}

// file2.cpp
// internalVar와 internalFunc는 접근 불가

전역 범위에서 static을 붙이면 그 이름은 이 번역 단위 밖에서는 아예 보이지 않게 됩니다. 이게 유용한 이유는, 여러 .cpp 파일이 우연히 같은 이름의 헬퍼 함수나 변수를 각자 만들어도 static으로 감춰두면 링크 단계에서 이름 충돌이 나지 않기 때문입니다 — 파일 내부에서만 쓰는 구현 세부사항을 캡슐화하는 전통적인 방법입니다.

No Linkage

void func() {
    int localVar = 10;  // no linkage
    // 이 함수 내에서만 존재
}

지역 변수는 애초에 함수 바깥에서 이름으로 참조할 방법이 없으므로 링키지 개념 자체가 적용되지 않습니다.

Storage Duration

Automatic Storage

void func() {
    int x = 10;  // automatic storage
    // 함수 종료 시 자동 파괴
}

Static Storage

// 전역 변수
int global = 10;  // static storage

void func() {
    static int counter = 0;  // static storage
    counter++;
    cout << counter << endl;
}

int main() {
    func();  // 1
    func();  // 2
    func();  // 3
}

여기서 함수 내부의 static int counter가 바로 앞서 말한 “static이 저장 기간에 영향을 주는” 경우입니다. counter는 여전히 func() 안에서만 이름으로 접근할 수 있으므로 링키지는 없지만(no linkage), 메모리에는 프로그램이 끝날 때까지 단 한 번만 존재합니다(static storage) — 그래서 func()를 여러 번 호출해도 매번 0으로 리셋되지 않고 값이 누적됩니다.

Thread Storage

#include <thread>

thread_local int threadVar = 0;  // thread storage

void increment() {
    threadVar++;
    cout << "스레드 " << this_thread::get_id()
         << ": " << threadVar << endl;
}

int main() {
    thread t1(increment);
    thread t2(increment);

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

    // 각 스레드마다 독립적인 threadVar
}

thread_local은 static storage와 비슷하게 오래 살아 있지만, 수명의 기준이 프로그램이 아니라 스레드라는 점이 다릅니다. 스레드가 시작되면 그 스레드용 복사본이 생기고, 스레드가 끝날 때 파괴됩니다. t1과 t2는 이름은 같은 threadVar를 쓰지만 실제로는 서로 완전히 독립된 메모리 위치를 갖게 되어, 한 스레드의 증가가 다른 스레드에 전혀 영향을 주지 않습니다.

스레드 풀과 함께 쓸 때는 이 수명 규칙이 함정이 됩니다. 풀의 워커 스레드는 여러 작업을 차례로 실행하면서 종료되지 않으므로, 한 작업이 thread_local에 남긴 값이 같은 스레드에서 실행되는 다음 작업에 그대로 보입니다. 요청마다 초기화된다고 가정하고 사용자 ID나 트랜잭션 상태를 thread_local에 넣으면, 다른 사용자의 요청에 이전 값이 섞이는 심각한 버그가 됩니다. 이런 상태는 작업 시작 시 명시적으로 초기화하거나, 작업 컨텍스트 객체로 넘기는 편이 안전합니다.

Dynamic Storage

int* ptr = new int(10);  // dynamic storage
// 명시적 delete 필요
delete ptr;

실전 예시

예시 1: 싱글톤 (static)

class Singleton {
private:
    Singleton() {
        cout << "싱글톤 생성" << endl;
    }

public:
    static Singleton& getInstance() {
        static Singleton instance;  // static storage
        return instance;
    }

    void doSomething() {
        cout << "작업 수행" << endl;
    }
};

int main() {
    Singleton::getInstance().doSomething();
    Singleton::getInstance().doSomething();
    // 한 번만 생성됨
}

이 패턴(Meyers 싱글턴)이 안전한 이유는 함수 내부의 static 지역 변수가 C++11부터 스레드 안전하게 초기화되도록 표준이 보장하기 때문입니다. 여러 스레드가 동시에 getInstance()를 처음 호출해도 instance는 정확히 한 번만 생성되며, 이는 뒤에서 다룰 전역 변수의 초기화 순서 문제를 함수 호출 시점까지 늦춰서 회피하는 방식이기도 합니다.

예시 2: 함수 호출 카운터

void trackedFunction() {
    static int callCount = 0;  // static storage
    callCount++;

    cout << "호출 횟수: " << callCount << endl;
}

int main() {
    trackedFunction();  // 1
    trackedFunction();  // 2
    trackedFunction();  // 3
}

예시 3: 스레드별 캐시

#include <thread>
#include <vector>

thread_local vector<int> cache;

void processData(int data) {
    cache.push_back(data);

    cout << "스레드 " << this_thread::get_id()
         << " 캐시 크기: " << cache.size() << endl;
}

int main() {
    thread t1([] {
        for (int i = 0; i < 5; i++) {
            processData(i);
        }
    });

    thread t2([] {
        for (int i = 0; i < 3; i++) {
            processData(i);
        }
    });

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

    // 각 스레드마다 독립적인 cache
}

t1이 5번, t2가 3번 processData를 호출해도 서로의 cache.size()에 전혀 영향을 주지 않는 것은, 뮤텍스로 보호할 필요 없이 thread_local이 데이터 레이스 자체를 구조적으로 없애주기 때문입니다. 스레드마다 독립된 작업 버퍼가 필요한 경우, 매번 락을 잡는 공유 컨테이너보다 이 방식이 더 간단하고 빠른 경우가 많습니다.

예시 4: 익명 네임스페이스

// file1.cpp
namespace {
    int internalVar = 10;  // internal linkage

    void internalFunc() {
        cout << internalVar << endl;
    }
}

void publicFunc() {
    internalFunc();  // OK
}

// file2.cpp
// internalVar와 internalFunc 접근 불가

익명 네임스페이스는 앞서 본 파일 범위 static과 정확히 같은 효과(내부 링키지)를 내는 현대적인 대안입니다. 모던 C++ 스타일 가이드 다수가 개별 static 선언보다 익명 네임스페이스로 묶는 것을 권장하는데, 여러 개의 내부 전용 심볼을 한 블록으로 묶어 “이 파일에서만 쓰는 것들”이라는 의도를 한눈에 드러낼 수 있기 때문입니다.

extern 사용법

변수 선언

// globals.cpp
int globalCounter = 0;  // 정의

// main.cpp
extern int globalCounter;  // 선언

int main() {
    globalCounter++;
    cout << globalCounter << endl;
}

const와 extern

// file1.cpp
extern const int value = 10;  // external linkage

// file2.cpp
extern const int value;  // 선언

int main() {
    cout << value << endl;  // 10
}

const 전역 변수는 기본적으로 내부 링키지를 갖는다는 점(C++가 C와 다른 부분)을 놓치기 쉽습니다. const int value = 10;이라고만 쓰면 static을 안 붙였어도 이미 내부 링키지가 되어 다른 파일에서 extern으로 참조할 수 없으므로, 여러 파일에서 공유하려는 의도라면 반드시 정의하는 쪽에 extern을 명시적으로 붙여 외부 링키지로 바꿔줘야 합니다.

템플릿 extern

// template.h
template<typename T>
void func(T value);

// template.cpp
template<typename T>
void func(T value) {
    cout << value << endl;
}

// 명시적 인스턴스화
template void func<int>(int);

// main.cpp
extern template void func<int>(int);  // 선언

int main() {
    func(10);
}

extern template은 “이 템플릿의 int 버전은 이미 다른 곳(template.cpp)에서 인스턴스화되었으니, 이 번역 단위에서는 다시 인스턴스화하지 말라”는 선언입니다. 헤더에 정의된 템플릿을 여러 .cpp 파일이 include하면 각 파일마다 같은 인스턴스가 중복 생성되었다가 링커가 하나로 합치는 낭비가 생기는데, 이 선언으로 컴파일 시간과 오브젝트 파일 크기를 줄일 수 있습니다.

다만 위 예제처럼 헤더에 선언만 있고 정의가 template.cpp에만 있다면, main.cpp는 애초에 본문을 볼 수 없어 인스턴스를 만들 수 없으므로 extern template 줄이 없어도 결과는 같습니다. extern template이 실제로 효과를 내는 것은 템플릿 정의가 헤더에 있을 때입니다. 반대로 이 예제 구조에서 template.cpp의 명시적 인스턴스화를 지우면, 컴파일은 되지만 링크 단계에서 undefined reference to 'void func<int>(int)'가 납니다. “템플릿 정의를 .cpp로 옮겼더니 링크 에러가 난다”는 흔한 질문의 원인이 이것이며, 명시적으로 인스턴스화한 타입 외에는 쓸 수 없다는 제약을 감수해야 합니다.

static 키워드

함수 내 static

void func() {
    static int x = 0;  // 한 번만 초기화
    x++;
    cout << x << endl;
}

int main() {
    func();  // 1
    func();  // 2
    func();  // 3
}

클래스 static 멤버

class Counter {
private:
    static int count;  // 선언

public:
    Counter() { count++; }
    static int getCount() { return count; }
};

int Counter::count = 0;  // 정의

int main() {
    Counter c1, c2, c3;
    cout << Counter::getCount() << endl;  // 3
}

클래스 안의 static int count;는 선언일 뿐이고, 클래스 밖에 int Counter::count = 0;이라는 별도의 정의가 반드시 필요합니다(C++17 inline static 이전 기준). 이 정의를 빠뜨리면 Counter::getCount()를 실제로 호출하는 시점에 undefined reference to 'Counter::count' 링크 에러가 나는데, 헤더에만 클래스를 선언하고 .cpp 파일에 이 정의를 넣는 것을 잊는 실수가 실무에서 흔히 나옵니다. C++17부터는 클래스 안에 inline static int count = 0;이라고 쓰면 선언과 정의가 한 번에 되어 .cpp 쪽 정의가 필요 없습니다. inline 변수는 여러 번역 단위에 같은 정의가 나타나도 링커가 하나로 합쳐 주기 때문이며, 같은 원리로 네임스페이스 범위의 전역 상수도 헤더에 inline constexpr int kMaxUsers = 100;처럼 둘 수 있습니다.

자주 발생하는 문제

문제 1: static 초기화 순서

// file1.cpp
int x = compute();  // 먼저?

// file2.cpp
int y = compute();  // 먼저?

// 순서 미정의!

// ✅ 함수 내 static 사용
int& getX() {
    static int x = compute();
    return x;
}

서로 다른 번역 단위에 있는 전역 객체들의 초기화 순서는 표준이 보장하지 않습니다(Static Initialization Order Fiasco). x의 초기화가 y의 초기화에 의존한다면, 링크 순서나 컴파일러에 따라 y가 아직 초기화되지 않은 상태로 쓰일 위험이 있습니다. 함수 내부의 static 지역 변수로 바꾸면 초기화가 “처음 호출되는 시점”까지 지연(lazy initialization)되므로, 어느 파일이 먼저 로드되는지와 무관하게 항상 필요한 순간에 정확히 초기화됩니다.

이 버그가 악명 높은 이유는 재현 조건이 코드가 아니라 빌드 설정에 있기 때문입니다. 같은 소스라도 링크 명령에서 오브젝트 파일 순서가 바뀌거나, CMake가 소스 목록을 다른 순서로 넘기거나, 정적 라이브러리를 공유 라이브러리로 바꾸는 순간 초기화 순서가 달라집니다. “어제까지 잘 되던 프로그램이 파일 하나 추가했더니 main 진입 전에 죽는다”는 증상이 대표적이며, 디버거로 보면 main보다 앞선 전역 생성자 안에서 크래시가 나 있습니다. 함수 내 static으로 바꿀 때는 반대 방향인 파괴 순서도 생각해야 합니다. 정적 객체는 생성의 역순으로 파괴되므로, 한 전역 객체의 소멸자가 이미 파괴된 다른 함수 내 static 객체를 쓰면 종료 시점에 크래시가 납니다. 로거처럼 종료 직전까지 쓰이는 객체는 의도적으로 new로 만들고 해제하지 않는 방식(static Logger* p = new Logger;)이 쓰이기도 합니다. C++20의 constinit을 붙이면 전역 변수가 반드시 컴파일 시간에 초기화되도록 강제할 수 있어, 순서 문제가 없는 정적 초기화인지 컴파일러가 확인해 줍니다.

문제 2: extern 중복 정의

// ❌ 링크 에러
// file1.cpp
int value = 10;

// file2.cpp
int value = 20;  // 중복 정의!

// ✅ 한 곳에서만 정의
// file1.cpp
int value = 10;

// file2.cpp
extern int value;

이 문제는 헤더 파일에 (선언이 아니라) 정의를 실수로 넣었을 때도 똑같이 발생합니다. 여러 .cpp가 그 헤더를 include하면 각 번역 단위마다 정의가 복제되어 value가 여러 번 정의된 것으로 취급됩니다 — 헤더에는 extern int value;(선언)만 두고, 실제 정의(int value = 10;)는 단 하나의 .cpp 파일에만 두는 것이 원칙입니다. GNU 링커에서는 multiple definition of 'value'; file1.o: first defined here, MSVC에서는 LNK2005: "int value" already defined in file1.obj 형태의 에러가 납니다. C++17 이상이라면 헤더에 inline int value = 10;으로 정의하는 방법도 있습니다.

반대로 헤더에 static int value = 10;을 두는 방식은 링크 에러를 없애 주지만 문제를 숨길 뿐입니다. 내부 링키지라서 include한 파일마다 서로 다른 value가 생기므로, 한 파일에서 값을 바꿔도 다른 파일에는 반영되지 않습니다. 공유 상태를 의도했다면 조용히 틀린 결과가 나오는 셈이라, 링크 에러보다 훨씬 찾기 어렵습니다.

문제 3: thread_local 초기화

// ❌ 복잡한 초기화
thread_local vector<int> data = expensiveInit();  // 매 스레드마다

// ✅ 지연 초기화
thread_local unique_ptr<vector<int>> data;

void ensureInit() {
    if (!data) {
        data = make_unique<vector<int>>(expensiveInit());
    }
}

thread_local 변수의 초기화는 스레드마다 각각 일어납니다. 표준은 동적 초기화가 “그 스레드에서 변수를 처음 사용하기 전까지” 일어나면 된다고만 정하고 있어, 이론적으로는 스레드 시작 시점에 초기화될 수도 있습니다. 실제 GCC와 Clang은 첫 접근 시점에 초기화하는 방식(접근할 때마다 초기화 여부를 확인하는 래퍼 함수를 거침)으로 구현하므로, 쓰지 않는 스레드가 비용을 치르는 일은 대개 없습니다. 그럼에도 unique_ptr로 감싸 직접 지연 초기화하는 방식은 초기화 시점을 코드에서 명시적으로 통제하고, 초기화 실패를 예외 대신 반환값으로 처리하거나 필요할 때 해제(data.reset())할 수 있다는 장점이 있습니다.

thread_local에는 성능상 알아 둘 점도 있습니다. 동적 초기화가 필요한 thread_local은 접근할 때마다 위의 래퍼 함수를 거치므로, 아주 뜨거운 루프에서는 지역 변수에 참조를 한 번 받아 두고 쓰는 편이 낫습니다. 또 공유 라이브러리(.so, .dll) 안의 thread_local은 동적 TLS 모델로 접근해 더 느려질 수 있고, dlclose로 라이브러리를 내릴 때 살아 있는 스레드의 thread_local 소멸자가 문제를 일으키는 사례도 알려져 있습니다.

초기화 순서

정적 초기화

// 컴파일 타임 또는 프로그램 시작 전
int x = 10;
const int y = 20;

동적 초기화

// 런타임
int x = compute();

// 순서: 같은 파일 내에서는 순서 보장
int a = 10;
int b = a + 5;  // OK

정적 초기화(상수 표현식으로 초기화 가능한 경우)는 프로그램이 시작되기도 전에 완료되므로 순서 문제가 아예 없습니다. 반면 함수 호출 결과로 초기화하는 동적 초기화는 런타임에 실행되어야 하므로, 앞서 다룬 초기화 순서 문제는 오직 이 동적 초기화 케이스에서만 발생합니다. 같은 파일 안에서는 선언된 순서대로 초기화된다는 보장이 있어 int b = a + 5;가 안전하지만, 파일이 다르면 이 보장이 사라집니다.

함수 내 static

int& getValue() {
    static int value = compute();  // 첫 호출 시 초기화
    return value;
}

// 스레드 안전 (C++11)

FAQ

Q1: extern과 static의 차이는?

A: 파일 범위에서 extern은 “이 이름은 외부 링키지이며 정의는 다른 곳에 있다”는 선언이고, static은 “이 이름을 이 번역 단위 안에 가둔다”는 내부 링키지 지정입니다. 함수 안의 static은 링키지와 무관하게 저장 기간만 바꾼다는 점이 다릅니다.

Q2: thread_local은 언제 사용하나요?

A: 스레드마다 독립적으로 두면 락이 필요 없어지는 상태에 씁니다. 스레드별 난수 생성기(std::mt19937은 스레드 안전하지 않음), 스레드별 버퍼, errno처럼 호출 스레드에 속한 오류 상태가 대표적입니다. 스레드 풀에서는 이전 작업의 값이 남는다는 점에 주의해야 합니다.

Q3: static 초기화 순서 문제는 어떻게 피하나요?

A: 다른 번역 단위의 전역 객체에 의존하는 전역 객체를 만들지 않는 것이 가장 확실합니다. 필요하다면 함수 내 static으로 바꿔 첫 호출 시점에 초기화하고, 상수라면 constexpr나 C++20 constinit으로 컴파일 시간 초기화를 강제합니다.

Q4: extern “C”는 왜 필요한가요?

A: C 코드와 링크하기 위해 name mangling을 방지합니다.

Q5: 전역 변수는 피해야 하나요?

A: 변경 가능한 전역 상태는 가능하면 피하는 것이 좋습니다. 어느 코드가 값을 바꾸는지 추적하기 어렵고, 테스트마다 상태를 초기화하기 어려우며, 멀티스레드에서는 동기화가 필요해집니다. 싱글톤도 결국 전역 상태이므로 문제를 이름만 바꿔 옮기는 경우가 많습니다. 필요한 객체를 생성자나 함수 인자로 넘기는 의존성 주입이 테스트하기 쉬운 대안이고, 읽기 전용 상수는 inline constexpr 전역으로 두어도 문제가 없습니다.


같이 보면 좋은 글