C++ ABI 호환성 완벽 가이드 | PIMPL·C 인터페이스·버전 관리·프로덕션 패턴 [#55-4]
이 글의 핵심
라이브러리 업데이트 후 크래시가 나요. C++ ABI(Application Binary Interface) 호환성: 문제 시나리오, PIMPL·C 인터페이스·버전 관리, 자주 발생하는 에러, 검증 도구, 프로덕션 패턴까지 실전 코드로 정리합니다.
들어가며: 라이브러리 업데이트 후 크래시가 나요
”private 멤버 하나 추가했을 뿐인데 왜 기존 앱이 죽어요?”
ABI(Application Binary Interface)는 컴파일된 바이너리가 서로 맞닿을 때의 규약입니다. 구조체 크기·멤버 오프셋·함수 호출 방식·vtable 레이아웃·name mangling 등이 포함됩니다. 비유하면 “두 나라가 국경에서 만날 때, 서로의 언어·규칙·서류 형식이 맞아야 통과할 수 있는 것”과 같습니다. C++는 표준에서 ABI를 정의하지 않아 컴파일러·플랫폼·표준 라이브러리 버전마다 ABI가 달라질 수 있습니다.
문제의 핵심:
- 라이브러리 v1.0을 사용하는 앱이 빌드된 상태에서, 라이브러리만 v1.1로 업데이트
- v1.1에서
Widget클래스에private멤버std::vector<int> cache_를 추가 - 앱은 재컴파일 없이 이전 헤더로 컴파일된 바이너리를 그대로 사용
Widget의 메모리 레이아웃이 바뀌어 오프셋·크기 불일치 → 세그멘테이션 폴트 또는 데이터 손상
flowchart TB
subgraph problem[ABI 깨짐 시나리오]
P1[앱: v1.0 헤더로 빌드] --> P2[Widget: 16바이트 가정]
P3[라이브러리: v1.1로 업데이트] --> P4[Widget: 40바이트 실제]
P2 --> P5[오프셋 불일치]
P4 --> P5
P5 --> P6[크래시/데이터 손상]
end
subgraph solution[ABI 호환 해결]
S1[PIMPL/C 인터페이스] --> S2[구조체 크기 고정]
S2 --> S3[버전 관리 전략]
S3 --> S4[안전한 업데이트]
end
이 글에서 다루는 것:
- 문제 시나리오: ABI가 깨지는 실제 상황 8가지
- ABI 기본 개념: 구조체 레이아웃·vtable·name mangling
- 완전한 ABI 호환 예제: PIMPL·C 인터페이스·버전 관리
- 자주 발생하는 에러와 해결법
- 검증 도구: abi-compliance-checker, libabigail, CI 통합
- 프로덕션 패턴: ABI 검증·배포 체크리스트
요구 환경: C++17 이상
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 문제 시나리오: ABI가 깨지는 순간
- ABI 기본 개념
- 핵심 기법: PIMPL과 C 인터페이스
- 완전한 ABI 호환 예제
- 자주 발생하는 에러와 해결법
- 버전 관리 전략
- ABI 검증 도구
- 프로덕션 패턴
- 정리
1. 문제 시나리오: ABI가 깨지는 순간
시나리오 1: private 멤버 추가로 크래시
문제: Document 클래스를 공개 API로 배포한 뒤, 내부 캐시(std::unordered_map<std::string, size_t>)를 추가했습니다. document.h를 include하는 사용자 코드가 전부 재컴파일되지 않은 상태에서, 이전에 빌드된 라이브러리와 새 라이브러리를 링크하면 ABI 불일치로 크래시가 발생합니다.
원인: Document의 크기와 멤버 오프셋이 바뀌었는데, 앱은 이전 레이아웃을 가정한 상태입니다. getParagraph() 호출 시 this 포인터 오프셋이 잘못되어 잘못된 메모리를 읽습니다.
해결: PIMPL로 구현을 숨기면, Impl에 멤버를 추가해도 공개 클래스의 크기는 포인터 하나(8바이트)로 고정되어 ABI가 유지됩니다.
시나리오 2: 가상 함수 순서 변경으로 vtable 깨짐
문제: 호스트 앱과 플러그인이 서로 다른 시점에 빌드됩니다. 호스트가 PluginInterface의 가상 함수를 중간에 추가했는데, 기존 플러그인들은 vtable 레이아웃이 바뀌어 process() 호출 시 잘못된 함수가 실행되거나 크래시가 발생합니다.
원인: vtable은 가상 함수 포인터 배열입니다. process()가 인덱스 0이었는데, init()을 앞에 추가하면 process()가 인덱스 1로 밀립니다. 구버전 플러그인은 process를 0번으로 호출하는데, 실제로는 init이 0번에 있어 잘못된 동작이 발생합니다.
해결: 가상 함수는 끝에만 추가합니다. 새 기능은 버전 번호로 분기해 version >= 2일 때만 init()을 호출합니다.
시나리오 3: std::string을 API 경계로 넘길 때
문제: 플러그인 API에서 std::string을 넘깁니다. 호스트는 GCC+libstdc++로, 플러그인은 Clang+libc++로 빌드했습니다. std::string의 내부 레이아웃이 컴파일러/표준 라이브러리마다 다르므로 세그멘테이션 폴트가 발생합니다.
해결: C ABI로 경계를 만들고, std::string 대신 const char*와 size_t를 사용합니다. C ABI는 컴파일러·플랫폼 간에 안정적입니다.
시나리오 4: 컴파일러 버전 차이
문제: GCC 9로 빌드한 라이브러리와 GCC 11로 빌드한 앱을 링크했습니다. std::string·std::vector의 내부 구현이 GCC 버전마다 달라 ABI가 호환되지 않습니다.
해결: 동일 툴체인으로 빌드하거나, C 인터페이스로 경계를 둡니다. 배포 시 “GCC 9 이상”처럼 최소 버전을 명시합니다.
시나리오 5: 인라인 함수 변경
문제: 헤더에 inline int getVersion() { return 0; }를 두었습니다. 라이브러리에서 return 1로 바꿨는데, 앱이 재컴파일되지 않습니다. 앱은 getVersion() 호출 시 인라인된 0을 사용하고, 라이브러리는 1을 기대합니다. 동작 불일치가 발생합니다.
해결: 공개 API의 인라인 함수는 ABI의 일부입니다. 변경 시 사용자 재컴파일이 필요합니다. 비인라인으로 선언하고 .cpp에 정의하면 라이브러리만 교체해도 됩니다.
시나리오 6: 템플릿 인스턴스화 불일치
문제: template <typename T> void process(T x)를 헤더에 정의했습니다. 라이브러리와 앱이 같은 헤더를 쓰지만, 다른 컴파일 옵션(-O2 vs -O0, 다른 -D 매크로)으로 인스턴스화하면 기대와 다른 동작이 나올 수 있습니다.
해결: 템플릿은 헤더 전용이므로 ABI 경계에 두지 않습니다. 라이브러리 공개 API는 비템플릿·비인라인으로 최소화합니다.
시나리오 7: DLL/SO 업데이트 후 심볼 누락
문제: 라이브러리 v1.1에서 deprecated_func()를 제거하고 new_func()로 교체했습니다. 기존 앱이 deprecated_func()를 호출하는데, 새 라이브러리에는 해당 심볼이 없어 런타임 로드 실패 또는 undefined symbol 에러가 발생합니다.
해결: 심볼 버전 관리로 구버전 심볼을 유지하거나, MAJOR 버전을 올려 사용자에게 재컴파일을 요구합니다. 하위 호환 시 deprecated_func()를 new_func()로 위임하는 래퍼를 두는 방법도 있습니다.
시나리오 8: 정렬·패딩 차이
문제: 32비트와 64비트 빌드를 혼용했거나, #pragma pack 설정이 다릅니다. struct Config { int a; bool b; }의 크기가 플랫폼마다 달라 데이터가 잘못 해석됩니다.
해결: 고정 크기 타입(int32_t, uint64_t) 사용, 명시적 패딩 또는 직렬화 포맷으로 경계를 넘깁니다. C 인터페이스에서는 char 배열로 고정 크기 버퍼를 사용합니다.
ABI 호환성 의사결정 흐름
flowchart TB
subgraph input[입력]
N[라이브러리 배포?]
Q[플러그인 시스템?]
M[재컴파일 없이 업데이트?]
end
subgraph decision[의사결정]
D1{API 경계가 있음?}
D2{Different 컴파일러?}
D3{std:: 타입 노출?}
end
subgraph result[선택]
R1[PIMPL 적용]
R2[C 인터페이스]
R3[버전 관리 전략]
R4[ABI 검증 CI]
end
N --> D1
Q --> D2
M --> D3
D1 --> R1
D2 --> R2
D3 --> R3
D1 --> R4
2. ABI 기본 개념
구조체 레이아웃·크기·정렬
C++ 구조체/클래스의 메모리 레이아웃은 멤버 선언 순서와 정렬 규칙에 따라 결정됩니다. 멤버를 추가·삭제·순서 변경하면 오프셋이 바뀌어 이전 바이너리와 호환되지 않습니다.
// widget_v1.h — v1.0
struct Widget {
int id;
double value;
};
// sizeof(Widget) = 16 (정렬으로 4+4 패딩 + 8)
// widget_v2.h — v1.1 (ABI 깨짐!)
struct Widget {
int id;
std::vector<int> cache_; // 추가 — 크기·오프셋 변경
double value;
};
// sizeof(Widget) = 32 이상 (vector는 24바이트 이상)
// app.cpp — v1.0 헤더로 빌드된 앱
Widget w;
w.value = 3.14; // v1.0: offset 8, v1.1: offset 32 — 잘못된 메모리 쓰기!
설명: v1.0 앱은 value가 오프셋 8에 있다고 가정합니다. v1.1에서는 cache_가 오프셋 8을 차지하고 value는 32 근처로 밀립니다. 앱이 w.value에 쓰면 이전 메모리를 덮어 쓰게 되어 크래시나 데이터 손상이 발생합니다.
vtable과 가상 함수
가상 함수가 있는 클래스는 vtable(가상 함수 테이블) 포인터를 멤버로 가집니다. vtable은 함수 포인터 배열이고, 인덱스는 선언 순서에 따라 결정됩니다.
// plugin_v1.h
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual int process() = 0;
};
// vtable: [0] 소멸자, [1] process
// plugin_v2.h — ABI 깨짐!
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual void init(); // 새로 추가 — process 인덱스 밀림
virtual int process() = 0;
};
// vtable: [0] 소멸자, [1] init, [2] process
구버전 플러그인은 process를 인덱스 1로 호출하는데, v2에서는 인덱스 1이 init입니다. 잘못된 함수가 실행됩니다.
Name Mangling
C++는 오버로딩을 지원하므로, 컴파일러가 함수 이름에 타입 정보를 붙입니다 (name mangling). GCC와 MSVC의 mangling 규칙이 다르고, 같은 GCC라도 버전마다 다를 수 있습니다. extern “C”로 내보내면 mangling이 제거되어 심볼 이름이 고정됩니다.
// C++ name mangling — 컴파일러마다 다름
void process(int x); // GCC: _Z7processi
void process(double x); // GCC: _Z7processd
// C 링크 — 고정
extern "C" void process(int x); // process
ABI에 영향을 주는 요소들
| 요소 | ABI 영향 | 비고 |
|---|---|---|
| 구조체 크기·멤버 오프셋 | 높음 | 멤버 추가/삭제/순서 변경 시 깨짐 |
| 가상 함수 순서 | 높음 | 중간 삽입 시 vtable 인덱스 밀림 |
| 인라인 함수 본문 | 높음 | 호출처에 인라인됨 |
| 템플릿 인스턴스화 | 높음 | 컴파일 옵션에 따라 다름 |
std:: 타입 | 높음 | libstdc++/libc++ 버전마다 다름 |
extern "C" 함수 | 낮음 | C ABI는 안정적 |
| 포인터 크기 | 고정 | 64비트에서 8바이트 |
3. 핵심 기법: PIMPL과 C 인터페이스
PIMPL: 구현을 포인터 뒤에 숨기기
PIMPL(Pointer to Implementation)은 공개 클래스가 구현체를 가리키는 포인터만 갖고, 구현체 정의는 .cpp에만 두는 패턴입니다. 공개 클래스의 크기는 포인터 하나로 고정되므로, Impl 쪽에서 멤버를 바꿔도 ABI가 유지됩니다.
flowchart LR
subgraph stable[PIMPL — ABI 안정]
A1[Widget] --> A2[포인터 8B]
A2 --> A3[Impl 변경 OK]
end
subgraph broken[일반 클래스 — ABI 깨짐]
B1[Widget] --> B2[멤버 노출]
B2 --> B3[레이아웃 변경 시 크래시]
end
// widget.h — 공개 헤더 (ABI 고정)
#pragma once
#include <memory>
#include <string>
class WidgetImpl; // 전방 선언만
class Widget {
public:
Widget();
~Widget();
Widget(const Widget& other);
Widget& operator=(const Widget& other);
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
void setTitle(const std::string& title);
[[nodiscard]] std::string getTitle() const;
private:
std::unique_ptr<WidgetImpl> pImpl_; // 크기 고정: 8바이트
};
// widget.cpp — 구현 (내부 구조 변경 가능)
#include "widget.h"
#include <unordered_map> // 공개 헤더에 노출 안 함
class WidgetImpl {
public:
std::string title;
std::unordered_map<std::string, size_t> cache; // v2에서 추가해도 ABI 유지
};
Widget::Widget() : pImpl_(std::make_unique<WidgetImpl>()) {}
Widget::~Widget() = default;
Widget::Widget(const Widget& other)
: pImpl_(std::make_unique<WidgetImpl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
*pImpl_ = *other.pImpl_;
}
return *this;
}
void Widget::setTitle(const std::string& title) { pImpl_->title = title; }
std::string Widget::getTitle() const { return pImpl_->title; }
C 인터페이스: 최대 ABI 안정성
C++ name mangling은 컴파일러마다 다릅니다. extern “C”로 내보내면 심볼 이름이 고정되어, 다른 컴파일러로 빌드한 바이너리와도 링크할 수 있습니다. std::string·std::vector 같은 C++ 타입은 API 경계에 두지 않고, const char·void·size_t를 사용합니다.
// plugin_api.h — C 인터페이스 (ABI 최대 안정)
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
typedef struct PluginHandle* PluginHandlePtr;
PluginHandlePtr plugin_create(const char* config);
void plugin_destroy(PluginHandlePtr handle);
int plugin_process(PluginHandlePtr handle, const void* input, size_t input_size,
void* output, size_t output_size);
#ifdef __cplusplus
}
#endif
// plugin_impl.cpp — C++ 래퍼가 C API 구현
#include "plugin_api.h"
#include <string>
#include <cstring>
struct PluginImpl {
std::string config;
// C++ 타입은 내부에서만 사용
};
extern "C" {
PluginHandlePtr plugin_create(const char* config) {
auto* p = new PluginImpl;
if (config) p->config = config;
return p;
}
void plugin_destroy(PluginHandlePtr handle) {
delete static_cast<PluginImpl*>(handle);
}
int plugin_process(PluginHandlePtr handle, const void* input, size_t input_size,
void* output, size_t output_size) {
auto* p = static_cast<PluginImpl*>(handle);
// ... 처리 ...
return 0;
}
}
4. 완전한 ABI 호환 예제
예제 1: PIMPL — 완전한 위젯 라이브러리
// widget.h — 공개 헤더 (ABI 고정)
#pragma once
#include <memory>
#include <string>
#include <vector>
class WidgetImpl;
class Widget {
public:
Widget();
~Widget();
Widget(const Widget& other);
Widget& operator=(const Widget& other);
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
void setTitle(const std::string& title);
[[nodiscard]] std::string getTitle() const;
void addTag(const std::string& tag);
[[nodiscard]] std::vector<std::string> getTags() const;
private:
std::unique_ptr<WidgetImpl> pImpl_;
};
// widget.cpp — 구현 (내부 구조 변경 가능)
#include "widget.h"
#include <unordered_map>
#include <algorithm>
class WidgetImpl {
public:
std::string title;
std::vector<std::string> tags;
std::unordered_map<std::string, size_t> cache; // v2에서 추가해도 ABI 유지
};
Widget::Widget() : pImpl_(std::make_unique<WidgetImpl>()) {}
Widget::~Widget() = default;
Widget::Widget(const Widget& other)
: pImpl_(std::make_unique<WidgetImpl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
*pImpl_ = *other.pImpl_;
}
return *this;
}
void Widget::setTitle(const std::string& title) { pImpl_->title = title; }
std::string Widget::getTitle() const { return pImpl_->title; }
void Widget::addTag(const std::string& tag) { pImpl_->tags.push_back(tag); }
std::vector<std::string> Widget::getTags() const { return pImpl_->tags; }
예제 2: C 인터페이스 — 플러그인 시스템
// plugin_interface.h — 공개 헤더 (ABI 고정)
#pragma once
#include <cstdint>
#include <cstddef>
#ifdef __cplusplus
extern "C" {
#endif
#define PLUGIN_ABI_VERSION 1
struct PluginAPI {
uint32_t version;
void* (*create)(const char* config);
void (*destroy)(void* handle);
int (*process)(void* handle, const void* input, size_t in_len,
void* output, size_t out_len);
};
// 플러그인은 이 심볼을 내보냄
#define PLUGIN_EXPORT extern "C" __attribute__((visibility("default")))
#ifdef __cplusplus
}
#endif
// my_plugin.cpp — 플러그인 구현
#include "plugin_interface.h"
#include <cstring>
#include <string>
struct PluginState {
std::string config;
};
PLUGIN_EXPORT PluginAPI plugin_api = {
.version = PLUGIN_ABI_VERSION,
.create = -> void* {
auto* p = new PluginState;
if (config) p->config = config;
return p;
},
.destroy = {
delete static_cast<PluginState*>(handle);
},
.process = -> int {
(void)handle;
(void)input;
(void)in_len;
(void)output;
(void)out_len;
return 0;
}
};
예제 3: 버전 관리 — 확장 구조체
// config_v1.h
struct PluginConfig {
void* reserved; // 확장용 — 나중에 추가
};
// config_v2.h — ABI 호환
struct PluginConfig {
void* reserved;
int flags; // reserved 다음에 추가
const char* log; // 추가
};
// 사용 시
void init_plugin(PluginConfig* cfg) {
if (cfg->reserved != nullptr) {
// v2: 확장 필드 사용
auto* ext = static_cast<PluginConfigV2*>(cfg->reserved);
ext->flags = 0;
ext->log = nullptr;
}
}
예제 4: 가상 함수 — 끝에만 추가
// plugin_interface_v1.h
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual int process(const void* input, void* output) = 0;
};
// plugin_interface_v2.h — ABI 호환
class IPlugin {
public:
virtual ~IPlugin() = default;
virtual int process(const void* input, void* output) = 0;
virtual void init(); // 끝에 추가 — 기존 인덱스 유지
};
// 호스트에서 사용
void use_plugin(IPlugin* p, uint32_t version) {
if (version >= 2) {
p->init(); // v2 플러그인만
}
p->process(input, output);
}
예제 5: C 인터페이스 + C++ 래퍼
C API(plugin_create 등)로 ABI 경계를 두고, C++ 래퍼 클래스(Plugin)로 std::vector·std::string을 내부에서만 사용합니다. 사용자 코드는 C++ 래퍼가 C API를 호출합니다.
5. 자주 발생하는 에러와 해결법
에러 1: “invalid application of ‘sizeof’ to incomplete type”
원인: unique_ptr<Impl>의 소멸자가 Impl의 완전한 타입을 요구하는데, 소멸자 정의가 Impl이 보이는 .cpp에 없고 헤더에 = default로 두었을 때 발생합니다.
// ❌ 잘못된 예 — widget.h
class Widget {
~Widget() = default; // Impl이 불완전 타입일 때 에러
std::unique_ptr<WidgetImpl> pImpl_;
};
// ✅ 올바른 예 — widget.h
class Widget {
~Widget(); // 선언만
std::unique_ptr<WidgetImpl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include "widget_impl.h"
Widget::~Widget() = default; // Impl이 완전한 타입인 곳에서 정의
에러 2: 공개 헤더에 STL 컨테이너 포함으로 ABI 불안정
원인: std::vector의 내부 레이아웃이 컴파일러/표준 라이브러리 버전마다 다를 수 있습니다. 공개 API에 std::vector를 직접 노출하면 ABI가 깨질 수 있습니다.
// ❌ ABI 위험 — 라이브러리 공개 API
class Document {
std::vector<std::string> paragraphs_; // STL 레이아웃 변경 시 ABI 깨짐
};
// ✅ ABI 안전 — PIMPL로 숨기면 공개 클래스 크기 고정
class Document {
std::unique_ptr<DocumentImpl> pImpl_; // 포인터 크기만 노출
};
에러 3: 가상 함수 중간 추가로 vtable 깨짐
원인: 공개 인터페이스 클래스에 새 가상 함수를 끝에 추가하지 않고 중간에 넣으면, 기존 vtable 인덱스가 밀려 ABI가 깨집니다.
// ❌ 잘못된 예 — process 앞에 새 함수 추가
class IPlugin {
virtual void init(); // 새로 추가 — process 인덱스 밀림
virtual int process(); // 기존 플러그인은 process가 0번이었는데 1번으로 바뀜
};
// ✅ 올바른 예 — 끝에만 추가
class IPlugin {
virtual int process();
virtual void init(); // 끝에 추가 — 기존 인덱스 유지
};
에러 4: std::string을 API 경계로 넘김
원인: std::string을 플러그인/라이브러리 경계로 넘기면, libstdc++와 libc++의 내부 레이아웃 차이로 크래시가 발생합니다.
// ❌ 잘못된 예
extern "C" void process(std::string input); // C 링크인데 C++ 타입
// 플러그인 API
class Plugin {
virtual std::string getResult(); // 호스트·플러그인 std::string 불일치
};
// ✅ 올바른 예 — C 타입 사용
extern "C" void process(const char* input, size_t len);
// 또는
extern "C" void process(const char* input); // null-terminated
에러 5: 복사 대입 시 self-assignment 미처리
원인: *pImpl_ = *other.pImpl_에서 this == &other이면 pImpl_를 먼저 삭제한 뒤 other.pImpl_를 참조하려 할 때 이미 삭제된 메모리를 참조할 수 있습니다.
// ❌ 잘못된 예
Widget& Widget::operator=(const Widget& other) {
*pImpl_ = *other.pImpl_; // this == &other이면 위험
return *this;
}
// ✅ 올바른 예
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
*pImpl_ = *other.pImpl_;
}
return *this;
}
에러 6: noexcept 이동 생성자 누락
원인: std::vector 등이 재할당 시 요소를 이동할 때 noexcept 이동 생성자를 선호합니다. noexcept가 없으면 복사로 fallback되어 성능이 떨어질 수 있습니다.
// ❌ 성능 저하 — vector 재할당 시 복사 사용
Widget(Widget&&) = default; // noexcept 없음
// ✅ 권장 — vector 재할당 시 이동 사용
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
에러 7: 컴파일러/표준 라이브러리 버전 혼용
원인: GCC 9로 빌드한 .so와 GCC 11로 빌드한 앱을 링크했습니다. std::string·std::vector의 ABI가 호환되지 않습니다.
# ❌ 위험한 조합
# libfoo.so: g++-9로 빌드
# app: g++-11로 빌드
# → 링크는 되지만 런타임 크래시 가능
# ✅ 동일 툴체인
# 또는 C 인터페이스로 경계를 두어 std:: 타입을 API에 노출하지 않음
에러 8: 인라인 함수 본문 변경
원인: 헤더의 inline int getVersion() { return 0; }를 return 1로 바꿨는데, 앱이 재컴파일되지 않습니다. 앱은 인라인된 0을 사용합니다.
// ❌ 인라인 변경 시 사용자 재컴파일 필요
inline int getVersion() { return 1; } // 0에서 1로 변경
// ✅ 비인라인 — 라이브러리만 교체해도 됨
int getVersion(); // .cpp에 정의
에러 9: 심볼 visibility 누락 (macOS/Linux)
원인: macOS는 기본적으로 모든 심볼이 숨겨져 있습니다. DLL을 내보내려면 __attribute__((visibility("default")))가 필요합니다.
// ❌ 잘못된 예 — macOS에서 심볼 미노출
extern "C" void plugin_create();
// ✅ 올바른 예
#if defined(__APPLE__) || defined(__linux__)
#define PLUGIN_EXPORT __attribute__((visibility("default")))
#elif defined(_WIN32)
#define PLUGIN_EXPORT __declspec(dllexport)
#else
#define PLUGIN_EXPORT
#endif
extern "C" PLUGIN_EXPORT void plugin_create();
6. 버전 관리 전략
시맨틱 버전과 ABI
- MAJOR: ABI 호환 깨짐 (공개 클래스 레이아웃 변경, 가상 함수 순서 변경) → 사용자 재컴파일 필수
- MINOR: 기능 추가 (새 함수 끝에 추가, PIMPL 내부만 변경) → 재컴파일 없이 사용 가능
- PATCH: 버그 수정 (구현만 변경, ABI 동일) → 재컴파일 없이 사용 가능
전략 1: 가상 함수는 끝에만 추가
// v1.0
class IPlugin {
virtual int process();
};
// v1.1 — ABI 호환
class IPlugin {
virtual int process();
virtual void init(); // 끝에 추가
};
전략 2: 버전 번호로 분기
// plugin_interface.h
struct PluginAPI {
uint32_t version; // 1, 2, 3...
void* (*create)(const char* config);
void (*destroy)(void* handle);
int (*process)(void* handle, const void* input, void* output);
// v2: void (*init)(void* handle); // version >= 2일 때만 사용
};
// 호스트에서
if (api->version >= 2 && api->init) {
api->init(handle);
}
전략 3: 확장 구조체
// v1
struct PluginConfig {
void* reserved;
};
// v2 — ABI 호환
struct PluginConfig {
void* reserved;
int flags;
const char* log;
};
전략 4: 심볼 버전 관리 (Linux)
# plugin.ver — 심볼 버전 스크립트
PLUGIN_1.0 {
global:
plugin_create;
plugin_destroy;
plugin_process;
local: *;
};
PLUGIN_1.1 {
global:
plugin_init; # 새 함수
} PLUGIN_1.0;
# 링크 시
g++ -shared -Wl,--version-script=plugin.ver -o libplugin.so plugin.cpp
전략 5: ABI 네임스페이스 (GCC/Clang)
// _GLIBCXX_USE_CXX11_ABI 매크로로 std::string ABI 선택
#define _GLIBCXX_USE_CXX11_ABI 0 // 구 ABI (GCC 5 이전)
#define _GLIBCXX_USE_CXX11_ABI 1 // 신 ABI (기본)
// 주의: 모든 번역 단위에서 동일해야 함
7. ABI 검증 도구
도구 비교
| 도구 | 용도 | 플랫폼 |
|---|---|---|
| abi-compliance-checker | 라이브러리 ABI 변경 감지 | Linux |
| abi-dumper | .so에서 ABI 추출 | Linux |
| libabigail | ABI 비교·diff | Linux |
| dumpbin | PE/COFF 심볼 확인 | Windows |
| nm | 오브젝트 심볼 목록 | Unix |
abi-compliance-checker 사용법
# 1. 이전 버전 ABI 스냅샷 저장
abi-dumper libplugin.so -o old.xml -l libplugin
# 2. 코드 변경 후 새 빌드
# (빌드 수행)
# 3. 새 버전 ABI 스냅샷
abi-dumper libplugin.so -o new.xml -l libplugin
# 4. ABI 호환성 검사
abi-compliance-checker -l libplugin -old old.xml -new new.xml
nm으로 심볼 확인
# C++ 심볼 (demangled)
nm -C libplugin.so | grep plugin
# C 심볼 (extern "C")
nm libplugin.so | grep plugin_create
dumpbin (Windows)
dumpbin /EXPORTS plugin.dll
libabigail 사용
# ABI 추출
abidw libplugin.so --out-file old.abi
# 변경 후
abidw libplugin.so --out-file new.abi
# diff
abidiff old.abi new.abi
CI에서 ABI 검증
# .github/workflows/abi-check.yml — PR 시 ABI 변경 감지
name: ABI Check
on:
pull_request:
paths: ['src/**', 'include/**']
jobs:
abi-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 2 }
- run: sudo apt-get install -y abi-compliance-checker abi-dumper
- run: |
git checkout HEAD~1
cmake -B build-old -DCMAKE_BUILD_TYPE=Release && cmake --build build-old
- run: git checkout ${{ github.sha }}
- run: |
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
- run: |
abi-dumper build-old/libplugin.so -o old.xml -l libplugin
abi-dumper build/libplugin.so -o new.xml -l libplugin
abi-compliance-checker -l libplugin -old old.xml -new new.xml
CMake에서 ABI 검증 타겟
# CMakeLists.txt
find_program(ABI_DUMPER abi-dumper)
find_program(ABI_CHECKER abi-compliance-checker)
if(ABI_DUMPER AND ABI_CHECKER)
add_custom_target(abi-check
COMMAND ${ABI_DUMPER} $<TARGET_FILE:plugin> -o abi.xml -l plugin
COMMAND ${CMAKE_COMMAND} -E echo "ABI snapshot saved to abi.xml"
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
endif()
8. 프로덕션 패턴
패턴 1: PIMPL + 팩토리
// widget.h
class Widget {
public:
static std::unique_ptr<Widget> create(const std::string& type);
// ...
};
// widget.cpp
std::unique_ptr<Widget> Widget::create(const std::string& type) {
if (type == "button") return std::make_unique<ButtonWidget>();
if (type == "label") return std::make_unique<LabelWidget>();
return nullptr;
}
패턴 2: C 인터페이스 + C++ 래퍼
// plugin_api.h — C (ABI 안정)
extern "C" PluginHandlePtr plugin_create(const char* config);
// plugin_wrapper.h — C++ (사용 편의)
class Plugin {
public:
explicit Plugin(const std::string& path);
void process(const std::vector<uint8_t>& input, std::vector<uint8_t>& output);
private:
std::unique_ptr<PluginImpl> pImpl_;
};
패턴 3: 인터페이스 + PIMPL
// 인터페이스는 공개, 구현은 PIMPL
class IProcessor {
public:
virtual ~IProcessor() = default;
virtual void process() = 0;
};
class ProcessorImpl;
class Processor : public IProcessor {
public:
Processor();
~Processor() override;
void process() override;
private:
std::unique_ptr<ProcessorImpl> pImpl_;
};
패턴 4: shared_ptr 대신 unique_ptr 사용 (기본)
// ✅ 기본: unique_ptr — 소유권 명확, 오버헤드 적음
std::unique_ptr<Impl> pImpl_;
// shared_ptr — 여러 인스턴스가 공유할 때만
// std::shared_ptr<Impl> pImpl_;
패턴 5: Lazy PIMPL (초기화 지연)
class HeavyWidget {
public:
void doWork() {
ensureImpl(); // 첫 호출 시에만 생성
pImpl_->work();
}
private:
void ensureImpl() {
if (!pImpl_) pImpl_ = std::make_unique<HeavyWidgetImpl>();
}
std::unique_ptr<HeavyWidgetImpl> pImpl_;
};
패턴 6: 빌드 시나리오 시퀀스
sequenceDiagram
participant User as 사용자 코드
participant Widget as Widget (공개)
participant Impl as WidgetImpl
User->>Widget: Widget w;
Widget->>Impl: make_unique()
Impl-->>Widget: pImpl_
User->>Widget: w.doSomething()
Widget->>Impl: pImpl_->doSomething()
Impl-->>Widget: 결과
Widget-->>User: 반환
패턴 7: 배포 체크리스트
- 빌드 전: 공개 API에 구조체 멤버 직접 노출 금지, 가상 함수 끝에만 추가, API 경계에
std::string·std::vector금지 - 빌드 후: abi-compliance-checker로 ABI 검증, nm/dumpbin으로 심볼 확인, 시맨틱 버전 수립
- 배포 시: 최소 툴체인 버전 명시, deprecated 심볼 유지, 릴리스 노트에 ABI 변경 명시
성능 고려사항
- 포인터 간접 참조 비용: PIMPL은
pImpl_->로 한 단계 포인터를 따라가므로, 캐시 미스 가능성이 있습니다. 핫 루프에서 수백만 번 호출되는 함수라면 인라인으로 직접 접근하는 것보다 느릴 수 있습니다. - 힙 할당:
make_unique<Impl>()로 생성 시 힙 할당이 발생합니다. Lazy PIMPL이나 객체 풀을 쓰면 할당을 줄일 수 있습니다. - 적용 기준: 대부분의 경우 포인터 간접 참조 비용은 무시할 수준입니다. 프로파일링 후 실제 병목이 PIMPL일 때만 최적화를 고려합니다.
9. 정리
| 주제 | 요약 |
|---|---|
| ABI | 바이너리 간 규약 — 구조체 레이아웃·vtable·name mangling |
| PIMPL | 구현을 포인터 뒤에 숨겨 레이아웃 고정 |
| C 인터페이스 | extern “C”로 최대 ABI 안정성 |
| 버전 관리 | 가상 함수 끝에만 추가, 버전 번호 분기, 확장 구조체 |
| 에러 방지 | std:: 타입 API 경계 금지, 동일 툴체인, 인라인 주의 |
| 검증 도구 | abi-compliance-checker, libabigail, CI 통합 |
ABI 호환성 체크리스트
적용하면 좋은 경우:
- 라이브러리로 배포하는가?
- 플러그인 시스템을 사용하는가?
- DLL/SO를 다른 프로젝트와 공유하는가?
- 사용자 재컴파일 없이 업데이트를 지원하는가?
필수 규칙:
- 공개 API에 구조체 멤버 직접 노출 금지
- 가상 함수는 끝에만 추가
- API 경계에 std::string·std::vector 금지
- 인라인 함수 변경 시 사용자 재컴파일 필요
- 동일 툴체인 또는 C 인터페이스로 경계 분리
구현 체크리스트
- PIMPL 또는 C 인터페이스 적용
- 소멸자 .cpp 정의 (unique_ptr + 불완전 타입)
- self-assignment 체크, 이동 후 원본 사용 금지
- 가상 함수 끝에만 추가
- ABI 검증 도구 CI 통합
- 시맨틱 버전 정책 수립
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 라이브러리 배포, 플러그인 시스템, DLL/SO 공유 라이브러리 개발 시 ABI 호환성이 필수입니다. 본문의 문제 시나리오와 버전 관리 전략을 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 인터페이스·PIMPL(#38-3), 플러그인 시스템(#55-2), 크로스 플랫폼(#55-4)을 먼저 읽으면 이해가 쉽습니다.
Q. 더 깊이 공부하려면?
A. Itanium C++ ABI, GCC ABI, abi-compliance-checker, libabigail을 참고하세요.
참고 자료
- Itanium C++ ABI
- GCC C++ ABI
- abi-compliance-checker
- 인터페이스·PIMPL #38-3
- 플러그인 시스템 #55-2
한 줄 요약: PIMPL·C 인터페이스·버전 관리로 ABI 호환성을 유지하고, 라이브러리 업데이트 시 크래시를 방지할 수 있습니다.
관련 글
- C++ ABI 안정성 완벽 가이드 | 바이너리 호환성·버전 관리·프로덕션 패턴 [#55-8]
- C++ 동적 로딩 완벽 가이드 | dlopen·LoadLibrary·실전 패턴 [#55-2]
- C++ 플러그인 시스템 완벽 가이드 | dlopen·LoadLibrary·인터페이스·핫 리로드 [실전]
- C++ 크로스 플랫폼 기초 완벽 가이드 | 플랫폼 감지·std::filesystem