C++ extern과 링키지: 전역 변수 공유, 내부·외부 연결, C 함수 연동

이 글의 핵심

내부 연결과 외부 연결의 차이, extern으로 전역 변수와 함수를 여러 번역 단위에서 공유하는 방법, extern "C"로 C ABI와 연동할 때의 이름 맹글링 문제를 정리합니다. extern과 static의 차이, 헤더 설계 팁과 중복 정의 링크 에러도 다룹니다.

들어가며

C++의 extern은 다른 파일에 정의된 전역 변수나 함수를 이 파일에서 쓰겠다고 선언하는 키워드입니다. 이 키워드가 왜 필요한지는 링키지(linkage)라는 개념을 알아야 이해할 수 있습니다.

링키지가 필요한 이유는 C++이 파일마다 따로 컴파일되기 때문입니다. 컴파일러는 .cpp 하나(정확히는 헤더까지 펼친 번역 단위 하나)만 보고 목적 파일(.o, .obj)을 만들고, 다른 파일에 있는 변수나 함수는 “이런 이름의 심볼이 어딘가 있다”는 표시만 남겨 둡니다. 그 표시를 실제 정의와 연결하는 것이 링커의 일이고, 링키지는 “이 이름을 다른 번역 단위와 공유할 것인가”를 정하는 속성입니다. 그래서 링키지 관련 실수는 컴파일 에러가 아니라 링크 에러로 나타납니다. 선언은 있는데 정의가 없으면 “undefined reference to counter'"(GCC·Clang) 또는 "LNK2001: unresolved external symbol"(MSVC)이, 정의가 두 곳에 있으면 "multiple definition of counter’” 또는 “LNK2005: already defined”가 납니다.


선언과 정의를 나누는 extern

a.cpp의 extern 선언과 b.cpp의 정의가 각각 컴파일된 뒤 링커에서 연결되는 과정

그림: extern 선언은 컴파일 단계에서는 심볼을 미해결로 두고, 링커가 다른 번역 단위의 정의를 찾아 연결한다.

extern 선언이 하는 일

// file1.cpp
int globalVar = 10;

// file2.cpp
extern int globalVar;
int x = globalVar;

헤더에 선언, 소스에 정의

// globals.h
extern int counter;
extern void increment();

// globals.cpp
#include "globals.h"
#include <iostream>

int counter = 0;

void increment() {
    counter++;
}

// main.cpp
#include "globals.h"
#include <iostream>

int main() {
    increment();
    std::cout << counter << std::endl;  // 1
}

내부 연결과 외부 연결

개념의미대표적인 경우
외부 연결(external linkage)다른 번역 단위(.cpp)에서 같은 이름이 하나의 엔티티를 가리킴파일 범위의 비-static 함수와 변수, 클래스의 멤버 함수와 static 데이터 멤버
내부 연결(internal linkage)이름이 해당 번역 단위 안에서만 보임static 파일 범위 변수/함수, extern이나 inline 없이 정의한 네임스페이스 범위 const/constexpr 변수, 익명 네임스페이스 안의 이름
연결 없음그 스코프 밖에서는 이름으로 가리킬 수 없음함수 안의 지역 변수(함수 안 static 변수 포함), 블록 범위의 이름

“이걸 헤더에 넣으면 ODR 위반인가?”라는 질문은 대개 연결과 inline 규칙의 문제입니다. 함수는 inline 정의를 헤더에 둘 수 있지만, 일반 전역 변수의 정의는 한 .cpp에만 두어야 합니다(C++17부터는 inline 변수로 헤더에 둘 수도 있습니다).


extern과 static의 차이

연결 범위 비교

// extern (외부 연결)
extern int globalVar;

// static (내부 연결)
static int localVar;

같은 이름을 두 파일에서 쓸 때

// file1.cpp
int externVar = 10;
static int staticVar = 20;

