C++ multiple definition 링커 에러: 헤더 정의 중복의 원인과 해결
이 글의 핵심
컴파일은 통과했는데 링커가 같은 심볼이 여러 번 정의됐다며 멈추는 경우, 헤더 가드로 해결되지 않는다는 점이 가장 헷갈립니다. 헤더 가드는 한 번역 단위 안의 중복만 막고, 여러 .cpp에 같은 정의가 들어가는 문제는 inline이나 extern 선언으로 풀어야 합니다. 유틸리티 함수, 전역 설정 변수, 템플릿 특수화 사례로 헤더에 넣어도 되는 것과 안 되는 것을 구분해 봅니다.
들어가며: “컴파일은 되는데 링크에서 multiple definition…”
C++ 프로젝트를 여러 파일로 나누다 보면 multiple definition of 에러를 만나게 됩니다. 컴파일은 성공하는데 링크 단계에서 실패합니다.
// 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의 차이를 정리합니다. 헤더와 무관하게 빌드 설정 때문에 같은 에러가 나는 경우도 뒤에서 따로 다룹니다.
multiple definition 에러란?
에러 메시지
/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 에러]
핵심: 헤더 파일을 여러 .cpp가 포함하면, 각 .cpp마다 함수가 정의됩니다.
ODR (One Definition Rule)
규칙
C++ 표준의 ODR(One Definition Rule)은:
- 변수·함수는 전체 프로그램에서 최대 한 번만 정의되어야 함
- 클래스·템플릿·inline 함수는 여러 번 정의 가능 (단, 모두 동일해야 함)
두 번째 규칙의 “단, 모두 동일해야 함”이 함정입니다. inline 함수나 클래스 정의가 번역 단위마다 다르면(예: 헤더 안의 #ifdef DEBUG 분기가 어떤 .cpp에서는 켜지고 어떤 .cpp에서는 꺼진 경우) 표준은 이를 “진단이 필요 없는 ill-formed(IFNDR)“로 규정합니다. 링커는 여러 정의 중 아무거나 하나를 골라 쓰고 에러도 경고도 내지 않으므로, 디버그 빌드에서만 이상한 값이 나오는 식의 재현하기 어려운 버그로 이어집니다. 즉 multiple definition 에러는 링커가 잡아 주는 “운 좋은” ODR 위반이고, inline으로 에러를 없앴다고 해서 모든 ODR 문제가 사라지는 것은 아닙니다.
ODR 위반 예제
// ❌ ODR 위반
// file1.cpp
int globalVar = 42;
// file2.cpp
int globalVar = 99; // ❌ 중복 정의
// 링크 에러: multiple definition of 'globalVar'
ODR 준수 예제
// ✅ ODR 준수
// header.h
extern int globalVar; // 선언만
// file1.cpp
int globalVar = 42; // 정의 (한 곳에만)
// file2.cpp
#include "header.h"
// globalVar 사용 가능 (정의 없음)
5가지 주요 원인과 해결법
원인 1: 헤더에 함수 정의
가장 흔한 원인: 헤더 파일에 일반 함수를 정의함.
// ❌ utils.h
void foo() { // 헤더에 정의
std::cout << "foo\n";
}
// main.cpp
#include "utils.h"
// other.cpp
#include "utils.h"
// 링크 에러: multiple definition of 'foo()'
해결법 1: 선언과 정의 분리 (권장)
// ✅ utils.h
void foo(); // 선언만
// utils.cpp
void foo() { // 정의
std::cout << "foo\n";
}
해결법 2: inline 키워드
// ✅ utils.h
inline void foo() { // inline: 여러 번 정의 허용
std::cout << "foo\n";
}
해결법 3: static 키워드 (비권장)
// ✅ utils.h
static void foo() { // 각 .cpp마다 별도 복사본
std::cout << "foo\n";
}
주의: static은 각 .cpp 파일마다 별도 함수를 만들어 코드 크기가 증가합니다.
원인 2: 헤더에 전역 변수 정의
// ❌ config.h
int maxConnections = 100; // 헤더에 정의
// main.cpp
#include "config.h"
// server.cpp
#include "config.h"
// 링크 에러: multiple definition of 'maxConnections'
해결법 1: extern 사용 (권장)
// ✅ config.h
extern int maxConnections; // 선언만
// config.cpp
int maxConnections = 100; // 정의 (한 곳에만)
해결법 2: inline 변수 (C++17)
// ✅ config.h
inline int maxConnections = 100; // C++17: inline 변수
해결법 3: constexpr
// ✅ config.h
constexpr int maxConnections = 100; // 네임스페이스 범위 const/constexpr 변수는 내부 링크
해결법 3은 이유가 해결법 2와 다릅니다. 네임스페이스 범위의 const/constexpr 변수는 기본적으로 내부 링크(internal linkage) 를 가지므로 .cpp마다 별도 사본이 생기고, 링커가 서로 충돌시키지 않습니다. 정수 상수는 보통 컴파일 시점에 값으로 치환되어 사본이 실제로 메모리에 남지 않지만, 주소를 취하면 번역 단위마다 다른 주소가 나옵니다. 모든 곳에서 정확히 하나의 객체가 필요하면 C++17의 inline constexpr을 쓰는 편이 명확합니다.
원인 3: 클래스 멤버 함수를 헤더에 정의
주의: 클래스 내부에 정의하면 암시적 inline이므로 OK.
// ✅ OK (암시적 inline)
// MyClass.h
class MyClass {
public:
void foo() { // 클래스 내부 정의 → 암시적 inline
std::cout << "foo\n";
}
};
에러 발생: 클래스 외부에 정의하면 inline 명시 필요.
// ❌ MyClass.h
class MyClass {
public:
void foo(); // 선언
};
void MyClass::foo() { // ❌ 클래스 외부 정의 (inline 없음)
std::cout << "foo\n";
}
// 링크 에러: multiple definition of 'MyClass::foo()'
해결:
// ✅ 해결 1: inline 명시
inline void MyClass::foo() {
std::cout << "foo\n";
}
// ✅ 해결 2: .cpp로 이동 (권장)
// MyClass.cpp
void MyClass::foo() {
std::cout << "foo\n";
}
원인 4: 템플릿 특수화를 헤더에 정의
// ❌ utils.h
template <typename T>
void print(T value) {
std::cout << value << '\n';
}
// 명시적 특수화 (inline 없음)
template <>
void print<int>(int value) { // ❌ 중복 정의
std::cout << "int: " << value << '\n';
}
// 링크 에러: multiple definition of 'void print<int>(int)'
기본 템플릿은 헤더에 정의해도 되는데 특수화는 왜 안 될까요? 기본 템플릿은 아직 “함수 틀”일 뿐이고, 템플릿에서 만들어지는 인스턴스는 ODR상 여러 번역 단위에 정의가 있어도 되도록 허용됩니다(컴파일러는 이를 COMDAT/weak 심볼로 내보내 링커가 하나만 남깁니다). 반면 template <>로 시작하는 완전(명시적) 특수화는 더 이상 템플릿이 아니라 타입이 모두 정해진 평범한 함수입니다. 그래서 일반 함수와 똑같이 inline을 붙이거나 .cpp로 옮겨야 합니다. 템플릿 코드를 헤더에 몰아 두는 헤더 온리 라이브러리에서 특수화 하나만 추가했는데 갑자기 링크가 깨지는 경우가 바로 이 패턴입니다.
해결:
// ✅ 해결 1: inline 명시
template <>
inline void print<int>(int value) {
std::cout << "int: " << value << '\n';
}
// ✅ 해결 2: .cpp로 이동
// utils.cpp
template <>
void print<int>(int value) {
std::cout << "int: " << value << '\n';
}
원인 5: 헤더 가드 없음 (같은 파일 내 중복)
// ❌ utils.h (헤더 가드 없음)
void foo() {
std::cout << "foo\n";
}
// main.cpp
#include "utils.h"
#include "utils.h" // 두 번 포함 → 같은 파일 내 중복 정의
// 컴파일 에러: redefinition of 'foo'
해결:
// ✅ utils.h
#ifndef UTILS_H
#define UTILS_H
void foo(); // 선언만
#endif
// 또는 #pragma once (대부분의 컴파일러 지원)
#pragma once
void foo();
inline vs static vs extern
inline: 여러 번 정의 허용 (권장)
// utils.h
inline void foo() { // 여러 .cpp에서 포함해도 OK
std::cout << "foo\n";
}
특징:
- 링커가 하나만 선택 (모든 정의가 동일하다고 가정)
- 호출 지점에 인라인되지 않은 사본은 링크 시 하나로 합쳐짐
- 인라인 최적화 가능 (단, 실제 인라인 여부는 컴파일러가 결정)
현대 C++에서 inline 키워드의 본래 의미는 “호출 지점에 펼쳐라”가 아니라 “여러 번역 단위에 같은 정의가 있어도 된다”입니다. 컴파일러는 inline이 없어도 작은 함수를 펼치고, inline이 있어도 큰 함수는 펼치지 않습니다. 그러니 성능 목적으로 inline을 붙이는 것이 아니라 링크 규칙 때문에 붙인다고 이해하는 편이 정확합니다.
static: 각 파일마다 별도 복사본
// utils.h
static void foo() { // 각 .cpp마다 별도 함수
std::cout << "foo\n";
}
특징:
- 각 .cpp 파일마다 별도 함수
- 코드 크기 증가
- 내부 링크 (다른 파일에서 접근 불가)
단점: 전역 변수를 static으로 만들면 각 파일마다 다른 인스턴스가 됩니다. 에러는 사라지지만 프로그램의 의미가 바뀌는 것이라 가장 위험한 “해결책”입니다. 컴파일도 링크도 성공하고, 한 파일에서 바꾼 값이 다른 파일에서 보이지 않는 현상은 런타임에야 드러납니다.
// ❌ 의도와 다름
// config.h
static int counter = 0;
// main.cpp
#include "config.h"
++counter; // main.cpp의 counter
// other.cpp
#include "config.h"
++counter; // other.cpp의 counter (다른 변수!)
extern: 선언만 (정의는 한 곳에)
// config.h
extern int maxConnections; // 선언만
// config.cpp
int maxConnections = 100; // 정의 (한 곳에만)
// main.cpp, other.cpp
#include "config.h"
// maxConnections 사용 가능 (같은 변수)
비교표
| 키워드 | 헤더에 정의 | 복사본 | 코드 크기 | 사용 시기 |
|---|---|---|---|---|
| inline | 가능 | 하나 | 작음 | 작은 함수 (권장) |
| static | 가능 | 여러 개 | 큼 | 파일 내부 전용 |
| extern | 선언만 | 하나 | 작음 | 전역 변수 |
| (없음) | 불가 | 하나 | 작음 | .cpp에 정의 |
헤더 파일 작성 규칙
헤더에 넣어도 되는 것
// ✅ OK
class MyClass {
void foo() { // 클래스 내부 정의 → 암시적 inline
// ...
}
};
// ✅ OK
inline void bar() { // inline 명시
// ...
}
// ✅ OK
template <typename T>
void baz(T value) { // 템플릿은 ODR상 여러 번역 단위 정의 허용
// ...
}
// ✅ OK
constexpr int MAX_SIZE = 100; // const/constexpr 변수 → 내부 링크
// ✅ OK (C++17)
inline int globalVar = 42; // inline 변수
헤더에 넣으면 안 되는 것
// ❌ 일반 함수 정의
void foo() {
// ...
}
// ❌ 전역 변수 정의
int globalVar = 42;
// ❌ static 멤버 변수 정의
class MyClass {
static int count;
};
int MyClass::count = 0; // .cpp로 이동해야 함
// ❌ 템플릿 명시적 특수화 (inline 없이)
template <>
void print<int>(int value) {
// ...
}
올바른 헤더/소스 분리
// ✅ MyClass.h
class MyClass {
public:
void foo(); // 선언만
void bar() { // 클래스 내부 정의 (OK)
// ...
}
static int count; // 선언만
};
// ✅ MyClass.cpp
void MyClass::foo() { // 정의
// ...
}
int MyClass::count = 0; // static 멤버 정의
실전 사례 분석
사례 1: 유틸리티 함수 중복 정의
문제 상황: 여러 소스 파일에서 사용하는 유틸리티 함수를 헤더에 정의했더니 링크 에러 발생.
에러 코드:
// utils.h
#ifndef UTILS_H
#define UTILS_H
#include <string>
#include <algorithm>
// 문자열 앞뒤 공백 제거
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
#include "utils.h"
#include <iostream>
int main() {
std::string text = " hello ";
std::cout << trim(text) << '\n'; // "hello"
return 0;
}
// parser.cpp
#include "utils.h"
#include <iostream>
void parse(const std::string& input) {
std::string cleaned = trim(input);
std::cout << "Parsed: " << cleaned << '\n';
}
// 컴파일:
// 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
에러 원인 분석:
1. main.cpp가 utils.h를 include
→ main.o에 trim() 함수 정의 포함
2. parser.cpp가 utils.h를 include
→ parser.o에 trim() 함수 정의 포함
3. 링커가 main.o와 parser.o를 합칠 때
→ trim() 함수가 두 곳에 정의되어 충돌!
해결법 1: inline 키워드 추가 (권장)
// ✅ utils.h
#ifndef UTILS_H
#define UTILS_H
#include <string>
#include <algorithm>
inline 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
// 이제 링크 에러 없음!
// inline 함수는 여러 번 정의되어도 링커가 하나만 선택
해결법 2: 선언과 정의 분리 (큰 함수에 적합)
// ✅ utils.h (선언만)
#ifndef UTILS_H
#define UTILS_H
#include <string>
std::string trim(const std::string& s); // 선언만
#endif
// ✅ utils.cpp (정의)
#include "utils.h"
#include <algorithm>
std::string trim(const std::string& s) { // 정의 (한 곳에만)
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);
}
// 컴파일:
// g++ main.cpp parser.cpp utils.cpp -o app
// 이제 utils.cpp만 trim() 정의를 가지므로 에러 없음
inline vs 분리 선택 기준:
| 함수 특징 | 권장 방법 | 이유 |
|---|---|---|
| 작은 함수 (3-5줄) | inline | 인라인 최적화, 헤더에 정의 편리 |
| 큰 함수 (10줄+) | 분리 | 컴파일 시간 단축, 코드 크기 감소 |
| 템플릿 함수 | 헤더에 정의 | 템플릿은 헤더에 있어야 함 |
| 자주 변경되는 함수 | 분리 | 헤더 변경 시 전체 재컴파일 방지 |
사례 2: 전역 설정 변수
문제 상황: 프로젝트 전체에서 사용하는 설정 값을 헤더에 정의했더니 링크 에러 발생.
에러 코드:
// config.h
#ifndef CONFIG_H
#define CONFIG_H
// 전역 설정 변수
int maxThreads = 8; // ❌ 헤더에 정의
int maxConnections = 100;
int port = 8080;
#endif
// main.cpp
#include "config.h"
#include <iostream>
int main() {
std::cout << "Port: " << port << '\n';
std::cout << "Max threads: " << maxThreads << '\n';
return 0;
}
// server.cpp
#include "config.h"
#include <iostream>
void start_server() {
std::cout << "Starting server on port " << port << '\n';
std::cout << "Max connections: " << maxConnections << '\n';
}
// 컴파일:
// g++ main.cpp server.cpp -o app
//
// 링크 에러:
// /usr/bin/ld: server.o:(.data+0x0): multiple definition of `maxThreads';
// main.o:(.data+0x0): first defined here
// /usr/bin/ld: server.o:(.data+0x4): multiple definition of `maxConnections';
// main.o:(.data+0x4): first defined here
해결법 1: extern 사용 (권장)
// ✅ config.h (선언만)
#ifndef CONFIG_H
#define CONFIG_H
// 선언: 이 변수가 어딘가에 정의되어 있다고 알림
extern int maxThreads;
extern int maxConnections;
extern int port;
#endif
// ✅ config.cpp (정의 - 한 곳에만)
#include "config.h"
// 정의: 실제 메모리 할당
int maxThreads = 8;
int maxConnections = 100;
int port = 8080;
// 컴파일:
// g++ main.cpp server.cpp config.cpp -o app
// 이제 config.cpp만 변수를 정의하므로 에러 없음
extern의 동작 원리:
config.h (선언):
"maxThreads라는 int 변수가 어딘가에 있어요"
config.cpp (정의):
"maxThreads를 여기서 실제로 만듭니다" (메모리 할당)
main.cpp, server.cpp:
"config.h를 include하여 maxThreads 존재를 알고 사용"
링커:
"모든 .o 파일을 합칠 때 maxThreads는 config.o에만 있습니다. OK!"
해결법 2: inline 변수 (C++17)
C++17부터는 inline 변수를 사용할 수 있습니다.
// ✅ config.h (C++17)
#ifndef CONFIG_H
#define CONFIG_H
inline int maxThreads = 8; // C++17: inline 변수
inline int maxConnections = 100;
inline int port = 8080;
#endif
// config.cpp 불필요!
// 컴파일:
// g++ -std=c++17 main.cpp server.cpp -o app
해결법 3: constexpr (컴파일 타임 상수)
값이 변하지 않으면 constexpr를 사용하세요.
// ✅ config.h
#ifndef CONFIG_H
#define CONFIG_H
constexpr int maxThreads = 8; // 네임스페이스 범위 constexpr → 내부 링크
constexpr int maxConnections = 100;
constexpr int port = 8080;
#endif
// config.cpp 불필요!
// 각 .cpp가 내부 링크 사본을 가지므로 링커 충돌 없음
비교표:
| 방법 | C++ 버전 | 런타임 수정 | 사용 시기 |
|---|---|---|---|
| extern + .cpp | C++98+ | ✅ 가능 | 런타임에 값 변경 필요 |
| inline 변수 | C++17+ | ✅ 가능 | 간단한 설정 값 |
| constexpr | C++11+ | ❌ 불가능 | 컴파일 타임 상수 |
| #define | C++98+ | ❌ 불가능 | 매크로 (타입 없음) |
실전 패턴 - 설정 클래스:
// ✅ config.h
#ifndef CONFIG_H
#define CONFIG_H
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"
// static 멤버 변수 정의 (한 곳에만)
int Config::maxThreads = 8;
int Config::maxConnections = 100;
int Config::port = 8080;
void Config::load_from_file(const std::string& path) {
// 파일에서 설정 로드
}
// 사용
// Config::port = 9000; // 런타임에 변경 가능
사례 3: 템플릿 특수화
에러 코드:
// printer.h
template <typename T>
void print(T value) {
std::cout << value << '\n';
}
template <>
void print<std::string>(std::string value) { // ❌ inline 없음
std::cout << "String: " << value << '\n';
}
// 링크 에러: multiple definition of 'void print<std::string>'
해결:
// ✅ printer.h
template <typename T>
void print(T value) {
std::cout << value << '\n';
}
template <>
inline void print<std::string>(std::string value) { // inline 추가
std::cout << "String: " << value << '\n';
}
헤더 가드 vs 중복 정의
헤더 가드의 역할
헤더 가드는 같은 파일 내에서 중복 포함을 방지합니다.
// utils.h
#ifndef UTILS_H
#define UTILS_H
void foo();
#endif
// main.cpp
#include "utils.h"
#include "utils.h" // 두 번째는 무시됨 (헤더 가드)
헤더 가드로 막을 수 없는 것:
// utils.h
#ifndef UTILS_H
#define UTILS_H
void foo() { // ❌ 정의
// ...
}
#endif
// main.cpp
#include "utils.h" // foo 정의 포함
// other.cpp
#include "utils.h" // foo 정의 포함
// 링크 에러: 헤더 가드로는 막을 수 없음!
이유: main.cpp와 other.cpp는 별도로 컴파일되므로, 각각 foo를 정의합니다. 헤더 가드의 #define UTILS_H는 전처리기 매크로이고, 매크로는 번역 단위가 바뀌면 전부 초기화됩니다. other.cpp를 컴파일하기 시작할 때 전처리기는 main.cpp에서 UTILS_H가 정의됐었다는 사실을 알 방법이 없습니다.
헤더가 멀쩡한데도 multiple definition이 날 때
헤더를 모두 점검했는데도 에러가 사라지지 않는다면 빌드 구성 쪽을 봐야 합니다. 처음 여러 파일 프로젝트를 다룰 때 제가 가장 오래 헤맸던 경우도 코드가 아니라 빌드 설정 문제였습니다.
.cpp 파일을 #include한 경우. #include "utils.cpp"처럼 소스 파일을 직접 포함하면 utils.cpp의 모든 정의가 포함한 쪽 번역 단위에 복사됩니다. 그런데 빌드 시스템은 utils.cpp도 따로 컴파일하므로 같은 정의가 두 번 생깁니다. 튜토리얼 예제를 합치다 보면 흔히 생기는 실수이며, 소스 파일은 절대 include하지 않는다는 규칙 하나로 막을 수 있습니다.
같은 소스를 빌드에 두 번 등록한 경우. CMake에서 add_executable(app main.cpp utils.cpp)와 별도의 add_library(common 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을 붙이는 것과 달리, 원래부터 파일 전용인 함수이므로 사본이 여러 개라는 부작용도 의도에 맞습니다.
같이 보면 좋은 글
- C++ LNK2019 | ‘unresolved external symbol’ 링커 에러 원인 5가지와 해결법
- C++ ODR(단일 정의 규칙): 헤더 정의 링크 에러, inline·템플릿 예외, 링커가 못 잡는 위반
- C++ Header Files
- C++ 링킹 이해하기: 정적 vs 동적 링킹, 심볼 확인, undefined reference 해결, LTO
- C++ 헤더 온리 라이브러리
- C++ vtable 에러
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- Visual Studio C++ 빌드 느림
자주 묻는 질문 (FAQ)
Q. 헤더에 둔 전역 변수는 extern과 C++17 inline 변수 중 무엇으로 고쳐야 하나요?
A. extern 방식은 헤더에 extern int g; 선언만 두고 .cpp 한 곳에 정의를 두는 방법으로, C++17 이전 컴파일러에서도 동작하고 정의 위치가 명확합니다. C++17 이상이라면 헤더에 inline int g = 0;처럼 inline 변수로 정의해 여러 번역 단위에 포함되어도 하나의 객체로 합쳐지게 할 수 있습니다. 상수라면 inline constexpr이 가장 간단하며, static을 붙이는 방식은 번역 단위마다 별도 복사본이 생겨 값을 공유하지 못한다는 점에서 목적이 다릅니다.
실무에서 한 가지 더 고려할 점은 초기화 순서입니다. extern 변수든 inline 변수든 동적 초기화(함수 호출 결과로 초기화)가 필요하면, 다른 번역 단위의 전역 객체 생성자에서 그 값을 읽을 때 아직 초기화되지 않았을 수 있습니다(static initialization order fiasco). 이런 설정 값은 Config& config() { static Config c; return c; }처럼 함수 안의 static 지역 변수로 감싸 처음 사용할 때 초기화되게 하는 편이 안전합니다.