programming language/C++

[C++ 기초] 템플릿과 콘셉트, 가상함수

공대키메라 2026. 5. 22. 22:19

이번 글에서는 c++ 에서 말하는 템플릿, 콘셉트, 가상함수에 대해서 알아보고자 한다.

 

필자가 헷갈려 하기 때문에 이해를 위해서 간혹 Java와 비교도 할 것이다.

 


목표

템플릿, 컨셉트, 그리고 가상함수에 대해 이해한다.


1. Template과 콘셉트

 

C++에서 Template은 함수 템플릿, 클래스 템플릿, 변수 템플릿이 있다.

 

Template이란 영어 사전을 보면 작업을 할 때 기준이 되는 '틀'이나 '양식' 을 말한다.

 

그렇다면 C++ 에서도 함수를 위한 틀, 클래스를 위한 틀, 변수를 위한 틀을 만든다고 이해하자.

 

우선 시작 전에 C++의 Template 개념과 비교되는것이 Java에는 뭐가 있는지 알아보자.

 

1.1) Java Generic의 특징

해당 비교는 AI 에게 표로 차이점을 정리해달라고 한 내용이다.

 

특징 Java 제네릭(Generics) C++템플릿(Template)
동작 방식 Type Erasure(타입 소거) Code Generation
컴파일 결과 List가 List로 변환됨 각 타입마다 별도의 바이너리 코드 생성
런타임 정보 타입 정보가 사라짐 각 구체화된 타입마다 고유한 코드 존재
성능 오토박싱 비용 발생 가능 각 구체화된 타입마다 고유한 코드 존재

 

자바의 제네릭은 런타임에 존재하지 않고 컴파일 타임에만 존재한다. 

 

이게 무슨 말인가? 예시를 보자.

 

List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0); // 여기서 형변환이 일어납니다.


// 컴파일러에 의해 이렇게 바뀝니다 (Type Erasure)
List list = new ArrayList(); // <String> 정보 삭제
list.add("hello");
String s = (String) list.get(0); // Object를 꺼내서 String으로 강제 형변환

 

다시 말하면 런타임에는 제네릭이라는 문법이 수행하는 로직이 없다 는 말이다.

 

이 흐름을 다시 상기시켜보자.

 

우리가 자바 코드가 있다면 컴파일을 통해 바이트 코드로 변환하고 이것을  JVM에 올려서 실행하면 Runtime이다. 

 

여기서 컴파일을 하고 Runtime에서 실행하려면 Generic이 없다는 말이다. 

 

Java의 제네릭이 '실행을 위한 도구'가 아니라 '컴파일러에게 주는 가이드' 이기 때문이다.

 

 

컴파일러가 코드 생성시 자동으로 필요하다 판단하면 객체를 생성해서(오토 박싱) 넣어준다.

 

이렇게 JVM의 경우 스스로 해주는 경우가 많다보니 고성능을 위해서는 고려해야 할 점이 많은 것이다. 

 

1.2) 템플릿  예시 코드 분석

설명만 보면 기억도 안나도 와닿지도 않는다.

 

결국엔 코드를 사용해야 하는데 글을 보고만 이해하면 너무 지엽적이기 때문이다.

 

요즘은 그래서 AI에게 가타부타 나누지 말고 한 번에 다 보여달라고 한다. 다음과 같이 말이다. 이를 위해 Gemini를 사용했다.

 

#include <iostream>

// ---------------------------------------------------------
// 1. 변수 템플릿 (Variable Template) - C++14 도입
// 특정 타입에 맞춘 상수를 컴파일 타임에 생성합니다.
// ---------------------------------------------------------
template <typename T>
constexpr T PI = T(3.1415926535897932385L);

template <typename T>
constexpr T SENSOR_CALIBRATION_FACTOR = T(1.05);


// ---------------------------------------------------------
// 2. 클래스 템플릿 (Class Template)
// 타입(T)뿐만 아니라 값(Size, 비타입 템플릿 매개변수)도 인자로 받습니다.
// ---------------------------------------------------------
template <typename T, size_t Size>
class SensorBuffer {
private:
    T data[Size]; // 힙 할당(new)이 없는 연속된 메모리 배열
    size_t count = 0;

public:
    void add(T value) {
        if (count < Size) {
            data[count++] = value;
        }
    }

    T* getData() { return data; }
    size_t getSize() const { return count; }
};


// ---------------------------------------------------------
// 3. 템플릿 별칭 (Alias Template) - C++11 도입
// 복잡한 템플릿 구조에 직관적인 이름을 부여합니다.
// ---------------------------------------------------------
template <typename T>
using LidarBuffer = SensorBuffer<T, 1024>; // 크기가 1024로 고정된 버퍼


// ---------------------------------------------------------
// 4. 함수 템플릿 (Function Template)
// 타입과 크기가 다른 모든 SensorBuffer를 처리할 수 있는 범용 함수입니다.
// ---------------------------------------------------------
template <typename T, size_t Size>
T calculateCalibratedSum(SensorBuffer<T, Size>& buffer) {
    T sum = 0;
    T* data = buffer.getData();
    
    for (size_t i = 0; i < buffer.getSize(); ++i) {
        // 변수 템플릿(SENSOR_CALIBRATION_FACTOR) 활용
        sum += data[i] * SENSOR_CALIBRATION_FACTOR<T>;
    }
    return sum;
}


// ---------------------------------------------------------
// 메인 실행부
// ---------------------------------------------------------
int main() {
    // A. 클래스 템플릿 인스턴스화 (크기 5짜리 double 버퍼)
    SensorBuffer<double, 5> tempSensor;
    tempSensor.add(25.1);
    tempSensor.add(26.3);

    // B. 템플릿 별칭 사용 (크기 1024짜리 float 버퍼)
    LidarBuffer<float> frontLidar;
    frontLidar.add(1.23f);
    frontLidar.add(1.45f);

    // C. 함수 템플릿 호출 (컴파일러가 인자 타입을 보고 자동으로 추론)
    double tempSum = calculateCalibratedSum(tempSensor);
    float lidarSum = calculateCalibratedSum(frontLidar);

    // D. 변수 템플릿 사용
    std::cout << "원주율(double): " << PI<double> << "\n";
    std::cout << "온도 센서 보정 합계: " << tempSum << "\n";
    std::cout << "라이다 센서 보정 합계: " << lidarSum << "\n";

    return 0;
}

 

함수를 찍어내기위해서, class를 찍어내기 위해서, 변수를 찍어내기 위해서 template을 쓴다는걸로 이해해도 될 것 같다.

 

여기서 constexpr이 나오는데, 이것은 constant expression으로 무조건 프로그램 실행 전(컴파일 타임) 에 값을 확정짓은 것이다.

 

이것을 통해 런타임 오버헤드를 0으로 만들 수 있다. 즉, 빠르다는 것이다.

 

아 그럼 언제 const 랑 constexpr써야 하나요?! 헷갈려!

  • 1원칙: 변경할 필요가 없는 변수는 무조건 상수로 만든다.
  • 2원칙 (constexpr 우선): 컴파일 할 때 그 값을 미리 알 수 있는가? (예: 10, 3.14, 계산 가능한 수식) -> 무조건 constexpr
  • 3원칙 (const 차선): 파일에서 읽어오거나, 사용자 입력을 받거나, 실행 중에 계산되어야만 하는가? -> const

아니 그런데 template이랑 이걸 섞어쓰는거지?

 

위의 코드에서 보면 

 

template <typename T> constexpr T PI = T(3.1415926535897932385L); 

 

이 부분이 있는데, 사용시에 어느 값으로 받을건지 설정이 가능하다.

 

 

클래스 템플릿은 선언한 클래스를 좀 편하게 쓰도록 도와준다.

 

함수 템플릿은 동일한 작업을 쉽게 하도록 도와준다.

 

 

필자는 다음과 같이 외우고 키워드를 찾기로 했다.

 

template! 틀! 변수, 클래스, 함수 다 틀로 찍어낼 수 있구나!

 

GoF Design Pattern을 공부하면 template method pattern이 나오는데 크게 다르지 않다. 

 

