C++ 인터페이스 설계와 PIMPL: 컴파일 의존성을 끊고 바이너리 호환성(ABI) 유지하기

들어가며: 헤더가 바뀌면 세상이 다시 빌드된다

C++에서 클래스의 private 멤버는 접근만 막을 뿐, 보이지 않는 것은 아닙니다. 컴파일러는 객체를 스택에 만들고 멤버 오프셋을 계산하기 위해 클래스의 전체 레이아웃을 알아야 하므로, private 멤버의 타입과 그 타입을 정의한 헤더까지 공개 헤더에 들어가야 합니다. 그 결과 private 멤버 하나를 추가하면 그 헤더를 include하는 모든 번역 단위가 다시 컴파일되고, 그 멤버 타입을 위해 include한 무거운 헤더는 사용자 코드의 컴파일 시간까지 늘립니다.

PIMPL(Pointer to Implementation)은 구현 멤버를 별도 클래스로 옮기고, 공개 클래스는 그 클래스를 포인터로만 가리키게 하는 관용구입니다. 구현 클래스의 정의는 .cpp에만 있으므로 공개 헤더는 구현이 바뀌어도 그대로이고, 부수 효과로 공개 클래스의 크기도 포인터 하나로 고정됩니다.

이 글은 PIMPL의 구현과 컴파일 의존성 쪽에 집중합니다. 배포한 라이브러리의 ABI를 버전 단위로 관리하는 방법(구조체 레이아웃·vtable·name mangling, extern "C" 인터페이스, 심볼 버전, abi-compliance-checker 같은 검증 도구)은 C++ 라이브러리 ABI 호환성 #55-4에서 다룹니다.

이 글에서 다루는 것:

  • 헤더 의존성이 재컴파일 범위를 넓히는 원리
  • PIMPL 패턴과 복사/이동/소멸 처리
  • PIMPL이 ABI에 주는 효과와 한계 (요약)
  • 자주 발생하는 구현 에러
  • PIMPL의 비용과 대안 (전방 선언, Fast PIMPL, 인터페이스 + 팩토리)

많이 include되는 헤더, 배포 후 멤버 추가: PIMPL이 필요한 순간

시나리오 1: 많이 include되는 헤더의 private 멤버

Widget 클래스가 수백 개의 .cpp에서 include됩니다. Widget에 JSON 캐시 멤버를 추가하려고 #include <nlohmann/json.hpp>를 넣었더니, Widget을 쓰는 모든 파일이 이 거대한 헤더를 파싱하게 되어 전체 빌드와 증분 빌드가 모두 느려집니다. 사용자 코드 중 JSON을 쓰는 곳은 하나도 없는데도 그렇습니다.

해결: 캐시 멤버를 WidgetImpl로 옮기면 nlohmann/json.hpp는 widget.cpp 하나만 include합니다.

시나리오 2: 라이브러리 배포 후 private 멤버 추가

Document 클래스를 바이너리로 배포한 뒤 내부 캐시(std::unordered_map)를 추가했습니다. 옛 헤더로 컴파일된 사용자 코드는 옛 크기로 Document를 할당하는데 새 라이브러리는 더 큰 객체라고 가정하고 쓰므로, 메모리를 넘어서 쓰고 크래시가 납니다.

해결: PIMPL로 구현을 숨기면 Impl에 멤버를 추가해도 공개 클래스의 크기가 바뀌지 않습니다.

시나리오 3: 플랫폼별 구현 분리

FileWatcher가 Windows에서는 ReadDirectoryChangesW, Linux에서는 inotify를 씁니다. 공개 헤더에 HANDLE이나 inotify 파일 디스크립터를 멤버로 두면 windows.h가 공개 헤더로 새어 나가거나, 헤더 안에 #ifdef가 생깁니다.

해결: 플랫폼별 멤버를 FileWatcherImpl에 두면 공개 헤더는 플랫폼 중립적입니다(아래 예제 2).

시나리오 4: 순환 의존성

