CodeGym /행동 /C++ SELF /구성/CLI 기반 구현 팩토리

구성/CLI 기반 구현 팩토리

C++ SELF
레벨 52, 레슨 3
사용 가능

1. 구현 선택 문제

애플리케이션이 조금만 복잡해져도 "클래스는 하나뿐"이라는 교과서적 이야기가 신화임이 드러납니다. 실제 코드에서는 같은 아이디어에 대한 여러 구현이 자주 있습니다: 테스트용으로 빠른 메모리 저장소, 프로덕션용 다른 구현, 디버그용 세 번째 구현 등. 여기서 질문이 생깁니다: 누가 어떤 구현을 생성할지를 선택하는가?

만약 선택을 main()에서 긴 if/else로 직접 처리한다면… 처음 이틀 정도는 견딜 만합니다. 하지만 두 번째, 세 번째 장소가 생기면 복붙 지옥이 옵니다.

이 강의의 아이디어: 구현 선택은 한 곳에 있어야 합니다 — 팩토리 안에. 그리고 구성/CLI(또는 사용자의 어떤 설정)는 팩토리에게 무엇을 선택할지 알려주기만 해야 합니다.

TaskTracker와 인터페이스 ITaskStorage

팩토리를 공허하게 논의하지 않기 위해 작은 학습용 애플리케이션 TaskTracker를 사용합니다: 작업을 추가하고, 목록을 보여주고, 작업을 완료로 표시합니다.

확장 지점을 마련해야 합니다: 예를 들어 작업 저장 방식이 두 가지라고 합시다. 이를 위해 저장소가 어떤 기능을 제공하는지 나타내는 인터페이스 ITaskStorage를 정의하되 내부 구현 방식은 말하지 않습니다.

#include <iosfwd>
#include <string>
#include <string_view>

struct ITaskStorage {
    virtual ~ITaskStorage() = default;

    // 전제조건: title은 비어 있을 수 있다(허용하나 논란의 여지 있음).
    // 후조건: 생성된 작업의 id를 반환한다 (id > 0).
    virtual int add(std::string_view title) = 0;

    // 전제조건: id > 0.
    // 후조건: 작업을 찾고 완료로 표시했다면 true를 반환한다.
    virtual bool mark_done(int id) = 0;

    // 후조건: 저장소의 현재 상태를 출력한다.
    virtual void print_all(std::ostream& out) const = 0;
};

주의할 점: 우리는 일부러 이것이 std::vector나 std::map이라고 말하지 않습니다. 클라이언트 코드는 내부 구현이 아니라 계약에 의존해야 합니다.

2. 구현 №1: std::vector 기반 저장소

첫 번째 구현은 최대한 직관적으로 만들겠습니다: 작업 목록을 std::vector에 보관합니다. 작은 데이터량과 학습용으로 괜찮은 옵션입니다: 출력하기 쉽고, 추가하기 쉽고, 이해하기 쉽습니다.

내부적으로 Task 모델과 VectorTaskStorage 클래스를 둡니다.

#include <iostream>
#include <string>
#include <string_view>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

struct VectorTaskStorage : ITaskStorage {
    int add(std::string_view title) override {
        const int id = next_id_++;
        tasks_.push_back(Task{id, std::string(title), false});
        return id;
    }

    bool mark_done(int id) override {
        for (auto& t : tasks_) {
            if (t.id == id) {
                t.done = true;
                return true;
            }
        }
        return false;
    }

    void print_all(std::ostream& out) const override {
        for (const auto& t : tasks_) {
            out << "[" << (t.done ? 'x' : ' ') << "] "
                << t.id << ": " << t.title << "\n";
        }
    }

private:
    int next_id_ = 1;
    std::vector<Task> tasks_;
};

네, 여기서는 vector에 대해 선형 검색을 사용합니다. 하지만 오늘의 주제는 최적화가 아니라 구현 선택 아키텍처입니다.

4. 구현 №2: std::map 기반 저장소

대안으로 std::map<int, Task>에 작업을 보관해 보겠습니다. 키는 id입니다. 이렇게 하면 id로 완료 표시를 하는 것이 더 쉽고 일반적으로 더 빠릅니다.

#include <iostream>
#include <map>
#include <string>
#include <string_view>

struct MapTaskStorage : ITaskStorage {
    int add(std::string_view title) override {
        const int id = next_id_++;
        tasks_.emplace(id, Task{id, std::string(title), false});
        return id;
    }

    bool mark_done(int id) override {
        auto it = tasks_.find(id);
        if (it == tasks_.end()) {
            return false;
        }
        it->second.done = true;
        return true;
    }

    void print_all(std::ostream& out) const override {
        for (const auto& [id, t] : tasks_) {
            out << "[" << (t.done ? 'x' : ' ') << "] "
                << id << ": " << t.title << "\n";
        }
    }

private:
    int next_id_ = 1;
    std::map<int, Task> tasks_;
};

바깥에서 보면(즉 ITaskStorage를 통해) 두 구현 모두 동일하게 보입니다. 클라이언트 코드는 내부가 vector인지, map인지, 또는 "원소 1개짜리 배열"인지 알 필요가 없습니다.

