C++ 링커 에러 심화 가이드 — 심볼 해석·약한 심볼·SIOF·중복 진단

이 글의 핵심

undefined reference나 duplicate symbol 에러는 소스 문법이 아니라 오브젝트와 라이브러리를 합치는 규칙에서 생깁니다. .a 파일을 한 번만 스캔하는 링커 동작 때문에 라이브러리 순서가 결과를 바꾸는 이유와 ODR 위반이 조용히 넘어가는 경우를 이해하면, nm·링커 맵으로 원인을 단계적으로 좁힐 수 있습니다.

들어가며

C++ 빌드에서 컴파일은 성공하고 링크에서만 실패하는 경우는, 각 소스 파일은 맞지만 오브젝트 파일과 라이브러리를 합치는 규칙(심볼 해석, ODR, 링크 순서)을 어겼을 때 발생합니다. 이 글은 오류 메시지를 읽는 법에서 한 걸음 더 들어가, 링커가 입력을 어떻게 보는지와 그에 맞는 진단 절차를 다룹니다. 링커가 심볼을 해석하는 과정, 약한·강한 심볼, 링크와 얽힌 런타임 문제인 정적 초기화 순서(SIOF), 중복 심볼 진단, 빌드 환경 문제를 차례로 살펴봅니다.

설명은 Linux의 GNU ld(BFD)·gold·lld, macOS의 ld64, Windows의 link.exe를 기준으로 합니다. 옵션 이름과 메시지는 다르지만 “미해결 참조를 채우고, 충돌하는 정의를 거부한다”는 흐름은 같습니다.


심볼 해석(symbol resolution) 과정

링커의 입력과 산출

컴파일러는 각 번역 단위(보통 .cpp 하나)를 재배치 가능 오브젝트(.o/.obj)로 만듭니다. 이 안에는 그 파일이 정의한 심볼과 다른 곳에서 찾아야 하는 참조가 심볼 테이블로, 나중에 주소를 채워야 할 위치가 재배치(relocation) 정보로 기록됩니다. 링커가 하는 일을 단순화하면 다음과 같습니다.

  1. 입력 오브젝트(직접 컴파일한 것과 정적 라이브러리에서 꺼낸 멤버)를 하나의 이미지로 합칩니다.
  2. 각 정의를 심볼 이름으로 식별합니다. C++ 함수는 네임스페이스와 매개변수 타입이 인코딩된 장식된 이름(mangled name, 예: _Z3fooi)을 씁니다.
  3. 각 미해결 참조에 대해 이름이 같은 정의를 찾아 주소를 채웁니다.
  4. 정의를 찾지 못하면 undefined reference(MSVC는 unresolved external symbol)가 납니다.
  5. 같은 이름에 강한 정의가 둘 이상이면 multiple definition(ld64는 duplicate symbol)이 납니다.

링커는 타입을 모르고 이름만 비교합니다. C++의 오버로드나 네임스페이스가 링크에서 구분되는 것은 그 정보가 장식된 이름에 들어 있기 때문입니다.

정적 라이브러리(.a/.lib)와 “한 번 스캔”

정적 아카이브(.a)는 여러 .o를 묶은 파일입니다. GNU ld는 명령줄을 왼쪽에서 오른쪽으로 처리하면서 아카이브를 만나면, 그 시점까지 미해결인 심볼을 정의하는 멤버 .o만 꺼내 링크에 넣습니다. 꺼낸 멤버가 새 미해결 참조를 만들면 같은 아카이브를 다시 훑어 채우지만, 아카이브를 지나간 뒤에 생긴 참조를 위해 되돌아가지는 않습니다. 그래서 명령줄 순서가 결과를 바꿉니다.

공유 라이브러리(.so/.dylib)는 실제 주소 결정이 실행 시점의 동적 로더에서 일어나지만, 링크 시점에도 링커는 미해결 심볼이 그 라이브러리에 있는지 확인하고 의존성(DT_NEEDED)으로 기록합니다.

순서 문제는 실제로 이렇게 나타납니다. GNU ld(BFD)나 gold에서 라이브러리를 소스보다 앞에 두면, 링커가 libfoo.a를 스캔하는 시점에는 아직 아무도 foo()를 필요로 하지 않으므로 어떤 멤버도 가져오지 않고 지나갑니다. 그다음 main.o가 foo()를 요구하지만 아카이브는 이미 지나간 뒤입니다.

g++ -L. -lfoo main.o -o app    # ❌ undefined reference to `foo()'
g++ main.o -L. -lfoo -o app    # ✅ 참조가 먼저, 정의를 가진 라이브러리가 나중
# 서로 참조하는 라이브러리 A·B: 그룹으로 묶어 반복 스캔
g++ main.o -Wl,--start-group -lA -lB -Wl,--end-group -o app

LLVM의 lld와 macOS의 ld64는 이 순서 문제에 거의 민감하지 않습니다. lld는 명령줄의 모든 아카이브 심볼을 기억해 두었다가 나중에 생긴 참조도 해결합니다. 그래서 macOS나 lld 환경에서 잘 되던 빌드가 GNU ld를 쓰는 Linux CI에서만 undefined reference로 깨지는 일이 흔하고, 이런 오류를 만나면 어떤 링커가 쓰였는지부터 확인하는 것이 빠릅니다. -Wl,--version을 붙여 빌드하거나 -fuse-ld= 설정을 보면 알 수 있습니다.

비슷하게 Ubuntu 계열 GCC는 기본으로 --as-needed가 켜져 있어서, 공유 라이브러리도 그 시점까지 미해결 참조가 있을 때만 의존성으로 기록됩니다. g++ -lm main.o처럼 공유 라이브러리를 앞에 두면 libm.so가 “필요 없음”으로 판단되어 버려지고, 결과는 정적 아카이브와 같은 undefined reference입니다.

링크 에러가 나는 흔한 C++ 원인

undefined reference는 “정의를 찾지 못했다”는 한 문장이지만, C++에서는 원인이 몇 가지 패턴으로 수렴합니다.

  • 템플릿 정의를 .cpp에 둠: 템플릿은 사용하는 번역 단위에서 인스턴스화되어야 하는데, 정의가 다른 .cpp에 있으면 호출하는 쪽은 선언만 보고 참조만 남깁니다. undefined reference to 'void process<int>(int)'처럼 템플릿 인자가 붙은 이름이 보이면 이 경우입니다. 정의를 헤더로 옮기거나, 쓰는 타입만 명시적 인스턴스화합니다.
  • C 함수를 extern "C" 없이 선언: C 라이브러리의 c_func를 C++에서 그냥 선언하면 컴파일러는 _Z6c_funci 같은 장식된 이름을 찾고, C 라이브러리에는 c_func만 있어 매칭되지 않습니다. 오류 메시지에 c_func(int)처럼 매개변수 목록이 붙어 있으면 C++ 이름으로 찾고 있다는 신호입니다.
  • 정적 데이터 멤버의 정의 누락: C++17 이전에는 클래스 안의 static int count;는 선언일 뿐이라 .cpp에 int Foo::count = 0;이 필요했습니다. C++17부터는 static inline int count = 0;으로 헤더에서 정의할 수 있습니다.
  • 선언과 정의의 시그니처가 미묘하게 다름: void f(const std::string&)로 선언하고 void f(std::string&)로 정의하면 둘은 다른 함수입니다. nm -C로 라이브러리의 실제 심볼과 오류 메시지의 이름을 나란히 놓고 비교하면 차이가 보입니다. 라이브러리가 다른 표준 라이브러리 ABI(예: libstdc++의 _GLIBCXX_USE_CXX11_ABI 설정)로 빌드된 경우에도 std::__cxx11::basic_string과 옛 std::basic_string의 차이로 같은 증상이 납니다.

재배치(relocation)와 “왜 링크가 느린가”

심볼이 확정되면 링커는 코드·데이터 섹션 안의 주소 자리를 채웁니다. 큰 바이너리에서는 이 작업과 링크 시점 최적화(--gc-sections, LTO)가 빌드 시간의 상당 부분을 차지하고, 디버그 정보가 클수록 입력 자체가 커집니다. 증분 빌드에서 링크가 병목이라면 GNU ld 대신 lld나 mold로 바꾸는 것만으로도 시간이 크게 줄어드는 경우가 많고, 디버그 정보를 -gsplit-dwarf로 분리하는 방법도 있습니다.


약한 심볼(weak) vs 강한 심볼(strong)

강한 심볼

강한(strong) 정의는 링커가 그 이름의 주인으로 인정하는 일반적인 전역 정의입니다. 같은 링크 안에서 다른 강한 정의와 이름이 겹치면 충돌입니다.

약한 심볼

약한(weak) 정의는 “다른 강한 정의가 없을 때만 쓰이는” 정의입니다. 약한 정의가 여러 개면 하나가 선택되고, 강한 정의가 하나라도 있으면 그쪽이 이깁니다. 라이브러리가 기본 구현을 약한 심볼로 제공하고 애플리케이션이 같은 이름의 강한 정의로 교체하게 하는 데 주로 쓰입니다. 컴파일러가 inline 함수나 템플릿 인스턴스에 만드는 심볼도 비슷하게 중복이 허용되는데, ELF에서는 이를 약한 심볼과 COMDAT 그룹으로 처리합니다.

GNU 계열 컴파일러에서는 다음처럼 선언합니다.

// weak_default.cpp
extern "C" __attribute__((weak)) void platform_init() {
    // 기본은 아무 것도 안 함
}

애플리케이션에서 같은 시그니처로 강한 정의를 제공하면, 링커는 그 정의를 사용합니다. 임베디드 벤더 SDK의 인터럽트 핸들러가 전형적인 예입니다. 시작 코드가 모든 핸들러를 기본 무한 루프로 weak 정의해 두고, 사용자가 void USART1_IRQHandler(void)를 정의하면 그것으로 교체됩니다.

주의할 점은 약한 심볼과 정적 아카이브의 조합입니다. 강한 정의가 .a 안의 어떤 멤버에만 있고, 그 멤버를 끌어올 다른 이유(미해결 참조)가 없다면 링커는 그 멤버를 가져오지 않습니다. 약한 정의가 이미 참조를 만족시켰기 때문입니다. 결과적으로 강한 정의를 분명히 라이브러리에 넣었는데도 기본 구현이 호출됩니다. 이 경우 강한 정의를 가진 오브젝트를 직접 링크하거나 -Wl,--whole-archive로 아카이브 전체를 포함해야 합니다. 또 __attribute__((weak))는 ELF와 Mach-O의 기능이라 MSVC에는 같은 문법이 없고, 비슷한 효과를 내려면 /alternatename 링커 옵션 같은 별도 방법이 필요합니다.

약한 심볼을 덮어쓰려 했는데 기본 구현이 계속 호출된다면, 위의 아카이브 문제 외에 이름이 실제로 같은지도 확인해야 합니다. 기본 구현은 extern "C"로 선언했는데 교체 구현을 C++ 링키지로 정의하면 장식된 이름이 달라 서로 다른 심볼이 되고, 링커는 아무 경고 없이 약한 기본 구현을 씁니다. nm이나 objdump -t로 두 정의의 실제 심볼 문자열을 나란히 비교하면 바로 드러납니다.


Static Initialization Order Fiasco(SIOF)

문제의 본질

SIOF(Static Initialization Order Fiasco)는 서로 다른 번역 단위에 있는 전역·정적 객체의 동적 초기화 순서를 표준이 정하지 않는다는 사실에서 생깁니다. 한 번역 단위 안에서는 정의 순서대로 초기화되지만, 번역 단위 사이의 순서는 구현에 맡겨져 있고 실제로는 대개 링커에 전달된 오브젝트 순서를 따릅니다. 그래서 한 객체의 생성자가 다른 번역 단위의 전역 객체를 사용하면 아직 생성되지 않은 객체를 쓸 수 있습니다.

이것은 링크 에러가 아니라 런타임 버그지만 링크와 밀접하게 얽혀 있습니다. 소스는 그대로인데 빌드 시스템이 오브젝트 순서를 바꾸거나, 정적 라이브러리로 묶거나, LTO를 켜는 것만으로 동작이 바뀌기 때문입니다. 코드 변경 없이 빌드 설정만 바꿨는데 시작 직후 크래시가 나거나 전역 등록 목록이 비어 있다면 SIOF를 의심할 만합니다.

나쁜 예와 고친 예

// registry.h
#include <vector>
struct Registry { std::vector<int> items; void add(int v) { items.push_back(v); } };

// ❌ b.cpp: 네임스페이스 범위 전역 객체
Registry g_registry;          // 동적 초기화(생성자 실행) 시점이 a.cpp와 비교 불가

// ❌ a.cpp
extern Registry g_registry;
struct A {
    A() { g_registry.add(1); } // g_registry가 아직 생성되지 않았을 수 있음
};
A a;

a.cpp의 a가 b.cpp의 g_registry보다 먼저 초기화되면, A의 생성자는 생성자가 실행되지 않은 std::vector에 push_back합니다. 정적 저장소는 0으로 초기화되어 있으므로 우연히 동작하는 것처럼 보이다가, 나중에 g_registry의 생성자가 실행되면서 items를 빈 벡터로 다시 초기화해 이미 등록한 값이 사라집니다. 링크 순서에 따라 동작이 바뀌므로 “빌드 설정을 바꿨더니 플러그인 등록이 사라졌다”는 식으로 나타납니다.

// ✅ b.cpp: Construct On First Use
Registry& g_registry() {
    static Registry r;  // 첫 호출 시 초기화 (C++11부터 스레드 안전)
    return r;
}
// ✅ a.cpp
Registry& g_registry();
struct A {
    A() { g_registry().add(1); }  // 호출 시점에 r이 반드시 생성됨
};
A a;

함수 안의 static 지역 변수는 처음 호출될 때 초기화되므로, 다른 번역 단위의 전역 객체 생성자에서 호출해도 안전합니다. 이것이 SIOF의 대표적인 해결책인 Construct On First Use 관용구입니다. 다만 이 방식도 소멸 순서 문제(프로그램 종료 시 다른 전역 객체의 소멸자가 이미 파괴된 r을 쓰는 경우)는 남기 때문에, 종료 시점까지 쓰여야 하는 객체라면 static Registry* r = new Registry;처럼 일부러 파괴하지 않는 방식을 쓰기도 합니다.

그 밖의 대응

  • 상수로 초기화할 수 있는 전역은 C++20 constinit을 붙여 컴파일 타임 초기화를 강제합니다. 상수 초기화는 동적 초기화보다 먼저 끝나므로 SIOF 대상에서 빠지고, 동적 초기화가 필요한 식이면 컴파일 오류로 알려 줍니다.
  • 가능하면 전역 객체 간의 의존 자체를 없애고, 필요한 객체를 생성자 인자로 넘기는 방식으로 바꿉니다.
  • 의심되면 링크 순서를 바꿔 재현되는지 확인합니다. AddressSanitizer의 ASAN_OPTIONS=check_initialization_order=1(필요 시 strict_init_order=1)은 아직 초기화되지 않은 다른 모듈의 전역에 접근하면 initialization-order-fiasco로 보고해 줍니다.

중복 심볼(duplicate symbol) 진단

링커가 보고하는 것

multiple definition of 'foo'나 duplicate symbol _foo 같은 메시지는 링커에 보이는 같은 이름에 강한 정의가 둘 이상 있다는 뜻입니다. 메시지에는 충돌한 두 입력 파일이 함께 나오므로, 거기서부터 거슬러 올라갑니다.

단계별 진단

  1. 메시지에 나온 두 입력(main.o, libbar.a(bar.o) 등)을 확인합니다.
  2. nm -C(또는 llvm-nm -C)로 두 파일의 심볼을 봅니다. 같은 이름이 양쪽에서 T(코드)나 D/B(데이터)로 정의되어 있으면 충돌 원인입니다. 소문자 t/d는 내부 연결이라 충돌하지 않고, W/V는 약한 정의입니다.
  3. 그 심볼이 헤더에 정의된 함수나 변수라면 inline이 빠진 것입니다. 헤더에 정의한 일반 함수와 전역 변수는 그 헤더를 include한 모든 번역 단위에 정의가 생깁니다. 함수는 inline을 붙이고, 전역 변수는 C++17의 inline 변수로 바꾸거나 정의를 .cpp 하나로 옮깁니다.
  4. 같은 소스 파일이 빌드 스크립트의 실수로 두 번 컴파일되어 서로 다른 경로로 링크되거나, 같은 .o가 두 라이브러리에 함께 들어간 경우도 확인합니다.

ODR과의 관계

ODR(One Definition Rule)은 일반 함수와 변수가 프로그램 전체에서 정의를 하나만 갖도록 요구합니다. inline 함수(클래스 안에서 정의한 멤버 함수와 constexpr 함수는 암묵적으로 inline), inline 변수, 템플릿은 예외로, 여러 번역 단위에 정의가 있어도 되지만 그 정의들이 토큰 단위로 같아야 합니다.

여기서 더 위험한 쪽은 링커가 잡지 못하는 ODR 위반입니다. inline 함수나 템플릿 인스턴스는 약한(COMDAT) 심볼로 만들어지고, 링커는 같은 이름의 사본 중 아무거나 하나를 골라 나머지를 버립니다. 그런데 두 .cpp가 서로 다른 헤더 버전을 보고 있었거나, 같은 이름의 inline 함수를 우연히 다르게 정의했다면, 한쪽 정의가 조용히 사라지고 오류는 전혀 나지 않습니다. 매크로 설정(-DNDEBUG 유무 등)이 다른 오브젝트를 섞어 링크할 때도 같은 일이 생겨, 구조체 레이아웃이 달라진 상태로 서로 데이터를 주고받는 크래시가 됩니다. GCC의 -Wodr(LTO와 함께)나 ASan의 detect_odr_violation이 일부를 잡아 주지만 전부는 아니므로, 전역 이름 충돌을 피하려면 내부 전용 함수는 익명 네임스페이스에 두는 습관이 가장 확실한 예방책입니다.

자세한 패턴은 C++ multiple definition 에러 글과 함께 보는 것이 좋습니다.


빌드 환경 문제 추적

로컬에서는 되는데 CI에서만 실패할 때

컴파일러·링커·표준 라이브러리 버전, 기본 링커(lld와 GNU ld), 배포판의 기본 옵션(--as-needed), SDK와 라이브러리 경로가 다른지부터 비교합니다. 빌드 시스템이 실제로 실행한 링크 명령을 그대로 얻는 것이 첫 단계입니다. CMake는 cmake --build . --verbose, Make는 make V=1로 볼 수 있습니다.

공유 라이브러리 의존성

링크는 됐는데 실행 시 error while loading shared libraries나 undefined symbol이 난다면 링크 시점과 실행 시점에 찾은 .so가 다른 경우입니다. readelf -d app으로 기록된 의존성(NEEDED)과 RUNPATH를, ldd app으로 실제로 로드될 파일 경로를, nm -D --defined-only libfoo.so로 그 라이브러리가 내보내는 심볼을 확인합니다.

LTO

LTO를 켜면 오브젝트 파일에 기계어 대신 컴파일러의 중간 표현이 들어가고, 링커 플러그인이 링크 시점에 최적화와 코드 생성을 합니다. 이 때문에 일반 ar로 만든 아카이브에서는 심볼 인덱스가 비어 undefined reference가 나기도 합니다(gcc-ar나 llvm-ar를 써야 합니다). 링크 실패나 크래시가 LTO 때문인지 확인하려면 LTO만 끈 빌드에서 같은 입력으로 재현되는지 봅니다.

Windows와 MSVC

LNK2005(이미 정의된 심볼)와 그 결과로 나오는 LNK1169는 중복 정의, LNK2019/LNK2001은 미해결 외부 심볼입니다. 런타임 라이브러리 옵션(/MD, /MT, /MDd, /MTd)이 프로젝트나 서드파티 라이브러리마다 다르면 LNK2038: mismatch detected for 'RuntimeLibrary'나 CRT 함수의 LNK2005가 납니다. 솔루션 전체와 서드파티 라이브러리를 같은 설정으로 맞추는 것이 원칙입니다.

링커 맵 파일과 심볼 추적

-Wl,-Map=output.map(GNU ld, lld) 또는 /MAP(MSVC)으로 맵 파일을 만들면 어떤 아카이브의 어떤 멤버가 왜 링크에 들어왔는지, 각 심볼이 어느 오브젝트에서 왔는지 볼 수 있습니다. GNU ld의 -Wl,--trace-symbol=foo는 특정 심볼을 정의하거나 참조하는 입력 파일을 모두 출력해 주므로, 중복 정의의 출처가 깊이 숨어 있을 때 유용합니다.


같이 보면 좋은 글