C++ static 멤버 변수와 함수: 초기화 순서, 스레드 안전성, C++17 inline static

이 글의 핵심

Counter 객체를 100개 만들어도 static 멤버 count는 하나뿐이며, 클래스 안의 선언과 별도로 정의가 필요하다는 규칙 때문에 링크 에러가 자주 납니다. 이 글은 static 멤버 함수에서 인스턴스 변수에 접근할 수 없는 이유, 정적 초기화 순서 문제, 여러 스레드에서의 안전성, C++17 inline static으로 정의 누락을 없애는 법을 설명합니다.

static 멤버 변수

모든 객체가 공유하는 클래스 레벨 변수

일반 멤버 변수는 객체마다 별도의 저장 공간을 갖지만, static 멤버는 그 클래스의 모든 인스턴스가 정확히 하나의 저장 공간을 공유합니다 — Counter 인스턴스를 100개 만들어도 count는 메모리상에 단 하나만 존재하며, 어느 인스턴스를 통해 접근하든 같은 값을 가리킵니다. 클래스 안의 static int count;는 선언일 뿐이고, 실제로 메모리를 할당하는 정의는 클래스 밖에서 int Counter::count = 0;로 별도로 해야 합니다 — 이는 static 멤버가 어느 한 객체에도 속하지 않고 프로그램 전체에서 단 하나만 존재해야 한다는 One Definition Rule(ODR) 때문입니다. 이 카운터 예시가 잘 보여주듯, 생성자에서 증가시키고 소멸자에서 감소시키면 count는 항상 “현재 살아있는 Counter 인스턴스의 개수”를 정확히 반영하게 됩니다 — c3가 중괄호 스코프를 벗어나며 소멸될 때 count가 3에서 2로 줄어드는 것이 그 증거입니다.

class Counter {
private:
    static int count;  // 선언
    
public:
    Counter() {
        count++;
    }
    
    ~Counter() {
        count--;
    }
    
    static int getCount() {
        return count;
    }
};

// 정의 (클래스 외부)
int Counter::count = 0;

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

static 멤버 함수

객체 없이 호출 가능한 함수

Math::add(3, 4)가 객체를 만들지 않고도 호출되는 이유는 static 멤버 함수가 애초에 this 포인터를 받지 않기 때문입니다 — 일반 멤버 함수는 컴파일러가 내부적으로 첫 번째 숨은 인자로 this를 전달하지만(obj.func()는 사실상 func(&obj)), static 멤버 함수는 이 숨은 매개변수 자체가 없어 특정 인스턴스와 무관하게 호출할 수 있습니다. 이런 함수는 클래스와 논리적으로 연관되어 있지만(수학 유틸리티, 팩토리 메서드 등) 객체 상태에 의존하지 않는 기능을 클래스 스코프 안에 깔끔하게 담아두는 용도로 흔히 쓰입니다 — 전역 함수로 만들 수도 있지만, 클래스 이름으로 스코프를 지정하면 네임스페이스 오염을 줄이고 관련 기능임을 명확히 드러낼 수 있습니다.

class Math {
public:
    static int add(int a, int b) {
        return a + b;
    }
    
    static int multiply(int a, int b) {
        return a * b;
    }
};

int main() {
    // 객체 없이 호출
    cout << Math::add(3, 4) << endl;       // 7
    cout << Math::multiply(3, 4) << endl;  // 12
}

static vs non-static

이 예시가 두 종류의 함수·변수를 나란히 놓고 보여주는 규칙은 단순합니다 — non-static 멤버 함수는 this가 있으므로 인스턴스 변수와 static 변수 양쪽 모두에 접근할 수 있지만, static 멤버 함수는 this가 없으므로 애초에 어떤 인스턴스의 instanceVar를 가리켜야 할지 알 방법이 없어 그 접근 자체가 컴파일 에러가 됩니다. 이 제약을 반대로 이용하면, 함수 시그니처만 보고도 “이 함수가 특정 객체의 상태에 의존하는가”를 판단할 수 있습니다 — static이 붙어 있다면 그 함수는 정의상 인스턴스 데이터를 건드릴 수 없으므로, 최소한 그 부분에서는 부작용의 범위가 명확하게 제한됩니다.

