C++ Facade 패턴: 복잡한 서브시스템 앞에 단순한 진입점 두기와 Pimpl·CRTP 변형

이 글의 핵심

Facade는 여러 서브시스템의 호출 순서와 초기화·정리 규칙을 한 클래스에 가두는 패턴입니다. 비디오·DB·게임 엔진 예제로 구조를 보고, 트랜잭션 경계를 숨길 때의 함정, 부분 초기화 실패와 역순 정리, Pimpl 컴파일 방화벽, 가상 디스패치 비용과 템플릿·CRTP 대안을 정리합니다.

Facade 패턴이란? 왜 필요한가

여러 서브시스템을 한 얼굴로 묶는 흐름은 구조 패턴 시리즈와 종합 가이드에서 다른 패턴과 대비해 볼 수 있습니다.

문제 시나리오: 복잡한 서브시스템

비디오 하나를 재생하려고 클라이언트가 여러 클래스를 직접 조작해야 하는 상황을 생각해 보겠습니다.

// 나쁜 설계: 클라이언트가 모든 세부사항을 알아야 함
int main() {
    VideoFile video("movie.mp4");
    CodecFactory factory;
    auto codec = factory.extract(video);   // unique_ptr<Codec>
    AudioMixer mixer;
    mixer.fix(video);
    VideoConverter converter;
    converter.convert(video, codec.get());
    // 네 개의 클래스와 그 호출 순서를 클라이언트가 모두 알아야 함
}

Facade 패턴은 이런 서브시스템 조작을 하나의 단순한 인터페이스 뒤로 감춥니다.

// 좋은 설계: Facade (개념 스케치)
class VideoPlayer {
    VideoFile video_;
    CodecFactory factory_;
    AudioMixer mixer_;
    VideoConverter converter_;
public:
    void play(const std::string& filename) {
        video_.load(filename);
        auto codec = factory_.extract(video_);   // unique_ptr<Codec>
        mixer_.fix(video_);
        converter_.convert(video_, codec.get());
        // 내부 복잡성 숨김
    }
};
int main() {
    VideoPlayer player;
    player.play("movie.mp4");  // 간단!
}
flowchart LR
    client[Client]
    facade["Facade<br/>(VideoPlayer)"]
    subsystem1["Subsystem1<br/>(VideoFile)"]
    subsystem2["Subsystem2<br/>(Codec)"]
    subsystem3["Subsystem3<br/>(AudioMixer)"]
    
    client --> facade
    facade --> subsystem1
    facade --> subsystem2
    facade --> subsystem3

Facade가 줄여 주는 것은 코드 줄 수보다 클라이언트가 알아야 하는 지식의 양입니다. 첫 번째 코드에서 클라이언트는 “코덱을 먼저 추출해야 하고, 오디오 싱크는 변환 전에 맞춰야 한다”는 호출 순서 규칙을 알고 있어야 했습니다. 이런 규칙이 여러 호출부에 복사되어 있으면, 서브시스템의 순서 요구가 바뀔 때마다 모든 호출부를 찾아 고쳐야 하고 한 곳이라도 놓치면 그곳만 잘못 동작합니다. Facade는 이 규칙을 한 곳에 가두어, 서브시스템이 바뀌어도 수정 범위가 Facade 내부로 끝나게 만듭니다. 반대로 서브시스템이 하나뿐이고 호출 순서 규칙도 없다면 Facade는 이름만 바꾼 위임 계층이 되어 오히려 코드를 읽는 단계만 늘립니다.


기본 구조

