CodeGym /행동 /C++ SELF /간단한 예제로 보는 클래스 템플릿

간단한 예제로 보는 클래스 템플릿

C++ SELF
레벨 55, 레슨 4
사용 가능

1. 클래스 템플릿

지금까지 Task, User, std::vector<Task>와 그 주변의 함수 몇 개를 만들며 평온하게 살아왔다면, 축하합니다. 이미 프로그래머입니다. 같은 일을 여러 방식으로 반복하는 법을 배운 것이니까요. 이제는 같은 일을 한 가지 방식으로 반복하는 법을 배워 보겠습니다. 클래스 템플릿은 이런 느낌이 들 때 필요합니다. “내가 같은 클래스를 계속 쓰고 있는데, 안쪽의 타입만 바뀌네.”

우리의 콘솔 애플리케이션 TaskPilot(작업용 미니 플래너)을 발전시킨다고 가정해 봅시다. 여기에는 작업, 프로젝트, 메모, 심지어 “물 마시기 알림”까지 있을 수 있고, 이들 모두에 비슷한 연산이 필요합니다. 목록 저장, 추가, 검색, 출력 같은 것들입니다.

아직 이게 왜 필요한지 확신이 없다면 그것도 괜찮습니다. 중요한 능력은 “템플릿을 어떻게 쓰는가”뿐 아니라 “언제 정말 적절한가”를 이해하는 것입니다. 클래스 템플릿은 강력하지만, 그만큼 신중함을 요구합니다.

설계도처럼 보는 클래스 템플릿

생각을 잘못해서 Box (또는 Repository)가 이미 완성된 타입이라고 여기기 쉽습니다. 하지만 실제로는 Box에서 <...>가 빠진 상태는 “머그컵”이라고만 말하고 세라믹인지, 종이컵인지, 고양이 그림이 있는지 말하지 않은 것과 같습니다. 컴파일러에게는 구체적인 정보가 필요합니다. 클래스 템플릿은 틀이고, 구체적인 타입은 템플릿 인수를 대입한 뒤에야 나타납니다.

용어를 너무 딱딱하지 않게 정리해 봅시다.

용어 형태 쉽게 말해 무슨 뜻인가
템플릿 매개변수
template <typename T>
“안에는 아직 정해지지 않은 타입 T가 있고, 나중에 대입한다”
템플릿 인수
Box<int>
“구체적인 타입 int를 T 대신 넣었다”
인스턴스화
Box<int> 사용
“컴파일러가 int용 구체적인 클래스 버전을 만들었다”

머리가 과열되지 않도록 간단한 도식을 보겠습니다.

flowchart TD
    A["template <typename T> class Box { ... }"] --> B["Box<int>"]
    A --> C["Box<std::string>"]
    A --> D["Box<double>"]
    B --> E["객체: Box<int> x;"]
    C --> F["객체: Box<std::string> s;"]

Box<int>와 Box<std::string>는 서로 다른 타입입니다. int와 std::string이 다른 것과 같습니다. 형태는 비슷해 보여도 호환되지는 않습니다.

2. 예제: Box<T>와 Setting<T>

Box<T>: “값을 담는 상자”

처음부터 복잡한 구조로 들어가지 않기 위해, 장난감 같지만 유용한 예제인 Box<T>부터 시작해 봅시다. 이 클래스는 타입 T의 값 하나를 저장합니다. 너무 단순해 보일 수 있지만, 동작 원리를 아주 잘 보여 줍니다. template를 어디에 쓰는지, <T>를 어디에 쓰는지, 그리고 const 메서드가 무엇인지 말입니다.

예를 들어 TaskPilot에서 “현재 선택된 프로젝트”(문자열)와 “현재 우선순위 필터”(숫자)를 임시로 저장하고 싶다고 해 봅시다. 어떤 타입이든 담을 수 있는 상자를 만들 수 있습니다.

#include <string>

template <typename T>
class Box {
public:
    Box() = default;

private:
    T value_{};
};

지금 상태로는 거의 쓸모가 없습니다. 값이 숨겨져 있고 메서드도 없으니까요. 하지만 핵심은 이미 보입니다. T value_{};는 타입이 T에 따라 달라지는 필드입니다.

최소한의 인터페이스를 추가해 봅시다. 값을 넣고, 꺼내는 기능입니다. 그리고 지난 며칠 동안 배운 내용도 바로 적용해 보겠습니다. 읽기 전용 접근에는 큰 객체를 복사하지 않도록 const T&가 자주 편리합니다.

template <typename T>
class Box {
public:
    explicit Box(T value) : value_(value) {}

    const T& get() const { return value_; }
    void set(T value) { value_ = value; }

private:
    T value_{};
};

이제 main()에서는 이렇게 보입니다.

#include <iostream>
#include <string>

int main() {
    Box<int> priority{2};
    Box<std::string> project{"Study"};

    std::cout << priority.get() << '\n'; // 2
    std::cout << project.get() << '\n';  // Study
}

주의할 점은 타입을 명시적으로 적어야 한다는 것입니다. Box<int>, Box<std::string>처럼 말입니다. 함수와는 달리, 함수에서는 컴파일러가 T를 추론해 주는 경우가 많지만, 기본적인 클래스 템플릿에서는 보통 타입을 직접 적어야 합니다. (클래스 타입 자동 추론은 과정의 뒤쪽에서 다루겠지만, 오늘은 아닙니다.)

Setting<T>: 중복 코드를 줄이고 동작 계약을 하나로 묶기

사람들이 처음 Box<T>를 보면 종종 이렇게 생각합니다. “그냥 int priority;와 std::string project;를 쓰면 되는데, 왜 굳이 이걸 쓰지?” 아주 정직한 질문입니다.

템플릿 클래스의 장점은 공통 동작이 생길 때 드러납니다. 즉, 서로 다른 타입에 대해 같은 방식으로 동작하는 로직이 있을 때입니다. 예를 들어 TaskPilot에서 “설정 값”을 저장하고, 항상 같은 형식으로 출력하고 싶다고 해 봅시다. key = value 같은 형태 말입니다.

Setting<T>를 만들어 봅시다. 하나의 설정을 나타냅니다.

#include <string>
#include <iostream>

template <typename T>
class Setting {
public:
    Setting(std::string name, T value) : name_(std::move(name)), value_(value) {}

    void print() const {
        std::cout << name_ << " = " << value_ << '\n'; // T에 대해 operator<< 가 필요
    }

private:
    std::string name_;
    T value_;
};

사용 예:

int main() {
    Setting<int> maxTasks{"max_tasks", 50};
    Setting<std::string> username{"user", "Alice"};

    maxTasks.print();  // max_tasks = 50
    username.print();  // user = Alice
}

바로 여기서 함수 템플릿에서 봤던 것과 같은 “연산 계약”이 등장합니다. 우리가 std::cout << value_를 수행하므로, 타입 T는 스트림에 출력될 수 있어야 합니다.

템플릿 클래스 덕분에 코드가 하나로 합쳐졌습니다. IntSetting과 StringSetting 두 클래스를 만드는 대신, Setting<T> 하나면 됩니다.

4. 실전 예제: Repository<T>

장난감 예제는 이쯤 하고, 이제 실제 코드에 더 가까운 것을 만들어 봅시다. TaskPilot에는 작업, 프로젝트, 메모 같은 엔터티가 있습니다. 이들은 자주 std::vector 안에 들어가고, 우리는 다음과 같은 연산을 수행합니다. 추가, id로 검색, 출력.

TaskRepository, ProjectRepository, NoteRepository를 각각 따로 만들다 보면, 어느 순간 세 클래스가 사실상 거의 같다는 것을 발견하게 됩니다. 바로 이럴 때 템플릿이 제 역할을 합니다.

먼저 간단한 작업 모델을 고정해 봅시다. 이전 강의들에서도 이미 사용했던 형태입니다.

#include <string>

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

이제 템플릿 저장소를 만들어 보겠습니다. 이 저장소는 std::vector<T>를 저장합니다. 그리고 T에는 int 타입의 id 필드가 있다고 가정하겠습니다. 중요한 점은, 템플릿이 “모든 타입에 대해 마법처럼 동작하는 것”이 아니라 “특정 계약을 공유하는 타입들에 대해 범용적인 것”이라는 사실입니다.

#include <vector>

template <typename T>
class Repository {
public:
    void add(T item) {
        items_.push_back(std::move(item));
    }

    const T* findById(int id) const {
        for (const auto& x : items_) {
            if (x.id == id) return &x;
        }
        return nullptr;
    }

private:
    std::vector<T> items_;
};

Task와 함께 사용하는 예:

#include <iostream>

int main() {
    Repository<Task> tasks;
    tasks.add(Task{1, "Learn templates", false});
    tasks.add(Task{2, "Drink water", true});

    const Task* t = tasks.findById(2);
    if (t) std::cout << t->title << '\n'; // Drink water
}

여기서 매우 중요한 효과를 얻습니다. 저장과 검색을 위한 하나의 코드가 서로 다른 엔터티에 대해 그대로 동작한다는 점입니다. 내일 다음과 같은 타입이 추가되더라도:

struct Project {
    int id{};
    std::string name;
};

Repository<Project>는 클래스를 복사하지 않아도 바로 동작합니다.

실전 예제: TaskPilot에서 찾은 엔터티 출력하기

주제가 너무 “공중에 떠 있는” 느낌으로 끝나지 않도록, 저장소에 작은 작업 출력 함수를 하나 더해 봅시다. 출력 자체는 템플릿이 아니지만, 템플릿 클래스가 애플리케이션을 어떻게 보조하는지 보기에는 좋습니다.

Task를 출력하는 일반 함수를 만들어 봅시다.

#include <iostream>

void printTask(const Task& t) {
    std::cout << "#" << t.id << " " << t.title;
    std::cout << (t.done ? " [done]\n" : " [todo]\n");
}

이제 findById의 결과를 사용해 봅시다.

int main() {
    Repository<Task> tasks;
    tasks.add(Task{1, "Learn templates", false});

    if (const Task* t = tasks.findById(1)) {
        printTask(*t); // #1 Learn templates [todo]
    }
}

템플릿 클래스는 “저장에 관한 전형적인 메커니즘”을 맡고, 비즈니스 코드(작업 로직)는 평범하고 읽기 쉽게 남겨 둡니다.

5. 클래스 템플릿을 어렵게 만드는 것

클래스 템플릿은 스위스 군용 칼과 비슷합니다. 유용하지만, 칼날을 자기 쪽으로 향한 채 통조림을 따려 들면 곤란해집니다. 어려움은 거의 항상 “철학”이 아니라 구체적인 지점에서 생깁니다. 문법, 타입 비호환, 컴파일러 메시지 같은 것들입니다.

서로 다른 특수화는 서로 다른 타입이다

당연해 보이지만, 뇌는 종종 “에너지를 아끼기” 위해 이것들을 같은 것으로 취급하려고 합니다.

Repository<Task> tasks;
Repository<Project> projects;

// tasks = projects; // 오류: 서로 다른 타입

이건 버그가 아니라 기능입니다. 컴파일러가 논리 오류로부터 여러분을 보호해 주는 것입니다.

<...> 없는 템플릿 이름은 타입이 아니다

초보자는 이렇게 쓰곤 합니다.

Repository repo; // 오류: T가 지정되지 않음

컴파일러는 무엇을 저장해야 하는지 추측할 수 없습니다. 이렇게 써야 합니다.

Repository<Task> repo;

네, 장황합니다. 하지만 정직합니다.

템플릿 안에서는 T에 대한 요구 사항을 암묵적으로 정의하게 된다

Repository<T>::findById 안에서 우리는 x.id를 썼습니다. 즉, 타입 T에는 .id가 있어야 합니다. 만약 여러분이 이렇게 하려고 한다면:

Repository<int> bad;

컴파일러는 id가 필요한 메서드를 컴파일하려는 순간 “폭발”합니다. 이것이 정상적인 모델입니다. 템플릿은 “진공 속에서” 컴파일되는 것이 아니라 “실제로 사용된 지점에서” 컴파일됩니다.

아직 템플릿 오류 읽기로 깊이 들어가지는 않겠지만, 유용한 습관 하나는 있습니다. 템플릿을 쓸 때 “T는 무엇을 할 수 있어야 하지?”를 머릿속으로 항상 말해 보는 것입니다.

클래스 밖의 메서드: 문법이 더 많아진다

일반 클래스에서 그랬듯이 메서드 정의를 클래스 본문 밖으로 빼기로 한다면, 표현은 좀 더 “공식적인” 형태가 됩니다. 코드에 빠져들지 않도록 아주 작은 클래스 예제로 최소 형태만 보겠습니다.

template <typename T>
class Box {
public:
    void set(T value);
private:
    T value_{};
};

template <typename T>
void Box<T>::set(T value) {
    value_ = value;
}

여기서는 즉시 두 가지 “복잡도 추가분”이 보입니다. template <typename T>를 다시 한 번 써야 하고, Box<T>::도 써야 합니다. 작은 예제에서는 그냥 귀찮고, 큰 예제에서는 오히려 규율을 만들어 줍니다.

6. 클래스 템플릿을 다룰 때의 전형적인 실수

오류 №1: 템플릿을 이미 완성된 타입으로 받아들이는 것.
많은 사람이 Box b; 또는 Repository repo;라고 쓰고, 왜 컴파일러가 화를 내는지 의아해합니다. 템플릿은 틀입니다. 타입은 인수를 대입한 뒤에야 생깁니다. Box<int>, Repository<Task>처럼 말입니다.

오류 №2: Box<int>와 Box<double>가 둘 다 “Box”니까 호환될 것이라고 기대하는 것.
클래스 템플릿에서는 각 인수가 별도의 타입을 만듭니다. 이것은 “설정만 다른 하나의 클래스”가 아니라 “클래스들의 가족”입니다. 그래서 서로 다른 특수화 사이의 대입은 금지되며, 이것이 로직이 뒤섞이는 것을 막아 줍니다.

오류 №3: T가 지원하지 않는 연산을 템플릿 안에 실수로 넣는 것.
예를 들어 Setting<T>::print()에서는 operator<<를 사용합니다. int와 std::string에는 문제가 없지만, 여러분의 사용자 정의 타입에는 출력 기능을 마련하기 전까지는 그렇지 않을 수 있습니다. 템플릿 코드는 실제로 사용할 때 비로소 컴파일되므로, 오류가 선언 위치가 아니라 사용 위치에서 갑자기 드러날 수 있습니다.

오류 №4: 컨테이너 안의 요소에 대한 포인터/참조를 반환해 놓고, 그것을 오랫동안 보관하는 것.
예제의 findById에서는 const T*를 std::vector 내부 요소에 대해 반환했습니다. 편리하긴 하지만, 포인터를 저장해 두었다가 그 뒤에 add()를 호출하면 위험할 수 있습니다. (벡터가 메모리를 재할당할 수 있기 때문입니다.) 초보자에게는, 찾은 포인터를 바로 “지금 여기서” 사용하고 오래 살아남는 변수로 저장하지 않는 습관이 안전합니다.

오류 №5: 컴파일러의 긴 메시지를 보고 당황하는 것.
템플릿 클래스가 어떤 타입에 “맞지 않을” 때, 오류 메시지는 종종 아주 긴 텍스트 두루마리처럼 보입니다. 이것은 일반화된 코드가 치르는 평범한 대가입니다. 올바른 접근은 가장 먼저 의미 있는 줄을 찾는 것입니다. 즉, 컴파일러가 여러분의 T에 대해 어떤 연산을 수행하지 못했는지를 보는 것입니다.

1
과제
C++ SELF, 레벨 55, 레슨 4
잠금
두 개의 박스
두 개의 박스
1
과제
C++ SELF, 레벨 55, 레슨 4
잠금
프로필 설정
프로필 설정
1
과제
C++ SELF, 레벨 55, 레슨 4
잠금
id로 찾는 카탈로그
id로 찾는 카탈로그
1
과제
C++ SELF, 레벨 55, 레슨 4
잠금
MinMax 저장소
MinMax 저장소
1
설문조사/퀴즈
error_code 및 오류 정책, 레벨 55, 레슨 4
사용 불가능
error_code 및 오류 정책
error_code 및 오류 정책
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION