C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]

들어가며: 헤더를 바꿀 때마다 전부 다시 컴파일된다

클래스에 private 멤버를 하나 추가했습니다. 그런데 이 클래스를 include하는 모든 .cpp가 다시 컴파일되었습니다. 프로젝트가 커질수록 이 연쇄 재컴파일이 증분 빌드 시간의 대부분을 차지하게 됩니다.

PIMPL(Pointer to Implementation)은 구현 세부를 .cpp 안으로 옮겨 헤더를 바꾸지 않고도 구현을 고칠 수 있게 하는 관용구이고, 브릿지 패턴은 “무엇을 할지”(추상)와 “어떻게 할지”(구현)를 별도 클래스 계층으로 나눠 서로 독립적으로 확장하게 하는 구조 패턴입니다. 두 기법 모두 “구현을 포인터 뒤로 숨긴다”는 점에서 닮아 있습니다.

PIMPL 적용 전후 구조를 보면 아래와 같습니다.

flowchart TB
  subgraph before["PIMPL 없음: 헤더에 구현 노출"]
    B1[widget.h] --> B2[data_, heavy_, internal_]
    B2 --> B3[include하는 모든 .cpp가 재컴파일]
  end
  subgraph after["PIMPL: 구현을 .cpp로 이동"]
    A1[widget.h] --> A2[pImpl 포인터만]
    A2 --> A3[구현 변경 시 widget.cpp만 재컴파일]
    A4[widget.cpp] --> A5[struct Impl: 실제 멤버]
  end

문제가 되는 코드는 다음과 같습니다. private 멤버(data_, heavy_, internal_)가 헤더에 그대로 있으면, 이 클래스를 include하는 모든 .cpp가 해당 타입들의 정의를 알아야 합니다. 특히 SomeBigLibrary처럼 헤더가 무거운 경우, 그 헤더가 조금만 바뀌어도 Widget을 쓰는 파일이 전부 다시 빌드됩니다.

// widget.h - 구현 디테일이 헤더에 노출됨
class Widget {
public:
    void draw();
private:
    std::vector<int> data_;                  // 구현 디테일이 헤더에 노출
    SomeBigLibrary heavy_;                   // 이 헤더를 include하는 쪽도 무거워짐
    std::map<std::string, void*> internal_;  // 바꿀 때마다 전부 리빌드
};

컴파일러는 Widget을 값으로 쓰는 모든 번역 단위에서 sizeof(Widget)과 멤버 레이아웃을 알아야 합니다. 그래서 private 멤버라도 헤더에 정의가 있어야 하고, 그 멤버가 바뀌면 #include "widget.h"를 가진 모든 파일이 영향을 받습니다.

PIMPL로 바꾸면 헤더에는 불완전 타입(전방 선언만 있고 정의가 없어 크기와 멤버를 알 수 없는 타입)인 struct Impl 선언과 std::unique_ptr<Impl> pImpl 하나만 남고, 실제 멤버는 widget.cpp 안의 struct Widget::Impl로 옮겨집니다. Widget을 include하는 쪽은 Impl의 크기를 알 필요가 없으므로, 헤더가 바뀌지 않는 한 widget.cpp만 다시 컴파일됩니다. 소멸자를 .cpp에 정의해야 하는 이유는 아래에서 따로 설명합니다.

// widget.h - 인터페이스만 노출
#include <memory>
class Widget {
public:
    Widget();
    ~Widget();
    void draw();
private:
    struct Impl;                  // 선언만
    std::unique_ptr<Impl> pImpl;  // 크기는 포인터 하나
};

