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

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 표현식 문법을 쓸 수 있습니다.
  • 연산·타입·반환 타입 요구 사항을 정의할 수 있습니다.
  • 기존 표준 개념과 조합해 실전에 적용할 수 있습니다.

목차

  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 의도가 분명해집니다.

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와 함께 쓰거나, 이렇게 단순히 타입 존재만 요구할 때 사용할 수 있습니다.

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 등)도 허용할 수 있습니다. 콜백·함수 객체가 “정수만 반환한다”를 컴파일 타임에 검사할 때 유용합니다.

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가 있는 타입”만 허용할 수 있습니다.

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이어도 만족합니다. 로깅·직렬화에서 “문자열로 바꿀 수 있는 타입”만 받고 싶을 때 쓰기 좋습니다.

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 스타일 할당자나 커스텀 메모리 풀을 템플릿 인자로 받을 때 이런 제약을 두면, 잘못된 타입이 넘어오는 것을 컴파일 단계에서 막을 수 있습니다.

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는 정수형·부동소수점 모두 받을 수 있습니다. &&로 묶으면 “모두 만족해야 한다”입니다. 표준 개념을 이렇게 조합해 “숫자 타입”, “반복 가능한 컨테이너” 같은 도메인 개념을 짧게 정의할 수 있습니다.

template <typename T>
concept Number = std::integral<T> || std::floating_point<T>;

template <Number T>
T add(T a, T b) {
    return a + b;
}

기존 개념 기반

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)를 안전하게 호출할 수 있습니다.

#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==가 있어야 합니다.

#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 — 특정 인자로 호출 가능

콜백이나 함수 객체가 주어진 인자 타입으로 호출 가능한지 검사합니다.

#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가 아닌 타입을 요구합니다.

#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()로 크기를 구할 수 있는 타입입니다.

#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 메서드를 가진 타입을 요구합니다.

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반환 타입을 동시에 검사할 수 있습니다.

#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

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입니다.

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을 만족해야 할 때 사용합니다.

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을 &&로 연결하면 “모두 만족해야 한다”는 의미입니다.

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);
}

개념 선택 (||)

||로 연결하면 “둘 중 하나만 만족해도 된다”는 의미입니다.

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);
};

계층적 조합

기본 개념을 조합해 더 구체적인 개념을 만듭니다.

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>;

조합 다이어그램

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 블록 안의 표현식이 해당 타입에서 유효하지 않습니다.

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

해결법:

// ✅ 올바른 예: 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와 다릅니다.

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

해결법:

// ✅ 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 검사 실패는 “제거”가 아니라 “에러”로 처리됩니다.

// ❌ 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에서 쓰고 싶을 때 문법이 다릅니다.

해결법:

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

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

증상: Concept A가 B를 요구하고, B가 A를 요구하면 순환 의존이 생깁니다.

// ❌ 위험: 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(); };

해결법: 개념을 계층적으로 분리하고, 순환 없이 기본 개념 → 파생 개념 순으로 정의합니다.

// ✅ 기본 개념 먼저
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에 있는 타입에서 실패할 수 있습니다.

// ❌ 문제: 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에서는 명시적 에러가 발생합니다.

// 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을 만족하는 오버로드와 만족하지 않는 오버로드를 모두 정의합니다.

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

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

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

증상: requires 블록 안에서 변수 선언은 불가합니다. 표현식만 넣을 수 있습니다.

// ❌ 선언문 불가
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 등을 재사용하면, 코드가 짧고 유지보수가 쉬워집니다.

// ❌ 불필요한 재정의
template <typename T>
concept MyIntegral = std::is_integral_v<T>;

// ✅ 표준 개념 사용
template <std::integral T>
void f(T) {}

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

“이 타입이 무엇을 할 수 있는지”를 나타내는 이름을 사용합니다. HasSerialize보다 Serializable이 더 직관적입니다.

// ❌ 구현 디테일 노출
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. 최소 요구 사항만 정의

과도한 제약은 불필요하게 타입을 제한합니다. 실제로 사용하는 연산·멤버만 요구합니다.

// ❌ 과도한 제약: 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 절이 더 읽기 쉽습니다.

// ✅ 단순: 한 줄로
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은 타입 검사만 하며, 생성되는 기계어에는 영향을 주지 않습니다.

// 두 함수는 동일한 기계어 생성
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; }

에러 메시지 비교

// 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>'
// 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합니다.

// 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를 적용합니다.

// 기존: 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을 활용합니다.

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

패턴 4: 문서화용 Concept

실제 제약보다는 “이 템플릿이 기대하는 타입”을 문서화하는 용도로 쓸 수 있습니다.

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

패턴 5: 조건부 컴파일

if constexpr와 함께 사용해, Concept 만족 여부에 따라 다른 구현을 선택합니다.

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 기반 오버로드 분기

타입에 따라 다른 구현을 선택합니다.

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 하이브리드

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이 실패했는지” 명확히 표시됩니다.

// ❌ 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)를 안전하게 호출할 수 있고, 만족하지 않는 타입이 넘어오면 컴파일 에러로 바로 잡을 수 있습니다.

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

콜백(함수 객체) 제약

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

스마트 포인터류

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++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
  • C++ Concepts와 Constraints | “타입 제약” 가이드
  • C++20 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. 없습니다. 모든 검사는 컴파일 타임에 이루어지며, 생성된 기계어에는 영향을 주지 않습니다.

관련 글

  • C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
  • C++20 Coroutine | co_await·co_yield로
  • C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
  • C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
  • C++ Concepts와 Constraints |
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3