#include <iostream>
#include <string>
// 서브시스템 클래스들 (복잡한 내부)
class Parser {
public:
    void parse(const std::string& path) { 
        std::cout << "Parsing " << path << "...\n";
    }
};
class Validator {
public:
    bool validate() { 
        std::cout << "Validating...\n";
        return true; 
    }
};
class Compiler {
public:
    void compile() { 
        std::cout << "Compiling...\n";
    }
};
class Linker {
public:
    void link() {
        std::cout << "Linking...\n";
    }
};
// Facade: 하나의 진입점
class BuildPipeline {
    Parser parser_;
    Validator validator_;
    Compiler compiler_;
    Linker linker_;
public:
    bool build(const std::string& path) {
        std::cout << "=== Build Started ===\n";
        parser_.parse(path);
        if (!validator_.validate()) {
            std::cout << "Validation failed!\n";
            return false;
        }
        compiler_.compile();
        linker_.link();
        std::cout << "=== Build Complete ===\n";
        return true;
    }
};
int main() {
    BuildPipeline pipeline;
    if (pipeline.build("src/main.cpp"))
        std::cout << "Build OK\n";
    return 0;
}

멀티미디어 시스템 예제

클라이언트가 VideoPlayer.play 하나만 호출하고 내부에서 서브시스템을 순서대로 호출

그림: 클라이언트는 play() 하나만 호출하고, 서브시스템 호출 순서는 VideoPlayer가 맡는다.

비디오 플레이어 Facade

#include <iostream>
#include <string>
#include <memory>
// 서브시스템: 복잡한 비디오 처리
class VideoFile {
    std::string filename_;
public:
    explicit VideoFile(std::string filename) : filename_(std::move(filename)) {
        std::cout << "Loading video: " << filename_ << '\n';
    }
    std::string getCodecType() const { return "MPEG4"; }
};
class Codec {
public:
    virtual ~Codec() = default;
    virtual void decode() = 0;
};
class MPEG4Codec : public Codec {
public:
    void decode() override { std::cout << "Decoding MPEG4...\n"; }
};
class H264Codec : public Codec {
public:
    void decode() override { std::cout << "Decoding H264...\n"; }
};
class CodecFactory {
public:
    static std::unique_ptr<Codec> extract(const VideoFile& file) {
        if (file.getCodecType() == "MPEG4")
            return std::make_unique<MPEG4Codec>();
        return std::make_unique<H264Codec>();
    }
};
class AudioMixer {
public:
    void fix(const VideoFile& file) {
        std::cout << "Fixing audio sync...\n";
    }
};
class VideoRenderer {
public:
    void render() {
        std::cout << "Rendering video...\n";
    }
};
// Facade: 간단한 인터페이스
class VideoPlayer {
public:
    void play(const std::string& filename) {
        std::cout << "=== Playing Video ===\n";
        VideoFile video(filename);
        auto codec = CodecFactory::extract(video);
        codec->decode();
        AudioMixer mixer;
        mixer.fix(video);
        VideoRenderer renderer;
        renderer.render();
        std::cout << "=== Playback Started ===\n";
    }
};
int main() {
    VideoPlayer player;
    player.play("movie.mp4");
    return 0;
}

클라이언트는 코덱 선택, 오디오 동기화, 렌더링 순서를 몰라도 play() 하나만 호출하면 됩니다.


데이터베이스 래퍼

복잡한 DB 연산을 단순화

#include <iostream>
#include <string>
#include <vector>
// 서브시스템: 저수준 DB 연산
class Connection {
public:
    void connect(const std::string& host) {
        std::cout << "Connecting to " << host << "...\n";
    }
    void disconnect() {
        std::cout << "Disconnecting...\n";
    }
};
class Query {
public:
    void prepare(const std::string& sql) {
        std::cout << "Preparing query: " << sql << '\n';
    }
    void execute() {
        std::cout << "Executing query...\n";
    }
};
class ResultSet {
public:
    std::vector<std::string> fetch() {
        return {"row1", "row2", "row3"};
    }
};
class Transaction {
public:
    void begin() { std::cout << "BEGIN TRANSACTION\n"; }
    void commit() { std::cout << "COMMIT\n"; }
    void rollback() { std::cout << "ROLLBACK\n"; }
};
// Facade: 간단한 DB 인터페이스
class Database {
    Connection conn_;
    Transaction trans_;
public:
    void connect(const std::string& host) {
        conn_.connect(host);
    }
    
    std::vector<std::string> query(const std::string& sql) {
        trans_.begin();
        try {
            Query q;
            q.prepare(sql);
            q.execute();
            ResultSet rs;
            auto results = rs.fetch();
            trans_.commit();
            return results;
        } catch (...) {
            trans_.rollback();
            throw;
        }
    }
    
    void disconnect() {
        conn_.disconnect();
    }
};
int main() {
    Database db;
    db.connect("localhost:5432");
    
    auto results = db.query("SELECT * FROM users");
    for (const auto& row : results)
        std::cout << "Row: " << row << '\n';
    
    db.disconnect();
    return 0;
}

query() 하나가 트랜잭션, 쿼리 준비, 결과 수집을 모두 처리하므로 편리해 보이지만, 이 예제에는 Facade 설계에서 흔한 실수가 들어 있습니다. query()가 호출마다 트랜잭션을 열고 닫기 때문에, 호출자가 “계좌 A에서 출금하고 계좌 B에 입금”처럼 두 쿼리를 하나의 트랜잭션으로 묶을 방법이 없습니다. 두 번째 쿼리가 실패해도 첫 번째는 이미 커밋된 뒤입니다. 경계를 숨기는 것이 Facade의 목적이지만, 트랜잭션 경계처럼 호출자가 결정해야 하는 정책까지 숨기면 사용할 수 없는 API가 됩니다. 실무에서는 db.transaction([&](Tx& tx) { tx.execute(...); tx.execute(...); });처럼 경계를 호출자에게 열어 주되, 람다가 예외 없이 끝나면 커밋하고 예외가 나면 롤백하는 규칙만 Facade가 책임지는 형태가 많이 쓰입니다.

예외 처리 쪽에도 함정이 있습니다. 위 코드는 trans_.commit() 자체가 예외를 던지면 catch로 들어가 이미 실패한 커밋 뒤에 다시 rollback()을 호출합니다. 트랜잭션 상태를 추적하는 RAII 가드(소멸자에서 “커밋되지 않았다면 롤백”)를 쓰면 이런 경로를 빠뜨리지 않고, rollback() 자체가 실패할 때 소멸자에서 예외가 새어 나가지 않도록 처리하는 것도 한곳에서 해결됩니다.


Facade가 비대해지거나 우회될 때

문제 1: Facade가 너무 커짐

// 나쁜 예: God Object
class SystemFacade {
public:
    void doEverything() { /* 100줄 */ }
    void doMore() { /* 100줄 */ }
    // 20개 메서드...
};

모든 기능을 한 Facade에 몰아넣으면 서브시스템 전체에 의존하는 God Object가 됩니다. 도메인 단위로 Facade를 나눕니다.

// 좋은 예: 책임 분리
class VideoFacade { /* 비디오 관련만 */ };
class AudioFacade { /* 오디오 관련만 */ };
class NetworkFacade { /* 네트워크 관련만 */ };

문제 2: 서브시스템 직접 접근

// 나쁜 예: Facade 우회
VideoFile video("movie.mp4");
Codec* codec = new MPEG4Codec();  // 직접 접근

클라이언트가 Facade를 우회해 서브시스템을 직접 만들면 Facade가 지키던 호출 순서 규칙이 다시 호출부로 흩어집니다. 서브시스템을 internal 네임스페이스나 비공개 멤버 뒤에 둡니다.

// 좋은 예: 서브시스템 숨김
namespace internal {
    class VideoFile { /* ... */ };
}
class VideoPlayer {  // public API
    internal::VideoFile video_;
};

문제 3: 모든 기능 노출 불가

// 문제: Facade가 고급 기능을 제공하지 않음
player.play("movie.mp4");  // OK
player.setSubtitle("en");  // 없음!

Facade는 자주 쓰는 경로만 단순화하므로 고급 기능이 빠지기 쉽습니다. 필요한 기능을 Facade 메서드로 추가하거나, 서브시스템 접근자를 제공하는 두 가지 방법이 있습니다.

