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++;
}

네임스페이스 범위의 static은 이름에 내부 링키지를 부여합니다. 다른 번역 단위에서는 이 이름을 참조할 수 없고, 다른 파일에 같은 이름의 static 변수나 함수가 있어도 서로 별개의 개체라서 링크 시 충돌하지 않습니다. 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;

static 데이터 멤버는 인스턴스마다 생기지 않고 클래스당 하나만 존재하며, 모든 객체가 이를 공유합니다. static 멤버 함수에는 this 포인터가 없어서 Database::incrementConnections()처럼 객체 없이 호출할 수 있고, 같은 이유로 객체를 인자로 받지 않는 한 비정적 멤버에 접근할 수 없습니다. C++17부터는 static inline int connectionCount = 0;처럼 클래스 안에서 바로 정의할 수 있어, 별도의 cpp 파일 정의가 필요 없습니다.

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

#include <iostream>
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
}

정적 지역 변수의 저장 공간은 스택이 아니라 정적 저장 영역(.data나 .bss)에 있어서 함수가 끝나도 값이 유지됩니다. 상수로 초기화되는 경우(static int id = 0;)는 프로그램 시작 전에 값이 정해지고, 생성자 호출처럼 동적 초기화가 필요한 경우는 실행 흐름이 처음 그 선언을 지날 때 한 번만 초기화됩니다. 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 세그먼트
    static int zeroVar;        // .bss 세그먼트 (0으로 초기화)
    int stackVar = 300;        // 스택
}

일반적인 ELF 실행 파일 기준으로 나타내면 다음과 같습니다. 0이 아닌 값으로 초기화된 정적 변수는 .data에, 0으로 초기화되거나 초기값이 없는 정적 변수는 파일에 공간을 차지하지 않는 .bss에 놓입니다.

+-------------------+
| Code (.text)      | ← 함수 코드
+-------------------+
| Data (.data)      | ← globalVar, fileVar, localVar
+-------------------+
| BSS (.bss)        | ← zeroVar (0 초기화 정적 변수)
+-------------------+
| 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은 다른 번역 단위에 정의된 변수를 정의 없이 선언만 할 때 사용합니다. 함수 선언은 원래 외부 링키지를 가지므로 extern을 붙여도 의미가 달라지지 않습니다.

// globals.cpp
int globalCounter = 0;  // 정의
void incrementCounter() {
    globalCounter++;
}
#include <iostream>
// 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와 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 (명시적 인스턴스화 억제)

템플릿은 사용하는 번역 단위마다 인스턴스화되고, 링커가 중복을 하나로 합칩니다. extern template은 특정 인스턴스를 한 번역 단위에서만 명시적으로 인스턴스화하고, 나머지 번역 단위에서는 인스턴스화하지 말고 그 결과를 쓰라고 지시합니다. 명시적 인스턴스화를 하는 vector.cpp에서는 멤버 함수의 정의가 보여야 합니다.

// 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;  // 이 번역 단위에서는 Vector<int>를 인스턴스화하지 않음
}

효과는 주로 컴파일 시간과 오브젝트 파일 크기에서 나타납니다. 무거운 템플릿을 수십 개 번역 단위가 같은 타입으로 쓰는 경우, 각 번역 단위가 같은 코드를 생성하고 최적화하는 작업을 반복하지 않아도 됩니다. 최종 실행 파일 크기는 원래도 링커가 중복을 합치므로 크게 달라지지 않는 경우가 많습니다. 단, 인라인 후보가 되는 멤버 함수는 extern template이 있어도 컴파일러가 인라인을 위해 계속 볼 수 있습니다.


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 필요
// globals.h
extern const int GLOBAL_MAX;  // 선언
// globals.cpp
extern const int GLOBAL_MAX = 1000;  // 정의 (extern이 없으면 내부 링키지가 되어 링크 에러)
// 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 함수 안에서 뮤텍스를 잠그는 것입니다. 두 경우 모두 호출하는 쪽에서 볼 수 있는 객체의 값은 바뀌지 않습니다. 자세한 기준은 아래 mutable 절에서 다룹니다.

const_cast (const 제거)

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

const_cast로 const를 벗기는 것 자체는 합법이지만, 원래 const로 정의된 객체를 그렇게 얻은 포인터로 수정하면 미정의 동작입니다.

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

