C static 심화 가이드 — 번역 단위·정적 초기화·TLS·inline·실무 패턴

이 글의 핵심

static의 두 축(저장 기간 연장·내부 링크)을 전제로, 번역 단위와 링커 관점의 심볼, C에서 정적 초기화가 상수식으로 제한되는 이유와 남아 있는 초기화 순서 문제, _Thread_local, C99/C11 inline의 연결 규칙, 그리고 실무에서의 캡슐화·초기화·동시성 관례를 연결해 설명합니다.

들어가며

C의 static 키워드는 어디에 붙느냐에 따라 의미가 달라집니다.

// 1. 함수 내부 static 변수
void counter(void) {
    static int count = 0;  // 함수 호출 간 값 유지
    count++;
    printf("%d\n", count);
}
// 2. 파일 범위 static 변수
static int file_var = 10;  // 이 파일에서만 접근 가능
// 3. static 함수
static void helper(void) { // 이 파일에서만 호출 가능
    // ...
}

함수 안에서는 변수의 수명을 프로그램 전체로 늘리고(저장 기간), 파일 범위에서는 이름이 다른 파일에서 보이지 않게 합니다(링크). 이 글은 이 두 역할을 하나씩 풀어 본 뒤, 메모리 배치, 번역 단위와 링커 심볼, C에서의 정적 초기화 규칙, _Thread_local, C99/C11 inline 규칙, 그리고 실무 코드에서 static을 쓰는 방식까지 이어서 다룹니다.


static 지역 변수

일반 지역 변수와 static 지역 변수

일반 지역 변수(자동 저장 기간)는 함수가 호출될 때마다 새로 만들어지고 함수가 끝나면 사라집니다. 보통 스택에 놓입니다. static 지역 변수는 정적 저장 기간을 가져, 프로그램이 시작될 때 이미 존재하고 종료될 때까지 유지됩니다. 이름이 보이는 범위(스코프)는 여전히 그 함수 블록 안뿐입니다.

#include <stdio.h>
// 일반 지역 변수
void normal_counter(void) {
    int count = 0;  // 호출될 때마다 0으로 초기화
    count++;
    printf("Normal: %d\n", count);  // 항상 1
}
// static 지역 변수
void static_counter(void) {
    static int count = 0;  // 프로그램 시작 전에 한 번만 초기화
    count++;
    printf("Static: %d\n", count);  // 1, 2, 3, ...
}
int main(void) {
    for (int i = 0; i < 3; i++) {
        normal_counter();
        static_counter();
    }
    return 0;
}
Normal: 1
Static: 1
Normal: 1
Static: 2
Normal: 1
Static: 3

초기화 시점

void test(void) {
    static int a = 10;      // 초깃값 10
    static int b;           // 초기화식이 없으면 0
    
    printf("a=%d, b=%d\n", a, b);
    a++;
    b++;
}
int main(void) {
    test();  // a=10, b=0
    test();  // a=11, b=1
    test();  // a=12, b=2
    return 0;
}

C에서 정적 저장 기간 객체의 초기화식은 상수식이어야 합니다. 그래서 static int a = 10;은 실행 중에 “처음 호출될 때 한 번” 대입되는 것이 아니라, 컴파일러가 실행 파일의 데이터 영역에 10을 미리 써 두고 프로그램이 로드될 때 그 값으로 시작합니다. 함수 안에 적혀 있어도 그 줄이 호출마다 실행되는 코드가 아닌 이유입니다. 초기화식이 없으면 값이 0(포인터는 널 포인터)인 것이 보장됩니다.

이 점이 C++과 다릅니다. C++에서는 static int a = compute();처럼 함수 호출로 초기화할 수 있고, 그 경우 실제로 최초 호출 시점에 초기화 코드가 실행됩니다(C++11부터는 스레드 안전하게). C에서 같은 코드를 쓰면 “initializer element is not constant” 컴파일 에러가 납니다.

예제: 함수 호출 카운터

#include <stdio.h>
void expensive_function(void) {
    static int call_count = 0;
    call_count++;
    
    if (call_count > 100) {
        fprintf(stderr, "Warning: expensive_function called %d times\n", 
                call_count);
    }
    
    // 실제 작업...
}

여러 스레드가 이 함수를 동시에 호출하면 call_count++는 데이터 경합입니다. 이 문제는 뒤의 멀티스레드 절에서 다시 다룹니다.


static 전역 변수

일반 전역 변수와 static 전역 변수

파일 범위에 static 없이 정의한 변수는 외부 링크(external linkage)를 가져 다른 파일에서 extern 선언으로 접근할 수 있습니다. static을 붙이면 내부 링크(internal linkage)가 되어 그 파일(번역 단위) 안에서만 보입니다.

// file1.c
#include <stdio.h>
int global_var = 100;        // 외부 링크: 다른 파일에서 접근 가능
static int file_var = 200;   // 내부 링크: 이 파일에서만 접근 가능
void func1(void) {
    printf("global_var=%d, file_var=%d\n", global_var, file_var);
}
// file2.c
#include <stdio.h>
extern int global_var;       // OK: file1.c의 global_var
// extern int file_var;      // 선언 자체는 컴파일되지만, 사용하면 링크 에러
void func2(void) {
    printf("global_var=%d\n", global_var);
}

file2.c에서 extern int file_var;라고 선언하는 것까지는 컴파일러가 막지 않습니다. 컴파일러는 다른 파일의 내용을 모르기 때문입니다. 실제로 사용하면 링커가 외부 링크를 가진 file_var 정의를 찾지 못해 “undefined reference” 에러를 냅니다.

이름 충돌 방지

// module_a.c
static int counter = 0;  // module_a 전용
void module_a_increment(void) { counter++; }
int module_a_get(void) { return counter; }
// module_b.c
static int counter = 0;  // module_b 전용 (module_a와 별개)
void module_b_increment(void) { counter++; }
int module_b_get(void) { return counter; }

두 파일의 counter는 서로 다른 객체이고 링크할 때 충돌하지 않습니다. 둘 다 static 없이 int counter = 0;으로 정의했다면 “multiple definition of `counter’” 링크 에러가 납니다.

// main.c
#include <stdio.h>
void module_a_increment(void);
int module_a_get(void);
void module_b_increment(void);
int module_b_get(void);
int main(void) {
    module_a_increment();
    module_a_increment();
    module_b_increment();
    printf("A: %d, B: %d\n", module_a_get(), module_b_get());  // A: 2, B: 1
    return 0;
}

static 함수

일반 함수와 static 함수

함수는 기본적으로 외부 링크를 가집니다. static을 붙이면 내부 링크가 되어 그 파일 안에서만 호출할 수 있습니다.

// utils.c
static int helper(int x);  // 사용 전에 선언 (또는 정의를 위로 올림)

// 외부에 공개할 함수
int public_function(int x) {
    return helper(x) * 2;
}
// 내부 헬퍼 함수 (외부에 숨김)
static int helper(int x) {
    return x + 10;
}

helper를 선언 없이 먼저 호출하면, C99 이후 표준에서는 암시적 함수 선언이 허용되지 않아 에러이고, 옛 규칙으로 암시적 선언을 허용하는 컴파일러에서도 암시적 선언은 외부 링크라서 뒤의 static 정의와 충돌합니다(“static declaration of ‘helper’ follows non-static declaration”). static 함수를 아래쪽에 정의한다면 위처럼 파일 앞부분에 static 선언을 두어야 합니다.

// main.c
int public_function(int x);
// int helper(int x);   // 선언은 되지만 호출하면 링크 에러: utils.c의 helper는 내부 링크
int main(void) {
    int result = public_function(5);  // 30
    return 0;
}

static 함수를 쓰는 이유

static 함수는 모듈의 공개 API와 내부 구현을 링크 수준에서 나눕니다. 헤더에 선언된 함수만 바깥에서 부를 수 있고, 내부 헬퍼는 다른 파일에서 같은 이름을 써도 충돌하지 않습니다. 코드를 읽는 사람에게도 “이 함수는 이 파일 안에서만 쓰인다”는 사실이 바로 드러나므로, 시그니처를 바꿀 때 영향 범위를 이 파일로 한정할 수 있습니다.

최적화 측면의 이점도 있습니다. 컴파일러는 static 함수의 호출 지점을 모두 알고 있으므로, 모든 호출을 인라인 전개한 뒤 함수 본체를 아예 제거하거나, 호출 규약을 바꾸는 등의 최적화를 할 수 있습니다. 외부 링크 함수는 다른 파일에서 호출될 가능성 때문에 본체를 항상 남겨야 합니다(링크 타임 최적화를 쓰면 이 차이가 줄어듭니다).

// logger.c
#include <stddef.h>
static void format_timestamp(char* buffer, size_t size) {
    // 내부 헬퍼
}
static void write_to_file(const char* message) {
    // 내부 헬퍼
}
// 공개 API
void log_message(const char* message) {
    char timestamp[64];
    format_timestamp(timestamp, sizeof(timestamp));
    write_to_file(message);
}

메모리 배치와 초기화

메모리 영역

C 표준은 메모리 배치를 규정하지 않지만, Linux의 ELF 실행 파일 같은 일반적인 환경에서 프로세스 주소 공간은 대략 다음과 같이 나뉩니다. 위가 높은 주소입니다.

flowchart TB
    stack["스택: 자동 저장 기간 변수, 매개변수 (아래로 자람)"]
    heap["힙: malloc 등 동적 할당 (위로 자람)"]
    bss[".bss: 0으로 초기화되는 정적 저장 기간 변수"]
    data[".data: 0이 아닌 값으로 초기화된 정적 저장 기간 변수"]
    rodata[".rodata: 문자열 리터럴, const 정적 데이터"]
    text[".text: 기계어 코드"]
    stack --> heap --> bss --> data --> rodata --> text

.data는 초깃값을 실행 파일 안에 그대로 담고 있고, .bss는 크기만 기록해 두었다가 로더가 0으로 채운 메모리를 할당합니다. 그래서 static int big[1000000];처럼 초기화하지 않은 큰 배열은 실행 파일 크기를 거의 늘리지 않지만, static int big[1000000] = {1};은 배열 전체가 .data에 들어가 실행 파일이 그만큼 커집니다.

static 변수의 메모리 배치

#include <stdio.h>
int global_init = 100;          // .data
int global_uninit;              // .bss
static int static_init = 200;   // .data
static int static_uninit;       // .bss
void func(void) {
    int local = 10;                 // 스택
    static int static_local = 20;   // .data
    static int static_local_uninit; // .bss
    
    printf("global_init:         %p\n", (void*)&global_init);
    printf("global_uninit:       %p\n", (void*)&global_uninit);
    printf("static_init:         %p\n", (void*)&static_init);
    printf("static_uninit:       %p\n", (void*)&static_uninit);
    printf("local:               %p\n", (void*)&local);
    printf("static_local:        %p\n", (void*)&static_local);
    printf("static_local_uninit: %p\n", (void*)&static_local_uninit);
}

실행해 보면 정적 저장 기간 변수들은 서로 가까운 낮은 주소에, local만 동떨어진 높은 주소(예: 0x7ffc...)에 찍힙니다. 구체적인 주소는 컴파일러, 링커, PIE와 ASLR 설정에 따라 실행마다 달라집니다. nm 명령으로 목적 파일을 보면 .data에 있는 심볼은 D/d, .bss는 B/b로 표시되고, 소문자는 static처럼 내부 링크를 가진 심볼입니다.

초기화 규칙

변수 종류초기화식이 없을 때저장 위치(일반적인 구현)
지역 변수값이 정해지지 않음(읽으면 UB가 될 수 있음)스택 또는 레지스터
static 지역 변수0.data / .bss
전역 변수0.data / .bss
static 전역 변수0.data / .bss
void test(void) {
    int a;              // 값이 정해지지 않음
    static int b;       // 0
    static int c = 10;  // 10
    
    printf("b=%d, c=%d\n", b, c);
    // printf("%d", a);  // 초기화하지 않은 지역 변수를 읽는 것은 UB가 될 수 있음
}

링크(Linkage)의 세 종류

C의 식별자는 외부 링크(다른 번역 단위에서도 같은 개체를 가리킴), 내부 링크(같은 번역 단위 안에서만 같은 개체를 가리킴), 링크 없음(그 선언 하나만 가리킴) 중 하나를 가집니다.

// file.c
int global = 10;                    // 외부 링크
static int file_scope = 20;         // 내부 링크
void func(void) {
    int local = 30;                 // 링크 없음
    static int static_local = 40;   // 링크 없음, 수명은 프로그램 전체
}

static_local이 보여 주듯, 함수 안의 static은 링크를 바꾸지 않고 저장 기간만 바꿉니다. 같은 키워드가 파일 범위에서는 링크를, 블록 안에서는 저장 기간을 바꾸기 때문에 헷갈리기 쉽습니다.


예제

예제 1: 숨긴 상태를 가진 설정 모듈

// config.h
#ifndef CONFIG_H
#define CONFIG_H
typedef struct Config Config;   // 불완전 타입: 내부 구조를 숨김
const Config* config_get(void);
int config_value(const Config* cfg);
void config_destroy(void);
#endif
// config.c
#include "config.h"
#include <stdio.h>
#include <stdlib.h>
struct Config {
    int value;
    char name[64];
};
static Config* instance = NULL;   // 이 파일에서만 접근 가능

static Config* create_config(void) {
    Config* cfg = malloc(sizeof *cfg);
    if (cfg) {
        cfg->value = 42;
        snprintf(cfg->name, sizeof cfg->name, "DefaultConfig");
    }
    return cfg;
}
const Config* config_get(void) {
    if (instance == NULL) {       // 멀티스레드에서는 pthread_once 등으로 보호 필요
        instance = create_config();
    }
    return instance;
}
int config_value(const Config* cfg) { return cfg->value; }
void config_destroy(void) {
    free(instance);
    instance = NULL;
}
// main.c
#include <stdio.h>
#include "config.h"
int main(void) {
    const Config* cfg1 = config_get();
    const Config* cfg2 = config_get();
    printf("Same instance? %s\n", (cfg1 == cfg2) ? "Yes" : "No");  // Yes
    printf("Value: %d\n", config_value(cfg1));
    config_destroy();
    return 0;
}

main.c는 struct Config의 멤버를 모르기 때문에 cfg1->value처럼 직접 접근할 수 없고, 반드시 config_value()를 거칩니다. 파일 범위 static이 상태를 숨기고, 불완전 타입이 구조를 숨기는 조합으로, C에서 캡슐화를 구현하는 가장 흔한 방식입니다. config_get()의 지연 생성은 두 스레드가 동시에 처음 호출하면 객체가 두 번 만들어질 수 있으므로, 멀티스레드 코드에서는 뒤에서 볼 pthread_once로 감싸야 합니다.

예제 2: 함수 호출 통계

#include <stdio.h>
#include <time.h>
void critical_function(void) {
    static int call_count = 0;
    static time_t first_call = 0;
    
    time_t now = time(NULL);
    if (call_count == 0) {
        first_call = now;
    }
    call_count++;
    
    // 실제 작업...
    
    if (call_count % 100 == 0) {
        double elapsed = difftime(now, first_call);
        if (elapsed > 0) {
            printf("Stats: %d calls in %.0f s (%.2f calls/sec)\n",
                   call_count, elapsed, call_count / elapsed);
        } else {
            printf("Stats: %d calls in under a second\n", call_count);
        }
    }
}

time()은 초 단위이므로 100번이 1초 안에 호출되면 경과 시간이 0이 됩니다. 0으로 나누면 double에서는 무한대가 출력되므로 따로 처리했습니다.


자주 하는 실수

static 지역 변수가 호출마다 초기화된다고 생각

void count_calls(void) {
    static int count = 0;  // 호출마다 0으로 리셋되지 않음
    count++;
    printf("%d\n", count);  // 1, 2, 3, ...
}

초기화식은 앞에서 본 것처럼 프로그램 시작 전에 한 번 적용됩니다. 매 호출마다 0에서 시작해야 한다면 static을 빼야 합니다.

지역 변수의 주소 반환

int* get_counter(void) {
    static int counter = 0;
    return &counter;  // OK: 정적 저장 기간이라 함수 종료 후에도 유효
}
int* get_local(void) {
    int local = 10;
    return &local;    // 잘못됨: 함수가 끝나면 local의 수명이 끝남
}

get_local()은 대부분의 컴파일러에서 경고만 나고 컴파일됩니다. 반환된 포인터를 역참조하는 순간 정의되지 않은 동작이며, 우연히 값이 남아 있어 테스트를 통과하는 경우가 많아 발견이 늦어집니다. static 변수의 주소를 돌려주는 쪽은 안전하지만, 호출자 모두가 같은 객체를 공유한다는 점을 API 문서에 밝혀야 합니다. strtok, localtime, asctime 같은 표준 함수가 이 방식이라 재진입이 불가능하고, 그래서 strtok_r, localtime_r 같은 변형이 따로 생겼습니다.

다른 파일의 static 전역에 extern으로 접근

// file1.c
static int private_var = 100;
// file2.c
#include <stdio.h>
extern int private_var;
int main(void) {
    printf("%d\n", private_var);  // 컴파일은 되지만 링크 실패: undefined reference
    return 0;
}

멀티스레드에서 동기화 없이 공유

#include <stdio.h>
// 데이터 경합
void thread_function(void) {
    static int shared = 0;  // 모든 스레드가 공유
    shared++;
    printf("%d\n", shared);
}
// 뮤텍스로 보호
#include <pthread.h>
void thread_safe_function(void) {
    static int shared = 0;
    static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
    
    pthread_mutex_lock(&lock);
    shared++;
    printf("%d\n", shared);
    pthread_mutex_unlock(&lock);
}

PTHREAD_MUTEX_INITIALIZER는 상수 초기화식이므로 static 뮤텍스를 별도 초기화 코드 없이 쓸 수 있습니다. 카운터 하나만 보호하면 된다면 C11의 _Atomic int와 atomic_fetch_add도 선택지입니다.

static 배열 크기를 런타임 값으로 지정

#include <stdlib.h>
void process(int size) {
    // static int array[size];   // 에러: 가변 길이 배열은 정적 저장 기간을 가질 수 없음
    static int array[100];        // 고정 크기
    int* dynamic = malloc(size * sizeof *dynamic);  // 런타임 크기가 필요하면 동적 할당
    // ...
    free(dynamic);
}

static을 활용하는 패턴

초기화 플래그

#include <stdio.h>
void initialize_once(void) {
    static int initialized = 0;
    if (!initialized) {
        printf("Performing expensive initialization...\n");
        // 복잡한 초기화 작업
        initialized = 1;
    }
    // 실제 작업
}

단일 스레드에서만 안전합니다. 두 스레드가 동시에 들어오면 둘 다 initialized == 0을 보고 초기화를 두 번 할 수 있으므로, 멀티스레드에서는 pthread_once나 C11 call_once를 씁니다.

캐싱

#include <string.h>
const char* get_user_name(void) {
    static char cache[64];
    static int cached = 0;
    if (!cached) {
        // 실제로는 DB나 파일에서 읽어옴
        strcpy(cache, "John Doe");
        cached = 1;
    }
    return cache;
}

반환하는 포인터가 내부 static 버퍼를 가리키므로, 호출자가 내용을 바꾸지 못하도록 const char*로 반환합니다.

상태 머신

typedef enum { STATE_IDLE, STATE_RUNNING, STATE_PAUSED, STATE_STOPPED } State;
typedef enum { EVENT_START, EVENT_PAUSE, EVENT_RESUME, EVENT_STOP } Event;

void state_machine(Event event) {
    static State current_state = STATE_IDLE;
    switch (current_state) {
        case STATE_IDLE:
            if (event == EVENT_START) current_state = STATE_RUNNING;
            break;
        case STATE_RUNNING:
            if (event == EVENT_PAUSE) current_state = STATE_PAUSED;
            else if (event == EVENT_STOP) current_state = STATE_STOPPED;
            break;
        case STATE_PAUSED:
            if (event == EVENT_RESUME) current_state = STATE_RUNNING;
            else if (event == EVENT_STOP) current_state = STATE_STOPPED;
            break;
        case STATE_STOPPED:
            break;
    }
}

상태를 함수 안 static에 두면 간단하지만, 상태 머신이 하나뿐이라는 가정이 코드에 박힙니다. 같은 상태 머신을 여러 개 돌리거나 테스트에서 상태를 초기화해야 한다면 상태를 구조체로 빼서 인자로 넘기는 편이 낫습니다.

디버그 카운터 매크로

#include <stdio.h>
#ifdef DEBUG
#define DEBUG_COUNT(name) \
    do { \
        static int count_##name = 0; \
        count_##name++; \
        if (count_##name % 1000 == 0) { \
            fprintf(stderr, #name " called %d times\n", count_##name); \
        } \
    } while (0)
#else
#define DEBUG_COUNT(name) ((void)0)
#endif
void some_function(void) {
    DEBUG_COUNT(some_function);
    // 실제 작업...
}

매크로가 펼쳐진 블록 안에 static 변수가 생기므로, 매크로를 쓴 위치마다 독립된 카운터가 만들어집니다.

모듈 캡슐화

// module.c
#include <string.h>
static int internal_state = 0;          // 내부 상태 (외부에 숨김)
static char internal_buffer[1024];
static void internal_helper(void) {     // 내부 헬퍼
    // ...
}
// 공개 API
void module_init(void) {
    internal_state = 0;
    memset(internal_buffer, 0, sizeof internal_buffer);
}
int module_process(const char* input) {
    internal_helper();
    // ...
    return internal_state;
}

번역 단위(Translation Unit)와 링크의 내부 구조

번역 단위란 무엇인가

번역 단위(translation unit)는 소스 파일 하나에 #include된 내용을 모두 펼쳐 전처리한 결과로, 컴파일러가 한 번에 컴파일하는 단위입니다. 보통 .c 파일 하나가 번역 단위 하나에 대응합니다. 각 번역 단위는 독립적으로 컴파일되어 목적 파일(object file)이 되고, 링커가 여러 목적 파일과 라이브러리를 합쳐 실행 파일을 만듭니다.

파일 범위의 static 변수와 함수는 내부 링크를 가지므로 그 번역 단위 안에서만 유효한 이름입니다. 링커는 서로 다른 번역 단위에 있는 같은 철자의 static 이름을 같은 객체로 합치지 않습니다. static이 없는 파일 범위 이름은 외부 링크를 가지며, 여러 번역 단위에서 같은 이름으로 참조하면 하나의 심볼로 연결됩니다.

잠정 정의(tentative definition)와 공통 심볼

파일 범위에서 int counter;처럼 초기화식과 extern 없이 쓴 선언은 잠정 정의(tentative definition)입니다. 같은 번역 단위 안에 int counter = 1; 같은 정식 정의가 나오면 그것이 정의가 되고, 끝까지 나오지 않으면 번역 단위 끝에서 0으로 초기화된 정의가 됩니다.

문제는 여러 번역 단위가 각자 int counter;를 가질 때입니다. 표준상으로는 외부 링크 객체의 정의가 여러 개인 것이므로 정의되지 않은 동작이지만, 오래된 Unix 관행에 따라 GCC와 Clang은 이런 정의를 공통 심볼(common symbol)로 만들어 링커가 하나로 합쳐 주었습니다(-fcommon). GCC 10과 Clang 11부터 기본값이 -fno-common으로 바뀌면서, 예전에는 링크되던 코드가 “multiple definition of `counter’” 에러를 내기 시작했습니다. 오래된 C 코드를 최신 컴파일러로 빌드할 때 자주 만나는 에러입니다.

해결책은 전역 변수를 헤더에는 extern int counter; 선언으로만 두고, 정의는 정확히 한 .c 파일에만 두는 것입니다. 한 파일 안에서만 쓰는 상태라면 static int counter;로 바꾸면 각 번역 단위가 자기 객체를 가지므로 이 문제가 아예 생기지 않습니다.

링커가 보는 관점: 심볼

링커는 변수의 수명이나 스코프가 아니라 목적 파일의 심볼 테이블을 봅니다. 외부 링크를 가진 이름은 전역(global) 심볼로 등록되어 다른 목적 파일의 미해결 참조와 연결되고, 내부 링크를 가진 이름은 지역(local) 심볼로 기록되거나 최적화로 아예 사라집니다. nm 출력에서 대문자(T, D, B)가 전역 심볼, 소문자(t, d, b)가 지역 심볼입니다. static 함수는 다른 번역 단위에서 extern 선언을 아무리 정확히 맞춰도 연결되지 않습니다.

정리하면 번역 단위는 컴파일의 경계이고, 내부·외부 링크는 링크의 경계입니다. 공유 라이브러리에서는 여기에 하나가 더해집니다. 외부 링크 심볼은 기본적으로 라이브러리 바깥에도 공개되므로, 라이브러리 내부에서만 여러 파일이 공유하는 함수는 __attribute__((visibility("hidden")))이나 -fvisibility=hidden으로 숨겨야 공개 ABI가 불필요하게 넓어지지 않습니다.


정적 저장 기간 객체의 초기화 순서

C에서는 초기화식이 상수식으로 제한된다

파일 범위 변수, static 전역, static 지역 변수는 모두 정적 저장 기간을 가집니다. C 표준은 이런 객체의 초기화식이 상수식(또는 문자열 리터럴, 정적 객체의 주소 같은 주소 상수)이어야 한다고 규정합니다. static int x = f();나 int b = a;(a가 다른 변수일 때)는 컴파일 에러입니다.

그래서 C에서는 정적 초기화가 실행 시간에 코드로 일어나지 않습니다. 모든 초깃값은 컴파일과 링크 시점에 결정되어 실행 파일의 .data 영역에 기록되고, 프로그램이 로드되는 순간 한꺼번에 준비됩니다. 이 때문에 C++의 “정적 초기화 순서 문제(static initialization order fiasco)”, 즉 다른 번역 단위의 전역 객체가 아직 생성자를 실행하지 않은 상태에서 그것을 사용하는 문제는 표준 C 코드에서는 생기지 않습니다. 한 번역 단위 안의 선언 순서도 초기화 결과에 영향을 주지 않습니다.

static int table_size = 64;
static int* table_ptr = &table_size;   // OK: 정적 객체의 주소는 주소 상수
// static int doubled = table_size * 2; // 에러: table_size는 상수식이 아님

순서 문제가 다시 생기는 곳

순서 문제는 표준 밖의 기능이나 런타임 초기화 코드를 쓰는 순간 돌아옵니다.

  • GCC와 Clang의 __attribute__((constructor)) 함수는 main 전에 실행되는데, 서로 다른 번역 단위에 있는 constructor 함수끼리의 실행 순서는 보장되지 않습니다. 같은 번역 단위 안에서도 우선순위(constructor(101))를 지정하지 않으면 순서에 기대면 안 됩니다.
  • 공유 라이브러리의 초기화 함수는 라이브러리 로드 순서에 따라 실행되며, dlopen으로 늦게 로드되는 라이브러리는 main 이후에 초기화됩니다.
  • C 코드가 C++ 프로그램에 링크되면, C++ 쪽 전역 객체의 생성자가 C 모듈의 init() 함수를 호출하는 식으로 순서 의존이 생길 수 있습니다.

이런 상황에서의 대응은 C++과 같습니다. 정적 객체끼리 초기화 순서에 의존하지 않게 설계하고, 꼭 필요하면 main에서 module_init()을 원하는 순서대로 명시적으로 호출하거나, 첫 사용 시 한 번만 초기화하는 지연 초기화(pthread_once, C11 call_once)를 씁니다.


스레드 지역 저장소(TLS)와 _Thread_local

TLS가 필요한 이유

일반 static 변수는 프로세스 전체에 하나뿐이고 모든 스레드가 같은 메모리를 공유합니다. 멀티스레드 환경에서는 뮤텍스로 보호하거나, 아예 스레드마다 별도의 객체를 두어야 합니다. 후자가 스레드 지역 저장소(Thread-Local Storage, TLS)입니다. errno가 대표적인 예로, 현대 C 라이브러리에서 errno는 스레드마다 따로 존재합니다.

C11의 _Thread_local

C11은 저장 클래스 지정자 _Thread_local을 도입했습니다. 키워드 자체는 헤더 없이 쓸 수 있고, <threads.h>는 같은 의미의 thread_local 매크로를 제공합니다(C23에서는 thread_local이 키워드가 되었습니다). GCC와 Clang은 C11 이전부터 같은 기능을 __thread 확장으로 제공해 왔습니다.

_Thread_local 객체는 스레드 저장 기간을 가지며, 각 스레드가 시작될 때 자기 사본이 초깃값으로 만들어지고 스레드가 끝날 때 사라집니다. 블록 안에서도 쓸 수 있는데, 그 경우 반드시 static 또는 extern과 함께 써야 합니다.

#include <stdio.h>
#include <threads.h>   /* thrd_create 사용. C11 스레드를 지원하는 라이브러리 필요 */

_Thread_local int calls_in_this_thread;   // 파일 범위 TLS

int worker(void *arg) {
    static _Thread_local int local_calls; // 블록 범위 TLS: static과 함께 써야 함
    calls_in_this_thread++;
    local_calls++;
    printf("thread %d: %d\n", *(int*)arg, calls_in_this_thread);  // 각 스레드에서 1
    return 0;
}

int main(void) {
    thrd_t t[2];
    int ids[2] = {0, 1};
    for (int i = 0; i < 2; i++) thrd_create(&t[i], worker, &ids[i]);
    for (int i = 0; i < 2; i++) thrd_join(t[i], NULL);
    return 0;
}

<threads.h>는 C11의 선택 기능이라 지원하지 않는 환경도 있습니다(glibc는 2.28부터 지원). 그런 환경에서는 _Thread_local만 쓰고 스레드 생성은 pthread로 하면 됩니다. MSVC는 최신 버전에서 C11 _Thread_local을 지원하며, 오래된 코드에서는 __declspec(thread)를 볼 수 있습니다.

static과의 비교

구분함수 내부 static_Thread_local
인스턴스 수프로세스당 1개스레드당 1개
동시 접근동기화 없으면 데이터 경합스레드 간 공유 없음
용도호출 간 상태 유지스레드별 컨텍스트, 버퍼, 캐시

주의할 점

TLS 변수 접근은 일반 전역 변수보다 약간의 비용이 있습니다. 실행 파일 안의 TLS는 스레드 포인터 레지스터 기준 고정 오프셋으로 접근하므로 거의 차이가 없지만, dlopen으로 로드되는 공유 라이브러리 안의 TLS는 __tls_get_addr 호출을 거치는 동적 모델을 쓰게 되어 더 느려질 수 있습니다. 또 TLS 변수의 주소를 다른 스레드에 넘기면, 원래 스레드가 끝나는 순간 그 주소는 무효가 됩니다. 스레드마다 큰 배열을 TLS로 두면 스레드 수만큼 메모리가 곱해진다는 점도 고려해야 합니다.


static inline과 extern inline (C99/C11)

C의 inline은 링크 규칙과 함께 읽는다

C99의 inline은 함수를 헤더에 정의해 여러 번역 단위에서 인라인 전개할 수 있게 하는 도구이지만, 그 규칙은 C++과 다르고 꽤 까다롭습니다. 핵심은 “외부 링크를 가진 비인라인 정의를 누가, 몇 번 내보내느냐”입니다.

static inline

static inline 함수는 내부 링크를 가집니다. 헤더에 두면 그 헤더를 포함한 각 번역 단위가 자기만의 사본을 가지므로 링크 충돌이 없고, 인라인되지 않은 호출이 있으면 그 번역 단위 안에 사본이 만들어집니다. 규칙이 단순해서 헤더용 작은 함수에 가장 많이 쓰입니다.

/* vec_math.h */
static inline int clamp_int(int v, int lo, int hi) {
    if (v < lo) return lo;
    if (v > hi) return hi;
    return v;
}

헤더에 그냥 static 함수를 두지 않고 static inline을 쓰는 실용적인 이유도 있습니다. 쓰이지 않는 static 함수는 -Wunused-function 경고를 내지만, static inline 함수는 경고하지 않습니다. 헤더를 포함하는 모든 파일이 모든 함수를 쓰지는 않으므로 이 차이가 큽니다.

inline과 extern inline

외부 링크를 가진 함수 하나를 인라인 가능하게 만들고 싶다면 C99 규칙은 다음과 같습니다.

/* clamp.h */
inline int clamp(int v, int lo, int hi) {   // 인라인 정의: 외부 정의를 내보내지 않음
    return v < lo ? lo : (v > hi ? hi : v);
}
/* clamp.c: 정확히 한 파일에만 */
#include "clamp.h"
extern inline int clamp(int v, int lo, int hi);  // 이 번역 단위가 외부 정의를 내보냄

헤더의 inline 정의만 있는 번역 단위에서는 그 정의가 인라인 전개용으로만 쓰이고 심볼을 내보내지 않습니다. 컴파일러가 인라인하지 않기로 한 호출은 외부 심볼 clamp를 참조하므로, 어딘가에 외부 정의가 하나 있어야 합니다. 그 역할을 extern inline 선언(또는 inline 없는 일반 선언)이 있는 .c 파일 하나가 맡습니다. 이 파일을 빠뜨리면 -O0 빌드처럼 인라인이 일어나지 않는 경우에만 “undefined reference to `clamp’” 링크 에러가 나서, 최적화 빌드에서는 멀쩡하다가 디버그 빌드에서만 깨지는 혼란스러운 증상이 생깁니다.

GCC의 옛 기본 모드(-std=gnu89, 또는 -fgnu89-inline)는 이 의미가 정반대입니다. GNU89에서는 extern inline이 “절대 외부 정의를 내보내지 않음”이고, 그냥 inline이 외부 정의를 내보냅니다. GCC 5부터 기본이 gnu11이 되어 C99 의미가 기본이지만, 오래된 코드나 빌드 옵션이 섞인 프로젝트에서는 같은 소스가 다르게 링크될 수 있습니다.

실무 권장

헤더에 넣을 작은 함수는 static inline이 기본 선택입니다. 함수 주소의 동일성이 중요하거나(모든 번역 단위에서 같은 주소여야 하는 경우) 사본이 여러 개 생기는 것이 부담이라면 inline + extern inline 패턴을 쓰고, 라이브러리 ABI로 노출할 함수는 헤더에 선언만 두고 한 .c에 일반 정의를 둡니다. 어떤 경우든 프로젝트 전체에서 같은 -std 옵션을 쓰는 것이 전제입니다.


프로덕션에서의 static 설계

모듈 경계: static + 공개 API

실무 C 코드베이스에서는 .c 파일 하나를 하나의 모듈로 보고, 내부 상태는 파일 범위 static 변수에, 내부 로직은 static 함수에 두고, 헤더에는 공개할 함수의 선언과 불완전 타입만 노출하는 구성이 가장 흔합니다. 앞의 설정 모듈 예제가 이 구조입니다.

헤더에 static 변수를 두지 않는 이유

헤더에 static int x;를 두면 그 헤더를 포함한 번역 단위마다 별개의 x가 생깁니다. 한 파일에서 바꾼 값이 다른 파일에서는 보이지 않아 “값이 파일마다 다르다”는 버그가 됩니다. 공유해야 하는 전역 상태는 정의를 한 곳에 두고, 헤더에는 extern 선언이나 접근자 함수만 둡니다. 예외는 static const int MAX_ITEMS = 64;처럼 바뀌지 않는 상수로, 이 경우에는 사본이 여러 개여도 문제가 없고 컴파일러가 대개 값으로 대체합니다.

한 번만 초기화: pthread_once와 call_once

런타임에 계산해야 하는 초기화(설정 파일 읽기, 테이블 생성 등)를 스레드 안전하게 한 번만 하려면 POSIX의 pthread_once를 씁니다.

#include <pthread.h>
static pthread_once_t once = PTHREAD_ONCE_INIT;
static int* table;

static void init_table(void) {
    // 테이블 할당과 계산
}
const int* get_table(void) {
    pthread_once(&once, init_table);   // 여러 스레드가 동시에 불러도 init_table은 한 번만 실행
    return table;
}

C11 <threads.h>의 call_once와 ONCE_FLAG_INIT도 같은 역할을 합니다. 두 경우 모두 once 변수 자체는 상수로 초기화되는 static 객체라서, 앞에서 본 C의 정적 초기화 규칙과 잘 맞습니다.

멀티스레드와 static

함수 안이든 파일 범위든 static 변수는 공유 상태입니다. 여러 스레드가 쓰는 카운터와 캐시는 뮤텍스나 원자 연산으로 보호하거나, 초기화 후 바뀌지 않는 읽기 전용 데이터로 만듭니다. 스레드별로 필요한 상태는 _Thread_local로 둡니다. 재진입 가능해야 하는 API라면 내부 static 버퍼를 반환하지 말고, 호출자가 버퍼를 넘기는 형태(localtime_r 방식)로 설계합니다.

임베디드 환경

임베디드 환경에서는 static const 데이터가 플래시(ROM)에 배치되어 RAM을 아끼고, 인터럽트 핸들러와 메인 루프가 공유하는 변수는 static volatile로 선언하는 경우가 많습니다. 다만 volatile은 컴파일러가 접근을 생략하거나 캐시하지 못하게 할 뿐 원자성을 보장하지 않습니다. CPU 워드보다 큰 값을 인터럽트와 공유하거나 읽고-수정하고-쓰는 연산을 한다면, 인터럽트를 잠시 막거나 원자 연산을 써야 합니다.


참고 자료

  • C11 Standard (ISO/IEC 9899:2011), 6.2.2(링크), 6.2.4(저장 기간), 6.7.4(함수 지정자), 6.7.9(초기화)
  • “C Programming: A Modern Approach” by K. N. King
  • “Expert C Programming” by Peter van der Linden

같이 보면 좋은 글