// 해결 1: 메서드 추가
class VideoPlayer {
public:
    void play(const std::string& filename);
    void setSubtitle(const std::string& lang);  // 추가
};
// 해결 2: 서브시스템 접근자
class VideoPlayer {
public:
    VideoFile& getVideoFile() { return video_; }  // 고급 사용자용
};

해결 2는 신중하게 써야 합니다. 서브시스템 참조를 한 번 내주면 Facade가 지키던 불변식(예: “재생 중에는 파일을 바꿀 수 없다”)을 호출자가 우회할 수 있고, 그 참조의 수명은 Facade 객체에 묶여 있어 Facade보다 오래 보관하면 댕글링이 됩니다. 실무에서 흔한 절충은 일반 사용자를 위한 Facade와 별도로, 서브시스템을 직접 조합할 수 있는 저수준 API를 공식적으로 함께 공개하는 것입니다. Facade는 편의 계층일 뿐 유일한 입구일 필요는 없고, 두 계층을 명확히 나눠 문서화하는 편이 “숨겨진 탈출구”보다 유지보수하기 쉽습니다.


Pimpl과 컴파일 방화벽

Facade 헤더가 여러 서브시스템 헤더를 include하면, Facade를 쓰는 모든 번역 단위가 그 헤더들에 의존하게 되어 서브시스템 헤더 하나만 바뀌어도 재컴파일 범위가 커집니다. Pimpl(Pointer to implementation)은 공개 헤더에서 구체 타입 정의를 없애고 구현 세부를 .cpp에 두어, 빌드 의존성과 ABI를 Facade 경계에서 끊는 관용구입니다.

컴파일 방화벽이 하는 일

공개 헤더에는 전방 선언과 불투명 포인터(std::unique_ptr<Impl> 등)만 둡니다. 서브시스템과 서드파티 헤더는 구현 파일에서만 include하므로, 서브시스템이 바뀌어도 Facade의 클라이언트는 다시 컴파일할 필요가 없습니다.

한 가지 주의할 점은 소멸자 위치입니다. std::unique_ptr<Impl>의 기본 삭제자는 delete하는 지점에서 Impl의 완전한 정의가 필요하므로, 소멸자를 .cpp에 정의해야 합니다(= default라도 구현 파일에서). 헤더에서 암시적으로 생성되면 invalid application of 'sizeof' to incomplete type 'Impl' 같은 에러가 납니다. 이동 생성자와 이동 대입 연산자도 같은 이유로 구현 파일에서 = default로 정의합니다.

최소 예시: Facade를 Pimpl로 감싸기

// PlayerFacade.h — 클라이언트는 이 헤더만 보면 됨
#pragma once
#include <memory>
#include <string>

class PlayerFacade {
public:
    PlayerFacade();
    ~PlayerFacade(); // Impl이 불완전 타입일 때 구현 파일에서 정의
    PlayerFacade(PlayerFacade&&) noexcept;
    PlayerFacade& operator=(PlayerFacade&&) noexcept;

    void play(const std::string& path);

    PlayerFacade(const PlayerFacade&) = delete;
    PlayerFacade& operator=(const PlayerFacade&) = delete;

private:
    struct Impl;
    std::unique_ptr<Impl> impl_;
};
// PlayerFacade.cpp — 무거운 서브시스템 헤더는 여기서만
#include "PlayerFacade.h"
#include "codec/CodecSubsystem.h"
#include "audio/AudioSubsystem.h"

struct PlayerFacade::Impl {
    CodecSubsystem codec_;
    AudioSubsystem audio_;
};

PlayerFacade::PlayerFacade() : impl_(std::make_unique<Impl>()) {}
PlayerFacade::~PlayerFacade() = default;
PlayerFacade::PlayerFacade(PlayerFacade&&) noexcept = default;
PlayerFacade& PlayerFacade::operator=(PlayerFacade&&) noexcept = default;

void PlayerFacade::play(const std::string& path) {
    impl_->codec_.open(path);
    impl_->audio_.route(impl_->codec_);
}

이동 연산을 = default로 두면 이동된 쪽 객체의 impl_은 nullptr이 됩니다. 이동 후에 원래 객체의 play()를 호출하면 널 포인터 역참조로 크래시하므로, 공개 메서드 앞에 assert(impl_)를 두거나 “이동된 객체는 소멸과 대입만 허용”한다고 문서화해야 합니다. 또 모든 호출이 힙에 있는 Impl을 거치므로 포인터 간접 참조 한 번과 할당 한 번이 추가되는데, Facade처럼 호출 빈도가 낮은 경계에서는 문제가 되지 않지만 작은 값 타입에 Pimpl을 쓰는 것은 대개 손해입니다.

Facade가 얇은 진입점일수록 Pimpl의 효과가 큽니다. 반대로 Facade가 템플릿 매개변수를 받으면 구체 타입이 헤더에 드러나야 하므로 Pimpl과 맞지 않습니다. 그 경우에는 아래 템플릿 Facade를 쓰거나, 비템플릿 Facade 뒤에 내부 전용 템플릿 계층을 두는 방식을 고려합니다.


가상 디스패치 오버헤드 분석

Facade 뒤에 추상 인터페이스를 두면 구현을 런타임에 교체할 수 있지만, 그 대가로 동적 디스패치 비용이 생깁니다.

비용이 생기는 이유

일반적인 구현은 객체의 vtable 포인터를 읽고, 그 테이블에서 함수 주소를 읽은 뒤 간접 호출합니다. 호출 대상이 자주 바뀌는 지점이라면 CPU의 간접 분기 예측이 빗나가 파이프라인이 비워질 수 있습니다. 더 큰 비용은 인라인이 막힌다는 점입니다. 컴파일러가 호출 대상을 알 수 없으면 함수 본문을 호출 지점에 합쳐 최적화할 수 없습니다. 클래스를 final로 닫거나 링크 타임 최적화로 대상이 하나뿐임을 증명할 수 있으면 컴파일러가 비가상화(devirtualization)해 이 문제가 사라집니다.

언제 문제가 되는가

Facade를 초기화나 I/O 같은 작업에 한두 번 호출하는 정도라면 가상 호출 비용은 측정하기도 어려운 수준입니다. 문제가 되는 것은 게임, 오디오, 네트워크 폴링 루프처럼 프레임 예산이 빠듯한 코드에서 다형 호출이 내부에서 수천 번 반복될 때입니다.

설계 대응

가상 함수는 교체가 필요한 경계에만 두고, 경계 안쪽에서는 구체 타입으로 정적 바인딩합니다. 더 이상 확장하지 않을 구현 클래스는 final로 닫고, 자주 호출되는 경로는 템플릿 Facade나 CRTP로 옮겨 컴파일 타임에 바인딩합니다. 어느 쪽이든 추측보다 측정이 먼저입니다. 같은 시나리오에서 가상 인터페이스 버전과 구체 타입 버전을 샘플링 프로파일러로 비교해 보면, 그 Facade가 단순한 정책 경계인지 핫 경로인지 바로 드러납니다.


템플릿 기반 Facade

서브시스템 타입을 템플릿 매개변수(정책)로 주입하면 Facade는 여전히 단일 진입점이면서, 런타임 다형성 없이 서브시스템 조합을 바꿀 수 있습니다. GoF의 Facade 정의에는 없는 형태지만, 인터페이스는 Facade 그대로 두고 바인딩만 컴파일 타임으로 옮긴 변형으로 볼 수 있습니다.

정책 조합 예시

template <typename CodecPolicy, typename AudioPolicy>
class MediaFacade {
    CodecPolicy codec_;
    AudioPolicy audio_;
public:
    void play(const std::string& path) {
        codec_.open(path);
        audio_.sync(codec_);
    }
};

이렇게 하면 호출 대상이 컴파일 타임에 정해지므로 가상 호출이 사라지고 인라인도 가능해집니다. 대신 정책 조합마다 코드가 따로 생성되어 바이너리 크기와 컴파일 시간이 늘고, 구현이 헤더에 노출되어 Pimpl과 함께 쓰기 어렵습니다. 이 충돌은 비템플릿 Facade 뒤에 내부 템플릿 어댑터를 두거나, 실제로 쓰는 조합만 .cpp에서 명시적으로 인스턴스화(template class MediaFacade<FfmpegCodec, AlsaAudio>;)하는 방식으로 풉니다.

if constexpr로 단일 Facade 유지

C++17 이후에는 한 클래스 안에서 정책 특성에 따라 분기를 컴파일 타임에 제거할 수 있어, “하나의 play()” 표면을 유지하면서도 불필요한 코드를 배제할 수 있습니다.

template <typename Policy>
void runPipeline(Policy& p) {
    p.parse();
    if constexpr (requires { p.optimize(); }) {
        p.optimize(); // 해당 정책에만 존재할 때만 컴파일·생성
    }
    p.emit();
}

C++20의 requires가 없는 환경이라면 std::is_invocable·트레이트 특수화로 동일한 효과를 낼 수 있습니다.


CRTP(Curiously Recurring Template Pattern)로 정적 다형 Facade

CRTP는 기반 클래스가 파생 클래스 자신을 템플릿 인자(Derived)로 받아, static_cast<Derived*>(this)로 파생 클래스의 함수를 정적으로 호출하는 패턴입니다. Facade가 훅이나 커스터마이징 지점을 제공해야 하지만 가상 함수는 피하고 싶을 때 씁니다.

확장점을 CRTP로 열기

template <typename Derived>
class EngineFacadeBase {
protected:
    void afterInitHook() {
        static_cast<Derived*>(this)->onAfterInit(); // 정적 바인딩
    }
public:
    void initialize() {
        // ... 서브시스템 초기화 ...
        afterInitHook();
    }
};

class GameFacade : public EngineFacadeBase<GameFacade> {
    friend class EngineFacadeBase<GameFacade>;
    void onAfterInit() {
        // 프로젝트 전용 후처리
    }
};

주의할 점은 템플릿 인자를 잘못 적으면 미정의 동작이 된다는 것입니다. 예를 들어 class OtherFacade : public EngineFacadeBase<GameFacade>처럼 복사·붙여넣기로 템플릿 인자를 잘못 적으면 컴파일은 되지만 static_cast가 엉뚱한 타입으로 변환합니다. 이를 막는 관용구는 기저 클래스의 생성자를 private으로 두고 friend Derived;를 선언하는 것입니다. 그러면 템플릿 인자와 실제 파생 클래스가 다를 때 기저 생성자에 접근할 수 없어 컴파일 에러가 납니다. 또 Derived가 onAfterInit()을 정의하지 않으면 'class GameFacade' has no member named 'onAfterInit' 에러가 기저 클래스 템플릿 안쪽 위치로 표시되므로, C++20이라면 콘셉트로 요구 사항을 명시해 두면 에러 메시지가 훨씬 읽기 쉬워집니다.

외부 클라이언트에게는 여전히 GameFacade라는 단일 타입만 보이고, 확장은 CRTP 내부에서 처리되므로 공개 API는 단순하게 유지됩니다.


프로덕션 리팩터링 패턴

운영 중인 시스템에서 Facade는 레거시 코드를 감싸는 이음매(seam) 역할을 할 때가 많습니다. 레거시를 안전하게 교체할 때 Facade가 쓰이는 대표적인 방식은 다음과 같습니다.

Strangler Fig(교살 무화과): 점진적 래핑

