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

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

이 글의 핵심

C++ PIMPL과 브릿지 패턴에 대한 실전 가이드입니다. 구현 숨기기와 추상화 [#19-3] 등을 예제와 함께 상세히 설명합니다.

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

”한 줄만 바꿨는데 빌드가 10분 걸려요”

클래스에 private 멤버를 하나 추가했습니다. 그런데 이 클래스를 쓰는 모든 .cpp가 다시 컴파일되었습니다. 대규모 프로젝트에서 이 현상은 빌드 시간을 수 분에서 수십 분으로 늘립니다.

비유하면 PIMPL은 “선물 상자”와 같습니다. 상자 밖에서는 “무엇이 들어있는지” 알 수 없고, 상자만 전달하면 됩니다. 내용물(구현)을 바꿔도 상자(헤더)는 그대로이므로, 상자를 받는 쪽은 다시 확인할 필요가 없습니다. 브릿지 패턴은 “무엇을 할지”(추상)와 “어떻게 할지”(구현)를 분리해, TV 리모컨(추상)과 TV 본체(구현)를 독립적으로 교체할 수 있게 하는 것과 같습니다.

PIMPL vs 일반 헤더 구조를 한눈에 보면 아래와 같습니다.

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_;  // 바꿀 때마다 전부 리빌드
};

원인:

  • private 구현이 헤더에 있으면 컴파일러가 크기를 알아야 함
  • #include "widget.h" 하는 모든 파일이 구현 변경에 영향받음
  • 빌드 시간 증가

PIMPL로 해결: 헤더에는 불완전 타입(forward declaration만 있고 정의가 없는 타입. 크기·멤버를 알 수 없어 포인터로만 사용) struct Impl 선언과 std::unique_ptr<Impl> pImpl 하나만 두고, 실제 멤버(data_, heavy_, internal_)는 widget.cpp 안의 struct Widget::Impl에만 둡니다. 이렇게 하면 Widget을 include하는 쪽은 Impl 크기를 알 필요가 없어서, 구현을 바꿔도 헤더가 바뀌지 않으면 해당 .cpp만 다시 컴파일됩니다. 소멸자는 반드시 .cpp에 정의해 두어야 unique_ptr가 불완전 타입을 제대로 파괴합니다.

// widget.h - 인터페이스만 노출
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 소멸자 호출 위해 cpp에 둠

void Widget::draw() {
    // pImpl->...
}

이 글을 읽으면:

  • PIMPL로 컴파일 의존성과 빌드 시간을 줄일 수 있습니다.
  • 브릿지 패턴으로 추상과 구현을 분리할 수 있습니다.
  • 실전에서 둘을 언제 쓰면 좋은지 알 수 있습니다.
  • 자주 발생하는 실수와 해결법을 알 수 있습니다.

추가 문제 시나리오: PIMPL/브릿지가 필요한 상황

시나리오증상PIMPL/브릿지로 해결
시나리오 1: Boost.Asio 도입asio.hpp include 시 50개 이상 파일 재컴파일, 빌드 15분→3분Impl 안에만 Asio 타입 두기
시나리오 2: .so/.dll ABI 호환라이브러리 업데이트 시 클라이언트 앱 크래시PIMPL로 내부 레이아웃 고정
시나리오 3: 플랫폼별 빌드Windows/Linux/macOS 각각 다른 API, #ifdef 난립브릿지로 플랫폼 구현체 분리
시나리오 4: 테스트 주입실제 DB/네트워크 없이 단위 테스트브릿지로 Mock 구현체 주입
시나리오 5: 템플릿 폭발헤더 전용 라이브러리, 인스턴스화 비용PIMPL로 템플릿을 .cpp에 격리

시나리오 1: NetworkManager에 Boost.Asio 추가 시 include하는 80개 파일이 재컴파일되어 빌드 12분. PIMPL로 asio::io_contextstruct Impl 안에 두면 network_manager.cpp만 영향받습니다.