// file2.cpp
#include <iostream>
extern int externVar;
// extern int staticVar;  // 선언은 컴파일되지만, 사용하면 링크 에러(undefined reference)

int main() {
    std::cout << externVar << std::endl;  // 10
}

헤더에 static int value = 10;을 두면 중복 정의 링크 에러는 사라지지만 문제를 숨길 뿐입니다. 내부 연결이라 include한 파일마다 서로 다른 value가 생기므로, 한 파일에서 값을 바꿔도 다른 파일에는 반영되지 않습니다. 공유 상태를 의도했다면 링크 에러보다 훨씬 찾기 어려운 버그가 됩니다. 반대 방향의 함정도 있습니다. extern double globalVar;처럼 선언의 타입이 정의(int globalVar)와 다르면, GCC·Clang은 전역 변수 심볼에 타입 정보를 넣지 않으므로 링크가 그대로 성공하고(MSVC는 변수 심볼에도 타입을 넣어 링크 에러로 잡힙니다) 실행 중에 4바이트 int를 8바이트 double로 읽는 미정의 동작이 됩니다. 정의하는 .cpp도 선언이 있는 헤더를 include하게 하면 이런 불일치를 컴파일러가 잡아 줍니다.

extern 선언 타입이 정의와 달라도 경고·링크 오류 없이 실행되고, 헤더의 static 변수는 번역 단위마다 따로 존재함을 보여 주는 실행 결과

그림: 실제 실행 결과 — 정의는 int인데 extern double로 선언해도 -Wall -Wextra에서 경고나 링크 오류가 없고 엉뚱한 값을 읽는다(출력값은 메모리 배치에 따라 달라진다). 헤더에 static int를 두면 a.cpp가 올린 값이 b.cpp에는 보이지 않는다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · -std=c++20 -Wall -Wextra)

연결과 저장 기간은 다른 축

static이 헷갈리는 이유는 쓰는 위치에 따라 바꾸는 것이 다르기 때문입니다. 파일 범위의 static은 연결을 내부로 바꾸고, 함수 안의 static은 연결과 상관없이 저장 기간을 정적으로 바꿉니다. 함수 안의 static int counter는 그 함수 밖에서 이름으로 접근할 수 없으니 연결이 없지만, 메모리에는 프로그램이 끝날 때까지 하나만 존재해 호출할 때마다 값이 누적됩니다. 저장 기간은 자동(지역 변수), 정적(전역·static), 스레드(thread_local), 동적(new) 네 가지이고, 연결과는 독립적으로 정해집니다.

thread_local은 정적 저장 기간처럼 오래 살지만 수명의 기준이 스레드라서, 스레드마다 별도 사본이 생기고 스레드가 끝날 때 파괴됩니다. 스레드 풀과 함께 쓸 때는 이 규칙이 함정이 됩니다. 워커 스레드는 여러 작업을 차례로 실행하며 종료되지 않으므로, 한 작업이 thread_local에 남긴 값이 같은 스레드의 다음 작업에 그대로 보입니다. 요청마다 초기화된다고 가정하고 사용자 ID나 트랜잭션 상태를 넣으면 다른 사용자의 요청에 이전 값이 섞이므로, 작업 시작 시 명시적으로 초기화하거나 컨텍스트 객체로 넘기는 편이 안전합니다.


전역 설정·함수 선언·C 함수 연동 예제

여러 파일이 공유하는 전역 설정

// config.h
#ifndef CONFIG_H
#define CONFIG_H

#include <string>

extern int maxConnections;
extern std::string serverName;

#endif

// config.cpp
#include "config.h"

int maxConnections = 100;
std::string serverName = "MyServer";

// main.cpp
#include "config.h"
#include <iostream>

int main() {
    std::cout << "Max: " << maxConnections << std::endl;
    std::cout << "Server: " << serverName << std::endl;
}

다른 파일의 함수 선언

// utils.h
#ifndef UTILS_H
#define UTILS_H

#include <string>

