[2026] C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]

[2026] C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]

이 글의 핵심

C++ 커스텀 Concepts 작성: 도메인에 맞는 제약 조건 정의하기 [#22-2]. 실무에서 겪은 문제·템플릿이 너무 관대할 때.

들어가며: “우리 타입만 받고 싶어요”

표준 개념만으로는 부족할 때

정렬 가능한 타입, 직렬화 가능한 타입(데이터를 저장·전송용 형태로 바꿀 수 있는 타입)처럼 도메인별 조건을 템플릿에 걸고 싶었습니다. 표준에는 없는 제약이라 직접 Concept을 정의해야 했습니다.
커스텀 concept은 “이 타입이 이 연산/멤버를 지원한다”를 requires로 적어 두면, 라이브러리 내부에서만 쓰는 타입이나 도메인 타입에 맞는 제약을 만들 수 있습니다. 표준 개념과 &&, ||로 조합해 쓰면 재사용하기 좋은 제약을 많이 만들 수 있습니다. 목표:

  • Sortable, Serializable 같은 커스텀 Concept 정의
  • requires 표현식(requires { ....}—타입이 특정 연산·멤버를 가졌는지 검사하는 식)으로 “이 연산/타입이 있다” 표현
  • 여러 개념을 조합해 사용 컴파일: C++20 기준이므로 g++ -std=c++20(또는 clang++ -std=c++20)으로 빌드하면 됩니다. 이 글을 읽으면:
  • concept 선언과 requires 표현식 문법을 쓸 수 있습니다.
  • 연산·타입·반환 타입 요구 사항을 정의할 수 있습니다.
  • 기존 표준 개념과 조합해 실전에 적용할 수 있습니다.

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. concept 선언
  2. requires 표현식 상세
  3. 복합 요구 사항
  4. 완전한 커스텀 Concept 예제
  5. Concept 조합과 재사용
  6. 자주 발생하는 오류와 해결법 6-1. 모범 사례 (Best Practices)
  7. 성능 비교: enable_if vs Concepts
  8. 프로덕션 패턴
  9. 실전 예제

1. concept 선언

기본 형태

Sortable은 “두 값 a, b에 대해 a < b가 가능하고, std::swap(a, b)가 호출 가능한 타입”을 요구합니다. requires(T a, T b) 안에 나열한 표현식이 컴파일만 되면 해당 요구 사항을 만족한 것으로 간주됩니다. 즉 std::sort에 넘길 수 있는 타입(비교·스왑 가능)을 라이브러리 내부에서 “Sortable”이라는 이름으로 재사용할 때 유용합니다. 표준에는 std::sortable이 있지만, 도메인별로 “우리 프로젝트에서 쓰는 정렬 가능 타입”을 이렇게 정의해 두면 API 의도가 분명해집니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;           // 비교 가능
    std::swap(a, b); // swap 가능 (또는 using std::swap; swap(a,b);)
};

타입 요구 사항

HasIterator는 “T::iteratorT::value_type이라는 타입이 존재한다”만 검사합니다. typename T::iterator처럼 typename으로 중첩 타입을 요구하면, std::vector, std::map 같은 STL 컨테이너는 만족하고, T가 그런 멤버 타입이 없으면 제약 불만족으로 걸러집니다. “반복자로 순회 가능한 컨테이너만 받고 싶다”는 제약을 std::ranges::range와 함께 쓰거나, 이렇게 단순히 타입 존재만 요구할 때 사용할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept HasIterator = requires {
    typename T::iterator;
    typename T::value_type;
};

반환 타입 요구 사항

{ expr } -> Concept 형태로 “이 표현식의 반환 타입이 Concept을 만족해야 한다”를 적을 수 있습니다. ReturnsInt는 “f()를 호출했을 때 반환 타입이 int와 동일한 타입”이어야 한다는 제약입니다. std::same_as<int>는 “완전히 같은 타입”만 허용하고, std::convertible_to<int>를 쓰면 int로 변환 가능한 타입(short, long 등)도 허용할 수 있습니다. 콜백·함수 객체가 “정수만 반환한다”를 컴파일 타임에 검사할 때 유용합니다. 다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept ReturnsInt = requires(T f) {
    { f() } -> std::same_as<int>;
};

2. requires 표현식 상세

여러 표현식 나열

requires(T obj) 안에 obj.draw(), obj.getBounds()처럼 여러 표현식을 나열하면, “T 타입의 객체가 이 멤버 함수들을 호출 가능해야 한다”는 제약이 됩니다. 반환 타입은 검사하지 않고, 문법적으로 호출만 가능한지 확인합니다. GUI에서 “그릴 수 있는 객체”, “경계를 반환하는 객체”만 받고 싶을 때 이렇게 Concept을 두면, 인터페이스 상속 없이도 “draw·getBounds가 있는 타입”만 허용할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Drawable = requires(T obj) {
    obj.draw();           // 멤버 함수
    obj.getBounds();      // 반환 타입은 검사 안 함 (호출 가능만)
};

반환 타입 검사

{ t.toString() } -> std::convertible_to<std::string>는 “t.toString()의 반환 타입이 std::string으로 변환 가능해야 한다”는 뜻입니다. same_as는 타입이 완전히 같아야 하고, convertible_to는 암시적 변환이 가능하면 됩니다. 예: 반환 타입이 const std::string&이거나 std::string이어도 만족합니다. 로깅·직렬화에서 “문자열로 바꿀 수 있는 타입”만 받고 싶을 때 쓰기 좋습니다. 다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept StringConvertible = requires(T t) {
    { t.toString() } -> std::convertible_to<std::string>;
};

중첩 요구 사항 (nested requirement)

Allocator는 “allocate(n)T::value_type*를 반환하고, deallocate를 호출할 수 있으며, T는 기본 생성 가능해야 한다”는 제약입니다. requires 블록 안에 연산·호출을 나열하고, 밖에 && std::default_constructible<T>처럼 다른 Concept을 붙여 여러 조건을 동시에 요구할 수 있습니다. STL 스타일 할당자나 커스텀 메모리 풀을 템플릿 인자로 받을 때 이런 제약을 두면, 잘못된 타입이 넘어오는 것을 컴파일 단계에서 막을 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Allocator = requires(T a, size_t n) {
    { a.allocate(n) } -> std::same_as<typename T::value_type*>;
    a.deallocate(/* ....*/);
} && std::default_constructible<T>;

3. 복합 요구 사항

논리 조합

Numberstd::integral이거나 std::floating_point인 타입만 허용합니다. ||로 두 Concept을 묶으면 “둘 중 하나만 만족해도 된다”는 뜻이 되어, add는 정수형·부동소수점 모두 받을 수 있습니다. &&로 묶으면 “모두 만족해야 한다”입니다. 표준 개념을 이렇게 조합해 “숫자 타입”, “반복 가능한 컨테이너” 같은 도메인 개념을 짧게 정의할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;
template <Number T>
T add(T a, T b) {
    return a + b;
}

기존 개념 기반

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Comparable = requires(T a, T b) {
    { a < b } -> std::same_as<bool>;
    { a > b } -> std::same_as<bool>;
};
template <typename T>
concept SortableContainer = std::ranges::range<T> && Comparable<std::ranges::range_value_t<T>>;

4. 완전한 커스텀 Concept 예제

예제 1: Serializable — 직렬화 가능 타입

파일·네트워크로 객체를 저장할 때 “serialize 메서드가 있는 타입만 받겠다”를 Concept으로 두면, 템플릿 함수 안에서 t.serialize(os)를 안전하게 호출할 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <iostream>
#include <fstream>
#include <concepts>
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
// 사용 예: Player 클래스
struct Player {
    std::string name;
    int level;
    void serialize(std::ostream& os) const {
        os << name << " " << level;
    }
};
template <Serializable T>
void saveToFile(const T& obj, const std::string& path) {
    std::ofstream f(path);
    obj.serialize(f);
}
int main() {
    Player p{"Hero", 10};
    saveToFile(p, "player.dat");  // ✅ OK
    // saveToFile(42, "x.dat");   // ❌ 에러: int는 Serializable 아님
}

예제 2: Hashable — 해시 가능 타입

해시맵의 키로 쓸 수 있는 타입을 제약합니다. std::hash<T>가 특수화되어 있고, operator==가 있어야 합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <functional>
#include <concepts>
template <typename T>
concept Hashable = requires(T a, T b) {
    { std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
    { a == b } -> std::same_as<bool>;
};
template <Hashable K, typename V>
class SimpleHashMap {
    // K를 키로 사용하는 해시맵 구현
};

예제 3: CallableWith — 특정 인자로 호출 가능

콜백이나 함수 객체가 주어진 인자 타입으로 호출 가능한지 검사합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <concepts>
#include <utility>
template <typename F, typename....Args>
concept InvocableWith = requires(F f, Args&&....args) {
    { f(std::forward<Args>(args)...) };
};
template <typename F, typename R, typename....Args>
concept ReturnsWhenCalledWith = requires(F f, Args&&....args) {
    { f(std::forward<Args>(args)...) } -> std::same_as<R>;
};
// 사용 예
void process(int x, InvocableWith<int> auto callback) {
    callback(x);
}

예제 4: SmartPointer — 스마트 포인터류

역참조, operator->, get()을 지원하면서 raw pointer가 아닌 타입을 요구합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <concepts>
#include <memory>
template <typename T>
concept SmartPointer = requires(T p) {
    *p;
    p.operator->();
    p.get();
} && !std::same_as<T, std::remove_cvref_t<T>*>;
template <SmartPointer P>
void usePointer(P p) {
    auto& ref = *p;
    auto ptr = p.get();
}

예제 5: RangeWithSize — 크기를 알 수 있는 범위

std::ranges::range이면서 size() 멤버나 std::ranges::size()로 크기를 구할 수 있는 타입입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <ranges>
#include <concepts>
template <typename T>
concept RangeWithSize = std::ranges::range<T> && requires(T& r) {
    { std::ranges::size(r) } -> std::convertible_to<std::size_t>;
};
template <RangeWithSize R>
void reserveAndProcess(R&& r) {
    auto sz = std::ranges::size(r);
    // 사전 할당 등에 활용
}

예제 6: 복합 Concept — ThreadSafeSerializable

직렬화 가능하면서 lock/unlock 메서드를 가진 타입을 요구합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template <typename T>
concept ThreadSafeSerializable = Serializable<T> && requires(T& t) {
    t.lock();
    t.unlock();
};
template <ThreadSafeSerializable T>
void saveThreadSafe(T& obj, const std::string& path) {
    obj.lock();
    std::ofstream f(path);
    obj.serialize(f);
    obj.unlock();
}

예제 7: compound requirements — 반환 타입 + noexcept 검사

{ expr } noexcept 형태로 “이 표현식이 예외를 던지지 않아야 한다”를 요구합니다. noexcept반환 타입을 동시에 검사할 수 있습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <concepts>
#include <utility>
// 이동 생성자가 noexcept인 타입 (벡터 재할당 등에 중요)
template <typename T>
concept NoThrowMoveConstructible = std::move_constructible<T> && requires(T t) {
    { T(std::move(t)) } noexcept -> std::same_as<T>;
};
// 스왑이 noexcept인 타입
template <typename T>
concept NoThrowSwappable = requires(T& a, T& b) {
    { std::swap(a, b) } noexcept;
};
// 사용: 예외 안전성이 중요한 컨테이너
template <NoThrowMoveConstructible T>
class SafeVector {
    // 재할당 시 std::move_if_noexcept 사용 가능
};

예제 8: 타입 요구 + 표현식 요구 — AllocatorAware

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename A>
concept StdAllocator = requires(A a, size_t n, typename A::value_type* p) {
    typename A::value_type;
    typename A::size_type;
    { a.allocate(n) } -> std::same_as<typename A::value_type*>;
    a.deallocate(p, n);
    { a.max_size() } -> std::convertible_to<typename A::size_type>;
} && std::copyable<A>;

예제 9: 논리 조합(||) — InputOrOutput

여러 인터페이스 중 하나라도 만족하면 되는 Concept입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename S>
concept InputStream = requires(S& s) { s.get(); { s.good() } -> std::convertible_to<bool>; };
template <typename S>
concept OutputStream = requires(S& s, char c) { s.put(c); { s.good() } -> std::convertible_to<bool>; };
template <typename S>
concept InputOrOutputStream = InputStream<S> || OutputStream<S>;

예제 10: InvocableWithResult — 반환 타입 제약

람다가 특정 인자로 호출 가능하고, 반환 타입이 특정 Concept을 만족해야 할 때 사용합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template <typename F, typename R, typename....Args>
concept InvocableWithResult = requires(F f, Args&&....args) {
    { std::invoke(f, std::forward<Args>(args)...) } -> std::same_as<R>;
};
template <InvocableWithResult<std::string, int> F>
std::vector<std::string> mapToStrings(const std::vector<int>& vec, F f) {
    std::vector<std::string> result;
    for (int x : vec) result.push_back(f(x));
    return result;
}

5. Concept 조합과 재사용

개념 합치기 (&&)

여러 Concept을 &&로 연결하면 “모두 만족해야 한다”는 의미입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Input = std::copyable<T> && Serializable<T>;
template <Input T>
void save(const T& obj, const std::string& path) {
    std::ofstream f(path);
    obj.serialize(f);
}

개념 선택 (||)

||로 연결하면 “둘 중 하나만 만족해도 된다”는 의미입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template <typename T>
concept OutputStream = requires(T& s, const char* p, size_t n) {
    s.write(p, n);
} || requires(T& s, char c) {
    s.put(c);
};

계층적 조합

기본 개념을 조합해 더 구체적인 개념을 만듭니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template <typename T>
concept Comparable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;
    { a > b } -> std::convertible_to<bool>;
};
template <typename T>
concept Key = std::totally_ordered<T> && std::copyable<T>;
template <typename M>
concept MapLike = requires(M m, typename M::key_type k, typename M::mapped_type v) {
    { m[k] } -> std::same_as<typename M::mapped_type&>;
    m.insert({k, v});
} && Key<typename M::key_type>;

조합 다이어그램

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart TB
  subgraph base[기본 개념]
    A["std copyable"]
    B[Serializable]
    C["std ranges range"]
  end
  subgraph composed[조합된 개념]
    D[Input = copyable && Serializable]
    E[SortableContainer = range && Comparable]
  end
  A --> D
  B --> D
  C --> E

6. 자주 발생하는 오류와 해결법

오류 1: “expression is not satisfied” — 표현식 검사 실패

증상: Concept 제약이 만족되지 않아 컴파일 에러가 발생합니다. 원인: requires 블록 안의 표현식이 해당 타입에서 유효하지 않습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 잘못된 예: std::swap은 ADL을 위해 using이 필요할 수 있음
template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;
    std::swap(a, b);  // 사용자 타입 T에 swap이 namespace에 있으면 실패할 수 있음
};

해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 올바른 예: swap은 ADL 고려
template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;
    requires std::swappable_with<T&, T&>;  // 표준 개념 사용
};
// 또는
template <typename T>
concept Sortable = std::totally_ordered<T> && std::swappable<T>;

오류 2: “no matching function” — 반환 타입 요구 사항 불일치

증상: { expr } -> std::same_as<X>에서 반환 타입이 정확히 X가 아닐 때 실패합니다. 원인: same_as는 타입이 완전히 동일해야 합니다. const X&X*X와 다릅니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 문제: toString()이 const std::string&를 반환하면
template <typename T>
concept StringConvertible = requires(T t) {
    { t.toString() } -> std::same_as<std::string>;  // const string& ≠ string
};

해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ convertible_to 사용: 암시적 변환 가능하면 OK
template <typename T>
concept StringConvertible = requires(T t) {
    { t.toString() } -> std::convertible_to<std::string>;
};

오류 3: requires 절에서 타입 추론 실패

증상: typename T::nested_type에서 T에 해당 중첩 타입이 없으면 제약 불만족입니다. 원인: SFINAE와 달리 Concept 검사 실패는 “제거”가 아니라 “에러”로 처리됩니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ T가 value_type을 갖지 않으면
template <typename T>
concept HasValueType = requires {
    typename T::value_type;
};
// HasValueType<int> → false (에러 아님, 단순히 불만족)

해결법: 의도한 동작입니다. if constexpr (HasValueType<T>)로 분기하거나, 제약이 맞는 오버로드만 선택되도록 설계합니다.

오류 4: requires 블록 안에서 noexcept 검사

증상: noexcept(expr)를 Concept에서 쓰고 싶을 때 문법이 다릅니다. 해결법: 아래 코드는 cpp를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept NoThrowMove = std::move_constructible<T> &&
    requires(T t) {
        { T(std::move(t)) } noexcept;
    };

오류 5: 자기 참조 Concept (순환 정의)

증상: Concept A가 B를 요구하고, B가 A를 요구하면 순환 의존이 생깁니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 위험: A → B → A
template <typename T>
concept A = B<T> && requires(T t) { t.foo(); };
template <typename T>
concept B = A<T> && requires(T t) { t.bar(); };

해결법: 개념을 계층적으로 분리하고, 순환 없이 기본 개념 → 파생 개념 순으로 정의합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 기본 개념 먼저
template <typename T>
concept HasFoo = requires(T t) { t.foo(); };
template <typename T>
concept HasBar = requires(T t) { t.bar(); };
template <typename T>
concept A = HasFoo<T> && HasBar<T>;

오류 6: const/참조 한정자 불일치

requires(const T& t)인데 serialize가 non-const면 제약 불만족. serializeconst 메서드로 정의하세요.

오류 7: ADL(Argument-Dependent Lookup)과 swap

증상: std::swap(a, b)만 요구하면, 사용자 정의 swapnamespace에 있는 타입에서 실패할 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 문제: N::MyType에 swap이 namespace N에 있음
// 패키지 선언
namespace N {
struct MyType { int x; };
void swap(MyType& a, MyType& b) { std::swap(a.x, b.x); }
}
template <typename T>
concept Sortable = requires(T a, T b) {
    a < b;
    std::swap(a, b);  // N::swap이 아닌 std::swap만 찾음 → 실패!
};

해결법: std::swappable_with<T&, T&> 표준 개념을 사용하거나, using std::swap; swap(a, b); 패턴을 requires에 표현합니다.

// ✅ 표준 개념 사용
template <typename T>
concept Sortable = std::totally_ordered<T> && std::swappable_with<T&, T&>;

오류 8: SFINAE와의 차이 — 제거 vs 에러

증상: enable_if에서는 제약 불만족 시 오버로드 후보에서 제거되지만, Concept에서는 명시적 에러가 발생합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// enable_if: 다른 오버로드가 선택됨
template <typename T>
std::enable_if_t<std::is_integral_v<T>, void> f(T) {}
template <typename T>
std::enable_if_t<!std::is_integral_v<T>, void> f(T) {}
// Concepts: 제약 불만족 시 에러
template <std::integral T>
void g(T) {}
// g(3.14);  // 에러: constraints not satisfied

해결법: “다른 타입은 다른 오버로드로” 가려면, Concept을 만족하는 오버로드와 만족하지 않는 오버로드를 모두 정의합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <std::integral T>
void process(T x) { /* 정수 처리 */ }
template <typename T>
void process(T x) requires (!std::integral<T>) { /* 그 외 처리 */ }

오류 9: requires 블록 내 변수 선언

증상: requires 블록 안에서 변수 선언은 불가합니다. 표현식만 넣을 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 선언문 불가
template <typename T>
concept C = requires(T t) { auto x = t.get(); };
// ✅ 표현식만
template <typename T>
concept C = requires(T t) { t.get(); { t.get() } -> std::convertible_to<int>; };

6-1. 모범 사례 (Best Practices)

1. 표준 개념을 최대한 활용

이미 있는 std::integral, std::ranges::range, std::copyable 등을 재사용하면, 코드가 짧고 유지보수가 쉬워집니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 불필요한 재정의
template <typename T>
concept MyIntegral = std::is_integral_v<T>;
// ✅ 표준 개념 사용
template <std::integral T>
void f(T) {}

2. Concept 이름은 도메인 용어로

“이 타입이 무엇을 할 수 있는지”를 나타내는 이름을 사용합니다. HasSerialize보다 Serializable이 더 직관적입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 구현 디테일 노출
template <typename T>
concept HasSerializeMethod = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
// ✅ 도메인 의도 표현
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};

3. 최소 요구 사항만 정의

과도한 제약은 불필요하게 타입을 제한합니다. 실제로 사용하는 연산·멤버만 요구합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 과도한 제약: size()를 안 쓰는데 요구
template <typename T>
concept Container = requires(T& c) {
    c.begin();
    c.end();
    c.size();  // 사용하지 않으면 제거
};
// ✅ 최소 요구
template <typename T>
concept Range = requires(T& c) {
    c.begin();
    c.end();
};

4. requires 절 vs template 파라미터 제약

간단한 제약은 template <Concept T> 형식이, 복잡한 제약은 requires 절이 더 읽기 쉽습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 단순: 한 줄로
template <Serializable T>
void save(const T& obj);
// ✅ 복합: requires 절이 명확
template <typename T>
void process(T&& obj) requires Serializable<std::remove_cvref_t<T>> && std::copyable<T>;

5. static_assert로 검증

static_assert(Serializable<Player>);
static_assert(!Serializable<int>);

7. 성능 비교: enable_if vs Concepts

컴파일 타임 오버헤드

Concept은 컴파일 타임에만 검사됩니다. 런타임 비용은 0입니다.

방식컴파일 시간에러 메시지가독성
제약 없음빠름템플릿 내부에서 난해낮음
std::enable_if보통SFINAE로 인해 난해낮음
Concepts비슷~약간 증가호출 지점에서 명확높음

벤치마크 (컴파일 시간)

대규모 템플릿 프로젝트에서 Concepts 사용 시:

  • GCC 13: enable_if 대비 약 5–10% 컴파일 시간 증가 (제약 검사 비용)
  • Clang 17: 비슷한 수준
  • MSVC 2022: Concepts가 더 최적화되어 enable_if보다 빠른 경우도 있음

런타임 성능

동일합니다. Concept은 타입 검사만 하며, 생성되는 기계어에는 영향을 주지 않습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 두 함수는 동일한 기계어 생성
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add_if(T a, T b) { return a + b; }
template <std::integral T>
T add_concept(T a, T b) { return a + b; }

에러 메시지 비교

아래 코드는 text를 사용한 구현 예제입니다. 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// enable_if 사용 시 (GCC)
error: no matching function for call to 'saveToFile(int, const char [6])'
note: candidate: 'void saveToFile(const T&, const std::string&) [with T = int]'
note:   template argument deduction/substitution failed:
note:     no type named 'type' in 'struct std::enable_if<false, void>'

다음은 간단한 text 코드 예제입니다. 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// Concepts 사용 시 (GCC)
error: no matching function for call to 'saveToFile(int, const char [6])'
note: constraints not satisfied
note: the concept 'Serializable<int>' evaluated to false

Concepts를 쓰면 어떤 제약이 불만족인지 바로 확인할 수 있습니다.

8. 프로덕션 패턴

패턴 1: Concept 헤더 분리

프로젝트 전역에서 쓸 Concept은 별도 헤더에 모아 두고, 필요한 곳에서 include합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// concepts.hpp
#pragma once
#include <concepts>
#include <ranges>
namespace my_project {
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};
template <typename T>
concept SortableRange = std::ranges::range<T> &&
    requires(std::ranges::range_value_t<T> a, std::ranges::range_value_t<T> b) {
        a < b;
        std::swap(a, b);
    };
}  // namespace my_project

패턴 2: 점진적 도입 (레거시와 공존)

기존 enable_if 코드를 한 번에 바꾸지 않고, 새 코드부터 Concepts를 적용합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 기존: enable_if (유지)
template <typename T>
std::enable_if_t<std::is_integral_v<T>, void> legacyProcess(T x);
// 신규: Concepts
template <std::integral T>
void newProcess(T x);

패턴 3: 테스트용 Concept

단위 테스트에서 “이 타입이 특정 인터페이스를 만족하는지” 검증할 때 Concept을 활용합니다. 다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 테스트에서
static_assert(Serializable<Player>);
static_assert(Serializable<Enemy>);
static_assert(!Serializable<int>);

패턴 4: 문서화용 Concept

실제 제약보다는 “이 템플릿이 기대하는 타입”을 문서화하는 용도로 쓸 수 있습니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

/// @brief 이 함수는 T가 다음을 지원할 때 사용 가능합니다:
///        - serialize(std::ostream&) const
///        - copy 생성 가능
template <Serializable T>
void exportToStream(const T& obj, std::ostream& os);

패턴 5: 조건부 컴파일

if constexpr와 함께 사용해, Concept 만족 여부에 따라 다른 구현을 선택합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template <typename T>
void process(T&& obj) {
    if constexpr (Serializable<std::remove_cvref_t<T>>) {
        std::ostringstream oss;
        obj.serialize(oss);
        send(oss.str());
    } else {
        send(toString(obj));  // fallback
    }
}

패턴 6: Concept 기반 오버로드 분기

타입에 따라 다른 구현을 선택합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template <std::ranges::random_access_range R>
void sortRange(R& r) requires std::sortable<std::ranges::iterator_t<R>> {
    std::ranges::sort(r);
}
template <std::ranges::range R>
void sortRange(R& r) requires (!std::ranges::random_access_range<R>) {
    std::vector<std::ranges::range_value_t<R>> vec(r.begin(), r.end());
    std::ranges::sort(vec);
}

패턴 7: CRTP + Concept 하이브리드

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template <typename T>
concept Drawable = requires(const T& t) {
    t.draw();
    { t.getBounds() } -> std::convertible_to<std::pair<int, int>>;
};
template <typename Derived>
requires Drawable<Derived>
class DrawableBase {
public:
    void render() const { static_cast<const Derived*>(this)->draw(); }
};
struct Circle : DrawableBase<Circle> {
    void draw() const {}
    std::pair<int, int> getBounds() const { return {0, 0}; }
};

패턴 8: 에러 메시지 개선용 Concept

복잡한 enable_if 조건을 Concept으로 추출하면, 제약 불만족 시 “어떤 Concept이 실패했는지” 명확히 표시됩니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ enable_if: "enable_if<false>"만 보임
template <typename T>
std::enable_if_t<std::is_integral_v<T> && sizeof(T) >= 4, T> process(T x) { return x * 2; }
// ✅ Concept: "IntegralAtLeast32Bit" 실패 등 구체적 메시지
template <typename T>
concept IntegralAtLeast32Bit = std::integral<T> && sizeof(T) >= 4;
template <IntegralAtLeast32Bit T> T process(T x) { return x * 2; }

프로덕션 체크리스트

  • 프로젝트 공통 Concept을 concepts.hpp에 모아 두기
  • 새 템플릿 API에는 Concepts 적용
  • static_assert로 주요 타입 검증
  • 에러 메시지가 호출 지점에서 명확한지 확인
  • 순환 Concept 정의 없도록 설계
  • ADL 고려 (swap 등) — 표준 개념 우선 사용

9. 실전 예제

직렬화 가능

Serializable은 “const T&std::ostream&에 대해 t.serialize(os)가 호출 가능하고, 반환 타입이 void”인 타입만 허용합니다. 파일·네트워크로 객체를 저장할 때 “serialize 메서드가 있는 타입만 받겠다”를 Concept으로 두면, 템플릿 함수 안에서 t.serialize(os)를 안전하게 호출할 수 있고, 만족하지 않는 타입이 넘어오면 컴파일 에러로 바로 잡을 수 있습니다. 다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
    { t.serialize(os) } -> std::same_as<void>;
};

콜백(함수 객체) 제약

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename F, typename....Args>
concept InvocableWith = requires(F f, Args&&....args) {
    { f(std::forward<Args>(args)...) };
};

스마트 포인터류

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

template <typename T>
concept SmartPointer = requires(T p) {
    *p;
    p.operator->();
    p.get();
} && !std::same_as<T, std::remove_cvref_t<T>*>;

실제로는 std::pointer_like 같은 표준 제안과 조합해 쓰는 경우가 많습니다. 레거시 코드에서는 std::enable_ifpointer_traits로 “포인터처럼 쓸 수 있는 타입”만 받는 식의 제약을 걸었으나, Concepts로 쓰면 의도와 에러 메시지가 더 명확해집니다.

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++ 커스텀 concept, concept 정의, requires 절, 타입 제약 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
선언template <...> concept Name = requires (...) { ....};
요구표현식 유효성, { expr } -> Concept, typename T::type
조합&&, || 로 여러 개념 결합
재사용다른 concept 안에서 사용 가능
성능런타임 오버헤드 없음, 컴파일 타임에만 검사
에러호출 지점에서 “constraints not satisfied”로 명확히 표시

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++20에서 자신만의 Concept을 정의하는 방법, requires 표현식, 복합 요구 사항, 그리고 실전에서 재사용 가능한 개념을 만드는 법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

Q. Concepts와 enable_if 중 뭘 써야 하나요?

A. 새 코드에는 Concepts를 권장합니다. 에러 메시지가 명확하고 가독성이 좋습니다. 레거시 코드는 점진적으로 마이그레이션할 수 있습니다.

Q. Concept 검사에 런타임 비용이 있나요?

A. 없습니다. 모든 검사는 컴파일 타임에 이루어지며, 생성된 기계어에는 영향을 주지 않습니다.

관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3