A.h가 B.h를, B.h가 A.h를 include합니다. A가 B를 값 멤버로 가지면 전방 선언만으로는 해결되지 않습니다.

해결: A가 B를 std::unique_ptr<B>로 가지면 A.h에는 class B; 전방 선언만 있으면 됩니다. 다만 이 경우에도 시나리오 1과 같은 이유로 A의 소멸자는 .cpp에서 정의해야 합니다.


컴파일 의존성은 어떻게 퍼지는가

#include는 텍스트 복사입니다. widget.h가 <map>, <string>, "cache.h"를 include하고 cache.h가 다시 "json.hpp"를 include하면, widget.h를 include하는 모든 파일은 이 전체를 파싱합니다. 빌드 시스템은 이 포함 관계를 따라 의존성을 추적하므로, 체인 가장 아래의 json.hpp나 cache.h가 바뀌어도 widget.h를 쓰는 모든 파일이 다시 컴파일됩니다.

PIMPL이 끊는 것은 이 체인입니다. 공개 헤더가 include해야 하는 것은 <memory>와 공개 함수 시그니처에 등장하는 타입뿐이고, 구현에 필요한 모든 헤더는 .cpp로 내려갑니다. 효과의 크기는 그 헤더가 얼마나 많이 include되는가 × 구현 쪽 헤더가 얼마나 무거운가 × 구현이 얼마나 자주 바뀌는가로 정해집니다. 세 가지 중 하나라도 작으면 PIMPL의 이득도 작습니다. 그래서 모든 클래스에 PIMPL을 적용하기보다, 많이 include되는 핵심 클래스 몇 개부터 적용하는 편이 투입 대비 효과가 큽니다.

어느 헤더가 비싼지는 추측하지 말고 측정합니다. Clang의 -ftime-trace는 번역 단위별로 어떤 헤더 파싱에 시간이 들었는지 Chrome 트레이스 형식으로 보여 주고, ClangBuildAnalyzer는 여러 트레이스를 모아 “가장 비싼 헤더” 목록을 만들어 줍니다. MSVC에서는 /Bt+나 Build Insights로 비슷한 정보를 얻습니다. 이 목록 상위에 있는 헤더가 어떤 공개 헤더를 통해 퍼지는지 보면 PIMPL을 적용할 후보가 나옵니다.

PIMPL을 쓰기 전에 먼저 시도할 만한 더 싼 방법도 있습니다. 포인터나 참조로만 쓰는 타입은 include 대신 전방 선언으로 충분하고, 함수 매개변수나 반환 타입도 선언만 할 때는 불완전 타입이어도 됩니다. include-what-you-use 같은 도구로 불필요한 include를 걷어 내는 것만으로도 상당 부분 해결되는 경우가 많습니다. PIMPL은 값 멤버 때문에 전방 선언이 불가능할 때 쓰는 다음 단계입니다.


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[Widget::Impl]
        D[data, cache, ...]
        I --> D
    end
    P -.->|포인터만| I
// widget.h — 공개 헤더
#pragma once
#include <memory>
#include <string>

class Widget {
public:
    Widget();
    ~Widget();                                  // 선언만: 정의는 .cpp
    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 title() const;
    void render();
private:
    class Impl;                                 // 중첩 클래스로 전방 선언
    std::unique_ptr<Impl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include <iostream>
#include <vector>

class Widget::Impl {
public:
    std::string title;
    std::vector<int> data;   // 나중에 멤버를 추가해도 widget.h는 바뀌지 않음
    void render() const { std::cout << "Rendering: " << title << "\n"; }
};

Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;                     // 여기서는 Impl이 완전한 타입

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& t) { pImpl_->title = t; }
std::string Widget::title() const { return pImpl_->title; }
void Widget::render() { pImpl_->render(); }

Impl을 Widget 안의 중첩 클래스로 선언하면 전역 이름 공간에 WidgetImpl 같은 이름이 새지 않고, Impl이 Widget의 private 멤버에 접근할 수 있어 편리합니다.