무언가... 코드의 중복을 늘 줄이고 재사용을 원하는건 어떤 언어든 동일하지 않을까?

 

2. 콘셉트 (concept) 키워드

콘셉트... 개념(concept)인데 이상하게 변수 앞에 이걸 또 붙인다.

 

#include <iostream>
#include <string>
#include <concepts> // C++20 컨셉 헤더

// ---------------------------------------------------------
// 1. 컨셉(Concept) 정의
// "T가 정수(integral)이거나 소수(floating_point)일 때만 통과시켜라!"
// ---------------------------------------------------------
template <typename T>
concept Numeric = std::is_integral_v<T> || std::is_floating_point_v<T>;

// ---------------------------------------------------------
// 2. 컨셉 적용 (typename 자리에 제약 조건을 넣습니다)
// ---------------------------------------------------------
template <Numeric T>  // <typename T> 대신 <Numeric T>를 사용!
constexpr T PI = T(3.1415926535897932385L);

int main() {
    auto p1 = PI<double>; // (O) Numeric 조건 통과
    auto p2 = PI<int>;    // (O) Numeric 조건 통과
    
    // auto p3 = PI<std::string>; 
    // (X) 컴파일 에러: "std::string은 Numeric 컨셉을 만족하지 않습니다!"
}

 

컨셉이란 음... 무언가 코드의 개념을 정의하는 키워드라고 보인다.

 

cppreference에서 뭐라는지 보도록 하자.

 

개념 라이브러리는 템플릿 인수의 컴파일 시간 유효성 검사를 수행하고 유형 속성에 기반한 함수 디스패치를 ​​수행하는 데 사용할 수 있는 기본 라이브러리 개념에 대한 정의를 제공합니다. 이러한 개념은 프로그램에서 등식 추론을 위한 기초를 제공합니다.

The concepts library provides definitions of fundamental library concepts that can be used to perform compile-time validation of template arguments and perform function dispatch based on properties of types. These concepts provide a foundation for equational reasoning in programs.

출처 : https://en.cppreference.com/cpp/concepts

 

아하! 이게 라이브러리구나! 사실 글은 많은데 솔직히 이거 다 읽어봤자 내 기억에서 금방 지워질거라 생각했다.

 

Java에서와 비교해서 생각해보면 interface에 맞춰서 코딩하고 여기에 자약을 걸기 위해서는 무언가 감싸야 한다.

 

C++ 에서는 상속 구조를 보지 않고 구조 혹은 기능만 컴파일 타임에 검사한다.

 

// 1. 컨셉 정의 (명찰이 아니라 '자격 요건' 명시)
template <typename T>
concept Sensor = requires(T a) {
    { a.read() } -> std::convertible_to<double>;
};

// 2. 전혀 관계없는 두 클래스 (상속도 없고, 서로 모름)
class Lidar {
public:
    double read() { return 12.5; }
};

class Radar { // Lidar와 아무런 상속 관계가 없음
public:
    double read() { return 8.2; }
};

// 3. 템플릿(생성자/함수)에 제약 걸기
template <Sensor T>
class DataProcessor {
public:
    DataProcessor(T sensor) {  // "이름이 뭐든 상관없어. read()만 있으면 통과!"
        double data = sensor.read();
    }
};

int main() {
    Lidar lidar;
    Radar radar;

    DataProcessor<Lidar> proc1(lidar); // (O) 통과
    DataProcessor<Radar> proc2(radar); // (O) 통과! 어댑터 없이 바로 사용 가능
}

 

3. 가상함수(Virtual Function)

C++에서의 가상 함수는 Java의 abstract와 비슷해 보인다. 

 

자바코드 예시

import java.util.ArrayList;
import java.util.List;

// 추상 클래스 (C++의 순수 가상 함수를 가진 클래스와 동일)
abstract class Sensor {
    
    // 1. 일반 메서드 (기본 구현 제공, 자식에서 오버라이딩 '선택')
    public void turnOn() {
        System.out.println("센서 기본 전원 ON");
    }