5. 설정: 구성/CLI와 StorageKind

이제 핵심으로 넘어갑니다. 사용자가 어떤 모드를 쓸지 지정한다고 합시다: "vector" 또는 "map". 실제 애플리케이션에서는 커맨드 라인 인자나 구성 파라미터일 수 있습니다. 여기서는 간단히 std::cin에서 문자열을 읽습니다.

먼저 선택이 타입 안전적이도록 enum class를 정의합니다. 입력은 문자열이 편리하지만 프로그램 내부 프로토콜로 쓰기에는 좋지 않습니다: 오타는 언제든 발생합니다.

enum class StorageKind {
    vector,
    map
};

이제 "입력 → enum" 매핑을 고정하는 것이 유용합니다:

사용자 입력 StorageKind
"vector"
StorageKind::vector
"map"
StorageKind::map

6. 파싱: 문자열 → StorageKind

이 단계에서는 "지저분한 외부 문자열"을 "깨끗한 내부 enum"으로 분리합니다. 분리하지 않아도 되지만, 그러면 테스트와 확장이 어려워집니다.

#include <string_view>

StorageKind parse_storage_kind(std::string_view text) {
    if (text == "vector") return StorageKind::vector;
    if (text == "map")    return StorageKind::map;

    // 계약: 알 수 없는 값은 기본적으로 vector로 간주한다.
    return StorageKind::vector;
}

네, 우리는 "오류 시 기본값"을 선택했습니다. 이것이 유일한 선택지는 아닙니다. std::optional을 사용해 오류를 표시할 수도 있지만 오늘의 목표는 팩토리입니다. 핵심은 알 수 없는 값에 대한 동작이 정의되어 있어야 한다는 것입니다.

7. 팩토리: 유일한 선택 지점으로서의 make_storage

팩토리는 설정에 따라 구체 객체를 생성하지만 외부에는 인터페이스로 반환하는 함수입니다: std::unique_ptr<ITaskStorage>.

핵심 아이디어: 클라이언트 코드는 구체 구현 클래스의 이름을 알 필요가 없다. 알고 있어야 할 것은 ITaskStorage뿐입니다.

#include <memory>

std::unique_ptr<ITaskStorage> make_storage(StorageKind kind) {
    switch (kind) {
        case StorageKind::vector:
            return std::make_unique<VectorTaskStorage>();
        case StorageKind::map:
            return std::make_unique<MapTaskStorage>();
    }
    return nullptr; // 실무에서는 여기에 도달하지 않아야 한다.
}

왜 unique_ptr인가요? 단순 객체가 아닌 이유는 인터페이스 타입(ITaskStorage)은 직접 생성할 수 없는 추상형이고, 또한 기본 타입을 통해 올바르게 삭제되어야 하기 때문입니다. unique_ptr는 소유권과 수명 관리를 해결해 줍니다.

일상어로 말하면: 팩토리는 "내가 생성할게, 너는 소유해"라고 말합니다.

8. main에서의 사용

이제 작은 애플리케이션 골격을 조립합니다. main()을 얇게 유지합니다: 설정을 읽고 팩토리에 객체 생성을 요청한 뒤 간단한 시나리오를 실행합니다.

#include <iostream>
#include <memory>
#include <string>

int main() {
    std::cout << "Choose storage (vector/map): ";
    std::string mode;
    std::cin >> mode;

    const StorageKind kind = parse_storage_kind(mode);
    std::unique_ptr<ITaskStorage> storage = make_storage(kind);

    const int id1 = storage->add("Learn C++");
    const int id2 = storage->add("Write factory");

    storage->mark_done(id1);

    std::cout << "Tasks:\n";
    storage->print_all(std::cout);
}

가능한 출력 예시:

// Choose storage (vector/map): vector
// Tasks:
// [x] 1: Learn C++
// [ ] 2: Write factory

여기서 가장 중요한 것은 "작업" 자체가 아니라 main()이 무엇이 생성되었는지 모른다는 점입니다. 그것은 단지 ITaskStorage라는 사실만 압니다. 이것이 코드 결합도를 낮추는 핵심입니다.

9. 선택 흐름 다이어그램

헷갈리지 않도록 전체 흐름을 하나의 단순한 블록 다이어그램으로 보는 것이 유용합니다. 실제로는 "구성/CLI"가 문자열, enum, 설정 구조체 등 어떤 것이든 될 수 있지만 원칙은 동일합니다: 먼저 설정을 해석하고, 그 다음 한 곳에서 구현을 생성합니다.

flowchart TD
    A[사용자 설정 (문자열/구성/CLI)] --> B[parse_* (문자열 -> enum)]
    B --> C[make_* 팩토리 (enum -> unique_ptr<I>)]
    C --> D[클라이언트 코드는 ITaskStorage를 통해 동작]

어딘가에서 make_*를 건너뛰고 수동으로 구현을 생성하고 싶은 유혹이 생긴다면, 이 다이어그램의 문을 강제로 여는 것을 상상해 보세요. 할 수는 있지만 그러면 아키텍처의 의미가 사라집니다.