class Example {
private:
    int instanceVar;
    static int staticVar;
    
public:
    Example(int val) : instanceVar(val) {}
    
    // non-static 멤버 함수
    void instanceFunc() {
        instanceVar++;  // OK
        staticVar++;    // OK
    }
    
    // static 멤버 함수
    static void staticFunc() {
        // instanceVar++;  // 에러: this 없음
        staticVar++;       // OK
    }
};

int Example::staticVar = 0;

실전 예시

예시 1: 싱글톤

싱글톤 패턴이 static 멤버에 의존하는 이유는 명확합니다 — “프로그램 전체에서 정확히 하나의 인스턴스만 존재해야 한다”는 요구사항 자체가 “클래스의 모든 사용자가 공유하는 단 하나의 저장 공간”이라는 static의 정의와 정확히 일치하기 때문입니다. 생성자를 private으로 감춰 new Database()를 외부에서 직접 호출하지 못하게 막고, 유일한 생성 경로를 getInstance()로 좁혀 그 함수가 “이미 인스턴스가 있으면 재사용하고, 없으면 그때 만든다”는 지연 초기화(lazy initialization)를 보장하도록 한 것이 이 패턴의 핵심입니다. 다만 이 구현은 실제 프로덕션 코드에는 부족한 점이 있는데, 여러 스레드가 동시에 getInstance()를 처음 호출하면 instance == nullptr 검사와 new Database() 사이에 경쟁 조건이 생겨 인스턴스가 두 번 생성될 수 있습니다 — C++11부터는 함수 내부 static 지역 변수의 초기화가 스레드 안전하게 보장되므로, 실무에서는 이 포인터 기반 패턴보다 static Database& getInstance() { static Database instance; return instance; } 형태(Meyer’s Singleton)가 더 널리 권장됩니다.

class Database {
private:
    static Database* instance;
    
    // private 생성자
    Database() {
        cout << "Database 연결" << endl;
    }
    
public:
    // 복사/이동 금지
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
    
    static Database* getInstance() {
        if (instance == nullptr) {
            instance = new Database();
        }
        return instance;
    }
    
    void query(const string& sql) {
        cout << "쿼리 실행: " << sql << endl;
    }
};

Database* Database::instance = nullptr;

int main() {
    Database* db1 = Database::getInstance();
    Database* db2 = Database::getInstance();
    
    cout << (db1 == db2) << endl;  // 1 (같은 인스턴스)
    
    db1->query("SELECT * FROM users");
}

이 구현에는 스레드 문제 말고도 짚을 점이 하나 더 있습니다. new로 만든 인스턴스를 아무도 delete하지 않으므로 소멸자가 끝내 호출되지 않습니다. 운영체제가 프로세스 종료 시 메모리를 회수하니 누수 자체는 큰 문제가 아니지만, 소멸자에서 DB 연결을 정상 종료하거나 버퍼를 파일에 flush해야 한다면 그 작업이 조용히 빠집니다. 반대로 Meyer’s Singleton은 프로그램 종료 시 소멸자가 호출되는 대신, 다른 전역 객체의 소멸자가 이미 파괴된 싱글톤을 사용하는 “소멸 순서” 문제가 생길 수 있습니다. 어느 쪽이 나은지는 소멸자에서 해야 할 일이 있는지, 종료 과정에서 싱글톤을 누가 쓰는지에 따라 달라집니다. 테스트하기 어렵다는 점도 싱글톤의 고질적인 약점이라, 새 코드에서는 인스턴스를 생성자 인자로 주입하는 방식을 먼저 고려하는 편이 좋습니다.

예시 2: 객체 카운터

