CodeGym /행동 /C++ SELF /“has‑a” vs “is‑a”: 디자인 도구

“has‑a” vs “is‑a”: 디자인 도구

C++ SELF
레벨 50, 레슨 2
사용 가능

1. 소개

30줄 정도의 작은 프로그램을 작성할 때는 아키텍처가 필요하지 않습니다: 모든 것이 머릿속에 들어가고 주된 문제는 ;를 잊거나 ==와 =를 헷갈리는 것입니다. 하지만 프로그램이 '한 함수보다 조금 더 커지면', 어디에 데이터가 저장되는지, 누가 데이터를 변경하는지, 누가 출력하는지, 누가 명령을 파싱하는지 같은 질서가 필요해집니다.

여기서 기본적인 설계 질문이 등장합니다: 당신의 객체는 무엇인가이다(is‑a) 인가요, 아니면 무엇을 포함/가지고 있나요(has‑a)?

간단한 콘솔 todo 애플리케이션을 만든다고 가정해봅시다. 작업(Tasks), 작업 저장소, 명령 로직("add", "done", "list"), 콘솔이 있습니다. 어느 순간 모든 것을 묶는 '주요 클래스'를 만들고 싶어집니다. 이 갈림길에서 두 가지를 할 수 있습니다: 상속으로 '붙여넣기' 하거나(이 단어는 당분간 조심스럽게 다루겠습니다) 구성으로 '부품들을 조립'하는 것입니다. 오늘은 후자를 선택합니다 — 거의 항상 더 쉽고, 더 안정적이며, 초보자에게 친절하기 때문입니다.

2. Is‑a: 객체가 다른 객체인 경우

'is‑a'라는 표현은 영어 문법처럼 들리지만, 프로그래밍에서는 매우 실용적인 합리성 체크입니다. "X is a Y"는 객체 X를 정직하게 Y라고 부를 수 있으며, 코드 사용자에게 거짓말을 하는 느낌이 들지 않는다는 의미입니다.

중요한 점: 여기서는 기술적 메커니즘이 아닌 의미에 대해 말하고 있습니다. 우리는 관계를 읽기 쉬운 방식으로 정리하는 법을 배우고 있습니다.

보통 '정직한' 예시들:

  • "고양이는 동물이다" (Cat is an Animal) — 자연스럽습니다.
  • "원은 도형이다" (Circle is a Shape) — 마찬가지입니다.
  • "학생은 사람이다" — 논리적입니다.

반면 인터뷰장에서 이상한 농담처럼 들리는 예시들:

  • "TodoApp은 TaskRepository다".
  • "명령 파서는 작업 벡터다".
  • "콘솔은 로거다" (가끔 그럴 수 있지만 보통은 아닙니다).

즉, is‑a는 객체가 다른 객체의 역할을 어디서나 맡을 수 있고 놀라움이 없어야 할 때 사용하는 것입니다. 그렇지 않다면 '사칭'을 시도하는 것입니다.

정직성 체크로서 입 밖으로 내어 말해볼 수 있는 문구가 있습니다: 대화에서 "우리 객체 X는 Y다"라고 말했을 때 그 말 때문에 사과하고 싶지 않다면, 아마도 그것은 is‑a 관계일 가능성이 높습니다.

3. Has‑a: 사칭 대신 구성

구성(composition)은 복합 객체를 각자의 역할을 수행하는 더 단순한 객체들로 만들어내는 것입니다. 쉽게 말하면: "애플리케이션은 저장소를 가지고 있다", "CLI는 입출력 스트림에 접근을 가지고 있다", "리포지토리는 작업 목록을 가지고 있다" 등입니다.

여기서 '가지고 있다'는 소유권을 반드시 의미하지는 않습니다 — 생애 주기(lifetime)에 대한 별도의 주제입니다. 그러나 거의 항상 하나의 객체가 다른 객체를 구성 요소로서 사용한다는 뜻입니다. 실무적인 C++에서는 고민될 때는 has‑a를 선택하는 것이 표준입니다.

차이를 한 표로 정리하면:

질문 is‑a has‑a
관계는 무엇을 의미하는가? "X는 Y다" "X는 Y를 포함/사용한다"
의미상 어떻게 들리는가? X는 Y를 기대하는 곳에 대체될 수 있다 X가 반드시 Y일 필요는 없지만 내부에 Y가 있다
일반적 위험 "사칭"과 이상한 책임 코드가 약간 더 많지만 역할이 명확함
초보자에게 실수하기 쉬움 대체로 안전함

핵심 아이디어: 구성은 객체의 구조를 드러낸다는 것이지, 객체를 다른 것으로 가장하려 하지 않습니다.

왜 "TodoApp is a TaskRepository"가 거의 항상 나쁜가

여기서 잠시 멈추고 정직하게 대답해볼 필요가 있습니다: "왜 애플리케이션을 리포지토리라고 부르면 안 되나요? 애플리케이션은 작업을 다루지 않나요?"

문제는 리포지토리는 저장과 조회의 역할이라는 점입니다. 반면 애플리케이션은 훨씬 더 많은 일을 합니다: 명령을 읽고 응답을 쓰며 사용자 동작을 저장소 연산과 연결합니다. "애플리케이션은 리포지토리다"라고 하면 애플리케이션이 단순히 작업 저장소일 것이라고 약속하게 됩니다. 하지만 실제로는 단순 저장소 이상입니다: '두뇌', '입', '귀' 역할도 합니다.

여기에는 심리적 요인이 있습니다: 초보자는 모든 것을 할 수 있는 '하나의 큰 객체'를 만들고 싶어하는 경향이 큽니다. 이는 자연스러운 출발점입니다. 그러나 코드가 성장하면서 다음 단계는 '모든 것을 직접 수행하는' 대신에 '조정만 하는' 주요 객체를 만드는 법을 배우는 것입니다.

4. todo 예제: 애플리케이션을 구성 요소로 분리하기

이제 예제를 조립해보겠습니다. 교육용으로 단순하게 만들 것입니다: 파일 처리 없음, 복잡한 오류 처리 없음, 불필요한 장치 없음. 목표는 구성이 애플리케이션을 어떻게 명확한 부분들로 나누는지 보는 것입니다.

모델: Task

작업 모델부터 시작합니다. 모델은 데이터입니다. 아무것도 읽지 않고 출력하지 않습니다. 단지 도메인에 존재하는 '무언가'입니다.

#include <string>

namespace todo::model {

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

} // namespace todo::model

저장소: TaskRepository

이제 저장소를 만듭니다: 여기서는 std::vector<Task>를 보유하고 항목을 추가하고 크기를 질의할 수 있습니다. 구성은 실제로 필드로 표현됩니다: 저장소는 작업 벡터를 가지고 있다.

#include <cstddef>
#include <utility>
#include <vector>

#include "task.hpp"

namespace todo::storage {

class TaskRepository {
public:
    void add(todo::model::Task t) { tasks_.push_back(std::move(t)); }
    std::size_t size() const { return tasks_.size(); }

private:
    std::vector<todo::model::Task> tasks_;
};

} // namespace todo::storage

저장소가 아직 작지만 이미 분명합니다: 저장소는 '작업'이 아니고 애플리케이션도 아닙니다. 단지 저장합니다.

조정자로서의 TodoApp

자주 하는 실수는 주요 객체가 '모든 것'이 되게 만드는 것입니다: 리포지토리이고 파서이고 프린터인. 그러나 구성을 선택하면 다르게 합니다: 주요 객체는 리포지토리를 가지고 있으며 그 작업을 리포지토리에게 위임합니다.

간단한 TodoApp 클래스를 소개하겠습니다. 이 클래스는 리포지토리를 포함합니다.

#include <cstddef>
#include <string>

#include "task_repository.hpp"

namespace todo::app {

class TodoApp {
public:
    void add_task(int id, const std::string& title) {
        repo_.add(todo::model::Task{id, title, false});
    }

    std::size_t task_count() const { return repo_.size(); }

private:
    todo::storage::TaskRepository repo_;
};

} // namespace todo::app

중요한 세부사항: TodoApp는 리포지토리가 아닙니다. 내부에 리포지토리가 있습니다. 이는 코드를 정직하게 만듭니다: 읽는 이는 TodoApp이 리포지토리를 관리한다는 것을 보고, 리포지토리인 척하지 않는다는 것을 알 수 있습니다.

위임은 구성의 연장이다

위임(delegation)은 한 객체의 메서드가 작업을 다른 객체로 '전달'할 때 일어납니다. 본질적으로 구성은 거의 항상 위임과 함께 갑니다: 객체를 부품으로 조립했다면 주요 객체는 "헤이, 리포지토리, 작업을 추가해줘", "헤이, 파서, 문자열을 파싱해줘", "헤이, CLI, 메시지를 출력해줘"라고 말할 수 있어야 합니다.

이는 개발팀의 매니저와 비슷합니다. 매니저는 핵심 코드를 직접 작성하지 않고, 모든 것을 직접 테스트하지 않으며, 릴리스를 직접 배포하지 않습니다(가끔은 그렇게 해야 할 때도 있지만). 매니저는 조정합니다: 적임자에게 일을 맡깁니다. 좋은 설계도 마찬가지입니다: 각 구성요소는 자기 일을 하고, '위'에는 그것들을 연결하는 역할이 있습니다.

5. I/O 계층: Printer와 CLI