시나리오 2: libwidget.so 배포 시 private 멤버 추가만으로 ABI가 깨질 수 있습니다. PIMPL이면 Widget 크기가 sizeof(unique_ptr)로 고정되어 Impl 내부 변경이 바이너리 호환을 깨지 않습니다.

목차

  1. PIMPL이란
  2. PIMPL 구현 방법
  3. 브릿지 패턴
  4. 실전 활용
  5. 자주 발생하는 문제
  6. 성능과 트레이드오프
  7. 프로덕션 패턴
  8. 구현 체크리스트

1. PIMPL이란

Pointer to Implementation

PIMPL은 “구현을 가리키는 포인터”로, 클래스의 구현 디테일을 .cpp로 옮겨 헤더에는 인터페이스만 두는 관용구입니다. C++에서 “컴파일 방화벽”(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: Client는 재컴파일 불필요
  Impl->>Impl: Impl 구조 변경은 widget.cpp만 영향

기본 형태

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

// myclass.h
#include <memory>

class MyClass {
public:
    MyClass();
    ~MyClass();  // 소멸자 반드시 선언 (unique_ptr<불완전타입> 위해)
    MyClass(MyClass&&) noexcept;
    MyClass& operator=(MyClass&&) noexcept;

    void doSomething();
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// myclass.cpp
#include "myclass.h"

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만 소유)
  • 힙 할당 1회로 구현체 생명주기 관리
  • shared_ptr는 참조 카운팅 오버헤드가 있어, 단일 소유 시에는 unique_ptr가 적합

2. PIMPL 구현 방법

복사/이동 처리

복사 시에는 Impl도 새로 만들어서 내용만 복사해야 합니다. *other.pImpl로 기존 Impl 내용을 읽어 std::make_unique<Impl>(*other.pImpl)로 새 Impl을 만들고, 대입 연산자에서는 자기 자신이 아닐 때만 *pImpl = *other.pImpl로 멤버 단위 복사를 합니다. 이동= default로 두면 unique_ptr가 알아서 이전해 주므로, 소멸자와 마찬가지로 .cpp에서 정의해 두면 됩니다.

// 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에 복사 생성자와 복사 대입 연산자가 정의되어 있어야 *other.pImpl 복사가 가능합니다. Implstd::unique_ptr 같은 비복사 가능 타입이 있으면, Impl의 복사 의미를 직접 구현해야 합니다.

구현체에서 외부 타입 사용

무거운 외부 라이브러리는 myclass.h가 아니라 myclass.cpp에서만 include합니다. 그러면 HeavyLibrary 정의는 이 .cpp의 컴파일 단위에만 필요하고, MyClass를 쓰는 다른 파일들은 heavy_library.h를 끌어오지 않아도 됩니다. 구현체(Impl) 안에 HeavyLibrary lib를 두면, 빌드 시간과 의존성 전파가 크게 줄어듭니다.

// 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 등 헤더가 큰 라이브러리는 PIMPL의 Impl 안에 두면, 해당 라이브러리를 쓰지 않는 프로젝트 부분의 컴파일이 빨라집니다.

주의사항: 소멸자

std::unique_ptr<Impl>를 사용할 때 소멸자를 헤더에만 두고 정의하지 않으면, 컴파일러가 자동으로 생성한 소멸자 안에서 unique_ptr를 파괴하려 할 때 Impl이 아직 불완전 타입이라 문제가 됩니다. 따라서 헤더에 ~Widget();만 선언하고, .cpp에서 Widget::~Widget() = default;로 정의해 두면, 그 시점에는 Impl이 이미 정의되어 있어서 안전하게 삭제됩니다.

// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    ~Widget();  // 반드시 선언하고, .cpp에서 정의
};
// widget.cpp
struct Widget::Impl { /* ... */ };

Widget::~Widget() = default;  // 여기서 Impl은 완전한 타입

