C++ Pimpl 이디엄: 헤더 변경 없이 구현 바꾸기, 소멸자를 .cpp에 두는 이유

이 글의 핵심

C++ Pimpl Idiom은 구현 세부를 포인터 뒤에 숨겨 헤더 의존성을 줄이는 패턴입니다. 컴파일 방화벽, ABI 안정성, 특수 멤버 함수 구현, 성능 트레이드오프까지 실전 예제로 정리합니다.

Pimpl Idiom이란?

Pimpl(pointer to implementation)은 클래스의 구현을 포인터 뒤로 숨기는 패턴입니다.

C++는 C와 마찬가지로 클래스의 모든 멤버(private 포함)를 헤더 파일에 노출해야 하는 언어입니다 — 클래스 크기와 레이아웃을 컴파일 타임에 알아야 스택에 객체를 할당하거나 다른 클래스의 멤버로 포함할 수 있기 때문입니다. 이는 private 멤버라도 그 타입과 이름이 헤더를 include하는 모든 파일에 그대로 노출된다는 뜻이고, 그 private 멤버가 참조하는 타입(예: 외부 라이브러리 헤더)까지 전이적으로 include되어야 한다는 뜻입니다. Pimpl은 이 언어적 제약을 우회하는 트릭입니다 — 실제 데이터 멤버를 전방 선언(forward declaration)만 된 Impl이라는 별도 클래스로 옮기고, Widget은 그 Impl을 가리키는 포인터 하나(std::unique_ptr<Impl>)만 가지도록 만듭니다. 포인터는 가리키는 타입이 불완전(incomplete)해도 선언할 수 있으므로, Widget.h를 include하는 코드는 Impl의 실제 내용(내부 데이터 멤버, 그것이 의존하는 외부 헤더)을 전혀 알 필요가 없어집니다.

// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
    
private:
    class Impl;  // 전방 선언
    std::unique_ptr<Impl> pImpl;  // 구현 포인터
};

// widget.cpp
class Widget::Impl {
public:
    void doSomething() {
        // 실제 구현
    }
    
private:
    // 내부 데이터
    int data;
    std::string name;
};

Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;

void Widget::doSomething() {
    pImpl->doSomething();
}

장점

가장 직접적인 효과는 빌드 시간입니다 — 헤더에 노출된 멤버 타입이 바뀌면(설령 private 멤버라도), 그 헤더를 #include하는 모든 번역 단위가 재컴파일 대상이 됩니다. 대형 프로젝트에서 자주 수정되는 클래스가 수백 개의 .cpp 파일에서 include된다면, 멤버 하나를 추가하는 사소한 변경도 수백 개 파일의 재컴파일을 유발합니다. Pimpl로 실제 데이터를 .cpp에 격리하면 헤더는 안정적으로 유지되고, Impl의 내부를 바꿔도 오직 widget.cpp만 재컴파일하면 됩니다 — 이것이 대규모 C++ 코드베이스에서 빌드 시간 단축을 위해 Pimpl을 채택하는 가장 흔한 이유입니다.

// ✅ 컴파일 의존성 감소
// widget.h - 헤더 변경 없음
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// widget.cpp - 구현만 변경
class Widget::Impl {
    // 내부 변경해도 헤더는 그대로
    // 클라이언트 재컴파일 불필요
};

실전 예시

아래 네 예시는 Pimpl이 실무에서 쓰이는 대표적인 네 가지 맥락을 보여줍니다: 특수 멤버 함수를 올바르게 구현하는 표준 패턴, 외부 라이브러리 헤더(MySQL 등)를 클라이언트에게 숨기는 라이브러리 경계, 플랫폼별 구현을 완전히 분리하는 크로스플랫폼 설계, 그리고 릴리스 간 바이너리 호환성(ABI)을 지키는 방법입니다.

예시 1: 기본 Pimpl

여기서 눈여겨볼 부분은 복사/이동 연산자를 헤더에서 선언만 하고 정의는 .cpp에 두었다는 점입니다. pImpl이 std::unique_ptr<Impl>인 이상, 컴파일러가 생성하는 기본 복사 생성자는 unique_ptr를 복사할 수 없어 컴파일 에러가 되므로, *other.pImpl을 역참조해 새 Impl을 만드는 깊은 복사를 직접 작성해야 합니다. 반면 이동 연산자는 unique_ptr가 이동을 지원하므로 = default로 충분합니다. 다만 이동 대입은 기존 Impl을 삭제해야 하므로 Impl이 완전한 타입이어야 하고, 그래서 헤더가 아니라 .cpp에서 = default로 정의합니다. 이것이 Pimpl 클래스에서 “복사는 직접 구현, 이동은 .cpp에서 default”가 표준 패턴이 되는 이유입니다.

// person.h
#include <memory>
#include <string>

class Person {
public:
    Person(const std::string& name, int age);
    ~Person();
    
    // 복사/이동 연산자 선언
    Person(const Person& other);
    Person& operator=(const Person& other);
    Person(Person&& other) noexcept;
    Person& operator=(Person&& other) noexcept;
    
    std::string getName() const;
    int getAge() const;
    void setName(const std::string& name);
    void setAge(int age);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// person.cpp
class Person::Impl {
public:
    Impl(const std::string& name, int age) 
        : name(name), age(age) {}
    
    std::string name;
    int age;
};

Person::Person(const std::string& name, int age)
    : pImpl(std::make_unique<Impl>(name, age)) {}

Person::~Person() = default;

Person::Person(const Person& other)
    : pImpl(std::make_unique<Impl>(*other.pImpl)) {}

Person& Person::operator=(const Person& other) {
    if (this != &other) {
        *pImpl = *other.pImpl;
    }
    return *this;
}

Person::Person(Person&& other) noexcept = default;
Person& Person::operator=(Person&& other) noexcept = default;

std::string Person::getName() const {
    return pImpl->name;
}

int Person::getAge() const {
    return pImpl->age;
}

void Person::setName(const std::string& name) {
    pImpl->name = name;
}

void Person::setAge(int age) {
    pImpl->age = age;
}

예시 2: 라이브러리 인터페이스

주석 처리된 #include <mysql/mysql.h>가 이 예시의 핵심입니다 — 실제 코드였다면 database.h가 아니라 database.cpp에만 존재했을 것이고, 이는 Database를 사용하는 클라이언트 코드가 MySQL 헤더나 라이브러리에 전혀 의존하지 않는다는 뜻입니다. 이런 설계 없이 MYSQL* 멤버를 Database 클래스에 직접 둔다면, database.h를 include하는 모든 파일이 MySQL 개발 헤더를 설치하고 include 경로에 추가해야 하며, 나중에 데이터베이스 드라이버를 교체(MySQL → PostgreSQL)하려 해도 헤더가 바뀌므로 모든 클라이언트가 재컴파일됩니다. Pimpl은 이런 서드파티 의존성을 라이브러리 경계 안쪽에 완전히 가둬, 클라이언트는 오직 Database의 public 인터페이스만 알면 되도록 만듭니다.

// database.h
#include <memory>
#include <string>
#include <vector>

class Database {
public:
    Database(const std::string& connectionString);
    ~Database();
    
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
    
    void connect();
    void disconnect();
    bool isConnected() const;
    
    void execute(const std::string& query);
    std::vector<std::string> query(const std::string& sql);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// database.cpp
#include <iostream>
// 외부 라이브러리 헤더 (클라이언트에 노출 안 됨)
// #include <mysql/mysql.h>

class Database::Impl {
public:
    Impl(const std::string& connStr) 
        : connectionString(connStr), connected(false) {}
    
    void connect() {
        std::cout << "연결: " << connectionString << std::endl;
        connected = true;
    }
    
    void disconnect() {
        std::cout << "연결 해제" << std::endl;
        connected = false;
    }
    
    bool isConnected() const {
        return connected;
    }
    
    void execute(const std::string& query) {
        std::cout << "실행: " << query << std::endl;
    }
    
    std::vector<std::string> query(const std::string& sql) {
        std::cout << "쿼리: " << sql << std::endl;
        return {"결과1", "결과2"};
    }
    
private:
    std::string connectionString;
    bool connected;
    // MYSQL* connection;  // 외부 라이브러리 타입
};

Database::Database(const std::string& connectionString)
    : pImpl(std::make_unique<Impl>(connectionString)) {}

Database::~Database() = default;

void Database::connect() {
    pImpl->connect();
}

void Database::disconnect() {
    pImpl->disconnect();
}

bool Database::isConnected() const {
    return pImpl->isConnected();
}

void Database::execute(const std::string& query) {
    pImpl->execute(query);
}

std::vector<std::string> Database::query(const std::string& sql) {
    return pImpl->query(sql);
}

예시 3: 플랫폼별 구현

전처리기 #ifdef _WIN32 / #ifdef __linux__를 헤더에 직접 넣는 대신, Window.h는 플랫폼 중립적으로 유지하고 Impl의 실제 정의만 플랫폼별 .cpp 파일(window_win32.cpp, window_x11.cpp)로 분리했다는 점이 중요합니다. 이렇게 하면 Window.h를 include하는 코드는 <windows.h>나 <X11/Xlib.h> 같은 플랫폼 종속 헤더를 전혀 볼 필요가 없고, 빌드 시스템이 플랫폼에 맞는 .cpp 파일 하나만 컴파일 대상에 포함시키면 됩니다. 헤더 자체에 #ifdef를 흩뿌리는 방식과 비교하면, 각 플랫폼의 구현이 완전히 분리된 파일에 있으므로 한 플랫폼 코드를 수정하다 다른 플랫폼의 빌드를 실수로 깨뜨릴 위험도 줄어듭니다.

// window.h
#include <memory>
#include <string>

class Window {
public:
    Window(const std::string& title, int width, int height);
    ~Window();
    
    void show();
    void hide();
    void setTitle(const std::string& title);
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// window_win32.cpp (Windows)
#ifdef _WIN32
// #include <windows.h>

class Window::Impl {
public:
    Impl(const std::string& title, int width, int height) {
        // HWND hwnd = CreateWindow(...);
        std::cout << "Windows 창 생성: " << title << std::endl;
    }
    
    void show() {
        // ShowWindow(hwnd, SW_SHOW);
        std::cout << "Windows 창 표시" << std::endl;
    }
    
    void hide() {
        std::cout << "Windows 창 숨김" << std::endl;
    }
    
    void setTitle(const std::string& title) {
        std::cout << "Windows 제목 변경: " << title << std::endl;
    }
    
private:
    // HWND hwnd;
};
#endif

// window_x11.cpp (Linux)
#ifdef __linux__
// #include <X11/Xlib.h>

class Window::Impl {
public:
    Impl(const std::string& title, int width, int height) {
        std::cout << "X11 창 생성: " << title << std::endl;
    }
    
    void show() {
        std::cout << "X11 창 표시" << std::endl;
    }
    
    void hide() {
        std::cout << "X11 창 숨김" << std::endl;
    }
    