// widget.cpp
struct Widget::Impl {
    std::vector<int> data_;
    SomeBigLibrary heavy_;
    std::map<std::string, void*> internal_;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // Impl이 완전한 타입인 곳에서 정의
void Widget::draw() {
    // pImpl->...
}

PIMPL과 브릿지가 쓰이는 상황

상황증상해결 방향
무거운 라이브러리 도입 (예: Boost.Asio)asio.hpp가 헤더를 타고 퍼져 많은 파일이 재컴파일됨Impl 안에만 Asio 타입을 둠
.so/.dll ABI 호환라이브러리 업데이트 후 클라이언트 앱이 크래시PIMPL로 공개 클래스 레이아웃 고정
플랫폼별 빌드Windows/Linux/macOS API가 달라 #ifdef가 난립브릿지로 플랫폼 구현체 분리
테스트 주입실제 DB/네트워크 없이 단위 테스트가 필요브릿지로 Mock 구현체 주입

예를 들어 NetworkManager에 Boost.Asio 멤버를 추가하면, network_manager.h를 include하는 모든 파일이 Asio 헤더까지 파싱하게 됩니다. asio::io_context를 struct Impl 안에 두면 Asio 헤더는 network_manager.cpp에서만 필요합니다. 공유 라이브러리로 libwidget.so를 배포하는 경우에는 private 멤버 하나만 추가해도 sizeof(Widget)이 바뀌어 기존에 빌드된 클라이언트와 ABI가 어긋날 수 있습니다. PIMPL이면 Widget의 크기가 sizeof(std::unique_ptr<Impl>)로 고정되므로 Impl 내부를 바꿔도 공개 레이아웃은 그대로입니다.

PIMPL: 구현을 포인터 뒤로 숨기기

PIMPL은 클래스의 구현 세부를 .cpp로 옮겨 헤더에는 인터페이스만 두는 관용구로, “컴파일 방화벽”(compilation firewall)이라고도 부릅니다. 헤더가 바뀌는 빈도가 줄어 증분 컴파일 시간이 줄고, 구현이 헤더에 드러나지 않아 캡슐화가 강해지며, 내부 레이아웃을 바꿔도 공개 클래스의 크기가 변하지 않아 ABI를 유지하기 쉬워집니다.

PIMPL 적용 전후 비교

sequenceDiagram
  participant Client as 클라이언트.cpp
  participant Header as widget.h
  participant Impl as widget.cpp (Impl)
  Note over Client,Impl: PIMPL 없음, Widget 멤버 수정 시
  Client->>Header: include widget.h
  Header->>Client: SomeBigLibrary, vector, map 등 전체 정의 전달
  Note over Client: Client도 재컴파일 필요
  Note over Client,Impl: PIMPL 적용, Widget 멤버 수정 시
  Client->>Header: include widget.h
  Header->>Client: Impl 전방 선언과 포인터 멤버만 전달
  Note over Client: 헤더가 그대로면 재컴파일 불필요
  Impl->>Impl: Impl 구조 변경은 widget.cpp만 영향

기본 형태

헤더에는 struct Impl; 전방 선언과 std::unique_ptr<Impl> pImpl만 두고, 생성자·소멸자·이동 생성자·이동 대입 연산자를 선언만 합니다. 정의를 .cpp에 두는 이유는 그 시점에 Impl이 완전한 타입이 되어 unique_ptr가 안전하게 삭제할 수 있기 때문입니다. 원래 private에 두려던 멤버는 모두 Impl에 넣고, 멤버 함수에서는 pImpl->value처럼 접근합니다.

// myclass.h
#include <memory>
class MyClass {
public:
    MyClass();
    ~MyClass();  // 반드시 선언만 (정의는 .cpp)
    MyClass(MyClass&&) noexcept;
    MyClass& operator=(MyClass&&) noexcept;
    void doSomething();
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// myclass.cpp
#include "myclass.h"
#include <string>
struct MyClass::Impl {
    int value = 0;
    std::string name;
    // 무거운 타입, 외부 라이브러리 등
};
MyClass::MyClass() : pImpl(std::make_unique<Impl>()) {}
MyClass::~MyClass() = default;
MyClass::MyClass(MyClass&&) noexcept = default;
MyClass& MyClass::operator=(MyClass&&) noexcept = default;
void MyClass::doSomething() {
    pImpl->value++;
}

unique_ptr를 쓰는 이유는 Impl을 MyClass 하나만 소유하기 때문입니다. 소유권이 코드에 그대로 드러나고, 참조 카운트를 원자적으로 갱신하는 shared_ptr의 비용도 없습니다. 이동 연산도 .cpp에 두는 것은 소멸자와 같은 이유입니다. 기본 이동 대입은 기존 Impl을 delete해야 하므로 Impl이 완전한 타입이어야 하고, 이동 생성자도 컴파일러에 따라 예외 정리 경로에서 소멸자를 요구할 수 있습니다.


복사·이동, 소멸자, 예외 처리까지 PIMPL 구현하기

복사 처리

unique_ptr는 복사할 수 없으므로 PIMPL 클래스는 기본적으로 복사 불가가 됩니다. 복사가 필요하면 Impl을 새로 만들어 내용을 복사하는 깊은 복사를 직접 구현합니다. 복사 생성자는 std::make_unique<Impl>(*other.pImpl)로 새 Impl을 만들고, 복사 대입 연산자는 자기 대입이 아닐 때 *pImpl = *other.pImpl로 멤버 단위 복사를 합니다.

// myclass.h - 복사 선언 추가
class MyClass {
public:
    MyClass(const MyClass& other);
    MyClass& operator=(const MyClass& other);
    // ... 기존 멤버
};
// myclass.cpp - 깊은 복사
MyClass::MyClass(const MyClass& other)
    : pImpl(std::make_unique<Impl>(*other.pImpl)) {}
MyClass& MyClass::operator=(const MyClass& other) {
    if (this != &other) {
        *pImpl = *other.pImpl;
    }
    return *this;
}

이 코드는 Impl이 복사 가능할 때만 컴파일됩니다. Impl에 std::unique_ptr나 std::mutex 같은 복사 불가 멤버가 있으면 Impl의 복사 의미를 직접 정의해야 합니다(아래 “Impl에 복사 불가 타입이 있을 때” 참고). 또 other가 이동된 객체라 other.pImpl이 비어 있으면 *other.pImpl은 널 역참조가 되므로, 이동 후 객체를 복사할 가능성이 있다면 널 검사를 추가합니다.

구현체에서 외부 타입 사용

무거운 외부 라이브러리는 myclass.h가 아니라 myclass.cpp에서만 include합니다. 그러면 HeavyLibrary 정의는 이 .cpp의 컴파일 단위에서만 필요하고, MyClass를 쓰는 다른 파일들은 heavy_library.h를 끌어오지 않습니다.

// myclass.h - HeavyLibrary를 include하지 않음
#include <memory>
class MyClass {
    // ...
};
// myclass.cpp - 여기서만 무거운 헤더 include
#include "myclass.h"
#include "heavy_library.h"
struct MyClass::Impl {
    HeavyLibrary lib;  // .cpp에만 의존
};

Boost, Qt, OpenCV처럼 헤더가 큰 라이브러리를 Impl 안에 가두면, 그 라이브러리를 직접 쓰지 않는 나머지 코드의 컴파일이 가벼워집니다.

소멸자를 .cpp에 정의해야 하는 이유

std::unique_ptr<Impl>의 소멸자는 기본 deleter인 std::default_delete<Impl>을 통해 delete를 호출하고, default_delete는 불완전 타입에 대해 static_assert로 컴파일을 막습니다. 소멸자를 아예 선언하지 않거나 헤더 안에서 = default로 정의하면, 컴파일러는 헤더를 include한 번역 단위에서 소멸자를 인라인으로 만들고, 그 시점의 Impl은 불완전 타입이므로 “invalid application of ‘sizeof’ to incomplete type” 같은 에러가 납니다.

그래서 헤더에는 ~Widget();만 선언하고, .cpp에서 Impl 정의 뒤에 Widget::~Widget() = default;로 정의합니다.

// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    Widget();
    ~Widget();  // 선언만
};
// widget.cpp
struct Widget::Impl { /* ... */ };
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // 여기서 Impl은 완전한 타입

헤더에 ~Widget();을 선언해 놓고 .cpp에 정의를 빠뜨리면 컴파일 에러가 아니라 “undefined reference to Widget::~Widget()” 같은 링크 에러가 납니다. 두 에러는 원인이 다르므로 메시지로 구분할 수 있습니다.

생성자에서 예외가 발생할 때

생성자 실행 중 예외가 나면 그 객체의 소멸자는 호출되지 않고, 이미 생성이 끝난 멤버만 역순으로 파괴됩니다. pImpl을 멤버 이니셜라이저에서 초기화해 두면, 생성자 본문에서 예외가 나더라도 pImpl은 이미 생성된 멤버이므로 unique_ptr 소멸자가 Impl을 해제합니다. 본문에서 대입하는 방식도 같은 이유로 누수는 없지만, Impl 생성 전에 다른 작업이 있으면 그 단계의 정리를 직접 신경 써야 하므로 이니셜라이저에서 초기화하는 편이 단순합니다.

// 권장: 멤버 이니셜라이저에서 초기화
Widget::Widget() : pImpl(std::make_unique<Impl>()) {
    // 여기서 예외가 나도 pImpl은 해제됨
}

브릿지 패턴: 추상과 구현을 따로 확장하기

추상과 구현 분리

브릿지 패턴은 GoF 구조 패턴의 하나로, 추상(Abstraction) 계층과 구현(Implementor) 계층을 분리해 서로 독립적으로 바꿀 수 있게 합니다. 렌더링을 예로 들면, 추상은 “도형을 그린다”이고 구현은 OpenGL, DirectX, 소프트웨어 렌더링 같은 실제 그리기 방식입니다.

flowchart TB
  subgraph abstraction["추상 (Abstraction)"]
    S[Shape]
    C[Circle]
    R[Rectangle]
    S --> C
    S --> R
  end
  subgraph implementor["구현 (Implementor)"]
    RI[Renderer 인터페이스]
    OGL[OpenGLRenderer]
    SW[SoftwareRenderer]
    RI --> OGL
    RI --> SW
  end
  C -->|"renderer->drawCircle()"| RI
  R -->|"renderer->drawRect()"| RI

구현 쪽에서는 Renderer 인터페이스가 “원 그리기”, “사각형 그리기” 같은 저수준 연산을 정의하고, OpenGLRenderer와 SoftwareRenderer가 각자 방식으로 구현합니다. 추상 쪽의 Shape는 Renderer*를 갖고, Circle과 Rectangle은 draw() 안에서 renderer->drawCircle(...) 같은 메서드를 호출합니다. 도형 종류와 렌더링 방식이 서로 독립적으로 늘어나고, 런타임에 넘겨주는 Renderer만 바꿔도 동작이 달라집니다.

// 구현 쪽 (Implementor)
class Renderer {
public:
    virtual ~Renderer() = default;
    virtual void drawCircle(int x, int y, int r) = 0;
    virtual void drawRect(int x, int y, int w, int h) = 0;
};
class OpenGLRenderer : public Renderer {
public:
    void drawCircle(int x, int y, int r) override {
        // OpenGL 호출
    }
    void drawRect(int x, int y, int w, int h) override {
        // OpenGL 호출
    }
};
class SoftwareRenderer : public Renderer {
public:
    void drawCircle(int x, int y, int r) override {
        // 픽셀 그리기
    }
    void drawRect(int x, int y, int w, int h) override {
        // 픽셀 그리기
    }
};
// 추상 쪽 (Abstraction)
class Shape {
protected:
    Renderer* renderer;  // 소유하지 않음
public:
    explicit Shape(Renderer* r) : renderer(r) {}
    virtual ~Shape() = default;
    virtual void draw() = 0;
};
class Circle : public Shape {
    int x, y, radius;
public:
    Circle(Renderer* r, int x, int y, int radius)
        : Shape(r), x(x), y(y), radius(radius) {}
    void draw() override {
        renderer->drawCircle(x, y, radius);
    }
};
class Rectangle : public Shape {
    int x, y, w, h;
public:
    Rectangle(Renderer* r, int x, int y, int w, int h)
        : Shape(r), x(x), y(y), w(w), h(h) {}
    void draw() override {
        renderer->drawRect(x, y, w, h);
    }
};

같은 Circle이라도 OpenGLRenderer를 넘기면 OpenGL로, SoftwareRenderer를 넘기면 소프트웨어로 그려집니다. 도형 클래스는 어떤 렌더러를 쓸지만 받고 실제 그리기는 구현체에 맡기므로, 새 렌더링 백엔드를 추가해도 기존 Shape 코드는 수정하지 않습니다. 여기서 Shape는 Renderer를 소유하지 않으므로, 렌더러가 도형보다 오래 살아 있어야 합니다.

OpenGLRenderer gl;
SoftwareRenderer sw;
Circle c1(&gl, 10, 10, 5);
Circle c2(&sw, 20, 20, 5);
c1.draw();  // OpenGL
c2.draw();  // Software

PIMPL과 브릿지 함께 쓰기

브릿지의 구현체 타입을 헤더에서 숨기면, 사용자에게는 Shape 인터페이스만 보이고 실제 렌더러 타입은 .cpp나 플랫폼별 라이브러리 안에 남습니다.

// shape.h - 사용자에게 보이는 인터페이스만
#include <memory>
class Renderer;  // 전방 선언
class Shape {
public:
    explicit Shape(std::unique_ptr<Renderer> renderer);
    virtual ~Shape();  // .cpp에서 정의 (Renderer가 불완전 타입이므로)
    virtual void draw() = 0;
protected:
    std::unique_ptr<Renderer> renderer;
};
// shape.cpp
#include "shape.h"
#include "renderer.h"  // 여기서 Renderer가 완전한 타입이 됨
Shape::Shape(std::unique_ptr<Renderer> renderer)
    : renderer(std::move(renderer)) {}
Shape::~Shape() = default;

이렇게 하면 렌더러 정의와 구체 타입이 헤더에 노출되지 않아 컴파일 의존성이 줄어듭니다.

브릿지 vs 상속만 사용

flowchart LR
  subgraph bad["상속만: 조합 폭발"]
    B1[Circle] --> B2[OpenGLCircle]
    B1 --> B3[DirectXCircle]
    B1 --> B4[SoftwareCircle]
    R1[Rectangle] --> R2[OpenGLRectangle]
    R1 --> R3[DirectXRectangle]
    R1 --> R4[SoftwareRectangle]
  end
  subgraph good["브릿지: 독립적 확장"]
    G1[Circle] --> G5[Renderer]
    G2[Rectangle] --> G5
    G5 --> G6[OpenGL]
    G5 --> G7[DirectX]
    G5 --> G8[Software]
  end

상속만 쓰면 도형 N종과 렌더러 M종의 조합마다 클래스가 필요해 N×M개가 됩니다. 브릿지는 두 계층을 분리하므로 N+M개로 충분합니다.

PIMPL 예제: DatabaseConnection

헤더, 구현, 사용 코드 전체 예제입니다.

// database_connection.h - 공개 API만 노출
#pragma once
#include <memory>
#include <string>
class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& connection_string);
    ~DatabaseConnection();
    DatabaseConnection(const DatabaseConnection&) = delete;
    DatabaseConnection& operator=(const DatabaseConnection&) = delete;
    DatabaseConnection(DatabaseConnection&&) noexcept;
    DatabaseConnection& operator=(DatabaseConnection&&) noexcept;
    bool connect();
    void disconnect();
    bool execute(const std::string& query);
    bool isConnected() const;
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// database_connection.cpp - 구현 디테일
#include "database_connection.h"
#include <sqlite3.h>  // 헤더에 노출되지 않음
struct DatabaseConnection::Impl {
    sqlite3* db = nullptr;
    std::string connection_string;
    bool connected = false;
    ~Impl() { if (db) sqlite3_close(db); }
};
DatabaseConnection::DatabaseConnection(const std::string& connection_string)
    : pImpl(std::make_unique<Impl>()) { pImpl->connection_string = connection_string; }