Rule of Five와 PIMPL

  • 소멸자: std::unique_ptr<Impl>의 삭제자는 delete를 호출하려면 Impl의 완전한 타입이 필요합니다. 그래서 소멸자는 헤더에 선언만 하고 Impl 정의가 보이는 .cpp에서 = default로 정의합니다. 소멸자를 선언하지 않으면 컴파일러가 헤더 안에서 암묵적으로 인라인 정의를 만들고, 거기서 불완전 타입 에러가 납니다(아래 에러 1).
  • 복사: unique_ptr 멤버 때문에 복사 생성자는 암묵적으로 삭제됩니다. 값 의미가 필요하면 위처럼 Impl을 깊은 복사하도록 직접 정의하고, 필요 없다면 복사를 = delete로 명시해 의도를 드러냅니다.
  • 이동: 이동 생성자와 이동 대입도 .cpp에서 = default로 정의해야 합니다. 이동 대입은 기존 Impl을 파괴하므로 역시 완전한 타입이 필요하기 때문입니다. noexcept를 붙이면 std::vector 재할당 시 복사 대신 이동이 쓰입니다.

shared_ptr<Impl>을 쓰면 삭제자가 생성 시점에 캡처되어 소멸자를 헤더에 둬도 되지만, 제어 블록과 원자적 참조 카운트 비용이 생기고 무엇보다 복사하면 Impl을 공유하는 얕은 복사가 됩니다. 이 의미를 의도한 경우(불변 Impl을 공유하는 copy-on-write 등)가 아니면 unique_ptr이 기본 선택입니다.


PIMPL과 ABI: 효과와 한계

PIMPL을 쓰면 공개 클래스의 크기가 포인터 하나로 고정되므로, Impl의 멤버를 바꿔도 옛 헤더로 컴파일된 코드와 새 라이브러리 바이너리가 같은 크기로 객체를 다룹니다. 이것이 Qt 같은 라이브러리가 “d-pointer”라는 이름으로 PIMPL을 쓰는 주된 이유입니다.

하지만 PIMPL만으로 ABI가 보장되지는 않습니다. 공개 클래스에 가상 함수를 추가하거나 순서를 바꾸면 vtable이 달라지고, 공개 함수 시그니처에 std::string 같은 표준 라이브러리 타입이 있으면 표준 라이브러리 구현이 다른 빌드와 섞일 때 레이아웃이 어긋나며, 헤더에 인라인으로 정의한 함수 본문은 사용자 바이너리에 복사되어 라이브러리를 업데이트해도 바뀌지 않습니다. 이런 요소를 어떻게 관리하는지, extern "C" 경계와 버전 전략, 그리고 변경이 ABI를 깨는지 자동으로 확인하는 도구는 ABI 호환성 #55-4에서 다룹니다.

정리하면 PIMPL은 데이터 레이아웃 변경으로부터 ABI를 지켜 주는 도구이고, 함수·가상 함수·타입 경계 쪽 ABI는 별도로 관리해야 합니다.


무거운 의존성 숨기기와 플랫폼별 구현 분리 예제

예제 1: 무거운 의존성을 숨기는 라이브러리 클래스

// document.h — 공개 API
#pragma once
#include <memory>
#include <string>
#include <vector>

class Document {
public:
    Document();
    ~Document();
    Document(const Document&);
    Document& operator=(const Document&);
    Document(Document&&) noexcept;
    Document& operator=(Document&&) noexcept;

    void setTitle(const std::string& title);
    [[nodiscard]] std::string title() const;
    void addParagraph(const std::string& text);
    [[nodiscard]] std::size_t paragraphCount() const;
private:
    class Impl;
    std::unique_ptr<Impl> pImpl_;
};
// document.cpp — 구현 (내부 구조 변경 가능)
#include "document.h"
#include <unordered_map>   // 공개 헤더에 노출되지 않음

class Document::Impl {
public:
    std::string title;
    std::vector<std::string> paragraphs;
    std::unordered_map<std::string, std::size_t> index;   // v2에서 추가해도 헤더 불변
};