totalCount와 activeCount를 나란히 두면 static 멤버가 서로 다른 두 가지 종류의 “전역 정보”를 자연스럽게 표현할 수 있음을 보여줍니다 — totalCount는 소멸자에서 절대 감소하지 않는 누적 카운터(지금까지 생성된 총 개수)이고, activeCount는 생성자·소멸자 양쪽에서 갱신되어 현재 시점의 상태(지금 살아있는 개수)를 반영합니다. id(++totalCount)처럼 멤버 초기화 리스트에서 static 변수를 증가시키며 그 결과를 인스턴스 멤버(id)에 대입하는 패턴도 눈여겨볼 만합니다 — 이렇게 하면 각 Widget 인스턴스가 생성 순서에 따른 고유 ID를 자동으로 부여받으면서도, 그 ID를 발급하는 카운터 자체는 클래스 전체가 공유하는 하나의 static 상태로 관리됩니다.

class Widget {
private:
    static int totalCount;
    static int activeCount;
    int id;
    
public:
    Widget() : id(++totalCount) {
        activeCount++;
        cout << "Widget " << id << " 생성" << endl;
    }
    
    ~Widget() {
        activeCount--;
        cout << "Widget " << id << " 소멸" << endl;
    }
    
    static int getTotalCount() {
        return totalCount;
    }
    
    static int getActiveCount() {
        return activeCount;
    }
};

int Widget::totalCount = 0;
int Widget::activeCount = 0;

int main() {
    cout << "총 생성: " << Widget::getTotalCount() << endl;  // 0
    cout << "활성: " << Widget::getActiveCount() << endl;    // 0
    
    Widget w1;
    cout << "총 생성: " << Widget::getTotalCount() << endl;  // 1
    cout << "활성: " << Widget::getActiveCount() << endl;    // 1
    
    {
        Widget w2, w3;
        cout << "총 생성: " << Widget::getTotalCount() << endl;  // 3
        cout << "활성: " << Widget::getActiveCount() << endl;    // 3
    }
    
    cout << "활성: " << Widget::getActiveCount() << endl;  // 1
}

예시 3: 설정 관리자

Config는 Database의 싱글톤과 달리 인스턴스를 아예 만들지 않고, 클래스 자체를 “static 멤버 함수와 static 데이터의 네임스페이스”처럼 사용하는 또 다른 흔한 패턴입니다. settings라는 하나의 map을 모든 set/get/has 호출이 공유하므로, 프로그램의 어느 위치에서 Config::set("host", ...)을 호출하든 그 이후 어디서든 Config::get("host")로 같은 값을 조회할 수 있습니다 — 설정값을 인자로 계속 전달하고 다니는 대신, 이렇게 전역적으로 접근 가능한 저장소에 모아두는 것이 애플리케이션 설정처럼 “프로그램 어디서든 필요할 수 있는 값”을 다루는 실용적인 방법입니다. 다만 이 패턴은 사실상 전역 가변 상태이므로, 여러 스레드에서 동시에 set을 호출하면 뒤에서 다룰 “스레드 안전성” 문제를 그대로 안게 된다는 점은 유의해야 합니다.

class Config {
private:
    static map<string, string> settings;
    
public:
    static void set(const string& key, const string& value) {
        settings[key] = value;
    }
    
    static string get(const string& key, const string& defaultValue = "") {
        auto it = settings.find(key);
        return it != settings.end() ? it->second : defaultValue;
    }
    
    static bool has(const string& key) {
        return settings.find(key) != settings.end();
    }
    
    static void printAll() {
        for (const auto& [key, value] : settings) {
            cout << key << " = " << value << endl;
        }
    }
};

map<string, string> Config::settings;

int main() {
    Config::set("host", "localhost");
    Config::set("port", "8080");
    Config::set("debug", "true");
    
    cout << "Host: " << Config::get("host") << endl;
    cout << "Port: " << Config::get("port") << endl;
    
    Config::printAll();
}

Config::settings 같은 static std::map 멤버에는 눈에 잘 띄지 않는 함정이 있습니다. map은 생성자를 호출해야 하는 동적 초기화 대상이라, 다른 .cpp 파일의 전역 객체가 자기 생성자 안에서 Config::set()을 호출하면 아직 생성되지 않은 map에 삽입하게 될 수 있습니다. 결과는 링크 순서에 따라 정상 동작하거나 main 진입 전에 크래시하는 것으로 갈립니다. 플러그인이나 명령 등록을 “전역 객체 생성자에서 레지스트리에 자기를 등록”하는 방식으로 구현할 때 특히 자주 걸리는 문제입니다. 저장소를 static map<string,string>& settings() { static map<string,string> m; return m; }처럼 함수 내부 static으로 바꾸면 첫 사용 시점에 생성되므로 이 문제가 사라집니다.

예시 4: 팩토리

ShapeFactory::create("circle")가 인스턴스를 만들지 않고도 호출 가능한 것은 이 함수의 목적 자체가 “어떤 특정 객체의 상태와도 무관하게, 문자열 하나만 보고 적절한 구체 타입의 객체를 새로 만들어 반환하는 것”이기 때문입니다 — static 멤버 함수는 이런 순수한 생성 로직을 표현하기에 정확히 알맞은 도구입니다. 이 패턴의 실질적 가치는 호출부(main)가 Circle이나 Rectangle이라는 구체 타입을 전혀 언급하지 않고 오직 Shape 인터페이스와 문자열 이름만으로 객체를 얻는다는 데 있습니다 — 나중에 Triangle을 추가하고 싶으면 ShapeFactory::create 내부의 분기 하나만 늘리면 되고, 이를 사용하는 기존 코드는 전혀 변경할 필요가 없습니다.

class Shape {
public:
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() const override {
        cout << "Circle" << endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() const override {
        cout << "Rectangle" << endl;
    }
};

class ShapeFactory {
public:
    static unique_ptr<Shape> create(const string& type) {
        if (type == "circle") {
            return make_unique<Circle>();
        } else if (type == "rectangle") {
            return make_unique<Rectangle>();
        }
        return nullptr;
    }
};

int main() {
    auto shape1 = ShapeFactory::create("circle");
    auto shape2 = ShapeFactory::create("rectangle");
    
    shape1->draw();  // Circle
    shape2->draw();  // Rectangle
}

이 팩토리는 모르는 문자열을 받으면 nullptr을 돌려주므로, 호출부가 결과를 확인하지 않고 ->draw()를 부르면 널 포인터 역참조로 크래시합니다. 입력이 사용자나 설정 파일에서 온다면 오타 하나가 곧 크래시가 되는 구조입니다. 실패 가능성을 타입으로 드러내려면 예외를 던지거나, 반환값을 확인하도록 [[nodiscard]]를 붙이거나, 알 수 없는 타입을 명시적으로 처리하는 편이 안전합니다. 분기가 늘어나면 if-else 대신 static std::unordered_map<std::string, std::function<std::unique_ptr<Shape>()>> 형태의 등록 테이블로 바꾸는 것이 일반적인데, 이 테이블도 앞의 Config와 같은 초기화 순서 함정이 있으므로 함수 내부 static으로 두어야 합니다.

static const

static const int MAX_SIZE = 100처럼 정수·열거형 타입의 static const 멤버는 클래스 정의 안에서 곧바로 초기화할 수 있다는 예외가 있습니다 — 컴파일러가 이 값이 컴파일 타임 상수임을 알고 있으므로, 배열 크기나 템플릿 인자 같은 컴파일 타임 문맥에서 바로 사용할 수 있게 해 주기 위한 특별 취급입니다(다만 그 값의 주소를 취하는 등 실제 정의가 필요한 사용이 있다면 여전히 클래스 밖에서 정의가 필요할 수 있습니다). 반면 double처럼 정수·열거형이 아닌 타입은 이 예외가 적용되지 않으므로, PI처럼 클래스 안에서는 선언만 하고 클래스 밖에서 const double Constants::PI = 3.14159;로 반드시 정의해야 합니다 — 이 비일관성이 실무에서 자주 헷갈리는 지점이며, 뒤에서 다룰 C++17의 inline static이 정확히 이 번거로움을 없애기 위해 도입되었습니다.

class Constants {
public:
    static const int MAX_SIZE = 100;
    static const double PI;
};

const double Constants::PI = 3.14159;

int main() {
    cout << Constants::MAX_SIZE << endl;  // 100
    cout << Constants::PI << endl;        // 3.14159
}

괄호 안의 “실제 정의가 필요한 사용”은 생각보다 흔하게 일어납니다. 대표적인 예가 std::max(n, Constants::MAX_SIZE)나 vec.push_back(Constants::MAX_SIZE)입니다. 두 함수는 인자를 const T&로 받는데, 참조를 만들려면 그 변수에 주소가 있어야 하므로 이것이 ODR-use가 됩니다. 클래스 밖 정의가 없으면 undefined reference to 'Constants::MAX_SIZE' 링크 에러가 나는데, 같은 값을 cout으로 출력할 때는 멀쩡하다 보니 원인을 짐작하기 어렵습니다. 게다가 최적화 수준에 따라 인라인되어 에러가 사라지기도 해서, 디버그 빌드에서만 링크가 실패하는 식으로 나타나기도 합니다. static const int 대신 static constexpr int MAX_SIZE = 100;으로 쓰면 C++17부터 암시적으로 inline이 되어 이 문제가 없어지고, double 같은 비정수 타입도 클래스 안에서 바로 초기화할 수 있습니다.

inline static (C++17)

C++17 이전에는 정수·열거형이 아닌 모든 static 멤버(문자열, 사용자 정의 타입 등)가 예외 없이 클래스 밖의 별도 정의를 요구했고, 이는 헤더 전용(header-only) 라이브러리를 만들 때 특히 성가신 제약이었습니다 — static 멤버를 가진 클래스를 헤더에 정의하면, 그 헤더를 여러 .cpp 파일에서 include할 때마다 정의가 중복되어 ODR 위반 링크 에러가 발생하므로, 반드시 대응하는 .cpp 파일을 하나 만들어 그 안에 정의를 둬야 했습니다. inline static은 inline 함수가 여러 번역 단위에 중복 정의되어도 링커가 하나로 합쳐 처리하는 것과 같은 방식으로, static 데이터 멤버도 헤더 안에서 선언과 동시에 정의할 수 있게 해 줍니다 — 이는 헤더 온리 라이브러리를 작성할 때 실질적으로 큰 편의를 제공하며, 이제는 정수·열거형이 아닌 static 멤버라도 별도의 .cpp 정의 파일 없이 헤더 하나로 완결된 클래스를 작성할 수 있습니다.

class Config {
public:
    // C++17: inline static (외부 정의 불필요)
    inline static int maxConnections = 100;
    inline static string serverName = "localhost";
};

int main() {
    cout << Config::maxConnections << endl;  // 100
    cout << Config::serverName << endl;      // localhost
}

inline static은 정의 누락과 중복 정의 문제를 해결하지만, 초기화 순서 문제까지 해결하지는 않는다는 점을 구분해야 합니다. inline static string serverName은 여전히 동적 초기화되는 전역 객체이고, 여러 번역 단위에 걸친 초기화 순서는 여전히 정해져 있지 않습니다(오히려 inline 변수의 초기화 순서 보장은 일반 변수보다 약합니다). 또 헤더에 둔 값을 바꾸면 그 헤더를 포함한 모든 파일이 다시 컴파일된다는 빌드 시간 비용도 있습니다. 자주 바뀌는 설정값이라면 .cpp에 정의를 두는 전통적인 방식이 여전히 유용합니다.

자주 발생하는 문제

문제 1: 초기화 순서

이는 static/전역 변수의 “정적 초기화 순서 대란(Static Initialization Order Fiasco)“으로 알려진 C++의 유명한 함정입니다 — 서로 다른 번역 단위(다른 .cpp 파일)에 있는 비지역(non-local) static 객체들 사이의 초기화 순서는 표준에 의해 정의되어 있지 않으며, 오직 같은 번역 단위 안에서 선언된 순서대로만 초기화가 보장됩니다. A::value와 B::value가 서로 다른 파일에 정의되어 있고 B::value가 런타임 함수 호출로 초기화된다면, 어느 쪽이 먼저 초기화될지는 링크 순서에 달려 있어 A::value = B::value + 1을 평가하는 시점에 B::value가 아직 0(초기화 전 0으로 채워진 값)일 수도, 이미 10일 수도 있습니다. 반대로 int B::value = 10;처럼 상수로 초기화되는 변수는 프로그램이 시작되기 전(정적 초기화 단계)에 값이 들어 있으므로 이 문제가 생기지 않습니다. 순서 문제는 생성자 호출이나 함수 호출이 필요한 동적 초기화에서만 일어납니다. 함수 내부의 지역 static 변수로 바꾸면 이 문제가 사라지는 이유는, 이런 지역 static은 “처음 그 함수가 호출되는 시점”에 초기화되도록 지연 초기화되기 때문입니다 — A::getValue()가 호출되어야 비로소 B::getValue()를 먼저 평가하므로, 초기화 순서가 프로그램의 실제 호출 순서에 의해 결정되어 항상 예측 가능합니다.

// ❌ 초기화 순서 불확실 (a.cpp와 b.cpp가 따로 컴파일됨)
// b.cpp
int loadDefault();                      // 설정 파일 등에서 런타임에 읽음
int B::value = loadDefault();           // 동적 초기화
// a.cpp
int A::value = B::value + 1;            // B::value가 아직 0일 수 있음

// ✅ 함수 내 static (B를 먼저 선언)
class B {
public:
    static int& getValue() {
        static int value = loadDefault();  // 첫 호출 시 초기화
        return value;
    }
};

class A {
public:
    static int& getValue() {
        static int value = B::getValue() + 1;  // 항상 B가 먼저 초기화됨
        return value;
    }
};

문제 2: 스레드 안전성

static 멤버는 정의상 모든 스레드가 공유하는 단 하나의 저장 공간이므로, 여러 스레드가 동시에 그 값을 읽고 쓰면 정확히 데이터 레이스가 성립하는 조건이 만들어집니다. count++가 원자적이지 않은 이유는 이 한 줄이 실제로는 “현재 값을 읽기 → 1을 더하기 → 다시 쓰기”라는 세 단계로 이루어져 있기 때문입니다 — 두 스레드가 이 세 단계를 교차 실행하면, 예를 들어 둘 다 같은 값 5를 읽은 뒤 각자 6을 써서 실제로는 두 번 증가해야 할 카운터가 한 번만 증가한 것처럼 보이는 값 손실(lost update)이 발생할 수 있습니다. lock_guard<mutex>로 감싸면 그 세 단계 전체가 한 스레드에 의해 끊기지 않고 완결되는 것을 보장해 이 경쟁을 없애며, 단순 카운터라면 mutex 대신 std::atomic<int>로 바꾸는 것이 락 없이도 원자성을 보장하면서 대체로 더 가벼운 대안입니다.

// ❌ 스레드 안전하지 않음
class Counter {
private:
    static int count;
    
public:
    static void increment() {
        count++;  // 경쟁 조건
    }
};

// ✅ mutex 사용
class Counter {
private:
    static int count;
    static mutex mtx;
    
public:
    static void increment() {
        lock_guard<mutex> lock(mtx);
        count++;
    }
};

int Counter::count = 0;
mutex Counter::mtx;
// ✅ 단순 카운터라면 atomic이 더 가벼움 (C++17)
class Counter {
    inline static std::atomic<int> count{0};
public:
    static void increment() { count.fetch_add(1, std::memory_order_relaxed); }
    static int get() { return count.load(); }
};

memory_order_relaxed는 “카운터 값 자체의 원자성만 필요하고, 이 증가를 기준으로 다른 데이터의 가시성을 맞출 필요는 없다”는 뜻입니다. 통계용 카운터처럼 값만 정확하면 되는 경우에 적합하고, 카운터 값을 보고 다른 공유 데이터를 읽는 로직이라면 기본값(seq_cst)을 쓰는 편이 안전합니다. 처음 멀티스레드 코드를 짤 때 흔히 겪는 문제가, 테스트에서는 스레드 경쟁이 거의 일어나지 않아 비원자적 count++로도 결과가 맞다가 부하가 걸린 운영 환경에서만 수치가 조금씩 모자라는 현상입니다. ThreadSanitizer(-fsanitize=thread)로 빌드해 테스트를 돌리면 이런 데이터 레이스를 WARNING: ThreadSanitizer: data race로 바로 잡아냅니다.

문제 3: 정의 누락

이 실수의 특징은 실패가 컴파일 시점이 아니라 링크 시점에 나타난다는 것입니다 — 클래스 안의 static int value;는 컴파일러에게 “이런 이름과 타입의 static 멤버가 존재한다”는 것만 알려주는 선언이므로, 이 선언만으로 그 값을 참조하는 코드(Example::value를 읽거나 쓰는 코드)는 문제없이 컴파일됩니다. 하지만 실제로 그 변수가 저장될 메모리 자체는 클래스 밖의 정의(int Example::value = 0;)가 있어야만 만들어지므로, 정의가 어디에도 없으면 컴파일은 성공하지만 최종 링크 단계에서 “undefined reference to Example::value” 같은 에러가 발생합니다. 이런 종류의 에러를 보면 컴파일러 자체의 에러가 아니라 “선언은 있는데 정의가 어딘가 빠졌다”는 신호로 해석하고, 해당 static 멤버의 정의가 정확히 하나의 .cpp 파일에 존재하는지 확인하는 것이 진단의 첫걸음입니다.

// ❌ 정의 누락
class Example {
public:
    static int value;  // 선언만
};

// int Example::value = 0;  // 정의 누락 (링크 에러)

// ✅ 정의 추가
class Example {
public:
    static int value;
};

int Example::value = 0;  // 정의

FAQ

Q1: 템플릿 클래스의 static 멤버는 몇 개가 생기나요?

A: 템플릿 인자 조합마다 하나씩 생깁니다. Counter<int>::count와 Counter<double>::count는 서로 다른 변수입니다. “모든 Counter 인스턴스 수”를 세고 싶었는데 타입별로 따로 세어지는 이유가 이것이며, 공통 카운터가 필요하면 템플릿이 아닌 기반 클래스에 static 멤버를 둡니다.

Q2: 공유 라이브러리(.so/.dll)를 쓰면 static 멤버가 하나라는 보장이 깨지나요?

A: 깨질 수 있습니다. 헤더에 정의된 inline static 멤버나 템플릿 static 멤버는 각 공유 라이브러리가 자기 사본을 가질 수 있고, 특히 Windows DLL은 명시적으로 export하지 않으면 모듈마다 별도 사본이 생깁니다. 그래서 “싱글톤인데 두 개가 있다”는 증상이 나옵니다. 모듈 경계를 넘어 공유해야 하는 static 상태는 한 모듈의 .cpp에 정의하고 export한 함수로만 접근하게 하는 것이 안전합니다.

Q3: static 멤버와 전역 변수는 무엇이 다른가요?

A: 저장 기간과 초기화 규칙은 같습니다. 차이는 이름이 클래스 스코프에 묶이고 private으로 접근을 제한할 수 있다는 점입니다. 전역 가변 상태라는 본질은 같으므로, 테스트 격리가 어렵고 스레드 동기화가 필요하다는 단점도 그대로 가집니다.


같이 보면 좋은 글