DatabaseConnection::~DatabaseConnection() = default;
DatabaseConnection::DatabaseConnection(DatabaseConnection&&) noexcept = default;
DatabaseConnection& DatabaseConnection::operator=(DatabaseConnection&&) noexcept = default;
bool DatabaseConnection::connect() {
    if (pImpl->connected) return true;
    // sqlite3_open은 실패해도 핸들을 할당할 수 있으므로 실패 시 닫아 줌
    if (sqlite3_open(pImpl->connection_string.c_str(), &pImpl->db) != SQLITE_OK) {
        sqlite3_close(pImpl->db);
        pImpl->db = nullptr;
        return false;
    }
    pImpl->connected = true;
    return true;
}
void DatabaseConnection::disconnect() {
    if (pImpl->db) { sqlite3_close(pImpl->db); pImpl->db = nullptr; }
    pImpl->connected = false;
}
bool DatabaseConnection::execute(const std::string& query) {
    if (!pImpl->connected || !pImpl->db) return false;
    char* err = nullptr;
    int rc = sqlite3_exec(pImpl->db, query.c_str(), nullptr, nullptr, &err);
    if (err) sqlite3_free(err);
    return rc == SQLITE_OK;
}
bool DatabaseConnection::isConnected() const { return pImpl->connected; }
// main.cpp - 사용자 코드
#include "database_connection.h"
// sqlite3.h를 include할 필요가 없음
int main() {
    DatabaseConnection conn(":memory:");
    if (conn.connect()) conn.execute("CREATE TABLE test (id INT);");
    conn.disconnect();
    return 0;
}