Impl이 불완전 타입인 상태에서 unique_ptr의 소멸자가 호출되면 안 되므로, 소멸자 정의는 반드시 .cpp에 둡니다.

생성자에서 예외 발생 시

PIMPL 객체 생성 중 예외가 나면, 이미 생성된 멤버만 소멸자가 정리합니다. unique_ptr는 예외 안전하므로, pImpl가 가리키던 Implunique_ptr 소멸 시 자동으로 해제됩니다. 다만 생성자 본문에서 pImpl를 사용하다 예외가 나면, 그 시점까지 생성된 부분만 정리되므로, 생성자에서는 pImpl 초기화를 이니셜라이저 리스트에서 완료하는 것이 좋습니다.

// ✅ 권장: 이니셜라이저에서 한 번에 초기화
Widget::Widget() : pImpl(std::make_unique<Impl>()) {
    // pImpl 사용
}

// ⚠️ 주의: 생성자 본문에서 여러 단계 초기화 시 예외 처리 고려
Widget::Widget() {
    pImpl = std::make_unique<Impl>();
    // 여기서 예외 나면 pImpl은 이미 할당됐으므로 unique_ptr가 정리
}

3. 브릿지 패턴

추상과 구현 분리

브릿지 패턴추상(인터페이스)구현(플랫폼별/방식별 구현) 을 분리해 서로 독립적으로 바꿀 수 있게 합니다. GoF 디자인 패턴에서 구조 패턴의 하나입니다.

예: 렌더링 API

  • 추상: “도형을 그린다”
  • 구현: 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

구현 쪽(Implementor)에서는 Renderer 인터페이스로 “원 그리기”, “사각형 그리기” 같은 저수준 API를 정의하고, OpenGLRendererSoftwareRenderer가 각각 다른 방식으로 구현합니다. 추상 쪽(Abstraction)의 ShapeRenderer*를 갖고, Circle·Rectangledraw() 안에서 renderer->drawCircle(...) 같은 메서드를 호출합니다. 이렇게 하면 도형 종류와 렌더링 방식이 서로 독립적으로 바뀌고, 런타임에 Shape에 넘겨주는 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 r)
        : Shape(r), x(x), y(y), radius(r) {}
    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 코드는 수정하지 않아도 됩니다.

OpenGLRenderer gl;
SoftwareRenderer sw;

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

PIMPL + 브릿지

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

// shape.h - 사용자에게 보이는 인터페이스만
#include <memory>

class Renderer;  // 전방 선언

class Shape {
public:
    explicit Shape(std::unique_ptr<Renderer> renderer);
    virtual ~Shape();
    virtual void draw() = 0;
protected:
    std::unique_ptr<Renderer> renderer;
};

// 구현은 .cpp 또는 플랫폼별 라이브러리에서
// shape.cpp
#include "shape.h"
#include "opengl_renderer.h"  // 또는 software_renderer.h

Shape::Shape(std::unique_ptr<Renderer> renderer)
    : renderer(std::move(renderer)) {}
Shape::~Shape() = default;

이렇게 하면 Renderer의 구체 타입이 헤더에 노출되지 않아, 컴파일 의존성과 ABI 노출이 줄어듭니다.

브릿지 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개 = 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 = default;
    DatabaseConnection& operator=(DatabaseConnection&&) noexcept = default;
    bool connect();
    void disconnect();
    bool execute(const std::string& query);
    bool isConnected() const;
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
// database_connection.cpp - 구현 디테일 (SQLite, PostgreSQL 등)
#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;

bool DatabaseConnection::connect() {
    if (pImpl->connected) return true;
    pImpl->connected = (sqlite3_open(pImpl->connection_string.c_str(), &pImpl->db) == SQLITE_OK);
    return pImpl->connected;
}
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:");
    bool ok = conn.connect();
    if (ok) conn.execute("CREATE TABLE test (id INT);");
    conn.disconnect();
    return 0;
}

핵심 포인트: main.cppsqlite3.h를 전혀 include하지 않습니다. DB 라이브러리를 MySQL로 바꿔도 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;
};
// file_system_win.cpp - Windows 구현
#include "file_system.h"
#include <windows.h>
#include <fstream>

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);
        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);
        return f && (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;
    }
};

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);
}

// 팩토리: 플랫폼별 인스턴스 생성
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>

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);
        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);
        return f && (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>());
}

빌드: Windows에서는 file_system_win.cpp만, Linux/macOS에서는 file_system_posix.cpp만 링크합니다. file_system.h는 공통이며, 플랫폼별 헤더(windows.h, dirent.h)는 각 .cpp에만 포함됩니다.


4. 실전 활용

대형 객체/라이브러리 감추기

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

// 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는 “백엔드에 쓰기”만 하고, 실제 출력 방식은 주입된 구현체가 담당하는 브릿지 구조가 됩니다. unique_ptr로 소유권이 명확해지고, 테스트 시 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 << std::endl;
    }
};

// 사용
Logger logger(std::make_unique<ConsoleBackend>());
logger.log("Hello");

플랫폼별 구현 분리

Windows/Linux/macOS에서 각각 다른 API를 쓸 때, 브릿지로 플랫폼 구현체를 분리하고 PIMPL로 헤더 의존성을 줄일 수 있습니다.

// file_system.h - 플랫폼 독립적 인터페이스
#include <memory>
#include <string>

class IFileSystemImpl {
public:
    virtual ~IFileSystemImpl() = default;
    virtual bool exists(const std::string& path) = 0;
    virtual std::string readFile(const std::string& path) = 0;
};

class FileSystem {
public:
    FileSystem();
    ~FileSystem();
    bool exists(const std::string& path) const;
    std::string readFile(const std::string& path) const;
private:
    std::unique_ptr<IFileSystemImpl> pImpl;
};
// file_system_win.cpp - Windows 전용
#include "file_system.h"
#include <windows.h>

class WinFileSystemImpl : public IFileSystemImpl {
public:
    bool exists(const std::string& path) override {
        DWORD attr = GetFileAttributesA(path.c_str());
        return attr != INVALID_FILE_ATTRIBUTES &&
               !(attr & FILE_ATTRIBUTE_DIRECTORY);
    }
    std::string readFile(const std::string& path) override {
        // Windows API로 파일 읽기
        return "";
    }
};

FileSystem::FileSystem() : pImpl(std::make_unique<WinFileSystemImpl>()) {}
FileSystem::~FileSystem() = default;
// ...

실전 예시: GUI 위젯 라이브러리

Qt, wxWidgets 같은 GUI 라이브러리는 PIMPL을 적극 활용합니다. 위젯 클래스가 플랫폼별 네이티브 핸들(HWND, NSView 등)을 갖고 있으면, 해당 플랫폼 헤더를 사용하는 모든 파일이 재컴파일됩니다. PIMPL로 감추면 플랫폼 코드는 .cpp에만 있고, 공개 API는 안정적으로 유지됩니다.

// button.h - 플랫폼 독립적
#include <memory>
#include <string>

class Button {
public:
    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;
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);
}

5. 자주 발생하는 문제

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

원인: unique_ptr<Impl>의 소멸자가 호출될 때 Impl이 아직 불완전 타입입니다. 헤더에 소멸자를 선언만 하고 .cpp에 정의를 두지 않았거나, .cpp에서 Impl 정의가 소멸자 정의보다 뒤에 올 때 발생합니다.

해결법:

// ❌ 잘못된 예: 소멸자 정의 없음
// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    ~Widget();  // 선언만 있고 cpp에 정의 없으면 기본 생성됨 → 에러
};
// ✅ 올바른 예: .cpp에 소멸자 정의
// widget.cpp
struct Widget::Impl { /* ... */ };
Widget::~Widget() = default;  // Impl이 완전한 타입인 시점에 정의

