static·extern·const·constexpr·inline·volatile·mutable: C++ 키워드가 실제로 바꾸는 것

이 글의 핵심

C++ 저장 클래스와 한정자 키워드가 링키지·수명·최적화에 각각 무엇을 바꾸는지 정리하고, 자주 쓰는 키워드 조합과 링키지·스토리지 클래스 요약표를 제공합니다.

일곱 키워드가 바꾸는 링키지·수명·평가 시점

C++의 핵심 키워드들(static, extern, const, constexpr, inline, volatile, mutable)이 각각 무엇을 바꾸는지 정리합니다. 이 키워드들이 헷갈리는 이유는 대부분 두 가지 서로 다른 축을 섞어 말하기 때문입니다. 하나는 이름이 어느 범위에서 보이는가(링키지: 이 번역 단위 안에서만인가, 프로그램 전체인가)이고, 다른 하나는 객체가 언제 만들어지고 사라지는가(저장 기간: 자동, 정적, 스레드, 동적)입니다. static은 위치에 따라 두 축을 모두 건드리고, inline은 최적화가 아니라 링키지 규칙(ODR)에 관한 키워드이며, volatile은 스레드와 무관하다는 식으로, 각 키워드가 어느 축에 속하는지 먼저 구분해 두면 나머지 규칙이 자연스럽게 정리됩니다.


static: 링키지와 수명을 바꾸는 세 가지 의미

세 가지 의미의 static

C++에서 static은 문맥에 따라 세 가지 다른 의미를 가집니다.

파일 스코프 static (내부 링키지)

// utils.cpp
static int counter = 0;  // 이 파일 내부에서만 접근 가능
static void helperFunction() {
    counter++;
}

특징:

  • 내부 링키지: 다른 파일에서 접근 불가
  • ODR 위반 방지: 같은 이름이 여러 파일에 있어도 충돌 없음
  • 현대 C++: 익명 네임스페이스 권장
// 현대 C++ 스타일
namespace {
    int counter = 0;  // static int counter = 0; 과 동일
    
    void helperFunction() {
        counter++;
    }
}

클래스 static 멤버

class Database {
public:
    static int connectionCount;  // 선언
    
    static void incrementConnections() {
        connectionCount++;
    }
};
// 정의 (cpp 파일)
int Database::connectionCount = 0;

특징:

  • 모든 인스턴스가 공유: 클래스당 하나의 복사본
  • this 포인터 없음: 인스턴스 없이 호출 가능
  • static 멤버만 접근 가능: 인스턴스 멤버 접근 불가

함수 내부 static (정적 지역 변수)

int getNextId() {
    static int id = 0;  // 첫 호출 시 한 번만 초기화
    return ++id;
}
int main() {
    std::cout << getNextId() << "\n";  // 1
    std::cout << getNextId() << "\n";  // 2
    std::cout << getNextId() << "\n";  // 3
}

특징:

  • 프로그램 시작 시 메모리 할당: 스택이 아닌 데이터 세그먼트
  • 첫 호출 시 초기화: 이후 호출에서는 초기화 생략
  • 스레드 안전 초기화: C++11부터 보장 (Magic Static)

“스레드 안전”은 초기화에 한해서만 보장된다는 점을 헷갈리면 안 됩니다. 여러 스레드가 동시에 getNextId()를 처음 호출해도 id = 0 초기화는 한 번만 일어나지만, 그 뒤의 ++id는 보호되지 않으므로 두 스레드가 같은 번호를 받을 수 있습니다. 여러 스레드에서 쓰려면 static std::atomic<int> id{0};처럼 연산 자체를 원자화해야 합니다. 또 초기화가 스레드 안전하게 되도록 컴파일러가 가드 변수 검사를 넣으므로, 성능에 민감한 함수 안의 정적 지역 변수는 매 호출마다 약간의 비용(대개 분기 하나)이 듭니다.

static 메모리 레이아웃

int globalVar = 42;           // .data 세그먼트
static int fileVar = 100;     // .data 세그먼트 (내부 링키지)
void function() {
    static int localVar = 200; // .data 세그먼트
    int stackVar = 300;        // 스택
}

메모리 구조:

+-------------------+
| Code (.text)      | ← 함수 코드
+-------------------+
| Data (.data)      | ← globalVar, fileVar, localVar
+-------------------+
| BSS (.bss)        | ← 초기화되지 않은 static 변수
+-------------------+
| Heap              | ← 동적 할당
+-------------------+
| Stack             | ← stackVar
+-------------------+

static으로 만드는 싱글톤과 레지스트리

싱글톤 패턴 (Meyer’s Singleton)

class Logger {
public:
    static Logger& getInstance() {
        static Logger instance;  // 스레드 안전 초기화
        return instance;
    }
    
    void log(const std::string& message) {
        std::cout << message << "\n";
    }
    
private:
    Logger() = default;
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;
};
// 사용
Logger::getInstance().log("Hello");

팩토리 레지스트리

class ShapeFactory {
public:
    using Creator = std::unique_ptr<Shape>(*)();
    
    static void registerShape(const std::string& name, Creator creator) {
        getRegistry()[name] = creator;
    }
    