중요한 아키텍처 습관 하나: 콘솔과 통신할 필요가 없는 구성요소는 <iostream>를 포함해서는 안 됩니다.

저장소가 내부에서 "OK"나 "ERROR"를 출력하기 시작하면 콘솔에 종속되어 버리고 다른 컨텍스트에서 재사용하기 어려워집니다. 따라서 보통 <iostream>는 CLI 계층에 둡니다.

Printer: std::ostream에 출력

아주 단순한 출력기(printer)를 만들겠습니다. 이것은 std::ostream에 출력합니다. 이것 또한 구성입니다: 프린터는 출력 스트림에 대한 참조를 가진다 (비소유 종속성).

#include <ostream>
#include <string>

namespace todo::cli {

class Printer {
public:
    explicit Printer(std::ostream& out) : out_(out) {}

    void print_line(const std::string& s) { out_ << s << '\n'; }

private:
    std::ostream& out_;
};

} // namespace todo::cli

여기서는 아직 생애 주기의 미묘한 점을 깊이 다루지 않지만 요지는 분명합니다: Printer는 '콘솔'이나 'ostream'이 아닙니다. 단지 주어진 것을 사용합니다.

TodoCli: 의존성을 가진 래퍼

이제 TodoCli를 만듭니다. 이 클래스는 문자열을 읽고 명령을 실행합니다. 이 강의에서는 포괄적인 파싱보다는 구성이 어떻게 작동하는지를 보는 것이 더 중요합니다.

TodoCli는 리포지토리(참조), 프린터(참조), 입력 스트림을 가지고 있습니다.

#include <istream>
#include <string>

#include "printer.hpp"
#include "task_repository.hpp"

namespace todo::cli {

class TodoCli {
public:
    TodoCli(todo::storage::TaskRepository& repo, Printer& printer, std::istream& in)
        : repo_(repo), printer_(printer), in_(in) {}

private:
    todo::storage::TaskRepository& repo_;
    Printer& printer_;
    std::istream& in_;
};

} // namespace todo::cli

이것은 순수한 구성입니다: CLI는 의존성을 가지고 있습니다. 사용은 하지만 리포지토리나 입력 스트림을 '가장' 하지는 않습니다.

간단한 '명령 한 스텝' 예제를 자유 함수로 추가해보겠습니다. 명령은 단순합니다: 입력 문자열이 "count"이면 작업 수를 출력합니다.

#include <istream>
#include <string>

namespace todo::cli {

void run_one(todo::storage::TaskRepository& repo, Printer& printer, std::istream& in) {
    std::string line;
    if (!std::getline(in, line)) return;

    if (line == "count") {
        printer.print_line("Tasks: " + std::to_string(repo.size())); // 예: Tasks: 3
    }
}

} // namespace todo::cli

has‑a 관점에서 본 구조

더 많은 코드를 작성하기 전에 구조를 시각화하면 좋습니다. 그렇지 않으면 머릿속에서 모든 것을 '공중에 매달아 두는' 상태가 되어 피곤해집니다. 우리가 지향하는 단순화된 구조는 다음과 같습니다:

flowchart TD
    App["TodoApp: 코디네이터"]
    Repo["TaskRepository: 저장소"]
    Print["Printer: CLI 도우미"]
    Cli["TodoCli: 명령을 읽음"]

    App --> Repo
    Cli --> Repo
    Cli --> Print

다이어그램의 의미는 간단합니다: 화살표는 '가지고 있다/사용한다'를 의미합니다. 이것이 바로 has‑a입니다.

TodoApp이 구성 요소를 연결한다

정상적인 아키텍처에서 TodoApp은 조정자입니다. 구성 요소를 생성(또는 주입)하고 이들을 연결합니다.

최소한의 형태로는 이렇게 보입니다: TodoApp은 값으로 리포지토리를 가지며, run()은 임시 Printer를 만들고 run_one을 호출합니다.

#include <iostream>

#include "printer.hpp"
#include "task_repository.hpp"

namespace todo::app {

class TodoApp {
public:
    void run(std::istream& in, std::ostream& out) {
        todo::cli::Printer printer(out);
        todo::cli::run_one(repo_, printer, in);
    }

private:
    todo::storage::TaskRepository repo_;
};

} // namespace todo::app

다시 말하지만 구성입니다: 애플리케이션은 리포지토리로 구성되어 있다. 애플리케이션은 리포지토리가 아닙니다.

6. 구성의 형태와 객체 생애 주기

이제 구성은 객체의 생애 주기 문제와 얽히기 시작합니다. 깊게 들어가지는 않지만 기본 그림을 정리해서 '그냥 포인터로 다 때우는' 실수를 피하도록 하겠습니다.

값으로 보관하기

가장 단순한 방법입니다: 만약 TodoApp이 TaskRepository repo_;를 포함한다면, 리포지토리는 애플리케이션과 함께 생성되고 함께 파괴됩니다. 보통 초보자에게 이상적인 시작점입니다: '널 참조'를 만들 확률이 적고 예측 가능성이 큽니다.

class TodoApp {
private:
    todo::storage::TaskRepository repo_; // TodoApp과 함께 존재함
};

참조로 보관하기

구성요소가 외부에서 생성될 수 있다면 참조로 보관하는 것이 가능합니다. std::ostream& 같은 의존성에 편리합니다.

class Printer {
public:
    explicit Printer(std::ostream& out) : out_(out) {}

private:
    std::ostream& out_; // out은 Printer보다 오래 살아야 함
};

참조는 "이게 없으면 동작하지 않음"을 명시합니다. 하지만 임시 객체를 넘기면 안 된다는 규율을 요구합니다.

std::unique_ptr

구성요소가 없을 수도 있거나 생성 시점을 늦추거나 구현 세부를 숨기고 싶을 때 unique_ptr을 사용합니다. 다만 이것은 '선택적·진보된' 도구로서 초기부터 복잡하게 쓰지 않도록 하세요.

#include <memory>

class TodoApp {
private:
    std::unique_ptr<todo::storage::TaskRepository> repo_;
};

중요: 왜 unique_ptr가 필요한지 모른다면 아마 당장은 필요하지 않습니다. 이것은 '유행'이 아니라 '구체적 이유'가 있을 때 쓰는 것입니다.

포인터 T*

포인터는 의존성이 없을 수도 있는 경우에 유용합니다. 그러나 지속적인 검사와 주의가 필요하기 때문에 교육용 응용 코드에서는 보통 참조나 값으로 시작하는 것이 좋습니다.

7. 흔한 실수

실수 #1: '사용한다'와 '이다'를 혼동하여 역할들을 하나의 엔티티로 합치려는 것.
보통은 "애플리케이션이 저장소와 함께 작동하니까 애플리케이션이 곧 저장소다"처럼 보입니다. 결과적으로 한 역할로는 설명할 수 없는 객체가 되고 모든 책임이 그 객체에 쌓입니다. 해결책은 간단한 습관입니다: "X is a Y"라고 소리 내어 말해보고 이상하게 들리면 재검토하세요.

실수 #2: 데이터를 보관하고, 명령을 파싱하고, 콘솔에 출력까지 동시에 하는 컴포넌트를 만드는 것.
처음에는 이렇게 하면 빠른 것처럼 보입니다. 그러나 명령 형식이 바뀌면 저장소가 깨지고 출력 형식이 바뀌면 파서가 깨지며 로직을 테스트하려면 콘솔을 '모조'해야 합니다. 구성은 역할을 분리하고 명시적으로 연결하는 것입니다.

실수 #3: '편하게' 내부의 비상수 참조를 밖으로 노출하는 것.
"std::vector<Task>&를 줘, 내가 직접 고칠게"라는 메서드를 제공하는 것은 매우 유혹적입니다. 하지만 그러면 불변식 제어를 잃고 저장소가 '데이터 자루'가 됩니다. 대신 연산 단위로 제공하세요: add, remove, mark_done, find_by_id 등.

실수 #4: 당신의 객체보다 수명이 짧은 객체에 대한 참조를 보관하는 것.
필드에 대한 참조는 강력하지만 생애주기 보증을 요구합니다. std::ostream&나 TaskRepository&를 보관한다면 누가 누구보다 오래 사는지를 이해해야 합니다. 확신이 없다면 간단한 구성요소는 값으로 보관하고, 명확한 아키텍처적 이유가 생길 때만 참조로 전환하세요.

실수 #5: '미래를 예측'하려고 미리 구성을 unique_ptr나 포인터로 복잡하게 만드는 것.
구성은 기본적으로 동적일 필요가 없습니다. 초보 코드가 복잡해지는 이유는 과제가 복잡해서가 아니라 작성자가 '대형 프로젝트처럼' 미리 설계하여 불필요한 간접층을 추가했기 때문인 경우가 많습니다. 컴포넌트가 항상 존재한다면 값으로 보관하세요. 컴포넌트가 필수지만 외부에서 주어진다면 참조로 보관하세요. 나머지는 구체적 필요가 생겼을 때 추가하세요.

1
과제
C++ SELF, 레벨 50, 레슨 2
잠금
직사각형 도면
직사각형 도면
1
과제
C++ SELF, 레벨 50, 레슨 2
잠금
프리픽스가 있는 출력
프리픽스가 있는 출력
1
과제
C++ SELF, 레벨 50, 레슨 2
잠금
영화 리포지토리
영화 리포지토리
1
과제
C++ SELF, 레벨 50, 레슨 2
잠금
노트 애플리케이션
노트 애플리케이션
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION