C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]
이 글의 핵심
C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기 [#38-3]에 대한 실전 가이드입니다.
들어가며: 헤더가 바뀌면 세상이 다시 빌드된다
”private 멤버를 바꿨을 뿐인데 왜 다 컴파일되지?”
공개 헤더에 구현 디테일(멤버 변수, 포함된 헤더)이 노출되면, 그 헤더를 쓰는 모든 번역 단위가 영향을 받습니다. private 멤버를 추가/삭제해도 레이아웃이 바뀌어 ABI가 깨질 수 있습니다.
PIMPL(Pointer to Implementation—구현을 가리키는 포인터)은 “구현을 다른 클래스로 옮기고, 공개 클래스는 그 구현을 포인터로만 가리키는” 관용구입니다. 비유하면 건물 앞면(공개 헤더)만 보여 주고, 실제 설비(구현 디테일)는 뒤쪽 별도 건물에 두어 수정해도 앞면이 바뀌지 않게 하는 것과 비슷합니다. 구현체의 헤더는 .cpp에서만 include하므로, 컴파일 의존성이 줄고 바이너리 호환성을 유지하기 쉬워집니다.
이 글에서 다루는 것:
- PIMPL 패턴: 구현을 포인터 뒤에 숨기기 — 헤더 변경 최소화
- 복사/이동/소멸 올바르게 처리하기
- ABI 안정성: 공개 헤더에 노출하는 타입·레이아웃 관리
- 문제 시나리오: 실제 겪는 상황과 해결
- 완전한 인터페이스/ABI 예제: 라이브러리·플러그인 수준
- 자주 발생하는 에러와 해결법
- 버전 관리 전략과 프로덕션 패턴
목차
- 문제 시나리오: PIMPL이 필요한 순간
- PIMPL 패턴
- 복사·이동·소멸 처리
- ABI와 공개 헤더 관리
- 완전한 인터페이스/ABI 예제
- 자주 발생하는 에러와 해결법
- 버전 관리 전략
- 프로덕션 패턴
- 정리
1. 문제 시나리오: PIMPL이 필요한 순간
시나리오 1: 라이브러리 배포 후 private 멤버 추가
문제: Document 클래스를 공개 API로 배포한 뒤, 내부 캐시(std::unordered_map)를 추가했습니다. document.h를 include하는 사용자 코드가 전부 재컴파일되고, 이전에 빌드된 라이브러리와 링크하면 ABI 불일치로 크래시가 발생합니다.
해결: PIMPL로 구현을 숨기면, Impl에 멤버를 추가해도 공개 헤더가 안정적이어서 사용자 재컴파일이 필요 없고 ABI가 유지됩니다.
시나리오 2: 대규모 프로젝트에서 빌드 시간 폭발
문제: Widget 클래스가 200개 이상의 .cpp 파일에서 include됩니다. Widget에 #include <boost/json.hpp>를 추가했더니, 해당 헤더가 무거워서 전체 빌드 시간이 8분에서 25분으로 늘어났습니다.
해결: PIMPL로 boost/json 의존성을 widget.cpp로 이동하면, widget.h를 include하는 200개 파일은 boost/json를 알 필요가 없어 빌드 시간이 크게 줄어듭니다.
시나리오 3: 플러그인 시스템에서 ABI 깨짐
문제: 호스트 앱과 플러그인이 서로 다른 시점에 빌드됩니다. 호스트가 PluginInterface의 가상 함수를 하나 추가했는데, 기존 플러그인들은 vtable 레이아웃이 바뀌어 크래시가 발생합니다.
해결: PIMPL + 인터페이스 클래스로 고정된 vtable을 유지하고, 새 기능은 버전 번호로 분기합니다. 구현 디테일은 PIMPL로 숨겨 ABI 변경을 최소화합니다.
시나리오 4: 플랫폼별 구현 분리
문제: FileWatcher가 Windows에서는 ReadDirectoryChangesW, Linux에서는 inotify를 사용합니다. 공개 헤더에 플랫폼별 헤더를 include하면 Windows 빌드에서 Linux 전용 헤더가 없어 컴파일 에러가 납니다.
해결: PIMPL로 구현을 숨기면, file_watcher.cpp에서만 #ifdef로 플랫폼별 구현을 선택합니다. 공개 헤더는 플랫폼 중립적입니다.
시나리오 5: 순환 의존성
문제: A.h가 B.h를 include하고, B.h가 A.h를 include합니다. 전방 선언으로 해결할 수 있지만, A가 B를 멤버로 가질 때는 전방 선언만으로 부족합니다.
해결: A가 B를 std::unique_ptr<B>로 갖고 PIMPL처럼 숨기면, A.h에서 B의 전방 선언만 하면 됩니다. A.cpp에서 B.h를 include해 구현합니다.
2. PIMPL 패턴
구현을 포인터 뒤에 숨기기
- 공개 클래스는 멤버로 구현체를 가리키는 포인터(보통
std::unique_ptr<Impl>)만 갖습니다. 구현체 클래스(Impl)의 정의는 .cpp에만 두고, 공개 헤더에는 전방 선언만 합니다. - 따라서 공개 헤더를 include하는 쪽은 Impl의 크기·레이아웃을 전혀 알 수 없고, Impl 쪽에서 멤버를 추가/삭제해도 공개 헤더는 그대로면 재컴파일 범위가 줄어듭니다.
flowchart TB
subgraph public["공개 헤더 (widget.h)"]
W[Widget]
P[pImpl_]
W --> P
end
subgraph impl["구현 (.cpp 전용)"]
I[WidgetImpl]
D[data, cache, ...]
I --> D
end
P -.->|포인터만| I
헤더에는 WidgetImpl의 정의가 없으므로 unique_ptr<WidgetImpl>의 소멸자가 Impl의 완전한 타입을 요구할 때를 위해 소멸자 선언만 두고 정의는 .cpp에 둡니다. .cpp에서 WidgetImpl을 정의한 뒤 Widget::Widget()에서 make_unique<WidgetImpl>()로 생성하고, doSomething()에서 pImpl_->로 구현에 위임합니다. 이렇게 하면 widget.h를 쓰는 모든 파일은 Impl 변경에 의해 재컴파일되지 않습니다.
// widget.h — 공개 헤더
#pragma once
#include <memory>
class WidgetImpl; // 전방 선언만
class Widget {
public:
Widget();
~Widget(); // Impl이 unique_ptr이므로 소멸자 선언 필요
void doSomething();
private:
std::unique_ptr<WidgetImpl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include "widget_impl.h" // 구현체 헤더는 여기서만
#include <vector>
struct WidgetImpl {
std::vector<int> data;
// 나중에 멤버 추가해도 widget.h를 쓰는 쪽은 재컴파일 불필요
};
Widget::Widget() : pImpl_(std::make_unique<WidgetImpl>()) {}
Widget::~Widget() = default;
void Widget::doSomething() {
// pImpl_->data 사용
}
- shared_ptr을 쓰면 소멸자를 헤더에 정의하지 않아도 되지만, 제어 블록과 참조 카운팅 비용이 생깁니다. unique_ptr이 기본 선택이고, 소멸자만 .cpp에 정의해 두면 됩니다.
3. 복사·이동·소멸 처리
Rule of Five와 PIMPL
- 소멸자:
std::unique_ptr<Impl>이 완전 타입을 삭제하려면 소멸자 정의가 Impl이 보이는 번역 단위(.cpp)에 있어야 하므로, 헤더에는 선언만, 구현은 .cpp에 둡니다. - 복사 생성/대입: 기본 복사는 pImpl_을 그대로 복사하면 얕은 복사가 되므로, 깊은 복사를 원하면 복사 생성자·복사 대입 연산자를 .cpp에서 Impl을 복사하도록 구현합니다.
- 이동: 기본 이동 생성/이동 대입으로 보통 충분합니다. noexcept를 붙이면 컨테이너 재할당 시 이동이 사용되기 유리합니다.
복사 생성자에서는 **make_unique<WidgetImpl>(*other.pImpl_)**로 other의 Impl 내용을 복사해 새 Impl을 만들고, 복사 대입에서는 *pImpl_ = *other.pImpl_로 Impl 간 대입을 합니다. 이동은 = default로 pImpl_ 포인터만 넘기면 되고, noexcept로 표준 컨테이너가 이동을 선호하도록 합니다.
// widget.h
class Widget {
// ...
Widget(const Widget& other);
Widget& operator=(const Widget& other);
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
};
// widget.cpp
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;
}
4. ABI와 공개 헤더 관리
바이너리 호환성이 중요한 경우
- ABI(Application Binary Interface—컴파일된 바이너리가 서로 맞닿을 때의 규약으로, 구조체 크기·함수 호출 방식 등이 포함됨)가 바뀌면 이전에 빌드된 라이브러리/플러그인과 링크·실행이 깨질 수 있습니다. 공개 헤더에 노출하는 것만으로도 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
- PIMPL을 쓰면 공개 클래스의 크기는 포인터 하나로 고정되므로, Impl 쪽에서 멤버를 바꿔도 포인터 크기는 변하지 않아 ABI가 유지됩니다. 단, 공개 클래스에 새 가상 함수를 추가하거나, 멤버 순서/타입을 바꾸면 ABI가 깨질 수 있으므로 버전 정책(메이저 버전에서만 ABI 변경 등)을 정해 두는 것이 좋습니다.
- 인라인 함수, 템플릿, export된 심볼도 ABI에 포함될 수 있으므로, 라이브러리 공개 API는 비템플릿·비인라인 인터페이스로 최소화하고, 구현은 .cpp 또는 내부 헤더로 두는 것이 안전합니다.
5. 완전한 인터페이스/ABI 예제
예제 1: 플러그인 시스템용 인터페이스
플러그인과 호스트가 서로 다른 시점에 빌드되므로, ABI가 고정된 인터페이스가 필요합니다.
// plugin_interface.h — 공개 헤더 (ABI 고정)
#pragma once
#include <memory>
#include <string>
#include <cstdint>
// C 링크로 name mangling 제거 — ABI 안정성 최대화
extern "C" {
struct PluginAPI {
uint32_t version; // ABI 버전
void* (*create)(const char* config);
void (*destroy)(void* handle);
int (*process)(void* handle, const void* input, void* output);
};
} // extern "C"
// plugin_host.h — 호스트용 PIMPL 래퍼
#pragma once
#include <memory>
#include <string>
class PluginImpl;
class PluginHost {
public:
explicit PluginHost(const std::string& plugin_path);
~PluginHost();
PluginHost(const PluginHost&) = delete;
PluginHost& operator=(const PluginHost&) = delete;
PluginHost(PluginHost&&) noexcept = default;
PluginHost& operator=(PluginHost&&) noexcept = default;
int process(const void* input, void* output);
private:
std::unique_ptr<PluginImpl> pImpl_;
};
// plugin_host.cpp — 구현 (플랫폼별 dlopen/LoadLibrary)
#include "plugin_host.h"
#include <dlfcn.h>
#include <stdexcept>
class PluginImpl {
public:
void* handle = nullptr;
PluginAPI* api = nullptr;
void* instance = nullptr;
~PluginImpl() {
if (api && instance) api->destroy(instance);
if (handle) dlclose(handle);
}
};
PluginHost::PluginHost(const std::string& plugin_path) : pImpl_(std::make_unique<PluginImpl>()) {
pImpl_->handle = dlopen(plugin_path.c_str(), RTLD_NOW);
if (!pImpl_->handle) throw std::runtime_error("failed to load plugin");
auto sym = dlsym(pImpl_->handle, "plugin_api");
if (!sym) throw std::runtime_error("plugin_api not found");
pImpl_->api = static_cast<PluginAPI*>(sym);
pImpl_->instance = pImpl_->api->create("");
}
PluginHost::~PluginHost() = default;
int PluginHost::process(const void* input, void* output) {
return pImpl_->api->process(pImpl_->instance, input, output);
}
예제 2: ABI 안정적인 라이브러리 클래스
// document.h — 공개 API (ABI 안정)
#pragma once
#include <memory>
#include <string>
#include <vector>
class DocumentImpl;
class Document {
public:
Document();
Document(const Document&);
Document& operator=(const Document&);
Document(Document&&) noexcept = default;
Document& operator=(Document&&) noexcept = default;
~Document();
void setTitle(const std::string& title);
[[nodiscard]] std::string getTitle() const;
void addParagraph(const std::string& text);
[[nodiscard]] std::vector<std::string> getParagraphs() const;
private:
std::unique_ptr<DocumentImpl> pImpl_;
};
// document.cpp — 구현 (내부 구조 변경 가능)
#include "document.h"
#include <unordered_map> // 공개 헤더에 노출 안 함
class DocumentImpl {
public:
std::string title;
std::vector<std::string> paragraphs;
std::unordered_map<std::string, size_t> cache; // v2에서 추가해도 ABI 유지
};
Document::Document() : pImpl_(std::make_unique<DocumentImpl>()) {}
Document::~Document() = default;
Document::Document(const Document& other)
: pImpl_(std::make_unique<DocumentImpl>(*other.pImpl_)) {}
Document& Document::operator=(const Document& other) {
if (this != &other) *pImpl_ = *other.pImpl_;
return *this;
}
void Document::setTitle(const std::string& title) { pImpl_->title = title; }
std::string Document::getTitle() const { return pImpl_->title; }
void Document::addParagraph(const std::string& text) { pImpl_->paragraphs.push_back(text); }
std::vector<std::string> Document::getParagraphs() const { return pImpl_->paragraphs; }
예제 3: 플랫폼별 구현 분리
// file_watcher.h — 플랫폼 중립적
#pragma once
#include <memory>
#include <functional>
#include <string>
class FileWatcherImpl;
class FileWatcher {
public:
using Callback = std::function<void(const std::string& path)>;
explicit FileWatcher(const std::string& path);
~FileWatcher();
void setCallback(Callback cb);
void start();
void stop();
private:
std::unique_ptr<FileWatcherImpl> pImpl_;
};
// file_watcher.cpp — 플랫폼별 구현
#include "file_watcher.h"
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/inotify.h>
#include <unistd.h>
#endif
class FileWatcherImpl {
// 플랫폼별 멤버
#ifdef _WIN32
HANDLE hDir = INVALID_HANDLE_VALUE;
#else
int inotifyFd = -1;
int watchFd = -1;
#endif
FileWatcher::Callback callback;
public:
// 플랫폼별 구현...
};
FileWatcher::FileWatcher(const std::string& path) : pImpl_(std::make_unique<FileWatcherImpl>()) {}
FileWatcher::~FileWatcher() = default;
// ... 구현 ...
6. 자주 발생하는 에러와 해결법
에러 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" // Impl 정의
Widget::~Widget() = default; // Impl이 완전한 타입인 곳에서 정의
에러 2: 복사 대입 시 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;
}
에러 3: 이동 후 원본 사용
원인: Widget w2 = std::move(w1); 후 w1은 “유효하지만 unspecified” 상태입니다. w1.doSomething()을 호출하면 크래시할 수 있습니다.
// ❌ 잘못된 예
Widget w1;
Widget w2 = std::move(w1);
w1.doSomething(); // 위험: w1.pImpl_가 nullptr일 수 있음
// ✅ 올바른 예 — 이동 후 원본 사용 안 함
Widget w2 = std::move(w1);
// w1은 더 이상 사용하지 않거나, 재할당 후에만 사용
에러 4: 공개 헤더에 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_; // 포인터 크기만 노출
};
에러 5: 가상 함수 추가로 vtable 깨짐
원인: 공개 인터페이스 클래스에 새 가상 함수를 끝에 추가하지 않고 중간에 넣으면, 기존 vtable 인덱스가 밀려 ABI가 깨집니다.
// ❌ 잘못된 예 — 기존 process() 앞에 새 함수 추가
class Plugin {
virtual void init(); // 새로 추가 — process 인덱스 밀림
virtual int process(); // 기존 플러그인은 process가 0번이었는데 1번으로 바뀜
};
// ✅ 올바른 예 — 끝에만 추가
class Plugin {
virtual int process();
virtual void init(); // 끝에 추가 — 기존 인덱스 유지
};
에러 6: widget_impl.h를 공개 헤더에 노출
원인: Impl 정의를 별도 헤더에 두고, 그 헤더를 공개 include에 포함하면 PIMPL의 이점이 사라집니다. Impl 변경 시 재컴파일 범위가 다시 늘어납니다.
// ❌ 잘못된 예 — widget.h
#include "widget_impl.h" // Impl 정의 노출 — 사용자도 include하게 됨
class Widget {
std::unique_ptr<WidgetImpl> pImpl_;
};
// ✅ 올바른 예 — widget.h
class WidgetImpl; // 전방 선언만
class Widget {
std::unique_ptr<WidgetImpl> pImpl_;
};
// widget.cpp에서만 #include "widget_impl.h" 또는 Impl을 같은 파일에 정의
에러 7: noexcept 이동 생성자 누락
원인: std::vector 등이 재할당 시 요소를 이동할 때 noexcept 이동 생성자를 선호합니다. noexcept가 없으면 복사로 fallback되어 성능이 떨어질 수 있습니다.
// ❌ 성능 저하 — vector 재할당 시 복사 사용
Widget(Widget&&) = default; // noexcept 없음
// ✅ 권장 — vector 재할당 시 이동 사용
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
7. 버전 관리 전략
시맨틱 버전과 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일 때만 사용
};
전략 3: 확장 구조체
// v1
struct PluginConfig {
void* reserved;
};
// v2 — ABI 호환
struct PluginConfig {
void* reserved;
int flags; // reserved 다음에 추가
const char* log; // 추가
};
전략 4: 심볼 버전 관리 (Linux)
# 심볼에 버전 추가 — libplugin.so.1
PLUGIN_1.0 {
global:
plugin_create;
plugin_destroy;
local: *;
};
# 새 버전 추가 시 기존 유지
PLUGIN_1.1 {
global:
plugin_init; # 새 함수
} PLUGIN_1.0;
ABI 검증 도구
| 도구 | 용도 | 플랫폼 |
|---|---|---|
| abi-compliance-checker | 라이브러리 ABI 변경 감지 | Linux |
| abi-dumper | .so에서 ABI 추출 | Linux |
| libabigail | ABI 비교·diff | Linux |
| dumpbin | PE/COFF 심볼 확인 | Windows |
| nm | 오브젝트 심볼 목록 | Unix |
# abi-compliance-checker 예시
abi-dumper libplugin.so -o old.xml -l libplugin
# (코드 변경 후)
abi-dumper libplugin.so -o new.xml -l libplugin
abi-compliance-checker -l libplugin -old old.xml -new new.xml
8. 프로덕션 패턴
패턴 1: PIMPL + 팩토리
// widget.h
class Widget {
public:
static std::unique_ptr<Widget> create(const std::string& type);
static std::shared_ptr<Widget> createShared(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: 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_;
};
패턴 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_; // 복사 시 shared
패턴 5: 빌드 시스템에서 ABI 검증
# abi-compliance-checker 사용
abi-compliance-checker -l libplugin -old old.xml -new new.xml
<!-- old.xml: 이전 버전 ABI 스냅샷 -->
패턴 6: C 링크 인터페이스 (최대 ABI 안정성)
C++ name mangling은 컴파일러마다 다릅니다. extern “C”로 내보내면 심볼 이름이 고정되어, 다른 컴파일러로 빌드한 바이너리와도 링크할 수 있습니다.
// plugin_api.h — C 인터페이스
#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, void* output);
#ifdef __cplusplus
}
#endif
// plugin_impl.cpp — C++ 래퍼가 PIMPL로 C API 호출
#include "plugin_api.h"
#include "plugin_impl.h"
PluginHandlePtr plugin_create(const char* config) {
return new PluginImpl(config);
}
void plugin_destroy(PluginHandlePtr handle) {
delete static_cast<PluginImpl*>(handle);
}
패턴 7: 빌드 시나리오 시퀀스
sequenceDiagram
participant User as 사용자 코드
participant Widget as Widget (공개)
participant Impl as WidgetImpl
User->>Widget: Widget w;
Widget->>Impl: make_unique<WidgetImpl>()
Impl-->>Widget: pImpl_
User->>Widget: w.doSomething()
Widget->>Impl: pImpl_->doSomething()
Impl-->>Widget: 결과
Widget-->>User: 반환
성능 고려사항
- 포인터 간접 참조 비용: PIMPL은
pImpl_->로 한 단계 포인터를 따라가므로, 캐시 미스 가능성이 있습니다. 핫 루프에서 수백만 번 호출되는 함수라면 인라인으로 직접 접근하는 것보다 느릴 수 있습니다. - 힙 할당:
make_unique<Impl>()로 생성 시 힙 할당이 발생합니다. Lazy PIMPL이나 객체 풀을 쓰면 할당을 줄일 수 있습니다. - 적용 기준: 대부분의 경우 포인터 간접 참조 비용은 무시할 수준입니다. 프로파일링 후 실제 병목이 PIMPL일 때만 최적화를 고려합니다.
9. 정리
| 주제 | 요약 |
|---|---|
| PIMPL | 구현을 포인터 뒤에 숨겨 헤더 변경·재컴파일·ABI 변동 최소화 |
| 특수 멤버 | 소멸자·복사는 .cpp에서 정의, 이동은 기본값 + noexcept 고려 |
| ABI | 공개 헤더 노출 최소화, PIMPL로 레이아웃 고정해 호환성 유지 |
| 버전 관리 | 가상 함수 끝에만 추가, 버전 번호 분기, 확장 구조체 |
| 에러 방지 | 소멸자 .cpp 정의, self-assignment 체크, 이동 후 원본 사용 금지 |
38번 시리즈는 클린 코드(const/noexcept/nodiscard) → 다형성(합성/variant) → 인터페이스(PIMPL/ABI) 순으로 “기능을 어떻게 배치하고 연결할지”의 기초를 다졌습니다.
실무에서 PIMPL이 필수인 경우
라이브러리 개발: Qt, Boost 같은 대형 라이브러리는 PIMPL을 광범위하게 사용합니다. 내부 구현을 바꿔도 공개 헤더가 안정적이어서, 마이너 버전 업데이트 시 사용자가 재컴파일하지 않아도 됩니다.
플러그인 시스템: 호스트 앱과 플러그인이 다른 시점에 빌드될 때, PIMPL로 인터페이스를 고정하면 플러그인 ABI가 깨지지 않아 호환성이 유지됩니다. Adobe, Autodesk 제품의 플러그인 SDK가 이 패턴을 씁니다.
컴파일 시간 단축: 대규모 프로젝트에서 자주 바뀌는 구현 디테일을 PIMPL로 숨기면, 헤더 변경 시 재컴파일 범위가 줄어 빌드 시간이 수십 분에서 수 분으로 단축되는 효과를 볼 수 있습니다.
컴파일 시간 비교: PIMPL 적용 전후
실측 데이터 (중형 프로젝트, 100개 번역 단위):
- PIMPL 적용 전: 헤더 변경 시 전체 재컴파일 약 8분
- PIMPL 적용 후: 헤더 변경 시 재컴파일 약 30초
- 개선율: 약 16배 빠름
핵심 클래스 5~10개에만 PIMPL을 적용해도 전체 빌드 시간이 크게 줄어듭니다. 특히 CI/CD 환경에서 빌드 시간 단축은 개발 속도에 직접적인 영향을 줍니다.
PIMPL 적용 체크리스트
적용하면 좋은 경우:
- 이 클래스가 많은 곳에서 include되는가?
- private 멤버가 자주 바뀌는가?
- 구현 디테일이 복잡한 헤더를 많이 include하는가?
- 라이브러리로 배포되어 ABI 안정성이 중요한가?
적용하지 않아도 되는 경우:
- 템플릿 클래스인가? (PIMPL과 템플릿은 잘 안 맞음)
- 성능이 매우 중요한 핫패스인가? (포인터 간접 참조 비용)
- 헤더 전용 라이브러리인가?
- 클래스가 매우 작고 단순한가?
실전 예제: PIMPL 전체 구현
widget.h (공개 헤더):
#pragma once
#include <memory>
#include <string>
class Widget {
public:
Widget();
~Widget();
Widget(const Widget& other);
Widget& operator=(const Widget& other);
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
void setTitle(const std::string& title);
[[nodiscard]] std::string getTitle() const;
void render();
private:
class Impl;
std::unique_ptr<Impl> pImpl_;
};
widget.cpp (구현):
#include "widget.h"
#include <vector>
#include <iostream>
class Widget::Impl {
public:
std::string title;
std::vector<int> data; // 나중에 추가해도 widget.h는 안 바뀜
void render() {
std::cout << "Rendering: " << title << "\n";
}
};
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
Widget::Widget(const Widget& other)
: pImpl_(std::make_unique<Impl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
*pImpl_ = *other.pImpl_;
}
return *this;
}
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::setTitle(const std::string& title) { pImpl_->title = title; }
std::string Widget::getTitle() const { return pImpl_->title; }
void Widget::render() { pImpl_->render(); }
이 패턴을 사용하면 Impl에 멤버를 추가해도 widget.h를 include하는 파일들은 재컴파일되지 않습니다.
다음 단계로 나아가기
이 글을 마스터했다면:
- 라이브러리 설계: 버전 관리, 심볼 가시성
- 빌드 시스템: CMake로 라이브러리 배포
- 패키지 관리: vcpkg, Conan으로 의존성 관리
관련 글: CMake 고급(#17-1), 패키지 매니저(#17-2)
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]
- C++ 디자인 패턴 종합 가이드 | Singleton·Factory
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
이 글에서 다루는 키워드 (관련 검색어)
PIMPL 패턴, C++ ABI, 바이너리 호환성, 컴파일 의존성, C++ 인터페이스 설계, 헤더 최적화, 전방 선언, unique_ptr PIMPL, C++ 라이브러리 설계 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 헤더만 바꿔도 전부 다시 빌드되는 문제를 PIMPL로 줄이고, 바이너리 호환성(ABI)을 유지하는 실무 기법을 다룹니다. 라이브러리 배포, 플러그인 시스템, 대규모 프로젝트 빌드 시간 단축에 적용합니다. 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: PIMPL로 구현을 숨기고 ABI·컴파일 의존성을 줄일 수 있습니다. 다음으로 캐시·데이터 지향(#39-1)를 읽어보면 좋습니다.
이전 글: C++ 아키텍처 #38-2: 다형성·variant
다음 글: [고성능 C++ #39-1] 캐시 효율적인 코드: 데이터 지향 설계(Data-Oriented Design)와 캐시 라인 최적화
관련 글
- [C++ 클린 코드 기초: const, noexcept, ]로 인터페이스 의도 명확히 하기](/blog/cpp-series-38-1-clean-code-const-noexcept-nodiscard/)
- C++ std::optional·std::variant 완벽 가이드 | nullptr 대신 타입 안전하게
- C++ 현대적 다형성 설계: 상속 대신 합성·variant
- C++ std::string_view·std::span 완벽 가이드 | 제로카피 뷰·댕글링 방지
- C++ ABI 호환성 완벽 가이드 | PIMPL·C 인터페이스·버전 관리·프로덕션 패턴 [#55-4]