    static std::unique_ptr<Shape> create(const std::string& name) {
        auto& registry = getRegistry();
        auto it = registry.find(name);
        return it != registry.end() ? it->second() : nullptr;
    }
    
private:
    static std::unordered_map<std::string, Creator>& getRegistry() {
        static std::unordered_map<std::string, Creator> registry;
        return registry;
    }
};

레지스트리를 static 멤버 변수가 아니라 함수 안의 static으로 둔 데는 이유가 있습니다. 각 도형이 자기 .cpp 파일의 전역 객체 생성자에서 registerShape를 호출하는 자기 등록 방식을 쓰면, 레지스트리가 일반 전역 변수일 경우 다른 번역 단위의 전역 초기화 순서가 정해져 있지 않아 레지스트리가 만들어지기 전에 등록을 시도할 수 있습니다(static initialization order fiasco). 함수 안의 static은 처음 호출될 때 만들어지므로 누가 먼저 호출하든 항상 초기화된 상태를 돌려줍니다. 다만 자기 등록 코드를 정적 라이브러리에 넣으면, 그 오브젝트 파일의 심볼을 아무도 참조하지 않는 경우 링커가 파일 자체를 버려 등록이 조용히 사라지는 문제가 있습니다. --whole-archive나 CMake의 OBJECT 라이브러리로 강제로 포함시켜야 합니다.


extern: 다른 번역 단위의 심볼 참조

외부 링키지 선언

extern은 다른 파일에 정의된 변수/함수를 참조할 때 사용합니다.

// globals.cpp
int globalCounter = 0;  // 정의
void incrementCounter() {
    globalCounter++;
}
// main.cpp
extern int globalCounter;  // 선언 (정의는 globals.cpp에)
extern void incrementCounter();  // 함수는 기본적으로 extern
int main() {
    incrementCounter();
    std::cout << globalCounter << "\n";  // 1
}

extern “C” (C 링키지)

C++은 함수 오버로딩을 위해 이름 맹글링(name mangling)을 사용합니다. C 라이브러리와 호환하려면 extern "C"를 사용합니다.

// C++ 이름 맹글링
void print(int x);        // _Z5printi
void print(double x);     // _Z5printd
// C 링키지 (맹글링 없음)
extern "C" {
    void c_print(int x);  // c_print (그대로)
}

실전 예제: C 라이브러리 래핑

// math_wrapper.h
#ifdef __cplusplus
extern "C" {
#endif
void calculate(double* result, double a, double b);
#ifdef __cplusplus
}
#endif
// math_wrapper.cpp
#include "math_wrapper.h"
#include <cmath>
extern "C" void calculate(double* result, double a, double b) {
    *result = std::sqrt(a * a + b * b);
}

extern "C"를 쓸 때 알아 둘 제약이 몇 가지 있습니다. 이름이 맹글링되지 않으므로 extern "C" 함수는 오버로딩할 수 없고, 같은 이름으로 두 개를 선언하면 conflicting declaration of C function 에러가 납니다. 또 C 쪽에는 예외라는 개념이 없어서, extern "C" 함수 밖으로 C++ 예외가 빠져나가 C 코드의 스택 프레임을 지나가면 동작이 보장되지 않습니다. C에서 호출될 콜백이나 C API로 공개하는 함수라면 본문을 try { ... } catch (...) { return error_code; }로 감싸 예외를 오류 코드로 바꾸는 것이 원칙입니다. 헤더의 #ifdef __cplusplus 가드를 빠뜨리면 C++ 쪽에서는 맹글링된 이름(_Z9calculatePddd)을 찾고 라이브러리에는 calculate만 있어서 undefined reference to 'calculate(double*, double, double)' 링크 에러가 나는데, 에러 메시지에 매개변수 타입이 붙어 있으면 맹글링된 이름을 찾고 있다는 신호입니다.

extern template (명시적 인스턴스화 억제)

템플릿 인스턴스화를 한 곳에서만 하며, 다른 곳에서는 재사용합니다.

// vector.h
template <typename T>
class Vector {
public:
    void push_back(const T& value);
    // ...
};
// vector.cpp
#include "vector.h"
// 명시적 인스턴스화
template class Vector<int>;
template class Vector<double>;
// main.cpp
#include "vector.h"
// 인스턴스화 억제 (vector.cpp의 것을 재사용)
extern template class Vector<int>;
extern template class Vector<double>;
int main() {
    Vector<int> v;  // 컴파일 시간 단축
}

효과:

  • 컴파일 시간 단축: 중복 인스턴스화 방지
  • 바이너리 크기 감소: 같은 코드가 여러 번 생성되지 않음

const: 수정 금지와 내부 링키지

const의 다양한 위치

// 1) const 변수
const int x = 10;  // x는 상수
// 2) const 포인터
int value = 42;
const int* ptr1 = &value;     // 포인터가 가리키는 값이 const
int* const ptr2 = &value;     // 포인터 자체가 const
const int* const ptr3 = &value;  // 둘 다 const
// 3) const 참조
void print(const std::string& str);  // 복사 방지, 수정 방지
// 4) const 멤버 함수
class Point {
    int x_, y_;
public:
    int getX() const { return x_; }  // 멤버 변수 수정 불가
    void setX(int x) { x_ = x; }     // non-const
};

