C++ 예제로 배우는 소프트웨어 설계 기법 (12) — 애플리케이션 프레임워크(Application Framework)

작은 도구를 여러 개 만들다 보면 main마다 설정 읽기, 로그 초기화, 종료 정리 같은 코드가 똑같이 반복된다. 애플리케이션 프레임워크(Application Framework)는 이 뼈대와 main을 프레임워크 쪽이 가져가고, 사용자는 클래스 하나를 상속해 할 일만 채우게 한다. MFC의 CWinApp, wxWidgets의 wxApp, Qt의 QApplication이 모두 이런 구조다. 이 글에서는 전역 객체가 스스로 등록하는 MFC 방식과 팩토리 함수를 정의하는 wxWidgets 방식의 프레임워크를 직접 만들어 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

반복되는 main

#include <cstdio>
#include <string>

// 프로그램마다 똑같이 반복되는 뼈대
int main(int argc, char** argv)
{
    std::printf("  load config, init log\n");      // 공통 초기화
    std::string who = argc > 1 ? argv[1] : "world";
    std::printf("  hello, %s\n", who.c_str());    // 실제로 프로그램마다 다른 부분은 이것뿐
    std::printf("  flush log, cleanup\n");         // 공통 정리
    return 0;
}
$ g++ -std=c++20 -Wall -o nf no_framework.cpp
$ ./nf kim
  load config, init log
  hello, kim
  flush log, cleanup

프로그램마다 다른 부분은 가운데 한 줄인데, 앞뒤 뼈대를 매번 복사한다.

main을 가진 프레임워크

#pragma once
#include <string>
#include <vector>

class Application {
    static inline Application* the_app_ = nullptr;
    friend int main(int, char**);
protected:
    std::vector<std::string> args;
public:
    Application() { the_app_ = this; }            // 전역 객체 생성 시 자신을 등록
    virtual ~Application() = default;
    virtual bool init_instance() { return true; } // 사용자가 재정의할 지점들
    virtual int run() = 0;
    virtual void exit_instance() {}
};
#include <cstdio>
#include "framework.h"

int main(int argc, char** argv)                   // main은 프레임워크가 가진다
{
    Application* app = Application::the_app_;
    if (!app) { std::fprintf(stderr, "  no application object\n"); return 1; }
    app->args.assign(argv + 1, argv + argc);
    std::printf("  [fw] init log/config\n");
    int rc = 1;
    if (app->init_instance()) rc = app->run();
    else std::printf("  [fw] init_instance failed\n");
    app->exit_instance();
    std::printf("  [fw] cleanup, rc=%d\n", rc);
    return rc;
}

프레임워크는 실행 흐름(초기화 → init_instance → run → exit_instance → 정리)을 고정하고, 단계마다 가상 함수로 사용자 코드를 부른다. 사용자 코드가 프레임워크를 부르는 게 아니라 프레임워크가 사용자 코드를 부르는 제어의 역전(Inversion of Control)이다.

main 없는 사용자 코드

#include <cstdio>
#include "framework.h"

class HelloApp : public Application {             // main 없이 할 일만 작성
    int run() override
    {
        std::printf("  hello, %s\n", args.empty() ? "world" : args[0].c_str());
        return 0;
    }
};
HelloApp theApp;                                  // 전역 객체 하나
#include <cstdio>
#include <cstdlib>
#include "framework.h"

class CalcApp : public Application {
    bool init_instance() override
    {
        if (args.size() != 2) { std::printf("  usage: calc A B\n"); return false; }
        return true;
    }
    int run() override
    {
        std::printf("  %s + %s = %d\n", args[0].c_str(), args[1].c_str(), std::atoi(args[0].c_str()) + std::atoi(args[1].c_str()));
        return 0;
    }
    void exit_instance() override { std::printf("  calc: bye\n"); }
};
CalcApp theApp;
$ g++ -std=c++20 -Wall -o hello framework.cpp hello_app.cpp
$ g++ -std=c++20 -Wall -o calc framework.cpp calc_app.cpp
$ ./hello kim
  [fw] init log/config
  hello, kim
  [fw] cleanup, rc=0
$ ./calc 3 4
  [fw] init log/config
  3 + 4 = 7
  calc: bye
  [fw] cleanup, rc=0
$ ./calc 3; echo "exit=$?"
  [fw] init log/config
  usage: calc A B
  [fw] init_instance failed
  calc: bye
  [fw] cleanup, rc=1
exit=1

두 프로그램 모두 main 없이 클래스와 전역 객체만 정의했다. calc는 init_instance에서 인자 검사가 실패하면 run을 건너뛰지만 exit_instance와 정리는 프레임워크가 그대로 수행한다.

$ g++ -std=c++20 -Wall -o empty framework.cpp
$ ./empty; echo "exit=$?"
  no application object
exit=1

애플리케이션 객체를 빠뜨려도 링크는 성공하고, 실행해야 문제를 알 수 있다.

$ sed 's/CalcApp theApp;/CalcApp calcApp;/' calc_app.cpp > calc_app2.cpp
$ g++ -std=c++20 -o dup framework.cpp hello_app.cpp calc_app2.cpp && ./dup 1 2
  [fw] init log/config
  1 + 2 = 3
  calc: bye
  [fw] cleanup, rc=0
$ g++ -std=c++20 -o dup framework.cpp calc_app2.cpp hello_app.cpp && ./dup 1 2
  [fw] init log/config
  hello, 1
  [fw] cleanup, rc=0

두 개를 넣으면 링크 순서상 나중에 생성된 객체가 등록을 덮어써서 어느 앱이 실행될지 링크 순서가 정한다.

기반 클래스 생성자에서는 등록만

#include <cstdio>

struct Application {
    Application() { std::printf("  in base ctor: "); name(); }   // 여기서 가상 함수를 부르면?
    virtual ~Application() = default;
    virtual void name() { std::puts("Application"); }
};
struct MyApp : Application {
    void name() override { std::puts("MyApp"); }
};

int main()
{
    MyApp a;
    std::printf("  after ctor:   ");
    a.name();
}
$ g++ -std=c++20 -Wall -o cv ctor_virtual.cpp
$ ./cv
  in base ctor: Application
  after ctor:   MyApp

Application 생성자가 실행될 때는 아직 MyApp 부분이 만들어지지 않아 가상 함수가 기반 버전으로 불린다. 그래서 생성자에서는 this만 저장하고, 가상 함수 호출은 main에서 객체가 완성된 뒤에 한다.

전역 객체 대신 팩토리 함수

#pragma once
#include <memory>

struct Application {
    virtual ~Application() = default;
    virtual int run() = 0;
};

std::unique_ptr<Application> create_app();        // 사용자 쪽이 정의할 팩토리 함수

// wxWidgets IMPLEMENT_APP과 같은 방식: 전역 객체 대신 팩토리 함수를 정의하는 매크로
#define IMPLEMENT_APP(T) \
    std::unique_ptr<Application> create_app() { return std::make_unique<T>(); }
#include <cstdio>
#include "fw2.h"

int main()
{
    std::printf("  [fw2] logging ready\n");       // 프레임워크 준비가 끝난 뒤에 생성
    auto app = create_app();
    int rc = app->run();
    std::printf("  [fw2] rc=%d\n", rc);
    return rc;
}
#include <cstdio>
#include "fw2.h"

struct MyApp : Application {
    MyApp() { std::printf("  MyApp ctor\n"); }
    int run() override { std::printf("  MyApp run\n"); return 0; }
};
IMPLEMENT_APP(MyApp)
#include "fw2.h"
struct OtherApp : Application { int run() override { return 0; } };
IMPLEMENT_APP(OtherApp)
$ g++ -std=c++20 -Wall -o a2 fw2.cpp app2.cpp
$ ./a2
  [fw2] logging ready
  MyApp ctor
  MyApp run
  [fw2] rc=0
$ g++ -std=c++20 -o bad fw2.cpp 2>&1 | grep -o "undefined reference.*"
undefined reference to `create_app()'
$ g++ -std=c++20 -o bad fw2.cpp app2.cpp app3.cpp 2>&1 | grep -o "multiple definition.*" | head -1
multiple definition of `create_app()'; /tmp/cc8FocC1.o:app2.cpp:(.text+0x0): first defined here

매크로가 create_app()을 정의하므로 애플리케이션 객체는 프레임워크 준비가 끝난 뒤 main 안에서 만들어진다. 애플리케이션을 빠뜨리거나 두 개 넣는 실수도 실행 전에 링크 에러로 드러난다.

방식앱 객체 생성 시점앱 누락앱 중복
————
전역 객체 자기 등록 (MFC)main 이전 (정적 초기화)실행 중 에러마지막 등록이 덮어씀
팩토리 함수 매크로 (wxWidgets)main 안, 프레임워크 준비 후링크 에러 (undefined)링크 에러 (multiple definition)

주의사항

상황문제대응
———
기반 생성자에서 가상 함수 호출파생 클래스 재정의가 불리지 않음생성자는 등록만, 호출은 main에서
앱 전역 객체 생성자에서 다른 전역 객체 사용정적 초기화 순서 문제무거운 초기화는 init_instance로
앱 객체를 두 개 정의등록 포인터가 덮어써짐팩토리 함수 방식으로 링크 시 검출
init_instance 실패 시 정리 생략자원 누수실패해도 exit_instance·정리를 항상 호출
프레임워크를 정적 라이브러리로 배포사용자 쪽에 main이 있으면 충돌문서화, 또는 main 대신 run_app() 제공 옵션

마무리

  • 애플리케이션 프레임워크는 main과 실행 흐름을 고정하고, 사용자 코드는 가상 함수로 끼워 넣는다(제어의 역전).
  • 전역 객체가 자신을 등록하는 방식은 간단하지만 생성 시점과 실수 검출이 약하다.
  • 팩토리 함수를 정의하는 매크로 방식은 프레임워크 준비 후 앱을 만들고, 누락·중복을 링크 단계에서 잡는다.

참고

답글 남기기