    // 2. 추상 메서드 (구현 없음, 자식에서 오버라이딩 '강제')
    public abstract void processData();
}

class Lidar extends Sensor {
    // 일반 메서드 오버라이딩
    @Override
    public void turnOn() {
        System.out.println("Lidar: 고전압 전원 ON");
    }

    // 추상 메서드 필수 구현
    @Override
    public void processData() {
        System.out.println("Lidar: 3D 포인트 클라우드 매핑 중...");
    }
}

class Radar extends Sensor {
    // turnOn()은 오버라이딩 하지 않음 -> 부모의 기본 구현 사용

    // 추상 메서드 필수 구현
    @Override
    public void processData() {
        System.out.println("Radar: 도플러 효과로 속도 측정 중...");
    }
}

public class Main {
    public static void main(String[] args) {
        // 부모 타입(Sensor)의 리스트에 자식 객체들을 담음
        List<Sensor> sensors = new ArrayList<>();
        sensors.add(new Lidar());
        sensors.add(new Radar());

        // 런타임 동적 바인딩 실행
        for (Sensor s : sensors) {
            s.turnOn();      
            s.processData(); 
            System.out.println("---");
        }
    }
}

 

Java에서 메소드에 abtract를 붙이면 이건 알아서 구상하라! 하는 의미로 강제성을 띈다.

 

그러면 C++를 보자. 

 

C++ 코드 예시

#include <iostream>
#include <vector>
#include <memory>

// 추상 클래스 역할
class Sensor {
public:
    // [중요] C++에서 상속을 사용할 때는 소멸자에 반드시 virtual을 붙여야 합니다.
    // 안 그러면 부모 포인터로 자식을 삭제할 때 자식의 메모리가 해제되지 않습니다.
    virtual ~Sensor() = default;

    // 1. 일반 가상 함수 (Java의 일반 메서드와 동일, 오버라이딩 '선택')
    virtual void turnOn() {
        std::cout << "센서 기본 전원 ON\n";
    }

    // 2. 순수 가상 함수 (Java의 abstract 메서드와 동일, = 0을 붙여 오버라이딩 '강제')
    virtual void processData() = 0;
};

class Lidar : public Sensor {
public:
    // 일반 가상 함수 오버라이딩
    void turnOn() override {
        std::cout << "Lidar: 고전압 전원 ON\n";
    }

    // 순수 가상 함수 필수 구현
    void processData() override {
        std::cout << "Lidar: 3D 포인트 클라우드 매핑 중...\n";
    }
};

class Radar : public Sensor {
public:
    // turnOn()은 오버라이딩 하지 않음 -> 부모의 기본 구현 사용

    // 순수 가상 함수 필수 구현
    void processData() override {
        std::cout << "Radar: 도플러 효과로 속도 측정 중...\n";
    }
};

int main() {
    // C++ 다형성은 부모 클래스의 '포인터'를 통해서만 동작합니다.
    // std::unique_ptr는 Java의 GC처럼 스코프를 벗어나면 메모리를 자동 해제해 줍니다.
    std::vector<std::unique_ptr<Sensor>> sensors;
    
    // 자식 객체들을 힙(Heap)에 생성하고 포인터를 리스트에 보관
    sensors.push_back(std::make_unique<Lidar>());
    sensors.push_back(std::make_unique<Radar>());

    // 런타임 동적 바인딩 실행 (vtable을 거쳐서 실제 메서드 호출)
    for (const auto& s : sensors) {
        s->turnOn();      // 포인터이므로 . 대신 -> 연산자 사용
        s->processData();
        std::cout << "---\n";
    }

    return 0;
}

 

C++에서는 일반 가상 함수, 순수 가상 함수가 있다.

 

일반 가상 함수는 그냥 써도 되는거고, 오버라이딩이 선택이지만

 

순수 가상 함수는 오버라이딩이 강제이다. 

 

왜 이렇게까지 c++에서는 virtual키워드를 써주고 말고 해야할까? 

 

 

이에 대해 이해하기위해 C++의 철학중 zero-overhead abstraction을 보자.

 

https://en.cppreference.com/w/cpp/language/Zero-overhead_principle

 

즉, "제로 오버헤드 원칙"이란, "개발자인 네가 허락하지 않은 짓은 컴파일러가 뒤에서 몰래 절대 하지 않겠다" 라는 말이다.

 

너가 직접 다 짜라! 이 말이다.

 

 

그러면 virtual 키워드랑 이게 무슨 상관이야? 이에 대한 대답은 Gemini에게 얻었다.

 

virtual 키워드가 바로 이 "사용하지 않는 것에 비용을 지불하지 마라(You don't pay for what you don't use)"는 제로 오버헤드 원칙이 언어 문법으로 탄생한 가장 대표적인 결과물이기 때문입니다.

정확히 이 원칙이 virtual 키워드와 어떻게 연결되는지 세 줄로 요약해 드립니다.

1. 다형성이라는 '기능'에는 반드시 '비용'이 청구된다.
메서드를 동적 바인딩(다형성)으로 실행하려면, 컴파일러는 객체 안에 몰래 8바이트짜리 포인터(vptr)를 추가해야 하고, 실행할 때마다 메모리를 건너뛰는 성능 저하(오버헤드)가 발생합니다. 이것이 기능의 '비용'입니다.

2. Java는 안 써도 일괄 결제, C++은 쓴 사람만 결제.
Java (원칙 위배):
다형성을 쓰든 안 쓰든, 모든 메서드에 기본적으로 다형성을 열어둡니다. 즉, 내가 다형성을 전혀 쓰지 않는 단순한 클래스를 만들어도, JVM은 나에게 메모리와 성능 오버헤드라는 '비용을 강제로 청구'합니다.

C++ (제로 오버헤드 원칙):
내가 다형성을 안 쓴다면, 단 1바이트의 메모리나 1클럭의 오버헤드도 허용하지 않아야 합니다. 그래서 C++은 다형성을 '기본 기능에서 빼버렸습니다'.

3. virtual = "내가 비용을 지불하겠다"는 결재 서명
결론적으로 virtual 키워드는 다형성이라는 고급 기능을 쓰기 위해, 개발자가 컴파일러에게 "나 이 기능 쓸 거니까, 8바이트 늘어나고 조금 느려지는 오버헤드 비용 청구서에 동의할게!"라고 명시적으로 사인하는 결재 서명입니다.

이 키워드를 적지 않으면 C++은 제로 오버헤드 원칙에 따라 "다형성을 안 쓰는구나? 그럼 1원도 안 깎이게 가장 빠르고 가벼운 코드로 만들어줄게"라며 정적 바인딩으로 코드를 찍어내게 됩니다.

이 원칙 때문에 C++은 개발자가 타이핑해야 할 키워드(virtual, constexpr, const)가 Java보다 훨씬 많지만, 그 대가로 세상에서 가장 정밀하게 하드웨어 자원을 통제할 수 있는 언어가 된 것입니다.

 


 

최근에는 공부하는 방식을 많이 바꿨다.

 

이전에는 천천히 다 따라서 읽는 방식이었다. 

 

이 방식의 단점은 뭔가 하다가 막히거나 흥미가 떨어지면 금방 다른 길로 세어버리기 좋다는 점이다.

 

 

최근에 하는 방식은 빠르게 우선 책을 본다. 그리고 모르면 아 그런가보다 하고 슥슥 넘긴다. 

 

그리고 이렇게 모르는 키워드에 대해 글을 정리한다. 

 

그와중에 AI에게 끊임없이 질문하고 공신력 있는 글을 보면서 내가 원하는 순서로 정리를 한다. 

 

 

솔직히 말하면 이렇게 해도 금방 까먹는다. 

 

그런데 전에보다 재미는 더 있으니 기존의 방식보다 흥미를 더 유발하니 나쁘지 않은 것 같다.

 

물론 동일하게... 잘 까먹어서 다시 들여다봐야 하지만 말이다.

 

 

참고:

https://www.geeksforgeeks.org/cpp/templates-cpp/

https://en.cppreference.com/cpp/concepts

https://en.cppreference.com/w/cpp/language/Zero-overhead_principle