새 구현을 한 번에 갈아끼우기 어렵다면, 호출 경로를 Facade 뒤로 옮겨가며 기존 모듈을 “말려 죽이는” 방식입니다. 초기에는 Facade가 단순 위임만 하다가, 기능 단위로 새 서브시스템으로 교체합니다. 플래그·라우팅 테이블로 일부 트래픽만 신규 경로로 보내며 검증합니다.

안티코럽션 레이어(Anti-Corruption Layer)

도메인 모델과 외부 SDK/레거시 API 사이에 Facade(또는 Adapter 묶음)를 두어 외부 용어·불변식이 내부로 스며드는 것을 막습니다. 이름은 다르지만, Facade가 번역 계층이 되는 전형적인 사례입니다.

테스트 이음매(seam)와 의존성 주입으로 Facade 검증

Facade 생성자에 추상 팩토리·인터페이스를 주입하면(런타임 다형), 프로덕션은 실제 서브시스템, 테스트는 페이크로 교체할 수 있습니다. 반대로 성능이 중요한 경로는 템플릿 매개변수로 테스트 더블을 주입하는 방식도 병행됩니다.

ABI와 배포 단위

동적 라이브러리 경계에 Facade를 둘 때는 Pimpl + 안정적인 C ABI 조합으로 공개 심볼을 최소화하는 경우가 많습니다. 헤더 변경이 배포물 전파를 줄이는지, 팀 규모와 링크 모델에 맞춰 선택합니다.

Facade와 함께 쓰는 구성 패턴

Singleton Facade

class Logger {
    Logger() = default;
public:
    static Logger& instance() {
        static Logger inst;
        return inst;
    }
    void log(const std::string& msg) {
        // 복잡한 로깅 시스템 감춤
        std::cout << "[LOG] " << msg << '\n';
    }
};
// 사용
Logger::instance().log("Application started");

전역 접근이 필요할 때만 제한적으로 쓰며, 테스트 결합도를 감안해 최근 코드베이스에서는 DI 가능한 로거 Facade를 선호하는 편입니다. 함수 안의 static 지역 변수는 C++11부터 초기화가 스레드 안전하게 한 번만 일어나지만, 소멸 순서는 여전히 함정입니다. 다른 전역 객체의 소멸자가 프로그램 종료 시점에 Logger::instance().log(...)를 부르면, Logger가 이미 소멸한 뒤일 수 있어 종료 직전에만 가끔 크래시하는 버그가 됩니다. 종료 시점까지 확실히 살아 있어야 한다면 의도적으로 소멸시키지 않는 방식(static Logger* inst = new Logger;)을 쓰기도 합니다.

Builder와 조합

class VideoPlayerBuilder {
    std::string codec_;
    bool subtitles_ = false;
public:
    VideoPlayerBuilder& setCodec(const std::string& c) { codec_ = c; return *this; }
    VideoPlayerBuilder& enableSubtitles() { subtitles_ = true; return *this; }
    VideoPlayer build() { return VideoPlayer(codec_, subtitles_); }  // 해당 생성자가 있다고 가정
};
// 사용
auto player = VideoPlayerBuilder()
    .setCodec("H264")
    .enableSubtitles()
    .build();

Facade가 고정 시그니처로는 파라미터 폭발이 나면, 빌더로 단계적 구성을 노출하고 최종적으로는 단일 VideoPlayer Facade만 넘기는 식으로 정리합니다.


완전한 예제: 게임 엔진 초기화

#include <iostream>
#include <string>
// 서브시스템: 복잡한 게임 엔진 컴포넌트
class GraphicsEngine {
public:
    void init() { std::cout << "Graphics: Initializing OpenGL...\n"; }
    void setResolution(int w, int h) { 
        std::cout << "Graphics: Set resolution " << w << "x" << h << '\n'; 
    }
    void enableVSync() { std::cout << "Graphics: VSync enabled\n"; }
};
class AudioEngine {
public:
    void init() { std::cout << "Audio: Initializing OpenAL...\n"; }
    void setVolume(float v) { 
        std::cout << "Audio: Volume set to " << v << '\n'; 
    }
};
class PhysicsEngine {
public:
    void init() { std::cout << "Physics: Initializing Bullet...\n"; }
    void setGravity(float g) { 
        std::cout << "Physics: Gravity set to " << g << '\n'; 
    }
};
class InputManager {
public:
    void init() { std::cout << "Input: Initializing SDL...\n"; }
    void bindKey(const std::string& key, const std::string& action) {
        std::cout << "Input: Bind " << key << " -> " << action << '\n';
    }
};
class NetworkManager {
public:
    void init() { std::cout << "Network: Initializing sockets...\n"; }
    void connect(const std::string& server) {
        std::cout << "Network: Connecting to " << server << "...\n";
    }
};
// Facade: 게임 엔진 초기화를 하나의 인터페이스로
class GameEngine {
    GraphicsEngine graphics_;
    AudioEngine audio_;
    PhysicsEngine physics_;
    InputManager input_;
    NetworkManager network_;
    
public:
    void initialize(int width, int height) {
        std::cout << "=== Game Engine Initialization ===\n";
        
        graphics_.init();
        graphics_.setResolution(width, height);
        graphics_.enableVSync();
        
        audio_.init();
        audio_.setVolume(0.8f);
        
        physics_.init();
        physics_.setGravity(9.8f);
        
        input_.init();
        input_.bindKey("W", "MoveForward");
        input_.bindKey("S", "MoveBackward");
        
        network_.init();
        
        std::cout << "=== Initialization Complete ===\n";
    }
    
    void connectToServer(const std::string& server) {
        network_.connect(server);
    }
    
    void shutdown() {
        std::cout << "=== Shutting Down ===\n";
    }
};
int main() {
    GameEngine engine;
    engine.initialize(1920, 1080);
    engine.connectToServer("game.server.com");
    
    std::cout << "\n[Game Running...]\n\n";
    
    engine.shutdown();
    return 0;
}

출력:

=== Game Engine Initialization ===
Graphics: Initializing OpenGL...
Graphics: Set resolution 1920x1080
Graphics: VSync enabled
Audio: Initializing OpenAL...
Audio: Volume set to 0.8
Physics: Initializing Bullet...
Physics: Gravity set to 9.8
Input: Initializing SDL...
Input: Bind W -> MoveForward
Input: Bind S -> MoveBackward
Network: Initializing sockets...
=== Initialization Complete ===
Network: Connecting to game.server.com...
[Game Running...]
=== Shutting Down ===

이 예제는 모든 init()이 성공한다고 가정하지만, 실제 엔진 초기화에서 Facade가 가장 가치 있는 순간은 중간에 실패했을 때입니다. 그래픽과 오디오는 초기화됐는데 물리 엔진 초기화가 실패하면, 이미 초기화한 두 서브시스템을 역순으로 정리한 뒤 에러를 보고해야 합니다. 각 서브시스템의 초기화를 생성자에, 정리를 소멸자에 두면(RAII) C++가 이 규칙을 자동으로 지켜 줍니다. 멤버는 선언 순서대로 생성되고 역순으로 소멸하며, 생성자가 예외를 던지면 이미 생성된 멤버만 소멸자가 호출되기 때문입니다. 반대로 위처럼 init()/shutdown() 쌍으로 만들면 이 순서를 Facade가 직접 관리해야 하고, 서브시스템 사이의 의존(예: 입력이 그래픽 창 핸들을 필요로 함)이 멤버 선언 순서와 어긋나지 않도록 주의해야 합니다. 이런 Facade를 리뷰할 때는 shutdown()이 initialize()의 정확한 역순인지, 그리고 initialize()가 중간에 실패했을 때 shutdown()을 불러도 안전한지를 먼저 확인하는 것이 좋습니다. 예제의 shutdown()은 메시지만 출력하고 아무것도 정리하지 않으므로, 실제 코드라면 각 서브시스템의 정리 함수를 역순으로 호출해야 합니다.


같이 보면 좋은 글