constexpr: 컴파일 타임 평가

컴파일 타임 상수

변수에 붙인 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의 뜻은 “이 변수를 통해서는 값을 바꾸지 않는다”는 읽기 전용 약속이고, 초기값이 컴파일 시점에 정해졌는지와는 무관합니다. 다만 정수형(과 열거형) 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++11의 constexpr 함수는 본문이 사실상 return 문 하나여야 했지만, C++14부터는 지역 변수와 반복문, 여러 문장을 쓸 수 있습니다.

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)

if constexpr는 조건을 컴파일 타임에 평가하고, 템플릿 안에서는 선택되지 않은 분기를 인스턴스화하지 않습니다. 그래서 특수화나 오버로드 없이 타입별 처리를 한 함수 안에 쓸 수 있습니다.

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(One Definition Rule)의 예외입니다.

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

외부 링키지를 가진 일반 함수는 프로그램 전체에서 정의가 하나만 있어야 합니다. inline 함수는 여러 번역 단위에 정의가 있어도 되는 대신, 모든 정의가 토큰 단위로 같아야 하고 사용하는 번역 단위마다 정의가 보여야 합니다.

“정의가 같아야 한다”는 조건은 컴파일러도 링커도 일반적으로 검사하지 않습니다. 두 .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 이전에는 헤더에 선언만 두고 cpp 파일 하나에 정의를 두어야 했습니다.

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

inline과 최적화

최적화 단계에서 실제로 함수 본문을 호출 위치에 펼칠지는 컴파일러가 함수 크기, 호출 빈도, 최적화 수준을 보고 결정합니다. inline 키워드는 GCC나 Clang에서 인라인 판단 기준을 약간 완화하는 힌트로 쓰이기도 하지만, 결정적인 요소는 아닙니다.

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

-O2, -O3 같은 최적화 옵션을 켜면 컴파일러는 inline이 없는 함수도 같은 번역 단위 안에서 정의가 보이면 인라인화합니다. 강제로 인라인화하려면 GCC/Clang의 __attribute__((always_inline))이나 MSVC의 __forceinline 같은 컴파일러 확장을 써야 하며, 재귀 함수처럼 펼칠 수 없는 경우에는 이것도 실패할 수 있습니다.

클래스 내부 정의 = 암시적 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 접근을 생략하거나, 합치거나, 다른 volatile 접근과 순서를 바꿀 수 없습니다. 함수 전체의 최적화를 끄는 것이 아니라, 해당 객체에 대한 접근만 소스 그대로 수행하게 만드는 것입니다.

volatile int hardwareRegister;
hardwareRegister = 1;
hardwareRegister = 2;
hardwareRegister = 3;

volatile이 있으면 최적화 빌드에서도 세 번의 쓰기가 모두 남습니다.

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

volatile이 없으면 앞의 두 쓰기는 관찰할 방법이 없으므로, 최적화 시 마지막 쓰기만 남을 수 있습니다.

mov [hardwareRegister], 3

volatile이 필요한 하드웨어 접근

하드웨어 레지스터

class GPIO {
    volatile uint32_t* const registerAddress_;
    
public:
    explicit GPIO(std::uintptr_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을 스레드 간 신호로 잘못 쓰는 예입니다.

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

volatile은 원자성도, 다른 스레드에서 보이는 메모리 순서도 보장하지 않습니다. 표준상 동기화 없이 여러 스레드가 volatile 변수에 접근하면 여전히 데이터 레이스이고 미정의 동작입니다.

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

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과 람다

#include <iostream>
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부터는 static constexpr 데이터 멤버가 암시적으로 inline이 되므로 클래스 밖 정의가 필요 없고, constexpr로 만들 수 없는 타입도 static inline으로 헤더에서 정의할 수 있습니다. 아래의 inline constexpr에서 inline은 생략해도 의미가 같습니다.

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
#include "header.h"        // 앞선 extern 선언이 보여야 외부 링키지가 됨
const int GLOBAL_MAX = 1000;
// C++17 방식
// header.h
inline constexpr int GLOBAL_MAX = 1000;  // 헤더에 정의 가능

static inline 함수

// header.h
class Utility {
public:
    static int add(int a, int b) {  // 클래스 안에서 정의했으므로 이미 암시적 inline
        return a + b;
    }
};
// 또는
inline int add(int a, int b) {  // 네임스페이스 레벨: 외부 링키지 + ODR 예외
    return a + b;
}
// static inline int add(...)  // 네임스페이스 레벨: 내부 링키지, 번역 단위마다 별도 복사본

네임스페이스 레벨에서 static inline은 C 헤더에서 흔히 쓰는 조합인데, C++에서는 번역 단위마다 함수의 별도 사본이 생기고 함수 주소도 번역 단위마다 달라집니다. 함수 안에 정적 지역 변수가 있으면 그 변수도 번역 단위마다 따로 생깁니다. 특별한 이유가 없다면 그냥 inline을 쓰는 편이 낫습니다.


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

링커 입장에서 링키지가 어떻게 보이는지 두 파일 예제로 따라가 보면 다음과 같습니다. 주소 값은 설명을 위한 가상의 값입니다.

링키지(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

출력 (C++ 심볼은 맹글링된 이름으로 나옴, nm -C로 디맹글링):
0000000000000000 T _Z4funcv    # T = 전역 텍스트 심볼
0000000000000010 t _Z6helperv  # t = 로컬 텍스트 심볼
                 U globalVar   # U = 정의되지 않음 (다른 파일에서 찾아야 함)

# 링크 맵 보기 (컴파일러 드라이버를 통해 링커에 옵션 전달)
g++ -Wl,-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 x = 100;처럼 상수로 초기화되는 변수는 프로그램이 실행되기 전에 값이 정해지므로(상수 초기화) 순서 문제가 없습니다. 문제는 함수 호출이나 생성자처럼 실행 시점에 계산해야 하는 동적 초기화입니다. 같은 번역 단위 안에서는 정의 순서대로 진행되지만, 서로 다른 번역 단위 사이의 순서는 정해져 있지 않습니다.

// 같은 파일 안에서는 정의 순서대로 초기화
int a = 10;
int b = a + 5;  // OK

// file1.cpp
std::string prefix = loadPrefix();   // 동적 초기화

// file2.cpp
extern std::string prefix;
std::string greeting = prefix + "님"; // 위험: prefix가 아직 생성되지 않았을 수 있음

가장 흔한 해결책은 함수 안의 정적 지역 변수로 바꾸는 것입니다. 처음 호출될 때 초기화되므로 누가 먼저 호출하든 초기화된 객체를 돌려받습니다.

const std::string& prefix() {
    static const std::string value = loadPrefix();  // 첫 호출 시 초기화
    return value;
}
std::string greeting = prefix() + "님";  // 안전

상수 초기화로 충분한 변수라면 C++20의 constinit을 붙여, 동적 초기화로 바뀌는 순간 컴파일 에러가 나게 할 수도 있습니다.


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

// config.h
#pragma once
#include <string>
#include <unordered_map>
#include <mutex>
#include <optional>
class Config {
public:
    // 싱글톤: static 멤버 함수 + 함수 안의 static 객체
    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 지역 변수는 접근할 때마다 초기화 여부를 확인하는 가드 검사가 들어가므로 비용이 0은 아니지만, 초기화 이후에는 보통 분기 하나 수준입니다.

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부터 inline constexpr로 헤더에 바로 정의하는 것이 가장 간단합니다. 이전 방식인 extern const 선언과 cpp 정의 조합은 정의하는 cpp 파일이 그 헤더를 포함하지 않으면 내부 링키지가 되어 링크 에러가 나는 함정이 있습니다.

// C++17 이전
// header.h
extern const int MAX_SIZE;
// source.cpp
#include "header.h"
const int MAX_SIZE = 100;

// C++17 이후
// header.h
inline constexpr int MAX_SIZE = 100;

번역 단위 안에서만 쓰는 함수와 변수는 static보다 익명 네임스페이스로 감싸는 방식이 많이 쓰입니다. 둘 다 내부 링키지를 만들지만, 익명 네임스페이스는 타입 정의까지 함께 가둘 수 있습니다.

// static 방식
static int helperFunction() {
    return 42;
}
// 익명 네임스페이스 방식
namespace {
    int helperFunction() {
        return 42;
    }
}

컴파일 타임 상수가 의도라면 const보다 constexpr을 씁니다.

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

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

참고 자료


같이 보면 좋은 글