문제 2: “use of undefined type” 에러

원인: Impl이 불완전 타입인 상태에서 pImpl->member처럼 멤버에 접근하거나, Impl의 크기/정렬이 필요한 연산을 헤더에서 수행할 때 발생합니다.

해결법: Impl을 사용하는 모든 코드(멤버 접근, 복사 생성자에서 *other.pImpl 등)는 반드시 Impl이 정의된 .cpp 파일에서 작성합니다.

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

문제 3: 복사 생성자/대입 연산자 누락

원인: unique_ptr는 복사 불가이므로, 복사 생성자와 복사 대입 연산자를 정의하지 않으면 컴파일러가 삭제합니다. MyClass를 복사하려 할 때 링크 에러나 암시적 삭제로 컴파일 실패가 납니다.

해결법: 복사가 필요하면 명시적으로 구현합니다.

// ✅ 복사 가능하게 만들기
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;
}

문제 4: shared_ptr 사용 시 순환 참조

원인: 브릿지에서 ShapeRenderershared_ptr로 갖고, Renderer 구현체가 다시 Shape를 참조하면 순환 참조가 생겨 메모리 누수가 발생할 수 있습니다.

해결법: 소유 관계가 단방향이면 unique_ptr를 사용하고, RendererShape를 참조할 필요가 있으면 weak_ptr 또는 raw 포인터를 사용합니다.

// ✅ 단방향 소유: unique_ptr
class Shape {
    std::unique_ptr<Renderer> renderer;  // Shape만 Renderer 소유
};

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

원인: PIMPL 객체를 std::move로 이동한 뒤, 원본 객체의 메서드를 호출하면 pImplnullptr일 수 있어 미정의 동작이 발생합니다.

해결법: 이동 후 원본 사용을 금지하거나, 이동 생성자/대입에서 pImplnullptr로 두고, 메서드에서 if (pImpl) 체크를 추가할 수 있습니다. 다만 “이동된 객체는 사용하지 않는다”는 규칙을 지키는 것이 더 깔끔합니다.

// ⚠️ 위험: 이동 후 원본 사용
MyClass a;
MyClass b = std::move(a);
a.doSomething();  // a.pImpl는 nullptr → 미정의 동작

// ✅ 안전: 이동된 객체는 사용하지 않음
MyClass a;
MyClass b = std::move(a);
b.doSomething();  // b만 사용

문제 6: 헤더에서 기본 소멸자 사용

원인: ~Widget() = default를 헤더에 두면, 컴파일러가 해당 번역 단위에서 소멸자를 인라인 생성합니다. 그 시점에 Impl이 불완전 타입이면 unique_ptr 소멸 시 에러가 납니다.

// ❌ 잘못된 예
// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    ~Widget() = default;  // 헤더에서 정의 → Impl 불완전 타입에서 호출됨
};
// ✅ 올바른 예
// widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
public:
    ~Widget();  // 선언만
};
// widget.cpp
Widget::~Widget() = default;  // Impl이 완전한 타입인 .cpp에서 정의

문제 7: Impl에 비복사 가능 타입 포함 시

원인: Implstd::unique_ptr, std::mutex 등 복사 불가 타입이 있으면, *pImpl = *other.pImpl 같은 멤버 단위 복사가 불가능합니다.

// ❌ Impl에 unique_ptr가 있으면
struct Widget::Impl {
    std::unique_ptr<SomeResource> resource;  // 복사 불가
};
// MyClass::MyClass(const MyClass& other)
//     : pImpl(std::make_unique<Impl>(*other.pImpl)) {}  // 컴파일 에러!
// ✅ 해결: Impl 복사 생성자에서 수동 복제
struct Widget::Impl {
    std::unique_ptr<SomeResource> resource;
    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;
    }
};

문제 8: 전방 선언 순서 오류

원인: struct Impl 전방 선언이 std::unique_ptr<Impl>보다 뒤에 오면, unique_ptrImpl을 참조할 때 아직 선언되지 않은 타입을 사용하게 됩니다.

// ❌ 잘못된 예
class Widget {
    std::unique_ptr<Impl> pImpl;  // Impl이 아직 선언 안 됨
    struct Impl;
};
// ✅ 올바른 예
class Widget {
    struct Impl;           // 먼저 전방 선언
    std::unique_ptr<Impl> pImpl;
};

문제 9: shared_ptr로 PIMPL 시 순환 참조

원인: 브릿지에서 ShapeRenderershared_ptr로 갖고, Renderer 구현체가 Shapeshared_ptr로 다시 참조하면 순환 참조로 메모리 누수 발생합니다.

// ❌ 순환 참조
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를 참조하지 않도록 설계

문제 10: const 메서드에서 pImpl 수정

원인: const 메서드 안에서 pImpl->cache = value처럼 캐시를 갱신하면 논리적 const가 깨집니다. mutable 남용 시 멀티스레드에서 race condition이 발생할 수 있습니다.

// ⚠️ 위험: const 메서드가 내부 상태 변경
std::string Widget::getName() const {
    if (pImpl->name_cache.empty())
        pImpl->name_cache = computeName();  // mutable 필요, 스레드 불안전
    return pImpl->name_cache;
}

해결: 캐시 없이 매번 계산하거나, 별도 스레드 안전 캐시 레이어를 사용합니다.


6. 성능과 트레이드오프

컴파일 시간

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

대규모 프로젝트에서 PIMPL 적용 시 증분 빌드 시간이 크게 줄어드는 경우가 많습니다.

런타임 오버헤드

  • 힙 할당: Impl마다 make_unique로 1회 힙 할당 발생
  • 간접 접근: pImpl->member로 포인터를 따라가는 비용 (캐시 미스 가능성)
  • 복사: 깊은 복사 시 Impl 전체 복사

권장: 작은 클래스(멤버 몇 개, 크기 수십 바이트)에는 PIMPL 오버헤드가 부담될 수 있으므로, 헤더가 무겁거나 컴파일 의존성이 큰 경우에 PIMPL을 적용하는 것이 좋습니다.

PIMPL vs 상속 기반 다형성

항목PIMPL상속 (가상 함수)
컴파일 의존성낮음높음 (헤더에 베이스 필요)
런타임 오버헤드포인터 1단계가상 호출 + 포인터
확장컴파일 타임에 Impl 교체런타임에 구현체 교체
용도구현 숨김, 빌드 시간다형성, 플러그인 구조

PIMPL 적용 판단 기준

PIMPL을 쓰는 것이 좋은 경우:

  • 외부 라이브러리(Boost, Qt, OpenCV 등) 헤더가 무거울 때
  • private 멤버 변경이 잦고, 이 클래스를 include하는 파일이 많을 때
  • ABI 안정성이 중요한 라이브러리를 배포할 때
  • 구현 디테일을 완전히 숨기고 싶을 때

PIMPL을 생략해도 되는 경우:

  • 클래스가 작고 멤버가 몇 개뿐일 때
  • 이 클래스를 include하는 파일이 1~2개일 때
  • 성능이 극도로 중요한 핫 패스에서 힙 할당을 피해야 할 때

성능 벤치마크 비교 (개념적 수치)

연산일반 멤버PIMPL차이
객체 생성스택 할당 ~1nsmake_unique ~50–100ns힙 할당 1회
멤버 접근 obj.x직접 접근pImpl->x 포인터 1단계캐시 미스 가능
100만 회 반복 접근~10ms~15–25ms1.5–2.5배
복사멤버 단위 복사*pImpl 깊은 복사 + 힙 할당비슷 또는 PIMPL이 약간 느림

메모리 레이아웃:

// 일반 클래스: 모든 멤버가 연속 메모리
class Widget {
    std::vector<int> data_;      // 24 bytes
    SomeBigLibrary heavy_;       // N bytes
    std::map<...> internal_;     // M bytes
};  // sizeof(Widget) = 24 + N + M

// PIMPL: 포인터 하나만
class Widget {
    std::unique_ptr<Impl> pImpl;  // 8 bytes (64비트)
};  // sizeof(Widget) = 8

PIMPL은 Widget 자체 크기를 포인터 크기로 고정하지만, Impl은 힙에 따로 있어 캐시 지역성이 떨어질 수 있습니다. 루프 안에서 pImpl->member를 매우 자주 접근하면, 일반 멤버보다 느려질 수 있습니다.

PIMPL vs 상속 vs 일반 멤버 요약

항목일반 멤버PIMPL상속(가상)
컴파일 시간헤더 변경 시 넓은 재컴파일구현 변경 시 .cpp만베이스/파생 헤더 모두 영향
메모리연속, 캐시 친화적힙 1회 + 포인터vtable + 포인터
런타임 오버헤드없음포인터 1단계가상 호출 + 포인터
ABI 안정성멤버 추가 시 깨짐Impl 내부 변경은 안전vtable 레이아웃 의존
다형성없음컴파일 타임에 고정런타임 교체 가능

7. 프로덕션 패턴

패턴 1: 팩토리 + PIMPL (플랫폼별 생성)

플랫폼별 구현체를 헤더에 노출하지 않고, 팩토리 함수만 공개합니다.

// widget.h
class Widget {
public:
    static std::unique_ptr<Widget> create();
};
// widget_win.cpp - Windows용
#include "widget.h"
#include <windows.h>
struct Widget::Impl { HWND hwnd; /* ... */ };
std::unique_ptr<Widget> Widget::create() { return std::make_unique<Widget>(); }
// widget_qt.cpp - Qt용 (다른 플랫폼)
#include "widget.h"
#include <QWidget>
struct Widget::Impl { QWidget* q; /* ... */ };
std::unique_ptr<Widget> Widget::create() { return std::make_unique<Widget>(); }

빌드 시 플랫폼에 맞는 .cpp만 링크합니다.

패턴 2: ABI 안정 공유 라이브러리

.so/.dll 배포 시 PIMPL로 공개 클래스 크기를 고정해 ABI 호환을 유지합니다.

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

Impl 내부는 자유롭게 변경해도 PublicService 크기는 sizeof(unique_ptr)로 유지됩니다.

패턴 3: 테스트용 Mock 브릿지

단위 테스트에서 실제 DB/네트워크 대신 Mock을 주입합니다.

// database.h
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
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(), 1);
}

패턴 4: 기존 코드 PIMPL 마이그레이션

  1. struct Impl 선언, std::unique_ptr<Impl> pImpl 추가. 기존 멤버 유지.
  2. 생성자에서 pImpl 생성 후 기존 멤버 값을 복사. 메서드를 pImpl->x 사용으로 점진 전환.
  3. 모든 접근이 pImpl로 옮겨지면 헤더에서 기존 멤버 제거.

패턴 5: Lazy Impl (초기화 지연)

생성 비용이 큰 Impl을 실제 사용 시점에 생성합니다.

class HeavyWidget {
    struct Impl;
    std::unique_ptr<Impl> pImpl;
    void ensureImpl() { if (!pImpl) pImpl = std::make_unique<Impl>(); }
public:
    void draw() { ensureImpl(); pImpl->render(); }
};

멀티스레드 환경에서는 std::call_once로 초기화를 보호합니다.


8. 구현 체크리스트

PIMPL 적용 시 확인할 항목입니다.

  • struct Impl 전방 선언 (헤더)
  • std::unique_ptr<Impl> pImpl 멤버 (헤더)
  • 소멸자: 헤더에 선언, .cpp에 정의
  • 생성자: .cpp에서 pImpl(std::make_unique<Impl>()) 초기화
  • 이동 생성자/대입: .cpp에 정의 (= default 가능)
  • 복사 필요 시: 복사 생성자/대입 명시적 구현
  • 무거운 헤더: .cpp에서만 include
  • Impl 멤버 접근: 모두 .cpp에서 수행

