C++ multiple definition 링커 에러: 헤더 정의 중복의 원인과 해결
이 글의 핵심
컴파일은 통과했는데 링커가 같은 심볼이 여러 번 정의됐다며 멈추는 경우, 헤더 가드로 해결되지 않는다는 점이 가장 헷갈립니다. 헤더 가드는 한 번역 단위 안의 중복만 막고, 여러 .cpp에 같은 정의가 들어가는 문제는 inline이나 extern 선언으로 풀어야 합니다. 유틸리티 함수, 전역 설정 변수, 템플릿 특수화 사례로 헤더에 넣어도 되는 것과 안 되는 것을 구분해 봅니다.
컴파일은 되는데 링크에서 실패하는 이유
C++ 프로젝트를 여러 파일로 나누다 보면 컴파일은 성공하는데 링크 단계에서 multiple definition of 에러가 나는 경우를 만납니다.
그림: 실제 실행 결과 — #pragma once는 한 파일 안의 중복 포함만 막는다. 헤더를 포함한 .cpp마다 twice가 정의되어 링크 단계에서 충돌한다. (환경: Ubuntu 24.04 · g++ 13.3)
// utils.h
void foo() { // 헤더에 정의
std::cout << "foo\n";
}
// main.cpp
#include "utils.h"
// other.cpp
#include "utils.h"
// 링크 에러:
// multiple definition of 'foo()'
// first defined here: main.o
// also defined here: other.o
컴파일러는 .cpp 파일 하나(정확히는 전처리가 끝난 번역 단위 하나)만 보고 오브젝트 파일을 만듭니다. main.cpp를 컴파일할 때 컴파일러는 other.cpp가 존재한다는 사실조차 모르므로, foo()의 정의가 한 번 들어 있는 것은 전혀 문제가 아닙니다. 두 오브젝트 파일을 처음으로 한자리에 모으는 주체가 링커이고, 그 순간 foo라는 같은 이름의 외부 심볼이 두 개의 강한(strong) 정의로 존재한다는 사실이 드러납니다. 그래서 이 에러는 “코드가 틀렸다”기보다 “정의를 어디에 둘지 설계가 틀렸다”는 신호입니다.
이 글은 ODR(One Definition Rule)을 기준으로 multiple definition 에러가 나는 원인을 짚고, 헤더 작성 규칙과 inline·static·extern의 차이를 정리합니다. 헤더와 무관하게 빌드 설정 때문에 같은 에러가 나는 경우도 뒤에서 따로 다룹니다.
에러 메시지 읽기
/usr/bin/ld: other.o: in function `foo()':
other.cpp:(.text+0x0): multiple definition of `foo()'; main.o:main.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
foo()가 main.o와 other.o 두 곳에 정의되어 있어 링커가 어느 것을 쓸지 정할 수 없다는 뜻입니다. Visual Studio(MSVC)에서는 같은 상황이 다른 번호로 나타납니다.
other.obj : error LNK2005: "void __cdecl foo(void)" (?foo@@YAXXZ) already defined in main.obj
fatal error LNK1169: one or more multiply defined symbols found
LNK2005가 실제 원인이고 LNK1169는 “그래서 링크를 중단한다”는 요약일 뿐이므로, 로그에서는 첫 번째 LNK2005 줄의 심볼 이름과 두 오브젝트 파일 이름을 먼저 확인합니다. 에러 메시지에 나온 두 파일이 모두 같은 헤더를 포함하고 있다면 원인은 거의 확실히 헤더 안의 정의이고, 두 파일이 전혀 관계없는 소스라면 뒤에서 설명할 소스 이중 등록이나 라이브러리 충돌을 의심해야 합니다.
flowchart TB
H[utils.h: foo 정의]
M[main.cpp: #include utils.h]
O[other.cpp: #include utils.h]
M --> M1[main.o: foo 정의]
O --> O1[other.o: foo 정의]
M1 --> L[링커]
O1 --> L
L --> E[multiple definition 에러]
ODR (One Definition Rule)
ODR(One Definition Rule)은 C++ 표준이 정의 개수에 대해 정한 규칙입니다. inline이 아닌 함수와 변수는 프로그램 전체에서 정확히 한 번만 정의되어야 합니다. 반면 클래스, 템플릿, inline 함수와 inline 변수는 번역 단위마다 하나씩 정의가 있어도 되며, 대신 그 정의들이 모두 토큰 단위로 같아야 합니다.
두 번째 규칙의 “모두 같아야 한다”가 함정입니다. inline 함수나 클래스 정의가 번역 단위마다 다르면(예: 헤더 안의 #ifdef DEBUG 분기가 어떤 .cpp에서는 켜지고 어떤 .cpp에서는 꺼진 경우) 표준은 이를 “진단이 필요 없는 ill-formed(IFNDR)“로 규정합니다. 링커는 여러 정의 중 아무거나 하나를 골라 쓰고 에러도 경고도 내지 않으므로, 디버그 빌드에서만 이상한 값이 나오는 식의 재현하기 어려운 버그로 이어집니다. 즉 multiple definition 에러는 링커가 잡아 주는 운 좋은 ODR 위반이고, inline으로 에러를 없앴다고 해서 모든 ODR 문제가 사라지는 것은 아닙니다.
// ODR 위반
// file1.cpp
int globalVar = 42;
// file2.cpp
int globalVar = 99; // 같은 이름의 두 번째 정의 -> multiple definition of 'globalVar'
// ODR 준수
// header.h
extern int globalVar; // 선언만
// file1.cpp
#include "header.h"
int globalVar = 42; // 정의는 한 곳에만
// file2.cpp
#include "header.h" // 선언만 보고 사용
주요 원인과 해결법
헤더에 일반 함수 정의
가장 흔한 원인입니다. 위의 utils.h처럼 헤더에 일반 함수를 정의하면 그 헤더를 포함한 .cpp마다 정의가 생깁니다. 해결 방법은 세 가지입니다.
// 해결 1: 선언과 정의 분리
// utils.h
void foo();
// utils.cpp
void foo() { std::cout << "foo\n"; }
// 해결 2: inline
// utils.h
inline void foo() { std::cout << "foo\n"; }
// 해결 3: static (권장하지 않음)
// utils.h
static void foo() { std::cout << "foo\n"; }
분리가 기본이고, 짧은 함수를 헤더에 두고 싶다면 inline을 붙입니다. static은 에러는 없애지만 .cpp마다 별도 함수가 생기므로 코드가 중복되고, 함수 안의 static 지역 변수도 파일마다 따로 존재하게 됩니다.
헤더에 전역 변수 정의
// config.h
int maxConnections = 100; // 헤더에 정의 -> 포함한 .cpp마다 정의
// 해결 1: extern 선언 + .cpp 한 곳에서 정의
// config.h
extern int maxConnections;
// config.cpp
int maxConnections = 100;
// 해결 2: C++17 inline 변수
// config.h
inline int maxConnections = 100;
// 해결 3: 값이 바뀌지 않는다면 constexpr
// config.h
constexpr int maxConnections = 100;
해결 3은 해결 2와 이유가 다릅니다. 네임스페이스 범위의 const/constexpr 변수는 기본적으로 내부 링크(internal linkage)를 가지므로 .cpp마다 별도 사본이 생기고, 링커가 서로 충돌시키지 않습니다. 정수 상수는 보통 컴파일 시점에 값으로 치환되어 사본이 실제로 메모리에 남지 않지만, 주소를 취하면 번역 단위마다 다른 주소가 나옵니다. 모든 곳에서 정확히 하나의 객체가 필요하면 C++17의 inline constexpr을 쓰는 편이 명확합니다.
클래스 외부에 정의한 멤버 함수
클래스 본문 안에서 정의한 멤버 함수는 암시적으로 inline이라 헤더에 있어도 괜찮습니다. 문제는 클래스 밖에서 정의할 때입니다.
// MyClass.h
class MyClass {
public:
void bar() { std::cout << "bar\n"; } // 클래스 안 정의: 암시적 inline
void foo();
};
void MyClass::foo() { // 클래스 밖 정의, inline 없음 -> multiple definition
std::cout << "foo\n";
}
inline void MyClass::foo() { ... }처럼 inline을 붙이거나, 정의를 MyClass.cpp로 옮깁니다. 클래스의 static 데이터 멤버도 같은 규칙을 따릅니다. int MyClass::count = 0;을 헤더에 쓰면 multiple definition이 되므로 .cpp로 옮기거나, C++17부터는 클래스 안에서 inline static int count = 0;으로 정의합니다.
템플릿의 완전 특수화
// utils.h
template <typename T>
void print(T value) {
std::cout << value << '\n';
}
template <>
void print<int>(int value) { // 완전 특수화, inline 없음 -> multiple definition
std::cout << "int: " << value << '\n';
}
기본 템플릿은 헤더에 정의해도 되는데 특수화는 왜 안 될까요? 기본 템플릿은 아직 “함수 틀”일 뿐이고, 템플릿에서 만들어지는 인스턴스는 ODR상 여러 번역 단위에 정의가 있어도 되도록 허용됩니다(컴파일러는 이를 COMDAT/weak 심볼로 내보내 링커가 하나만 남깁니다). 반면 template <>로 시작하는 완전(명시적) 특수화는 더 이상 템플릿이 아니라 타입이 모두 정해진 평범한 함수입니다. 그래서 일반 함수와 똑같이 inline을 붙이거나, 헤더에는 template <> void print<int>(int); 선언만 두고 정의를 .cpp로 옮겨야 합니다. 템플릿 코드를 헤더에 몰아 두는 헤더 온리 라이브러리에서 특수화 하나만 추가했는데 갑자기 링크가 깨지는 경우가 바로 이 패턴입니다.
헤더 가드 누락과 헤더 가드의 한계
헤더 가드 없이 같은 헤더를 한 .cpp에서 두 번 포함하면, 같은 번역 단위 안에 정의가 두 번 생기므로 링커까지 가지 않고 컴파일러가 redefinition of 'foo'를 냅니다. 헤더 가드(#ifndef UTILS_H / #define UTILS_H / #endif)나 #pragma once가 이것을 막습니다.
하지만 헤더 가드는 multiple definition 링커 에러를 막지 못합니다. #define UTILS_H는 전처리기 매크로이고, 매크로는 번역 단위가 바뀌면 전부 초기화됩니다. other.cpp를 컴파일하기 시작할 때 전처리기는 main.cpp에서 UTILS_H가 정의됐었다는 사실을 알 방법이 없으므로, 헤더 가드가 있어도 두 .cpp는 각각 정의를 하나씩 갖게 됩니다.
inline, static, extern 비교
현대 C++에서 inline 키워드의 본래 의미는 “호출 지점에 펼쳐라”가 아니라 “여러 번역 단위에 같은 정의가 있어도 된다”입니다. 컴파일러는 inline이 없어도 작은 함수를 펼치고, inline이 있어도 큰 함수는 펼치지 않습니다. 호출 지점에 펼쳐지지 않은 사본은 링크할 때 하나로 합쳐집니다. 그러니 성능 목적이 아니라 링크 규칙 때문에 붙인다고 이해하는 편이 정확합니다.
static은 이름을 내부 링크로 만들어, 각 .cpp가 자기만의 함수나 변수를 갖게 합니다. 함수라면 코드가 중복될 뿐이지만, 전역 변수에 붙이면 파일마다 다른 인스턴스가 되어 프로그램의 의미가 바뀝니다. 컴파일도 링크도 성공하고, 한 파일에서 바꾼 값이 다른 파일에서 보이지 않는 현상은 런타임에야 드러나므로 가장 위험한 “해결책”입니다.
// config.h
static int counter = 0;
// main.cpp
#include "config.h"
void a() { ++counter; } // main.cpp의 counter
// other.cpp
#include "config.h"
void b() { ++counter; } // other.cpp의 counter (다른 변수)
extern은 “이 이름의 변수가 다른 곳에 정의되어 있다”는 선언만 만듭니다. 헤더에 extern int maxConnections;를 두고 .cpp 하나에 int maxConnections = 100;을 두면, 그 헤더를 포함한 모든 파일이 같은 변수를 공유하고 링커는 config.o에서만 정의를 찾습니다.
| 키워드 | 헤더에 정의 | 프로그램 안의 사본 | 주 용도 |
|---|---|---|---|
inline | 가능 | 하나로 합쳐짐 | 헤더에 두는 짧은 함수, C++17 inline 변수 |
static | 가능 | 번역 단위마다 하나 | .cpp 내부 전용 함수·변수 |
extern | 선언만 | 하나(정의는 .cpp 한 곳) | 여러 파일이 공유하는 전역 변수 |
| 없음 | 불가 | 하나 | .cpp에 두는 일반 정의 |
헤더에 넣어도 되는 것과 안 되는 것
// 헤더에 넣어도 되는 것
class MyClass {
void foo() { /* ... */ } // 클래스 안 정의: 암시적 inline
};
inline void bar() { /* ... */ } // inline 함수
template <typename T>
void baz(T value) { /* ... */ } // 템플릿
constexpr int MAX_SIZE = 100; // const/constexpr 변수: 내부 링크
inline int globalVar = 42; // C++17 inline 변수
// 헤더에 넣으면 안 되는 것
void foo2() { /* ... */ } // 일반 함수 정의
int globalVar2 = 42; // 전역 변수 정의
int MyClass2::count = 0; // static 멤버 정의 (.cpp로)
template <>
void print<int>(int value) { } // inline 없는 완전 특수화
사례: 유틸리티 함수와 전역 설정 변수
헤더에 정의한 trim 함수
// utils.h
#ifndef UTILS_H
#define UTILS_H
#include <string>
std::string trim(const std::string& s) { // inline 없음
auto start = s.find_first_not_of(" \t\n\r");
auto end = s.find_last_not_of(" \t\n\r");
if (start == std::string::npos) {
return "";
}
return s.substr(start, end - start + 1);
}
#endif
// main.cpp와 parser.cpp가 모두 utils.h를 포함
// g++ main.cpp parser.cpp -o app
// /usr/bin/ld: parser.o: in function `trim(std::string const&)':
// parser.cpp:(.text+0x0): multiple definition of `trim(std::string const&)'; main.o:main.cpp:(.text+0x0): first defined here
main.cpp와 parser.cpp가 각각 utils.h를 포함하므로 main.o와 parser.o에 trim()의 정의가 하나씩 들어가고, 링커가 둘을 합칠 때 충돌합니다. 헤더 가드가 있지만 앞에서 본 것처럼 번역 단위가 다르면 소용이 없습니다.
해결은 inline std::string trim(...)으로 바꾸거나, 헤더에는 선언만 두고 정의를 utils.cpp로 옮긴 뒤 g++ main.cpp parser.cpp utils.cpp -o app으로 빌드하는 것입니다. 어느 쪽을 고를지는 함수의 크기와 변경 빈도로 판단합니다. 헤더에 둔 함수는 각 번역 단위에서 본문이 보이므로 링크 타임 최적화(LTO) 없이도 인라인될 기회가 생기지만, 그 헤더를 포함한 모든 .cpp가 함수 본문까지 컴파일해야 하고, 본문을 고칠 때마다 포함한 파일이 전부 다시 컴파일됩니다. 짧고 거의 바뀌지 않는 함수는 헤더에 inline으로, 길거나 자주 바뀌는 함수는 .cpp로 두는 것이 무난한 기준입니다. 템플릿은 사용하는 곳에서 본문이 보여야 하므로 헤더에 둡니다.
전역 설정 변수: extern과 inline 변수 중 무엇을 고를까
// config.h
#ifndef CONFIG_H
#define CONFIG_H
int maxThreads = 8; // 헤더에 정의
int maxConnections = 100;
int port = 8080;
#endif
// main.cpp와 server.cpp가 모두 config.h를 포함
// /usr/bin/ld: server.o:(.data+0x0): multiple definition of `maxThreads'; main.o:(.data+0x0): first defined here
| 방법 | C++ 버전 | 런타임 수정 | 맞는 경우 |
|---|---|---|---|
헤더에 extern + .cpp에 정의 | C++98 | 가능 | 정의 위치를 한 곳으로 명확히 두고 싶을 때 |
inline 변수 | C++17 | 가능 | 별도 .cpp 없이 헤더만으로 충분할 때 |
constexpr | C++11 | 불가 | 컴파일 타임 상수 |
#define | C | 불가 | 타입이 없어 권하지 않음 |
extern 방식은 C++17 이전 컴파일러에서도 동작하고 정의 위치가 명확합니다. C++17 이상이라면 헤더에 inline int port = 8080;처럼 inline 변수로 정의해 여러 번역 단위에 포함되어도 하나의 객체로 합쳐지게 할 수 있습니다. 상수라면 inline constexpr이 가장 간단합니다.
값이 많아지면 클래스의 static 멤버로 묶을 수도 있습니다.
// config.h
#ifndef CONFIG_H
#define CONFIG_H
#include <string>
class Config {
public:
static int maxThreads;
static int maxConnections;
static int port;
static void load_from_file(const std::string& path);
};
#endif
// config.cpp
#include "config.h"
int Config::maxThreads = 8; // static 멤버 정의는 한 곳에만
int Config::maxConnections = 100;
int Config::port = 8080;
void Config::load_from_file(const std::string& path) {
// 파일에서 설정 로드
}
고려할 점이 하나 더 있습니다. extern 변수든 inline 변수든 함수 호출 결과로 초기화하는 동적 초기화가 필요하면, 다른 번역 단위의 전역 객체 생성자에서 그 값을 읽을 때 아직 초기화되지 않았을 수 있습니다(static initialization order fiasco). 이런 설정 값은 Config& config() { static Config c; return c; }처럼 함수 안의 static 지역 변수로 감싸 처음 사용할 때 초기화되게 하는 편이 안전합니다.
헤더가 멀쩡한데도 multiple definition이 날 때
헤더를 모두 점검했는데도 에러가 사라지지 않는다면 빌드 구성 쪽을 봐야 합니다.
.cpp 파일을 #include한 경우가 첫 번째 후보입니다. #include "utils.cpp"처럼 소스 파일을 직접 포함하면 utils.cpp의 모든 정의가 포함한 쪽 번역 단위에 복사됩니다. 그런데 빌드 시스템은 utils.cpp도 따로 컴파일하므로 같은 정의가 두 번 생깁니다. 튜토리얼 예제를 합치다 보면 흔히 생기는 실수이며, 소스 파일은 include하지 않는다는 규칙 하나로 막을 수 있습니다.
같은 소스를 빌드에 두 번 등록한 경우도 있습니다. CMake에서 add_executable(app main.cpp utils.cpp)와 별도의 add_library(common STATIC utils.cpp)를 함께 두고 app이 common을 링크하면, utils.cpp의 심볼이 실행 파일의 오브젝트와 라이브러리 양쪽에 들어갑니다. 정적 라이브러리는 필요한 오브젝트만 끌어오므로 이미 정의된 심볼만 담긴 경우에는 조용히 넘어가기도 하지만, 그 오브젝트의 다른 심볼이 필요해 함께 끌려오는 순간 충돌이 납니다. Visual Studio 프로젝트에서 같은 파일을 두 번 추가했을 때도 비슷한 일이 생깁니다. 에러 메시지의 두 오브젝트 파일이 같은 소스에서 나왔다면 이 경우입니다.
C 헤더의 잠정 정의(tentative definition)도 흔한 원인입니다. C 코드에서 헤더에 int counter;처럼 초기화 없이 전역 변수를 두는 습관은 오래전부터 GCC가 공통 심볼(common symbol)로 합쳐 주었기 때문에 문제가 드러나지 않았습니다. GCC 10부터 기본값이 -fno-common으로 바뀌면서, 오래 잘 빌드되던 C 프로젝트가 컴파일러 업그레이드 직후 multiple definition of 'counter'로 깨지는 사례가 많이 보고됐습니다. 올바른 해결은 헤더에 extern int counter;를 두고 .c 파일 하나에 정의하는 것이며, -fcommon 플래그는 임시 우회책일 뿐입니다.
두 라이브러리가 같은 심볼을 정의한 경우도 있습니다. 서로 다른 서드파티 라이브러리가 같은 이름의 전역 함수(예: 각자 번들한 zlib의 crc32)를 포함하면 공유 라이브러리와 정적 라이브러리 조합에 따라 multiple definition이 나거나, 반대로 에러 없이 한쪽 구현이 조용히 선택됩니다. 정적 라이브러리는 필요한 오브젝트만 끌어오기 때문에 링크 순서에 따라 결과가 달라지기도 합니다. 이 경우는 코드 수정보다 중복 번들을 제거하고 하나의 공용 버전을 링크하는 쪽이 근본적인 해결입니다.
마지막으로, .cpp 안에서만 쓰는 도우미 함수가 다른 .cpp의 같은 이름 함수와 충돌하는 경우가 있습니다. 두 파일이 각자 helper()를 정의하면 둘 다 외부 링크라서 충돌합니다. 이럴 때는 namespace { void helper() { ... } }로 감싸 내부 링크로 만드는 것이 C++다운 해법입니다. 헤더에 static을 붙이는 것과 달리, 원래부터 파일 전용인 함수이므로 사본이 여러 개라는 점도 의도에 맞습니다.