main.cpp는 sqlite3.h를 전혀 include하지 않습니다. 이동 생성자와 이동 대입도 헤더에서 = default로 정의하지 않고 .cpp로 옮겼는데, 헤더에서 기본 정의된 이동 대입은 기존 Impl을 delete해야 하므로 불완전 타입 에러를 냅니다. DB 라이브러리를 다른 것으로 바꾸더라도 공개 헤더가 그대로라면 main.cpp는 다시 컴파일할 필요가 없고 다시 링크만 하면 됩니다.

브릿지 예제: 플랫폼별 파일 시스템

공통 헤더와 공통 구현, 플랫폼별 구현을 파일로 나눕니다.

// file_system.h - 플랫폼 독립 인터페이스
#pragma once
#include <memory>
#include <string>
#include <vector>
class IFileSystemImpl {
public:
    virtual ~IFileSystemImpl() = default;
    virtual bool exists(const std::string& path) = 0;
    virtual std::string readFile(const std::string& path) = 0;
    virtual bool writeFile(const std::string& path, const std::string& content) = 0;
    virtual std::vector<std::string> list(const std::string& path) = 0;
};
class FileSystem {
public:
    explicit FileSystem(std::unique_ptr<IFileSystemImpl> impl);
    ~FileSystem();
    bool exists(const std::string& path) const;
    std::string readFile(const std::string& path) const;
    bool writeFile(const std::string& path, const std::string& content) const;
    std::vector<std::string> list(const std::string& path) const;
private:
    std::unique_ptr<IFileSystemImpl> pImpl;
};
// 플랫폼별 .cpp가 하나씩 정의
std::unique_ptr<FileSystem> createFileSystem();
// file_system.cpp - 모든 플랫폼 공통
#include "file_system.h"
FileSystem::FileSystem(std::unique_ptr<IFileSystemImpl> impl)
    : pImpl(std::move(impl)) {}