const와 링키지

// C++ (모든 표준): 네임스페이스 범위의 const 변수는 기본적으로 내부 링키지
// (C에서는 외부 링키지라 C와 C++이 다른 부분)
const int MAX_SIZE = 100;  // static const int와 동일
// 외부 링키지로 만들려면 extern 필요
extern const int GLOBAL_MAX;  // 선언
// globals.cpp
extern const int GLOBAL_MAX = 1000;  // 정의
// constexpr 변수도 const이므로 기본적으로 내부 링키지
// (네임스페이스 범위의 constexpr 변수는 inline이 아님 — 암시적 inline은 static constexpr 데이터 멤버만)
constexpr int MAX_SIZE = 100;  // 번역 단위마다 별도의 복사본
// C++17: inline 변수로 외부 링키지 + 프로그램 전체에서 하나의 객체
inline constexpr int GLOBAL_MAX = 1000;  // 헤더에 정의 가능

헤더에 constexpr int MAX_SIZE = 100;을 두어도 링크 에러가 나지 않는 것은 내부 링키지 덕분입니다. 헤더를 포함한 번역 단위마다 자기만의 MAX_SIZE 를 갖기 때문에 이름이 충돌하지 않습니다. 대개는 값이 컴파일 시점에 코드에 박혀 차이가 없지만, 주소를 비교하거나(&MAX_SIZE) 참조로 넘기면 번역 단위마다 다른 주소가 나옵니다. 큰 constexpr std::array 테이블을 헤더에 두면 그 테이블이 번역 단위마다 복사되어 바이너리가 커질 수도 있습니다. inline constexpr은 이 복사본들을 링커가 하나로 합치게 하므로 C++17 이후 헤더 상수의 기본형으로 권장됩니다.

const 멤버 함수와 mutable

class Cache {
    mutable std::unordered_map<int, std::string> cache_;
    mutable std::mutex mutex_;
    
public:
    std::string get(int key) const {  // const 멤버 함수
        std::lock_guard<std::mutex> lock(mutex_);  // mutable이므로 가능
        
        auto it = cache_.find(key);
        if (it != cache_.end()) {
            return it->second;
        }
        
        // 캐시 미스: 계산 후 캐시에 저장
        std::string value = computeValue(key);
        cache_[key] = value;  // mutable이므로 가능
        return value;
    }
    
private:
    std::string computeValue(int key) const;
};

mutable 사용 시나리오:

  • 캐싱: const 함수에서 캐시 업데이트
  • 동기화: const 함수에서 뮤텍스 잠금
  • 지연 초기화: const 함수에서 첫 접근 시 초기화

const_cast (const 제거)

void legacyFunction(char* str);  // const를 받지 않는 레거시 함수
void modernFunction(const char* str) {
    // 레거시 함수가 실제로 수정하지 않는다는 것을 알 때만 사용
    legacyFunction(const_cast<char*>(str));
}

주의: const_cast로 실제 const 객체를 수정하면 미정의 동작입니다.

const int x = 10;
int* ptr = const_cast<int*>(&x);
*ptr = 20;  // 미정의 동작!

constexpr: 컴파일 타임 평가

컴파일 타임 상수

constexpr은 컴파일 타임에 값을 계산할 수 있음을 나타냅니다.

constexpr int square(int x) {
    return x * x;
}
constexpr int result = square(5);  // 컴파일 타임에 25로 계산
int arr[square(10)];  // 배열 크기로 사용 가능 (100)

constexpr vs const

const int x = getValue();      // 런타임 상수 (OK)
constexpr int y = getValue();  // 컴파일 에러! (getValue가 constexpr 아님)
constexpr int z = 42;          // 컴파일 타임 상수
const int w = 42;              // 정수형 const + 상수 초기값 → 이것도 상수 표현식 (int a[w]; 가능)

차이점:

  • const: 런타임 상수도 가능
  • constexpr: 반드시 컴파일 타임 상수

const의 뜻은 “이 변수를 통해서는 값을 바꾸지 않는다”는 읽기 전용 약속이고, 초기값이 컴파일 시점에 정해졌는지와는 무관합니다. 다만 정수형(과 열거형) const 변수가 상수 표현식으로 초기화되면 역사적인 이유로 상수 표현식처럼 쓸 수 있어서, 위의 w는 배열 크기나 템플릿 인자에 쓸 수 있습니다. 같은 const라도 const double d = 1.5;는 상수 표현식이 아닙니다. constexpr은 이런 타입별 예외 없이 “컴파일 시점에 값이 정해져야 한다”를 요구하므로, 초기값이 조건을 만족하지 못하면 constexpr variable 'y' must be initialized by a constant expression 에러로 바로 알려 줍니다. 의도가 컴파일 타임 상수라면 constexpr을 써야 실수했을 때 컴파일러가 잡아 줍니다.

constexpr 함수

constexpr int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}
// 컴파일 타임 계산
constexpr int fib10 = fibonacci(10);  // 55 (컴파일 타임)
// 런타임 계산도 가능
int n;
std::cin >> n;
int result = fibonacci(n);  // 런타임

C++14 이후: constexpr 함수에서 변수, 반복문 사용 가능

constexpr int factorial(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

constexpr 클래스

class Point {
    int x_, y_;
public:
    constexpr Point(int x, int y) : x_(x), y_(y) {}
    
    constexpr int getX() const { return x_; }
    constexpr int getY() const { return y_; }
    
    constexpr Point operator+(const Point& other) const {
        return Point(x_ + other.x_, y_ + other.y_);
    }
};
constexpr Point p1(1, 2);
constexpr Point p2(3, 4);
constexpr Point p3 = p1 + p2;  // 컴파일 타임 계산
static_assert(p3.getX() == 4, "X should be 4");
static_assert(p3.getY() == 6, "Y should be 6");

if constexpr (C++17)

컴파일 타임 분기로 템플릿 특수화 없이 타입별 처리가 가능합니다.

template <typename T>
void process(T value) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "Integer: " << value << "\n";
    } else if constexpr (std::is_floating_point_v<T>) {
        std::cout << "Float: " << value << "\n";
    } else {
        std::cout << "Other: " << value << "\n";
    }
}
process(42);      // "Integer: 42"
process(3.14);    // "Float: 3.14"
process("hello"); // "Other: hello"

inline: 인라인화가 아니라 ODR 예외

inline의 진짜 의미

많은 사람들이 inline을 “함수를 인라인화하라”는 지시로 오해하지만, 실제 의미는 ODR 예외입니다.

// header.h
inline int add(int a, int b) {  // 여러 cpp 파일에 포함되어도 OK
    return a + b;
}

ODR (One Definition Rule):

  • 일반 함수: 정의는 하나의 번역 단위에만 있어야 함
  • inline 함수: 여러 번역 단위에 정의가 있어도 OK (단, 정의가 동일해야 함)

“정의가 동일해야 함”이라는 조건은 컴파일러도 링커도 검사하지 않습니다. 두 .cpp 파일이 서로 다른 매크로 설정(예: 한쪽만 #define DEBUG_LOG)으로 같은 헤더의 inline 함수를 컴파일하면 정의가 달라지는데, 링커는 경고 없이 그중 하나를 골라 모든 호출에 씁니다. 그 결과 디버그 빌드에서만 나타나던 로그가 릴리스 바이너리에 섞이거나, 구조체 크기가 다르게 계산된 코드가 섞여 메모리가 깨지는 “ODR 위반” 버그가 됩니다. GCC/Clang의 -flto와 -Wodr, 또는 AddressSanitizer의 detect_odr_violation이 이런 문제 일부를 잡아 줍니다. 헤더에 두는 inline 함수와 템플릿은 빌드 설정에 따라 내용이 바뀌지 않게 하는 것이 원칙입니다.

inline 변수 (C++17)

// config.h
inline int globalConfig = 100;  // 헤더에 정의 가능!
inline std::string appName = "MyApp";

이전 방식 (C++17 이전):

// config.h
extern int globalConfig;  // 선언
// config.cpp
int globalConfig = 100;   // 정의

inline과 최적화

컴파일러는 inline 키워드와 무관하게 인라인화를 결정합니다.

inline void smallFunction() {
    // 짧은 함수: 컴파일러가 인라인화할 가능성 높음
}
inline void hugeFunction() {
    // 긴 함수: inline 키워드가 있어도 인라인화 안 될 수 있음
    for (int i = 0; i < 1000; ++i) {
        // ...
    }
}

컴파일러 최적화 옵션:

  • -O2, -O3: 자동 인라인화
  • __attribute__((always_inline)) (GCC/Clang): 강제 인라인화
  • __forceinline (MSVC): 강제 인라인화

클래스 내부 정의 = 암시적 inline

class MyClass {
public:
    int getValue() const { return value_; }  // 암시적으로 inline
    
    void setValue(int value);  // inline 아님
    
private:
    int value_;
};
// cpp 파일
void MyClass::setValue(int value) {
    value_ = value;
}

volatile: 최적화를 막는 메모리 접근

volatile의 의미

volatile은 컴파일러에게 최적화를 하지 말라고 지시합니다.

volatile int hardwareRegister;
// 컴파일러는 이 코드를 최적화하지 않음
hardwareRegister = 1;
hardwareRegister = 2;
hardwareRegister = 3;

최적화 없이:

mov [hardwareRegister], 1
mov [hardwareRegister], 2
mov [hardwareRegister], 3

최적화 시 (volatile 없으면):

mov [hardwareRegister], 3  // 1, 2는 생략

volatile이 필요한 하드웨어 접근

하드웨어 레지스터

class GPIO {
    volatile uint32_t* const registerAddress_;
    
public:
    GPIO(uint32_t address) : registerAddress_(reinterpret_cast<volatile uint32_t*>(address)) {}
    
    void setHigh() {
        *registerAddress_ |= 0x01;
    }
    
    void setLow() {
        *registerAddress_ &= ~0x01;
    }
    
    bool isHigh() const {
        return (*registerAddress_ & 0x01) != 0;
    }
};

setHigh의 *registerAddress_ |= 0x01은 한 줄이지만 실제로는 읽기 → 수정 → 쓰기 세 단계입니다. volatile은 각 단계가 생략되지 않게 할 뿐 셋을 하나로 묶어 주지 않으므로, 읽은 직후 인터럽트 핸들러가 같은 레지스터의 다른 비트를 바꾸면 그 변경이 덮어써집니다. 임베디드 코드에서 “가끔 다른 핀 설정이 풀린다”는 버그의 흔한 원인이라, 인터럽트와 공유하는 레지스터는 이 구간에서 인터럽트를 막거나, 하드웨어가 제공하는 비트별 set/clear 레지스터를 쓰는 것이 정석입니다. 참고로 C++20은 volatile 변수에 대한 |=, ++ 같은 복합 연산을 이런 오해를 부른다는 이유로 deprecated로 지정해 컴파일러가 경고를 냈고, 임베디드 코드에서 반발이 커서 C++23에서 비트 연산 복합 대입(|=, &=, ^=)은 다시 허용되었습니다.

메모리 매핑 I/O

struct DeviceRegisters {
    volatile uint32_t control;
    volatile uint32_t status;
    volatile uint32_t data;
};
DeviceRegisters* device = reinterpret_cast<DeviceRegisters*>(0x40000000);
void sendData(uint32_t value) {
    while (!(device->status & STATUS_READY)) {
        // volatile이므로 매번 status를 읽음
    }
    device->data = value;
}

volatile과 멀티스레딩 (주의!)

잘못된 사용:

volatile bool flag = false;
// Thread 1
void thread1() {
    flag = true;  // 다른 스레드에 신호
}
// Thread 2
void thread2() {
    while (!flag) {  // 잘못된 동기화!
        // ...
    }
}

문제점:

  • volatile은 메모리 순서를 보장하지 않음
  • 원자성을 보장하지 않음

이 코드가 x86에서 “잘 동작하는 것처럼 보이는” 것이 오히려 함정입니다. x86은 메모리 모델이 강해서 bool 하나를 쓰고 읽는 정도는 대부분 기대대로 보이고, volatile 덕분에 컴파일러가 루프 밖으로 읽기를 빼내지도 않으니 종료 플래그 용도로는 멀쩡해 보입니다. 하지만 표준상 여전히 데이터 레이스라 미정의 동작이고, 플래그와 함께 다른 데이터를 넘기는 순간 문제가 드러납니다. 스레드 1이 결과 값을 쓰고 flag = true를 한 뒤 스레드 2가 플래그를 보고 결과를 읽는 패턴에서, ARM 같은 약한 메모리 모델의 CPU는 결과 쓰기가 보이기 전에 플래그 쓰기가 먼저 보이게 할 수 있습니다. ThreadSanitizer는 volatile 변수 접근도 데이터 레이스로 보고하므로, 테스트에서 TSan을 돌리면 이런 코드를 찾을 수 있습니다. 올바른 방법:

std::atomic<bool> flag(false);
// Thread 1
void thread1() {
    flag.store(true, std::memory_order_release);
}
// Thread 2
void thread2() {
    while (!flag.load(std::memory_order_acquire)) {
        // ...
    }
}

mutable: 논리적 const 안에서의 수정

const 멤버 함수에서 수정 가능

class Counter {
    mutable int accessCount_ = 0;
    int value_;
    
public:
    int getValue() const {
        ++accessCount_;  // const 함수에서 수정 가능
        return value_;
    }
    
    int getAccessCount() const {
        return accessCount_;
    }
};

mutable로 구현하는 지연 초기화·캐시·잠금

지연 초기화 (Lazy Initialization)

class ExpensiveResource {
    mutable std::unique_ptr<Data> data_;
    
public:
    const Data& getData() const {
        if (!data_) {
            data_ = std::make_unique<Data>();  // 첫 접근 시 초기화
        }
        return *data_;
    }
};

캐싱

class Matrix {
    std::vector<std::vector<double>> data_;
    mutable std::optional<double> cachedDeterminant_;
    
public:
    double determinant() const {
        if (!cachedDeterminant_) {
            cachedDeterminant_ = computeDeterminant();
        }
        return *cachedDeterminant_;
    }
    
private:
    double computeDeterminant() const;
};

동기화

class ThreadSafeCounter {
    mutable std::mutex mutex_;
    int value_ = 0;
    
public:
    int getValue() const {
        std::lock_guard<std::mutex> lock(mutex_);
        return value_;
    }
    
    void increment() {
        std::lock_guard<std::mutex> lock(mutex_);
        ++value_;
    }
};

mutable과 람다

int main() {
    int x = 0;
    
    // mutable 람다: 캡처한 변수를 수정 가능
    auto increment = [x]() mutable {
        ++x;  // 복사본 수정
        return x;
    };
    
    std::cout << increment() << "\n";  // 1
    std::cout << increment() << "\n";  // 2
    std::cout << x << "\n";            // 0 (원본은 변경 안 됨)
}

mutable의 기준은 논리적 상수성입니다. 호출하는 쪽에서 관찰할 수 있는 상태가 바뀌지 않는다면(캐시, 접근 횟수, 뮤텍스) const 함수 안에서 바꿔도 되고, 관찰 가능한 값이 바뀐다면 그 함수는 const가 아니어야 합니다. 멀티스레드 환경에서는 한 가지가 더 붙습니다. 표준 라이브러리는 const 멤버 함수를 여러 스레드가 동시에 호출해도 안전하다고 가정하고 설계되어 있는데, 위의 지연 초기화나 캐싱 예제처럼 const 함수가 mutable 멤버를 동기화 없이 쓰면 그 가정이 깨집니다. 두 스레드가 동시에 getData()를 처음 호출하면 둘 다 data_가 비어 있다고 보고 각자 객체를 만들어 대입하는 데이터 레이스가 생깁니다. mutable 멤버를 수정하는 const 함수는 뮤텍스나 std::call_once로 보호하거나, 단일 스레드 전용이라고 문서화해 두어야 합니다. 람다의 mutable은 이와 별개로, 값으로 캡처한 복사본을 호출 연산자 안에서 바꿀 수 있게 하는 표시입니다.


static constexpr·inline constexpr 같은 조합

static const vs static constexpr

class Config {
public:
    static const int MAX_SIZE = 100;        // C++11 이전 스타일
    static constexpr int BUFFER_SIZE = 256; // 현대 C++ 스타일
    
    static const std::string APP_NAME;      // 복잡한 타입은 cpp에서 정의
};
// cpp 파일
const std::string Config::APP_NAME = "MyApp";

C++17 이후:

class Config {
public:
    static inline constexpr int MAX_SIZE = 100;
    static inline const std::string APP_NAME = "MyApp";  // 헤더에 정의 가능
};

extern const vs inline constexpr

// C++11 방식
// header.h
extern const int GLOBAL_MAX;
// source.cpp
const int GLOBAL_MAX = 1000;
// C++17 방식
// header.h
inline constexpr int GLOBAL_MAX = 1000;  // 헤더에 정의 가능

static inline 함수

// header.h
class Utility {
public:
    static inline int add(int a, int b) {  // static + inline
        return a + b;
    }
};
// 또는
inline int add(int a, int b) {  // 네임스페이스 레벨
    return a + b;
}

링키지와 스토리지 클래스 한눈에 보기

링커가 처리하는 링키지 메커니즘:

링키지(Linkage): 여러 오브젝트 파일을 링크할 때 심볼 이름 해석 방식

컴파일 및 링크 과정:

file1.cpp:
int globalVar = 42;           // 외부 링키지
static int fileVar = 100;     // 내부 링키지

void func() {
    extern int globalVar;     // 외부 링키지 선언
}

file2.cpp:
extern int globalVar;         // 외부 링키지 참조
static int fileVar = 200;     // 내부 링키지 (file1과 별개!)

void func2() {
    globalVar++;              // file1의 globalVar 접근
}

컴파일러가 생성하는 심볼 테이블:

file1.o:
┌──────────────┬─────────┬────────┐
│ 심볼         │ 링키지  │ 주소   │
├──────────────┼─────────┼────────┤
│ globalVar    │ GLOBAL  │ 0x1000 │
│ fileVar      │ LOCAL   │ 0x2000 │
│ func         │ GLOBAL  │ 0x3000 │
└──────────────┴─────────┴────────┘

file2.o:
┌──────────────┬─────────┬────────┐
│ 심볼         │ 링키지  │ 주소   │
├──────────────┼─────────┼────────┤
│ globalVar    │ UNDEF   │ -      │  ← 정의 없음, 링커가 해결
│ fileVar      │ LOCAL   │ 0x2000 │  ← file1의 fileVar와 독립적!
│ func2        │ GLOBAL  │ 0x3000 │
└──────────────┴─────────┴────────┘

링커 동작:

1. 심볼 수집:
   GLOBAL 심볼:
   - globalVar (file1.o)
   - func (file1.o)
   - func2 (file2.o)
   
   LOCAL 심볼:
   - fileVar (file1.o, 주소 0x2000)
   - fileVar (file2.o, 주소 0x2000)  ← 이름 같아도 충돌 없음!

2. 심볼 해석:
   file2.o의 globalVar (UNDEF)
   → file1.o의 globalVar (0x1000) 참조로 해결

3. 주소 재배치:
   file1.o의 globalVar: 0x1000 → 최종 0x400000
   file2.o의 func2에서 globalVar 접근:
   → 0x400000으로 재배치

최종 실행 파일 심볼 테이블:

┌──────────────┬─────────┬────────┐
│ 심볼         │ 링키지  │ 주소   │
├──────────────┼─────────┼────────┤
│ globalVar    │ GLOBAL  │ 0x400000│  ← 하나!
│ file1:fileVar│ LOCAL   │ 0x401000│  ← 분리됨
│ file2:fileVar│ LOCAL   │ 0x402000│  ← 분리됨
│ func         │ GLOBAL  │ 0x403000│
│ func2        │ GLOBAL  │ 0x404000│
└──────────────┴─────────┴────────┘

내부 링키지 심볼 (GCC/Clang 기준):

file1.cpp:
static int counter = 0;
→ 심볼: _ZL7counter  (L = 내부 링키지 표시), 바인딩: LOCAL

file2.cpp:
static int counter = 0;
→ 심볼: _ZL7counter  (이름은 같지만 LOCAL 바인딩이라 링커가 서로 연결하지 않음)

익명 네임스페이스:

file1.cpp:
namespace {
    int counter = 0;
}

→ 심볼: _ZN12_GLOBAL__N_17counterE  (_GLOBAL__N_1 = 익명 네임스페이스), 바인딩: LOCAL
→ 내부 링키지와 동일한 효과

extern "C"와 링키지:

C++:
void func(int x) { }
→ 심볼: _Z4funci  (i = int 타입)

C:
extern "C" void func(int x) { }
→ 심볼: func  (맹글링 없음)

링커 오류 예시:

중복 정의 (Multiple Definition):

file1.cpp:
int globalVar = 42;

file2.cpp:
int globalVar = 100;

링커 오류:
multiple definition of `globalVar'
file2.o: globalVar
file1.o: globalVar
first defined here

정의되지 않음 (Undefined Reference):

file1.cpp:
extern int missingVar;

int main() {
    return missingVar;
}

링커 오류:
undefined reference to `missingVar'

ODR (One Definition Rule):

규칙: 외부 링키지 심볼은 전체 프로그램에서 정의 1개만!

inline 예외:

file1.cpp:
inline int func() { return 42; }

file2.cpp:
inline int func() { return 42; }

→ 링커가 하나만 선택 (COMDAT 섹션)
→ ODR 위반 아님!

링키지 확인 명령어:

# 심볼 테이블 보기
nm file.o

출력:
0000000000000000 T func        # T = GLOBAL
0000000000000004 t _Z8helperv  # t = LOCAL
                 U globalVar   # U = UNDEFINED

# 링크 맵 보기
ld -Map=output.map file1.o file2.o

# readelf로 심볼 정보
readelf -s a.out

링키지 종류

키워드링키지설명
static (파일 스코프)내부파일 내부에서만 접근
extern외부다른 파일에서 접근 가능
const (네임스페이스 범위)내부C++에서는 모든 표준에서 기본 내부 링키지 (C와 다름)
constexpr (네임스페이스 범위)내부inline을 붙이지 않으면 내부 링키지
inline외부ODR 예외 (여러 정의 허용)
익명 네임스페이스내부static과 동일

스토리지 클래스

키워드스토리지생명주기
static (지역)정적프로그램 시작~종료
static (전역)정적프로그램 시작~종료
extern정적프로그램 시작~종료
(일반 지역 변수)자동블록 진입~탈출
thread_local스레드스레드 시작~종료

초기화 순서

// 전역 변수 초기화 순서는 정의되지 않음 (같은 파일 내에서는 순서대로)
int a = 10;
int b = a + 5;  // OK (같은 파일)
// 다른 파일의 전역 변수 의존은 위험
// file1.cpp
int x = 100;
// file2.cpp
extern int x;
int y = x + 10;  // 위험! x가 초기화되지 않았을 수 있음

해결책: 함수 내부 static

int& getX() {
    static int x = 100;  // 첫 호출 시 초기화
    return x;
}
int y = getX() + 10;  // 안전

키워드를 조합한 설정 관리 클래스

// config.h
#pragma once
#include <string>
#include <unordered_map>
#include <mutex>
#include <optional>
class Config {
public:
    // 싱글톤 (static + inline)
    static Config& getInstance() {
        static Config instance;
        return instance;
    }
    
    // const 멤버 함수 + mutable
    std::optional<std::string> get(const std::string& key) const {
        std::lock_guard<std::mutex> lock(mutex_);
        auto it = data_.find(key);
        return it != data_.end() ? std::optional(it->second) : std::nullopt;
    }
    
    void set(const std::string& key, const std::string& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        data_[key] = value;
    }
    
    // constexpr 상수
    static inline constexpr int MAX_KEY_LENGTH = 256;
    static inline constexpr int MAX_VALUE_LENGTH = 1024;
    
private:
    Config() = default;
    Config(const Config&) = delete;
    Config& operator=(const Config&) = delete;
    
    mutable std::mutex mutex_;  // const 함수에서 사용
    std::unordered_map<std::string, std::string> data_;
};
// 전역 헬퍼 함수 (inline)
inline std::string getConfigOrDefault(const std::string& key, const std::string& defaultValue) {
    auto value = Config::getInstance().get(key);
    return value.value_or(defaultValue);
}
// main.cpp
#include "config.h"
#include <iostream>
int main() {
    auto& config = Config::getInstance();
    
    config.set("app.name", "MyApp");
    config.set("app.version", "1.0.0");
    
    std::cout << "App: " << getConfigOrDefault("app.name", "Unknown") << "\n";
    std::cout << "Version: " << getConfigOrDefault("app.version", "0.0.0") << "\n";
    
    static_assert(Config::MAX_KEY_LENGTH == 256, "Key length should be 256");
}

키워드별 성능 영향

inline과 성능

// 짧은 함수: 인라인화 시 성능 향상
inline int square(int x) {
    return x * x;
}
// 호출 오버헤드 제거
int result = square(5);  // mov eax, 25 (인라인화 시)

constexpr과 성능

// 컴파일 타임 계산
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int result = factorial(10);  // 3628800 (컴파일 타임)
// 어셈블리: mov eax, 3628800

static과 성능

// 함수 호출마다 초기화 (느림)
void function1() {
    std::vector<int> data(1000);
    // ...
}
// 한 번만 초기화 (빠름)
void function2() {
    static std::vector<int> data(1000);
    // ...
}

주의: static 지역 변수는 스레드 안전 초기화로 인한 오버헤드가 있습니다.

function2처럼 버퍼를 static으로 바꾸는 최적화는 동작도 바꾼다는 점을 알고 써야 합니다. 벡터가 호출 사이에 내용을 유지하므로 이전 호출이 남긴 값이 다음 호출에 보이고, 두 스레드가 동시에 이 함수를 호출하면 같은 벡터를 동시에 수정하는 데이터 레이스가 됩니다. 재귀 호출에서도 같은 버퍼를 공유해 결과가 깨집니다. 할당 비용을 줄이는 것이 목적이라면 호출하는 쪽이 버퍼를 만들어 참조로 넘기거나, 스레드마다 하나씩 두는 thread_local이 더 안전한 선택입니다.


GCC/Clang과 MSVC의 키워드 처리 차이

GCC/Clang

// 강제 인라인화
__attribute__((always_inline)) inline void forceInline() {
    // ...
}
// 인라인화 금지
__attribute__((noinline)) void noInline() {
    // ...
}
// 가시성 제어
__attribute__((visibility("hidden"))) void internalFunction() {
    // ...
}

MSVC

// 강제 인라인화
__forceinline void forceInline() {
    // ...
}
// 인라인화 금지
__declspec(noinline) void noInline() {
    // ...
}
// DLL export/import
__declspec(dllexport) void exportedFunction() {
    // ...
}

C++17 이후 권장하는 선택

C++17 이후 스타일

// ❌ 구식
// header.h
extern const int MAX_SIZE;
// source.cpp
const int MAX_SIZE = 100;
// ✅ 현대
// header.h
inline constexpr int MAX_SIZE = 100;
// ❌ 구식
static int helperFunction() {
    return 42;
}
// ✅ 현대
namespace {
    int helperFunction() {
        return 42;
    }
}

constexpr 우선

// △ 정수형이라 이것도 컴파일 타임 상수지만, 초기값이 상수가 아니어도 조용히 컴파일됨
const int SIZE = 10 * 10;
// ✅ 컴파일 타임 상수임을 컴파일러가 강제
constexpr int SIZE = 10 * 10;

위 const int 버전도 실제로는 컴파일 시점에 100으로 계산됩니다. constexpr을 권하는 이유는 속도가 아니라 의도의 검증입니다. 나중에 누군가 초기값을 readConfig() * 10으로 바꾸면 const 버전은 아무 경고 없이 런타임 상수가 되어, 그 값을 배열 크기로 쓰던 다른 곳에서 뒤늦게 에러가 납니다. constexpr 버전은 바꾼 그 줄에서 바로 에러가 납니다.

멀티스레딩에서는 atomic 사용

// ❌ volatile (멀티스레딩에 부적합)
volatile bool flag = false;
// ✅ atomic
std::atomic<bool> flag(false);

키워드 정리

키워드별 용도 요약표

키워드주요 용도핵심 특징
static내부 링키지, 정적 저장파일/클래스/함수 스코프에 따라 의미 다름
extern외부 링키지 선언다른 파일의 변수/함수 참조
const런타임 상수수정 불가, const 멤버 함수
constexpr컴파일 타임 상수컴파일 타임 계산 가능
inlineODR 예외여러 정의 허용, 인라인화는 부수 효과
volatile최적화 방지하드웨어 레지스터, MMIO
mutableconst 예외const 함수에서 수정 가능

코드 리뷰에서 확인할 항목

  • 파일 스코프 static 대신 익명 네임스페이스 사용
  • const 대신 constexpr 사용 (가능한 경우)
  • C++17 이후: inline constexpr로 헤더에 상수 정의
  • 멀티스레딩: volatile 대신 atomic 사용
  • mutable은 논리적 const에만 사용
  • extern “C”로 C 라이브러리 호환성 확보
  • static 초기화 순서 문제 주의

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. volatile을 멀티스레드 종료 플래그로 쓰면 왜 안 되나요?

A. volatile은 컴파일러가 해당 변수의 읽기·쓰기를 생략하거나 레지스터에 캐시하지 못하게 할 뿐, 원자성이나 다른 스레드에서 보이는 순서(메모리 순서)는 보장하지 않습니다. C++ 표준에서 volatile 변수에 여러 스레드가 동기화 없이 접근하면 여전히 데이터 레이스이며 미정의 동작입니다. 스레드 간 플래그에는 std::atomic<bool>을 쓰고, volatile은 메모리 맵 I/O 레지스터처럼 원래 목적에만 쓰는 것이 맞습니다.