10. 유용한 뉘앙스와 확장

왜 팩토리는 "불필요한 함수"가 아닌가

초보자가 자주 하는 반응: "팩토리가 왜 필요해? main()에 if문 하나 쓰면 되잖아." 가능합니다. 하지만 팩토리가 필요한 이유는 선택 지점은 하나여야 한다는 점입니다.

선택이 코드 곳곳에 흩어져 있으면 세 번째 구현을 추가할 때 객체를 생성하는 모든 장소를 찾아야 합니다. 그리고 결국 하나를 빠뜨리게 됩니다(머피의 법칙은 데모에서 터질 장소를 선택합니다).

팩토리는 두 가지를 합니다: 선택을 중앙화하고 클라이언트 코드가 구체 클래스에 대해 알지 못하게 보호합니다. 애플리케이션이 성장하면(테스트, 별도 팀, 레이어, 여러 실행 시나리오 등) 이점이 더욱 명확해집니다.

왜 enum 기반 팩토리가 문자열 기반보다 거의 항상 더 좋은가

가끔 이렇게 하고 싶을 때가 있습니다:

std::unique_ptr<ITaskStorage> make_storage(std::string_view mode);

이것은 동작할 수 있지만, 두 가지 책임을 섞게 됩니다: 입력을 인식하는 것과 객체를 생성하는 것. 작은 예제에서는 편리해 보이지만 나중에는 "vector", "vec", "v", "VECTOR" 등을 받아야 할 수도 있고, 그러면 팩토리가 잡다한 일을 하게 됩니다.

분리하는 것이 낫습니다: parse_*는 "이해"를 담당하고, make_*는 "생성"을 담당합니다. 이렇게 하면 각 함수가 하나의 아이디어만 책임져서 테스트와 가독성이 좋아집니다.

새 구현을 고통 없이 추가하는 방법

예를 들어 StorageKind::debug를 추가해 모든 동작을 출력한다고 합시다. 팩토리가 있다면 할 일 체크리스트는 명확합니다: enum class에 새 항목 추가, 새 클래스 구현, 팩토리의 switch에 분기 추가. 그게 전부입니다. 클라이언트 코드는 변경할 필요가 없습니다.

팩토리가 없다면 "어디서 storage를 생성하는가", "다른 곳에서는 또 생성되는가", "테스트에서는 누가 생성하는가" 등을 찾아 헤매게 될 것입니다.

11. 흔한 실수

실수 #1: 클라이언트 코드에서 직접 구체 구현을 생성한다("아, 잠깐만 이건 여기서 만들자").
보통은 "여긴 make_unique 한 군데밖에 없어"라는 말로 시작합니다. 그러다보면 그런 곳이 다섯 군데가 되고, 팩토리는 장식 요소로 전락합니다. 구현 선택을 중앙화하기로 약속했다면, 구체 클래스 생성은 팩토리에 있어야 합니다.

실수 #2: 팩토리가 ITaskStorage*를 반환하고 std::unique_ptr<ITaskStorage>를 반환하지 않는다.
원시 포인터는 "누가 객체를 삭제하나?"와 "언제 삭제하나?"라는 의문을 불러옵니다. 때때로 답은 "아무도"이고, 놀랍게도 이는 메모리 누수로 이어집니다. unique_ptr는 소유권을 명확히 합니다.

실수 #3: 인터페이스에 가상 소멸자를 잊음.
기본 타입이 다형적이면 소멸자는 가상이어야 합니다. 가상이 아니면 unique_ptr<ITaskStorage>로 삭제할 때 객체가 제대로 파괴되지 않을 수 있습니다. 규칙은 간단합니다: 인터페이스에는 거의 항상 virtual ~I() = default;가 있어야 합니다.

실수 #4: 팩토리에 문자열 파싱, std::cin 입력, 에러 출력 등 프로젝트의 절반을 집어넣음.
팩토리는 지루해야 합니다: 콘솔에서 읽거나 사용자에게 묻거나 UX를 결정해서는 안 됩니다. 팩토리의 역할은 이미 준비된 "선택"으로부터 필요한 객체를 생성하는 것입니다. 팩토리가 이것저것 하기 시작하면 건드리기 두려운 괴물이 됩니다.

실수 #5: 알 수 없는 옵션에 대한 동작을 정의하지 않음.
알 수 없는 값(오타, 잘못된 설정 등)이 들어왔을 때 프로그램은 예측 가능한 동작을 해야 합니다: 기본값 선택, nullptr 반환, 메시지 출력 등 무엇이든 좋지만 정의되어 있어야 합니다. "그냥 어찌되겠지"는 보통 nullptr 역참조와 슬픈 학생으로 끝납니다.

1
과제
C++ SELF, 레벨 52, 레슨 3
잠금
서명 모드
서명 모드
1
과제
C++ SELF, 레벨 52, 레슨 3
잠금
포매터 팩토리
포매터 팩토리
1
과제
C++ SELF, 레벨 52, 레슨 3
잠금
거리 변환기
거리 변환기
1
과제
C++ SELF, 레벨 52, 레슨 3
잠금
할 일 저장소
할 일 저장소
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION