C++ 라이브러리 ABI를 깨지 않는 법: PIMPL, extern "C" 인터페이스, 버전 관리, 검증 도구

들어가며: 라이브러리 업데이트 후 크래시가 나요

ABI(Application Binary Interface)는 컴파일된 바이너리끼리 맞닿을 때의 규약입니다. 구조체 크기와 멤버 오프셋, 함수 호출 규약, vtable 레이아웃, name mangling, 예외 처리 방식이 모두 포함됩니다. C++ 표준은 ABI를 정의하지 않으므로, 이 규약은 플랫폼(Linux/macOS의 Itanium C++ ABI, Windows의 MSVC ABI)과 컴파일러, 표준 라이브러리 구현에 따라 정해집니다.

API 호환성과 ABI 호환성은 다릅니다. API 호환은 “새 헤더로 다시 컴파일하면 된다”이고, ABI 호환은 “다시 컴파일하지 않고 새 .so/.dll로 교체해도 된다”입니다. private 멤버 추가는 API는 그대로지만 ABI를 깹니다. 라이브러리를 소스로만 배포하고 사용자가 항상 함께 빌드한다면 ABI를 신경 쓸 필요가 거의 없습니다. ABI가 문제가 되는 것은 바이너리를 따로 교체하는 경우, 즉 공유 라이브러리 배포, 플러그인 시스템, OS 패키지로 설치되는 라이브러리입니다.

flowchart TB
    subgraph problem[ABI 깨짐 시나리오]
        P1[앱: v1.0 헤더로 빌드] --> P2[Widget: 16바이트 가정]
        P3[라이브러리: v1.1로 교체] --> P4[Widget: 40바이트 실제]
        P2 --> P5[오프셋 불일치]
        P4 --> P5
        P5 --> P6[크래시/데이터 손상]
    end

이 글은 라이브러리 수준에서 ABI를 관리하는 방법을 다룹니다. PIMPL 자체의 구현(Rule of Five, 불완전 타입 에러, 이동 후 상태, Fast PIMPL 같은 대안)은 인터페이스 설계와 PIMPL #38-3에 있습니다.

요구 환경: C++17 이상 (예제 2의 지정 초기화는 C++20)


ABI가 깨지는 상황

변경왜 깨지는가대응
공개 클래스에 멤버 추가·삭제·순서 변경크기·오프셋이 바뀜PIMPL로 레이아웃 숨김
가상 함수를 중간에 추가·순서 변경vtable 슬롯 인덱스가 밀림끝에만 추가 + 버전 확인, 또는 새 인터페이스
API 경계로 std::string·std::vector 전달표준 라이브러리 구현·설정마다 레이아웃이 다름C 타입(const char* + 길이)
헤더의 인라인 함수 본문 변경옛 본문이 사용자 바이너리에 이미 복사됨공개 API는 비인라인
함수 제거·시그니처 변경심볼이 사라지거나 mangled 이름이 바뀜deprecated 래퍼 유지, MAJOR 버전
예외를 경계 밖으로 던짐런타임·RTTI가 다르면 잡을 수 없음C 경계에서는 에러 코드
32/64비트, #pragma pack 혼용구조체 크기·정렬이 다름고정 크기 타입, 명시적 크기 필드

표에서 보듯 대부분의 문제는 경계에 무엇을 노출하느냐의 문제입니다. 경계에 노출하는 것을 줄일수록 ABI를 지키기 쉬워지고, 극단적으로 줄인 형태가 extern "C" 인터페이스입니다.


ABI 기본 개념

구조체 레이아웃·크기·정렬

// v1.0
struct Widget {
    int id;
    double value;
};
// sizeof(Widget) == 16 (id 4바이트 + 패딩 4바이트 + value 8바이트)
// v1.1 — ABI 깨짐
struct Widget {
    int id;
    std::vector<int> cache_;   // 추가: 크기·오프셋 변경
    double value;
};
// sizeof(Widget) == 40 (libstdc++ x86-64 기준, vector는 24바이트)

v1.0 헤더로 컴파일된 앱은 value가 오프셋 8에 있다고 가정하고, 객체를 16바이트로 스택에 할당합니다. v1.1 라이브러리 함수는 같은 객체를 40바이트로 보고 오프셋 32에 value를 쓰므로, 앱의 스택을 넘어 다른 변수를 덮어씁니다. 이런 버그는 크래시 지점이 원인과 멀리 떨어져 나타나서 추적이 가장 어렵습니다.

vtable과 가상 함수

가상 함수가 있는 클래스의 객체는 vtable 포인터를 갖고, vtable의 슬롯 순서는 선언 순서로 정해집니다.

// v1
class IPlugin {
public:
    virtual ~IPlugin() = default;     // Itanium ABI에서는 소멸자가 슬롯 2개를 차지
    virtual int process() = 0;
};
// v2 — ABI 깨짐
class IPlugin {
public:
    virtual ~IPlugin() = default;
    virtual void init();              // 새로 추가: process의 슬롯이 밀림
    virtual int process() = 0;
};

v1 헤더로 컴파일된 호스트는 process를 옛 슬롯 번호로 호출하는데, v2로 빌드된 플러그인에서는 그 슬롯에 init이 있습니다. 몇 가지 덧붙이면:

  • 끝에 추가하는 것도 조건부로만 안전합니다. 인터페이스를 구현하는 쪽(플러그인)이 옛 버전이면 그 vtable에는 새 슬롯이 없습니다. 호스트가 새 함수를 호출하기 전에 반드시 버전을 확인해야 합니다(4절 예제 3).
  • MSVC는 같은 이름의 가상 함수 오버로드를 vtable에서 묶어 배치하므로, 기존 가상 함수의 오버로드를 끝에 추가해도 순서가 바뀔 수 있습니다. 새 가상 함수에는 새 이름을 쓰는 편이 안전합니다.

Name Mangling

C++는 오버로딩을 지원하므로 컴파일러가 함수 이름에 타입 정보를 붙입니다. Itanium ABI 계열(GCC, Clang)은 같은 규칙을 쓰지만 MSVC는 완전히 다른 규칙을 씁니다. extern "C"로 내보내면 mangling이 없어 심볼 이름이 고정됩니다.

void process(int x);                  // GCC/Clang: _Z7processi
void process(double x);               // GCC/Clang: _Z7processd
extern "C" void process_int(int x);   // process_int (오버로드 불가)

mangled 이름에는 매개변수 타입이 들어가므로, 매개변수 타입을 int에서 long으로 바꾸는 것도 심볼 이름을 바꿉니다. 옛 앱은 옛 이름을 찾다가 “undefined symbol”로 로드에 실패합니다. 반대로 extern "C" 함수는 타입이 이름에 없어서, 시그니처를 바꿔도 링크는 성공하고 런타임에 잘못된 인자로 호출됩니다. C 인터페이스는 안정적이지만, 시그니처 변경을 링커가 잡아 주지 않는다는 점에서 더 엄격한 규율이 필요합니다.

표준 라이브러리와 툴체인

  • libstdc++: GCC 5에서 std::string과 std::list의 레이아웃을 C++11 요구에 맞게 바꾸면서 두 ABI를 모두 제공합니다(_GLIBCXX_USE_CXX11_ABI). 이 값이 다른 바이너리끼리 std::string을 주고받으면 깨집니다. 같은 값이라면 GCC 버전 간에는 하위 호환이 유지됩니다(새 GCC로 빌드한 것을 실행하려면 실행 환경의 libstdc++가 그만큼 새 버전이어야 함).
  • libc++와 libstdc++는 서로 호환되지 않습니다. std::string의 내부 구조가 완전히 다릅니다.
  • MSVC: Visual Studio 2015부터 2022까지는 런타임(vcruntime140)의 이진 호환을 유지하지만, 디버그 CRT와 릴리스 CRT를 섞거나 _ITERATOR_DEBUG_LEVEL이 다르면 표준 컨테이너 레이아웃이 달라집니다. MinGW와 MSVC는 C++ 수준에서 호환되지 않습니다.

핵심 기법: PIMPL과 C 인터페이스

PIMPL: 데이터 레이아웃을 숨긴다