FileSystem::~FileSystem() = default;
bool FileSystem::exists(const std::string& path) const { return pImpl->exists(path); }
std::string FileSystem::readFile(const std::string& path) const { return pImpl->readFile(path); }
bool FileSystem::writeFile(const std::string& path, const std::string& content) const {
    return pImpl->writeFile(path, content);
}
std::vector<std::string> FileSystem::list(const std::string& path) const {
    return pImpl->list(path);
}
// file_system_win.cpp - Windows 구현
#include "file_system.h"
#include <windows.h>
#include <fstream>
#include <iterator>
class WinFileSystemImpl : public IFileSystemImpl {
public:
    bool exists(const std::string& path) override {
        DWORD attr = GetFileAttributesA(path.c_str());
        return attr != INVALID_FILE_ATTRIBUTES;
    }
    std::string readFile(const std::string& path) override {
        std::ifstream f(path, std::ios::binary);
        return std::string((std::istreambuf_iterator<char>(f)),
                           std::istreambuf_iterator<char>());
    }
    bool writeFile(const std::string& path, const std::string& content) override {
        std::ofstream f(path, std::ios::binary);
        return static_cast<bool>(f << content);
    }
    std::vector<std::string> list(const std::string& path) override {
        std::vector<std::string> result;
        WIN32_FIND_DATAA fd;
        std::string pattern = path + "\\*";
        HANDLE h = FindFirstFileA(pattern.c_str(), &fd);
        if (h != INVALID_HANDLE_VALUE) {
            do {
                result.push_back(fd.cFileName);
            } while (FindNextFileA(h, &fd));
            FindClose(h);
        }
        return result;
    }
};
std::unique_ptr<FileSystem> createFileSystem() {
    return std::make_unique<FileSystem>(std::make_unique<WinFileSystemImpl>());
}
// file_system_posix.cpp - Linux/macOS 구현
#include "file_system.h"
#include <sys/stat.h>
#include <dirent.h>
#include <fstream>
#include <iterator>
class PosixFileSystemImpl : public IFileSystemImpl {
public:
    bool exists(const std::string& path) override {
        struct stat st;
        return stat(path.c_str(), &st) == 0;
    }
    std::string readFile(const std::string& path) override {
        std::ifstream f(path, std::ios::binary);
        return std::string((std::istreambuf_iterator<char>(f)),
                           std::istreambuf_iterator<char>());
    }
    bool writeFile(const std::string& path, const std::string& content) override {
        std::ofstream f(path, std::ios::binary);
        return static_cast<bool>(f << content);
    }
    std::vector<std::string> list(const std::string& path) override {
        std::vector<std::string> result;
        DIR* d = opendir(path.c_str());
        if (d) {
            struct dirent* ent;
            while ((ent = readdir(d)) != nullptr)
                result.push_back(ent->d_name);
            closedir(d);
        }
        return result;
    }
};
std::unique_ptr<FileSystem> createFileSystem() {
    return std::make_unique<FileSystem>(std::make_unique<PosixFileSystemImpl>());
}

빌드할 때는 file_system.cpp를 항상 포함하고, Windows에서는 file_system_win.cpp만, Linux/macOS에서는 file_system_posix.cpp만 함께 링크합니다. 플랫폼별 헤더(windows.h, dirent.h)는 각 .cpp에만 들어갑니다. 두 구현의 list()는 모두 .과 .. 항목도 돌려주므로, 필요하면 걸러내야 합니다. C++17 이상이라면 이 정도 기능은 std::filesystem으로 플랫폼 분기 없이 구현할 수 있다는 점도 고려할 만합니다.


대형 라이브러리 감추기와 GUI 위젯 예시

설정 클래스에서 파서 감추기

설정 클래스가 내부적으로 맵, 파서, 파일 I/O 등 여러 타입을 쓰더라도, 헤더에는 get/set 같은 공개 API만 두고 나머지는 struct Impl 안으로 숨깁니다. 사용하는 쪽은 config.h만 include하면 되고, 구현이 바뀌어도 헤더가 그대로면 재컴파일 범위가 config.cpp로 한정됩니다.