    void setTitle(const std::string& title) {
        std::cout << "X11 제목 변경: " << title << std::endl;
    }
    
private:
    // Display* display;
    // Window window;
};
#endif

Window::Window(const std::string& title, int width, int height)
    : pImpl(std::make_unique<Impl>(title, width, height)) {}

Window::~Window() = default;

void Window::show() {
    pImpl->show();
}

void Window::hide() {
    pImpl->hide();
}

void Window::setTitle(const std::string& title) {
    pImpl->setTitle(title);
}

예시 4: ABI 안정성

library_v1.h와 library_v2.h를 나란히 보면, func2()가 public 인터페이스에 추가되었어도 MyClass의 헤더상 크기(멤버는 여전히 pImpl 포인터 하나)는 전혀 바뀌지 않았습니다. 이것이 ABI(바이너리 인터페이스) 안정성의 핵심입니다 — Pimpl이 없었다면 Impl의 새 멤버(newData)가 MyClass 자체의 데이터 멤버로 직접 추가되어 sizeof(MyClass)가 바뀌었을 것이고, 이는 v1 헤더로 컴파일된 기존 바이너리(.so/.dll)와 v2 헤더로 컴파일된 새 클라이언트 코드가 서로 다른 객체 크기를 가정하게 되어 링크 시점이 아니라 런타임에 메모리 손상으로 이어지는 매우 찾기 어려운 버그를 유발합니다. 라이브러리를 소스 재컴파일 없이 교체 가능한 공유 라이브러리(.so, .dll)로 배포해야 하는 SDK나 플러그인 시스템에서 Pimpl이 사실상 필수로 여겨지는 이유가 여기 있습니다.

// library_v1.h (버전 1)
class MyClass {
public:
    MyClass();
    ~MyClass();
    void func1();
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// library_v2.h (버전 2 - 데이터 멤버와 크기는 v1과 동일)
class MyClass {
public:
    MyClass();
    ~MyClass();
    void func1();
    void func2();  // 새 함수 추가
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// library_v2.cpp
class MyClass::Impl {
public:
    void func1() {
        std::cout << "func1" << std::endl;
    }
    
    void func2() {  // 새 구현
        std::cout << "func2" << std::endl;
    }
    
private:
    int newData;  // 새 멤버 추가 (ABI 영향 없음)
};

Fast Pimpl (최적화)

일반적인 Pimpl은 Impl 객체를 별도의 힙 할당(make_unique<Impl>())으로 만들기 때문에, 객체 하나를 생성할 때마다 힙 할당 한 번, 소멸할 때 해제 한 번이 추가로 발생하고, Widget과 Impl이 메모리상 서로 떨어져 있어 pImpl->member에 접근할 때 캐시 지역성이 떨어집니다. Fast Pimpl은 이 힙 할당을 없애는 절충안으로, Impl이 들어갈 만큼의 고정 크기 바이트 배열(implStorage)을 Widget 객체 안에 직접 두고, 그 자리에 placement new로 Impl을 생성합니다. 대가는 ImplSize를 Impl의 실제 크기보다 작지 않게 미리 알아야 한다는 점(구현이 커지면 헤더의 크기 상수를 갱신해야 하므로, Pimpl의 핵심 이점인 “헤더 불변”이 부분적으로 깨집니다)과, 코드가 reinterpret_cast와 placement new/명시적 소멸자 호출을 다뤄야 해 일반 Pimpl보다 작성하기 까다롭다는 점입니다. 힙 할당 비용이 프로파일링에서 실제 병목으로 확인된 경로가 아니라면 일반 unique_ptr<Impl> 방식을 기본으로 삼는 편이 안전합니다.

// Impl을 Widget 객체 내부 버퍼에 직접 생성 (별도 힙 할당 없음)
class Widget {
public:
    Widget();
    ~Widget();
    
private:
    static constexpr size_t ImplSize = 64;
    alignas(std::max_align_t) unsigned char implStorage[ImplSize];

    class Impl;
    Impl* pImpl() {
        return std::launder(reinterpret_cast<Impl*>(implStorage));
    }
};

// widget.cpp — 여기서만 Impl이 완전한 타입이므로 크기·정렬 검사도 여기에 둔다
Widget::Widget() {
    static_assert(sizeof(Impl) <= ImplSize, "ImplSize를 늘리세요");
    static_assert(alignof(Impl) <= alignof(std::max_align_t));
    new (implStorage) Impl();          // placement new
}
Widget::~Widget() { pImpl()->~Impl(); } // delete가 아니라 소멸자 직접 호출

버퍼를 alignas(8)처럼 고정값으로 잡으면 Impl 안에 long double이나 SIMD 타입처럼 더 큰 정렬이 필요한 멤버가 생기면 조용히 오정렬됩니다. 그리고 static_assert가 없으면 누군가 Impl에 멤버를 추가해 64바이트를 넘기는 순간 버퍼 밖을 덮어쓰는 메모리 오염이 생기는데, 이 버그는 크래시 위치가 원인과 멀어서 찾기가 매우 어렵습니다. Fast Pimpl은 헤더를 바꾸지 않는다는 Pimpl의 장점을 일부 포기하는 방식(버퍼 크기가 헤더에 박힘)이라, 힙 할당이 실제로 프로파일에서 문제로 확인된 경우에만 쓰는 것이 좋습니다. 복사·이동 연산도 전부 직접 구현해야 합니다.

자주 발생하는 문제

문제 1: 소멸자 정의 누락

std::unique_ptr<Impl>의 소멸자는 내부적으로 delete를 호출하기 위해 Impl의 완전한 정의(소멸자를 포함한 전체 타입 정보)를 필요로 합니다. 헤더에서 ~Widget() = default를 작성하면, 그 시점의 헤더 안에는 class Impl;이라는 전방 선언밖에 없어 unique_ptr의 소멸 코드가 인스턴스화될 수 없고, “불완전 타입에 대해 삭제할 수 없다”는 컴파일 에러가 발생합니다. 반대로 .cpp 파일에서 소멸자를 정의하면 그 시점에는 이미 Impl의 완전한 정의가 같은 파일에 있으므로 문제없이 컴파일됩니다 — 이는 Pimpl 패턴을 쓸 때 거의 항상 마주치는 첫 번째 함정이며, “소멸자는 헤더에서 ~Widget();으로 선언만 하고, = default 정의는 .cpp에 둔다”는 규칙으로 요약됩니다. 같은 이유로 이동 대입 연산자와, 생성자가 예외를 던질 수 있어 pImpl을 정리해야 하는 생성자도 .cpp에서 정의해야 합니다.

// ❌ 헤더에서 소멸자 정의
class Widget {
public:
    ~Widget() = default;  // 에러: Impl 불완전 타입
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// ✅ cpp 파일에서 정의
// widget.h
class Widget {
public:
    ~Widget();
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// widget.cpp
Widget::~Widget() = default;

문제 2: 복사 연산자

이는 앞서 “예시 1: 기본 Pimpl”에서 이미 다룬 문제의 구체적인 증상입니다. unique_ptr는 설계상 복사가 금지된 타입(복사 생성자와 복사 대입 연산자가 = delete)이므로, pImpl을 멤버로 가진 클래스에 대해 컴파일러가 암묵적으로 생성하려는 복사 생성자·복사 대입 연산자는 그 시도 자체에서 컴파일 에러를 냅니다. 이것이 “에러”라는 점은 오히려 안전장치입니다 — 만약 unique_ptr가 아니라 원시 포인터로 pImpl을 선언했다면 컴파일은 되지만 포인터 값만 복사되는 얕은 복사가 조용히 발생해, 두 Widget 객체가 같은 Impl을 가리키다 이중 삭제로 이어졌을 것입니다. 컴파일 에러로 드러나는 편이 런타임 크래시보다 훨씬 다루기 쉬우므로, 복사가 필요한 Pimpl 클래스는 반드시 *other.pImpl을 역참조하는 깊은 복사를 직접 작성해야 합니다.

// ❌ 기본 복사 (얕은 복사)
class Widget {
public:
    // 컴파일러 생성 복사는 unique_ptr 복사 불가
    
private:
    std::unique_ptr<Impl> pImpl;
};

// ✅ 명시적 복사 구현
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;
}

문제 3: 성능 오버헤드

Pimpl의 비용은 세 가지가 겹쳐 있습니다: pImpl->method() 호출은 포인터를 한 번 더 역참조해야 하고, 컴파일러는 .cpp에 숨겨진 Impl의 구현을 볼 수 없으므로 헤더에서는 그 호출을 인라인할 수 없으며, Widget과 Impl이 별도의 힙 블록에 있어 함께 접근할 때 캐시 미스 가능성이 늘어납니다. 그래서 실무 원칙은 “전부 숨기지 말고 선택적으로 숨기라”는 것입니다 — 클래스 외부에서 자주 호출되고 구현이 단순한 접근자(getValue() 같은)는 클래스 안에 인라인으로 직접 두고, 정말 복잡하거나 외부 의존성이 있는 로직만 Impl 뒤로 숨기면 대부분의 이점(빌드 시간 단축, ABI 안정성)을 유지하면서 성능이 중요한 경로의 비용은 피할 수 있습니다.

// ❌ 간접 호출 오버헤드
void Widget::doSomething() {
    pImpl->doSomething();  // 간접 호출
}

// ✅ 인라인 가능한 함수는 직접 구현
class Widget {
public:
    int getValue() const { return value; }  // 인라인
    
private:
    int value;  // 간단한 데이터는 직접
    class Impl;
    std::unique_ptr<Impl> pImpl;  // 복잡한 구현만
};

문제 4: 전방 선언 제약

Impl getImpl() const처럼 불완전 타입을 값으로 반환하거나 매개변수로 받는 선언은, 컴파일러가 그 타입의 크기와 복사 방법을 알아야 하는데 헤더 시점에는 전방 선언밖에 없어 실패합니다. 이는 “헤더는 Impl의 완전한 정의를 몰라도 된다”는 Pimpl의 근본 전제를 어기는 사용법이므로 자연스러운 제약입니다. 포인터나 참조(const Impl*, const Impl&)는 가리키는 대상의 크기를 몰라도 선언할 수 있으므로 문제없이 컴파일되며, 애초에 Impl을 클래스 외부에 노출하는 API 자체가 Pimpl의 캡슐화 의도와 어긋나므로 이런 접근자는 내부 디버깅 목적이 아니라면 설계 자체를 재검토할 신호로 보는 것이 좋습니다.

// ❌ Impl 타입 직접 사용
class Widget {
public:
    Impl getImpl() const;  // 에러: 불완전 타입
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// ✅ 포인터/참조만 사용
class Widget {
public:
    const Impl* getImpl() const;  // OK
    
private:
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

문제 5: const 멤버 함수에서 Impl을 수정해도 컴파일된다

int Widget::value() const {
    pImpl->bump();        // ⚠️ const 함수인데 상태를 바꾼다 — 에러 없이 컴파일됨
    return pImpl->v;
}

const 멤버 함수 안에서 pImpl 자체는 const std::unique_ptr<Impl>이지만, unique_ptr의 operator->는 가리키는 대상의 const를 전파하지 않아 Impl*을 돌려줍니다. 그래서 Pimpl로 바꾸는 순간 컴파일러가 해 주던 const 정합성 검사가 사라지고, 위 코드는 에러나 경고 없이 컴파일되어 value()를 부를 때마다 상태가 바뀝니다. 여러 스레드가 “읽기 전용”이라고 믿고 const 함수를 동시에 호출하는 코드라면 데이터 레이스로 이어집니다.

해결은 const를 전파하는 래퍼입니다. Library Fundamentals TS v2의 std::experimental::propagate_const(libstdc++에 <experimental/propagate_const>로 들어 있음)로 감싸면 const 멤버 함수 안에서 pImpl->이 const Impl*을 돌려주므로, non-const 함수 bump() 호출이 컴파일 에러(GCC 기준 “discards qualifiers”)로 막힙니다.

#include <experimental/propagate_const>
std::experimental::propagate_const<std::unique_ptr<Impl>> pImpl;

표준 라이브러리 구현에 따라 제공되지 않을 수 있으니(MSVC에는 없음), 없다면 Impl& impl() { return *pImpl; } / const Impl& impl() const { return *pImpl; } 한 쌍의 접근자를 만들고 멤버 함수에서는 항상 impl()을 거치게 하는 방법이 이식성이 좋습니다.

문제 6: 이동된 객체의 pImpl은 nullptr

Widget a;
Widget b = std::move(a);   // 이동 생성자 = default
a.value();                  // ❌ a.pImpl은 nullptr → 크래시

unique_ptr을 이동하면 원본은 nullptr가 되므로, 이동된 Widget에서 멤버 함수를 부르면 널 포인터 역참조입니다. 표준 라이브러리 타입의 “이동 후에도 유효하지만 지정되지 않은 상태” 보장을 기대하고 이동된 객체를 재사용하거나 새 값을 대입하는 코드가 흔한데, 위의 복사 대입 *pImpl = *other.pImpl;도 this가 이동된 객체라면 바로 크래시합니다. 선택지는 둘입니다. 이동된 객체에 허용하는 연산을 소멸과 대입으로 한정하고, 대입 연산자에서 if (!pImpl) pImpl = std::make_unique<Impl>(*other.pImpl); else *pImpl = *other.pImpl;처럼 널을 처리하는 것, 또는 성능을 조금 양보하고 이동 후에도 빈 Impl을 새로 만들어 두는 것입니다. 어느 쪽이든 문서에 적어 두어야 사용자가 헷갈리지 않습니다.

문제 7: shared_ptr Pimpl은 값 의미론을 바꾼다

복사 비용을 줄이려고 std::shared_ptr<Impl>을 쓰고 복사 생성자를 = default로 두면, 복사본들이 같은 Impl을 공유합니다. Widget b = a; b.setName("x");가 a의 이름까지 바꾸는 것이죠. 겉보기에는 값처럼 복사되는 클래스가 실제로는 참조처럼 동작하므로, 이 방식은 Impl이 생성 후 변하지 않는 불변 객체일 때만 안전합니다. 수정이 필요하면 쓰기 직전에 use_count() > 1이면 복제하는 copy-on-write를 함께 구현해야 하고, 멀티스레드에서는 그 검사 자체가 경쟁 조건이 될 수 있어 생각보다 까다롭습니다.

Pimpl vs 다른 패턴

세 패턴 모두 “포인터를 통해 무언가를 간접적으로 참조한다”는 구조는 비슷해 보이지만 해결하는 문제가 서로 다릅니다. Pimpl의 목적은 순수하게 컴파일 방화벽(구현 세부 사항을 헤더에서 감춰 빌드 의존성과 ABI를 안정시키는 것)이며, Impl은 다형적일 필요가 전혀 없고 오직 하나의 구체 타입만 존재합니다. Bridge 패턴은 여기서 한 걸음 더 나아가 impl이 추상 인터페이스이고 런타임에 여러 구현체 중 하나를 교체 가능하도록 설계된 것 — 즉 Pimpl이 “구현을 숨기는 것”이라면 Bridge는 “추상화와 구현을 각자 독립적으로 확장 가능하게 분리하는 것”입니다. Strategy 패턴은 아예 다른 목적으로, 알고리즘 자체(정렬 방식, 압축 방식 등)를 런타임에 교체하기 위한 것이며 컴파일 방화벽과는 무관합니다. 실무에서는 “그냥 헤더를 안정시키고 싶다”면 Pimpl, “구현체를 런타임에 갈아 끼워야 한다”면 Bridge나 Strategy를 선택하는 것이 자연스럽습니다.

// Pimpl: 구현 숨기기
class Widget {
    class Impl;
    std::unique_ptr<Impl> pImpl;
};

// Bridge: 추상화와 구현 분리
class Widget {
    std::unique_ptr<WidgetImpl> impl;  // 인터페이스
};

// Strategy: 알고리즘 교체
class Widget {
    std::unique_ptr<Strategy> strategy;
};

같이 보면 좋은 글