extern void printMessage(const std::string& msg);
extern int calculate(int a, int b);

#endif

// utils.cpp
#include "utils.h"
#include <iostream>

void printMessage(const std::string& msg) {
    std::cout << msg << std::endl;
}

int calculate(int a, int b) {
    return a + b;
}

// main.cpp
#include "utils.h"

int main() {
    printMessage("Hello");
    int result = calculate(5, 3);
    printMessage(std::to_string(result));
}

extern “C”로 C 함수 호출

// c_library.h
#ifdef __cplusplus
extern "C" {
#endif

void c_function();
int c_calculate(int a, int b);

#ifdef __cplusplus
}
#endif

// c_library.c
#include "c_library.h"
#include <stdio.h>

void c_function() {
    printf("C function\n");
}

int c_calculate(int a, int b) {
    return a + b;
}

// main.cpp
#include "c_library.h"
#include <iostream>

int main() {
    c_function();
    int result = c_calculate(5, 3);
    std::cout << result << std::endl;
}

extern "C"와 이름 장식

C++는 이름 장식(name mangling)으로 매개변수 타입을 심볼 이름에 넣어 오버로드를 구분합니다. C ABI에는 이런 장식이 없으므로, C 라이브러리 심볼과 링크하려면 extern "C"로 C 언어 링키지를 명시해야 합니다.

C 링키지 함수는 이름만으로 심볼이 만들어지므로 오버로드할 수 없고, 같은 이름의 extern "C" 함수는 프로그램 전체에서 하나만 존재할 수 있습니다. C 라이브러리에 C++ 함수를 콜백으로 넘길 때는 표준상 언어 링키지가 함수 타입의 일부이므로 콜백도 extern "C"로 선언하는 것이 원칙입니다(GCC·Clang·MSVC는 실제로 이 차이를 구분하지 않아 그냥 넘겨도 동작합니다). 또 C 코드는 C++ 예외를 모르므로, C 라이브러리가 호출한 콜백에서 예외가 빠져나가면 C 프레임을 통과하는 예외가 되어 동작을 보장할 수 없습니다. 콜백 경계에서는 try/catch로 예외를 잡아 에러 코드로 바꿔야 합니다. 앞의 예제처럼 #ifdef __cplusplus로 extern "C" {를 감싸면 같은 헤더를 C와 C++ 양쪽에서 쓸 수 있습니다.

extern "C" int plugin_init(void* ctx);  // 심볼명이 C 규칙으로 노출

이름 장식이 실제로 어떻게 보이는지 확인하면 개념이 분명해집니다. GCC에서 int add(int, int)는 목적 파일 안에 _Z3addii 같은 이름으로 기록되고, extern "C"를 붙이면 그냥 add로 기록됩니다. C 컴파일러로 빌드한 라이브러리는 add라는 심볼만 제공하므로, C++ 쪽 헤더에 extern "C"가 빠져 있으면 링커는 _Z3addii를 찾다가 “undefined reference to add(int, int)'"를 냅니다. 에러 메시지에 매개변수 목록이 붙어 있다면 C++ 이름으로 찾고 있다는 뜻이므로 extern “C”누락을 가장 먼저 의심하면 됩니다. Linux에서는nm -C lib.a | grep add로 라이브러리가 실제로 어떤 이름을 내보내는지 확인할 수 있습니다. dlsym이나 GetProcAddress로 플러그인 함수를 이름으로 찾는 경우에도 같은 이유로 extern “C”`가 필요합니다.


extern template으로 인스턴스화 줄이기

// header.h
#include <iostream>

template<typename T>
class MyClass {
public:
    void print(T value) { std::cout << value << '\n'; }
};

extern template class MyClass<int>;

// source.cpp
#include "header.h"
template class MyClass<int>;  // 명시적 인스턴스화 정의는 여기 한 곳에만

// main.cpp
#include "header.h"

int main() {
    MyClass<int> obj;
}

extern template은 “이 인스턴스는 다른 파일에서 만들어 두었으니 여기서는 만들지 말라”는 선언입니다. 템플릿은 보통 사용하는 모든 번역 단위에서 각자 인스턴스화되고 링커가 중복을 버리는데, 수백 개 파일이 같은 MyClass<int>를 쓰면 같은 코드를 수백 번 컴파일하는 셈이라 빌드 시간이 늘어납니다. extern template으로 인스턴스화를 한 파일(source.cpp)에 몰아 두면 이 중복 컴파일을 줄일 수 있습니다. 대신 source.cpp의 명시적 인스턴스화를 빠뜨리면 “undefined reference to MyClass<int>::print(int)'" 링크 에러가 나고, 명시적으로 인스턴스화하지 않은 다른 타입(MyClass`)은 평소처럼 사용하는 쪽에서 인스턴스화되므로 헤더에 템플릿 정의 전체가 있어야 한다는 점은 그대로입니다.


중복 정의·초기화 순서·const 연결 문제

헤더에 정의를 넣어 multiple definition

// ❌ 헤더에 정의
// header.h
int globalVar = 10;

// ✅ 헤더에 선언, 소스에 정의
// header.h
extern int globalVar;

// source.cpp
int globalVar = 10;

파일 간 전역 변수 초기화 순서

// file1.cpp
int a = 10;

// file2.cpp
extern int a;
int b = a * 2;

// ✅ 함수 내 static (file1.cpp에 정의하고 file2.cpp에서 호출)
int& getA() {
    static int a = 10;
    return a;
}

int b = getA() * 2;

이 문제는 “정적 초기화 순서 문제(static initialization order fiasco)“로 알려져 있습니다. 서로 다른 번역 단위에 있는 전역 변수들이 동적 초기화되는 순서는 표준이 정하지 않으므로, file2.cpp의 b가 초기화될 때 file1.cpp의 a가 아직 초기화되지 않았을 수 있습니다. 엄밀히 말하면 위의 int a = 10;처럼 상수로 초기화되는 변수는 프로그램 시작 전에 값이 채워지는 상수 초기화 대상이라 실제로는 안전합니다. 진짜 문제는 std::string serverName = "MyServer";나 생성자가 있는 객체처럼 실행 시점에 초기화 코드가 돌아야 하는 전역 변수입니다. 이 경우 링크 순서나 빌드 설정만 바뀌어도 결과가 달라져, 어떤 빌드에서는 멀쩡하다가 다른 빌드에서만 시작하자마자 크래시가 납니다. 함수 안의 static 지역 변수는 처음 호출될 때 초기화되므로 이 순서 문제를 피할 수 있고, C++11부터는 초기화가 스레드 안전하게 한 번만 일어나도록 보장됩니다. C++20의 constinit을 붙이면 전역 변수가 반드시 상수 초기화되는지를 컴파일러가 검사해 줍니다.

const 전역 변수는 내부 연결

// 내부 연결: 이 헤더를 포함한 파일마다 자기 MAX 사본을 가짐
// header.h
const int MAX = 100;

// 외부 연결: 프로그램 전체가 MAX 하나를 공유
// header.h
extern const int MAX;

// source.cpp
const int MAX = 100;

const 전역 변수가 기본적으로 내부 연결인 이유는 헤더에 const int MAX = 100;을 두고 여러 파일에서 포함해도 중복 정의 에러가 나지 않게 하려는 설계입니다. 즉 위의 첫 번째 코드도 실제로는 각 파일이 자기만의 MAX 사본을 갖는 방식으로 정상 동작하며, 값이 작은 상수라면 컴파일러가 대부분 상수로 치환해 사본 비용도 거의 없습니다. 문제가 되는 것은 모든 파일이 정확히 하나의 객체를 공유해야 할 때(주소 비교, 큰 테이블)입니다. 이때 주의할 점은 source.cpp의 정의에도 외부 연결이 적용되어야 한다는 것입니다. 위 코드처럼 source.cpp가 extern 선언이 있는 헤더를 먼저 포함하면 그 선언을 따라 외부 연결이 되지만, 헤더를 포함하지 않은 채 const int MAX = 100;만 쓰면 내부 연결이 되어 다른 파일에서 “undefined reference to MAX'"가 납니다. 이런 실수를 피하려면 정의에도 extern const int MAX = 100;처럼 명시하거나, C++17 이상에서는 헤더에 inline constexpr int MAX = 100;`을 두는 것이 가장 간단합니다.

전역 extern 변수의 네임스페이스 오염

// ❌ 전역 네임스페이스 오염
extern int counter;

// ✅ 네임스페이스 사용
namespace MyApp {
    extern int counter;
}

헤더 가드·#pragma once와의 관계

헤더 가드(#ifndef / #define)나 #pragma once는 전처리 단계에서 같은 헤더가 같은 번역 단위에 두 번 포함되는 것을 막습니다. 이것은 링키지와는 다른 층의 문제입니다. 가드는 한 번역 단위 안의 중복 파싱을 막을 뿐이고, 링키지는 여러 번역 단위의 심볼을 링커가 어떻게 합칠지를 정합니다. 그래서 헤더에 int x = 0;을 두면 가드가 있어도 이 헤더를 포함한 .cpp마다 정의가 하나씩 생겨 multiple definition 에러가 납니다.

둘을 함께 쓰는 방식은 이렇습니다. 헤더에는 여러 번 포함되어도 안전한 선언(extern int x;)만 두고 가드로 감쌉니다. 정의(int x = 0;)는 .cpp 한 곳에 두거나, 헤더에 둬야 한다면 inline 변수처럼 중복 정의가 허용되는 형태를 씁니다.


여러 파일에서 쓰는 로거 만들기

// logger.h
#ifndef LOGGER_H
#define LOGGER_H

#include <string>

extern int logLevel;
extern void log(const std::string& message);

#endif

// logger.cpp
#include "logger.h"
#include <iostream>

int logLevel = 1;

void log(const std::string& message) {
    if (logLevel > 0) {
        std::cout << "[LOG] " << message << std::endl;
    }
}

// main.cpp
#include "logger.h"

int main() {
    logLevel = 2;
    log("Application started");
}

라이브러리를 설계할 때의 연결 규칙

라이브러리의 공개 API는 네임스페이스로 감싸고, 전역 extern 변수는 꼭 필요한 경우에만 둡니다. 외부에 보일 필요가 없는 구현 세부는 static이나 익명 네임스페이스로 내부 연결을 주면, 다른 라이브러리와 이름이 겹쳐도 링크 충돌이 나지 않습니다. 헤더에 템플릿처럼 정의가 꼭 노출되어야 하는 구현은 detail 같은 네임스페이스로 구분합니다. C에서도 호출할 수 있어야 한다면 extern "C" 함수로 경계를 만들고, 호출 규약과 스레드 안전성을 문서에 적어 둡니다. C++20 모듈을 쓸 수 있다면 export한 이름만 밖으로 보이므로 헤더보다 노출 범위를 통제하기 쉽습니다.

참고 자료


자주 묻는 질문 (FAQ)

const 전역 변수를 다른 파일에서 extern으로 참조하면 왜 undefined reference가 나나요?

C++에서 네임스페이스 범위의 const 변수는 따로 지정하지 않으면 내부 연결을 가지므로, 정의한 .cpp 파일 밖에서는 보이지 않습니다. 그래서 다른 파일에서 extern const int kMax;로 선언해도 링커가 심볼을 찾지 못합니다. 정의하는 쪽에도 extern const int kMax = 100;처럼 extern을 붙여 외부 연결로 만들거나, C++17 이상이라면 헤더에 inline constexpr int kMax = 100;을 두는 방법이 더 간단합니다.