C++ LNK2019 | 'unresolved external symbol' 링커 에러 원인 5가지와 해결법
이 글의 핵심
컴파일은 성공했는데 링크 단계에서 LNK2019가 나는 이유는 컴파일러가 선언만 보고 넘어간 심볼을 링커가 끝내 찾지 못했기 때문입니다. 오류 메시지에서 누락된 심볼 이름을 읽는 법, Visual Studio 프로젝트 속성과 CMake target_link_libraries에서 확인할 곳, 템플릿 명시적 인스턴스화까지 정리했습니다.
들어가며
LNK2019: unresolved external symbol은 C++ 개발자라면 누구나 한 번쯤 만나는 링커 에러입니다. 링커는 컴파일된 오브젝트 파일과 라이브러리를 묶어 실행 파일이나 라이브러리를 만드는 단계이고, unresolved external symbol은 “어딘가에서 이 함수나 변수를 사용하는데, 그 정의(구현)를 어디에서도 찾지 못했다”는 뜻입니다. 컴파일은 성공했는데 링크 단계에서 실패하므로, 원인은 코드 문법이 아니라 정의의 위치, 빌드 대상, 링크 설정, 이름 일치 여부에 있습니다.
그림: 실제 실행 결과 — 선언만 있으면 컴파일은 통과하고, 링크 단계에서 정의를 찾지 못해 실패한다. MSVC에서는 같은 상황이 LNK2019로 나온다. (환경: Ubuntu 24.04 · g++ 13.3)
예제와 해결법은 Visual Studio(Windows)와 g++/Clang + CMake(Linux/macOS)를 기준으로 합니다. 에러 메시지에 적힌 심볼 이름에서 출발해, 그 심볼의 정의가 있는지, 정의가 든 .cpp가 빌드에 포함되는지, 필요한 .lib/.a를 링크하는지, 이름·네임스페이스·호출 규약이 일치하는지, 템플릿 정의가 사용하는 곳에서 보이는지를 차례로 좁혀 가면 대부분 해결됩니다.
LNK2019가 생기는 단계: 컴파일과 링크
C++ 빌드는 두 단계로 나뉩니다.
첫 번째 단계인 컴파일에서는 각 .cpp 파일이 독립적으로 오브젝트 파일(.obj 또는 .o)로 변환됩니다. 이때 컴파일러는 void foo(); 같은 선언만 보고도 호출 코드를 만들 수 있습니다. 함수의 정의가 다른 번역 단위에 있을 것이라고 가정하고, 오브젝트 파일에 “외부 심볼 foo가 필요하다”고 기록해 둡니다. 그래서 헤더에 선언만 있어도 컴파일은 성공합니다.
두 번째 단계인 링크에서는 모든 오브젝트 파일과 라이브러리를 모아 실행 파일을 만듭니다. 링커는 각 오브젝트 파일이 필요하다고 기록한 외부 심볼을 다른 오브젝트 파일이나 라이브러리에서 찾아 주소를 연결합니다. 끝내 찾지 못한 심볼이 있으면 LNK2019(GCC/Clang에서는 undefined reference to)가 납니다.
이렇게 나뉘어 있기 때문에 파일 하나를 고치면 그 파일만 다시 컴파일하고 나머지는 기존 오브젝트 파일을 재사용하는 증분 빌드가 가능합니다. 대신 번역 단위 사이의 불일치는 링크 단계에 가서야 드러납니다.
flowchart LR
subgraph compile["1단계: 컴파일"]
A[.cpp + 헤더] --> B[컴파일러]
B --> C[.obj / .o]
C --> D["선언만 있어도 OK"]
end
subgraph link["2단계: 링크"]
C --> E[링커]
E --> F[.exe / 실행파일]
E -.->|정의 없음 →| G[LNK2019 에러]
end
에러 메시지 읽기
error LNK2019: unresolved external symbol "void __cdecl foo(void)" (?foo@@YAXXZ)
referenced in function main
따옴표 안의 void __cdecl foo(void)는 찾지 못한 심볼을 사람이 읽을 수 있게 풀어 쓴 것이고, 괄호 안의 ?foo@@YAXXZ는 링커가 실제로 찾는 장식된(mangled) 이름입니다. referenced in function main은 이 심볼을 main 함수에서 사용했다는 뜻입니다(32비트 빌드에서는 _main처럼 밑줄이 붙어 표시됩니다). 장식된 이름만 있을 때는 Visual Studio 개발자 명령 프롬프트에서 undname ?foo@@YAXXZ로 원래 시그니처를 확인할 수 있고, GCC/Clang에서는 c++filt가 같은 역할을 합니다.
원인 1: 함수 선언만 있고 정의가 없음
// utils.h
#ifndef UTILS_H
#define UTILS_H
void printMessage(); // 선언만 있음
#endif
// main.cpp
#include "utils.h"
int main() {
printMessage(); // ❌ LNK2019: unresolved external symbol
return 0;
}
utils.cpp가 없으면 링커는 printMessage()의 정의를 찾을 수 없습니다. 헤더에 선언을 추가하고 호출부까지 작성한 뒤 정작 구현을 빠뜨리는 경우로, 가장 흔한 원인입니다. 클래스 멤버 함수를 헤더에 선언만 하고 구현하지 않은 경우, static 데이터 멤버를 클래스 안에서 선언만 하고 클래스 밖 정의(int Counter::count = 0;)를 빠뜨린 경우, 가상 함수를 선언만 하고 정의하지 않은 경우도 같은 원인입니다.
정의를 추가하면 해결됩니다.
// utils.cpp
#include "utils.h"
#include <iostream>
void printMessage() {
std::cout << "Hello from utils!\n";
}
그리고 이 파일이 빌드에 포함되어야 합니다. CMake라면 실행 파일의 소스 목록에 넣거나 별도 라이브러리로 만들어 링크합니다.
add_executable(myapp main.cpp utils.cpp)
# 또는 라이브러리로 분리
add_library(utils utils.cpp)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE utils)
명령줄에서 직접 빌드할 때는 두 파일을 함께 넘깁니다.
g++ main.cpp utils.cpp -o myapp
원인 2: 소스 파일을 빌드에 포함하지 않음
add_executable(myapp
main.cpp
# utils.cpp 누락 ❌
)
utils.cpp에 정의가 있어도 빌드 대상에 포함되지 않으면 컴파일 자체가 되지 않으므로 링커가 찾을 수 없습니다. 새 .cpp 파일을 디스크에 만들었지만 빌드 시스템에 등록하지 않은 경우입니다. Visual Studio에서는 파일 탐색기로 폴더에 복사만 하고 프로젝트에 추가하지 않았을 때 생깁니다.
빌드 출력을 보면 확인할 수 있습니다. utils.cpp를 컴파일하는 줄이 없고 main.cpp만 컴파일한 뒤 바로 링크로 넘어갑니다. CMake에서는 소스 목록에 파일을 추가하고, Visual Studio에서는 솔루션 탐색기에서 프로젝트를 우클릭해 추가 → 기존 항목으로 utils.cpp를 등록합니다. Makefile이라면 링크 규칙의 의존성과 명령에 utils.o가 들어 있는지 확인합니다.
myapp: main.o utils.o
g++ -o myapp main.o utils.o
main.o: main.cpp utils.h
g++ -c main.cpp
utils.o: utils.cpp utils.h
g++ -c utils.cpp
원인 3: 라이브러리를 링크하지 않음
#include <mylib/api.h>
int main() {
mylib::doSomething(); // ❌ LNK2019
return 0;
}
헤더를 include하면 선언이 보이므로 컴파일은 되지만, 구현은 별도의 라이브러리 파일(.lib, .a, 또는 공유 라이브러리의 가져오기 라이브러리)에 있습니다. 링커에게 그 라이브러리를 알려 주지 않으면 구현을 찾지 못합니다. Windows에서 소켓 함수를 쓰면서 ws2_32.lib를, 메시지 박스 같은 UI 함수를 쓰면서 user32.lib를 빠뜨리는 경우가 대표적이고, OpenSSL을 쓴다면 libssl.lib와 libcrypto.lib가 필요합니다. 반대로 Boost.Asio는 헤더 온리이고, 의존하던 Boost.System도 Boost 1.69부터 헤더 온리가 되어 최신 버전에서는 별도 링크가 필요 없습니다.
Visual Studio에서는 프로젝트 속성 → 링커 → 입력 → 추가 종속성에 mylib.lib를 추가하고, 링커 → 일반 → 추가 라이브러리 디렉터리에 그 파일이 있는 경로를 넣습니다. 이 설정은 구성(Debug/Release)과 플랫폼(x64/Win32)마다 따로 저장되므로, 상단의 구성 선택을 “모든 구성”으로 바꾼 뒤 편집하거나 각 구성을 따로 확인해야 합니다. 소스 코드에서 #pragma comment(lib, "ws2_32.lib")로 지정하는 방법도 있습니다.
CMake에서는 대상에 라이브러리를 연결합니다. 패키지가 CMake 설정 파일을 제공하면 find_package가 가장 깔끔합니다.
find_package(OpenSSL REQUIRED)
target_link_libraries(myapp PRIVATE OpenSSL::SSL OpenSSL::Crypto)
# 설정 파일이 없는 라이브러리
find_library(MYLIB_LIBRARY NAMES mylib PATHS /usr/local/lib)
if(NOT MYLIB_LIBRARY)
message(FATAL_ERROR "mylib not found")
endif()
target_link_libraries(myapp PRIVATE ${MYLIB_LIBRARY})
GCC/Clang 명령줄에서는 -L로 경로를, -l로 라이브러리 이름을 줍니다. 정적 라이브러리는 순서가 중요해서, 그 라이브러리를 사용하는 오브젝트 파일보다 뒤에 와야 합니다.
g++ main.cpp -o myapp -L/path/to/lib -lmylib
원인 4: 네임스페이스·이름 불일치
// utils.h
namespace util {
void printMessage();
}
// utils.cpp
#include "utils.h"
#include <iostream>
// ❌ 네임스페이스 없이 정의 → 전역 ::printMessage라는 별개의 함수가 생김
void printMessage() {
std::cout << "Hello\n";
}
// main.cpp
#include "utils.h"
int main() {
util::printMessage(); // ❌ LNK2019: util::printMessage를 찾을 수 없음
return 0;
}
C++ 컴파일러는 함수 이름에 네임스페이스, 클래스, 매개변수 타입 정보를 붙여 장식(name mangling)합니다. MSVC에서 util::printMessage()는 ?printMessage@util@@YAXXZ, 전역 printMessage()는 ?printMessage@@YAXXZ가 되므로, 링커 입장에서는 완전히 다른 심볼입니다. 컴파일러는 utils.cpp의 전역 함수를 새 함수 정의로 받아들이므로 아무 경고 없이 넘어갑니다.
정의를 같은 네임스페이스 안에 두거나, 정규화된 이름으로 정의합니다. 정규화된 이름으로 정의하면 선언과 시그니처가 다를 때 컴파일 에러가 나므로 이런 실수를 더 일찍 잡을 수 있습니다.
#include "utils.h"
#include <iostream>
void util::printMessage() { // ✅ 선언이 없으면 컴파일 에러로 알려 줌
std::cout << "Hello\n";
}
같은 원리로 대소문자나 철자가 다른 경우(printMessage와 PrintMessage), 매개변수 타입이나 const 여부가 다른 경우도 서로 다른 심볼이 되어 LNK2019로 나타납니다. 선언과 정의의 호출 규약(__cdecl, __stdcall)이 다를 때도 마찬가지입니다.
C 라이브러리와 extern “C”
C 컴파일러는 이름을 장식하지 않으므로, C로 컴파일된 라이브러리의 함수는 foo 같은 원래 이름으로 존재합니다. C++ 코드에서 이 함수를 일반 선언으로 호출하면 C++ 방식으로 장식된 이름을 찾게 되어 링크에 실패합니다. extern "C"는 이 선언을 C 방식의 이름으로 링크하라고 컴파일러에 알려 줍니다.
잘 만든 C 라이브러리 헤더는 다음처럼 스스로 처리합니다.
#ifdef __cplusplus
extern "C" {
#endif
void c_function(int x);
#ifdef __cplusplus
}
#endif
헤더가 이렇게 되어 있지 않은 라이브러리라면 include를 extern "C" { #include "c_library.h" }로 감쌀 수 있습니다. 다만 그 헤더가 내부에서 C++ 헤더나 표준 헤더를 include하면 문제가 생길 수 있으므로, 가능하면 헤더 자체를 고치거나 래퍼 헤더를 만드는 편이 안전합니다.
원인 5: 템플릿 정의가 헤더에 없음
// math.h
template <typename T>
T add(T a, T b); // 선언만
// math.cpp
#include "math.h"
template <typename T>
T add(T a, T b) { // ❌ 정의가 .cpp에만 있음
return a + b;
}
// main.cpp
#include "math.h"
int main() {
int result = add(3, 5); // ❌ LNK2019
return 0;
}
템플릿은 그 자체로는 코드가 아니고, 특정 타입으로 사용될 때 그 타입에 맞는 코드가 생성(인스턴스화)됩니다. 이 생성은 템플릿을 사용하는 번역 단위를 컴파일할 때 일어나므로, 그 시점에 정의가 보여야 합니다.
main.cpp를 컴파일할 때 컴파일러는 add<int>가 필요하다는 것을 알지만 math.h에는 선언만 있어 코드를 만들 수 없습니다. 그래서 add<int>를 외부 심볼로 기록해 둡니다. 한편 math.cpp는 따로 컴파일되는데, 그 안에서는 add를 어떤 타입으로도 사용하지 않으므로 add<int>가 생성되지 않습니다. 결국 링커는 add<int>를 어디에서도 찾지 못합니다.
가장 일반적인 해결책은 정의를 헤더로 옮기는 것입니다.
// math.h
#ifndef MATH_H
#define MATH_H
template <typename T>
T add(T a, T b) { // ✅ 헤더에 정의
return a + b;
}
#endif
사용할 타입이 정해져 있다면 .cpp에서 명시적 인스턴스화를 할 수도 있습니다.
// math.cpp
#include "math.h"
template <typename T>
T add(T a, T b) {
return a + b;
}
// ✅ 명시적 인스턴스화: 이 번역 단위에서 int, double 버전을 생성
template int add<int>(int, int);
template double add<double>(double, double);
이렇게 하면 math.cpp를 컴파일할 때 add<int>와 add<double>이 생성되어 링커가 찾을 수 있습니다. 템플릿 구현을 숨기고 컴파일 시간을 줄이는 효과가 있지만, 목록에 없는 타입(add<long> 등)을 쓰면 다시 LNK2019가 나므로 사용할 타입을 미리 알 때만 적합합니다.
Visual Studio에서 확인할 곳
빌드가 실패하면 오류 목록보다 출력 창(Output)의 전체 메시지를 보는 편이 정보가 많습니다. 찾지 못한 심볼과 그 심볼을 참조한 함수가 함께 나오므로, 해당 함수가 있는 파일부터 확인합니다.
프로젝트 속성에서는 링커 → 입력 → 추가 종속성에 필요한 .lib가 모두 있는지, 링커 → 일반 → 추가 라이브러리 디렉터리에 그 경로가 있는지 확인합니다. C/C++ → 일반 → 추가 포함 디렉터리는 헤더 경로로, 컴파일 단계의 설정이라 LNK2019와는 직접 관계가 없습니다.
플랫폼도 맞아야 합니다. x64 프로젝트에 x86(Win32)용 라이브러리를 링크하면 링커가 라이브러리를 건너뛰면서 경고(LNK4272 등)를 내고, 그 결과 심볼을 찾지 못해 LNK2019가 이어집니다. 라이브러리 경로의 x64/, x86/ 폴더와 프로젝트의 플랫폼 설정을 함께 확인합니다.
런타임 라이브러리 설정(/MT 정적, /MD 동적)이나 Debug/Release 구성이 라이브러리와 다르면 LNK2038(불일치 감지)이나 LNK2005(중복 정의)가 나는 경우가 많고, 일부 심볼은 LNK2019로 나타나기도 합니다. 미리 빌드된 라이브러리를 쓸 때는 그 라이브러리를 빌드한 설정과 프로젝트 설정을 맞춥니다.
CMake에서 확인할 곳
target_link_libraries에 필요한 대상이 모두 연결되어 있는지 확인합니다. 라이브러리 대상이 다른 라이브러리에 의존한다면, 그 의존성을 PUBLIC으로 선언해야 이를 링크하는 실행 파일에도 전달됩니다.
add_library(core core.cpp)
target_link_libraries(core PUBLIC OpenSSL::Crypto) # core를 쓰는 대상에도 전파
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE core)
실제로 어떤 명령으로 링크되는지는 상세 빌드 로그에서 볼 수 있습니다.
cmake --build . --verbose
링크 명령에 기대한 .lib/.a 파일이나 -l 옵션이 없다면 CMake 설정 문제이고, 있는데도 실패한다면 심볼 이름이나 플랫폼 불일치를 의심합니다. 라이브러리에 실제로 그 심볼이 있는지는 Windows에서 dumpbin /symbols mylib.lib(가져오기 라이브러리는 dumpbin /exports)로, Linux에서 nm -C libmylib.a | grep doSomething으로 확인할 수 있습니다. 심볼이 라이브러리에 있는데 이름의 네임스페이스나 매개변수 타입이 다르다면 원인 4에 해당합니다.
다음으로 Segfault 디버깅이나 CMake 링크 에러를 함께 보면 빌드와 런타임 에러를 이어서 다룰 수 있습니다.