C++ 링킹 이해하기: 정적 vs 동적 링킹, 심볼 확인, undefined reference 해결, LTO
이 글의 핵심
링커가 오브젝트 파일을 어떻게 묶는지, 정적·동적 라이브러리의 차이, 심볼을 확인해 undefined reference를 추적하는 방법, LTO의 효과를 실제 프로젝트 빌드 예제로 정리합니다.
들어가며
링킹(Linking)은 오브젝트 파일(.o)을 하나의 실행 파일이나 라이브러리로 결합하는 컴파일의 마지막 단계입니다. 컴파일러는 각 .cpp를 독립적으로 기계어로 변환하며, 링커가 이들을 연결하여 최종 실행 파일을 생성합니다.
컴파일 다음에 링커가 하는 일
컴파일과 링킹 나눠 보기
# 1단계: 컴파일 (소스 → 오브젝트)
# -c: 컴파일만 수행 (링킹 안함)
# -o: 출력 파일 이름 지정
g++ -c main.cpp -o main.o # main.cpp → main.o (기계어 코드)
g++ -c util.cpp -o util.o # util.cpp → util.o (기계어 코드)
# 각 .cpp 파일이 독립적으로 .o 파일로 변환됨
# 이 단계에서는 함수 호출이 아직 연결 안 됨 (심볼만 기록)
# 2단계: 링킹 (오브젝트 → 실행 파일)
# 여러 .o 파일을 하나의 실행 파일로 결합
# main.o의 함수 호출을 util.o의 함수 정의와 연결
g++ main.o util.o -o myapp
# 링커가 심볼 해석, 재배치, 최종 실행 파일 생성
# 또는 한 번에: 컴파일 + 링킹
# 내부적으로는 위의 2단계를 자동으로 수행
g++ main.cpp util.cpp -o myapp
파일 구조:
// util.h
#pragma once
int add(int a, int b);
// util.cpp
#include "util.h"
int add(int a, int b) {
return a + b;
}
// main.cpp
#include <iostream>
#include "util.h"
int main() {
std::cout << add(10, 20) << std::endl;
return 0;
}
심볼 해석과 재배치
1. 심볼 해석 (Symbol Resolution)
- main.cpp의 add() 호출을 util.o의 add() 정의와 연결
2. 재배치 (Relocation)
- 최종 메모리 배치에 맞게 주소/오프셋 조정
3. 최종 이미지 생성
- 실행 파일 또는 라이브러리(.so/.dll) 생성
main.cpp를 컴파일할 때 컴파일러가 아는 것은 util.h에 적힌 선언뿐입니다. “int add(int, int)라는 함수가 어딘가에 있다”는 약속만 믿고 호출 코드를 만들고, 호출 대상 주소 자리는 비워 둔 채 “여기에 add의 주소가 필요하다”는 재배치 항목을 main.o에 기록합니다. 링커는 모든 오브젝트 파일의 심볼 테이블을 모아서 이 빈칸마다 실제 정의를 찾아 연결합니다. 그래서 헤더만 include하고 util.cpp를 빌드에 넣지 않으면 컴파일은 성공하고 링크에서 실패하는 것이며, 반대로 같은 함수가 두 오브젝트 파일에 정의되어 있으면 링커가 어느 쪽을 쓸지 몰라 multiple definition 에러를 냅니다.
이 구조를 이해하면 빌드 시간 문제도 설명됩니다. util.cpp만 고쳤다면 util.o만 다시 컴파일하고 링크하면 되지만, util.h를 고치면 그것을 include하는 모든 .cpp를 다시 컴파일해야 합니다. 헤더에 구현을 몰아넣은 프로젝트가 작은 수정에도 전체를 다시 빌드하게 되는 이유입니다.
ar로 정적 라이브러리 만들어 링크하기
# 1. 오브젝트 파일 생성
g++ -c lib.cpp -o lib.o
# 2. 정적 라이브러리 생성 (.a)
# ar: 아카이브 도구 (여러 .o 파일을 하나의 .a 파일로 묶음)
# r: 파일 추가/교체
# c: 아카이브 생성 (없으면)
# s: 인덱스 생성 (심볼 테이블)
ar rcs libmylib.a lib.o
# 결과: libmylib.a (정적 라이브러리)
# 3. 사용: 정적 라이브러리를 링크하여 실행 파일 생성
# -L.: 현재 디렉토리에서 라이브러리 검색
# -lmylib: libmylib.a 링크 (lib 접두사, .a 접미사 자동 추가)
g++ main.cpp -L. -lmylib -o myapp
# 링커가 libmylib.a의 코드를 myapp에 포함 (정적 링킹)
예제:
// lib.h
#pragma once
int multiply(int a, int b);
// lib.cpp
#include "lib.h"
int multiply(int a, int b) {
return a * b;
}
// main.cpp
#include <iostream>
#include "lib.h"
int main() {
std::cout << multiply(5, 6) << std::endl; // 30
return 0;
}
특징:
- ✅ 배포 간단 (단일 실행 파일)
- ✅ 빠름 (런타임 로딩 없음)
- ❌ 크기 큼 (라이브러리 코드 포함)
- ❌ 업데이트 시 재링크 필요
정적 라이브러리(.a)는 사실 오브젝트 파일을 묶어 둔 아카이브일 뿐입니다. 링커는 아카이브 전체를 복사하지 않고, 현재 해결되지 않은 심볼을 정의하는 오브젝트 파일만 골라서 실행 파일에 넣습니다. 그래서 거대한 정적 라이브러리를 링크해도 실제로 쓰는 부분만 포함되며, 이 “필요한 것만 꺼낸다”는 동작이 뒤에서 볼 라이브러리 순서 문제의 원인이기도 합니다.
라이브러리의 보안 패치가 나왔을 때 정적 링크한 프로그램은 모두 다시 빌드해서 배포해야 한다는 점이 운영 관점의 가장 큰 단점입니다. 또 Linux에서 glibc까지 -static으로 묶으면 getaddrinfo나 사용자 조회 함수가 실행 시 NSS 모듈을 동적으로 불러오려 해서 Using 'getaddrinfo' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking 경고가 나고, 다른 배포판에서 이상하게 동작할 수 있습니다. 완전한 정적 바이너리가 필요하다면 musl libc 기반(Alpine 등)으로 빌드하는 방법이 흔히 쓰입니다.
공유 라이브러리 만들어 동적 링크하기
# Linux/macOS
# 1. 위치 독립 코드 (PIC) 컴파일
# -fPIC: Position Independent Code
# 메모리 어디에 로드되든 실행 가능한 코드 생성
# 공유 라이브러리는 여러 프로세스가 다른 주소에 로드하므로 필수
g++ -fPIC -c lib.cpp -o lib.o
# 2. 동적 라이브러리 생성 (.so)
# -shared: 공유 라이브러리로 링크
g++ -shared lib.o -o libmylib.so
# 결과: libmylib.so (Shared Object)
# 3. 사용: 동적 라이브러리를 링크하여 실행 파일 생성
# -L.: 현재 디렉토리에서 라이브러리 검색
# -lmylib: libmylib.so 링크 (심볼 정보만 기록)
# -Wl,-rpath,.: 링커 옵션 전달
# -rpath: 런타임 라이브러리 검색 경로를 실행 파일에 포함
# .: 현재 디렉토리를 검색 경로로 추가
g++ main.cpp -L. -lmylib -Wl,-rpath,. -o myapp
# 4. 실행
./myapp
# 런타임에 동적 링커가 libmylib.so를 메모리에 로드
# 또는: 환경 변수로 라이브러리 경로 지정
LD_LIBRARY_PATH=. ./myapp
# LD_LIBRARY_PATH: 런타임 라이브러리 검색 경로
# Windows
# 1. DLL 생성 (Dynamic Link Library)
# -shared: 공유 라이브러리로 컴파일
g++ -shared lib.cpp -o mylib.dll
# 결과: mylib.dll (Windows 동적 라이브러리)
# 2. 사용: DLL을 링크하여 실행 파일 생성
# -L.: 현재 디렉토리에서 라이브러리 검색
# -lmylib: mylib.dll 링크 (lib 접두사 생략, .dll 자동 추가)
g++ main.cpp -L. -lmylib -o myapp.exe
# 실행 시 mylib.dll이 같은 디렉토리에 있어야 함
특징:
- ✅ 크기 작음 (라이브러리 공유)
- ✅ 메모리 효율 (여러 프로세스 공유)
- ✅ 업데이트 쉬움 (ABI가 호환되면 재컴파일 불필요)
- ❌ 런타임 로딩 필요
- ❌ 버전 관리 복잡
동적 링크 시 링커는 libmylib.so의 코드를 복사하지 않고 “실행할 때 이 라이브러리가 필요하다”는 기록(NEEDED 항목)과 심볼 참조만 남깁니다. 실제 연결은 프로그램이 시작될 때 동적 링커(ld-linux-x86-64.so.2)가 수행합니다. 함수 호출은 PLT/GOT라는 간접 테이블을 거치므로 정적 링크보다 호출 비용이 약간 있고, 기본적으로 함수가 처음 호출될 때 주소를 찾는 지연 바인딩(lazy binding)을 합니다.
“라이브러리만 교체하면 된다”는 장점에는 ABI 호환성이라는 조건이 붙습니다. 클래스에 멤버 변수를 하나 추가하거나 가상 함수 순서를 바꾸면 헤더를 보고 컴파일된 기존 실행 파일은 객체 크기와 vtable 배치를 옛날 기준으로 알고 있어서, 새 .so로 교체하는 순간 메모리를 잘못 읽고 크래시가 납니다. 소스 코드는 호환되는데 바이너리는 호환되지 않는 이 문제 때문에, 공개 라이브러리들은 libfoo.so.1처럼 soname에 주 버전을 넣고(-Wl,-soname,libmylib.so.1), ABI가 깨질 때만 주 버전을 올립니다. PIMPL 패턴으로 구현 세부를 숨기는 것도 ABI를 안정적으로 유지하기 위한 대표적인 기법입니다.
rpath 예제의 -Wl,-rpath,.에는 함정이 하나 있습니다. .는 실행 파일이 있는 디렉터리가 아니라 프로그램을 실행한 현재 작업 디렉터리로 해석됩니다. 그래서 ./myapp은 동작하지만 다른 디렉터리에서 /path/to/myapp으로 실행하면 라이브러리를 찾지 못합니다. 실행 파일 위치 기준으로 찾게 하려면 -Wl,-rpath,'$ORIGIN'을 쓰고, 셸이 $ORIGIN을 변수로 치환하지 않도록 작은따옴표로 감싸야 합니다. Windows의 DLL은 rpath 개념이 없고, 실행 파일과 같은 디렉터리나 PATH에서 찾는 것이 기본입니다. MinGW의 g++는 위 예처럼 DLL에 직접 링크할 수 있지만, MSVC에서는 DLL과 함께 만들어지는 임포트 라이브러리(.lib)에 링크해야 하고 내보낼 함수에 __declspec(dllexport)를 붙여야 합니다.
nm·ldd·objdump로 심볼 확인하기
nm으로 정의·미정의 심볼 보기
# nm: 심볼 테이블 확인 도구
# 실행 파일이나 오브젝트 파일의 심볼(함수, 변수) 목록 출력
# 심볼 목록: 모든 심볼 출력
nm myapp
# 정의된 심볼 (T: text section)
# -g: 외부 심볼만 (global)
# grep " T ": 코드 영역에 정의된 심볼만 필터링
nm -g myapp | grep " T "
# 미정의 심볼 (U: undefined)
# -u: 링킹이 필요한 외부 심볼만 출력
nm -u myapp
# 예시 출력:
# 0000000000401136 T main
# 0000000000401136: 메모리 주소
# T: Text section (코드 영역에 정의됨)
# main: 심볼 이름
# 0000000000401156 T _Z3addii (mangled name)
# _Z3addii: C++ 이름 맹글링 (add(int, int))
# U printf@@GLIBC_2.2.5
# U: Undefined (외부 라이브러리에서 제공)
# printf@@GLIBC_2.2.5: glibc 버전 2.2.5의 printf
_Z3addii 같은 맹글링된 이름은 nm -C(또는 c++filt _Z3addii)로 add(int, int)처럼 읽기 쉬운 형태로 바꿔 볼 수 있습니다. C++에서는 오버로딩 때문에 함수 이름만으로는 구분이 안 되어 매개변수 타입을 이름에 인코딩하는 것이고, 이 때문에 선언과 정의의 매개변수 타입이 조금만 달라도(int vs long) 서로 다른 심볼이 되어 undefined reference가 납니다. 그 밖의 심볼 종류로 D/B는 초기화된/초기화되지 않은 전역 변수, W는 약한 심볼(인라인 함수나 템플릿 인스턴스처럼 여러 오브젝트에 있어도 되는 심볼), 소문자 t는 static 함수처럼 파일 밖에서 보이지 않는 지역 심볼입니다. printf@@GLIBC_2.2.5의 버전 태그는 “이 바이너리는 최소 이 버전의 glibc가 필요하다”는 뜻이라, 최신 배포판에서 빌드한 프로그램이 오래된 서버에서 version 'GLIBC_2.34' not found 에러를 내는 이유가 여기에 있습니다.
ldd로 동적 라이브러리 의존성 보기
# ldd: 동적 라이브러리 의존성 확인 (Linux)
# 실행 파일이 필요로 하는 모든 공유 라이브러리 출력
$ ldd myapp
linux-vdso.so.1 (0x00007fff...)
# linux-vdso: 커널 시스템 콜 최적화 (가상 라이브러리)
libmylib.so => ./lib/libmylib.so (0x00007f...)
# libmylib.so: 우리가 만든 라이브러리
# => ./lib/libmylib.so: 실제 파일 경로
# (0x00007f...): 메모리 로드 주소
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
# libc.so.6: C 표준 라이브러리 (printf, malloc 등)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
# ld-linux: 동적 링커 (런타임에 라이브러리 로드)
objdump로 섹션과 심볼 테이블 보기
# objdump: 오브젝트 파일 분석 도구 (디버깅용)
# 디스어셈블: 기계어 → 어셈블리 변환
# -d: disassemble (코드 영역 디스어셈블)
objdump -d myapp
# 실행 파일의 기계어 코드를 어셈블리로 출력
# 함수별 어셈블리 코드 확인 가능
# 심볼 테이블: 모든 심볼 출력
# -t: symbol table
objdump -t myapp
# nm과 유사하지만 더 자세한 정보 제공
# 동적 심볼: 동적 링킹에 사용되는 심볼만
# -T: dynamic symbol table
objdump -T myapp
# 런타임에 로드되는 공유 라이브러리의 심볼 출력
undefined reference·라이브러리 순서·경로 문제
undefined reference to
# 에러 메시지
undefined reference to `add(int, int)'
# 원인: 함수 구현 누락
해결:
# 1. 오브젝트 파일 추가
g++ main.o missing.o -o myapp
# 2. 라이브러리 추가
g++ main.o -lmissing -o myapp
# 3. 소스 파일 추가
g++ main.cpp missing.cpp -o myapp
위 세 가지는 “정의를 빌드에 넣지 않은” 경우의 해결책이고, 실무에서는 정의가 분명히 있는데도 이 에러가 나는 경우가 더 골치 아픕니다. 제가 가장 자주 보는 원인은 다음과 같습니다.
- 템플릿 정의를
.cpp에 둔 경우. 템플릿은 사용하는 곳에서 인스턴스화되어야 하므로, 정의가 헤더에 없으면undefined reference to 'Stack<int>::push(int)'가 납니다. 정의를 헤더로 옮기거나,.cpp에서template class Stack<int>;로 명시적 인스턴스화를 해야 합니다. - C 라이브러리를 C++에서 호출하는 경우. C++ 컴파일러는
add를_Z3addii처럼 맹글링된 이름으로 찾지만 C로 컴파일된 라이브러리에는add라는 이름만 있습니다. C 헤더를extern "C" { ... }로 감싸야 합니다.nm출력에서 이름이 맹글링되어 있는지 확인하면 바로 구분됩니다. - 클래스의
static멤버 변수를 선언만 한 경우. 클래스 안의static int count;는 선언일 뿐이라.cpp에int Foo::count = 0;정의가 필요합니다. C++17부터는static inline int count = 0;으로 헤더에서 해결할 수 있습니다. - 가상 함수를 정의하지 않은 경우.
undefined reference to 'vtable for Foo'는 vtable 자체가 아니라, 클래스의 첫 번째 비인라인 가상 함수(보통 소멸자)의 정의가 빠졌다는 뜻입니다. vtable은 그 함수가 정의된 오브젝트 파일에 만들어지기 때문입니다. - 서로 다른 표준 라이브러리 ABI로 빌드된 라이브러리. GCC 5 이후
std::string의 ABI가 바뀌어, 오래된 라이브러리와 섞으면std::__cxx11::basic_string관련 undefined reference가 납니다._GLIBCXX_USE_CXX11_ABI설정을 맞추거나 라이브러리를 같은 컴파일러로 다시 빌드해야 합니다.
에러 메시지에 나온 심볼 이름을 복사해 nm -C libfoo.a | grep 'add'로 라이브러리에 실제로 그 이름이 있는지, 시그니처(int와 long, const 여부)가 정확히 같은지 비교하는 것이 가장 빠른 진단 방법입니다.
의존 관계와 반대 순서로 -l을 적음
# ❌ 순서 잘못 (libA가 libB에 의존)
g++ main.o -lB -lA -o myapp
# ✅ 의존성 순서 (의존하는 쪽이 앞)
g++ main.o -lA -lB -o myapp
GNU ld는 명령줄을 왼쪽에서 오른쪽으로 한 번만 훑습니다. 정적 라이브러리를 만나면 “지금까지 해결되지 않은 심볼”을 정의하는 오브젝트만 꺼내고 넘어가므로, -lB를 먼저 처리할 때는 아직 libA가 필요로 하는 심볼이 목록에 없어서 아무것도 꺼내지 않습니다. 그 뒤 -lA에서 B의 함수를 필요로 하는 코드를 꺼내지만, 이미 지나간 libB로 돌아가지 않아 undefined reference가 납니다. 같은 이유로 g++ -lmylib main.cpp처럼 라이브러리를 소스 파일보다 앞에 쓰는 것도 실패합니다.
두 라이브러리가 서로를 참조하는 순환 의존성이라면 -lA -lB -lA처럼 반복해서 쓰거나, -Wl,--start-group -lA -lB -Wl,--end-group으로 묶어 그 안을 반복 탐색하게 합니다. 참고로 macOS의 링커(ld64)와 LLVM의 lld는 이런 순서 제약이 없어서, macOS에서 잘 빌드되던 프로젝트가 Linux CI에서만 실패하는 일이 자주 생깁니다. CMake의 target_link_libraries로 의존성을 선언해 두면 CMake가 순서를 알아서 맞춰 주므로 이 문제를 크게 줄일 수 있습니다.
cannot find -l: 라이브러리 경로 누락
# ❌ 경로 없음
g++ main.o -lmylib -o myapp
# error: cannot find -lmylib
# ✅ 경로 지정
g++ main.o -L./lib -lmylib -o myapp
g++ main.o -L/usr/local/lib -lmylib -o myapp
-L은 링크할 때 라이브러리를 찾는 경로이고, -l은 이름 규칙에 따라 libmylib.so를 먼저, 없으면 libmylib.a를 찾습니다. 같은 디렉터리에 둘 다 있으면 동적 라이브러리가 선택되므로, 정적 링크를 원한다면 -Wl,-Bstatic -lmylib -Wl,-Bdynamic처럼 지정하거나 .a 파일의 전체 경로를 직접 넘깁니다. cannot find -lmylib는 파일이 없다는 뜻 외에도 32비트/64비트나 아키텍처가 다른 라이브러리만 있을 때 skipping incompatible libmylib.so when searching for -lmylib 경고와 함께 나오기도 합니다.
실행 시 공유 라이브러리를 못 찾을 때 rpath
# ❌ 실행 시 라이브러리 못 찾음
$ ./myapp
error while loading shared libraries: libmylib.so: cannot open shared object file
# ✅ rpath 설정 (실행 파일에 검색 경로 포함)
g++ main.o -L./lib -lmylib -Wl,-rpath,./lib -o myapp
# ✅ LD_LIBRARY_PATH 설정
export LD_LIBRARY_PATH=./lib:$LD_LIBRARY_PATH
./myapp
# ✅ 시스템 경로에 설치
sudo cp libmylib.so /usr/local/lib/
sudo ldconfig
세 방법은 적용 범위가 다릅니다. rpath는 실행 파일 안에 경로를 기록하므로 배포 단위와 함께 움직이고, LD_LIBRARY_PATH는 그 셸에서 실행하는 모든 프로그램에 영향을 주기 때문에 개발·테스트용 임시 방편에 가깝습니다(보안상 setuid 프로그램에서는 무시됩니다). 시스템 경로 설치는 여러 프로그램이 공유할 라이브러리에 적합하지만, /usr/local/lib이 /etc/ld.so.conf에 포함되어 있지 않은 배포판도 있고 ldconfig를 빼먹으면 캐시가 갱신되지 않아 여전히 못 찾습니다. 최근 링커는 -rpath를 DT_RUNPATH로 기록하는데, 이 경우 LD_LIBRARY_PATH가 rpath보다 우선한다는 점도 디버깅할 때 알아 두면 좋습니다. readelf -d myapp | grep -E 'RPATH|RUNPATH'로 실제로 무엇이 기록되었는지 확인할 수 있습니다.
-flto로 링크 타임 최적화
# LTO 활성화 (Link Time Optimization)
# -flto: 링크 타임에 전체 프로그램 최적화 수행
g++ -flto main.cpp util.cpp -o myapp
# 동작:
# 1. 컴파일 시 중간 표현(IR, Intermediate Representation) 생성
# 2. 링크 시 모든 파일의 IR을 함께 분석
# 3. 함수 인라이닝, 데드 코드 제거 등 전역 최적화
# 장점: 파일 경계를 넘는 인라이닝·데드 코드 제거
# 단점: 빌드 시간·메모리 증가
# 최적화 레벨 조합
# -flto: 링크 타임 최적화
# -O3: 최고 수준 컴파일 타임 최적화
g++ -flto -O3 main.cpp util.cpp -o myapp
# -O3와 -flto를 함께 사용하면 최대 성능 달성
# 릴리스 빌드에 권장
효과:
- 전체 프로그램 최적화
- 인라인 확장 (파일 경계 넘어)
- 데드 코드 제거
- 코드에 따라 실행 성능 향상 (효과는 프로젝트마다 다르므로 측정 필요) 단점:
- 컴파일 시간 증가
- 메모리 사용 증가
- 디버깅 어려움
LTO가 필요한 이유는 일반 빌드에서 컴파일러가 한 번에 파일 하나만 보기 때문입니다. main.cpp를 컴파일할 때는 add의 본문을 모르므로 add(10, 20)을 인라인하거나 30으로 계산해 둘 수 없습니다. -flto를 주면 .o 파일에 기계어 대신(또는 함께) 컴파일러의 중간 표현(GCC는 GIMPLE, Clang은 LLVM 비트코드)이 담기고, 링크 단계에서 전체 프로그램을 보며 최적화합니다. 효과는 작은 함수가 여러 파일에 흩어진 코드에서 크고, 이미 핫 경로가 헤더에 인라인되어 있는 코드에서는 거의 없으므로 반드시 측정해야 합니다.
실제로 적용할 때 흔히 부딪히는 문제도 있습니다. 컴파일 단계와 링크 단계 모두에 -flto를 넘겨야 하고, 정적 라이브러리를 만들 때 일반 ar 대신 gcc-ar(Clang은 llvm-ar)를 써야 LTO 오브젝트의 심볼 인덱스가 제대로 만들어집니다. 그렇지 않으면 멀쩡한 함수가 undefined reference로 나옵니다. GCC와 Clang의 LTO 오브젝트는 서로 호환되지 않고, 컴파일러 버전이 달라도 섞을 수 없습니다. 링크 시간이 너무 길어진다면 GCC의 -flto=auto(병렬 LTO)나 Clang의 -flto=thin(ThinLTO)이 대부분의 이점을 유지하면서 빌드 시간을 크게 줄여 줍니다. CMake에서는 set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)으로 컴파일러에 맞는 옵션을 자동으로 붙일 수 있습니다.
Makefile과 CMake로 라이브러리 빌드 구성
Makefile
# Makefile
CXX = g++
CXXFLAGS = -std=c++17 -Wall -g
LDFLAGS = -L./lib
LDLIBS = -lmylib
OBJS = main.o util.o
myapp: $(OBJS)
$(CXX) $(OBJS) $(LDFLAGS) $(LDLIBS) -o myapp
main.o: main.cpp util.h
$(CXX) $(CXXFLAGS) -c main.cpp
util.o: util.cpp util.h
$(CXX) $(CXXFLAGS) -c util.cpp
clean:
rm -f $(OBJS) myapp
.PHONY: clean
링크 규칙에서 $(OBJS)가 $(LDLIBS)보다 앞에 있는 것이 중요합니다. 앞에서 본 순서 규칙 때문에 라이브러리를 오브젝트 파일보다 먼저 쓰면 undefined reference가 납니다. Make의 관례상 -L 같은 링커 옵션은 LDFLAGS에, -l 라이브러리 목록은 LDLIBS에 두며, 내장 규칙도 이 순서로 명령을 만듭니다. main.o: main.cpp util.h처럼 헤더 의존성을 손으로 적는 방식은 헤더가 늘어나면 빠뜨리기 쉬우므로, 실무에서는 -MMD -MP 옵션으로 컴파일러가 의존성 파일(.d)을 생성하게 하고 -include $(OBJS:.o=.d)로 불러오는 방식을 많이 씁니다.
CMake
# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(MyApp)
set(CMAKE_CXX_STANDARD 17)
# 정적 라이브러리
add_library(mylib STATIC lib.cpp)
# 실행 파일
add_executable(myapp main.cpp util.cpp)
# 링크
target_link_libraries(myapp mylib)
# 동적 라이브러리
add_library(mylib_shared SHARED lib.cpp)
set_target_properties(mylib_shared PROPERTIES OUTPUT_NAME mylib)
# rpath 설정
set_target_properties(myapp PROPERTIES
INSTALL_RPATH "${CMAKE_INSTALL_PREFIX}/lib"
BUILD_WITH_INSTALL_RPATH TRUE
)
CMake를 쓰면 이 글에서 다룬 대부분의 세부 사항을 직접 신경 쓰지 않아도 됩니다. target_link_libraries(myapp mylib)는 링크 순서, -L 경로, 공유 라이브러리의 -fPIC를 알아서 처리하고, 빌드 트리에서 실행할 때 필요한 rpath도 자동으로 넣어 줍니다. 다만 BUILD_WITH_INSTALL_RPATH TRUE로 설정하면 빌드 트리에서도 설치 경로를 rpath로 쓰므로, 설치하기 전에 빌드 디렉터리에서 바로 실행하면 라이브러리를 못 찾을 수 있습니다. 배포 패키지를 만든다면 설치 경로를 절대 경로로 고정하기보다 INSTALL_RPATH "$ORIGIN/../lib"처럼 실행 파일 기준 상대 경로를 쓰는 편이 옮기기 쉽습니다. 실무에서는 target_link_libraries(myapp PRIVATE mylib)처럼 PRIVATE/PUBLIC을 명시해 의존성이 어디까지 전파될지 정하는 것도 권장됩니다.
링킹 정리
핵심 요약
- 링킹: 오브젝트 파일을 실행 파일로 결합
- 정적 링킹: 라이브러리 포함 (크기 큼, 빠름)
- 동적 링킹: 런타임 로드 (크기 작음, 공유)
- 심볼 해석: 함수 호출을 정의와 연결
- LTO: 전체 프로그램 최적화
정적 링킹과 동적 링킹 비교
| 특징 | 정적 링킹 | 동적 링킹 |
|---|---|---|
| 파일 | .a (Linux), .lib (Windows) | .so (Linux), .dll (Windows) |
| 크기 | 큼 (라이브러리 포함) | 작음 (참조만) |
| 속도 | 빠름 (로딩 없음) | 약간 느림 (로딩) |
| 메모리 | 중복 가능 | 공유 가능 |
| 업데이트 | 재링크 필요 | 라이브러리만 교체 |
| 배포 | 간단 (단일 파일) | 복잡 (의존성) |
링킹 방식 선택과 에러 메시지별 해결
링킹 전략:
- 개발: 동적 링킹 (빠른 빌드)
- 배포: 정적 링킹 (간단한 배포)
- 공유 라이브러리: 동적 링킹
- 임베디드: 정적 링킹 에러 해결:
undefined reference: 오브젝트/라이브러리 추가cannot find -l:-L경로 추가cannot open shared object: rpath 또는LD_LIBRARY_PATH설정multiple definition: 중복 정의 제거
자주 묻는 질문 (FAQ)
Q. 링크는 성공했는데 실행 시 cannot open shared object file 에러가 나면 어떻게 하나요?
A. -L은 링크할 때만 쓰이는 경로라서, 실행 시 동적 로더는 이 경로를 모르고 시스템 라이브러리 경로, LD_LIBRARY_PATH, 바이너리에 기록된 RPATH/RUNPATH만 검색합니다. ldd ./app으로 not found로 표시되는 라이브러리를 먼저 확인합니다. 배포용이라면 본문의 rpath 설정처럼 -Wl,-rpath,'$ORIGIN/../lib'로 실행 파일 기준 상대 경로를 기록하거나, 시스템 경로에 설치한 뒤 ldconfig를 실행하는 방법이 안정적입니다.
같이 보면 좋은 글
- C++ 컴파일 과정 4단계: 전처리·컴파일·어셈블·링킹과 undefined reference가 나는 이유
- C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]
- C++20 std::span 심화: 정적·동적 extent, subspan, 읽기 전용 뷰
- C++ Name Mangling
- C++ Makefile
- C++ 정적 초기화 순서