Document::Document() : pImpl_(std::make_unique<Impl>()) {}
Document::~Document() = default;
Document::Document(const Document& o) : pImpl_(std::make_unique<Impl>(*o.pImpl_)) {}
Document& Document::operator=(const Document& o) {
    if (this != &o) *pImpl_ = *o.pImpl_;
    return *this;
}
Document::Document(Document&&) noexcept = default;
Document& Document::operator=(Document&&) noexcept = default;

void Document::setTitle(const std::string& t) { pImpl_->title = t; }
std::string Document::title() const { return pImpl_->title; }
void Document::addParagraph(const std::string& text) { pImpl_->paragraphs.push_back(text); }
std::size_t Document::paragraphCount() const { return pImpl_->paragraphs.size(); }

공개 함수가 std::vector<std::string>을 통째로 반환하던 것을 paragraphCount()처럼 좁히면, 내부 저장 방식을 바꿀 여지가 더 생깁니다. PIMPL은 멤버를 숨기지만, 공개 함수의 시그니처가 내부 자료구조를 그대로 드러내면 그 자료구조는 사실상 공개 API가 됩니다.

예제 2: 플랫폼별 구현 분리

// file_watcher.h — 플랫폼 중립적
#pragma once
#include <functional>
#include <memory>
#include <string>

class FileWatcher {
public:
    using Callback = std::function<void(const std::string& path)>;
    explicit FileWatcher(const std::string& path);
    ~FileWatcher();
    FileWatcher(const FileWatcher&) = delete;             // OS 핸들을 가지므로 복사 금지
    FileWatcher& operator=(const FileWatcher&) = delete;
    void setCallback(Callback cb);
    void start();
    void stop();
private:
    class Impl;
    std::unique_ptr<Impl> pImpl_;
};
// file_watcher.cpp — 플랫폼별 멤버는 여기에만
#include "file_watcher.h"
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <sys/inotify.h>
#include <unistd.h>
#endif

class FileWatcher::Impl {
public:
#ifdef _WIN32
    HANDLE hDir = INVALID_HANDLE_VALUE;
#else
    int inotifyFd = -1;
    int watchFd = -1;
#endif
    Callback callback;
    // 플랫폼별 start/stop 구현...
};

FileWatcher::FileWatcher(const std::string& path) : pImpl_(std::make_unique<Impl>()) { /* 경로 등록 */ }
FileWatcher::~FileWatcher() = default;

windows.h가 공개 헤더로 새지 않으므로, FileWatcher를 쓰는 코드는 min/max 매크로 오염 같은 windows.h의 부작용을 겪지 않습니다. 플랫폼이 많아지면 file_watcher_win.cpp와 file_watcher_linux.cpp로 나눠 CMake가 고르게 하는 방식이 더 깔끔합니다.


불완전 타입 sizeof, 이동된 객체의 null Impl, noexcept 누락

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

원인: unique_ptr<Impl>의 소멸자가 Impl의 완전한 타입을 요구하는데, 소멸자가 헤더에서 암묵적으로 또는 = default로 정의되었습니다. 이동 대입, 이동 생성자, 생성자의 예외 정리 경로도 같은 이유로 에러를 낼 수 있습니다.

// ❌ widget.h
class Widget {
    ~Widget() = default;          // 여기서 Impl은 불완전 타입
    class Impl;
    std::unique_ptr<Impl> pImpl_;
};
// ✅ widget.h: 선언만
~Widget();
// widget.cpp: Impl 정의 뒤에서
Widget::~Widget() = default;

에러 2: 이동 후 원본 사용

원인: Widget w2 = std::move(w1); 뒤에 w1.pImpl_은 nullptr입니다. PIMPL 클래스의 거의 모든 멤버 함수는 pImpl_->로 위임하므로, 이동된 객체에서 호출하면 널 포인터 역참조로 크래시가 납니다. 일반 클래스라면 “유효하지만 지정되지 않은 상태”로 대부분의 함수가 동작하는 것과 차이가 있습니다.

Widget w1;
Widget w2 = std::move(w1);
w1.render();          // ❌ pImpl_ == nullptr
w1 = Widget{};        // ✅ 새 값을 대입한 뒤에는 다시 사용 가능