공개 클래스가 std::unique_ptr<Impl>만 갖게 하면 크기가 포인터 하나로 고정되어, Impl의 멤버 변경이 ABI에 영향을 주지 않습니다. 구현 방법, 특수 멤버 함수 처리, 불완전 타입 에러와 이동 후 null Impl 같은 함정, 비용과 대안은 PIMPL #38-3에서 다룹니다.

ABI 관점에서 기억할 점은 PIMPL이 데이터 레이아웃만 지킨다는 것입니다. 공개 멤버 함수의 시그니처, 가상 함수, 공개 함수에 등장하는 std:: 타입, 인라인 함수는 여전히 ABI의 일부입니다. 그래서 “C++ 클래스 API + PIMPL”은 같은 툴체인·같은 표준 라이브러리 설정을 쓰는 사용자에게 바이너리 교체를 허용하는 수준이고, 툴체인이 다를 수 있는 사용자(서드파티 플러그인 작성자 등)에게는 C 인터페이스가 필요합니다.

C 인터페이스: 툴체인 경계를 넘는다

extern "C" 함수와 불투명 핸들, C 호환 타입(const char*, size_t, 고정 크기 정수, POD 구조체)만 경계에 두면 컴파일러와 표준 라이브러리가 달라도 호출할 수 있습니다.

// plugin_api.h — C 인터페이스
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif

typedef struct PluginHandle PluginHandle;       // 불투명 타입

PluginHandle* plugin_create(const char* config);
void          plugin_destroy(PluginHandle* handle);
int           plugin_process(PluginHandle* handle,
                             const void* input, size_t input_size,
                             void* output, size_t output_size);
const char*   plugin_last_error(PluginHandle* handle);   // 예외 대신 에러 문자열

#ifdef __cplusplus
}
#endif
// plugin_impl.cpp — 내부는 자유롭게 C++
#include "plugin_api.h"
#include <exception>
#include <string>

struct PluginHandle {
    std::string config;
    std::string last_error;
};

extern "C" PluginHandle* plugin_create(const char* config) {
    try {
        auto* p = new PluginHandle;
        if (config) p->config = config;
        return p;
    } catch (...) {
        return nullptr;                  // 예외가 C 경계를 넘지 않게
    }
}

extern "C" void plugin_destroy(PluginHandle* h) { delete h; }

extern "C" int plugin_process(PluginHandle* h, const void* in, size_t in_len,
                              void* out, size_t out_len) {
    try {
        // ... 처리 ...
        return 0;
    } catch (const std::exception& e) {
        h->last_error = e.what();
        return -1;
    }
}

extern "C" const char* plugin_last_error(PluginHandle* h) { return h->last_error.c_str(); }

C 인터페이스에서 지켜야 할 규칙은 세 가지입니다.

  1. 메모리는 할당한 쪽이 해제합니다. 라이브러리가 new한 객체를 앱이 delete하면, 두 바이너리가 다른 힙(Windows에서 CRT가 다른 경우)을 쓸 때 크래시가 납니다. 그래서 plugin_create와 짝을 이루는 plugin_destroy를 제공합니다.
  2. 예외는 경계를 넘기지 않습니다. C++ 예외가 extern "C" 함수 밖으로 나가면, 호출자가 다른 런타임이면 잡을 수 없고 대개 std::terminate로 끝납니다. 모든 진입점을 try/catch로 감싸 에러 코드로 바꿉니다.
  3. 소유권과 수명을 문서화합니다. plugin_last_error가 반환하는 포인터는 다음 호출 전까지만 유효하다는 식의 계약을 헤더 주석에 적습니다.

사용 편의를 위해 이 C API 위에 헤더 전용 C++ 래퍼(std::unique_ptr과 커스텀 삭제자, 에러 코드를 예외로 바꾸는 얇은 클래스)를 제공하면, 래퍼는 사용자 쪽에서 컴파일되므로 ABI 경계를 넘지 않습니다.


예제

예제 1: 함수 테이블로 내보내는 플러그인

심볼을 함수마다 내보내는 대신, 버전 필드가 있는 함수 테이블 하나를 내보내는 방식입니다. 호스트는 dlsym/GetProcAddress로 심볼 하나만 찾으면 됩니다.

// plugin_interface.h
#pragma once
#include <stdint.h>
#include <stddef.h>

#define PLUGIN_ABI_VERSION 2

#if defined(_WIN32)
  #define PLUGIN_EXPORT __declspec(dllexport)
#else
  #define PLUGIN_EXPORT __attribute__((visibility("default")))
#endif

#ifdef __cplusplus
extern "C" {
#endif
typedef struct PluginAPI {
    uint32_t abi_version;     // 항상 첫 필드
    uint32_t struct_size;     // sizeof(PluginAPI) — 호스트가 필드 존재 여부 판단
    void* (*create)(const char* config);
    void  (*destroy)(void* handle);
    int   (*process)(void* handle, const void* in, size_t in_len, void* out, size_t out_len);
    /* v2에서 추가 — 항상 끝에 */
    int   (*configure)(void* handle, const char* key, const char* value);
} PluginAPI;
#ifdef __cplusplus
}
#endif
// my_plugin.cpp (C++20 지정 초기화)
#include "plugin_interface.h"
#include <string>

struct PluginState { std::string config; };

extern "C" PLUGIN_EXPORT const PluginAPI plugin_api = {
    .abi_version = PLUGIN_ABI_VERSION,
    .struct_size = sizeof(PluginAPI),
    .create  = [](const char* cfg) -> void* { return new PluginState{cfg ? cfg : ""}; },
    .destroy = [](void* h) { delete static_cast<PluginState*>(h); },
    .process = [](void*, const void*, size_t, void*, size_t) -> int { return 0; },
    .configure = [](void*, const char*, const char*) -> int { return 0; },
};
// host.cpp — v1 플러그인도 안전하게 다룸
bool has_configure(const PluginAPI* api) {
    return api->struct_size >= offsetof(PluginAPI, configure) + sizeof(api->configure)
        && api->configure != nullptr;
}

abi_version만 두면 “버전 2 이상이면 configure가 있다”는 식으로 판단하게 되는데, struct_size를 함께 두면 필드 단위로 판단할 수 있어 중간 버전이 여러 개일 때 더 견고합니다. Windows API의 cbSize 필드가 같은 목적입니다. 캡처 없는 람다는 함수 포인터로 변환되므로 C 호환 함수 포인터 필드에 넣을 수 있습니다.

예제 2: 사용자가 채워서 넘기는 설정 구조체

라이브러리에 옵션을 넘기는 구조체는 호출자가 할당하므로, 필드를 추가하면 옛 호출자가 할당한 구조체가 새 라이브러리가 기대하는 것보다 작아집니다. 예전 코드에서 void* reserved 필드를 두고 나중에 필드를 추가하는 방식을 볼 수 있는데, 이 방식은 구조체 크기 자체를 바꾸므로 호출자 할당 구조체에서는 ABI를 지키지 못합니다. 크기 필드를 첫 필드로 두는 방식이 안전합니다.

typedef struct LibOptions {
    uint32_t size;          // 호출자가 sizeof(LibOptions)로 채움
    uint32_t flags;         // v1
    const char* log_path;   // v2에서 추가
} LibOptions;

// 라이브러리 쪽
int lib_init(const LibOptions* opt) {
    LibOptions o = {};                         // 새 필드는 기본값
    o.size = sizeof(LibOptions);
    memcpy(&o, opt, opt->size < sizeof(o) ? opt->size : sizeof(o));   // 호출자가 준 만큼만 복사
    // o.log_path는 v1 호출자라면 nullptr
    return 0;
}

예제 3: C++ 인터페이스 클래스에 기능 추가하기

C++ 인터페이스를 유지해야 한다면, 기존 인터페이스를 고치지 말고 새 인터페이스를 추가하는 방식이 가장 안전합니다. COM의 IUnknown::QueryInterface와 같은 발상입니다.

class IPlugin {                              // v1 — 절대 수정하지 않음
public:
    virtual ~IPlugin() = default;
    virtual int process(const void* in, void* out) = 0;
};

class IPlugin2 : public IPlugin {            // v2 기능
public:
    virtual void configure(const char* key, const char* value) = 0;
};

// 호스트: 플러그인이 v2를 지원하는지 물어봄
extern "C" IPlugin* create_plugin();
extern "C" uint32_t plugin_interface_version();   // 2 이상이면 IPlugin2로 캐스트 가능

void use(IPlugin* p, uint32_t ver) {
    if (ver >= 2) static_cast<IPlugin2*>(p)->configure("mode", "fast");
    p->process(nullptr, nullptr);
}

dynamic_cast로 판별하고 싶어지지만, 호스트와 플러그인이 다른 바이너리이면 RTTI 정보가 공유되지 않아 실패할 수 있습니다(특히 심볼 visibility를 숨긴 경우). 버전 함수로 명시적으로 판별하는 편이 확실합니다. 이 방식은 여전히 C++ vtable ABI에 의존하므로 호스트와 플러그인이 같은 ABI 계열 컴파일러를 써야 합니다.


자주 발생하는 ABI 에러

PIMPL을 구현할 때의 에러(불완전 타입 소멸자, 이동된 객체의 null Impl, noexcept 이동 누락)는 #38-3의 에러 절에 있습니다.

에러 1: 공개 클래스의 STL 멤버

// ❌ 라이브러리 공개 API
class Document {
    std::vector<std::string> paragraphs_;   // 레이아웃이 크기에 그대로 드러남
};
// ✅ PIMPL로 숨김
class Document {
    class Impl;
    std::unique_ptr<Impl> pImpl_;
};

멤버를 바꾸지 않더라도, 사용자가 다른 _GLIBCXX_USE_CXX11_ABI나 _ITERATOR_DEBUG_LEVEL로 빌드하면 std::vector와 std::string의 레이아웃이 달라져 Document의 크기가 어긋납니다.

에러 2: std::string을 API 경계로 넘김

// ❌ extern "C"인데 C++ 타입 — 링크는 되지만 의미 없음 (MSVC는 C4190 경고)
extern "C" void process(std::string input);
// ✅
extern "C" void process(const char* input, size_t len);

extern "C"는 이름 mangling만 없앨 뿐 인자 타입의 레이아웃을 바꾸지 않습니다. std::string을 받는 extern "C" 함수는 C ABI의 이점이 전혀 없습니다.

에러 3: _GLIBCXX_USE_CXX11_ABI 불일치

undefined reference to `foo(std::string const&)'

원인: 라이브러리는 _GLIBCXX_USE_CXX11_ABI=0(옛 CentOS 계열 devtoolset 기본, 일부 배포 바이너리)으로, 앱은 =1(현재 기본)로 빌드되었습니다. 새 ABI의 std::string은 std::__cxx11::basic_string이라는 다른 이름으로 mangling되므로, 링크 단계에서 심볼을 찾지 못하는 형태로 드러납니다. 해결: 모든 번역 단위와 라이브러리에서 같은 값을 쓰거나, 경계를 C 인터페이스로 바꿉니다. nm -C libfoo.so | grep __cxx11로 라이브러리가 어느 쪽인지 확인할 수 있습니다.

에러 4: 인라인 함수 본문 변경

// 헤더
inline int api_version() { return 2; }   // 1에서 2로 바꿨지만 옛 앱에는 1이 박혀 있음

해결: 버전처럼 라이브러리 쪽 값을 알려야 하는 함수는 비인라인으로 선언하고 .cpp에 정의합니다. 헤더의 constexpr 상수도 같은 이유로 “컴파일 시점의 버전”만 알려 줍니다. 런타임 라이브러리 버전과 컴파일 시점 헤더 버전을 모두 제공하고, 앱이 시작할 때 둘을 비교하는 것이 흔한 패턴입니다(zlib의 ZLIB_VERSION과 zlibVersion()).

에러 5: 심볼 visibility

원인: GCC/Clang의 기본 visibility는 default(모두 내보냄)지만, 라이브러리 크기와 로드 시간을 줄이고 ABI 표면을 좁히기 위해 -fvisibility=hidden(CMake CXX_VISIBILITY_PRESET hidden)을 켜는 경우가 많습니다. 이때 내보낼 심볼에 표시를 빠뜨리면 dlsym이 찾지 못합니다.

#if defined(_WIN32)
  #define LIB_API __declspec(dllexport)
#else
  #define LIB_API __attribute__((visibility("default")))
#endif
extern "C" LIB_API PluginHandle* plugin_create(const char* config);

-fvisibility=hidden은 ABI 관리에 오히려 유리합니다. 의도하지 않게 내보낸 내부 함수가 없으므로, 내부 함수를 바꿔도 ABI 검사 도구가 경고하지 않고, 공개 표면이 명시적으로 관리됩니다.


버전 관리 전략

시맨틱 버전과 SONAME

  • MAJOR: ABI 깨짐 → 사용자 재컴파일 필수
  • MINOR: ABI 하위 호환 추가(새 함수, 끝에 추가한 필드) → 옛 앱은 그대로 동작
  • PATCH: 구현만 변경

Linux에서는 이 정책을 SONAME으로 표현합니다. libfoo.so.1.4.2의 SONAME을 libfoo.so.1로 두면, 앱은 링크 시 SONAME을 기록하고 실행 시 libfoo.so.1을 찾습니다. MINOR/PATCH 업데이트는 같은 SONAME으로 교체되고, ABI를 깨는 MAJOR 업데이트는 libfoo.so.2가 되어 옛 앱과 새 앱이 두 라이브러리를 나란히 쓸 수 있습니다.

add_library(foo SHARED foo.cpp)
set_target_properties(foo PROPERTIES
    VERSION 1.4.2          # 실제 파일: libfoo.so.1.4.2
    SOVERSION 1)           # SONAME: libfoo.so.1 — ABI가 깨질 때만 올림

SOVERSION을 프로젝트 버전과 자동으로 묶어 두면 MINOR 릴리스마다 SONAME이 바뀌어, 사용자는 ABI가 같은데도 재링크해야 합니다. SOVERSION은 ABI가 실제로 깨질 때만 사람이 올리는 값으로 관리하는 편이 좋습니다. macOS에서는 install name의 compatibility version/current version이, Windows에서는 DLL 파일 이름에 버전을 넣는 관례가 같은 역할을 합니다.

심볼 버전 관리 (Linux)

한 라이브러리 안에서 같은 함수의 옛 동작과 새 동작을 동시에 제공할 때 씁니다. glibc가 수십 년 동안 SONAME을 바꾸지 않고 하위 호환을 유지하는 방법입니다.

# plugin.ver — 버전 스크립트
PLUGIN_1.0 {
  global:
    plugin_create;
    plugin_destroy;
    plugin_process;
  local: *;
};
PLUGIN_1.1 {
  global:
    plugin_configure;
} PLUGIN_1.0;
target_link_options(plugin PRIVATE "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/plugin.ver")

local: *는 스크립트에 적지 않은 모든 심볼을 숨깁니다. visibility와 같은 효과를 링크 단계에서 한 번 더 보장합니다. 심볼 버전은 강력하지만 유지 비용도 크므로, 대부분의 라이브러리는 SONAME + visibility로 충분합니다.

인라인 네임스페이스로 ABI 버전 표시

namespace mylib {
inline namespace v2 {          // mangled 이름에 v2가 들어감
    class Parser { /* ... */ };
}
}
// 사용자는 mylib::Parser로 씀. v1 바이너리와 섞이면 링크 에러로 즉시 드러남

libc++가 std::__1, libstdc++가 std::__cxx11을 쓰는 것과 같은 기법입니다. ABI를 깨는 변경을 할 때 인라인 네임스페이스 이름을 바꾸면, 옛 바이너리와 섞였을 때 런타임 크래시 대신 링크 에러가 나므로 문제를 훨씬 일찍 발견합니다.


ABI 검증 도구

사람이 리뷰에서 ABI 변경을 모두 잡기는 어렵습니다. private 멤버 하나, 기본 인자 하나, 인라인 함수 하나가 ABI를 바꾸기 때문입니다. 그래서 이전 릴리스와 현재 빌드를 도구로 비교합니다.

도구용도플랫폼
libabigail (abidw, abidiff)디버그 정보 기반 ABI 추출·비교Linux (ELF)
abi-dumper + abi-compliance-checkerABI 덤프와 HTML 호환성 보고서Linux
nm / objdump내보낸 심볼 목록Unix
dumpbin /EXPORTSDLL 내보내기 목록Windows

libabigail

# 두 도구 모두 디버그 정보(-g)가 있는 빌드가 필요
abidw --out-file libplugin-1.4.abi build-old/libplugin.so
abidiff libplugin-1.4.abi build/libplugin.so
echo $?   # 비트 플래그: 4=ABI 변경, 8=호환되지 않는 변경

abidiff의 종료 코드는 비트 플래그라서 CI에서 “호환되지 않는 변경이면 실패”를 쉽게 표현할 수 있습니다. 헤더 디렉터리를 --headers-dir로 지정하면 공개 헤더에 선언된 타입만 비교해 내부 타입 변경으로 인한 잡음이 줄어듭니다.

abi-compliance-checker

abi-dumper build-old/libplugin.so -o old.dump -lver 1.4
abi-dumper build/libplugin.so     -o new.dump -lver 1.5
abi-compliance-checker -l libplugin -old old.dump -new new.dump
# compat_reports/ 아래에 바이너리·소스 호환성 HTML 보고서 생성

CI에서 ABI 검증

# .github/workflows/abi-check.yml
name: ABI Check
on:
  pull_request:
    paths: ['src/**', 'include/**']
jobs:
  abi-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: sudo apt-get install -y abigail-tools
      - name: Build last release
        run: |
          git worktree add ../base $(git describe --tags --abbrev=0)
          cmake -S ../base -B build-base -DCMAKE_BUILD_TYPE=RelWithDebInfo
          cmake --build build-base
      - name: Build PR
        run: |
          cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
          cmake --build build
      - name: Compare
        run: abidiff --headers-dir1 ../base/include --headers-dir2 include
             build-base/libplugin.so build/libplugin.so

비교 기준은 직전 커밋이 아니라 마지막 릴리스 태그로 잡습니다. 사용자가 가진 바이너리는 릴리스 버전이기 때문입니다. RelWithDebInfo로 빌드하는 것은 두 도구 모두 디버그 정보에서 타입 레이아웃을 읽기 때문입니다.


배포 체크리스트

  • 설계 시: 바이너리로 교체될 경계가 어디인지 정합니다. 같은 툴체인 사용자만 있으면 C++ API + PIMPL, 서드파티 플러그인이면 C 인터페이스.
  • 공개 헤더: 공개 클래스에 데이터 멤버 없음(PIMPL), 인라인 함수 최소화, 경계의 std:: 타입은 툴체인 고정을 전제로만.
  • 빌드: -fvisibility=hidden + 명시적 export 매크로, SOVERSION은 ABI가 깨질 때만 올림.
  • CI: 마지막 릴리스 대비 abidiff, 호환되지 않는 변경이면 실패시키거나 MAJOR 버전 증가를 요구.
  • 배포: 최소 툴체인·표준 라이브러리 버전과 _GLIBCXX_USE_CXX11_ABI 값을 문서화, 제거할 심볼은 한 MAJOR 동안 deprecated 래퍼로 유지.

정리

주제요약
ABI vs APIABI 호환 = 재컴파일 없이 바이너리 교체 가능
깨지는 원인레이아웃, vtable 슬롯, mangled 이름, 인라인 본문, 표준 라이브러리 설정
PIMPL데이터 레이아웃만 지킨다 (구현은 #38-3)
C 인터페이스툴체인 경계를 넘는다. 할당한 쪽이 해제, 예외는 경계 밖으로 안 보냄
확장크기 필드가 있는 구조체, 새 인터페이스 추가, 버전 함수
버전SONAME, 심볼 버전, 인라인 네임스페이스
검증libabigail·abi-compliance-checker를 릴리스 태그 대비 CI에서

참고 자료


같이 보면 좋은 글