브릿지 패턴 적용 시:

  • Implementor 인터페이스 정의 (순수 가상 함수)
  • 구체 Implementor 클래스 구현
  • Abstraction이 Implementor 포인터/참조 보유
  • 런타임에 구현체 주입 (생성자 또는 setter)

프로덕션 패턴 적용 시:

  • 플랫폼별 팩토리: create() 함수로 구현체 선택
  • ABI 안정: 공개 헤더에 멤버 추가/삭제 금지
  • 테스트: Mock 구현체로 단위 테스트
  • 마이그레이션: 점진적 PIMPL 전환 (기존 멤버 → pImpl)

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
  • C++ 디자인 패턴 | Observer·Strategy
  • C++ 디자인 패턴 | Adapter·Decorator

이 글에서 다루는 키워드 (관련 검색어)

C++ PIMPL, 브릿지 패턴, 헤더 의존성 감소, 컴파일 시간, 불완전 타입, unique_ptr 소멸자 등으로 검색하시면 이 글이 도움이 됩니다.

정리

주제용도효과
PIMPL구현을 .cpp로 이동컴파일 시간 감소, 캡슐화, ABI 완화
브릿지추상과 구현 분리플랫폼/방식 교체 용이
소멸자PIMPL 사용 시헤더에 선언, .cpp에 정의
프로덕션팩토리, ABI, Mock플랫폼 분리, 테스트, 마이그레이션

핵심 원칙:

  1. PIMPL: unique_ptr<Impl> + 불완전 타입이므로 소멸자·이동은 .cpp에서 처리
  2. 브릿지: “무엇을 할지”와 “어떻게 할지”를 다른 계층으로 분리
  3. 무거운 헤더 의존성은 구현체(.cpp) 쪽으로만 두기

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. PIMPL은 헤더가 무거운 라이브러리(Boost, Qt 등)를 사용할 때, 또는 구현 변경 시 재컴파일 범위를 줄이고 싶을 때 적용합니다. 브릿지는 플랫폼별 구현(파일 시스템, 렌더링, 로깅 백엔드)을 분리할 때 유용합니다. 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

Q. PIMPL과 브릿지를 같이 쓸 수 있나요?

A. 네. 브릿지의 구현체(Implementor)를 PIMPL로 감추면, 헤더에는 추상 인터페이스만 노출되고 구체 구현은 .cpp에 숨겨집니다. Shapestd::unique_ptr<Renderer>를 갖고, Renderer의 구체 타입(OpenGLRenderer 등)은 .cpp에서만 include하는 방식이 대표적입니다.

Q. shared_ptr 대신 unique_ptr를 쓰는 이유는?

A. PIMPL에서 Impl은 해당 클래스만 소유하므로 단일 소유권이 맞습니다. unique_ptr는 참조 카운팅 오버헤드가 없고, 소유권이 명확해 코드 이해가 쉽습니다. 여러 곳에서 공유해야 할 때만 shared_ptr를 고려합니다.

Q. 프로덕션에서 주의할 점은?

A. (1) 소멸자를 반드시 .cpp에 정의할 것, (2) 복사가 필요하면 명시적으로 구현할 것, (3) 무거운 헤더는 .cpp에서만 include할 것, (4) 작은 클래스에는 PIMPL 오버헤드가 부담될 수 있으므로 적용 대상을 신중히 선택할 것.

관련 글

  • C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
  • C++ 디자인 패턴 | Adapter·Decorator
  • C++ 이동 의미론 완벽 가이드 | rvalue 참조·std::move
  • C++ Perfect Forwarding 완벽 가이드 | 유니버설 참조·std::forward
  • C++ 디자인 패턴 종합 가이드 | Singleton·Factory