복사 대입도 같은 함정이 있습니다. *pImpl_ = *other.pImpl_은 other가 이동된 객체면 널 역참조가 됩니다. 이동된 객체를 복사 원본으로 쓰는 코드가 있을 수 있다면 복사 연산에서 other.pImpl_이 비었는지 검사하거나, 이동 후에도 빈 Impl을 갖도록 이동 연산을 직접 구현하는 정책을 정해야 합니다(후자는 이동이 할당을 하게 되어 noexcept를 보장하기 어려워집니다). 이 선택은 클래스 문서에 적어 둡니다.

자기 대입(w = w)은 *pImpl_ = *other.pImpl_이 Impl의 자기 대입이 될 뿐이라 대부분 안전하지만, this != &other 검사는 불필요한 복사를 피하는 용도로 둡니다.

에러 3: Impl 정의 헤더를 공개 헤더에서 include

원인: Impl 정의를 widget_impl.h로 분리한 뒤 그 헤더를 widget.h에서 include하면 PIMPL의 의미가 사라집니다. Impl을 별도 헤더로 두는 것 자체는 테스트 코드에서 Impl에 접근하려는 경우 등에 유용하지만, 그 헤더는 구현 쪽(.cpp와 테스트)에서만 include합니다.

에러 4: noexcept 이동 생성자 누락

원인: std::vector는 재할당 시 요소의 이동 생성자가 noexcept일 때만 이동을 쓰고, 그렇지 않으면 강한 예외 보장을 위해 복사합니다. PIMPL 클래스의 복사는 Impl 전체를 깊은 복사하므로 비용이 큽니다.

Widget(Widget&&) noexcept;             // 헤더
Widget::Widget(Widget&&) noexcept = default;   // .cpp

공개 헤더에 STL 타입을 노출해서 생기는 ABI 문제나 가상 함수 추가로 vtable이 깨지는 문제는 ABI 호환성 글의 에러 절에서 다룹니다.


PIMPL의 비용과 대안

비용

  • 힙 할당: 객체마다 make_unique<Impl>() 한 번. 작은 객체를 대량으로 만드는 경우 할당 비용이 두드러질 수 있습니다.
  • 간접 참조: 모든 멤버 접근이 포인터를 한 번 더 따라갑니다. 공개 객체와 Impl이 메모리상 떨어져 있어 캐시 지역성이 나빠집니다.
  • 인라인 불가: 모든 함수 본문이 .cpp에 있으므로, LTO를 쓰지 않으면 getter 같은 짧은 함수도 인라인되지 않습니다.
  • 보일러플레이트: 공개 함수마다 위임 함수가 하나씩 필요합니다.

대부분의 클래스에서 이 비용은 무시할 수준이지만, 핫 루프에서 수백만 번 호출되는 값 타입(벡터, 행렬, 작은 핸들)에는 맞지 않습니다. PIMPL이 적합한 것은 “많이 include되고, 구현이 자주 바뀌고, 호출 빈도는 높지 않은” 서비스·매니저류 클래스입니다.

대안 1: 인터페이스 + 팩토리

// renderer.h
#pragma once
#include <memory>
class Renderer {
public:
    virtual ~Renderer() = default;
    virtual void draw() = 0;
    static std::unique_ptr<Renderer> create();   // 구현 클래스는 .cpp에만
};

추상 인터페이스를 공개하고 구현 클래스는 .cpp에 숨기는 방식입니다. PIMPL과 같은 컴파일 방화벽 효과가 있고 위임 보일러플레이트가 없지만, 가상 호출 비용이 생기고 객체를 반드시 힙에 만들어야 하며 값 의미(복사)를 주기 어렵습니다. 여러 구현(플랫폼별, 테스트용 목)을 런타임에 바꿔 끼워야 한다면 이쪽이 자연스럽고, 값처럼 복사되는 타입이라면 PIMPL이 자연스럽습니다.

대안 2: Fast PIMPL (인라인 저장소)

힙 할당을 피하려고 공개 클래스 안에 Impl 크기만큼의 정렬된 바이트 배열을 두고 placement new로 Impl을 만드는 방식입니다.

class Widget {
    // ...
private:
    struct Impl;
    alignas(std::max_align_t) std::byte storage_[64];   // Impl이 들어갈 공간
    Impl* impl() noexcept;
};
// widget.cpp (Impl은 private이므로 멤버 함수 안에서 검사)
Widget::Widget() {
    static_assert(sizeof(Impl) <= sizeof(storage_), "storage_ 크기를 늘려야 함");
    new (storage_) Impl();
}

할당은 사라지지만, Impl이 커져서 64바이트를 넘으면 공개 헤더의 숫자를 바꿔야 하고 그 순간 공개 클래스 크기가 바뀌어 ABI가 깨집니다. 즉 컴파일 방화벽은 대부분 유지되지만 ABI 안정성은 잃습니다. 여유 공간을 넉넉히 잡는 방식으로 완화할 수 있을 뿐입니다. 복사·이동·소멸도 모두 수동으로 구현해야 하므로, 측정으로 할당 비용이 문제라는 것이 확인된 경우에만 고려합니다.

대안 3: Lazy PIMPL

class HeavyWidget {
public:
    void doWork() {
        if (!pImpl_) pImpl_ = std::make_unique<Impl>();   // 첫 호출 시에만 생성
        pImpl_->work();
    }
private:
    struct Impl;
    std::unique_ptr<Impl> pImpl_;
};

생성 비용이 크고 실제로 쓰이지 않는 경우가 많은 객체에 쓸 수 있습니다. 다만 const 멤버 함수에서 지연 생성하려면 pImpl_을 mutable로 둬야 하고, 여러 스레드가 같은 객체의 const 함수를 동시에 호출하면 데이터 레이스가 됩니다. 이 경우 std::call_once 등으로 보호해야 하므로 단순함이라는 장점이 줄어듭니다.

적용 판단

적용하면 좋은 경우: 많은 곳에서 include되는 클래스, private 멤버가 자주 바뀌는 클래스, 구현이 무거운 헤더에 의존하는 클래스, 바이너리로 배포되는 라이브러리의 공개 클래스.

적용하지 않아도 되는 경우: 템플릿 클래스(구현이 헤더에 있어야 함), 핫 패스의 작은 값 타입, 헤더 전용 라이브러리, 한두 곳에서만 쓰는 내부 클래스.


PIMPL이 지켜 주지 않는 ABI 경계

PIMPL은 공개 클래스의 데이터 레이아웃을 고정해 줄 뿐입니다. 공개 멤버 함수의 시그니처를 바꾸거나, 가상 함수를 추가해 vtable 순서가 달라지거나, 인라인 함수·템플릿 구현이 헤더에 남아 있으면 PIMPL을 써도 기존 바이너리와의 호환이 깨집니다. 이 경계를 extern "C" 인터페이스, 심볼 버전 관리, 검증 도구로 어떻게 지키는지는 C++ 라이브러리 ABI를 깨지 않는 법에서 이어서 다룹니다.

헤더 의존성을 줄인 다음 빌드 구성 자체를 정리하려면 CMake 고급(#17-1)과 패키지 매니저(#17-2)로 넘어가면 됩니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 공개 헤더에 std::string이나 std::vector를 노출하면 왜 ABI가 불안정해지나요?

A. 표준 라이브러리 타입의 메모리 레이아웃은 컴파일러, 표준 라이브러리 버전, 디버그/릴리스 같은 빌드 설정에 따라 달라질 수 있습니다. 라이브러리와 이를 쓰는 애플리케이션이 다른 설정으로 빌드되면 같은 std::string이라도 레이아웃이 맞지 않아 크래시나 메모리 오염이 생깁니다. 바이너리로 배포하는 경계에는 const char*와 길이 같은 C 호환 타입을 쓰고, STL 멤버는 PIMPL 구현 안쪽에 숨깁니다.