// config.h - 외부에는 간단한 인터페이스만
#include <memory>
#include <string>
class Config {
public:
    Config();
    ~Config();
    std::string get(const std::string& key) const;
    void set(const std::string& key, const std::string& value);
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// config.cpp - 구현 디테일
#include "config.h"
#include <map>
#include <fstream>
#include "json_parser.h"  // 무거운 파서
struct Config::Impl {
    std::map<std::string, std::string> data;
    JsonParser parser;
    std::ifstream file;
};
Config::Config() : pImpl(std::make_unique<Impl>()) {}
Config::~Config() = default;
std::string Config::get(const std::string& key) const {
    auto it = pImpl->data.find(key);
    return it != pImpl->data.end() ? it->second : "";
}
void Config::set(const std::string& key, const std::string& value) {
    pImpl->data[key] = value;
}

스마트 포인터로 구현체 소유하기 (브릿지)

로거가 파일 출력, 콘솔 출력, 원격 전송 같은 여러 백엔드 중 하나를 쓰게 하려면, ILogBackend 인터페이스를 두고 생성 시 std::unique_ptr<ILogBackend>를 받아 저장합니다. Logger는 백엔드에 쓰기만 하고 실제 출력 방식은 주입된 구현체가 담당하는 브릿지 구조가 됩니다. 테스트에서는 mock 백엔드를 넣으면 됩니다.

// logger.h
#include <memory>
#include <string>
class ILogBackend {
public:
    virtual ~ILogBackend() = default;
    virtual void write(const std::string& msg) = 0;
};
class Logger {
public:
    explicit Logger(std::unique_ptr<ILogBackend> backend)
        : backend_(std::move(backend)) {}
    void log(const std::string& msg) {
        backend_->write(msg);
    }
private:
    std::unique_ptr<ILogBackend> backend_;
};
// console_backend.cpp
#include "logger.h"
#include <iostream>
class ConsoleBackend : public ILogBackend {
public:
    void write(const std::string& msg) override {
        std::cout << msg << '\n';
    }
};
// 사용 (함수 안에서)
Logger logger(std::make_unique<ConsoleBackend>());
logger.log("Hello");

여기서는 ILogBackend가 logger.h에 완전히 정의되어 있으므로 Logger의 소멸자를 헤더에 둬도 문제가 없습니다. 불완전 타입 문제는 전방 선언만 한 타입을 unique_ptr로 가질 때만 생깁니다.

GUI 위젯 라이브러리

Qt는 공개 클래스마다 d-pointer라는 이름으로 PIMPL을 일관되게 사용합니다. 위젯 클래스가 플랫폼별 네이티브 핸들(HWND, NSView 등)을 멤버로 가지면 해당 플랫폼 헤더가 공개 헤더로 새어 나갑니다. PIMPL로 감추면 플랫폼 코드는 .cpp에만 있고, 공개 API와 레이아웃은 안정적으로 유지됩니다.

// button.h - 플랫폼 독립적
#include <memory>
#include <string>
class Button {
public:
    explicit Button(const std::string& label);
    ~Button();
    void setEnabled(bool enabled);
    void setVisible(bool visible);
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// button_win.cpp - Windows 전용
#include "button.h"
#include <windows.h>
struct Button::Impl {
    HWND hwnd = nullptr;
    std::string label;
    bool enabled = true;
    bool visible = true;
};
Button::Button(const std::string& label) : pImpl(std::make_unique<Impl>()) {
    pImpl->label = label;
    // CreateWindowEx 등으로 HWND 생성
}
Button::~Button() = default;  // 실제로는 DestroyWindow 호출도 필요
void Button::setEnabled(bool enabled) {
    pImpl->enabled = enabled;
    if (pImpl->hwnd) EnableWindow(pImpl->hwnd, enabled);
}
void Button::setVisible(bool visible) {
    pImpl->visible = visible;
    if (pImpl->hwnd) ShowWindow(pImpl->hwnd, visible ? SW_SHOW : SW_HIDE);
}

자주 만나는 문제

문제 1: “invalid application of ‘sizeof’ to incomplete type”

원인은 앞에서 설명한 것처럼 unique_ptr<Impl>의 소멸자(또는 이동 대입, reset)가 Impl이 불완전 타입인 위치에서 인스턴스화되는 것입니다. 소멸자를 선언하지 않았거나, 헤더에서 ~Widget() = default;로 정의했거나, .cpp에서 소멸자 정의가 Impl 정의보다 앞에 있을 때 발생합니다.

// 잘못된 예 1: 소멸자를 선언하지 않음 → 헤더에서 암묵적 인라인 소멸자 생성
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    Widget();
};

// 잘못된 예 2: 헤더에서 default 정의 → 같은 문제
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    Widget();
    ~Widget() = default;
};
// 올바른 예
// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    Widget();
    ~Widget();  // 선언만
};
// widget.cpp
struct Widget::Impl { /* ... */ };
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // Impl 정의 뒤에서 정의

문제 2: 헤더에서 Impl 멤버에 접근

Impl이 불완전 타입인 곳에서 pImpl->member처럼 멤버에 접근하면 “incomplete type” 또는 MSVC의 “use of undefined type” 에러가 납니다. 헤더에 인라인 멤버 함수를 두면 흔히 생기는 실수입니다. Impl을 사용하는 코드(멤버 접근, 복사 생성자의 *other.pImpl 등)는 모두 Impl이 정의된 .cpp에 둡니다.

// 잘못된 예: 헤더의 인라인 함수에서 Impl 멤버 접근
// widget.h
inline void Widget::foo() {
    pImpl->value++;  // Impl이 불완전 타입이므로 에러
}
// 올바른 예: .cpp에서 접근
// widget.cpp
void Widget::foo() {
    pImpl->value++;  // 여기서 Impl은 완전한 타입
}

문제 3: 복사가 안 된다

unique_ptr 멤버는 복사할 수 없으므로 컴파일러가 만드는 복사 생성자와 복사 대입 연산자는 삭제(deleted)됩니다. 그래서 MyClass b = a; 같은 코드는 “use of deleted function” 컴파일 에러를 냅니다. 복사가 필요하면 앞의 “복사 처리”처럼 깊은 복사를 직접 구현합니다.

문제 4: 이동 후 사용 (use-after-move)

PIMPL 객체를 std::move로 이동하면 원본의 pImpl은 nullptr가 됩니다. 이 상태에서 원본의 메서드를 호출하면 널 포인터 역참조로 미정의 동작이 됩니다.

MyClass a;
MyClass b = std::move(a);
a.doSomething();  // a.pImpl은 nullptr → 미정의 동작
b.doSomething();  // b만 사용해야 함

“이동된 객체는 대입이나 소멸 외에는 쓰지 않는다”는 규칙을 지키는 것이 기본입니다. 이동 후에도 쓸 수 있어야 하는 타입이라면 메서드에서 if (!pImpl)을 검사하거나, 이동 연산에서 원본에 새 Impl을 만들어 주는 방식을 택합니다(이 경우 이동이 할당을 하게 되어 noexcept를 보장하기 어렵습니다).

문제 5: Impl에 복사 불가 타입이 있을 때

Impl에 std::unique_ptr나 std::mutex 같은 복사 불가 타입이 있으면 *pImpl = *other.pImpl 같은 멤버 단위 복사가 컴파일되지 않습니다.

// Impl에 unique_ptr가 있으면
struct Widget::Impl {
    std::unique_ptr<SomeResource> resource;  // 복사 불가
};
// Widget::Widget(const Widget& other)
//     : pImpl(std::make_unique<Impl>(*other.pImpl)) {}  // 컴파일 에러
// 해결: Impl에 복사 의미를 직접 정의
struct Widget::Impl {
    std::unique_ptr<SomeResource> resource;
    Impl() = default;  // 복사 생성자를 선언하면 기본 생성자가 사라지므로 명시
    Impl(const Impl& other)
        : resource(other.resource ? std::make_unique<SomeResource>(*other.resource) : nullptr) {}
    Impl& operator=(const Impl& other) {
        if (this != &other)
            resource = other.resource ? std::make_unique<SomeResource>(*other.resource) : nullptr;
        return *this;
    }
};

문제 6: 전방 선언 순서

클래스 안에서 std::unique_ptr<Impl> 멤버 선언이 struct Impl;보다 앞에 오면, 그 시점에 Impl이라는 이름을 찾을 수 없어 컴파일 에러가 납니다. 멤버 함수 본문과 달리 멤버 선언의 타입은 선언 위치까지 본 이름만 사용할 수 있습니다.

// 잘못된 예
class Widget {
    std::unique_ptr<Impl> pImpl;  // Impl이 아직 선언되지 않음
    struct Impl;
};
// 올바른 예
class Widget {
    struct Impl;                  // 먼저 전방 선언
    std::unique_ptr<Impl> pImpl;
};

문제 7: shared_ptr로 인한 순환 참조

브릿지에서 Shape가 Renderer를 shared_ptr로 갖고, Renderer 구현체가 다시 Shape를 shared_ptr로 참조하면 참조 카운트가 0이 되지 않아 둘 다 해제되지 않습니다.

// 순환 참조
class Shape {
    std::shared_ptr<Renderer> renderer;
};
class CachedRenderer : public Renderer {
    std::shared_ptr<Shape> cached_shape;  // Shape → Renderer → Shape
};
// 해결: 역방향은 weak_ptr로
class CachedRenderer : public Renderer {
    std::weak_ptr<Shape> cached_shape;  // 순환을 끊음
};
// 또는 Shape가 unique_ptr<Renderer>만 갖고, Renderer는 Shape를 참조하지 않도록 설계

소유 관계가 한 방향이면 처음부터 unique_ptr를 쓰는 편이 이런 문제를 원천적으로 막습니다.

문제 8: const 메서드가 Impl을 수정해도 컴파일된다

PIMPL에서 쉽게 놓치는 함정은 const가 포인터를 따라 전파되지 않는다는 점입니다. const 멤버 함수 안에서 pImpl 자체는 const std::unique_ptr<Impl>이지만, operator->는 Impl*(const가 아닌 포인터)를 돌려줍니다. 그래서 아래 코드는 mutable 없이도 그대로 컴파일되고, const 객체를 여러 스레드에서 동시에 읽는 상황에서 데이터 레이스가 생길 수 있습니다.

// const 메서드인데 내부 상태를 바꿈 (컴파일 에러 없음)
std::string Widget::getName() const {
    if (pImpl->name_cache.empty())
        pImpl->name_cache = computeName();  // 여러 스레드가 동시에 호출하면 레이스
    return pImpl->name_cache;
}

const 정확성을 지키려면 const 버전과 비const 버전의 접근자를 두고 const 메서드에서는 const 접근자만 쓰거나, std::experimental::propagate_const<std::unique_ptr<Impl>>(Library Fundamentals TS v2, libstdc++ 등에서 제공)를 사용합니다. 캐시가 정말 필요하면 뮤텍스나 std::call_once로 보호합니다.

class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
    Impl* impl() { return pImpl.get(); }
    const Impl* impl() const { return pImpl.get(); }  // const 메서드에서는 이쪽만 사용
    // ...
};

컴파일 시간과 런타임 오버헤드의 트레이드오프

컴파일 시간

상황PIMPL 없음PIMPL 적용
Widget에 private 멤버 추가Widget을 include하는 모든 .cpp 재컴파일widget.cpp만 재컴파일
Widget을 N개 파일이 사용N개 파일 재컴파일widget.cpp 1개 재컴파일
HeavyLibrary 헤더 변경Widget 사용처 전부 영향widget.cpp만 영향

공개 헤더 자체(공개 메서드 추가 등)를 바꾸면 PIMPL을 써도 사용처가 모두 다시 컴파일된다는 점은 같습니다. PIMPL이 줄여 주는 것은 private 구현이 바뀔 때의 재컴파일입니다.

런타임 오버헤드

PIMPL에는 세 가지 비용이 있습니다. 첫째, 객체를 만들 때마다 make_unique로 힙 할당이 한 번 일어납니다. 둘째, 멤버에 접근할 때 포인터를 한 번 더 따라가야 하고, Impl이 객체와 떨어진 힙 위치에 있어 캐시 미스가 늘 수 있습니다. 셋째, 멤버 함수가 .cpp에 있으므로 링크 타임 최적화(LTO)를 켜지 않으면 작은 접근자도 인라인되지 않습니다.

그래서 작은 값 타입이나 핫 루프 안에서 대량으로 생성되는 객체에는 PIMPL이 맞지 않습니다. 헤더가 무겁거나 컴파일 의존성이 넓게 퍼진 클래스, ABI를 고정해야 하는 공개 라이브러리 클래스에 적용하는 것이 좋습니다. 실제 비용이 문제가 되는지는 대상 코드에서 직접 프로파일링해 확인해야 합니다.

메모리 레이아웃은 다음과 같이 달라집니다.

// 일반 클래스: 모든 멤버가 객체 안에 연속으로 배치
class Widget {
    std::vector<int> data_;      // 64비트 주요 구현에서 보통 24바이트
    SomeBigLibrary heavy_;       // N바이트
    std::map<...> internal_;     // M바이트
};  // sizeof(Widget) >= 24 + N + M (패딩 포함)
// PIMPL: 포인터 하나만
class Widget {
    std::unique_ptr<Impl> pImpl;  // 64비트에서 8바이트
};  // sizeof(Widget) == 8 (기본 deleter 기준)

PIMPL, 상속, 일반 멤버 비교

항목일반 멤버PIMPL상속(가상 함수)
컴파일 의존성private 멤버 변경 시 넓은 재컴파일구현 변경 시 .cpp만인터페이스 헤더만 공개하면 낮음
메모리객체 안에 연속, 캐시 친화적힙 할당 1회 + 포인터vptr + 보통 힙 할당
호출 비용직접 접근, 인라인 가능포인터 1단계가상 호출(간접 호출)
ABI 안정성멤버 추가 시 깨짐Impl 내부 변경은 안전가상 함수 추가 순서에 민감
구현 교체불가빌드 시점에 결정런타임 교체 가능

PIMPL이 적합한 경우는 Boost, Qt, OpenCV 같은 무거운 외부 헤더를 감출 때, private 멤버 변경이 잦고 이 클래스를 include하는 파일이 많을 때, ABI 안정성이 중요한 라이브러리를 배포할 때입니다. 반대로 클래스가 작고 include하는 곳이 한두 군데뿐이거나, 성능이 중요한 경로에서 힙 할당을 피해야 할 때는 생략하는 편이 낫습니다.


플랫폼별 팩토리, ABI 안정 라이브러리, Mock 브릿지

팩토리와 PIMPL

플랫폼별 구현체를 헤더에 노출하지 않고, 공개 클래스와 생성 함수만 공개합니다. 헤더는 하나이고, 빌드 시 플랫폼에 맞는 .cpp만 링크합니다.

// widget.h
#include <memory>
class Widget {
public:
    static std::unique_ptr<Widget> create();
    ~Widget();
    void show();
private:
    Widget();  // create()로만 생성
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// widget_win.cpp - Windows용
#include "widget.h"
#include <windows.h>
struct Widget::Impl { HWND hwnd = nullptr; /* ... */ };
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
// 생성자가 private이므로 make_unique 대신 new 사용
std::unique_ptr<Widget> Widget::create() { return std::unique_ptr<Widget>(new Widget()); }
void Widget::show() { /* ShowWindow(pImpl->hwnd, SW_SHOW); */ }
// widget_qt.cpp - Qt용
#include "widget.h"
#include <QWidget>
struct Widget::Impl { QWidget* q = nullptr; /* ... */ };
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;
std::unique_ptr<Widget> Widget::create() { return std::unique_ptr<Widget>(new Widget()); }
void Widget::show() { /* pImpl->q->show(); */ }

ABI 안정 공유 라이브러리

.so/.dll로 배포할 때 PIMPL로 공개 클래스의 크기를 고정해 ABI 호환을 유지합니다. PUBLIC_API는 플랫폼에 따라 __declspec(dllexport)나 visibility 속성으로 정의하는 내보내기 매크로입니다.

// public_api.h - 배포용 헤더, 데이터 멤버 추가/삭제 금지
#include <memory>
class PUBLIC_API PublicService {
public:
    PublicService();
    ~PublicService();
    void doWork();
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

Impl 내부는 자유롭게 바꿔도 PublicService의 크기는 sizeof(std::unique_ptr<Impl>)로 유지됩니다. 다만 ABI는 크기만의 문제가 아닙니다. 가상 함수를 추가하거나 인라인 함수의 동작을 바꾸거나, 헤더 안에 STL 타입을 노출하면서 표준 라이브러리 버전이 달라지는 경우에도 호환성이 깨질 수 있습니다.

테스트용 Mock 브릿지

단위 테스트에서 실제 DB나 네트워크 대신 Mock 구현체를 주입합니다.

// database.h
#include <memory>
#include <string>
class IDatabase {
public:
    virtual ~IDatabase() = default;
    virtual bool execute(const std::string& query) = 0;
};
class DatabaseService {
public:
    explicit DatabaseService(std::unique_ptr<IDatabase> db);
    bool runReport();
private:
    std::unique_ptr<IDatabase> db_;
};
// test.cpp (GoogleTest)
class MockDatabase : public IDatabase {
public:
    bool execute(const std::string& query) override {
        executed_queries.push_back(query);
        return true;
    }
    std::vector<std::string> executed_queries;
};
TEST(DatabaseService, RunReport) {
    auto mock = std::make_unique<MockDatabase>();
    auto* ptr = mock.get();  // 소유권을 넘기기 전에 관찰용 포인터 확보
    DatabaseService svc(std::move(mock));
    svc.runReport();
    ASSERT_EQ(ptr->executed_queries.size(), 1u);
}

기존 코드를 PIMPL로 옮기기

한 번에 모든 멤버를 옮기기 어렵다면 단계적으로 전환할 수 있습니다. 먼저 struct Impl 선언과 std::unique_ptr<Impl> pImpl 멤버를 추가하고 기존 멤버는 그대로 둡니다. 다음으로 생성자에서 pImpl을 만들고, 멤버를 하나씩 Impl로 옮기면서 메서드를 pImpl->x 접근으로 바꿉니다. 모든 접근이 pImpl로 옮겨지면 헤더에서 기존 멤버를 제거합니다. 공개 헤더의 레이아웃이 바뀌는 마지막 단계에서는 ABI가 한 번 깨진다는 점을 염두에 둡니다.

Impl 생성 지연 (Lazy Impl)

생성 비용이 큰 Impl을 실제로 필요할 때 만듭니다. 이 경우에도 소멸자는 .cpp에 정의해야 합니다.

class HeavyWidget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
    void ensureImpl();  // .cpp: if (!pImpl) pImpl = std::make_unique<Impl>();
public:
    HeavyWidget();
    ~HeavyWidget();     // .cpp에서 정의
    void draw();        // .cpp: ensureImpl(); pImpl->render();
};

여러 스레드가 동시에 처음 접근할 수 있다면 std::once_flag와 std::call_once로 초기화를 한 번만 수행하도록 보호합니다.

참고 자료


같이 보면 좋은 글