C++ 태그 디스패칭: iterator_category로 오버로드 고르기와 if constexpr·Concepts 비교

이 글의 핵심

enable_if로 조건을 템플릿 인자에 숨기면 컴파일 에러 메시지가 몇 페이지로 불어나지만, 태그 디스패칭은 분기 조건이 함수 시그니처에 그대로 드러납니다. 이 글은 random_access_iterator_tag 같은 표준 태그로 알고리즘을 최적화하는 방법과 태그 타입 누락, 잘못된 태그 선택 같은 실수를 예제로 보여줍니다.

Tag Dispatching이란?

태그 디스패칭(Tag Dispatching)은 값이 아니라 타입으로 함수 오버로드를 선택하게 만드는 기법입니다. 조건문이 아니라 오버로드 해결(overload resolution) 규칙 자체를 컴파일 타임 분기 도구로 사용한다는 점이 핵심입니다. 실제로 하는 일은 단순합니다. 데이터를 담지 않는 빈 구조체(태그 타입)를 만들고, 그 태그를 함수의 마지막 인자로 얹어서 컴파일러가 인자 타입만 보고 올바른 오버로드를 고르게 하는 것입니다.

이 패턴이 SFINAE(enable_if 기반 조건부 활성화)보다 읽기 쉬운 이유는 분기 조건이 템플릿 인자 목록 안에 숨어 있지 않고, 함수 시그니처 자체로 드러나기 때문입니다. enable_if_t<is_integral_v<T>>처럼 반환 타입이나 템플릿 파라미터에 조건식을 끼워 넣는 방식은 컴파일이 실패했을 때 에러 메시지가 몇 페이지에 걸쳐 쏟아지는 경우가 많습니다. 반면 태그 디스패칭은 “이 오버로드는 IntTag를 받는다, 저 오버로드는 FloatTag를 받는다”는 사실이 함수 선언만 봐도 명확하므로, 코드를 처음 보는 사람도 어떤 조건에서 어떤 구현이 실행되는지 바로 추적할 수 있습니다. 아래는 가장 단순한 형태의 예시입니다.

// 태그 타입
struct IntTag {};
struct FloatTag {};

// 태그 디스패치
void processImpl(int value, IntTag) {
    cout << "정수: " << value << endl;
}

void processImpl(double value, FloatTag) {
    cout << "실수: " << value << endl;
}

// 통합 인터페이스
template<typename T>
void process(T value) {
    if constexpr (is_integral_v<T>) {
        processImpl(value, IntTag{});
    } else {
        processImpl(value, FloatTag{});
    }
}

int main() {
    process(10);    // 정수
    process(3.14);  // 실수
}

여기서 눈여겨볼 부분은 태그 타입이 완전히 비어 있다는 점입니다. IntTag, FloatTag는 멤버 변수도, 멤버 함수도 없는 빈 구조체(empty struct)입니다. 컴파일러 입장에서 빈 구조체 값을 함수 인자로 전달하는 동작은 아무런 실질적인 데이터 이동을 수반하지 않습니다. 최적화가 켜진 빌드에서는 태그 인자 전달 자체가 통째로 사라지고, 오버로드 선택이라는 컴파일 타임 결정만 바이너리에 남습니다. 그래서 “태그 디스패칭은 런타임 비용이 없다”는 말이 단순한 구호가 아니라, 빈 타입에는 저장할 상태가 없으므로 애초에 옮길 것이 없다는 사실에서 나오는 결론입니다. C++17 이후로는 이 예제처럼 최상위 분기를 if constexpr으로 처리하고, 그 아래에서 실제 오버로드를 태그로 나누는 혼합 방식도 흔히 씁니다.

Iterator Tag Dispatching

표준 라이브러리에서 태그 디스패칭이 가장 널리 쓰이는 자리는 반복자 카테고리(iterator category)입니다. <iterator> 헤더는 input_iterator_tag, forward_iterator_tag, bidirectional_iterator_tag, random_access_iterator_tag를 정의하는데, 이 타입들은 서로 무관한 별개의 태그가 아니라 상속 계층을 이룹니다. forward_iterator_tag는 input_iterator_tag를 공개 상속하고, bidirectional_iterator_tag는 forward_iterator_tag를, random_access_iterator_tag는 bidirectional_iterator_tag를 상속합니다. 즉 랜덤 접근 반복자의 태그 객체는 동시에 입력 반복자 태그이기도 합니다.

#include <iterator>

// 구현 함수들
template<typename Iter>
void advanceImpl(Iter& it, int n, input_iterator_tag) {
    // 입력 반복자: 한 칸씩만
    while (n--) ++it;
}

template<typename Iter>
void advanceImpl(Iter& it, int n, random_access_iterator_tag) {
    // 랜덤 접근 반복자: 한 번에
    it += n;
}

// 통합 인터페이스
template<typename Iter>
void advance(Iter& it, int n) {
    advanceImpl(it, n, typename iterator_traits<Iter>::iterator_category{});
}

int main() {
    vector<int> v = {1, 2, 3, 4, 5};
    auto it = v.begin();
    advance(it, 3);  // 랜덤 접근 (빠름)

    list<int> l = {1, 2, 3, 4, 5};
    auto it2 = l.begin();
    advance(it2, 3);  // 입력 반복자 (느림)
}

이 상속 구조 덕분에 모든 카테고리마다 오버로드를 만들 필요가 없습니다. 오버로드 해석은 인자를 가장 적게 변환하는 후보를 고르므로, random_access_iterator_tag를 넘기면 정확히 일치하는 랜덤 접근 버전이 선택되고, forward_iterator_tag나 bidirectional_iterator_tag를 넘기면 그 조상인 input_iterator_tag 버전으로 내려갑니다. 즉 가장 약한 카테고리용 구현 하나가 폴백 역할을 하고, 더 빠르게 처리할 수 있는 카테고리만 추가로 오버로드하면 됩니다. 태그가 단일 상속 사슬이라 같은 거리의 후보가 둘 생기지 않으므로 모호성 에러도 나지 않습니다.

반대로 폴백이 없으면 문제가 드러납니다. bidirectional_iterator_tag와 random_access_iterator_tag 버전만 만들어 두고 forward_list의 반복자(forward_iterator_tag)를 넘기면, forward_iterator_tag는 두 태그 어느 쪽으로도 변환되지 않기 때문에(변환은 파생 → 기반 방향만 됨) “no matching function” 에러가 납니다. 에러 메시지에 템플릿 인자가 길게 펼쳐져서 원인을 찾기 어려울 뿐, 조용히 틀린 버전이 선택되지는 않습니다.

실무에서 더 조용하게 사람을 괴롭히는 쪽은 카테고리를 잘못 선언한 반복자입니다. 직접 만든 반복자 어댑터에 iterator_category = random_access_iterator_tag라고 적어 두고 operator+=를 ++를 n번 반복하는 식으로 구현하면, 태그 디스패칭은 선언을 그대로 믿고 “O(1) 점프”를 가정한 경로를 고릅니다. 알고리즘은 맞게 돌지만 복잡도가 몰래 O(n)이 되어, 큰 입력에서만 느려지는 식으로 나타납니다. 반대 방향도 있습니다. C++20 ranges의 일부 뷰 반복자(예: 참조가 아닌 값을 반환하는 views::transform)는 새 방식의 iterator_concept로는 random access지만, 옛 방식의 iterator_category는 input_iterator_tag로 보고합니다. iterator_category로 디스패치하는 C++17 스타일 코드에 이런 반복자를 넘기면 가장 느린 경로가 선택되므로, C++20 코드베이스라면 태그 대신 std::random_access_iterator<It> 같은 반복자 concept으로 분기하는 편이 정확합니다.

실전 예시

예시 1: 거리 계산

std::distance의 내부 동작도 같은 원리로 만들어져 있습니다. 입력 반복자는 순차 접근만 가능하므로 시작점부터 끝점까지 한 칸씩 세어야 하지만, 랜덤 접근 반복자는 포인터 산술처럼 뺄셈 한 번으로 거리를 구할 수 있습니다. 두 구현의 시간 복잡도가 O(n)과 O(1)로 완전히 다르기 때문에, 이 차이를 함수 이름 하나 뒤에 숨기려면 태그 디스패칭 같은 컴파일 타임 선택 메커니즘이 필요합니다.

// 입력 반복자
template<typename Iter>
typename iterator_traits<Iter>::difference_type
distanceImpl(Iter first, Iter last, input_iterator_tag) {
    typename iterator_traits<Iter>::difference_type n = 0;
    while (first != last) {
        ++first;
        ++n;
    }
    return n;
}

// 랜덤 접근 반복자
template<typename Iter>
typename iterator_traits<Iter>::difference_type
distanceImpl(Iter first, Iter last, random_access_iterator_tag) {
    return last - first;  // O(1)
}

template<typename Iter>
auto distance(Iter first, Iter last) {
    return distanceImpl(first, last,
        typename iterator_traits<Iter>::iterator_category{});
}

int main() {
    vector<int> v = {1, 2, 3, 4, 5};
    cout << distance(v.begin(), v.end()) << endl;  // 5 (빠름)

    list<int> l = {1, 2, 3, 4, 5};
    cout << distance(l.begin(), l.end()) << endl;  // 5 (느림)
}

여기서 핵심은 distance를 호출하는 쪽은 자신이 넘긴 반복자가 어떤 카테고리인지 전혀 몰라도 된다는 점입니다. 호출자가 알아야 할 것은 “반복자 두 개를 넘기면 거리가 나온다”는 인터페이스뿐이고, 내부적으로 어떤 알고리즘이 실행될지는 iterator_traits<Iter>::iterator_category가 결정합니다. 이런 식으로 인터페이스와 구현을 분리해 두면, 나중에 새로운 반복자 카테고리(예: C++20의 contiguous_iterator_tag)가 추가되더라도 기존 distance 시그니처를 건드리지 않고 오버로드 하나만 추가하면 됩니다.

예시 2: 타입별 직렬화

struct PrimitiveTag {};
struct ContainerTag {};
struct CustomTag {};

// 타입 특성
template<typename T>
struct SerializeTraits {
    using tag = CustomTag;
};

template<> struct SerializeTraits<int> { using tag = PrimitiveTag; };
template<> struct SerializeTraits<double> { using tag = PrimitiveTag; };
template<typename T> struct SerializeTraits<vector<T>> { using tag = ContainerTag; };

// 구현
template<typename T>
string serializeImpl(const T& value, PrimitiveTag) {
    return to_string(value);
}

template<typename T>
string serializeImpl(const T& container, ContainerTag) {
    string result = "[";
    for (const auto& item : container) {
        result += serialize(item) + ",";
    }
    result += "]";
    return result;
}

template<typename T>
string serializeImpl(const T& value, CustomTag) {
    return value.toString();  // 커스텀 메서드 호출
}

// 통합 인터페이스
template<typename T>
string serialize(const T& value) {
    return serializeImpl(value, typename SerializeTraits<T>::tag{});
}

int main() {
    cout << serialize(42) << endl;
    cout << serialize(vector<int>{1, 2, 3}) << endl;
}

이 예제는 이터레이터 카테고리처럼 표준에 이미 정의된 태그가 아니라, 직접 만든 트레이트 클래스로 태그를 생성하는 패턴을 보여줍니다. SerializeTraits<T>를 특수화해서 타입별로 다른 tag를 대응시키는 방식은 라이브러리 설계에서 매우 흔한데, 사용자가 자신의 커스텀 타입을 직렬화 시스템에 등록하고 싶을 때 SerializeTraits<MyType>을 특수화하기만 하면 되고 serialize 본체는 전혀 수정할 필요가 없다는 확장성이 장점입니다. 다만 기본 템플릿이 CustomTag로 폴백되기 때문에, toString() 멤버 함수가 없는 타입을 실수로 넘기면 오버로드 해결 자체는 성공하지만 serializeImpl 본문 안에서 멤버 함수를 찾지 못해 에러가 나는데, 이 에러는 호출부가 아니라 템플릿 정의부 안쪽에서 발생하므로 초심자에게는 원인 파악이 까다로운 지점입니다.

예시 3: 복사 최적화

struct TrivialTag {};
struct NonTrivialTag {};

template<typename T>
using CopyTag = conditional_t<
    is_trivially_copyable_v<T>,
    TrivialTag,
    NonTrivialTag
>;

// Trivial 타입 (memcpy)
template<typename T>
void copyImpl(T* dest, const T* src, size_t n, TrivialTag) {
    memcpy(dest, src, n * sizeof(T));
}

// Non-trivial 타입 (생성자)
template<typename T>
void copyImpl(T* dest, const T* src, size_t n, NonTrivialTag) {
    for (size_t i = 0; i < n; i++) {
        new (&dest[i]) T(src[i]);
    }
}

template<typename T>
void copy(T* dest, const T* src, size_t n) {
    copyImpl(dest, src, n, CopyTag<T>{});
}

int main() {
    int arr1[5] = {1, 2, 3, 4, 5};
    int arr2[5];
    copy(arr1, arr2, 5);  // memcpy (빠름)

    string str1[3] = {"a", "b", "c"};
    string str2[3];
    copy(str1, str2, 3);  // 생성자 (안전)
}

std::copy, std::vector의 내부 구현도 실제로 이와 비슷한 최적화를 합니다. is_trivially_copyable_v<T>가 참인 타입(단순 대입만으로 복사가 완전히 성립하는 타입)은 memcpy로 통째로 복사해도 안전하지만, 생성자·소멸자에 부수효과가 있는 타입은 반드시 배치 new(placement new)로 각 원소의 생성자를 호출해 줘야 합니다. 이 조건을 런타임 if로 검사하면 컴파일러가 두 분기 모두를 항상 컴파일해야 하고, memcpy 분기는 non-trivial 타입에 대해서도 문법적으로는 유효하므로 실수로 잘못된 분기가 실행될 위험이 남습니다. 반면 태그 디스패칭은 애초에 타입에 맞지 않는 분기 자체가 인스턴스화되지 않으므로 이런 실수를 컴파일 타임에 원천 차단합니다.

예시 4: 알고리즘 최적화

struct SmallSizeTag {};
struct LargeSizeTag {};

template<size_t N>
using SizeTag = conditional_t<(N < 10), SmallSizeTag, LargeSizeTag>;

// 작은 배열: 버블 정렬
template<typename T, size_t N>
void sortImpl(T (&arr)[N], SmallSizeTag) {
    for (size_t i = 0; i < N; i++) {
        for (size_t j = i + 1; j < N; j++) {
            if (arr[j] < arr[i]) {
                swap(arr[i], arr[j]);
            }
        }
    }
}

// 큰 배열: 퀵 정렬
template<typename T, size_t N>
void sortImpl(T (&arr)[N], LargeSizeTag) {
    sort(begin(arr), end(arr));
}

template<typename T, size_t N>
void sort(T (&arr)[N]) {
    sortImpl(arr, SizeTag<N>{});
}

int main() {
    int small[5] = {5, 2, 8, 1, 9};
    sort(small);  // 버블 정렬

    int large[100];
    sort(large);  // 퀵 정렬
}

이 예제는 다소 인위적이지만 요점은 분명합니다. 배열 크기가 컴파일 타임에 알려져 있다면(N이 템플릿 비타입 파라미터), 크기에 따른 알고리즘 선택도 런타임 분기 없이 태그로 처리할 수 있다는 것입니다. 다만 실무에서는 “작은 배열엔 버블 정렬이 빠르다”는 가정을 코드로 굳히기 전에 실제 벤치마크로 임계값을 검증해야 합니다. 캐시 친화성, CPU 분기 예측기 상태에 따라 손익분기점이 10이 아니라 20~30일 수도 있고, 애초에 표준 라이브러리의 std::sort가 이미 삽입 정렬로 폴백하는 하이브리드 구현(인트로소트)이므로 직접 재구현할 실익이 크지 않은 경우도 많습니다.

if constexpr vs Tag Dispatching

// if constexpr (C++17)
template<typename T>
void process(T value) {
    if constexpr (is_integral_v<T>) {
        // 정수 처리
    } else {
        // 실수 처리
    }
}

// Tag Dispatching (C++11)
template<typename T>
void process(T value) {
    processImpl(value, TypeTag<T>{});
}

C++17 이후로 많은 사람들이 “이제 태그 디스패칭은 쓸모없어진 것 아니냐”고 묻습니다. 실제로 단일 함수 안에서 분기하는 정도라면 if constexpr이 압도적으로 간결하고, 저 역시 새 코드에서는 기본적으로 if constexpr을 먼저 씁니다. 그런데도 태그 디스패칭이 여전히 필요한 경우가 분명히 있습니다.

첫째, 각 분기가 완전히 다른 함수 시그니처나 별도의 템플릿 특수화를 요구할 때입니다. if constexpr은 하나의 함수 템플릿 본문 안에서 컴파일되지 않는 분기를 걸러내는 도구이지, 오버로드 자체를 나누는 도구가 아닙니다. 분기마다 반환 타입이 다르거나, 분기마다 별도의 파일에서 구현을 두고 헤더 의존성을 분리하고 싶거나, ADL(인자 종속 탐색)을 이용해 사용자가 자신의 네임스페이스에서 오버로드를 추가하도록 열어 두고 싶다면, 애초에 별개의 함수(오버로드)로 쪼개야 하고 그 선택 메커니즘이 곧 태그 디스패칭입니다.

둘째는 확장성입니다. if constexpr 사슬은 닫혀 있어서, 새 경우를 추가하려면 그 함수 본문을 직접 고쳐야 합니다. 반면 태그 디스패칭은 구현이 평범한 오버로드라, 라이브러리 사용자가 자기 태그 타입과 그 태그를 받는 process_impl 오버로드를 자기 네임스페이스에 추가하면 ADL로 찾아집니다. 표준 반복자 카테고리가 태그로 설계된 것도, 새 카테고리(C++20의 contiguous_iterator_tag)를 기존 계층 아래에 끼워 넣어도 옛 코드가 가장 가까운 조상 버전으로 계속 동작하게 하려는 이유가 큽니다.

선택 기준:

  • C++17 이상, 같은 함수 본문 안에서의 단순 분기: if constexpr (간결)
  • C++11/14 환경이거나 컴파일러가 구형: Tag Dispatching
  • 분기마다 시그니처·헤더·ADL이 달라져야 하는 경우: Tag Dispatching
  • 복잡한 다중 분기(3개 이상, 계층적 조건): Tag Dispatching (가독성)

SFINAE·Concepts와 비교

태그 디스패칭이 푸는 문제(“타입 성질에 따라 다른 구현을 고른다”)는 enable_if(SFINAE)나 C++20 concepts로도 풀 수 있습니다.

// SFINAE: 조건마다 오버로드를 켜고 끔
template<typename T>
std::enable_if_t<std::is_integral_v<T>> process(T v) { /* 정수 */ }
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>> process(T v) { /* 실수 */ }

// 태그 디스패칭: 조건은 한 곳에서, 구현은 평범한 오버로드로
struct integral_tag {}; struct floating_tag {};
template<typename T> void process_impl(T v, integral_tag) { /* 정수 */ }
template<typename T> void process_impl(T v, floating_tag) { /* 실수 */ }
template<typename T>
void process(T v) {
    process_impl(v, std::conditional_t<std::is_integral_v<T>, integral_tag, floating_tag>{});
}

// C++20 concepts: 제약으로 오버로드를 고름
template<std::integral T>       void process(T v) { /* 정수 */ }
template<std::floating_point T> void process(T v) { /* 실수 */ }

SFINAE 방식은 조건이 서로 겹치지 않도록 모든 오버로드의 조건을 직접 관리해야 합니다. 조건이 세 개, 네 개로 늘어나면 “A이고 B가 아닌 경우” 같은 조합을 손으로 써야 하고, 하나라도 겹치면 모호성 에러가 납니다. 태그 디스패칭은 조건 판정을 인터페이스 함수 한 곳에 모으고, 겹침 문제는 태그 상속의 “가장 가까운 기반” 규칙에 맡기므로 이 부분이 훨씬 다루기 쉽습니다.

C++20 concepts는 이 겹침 문제를 포함 관계(subsumption) 로 해결합니다. std::random_access_iterator는 std::bidirectional_iterator를 포함하므로, 두 제약의 오버로드가 모두 맞으면 더 구체적인 쪽이 선택됩니다. 태그 상속이 하던 일을 언어가 직접 해 주는 셈이라, C++20을 쓸 수 있고 조건이 concept으로 표현된다면 새 코드는 concepts가 가장 읽기 쉽습니다. 태그 디스패칭은 C++17 이하를 지원해야 하는 라이브러리, 조건이 iterator_category처럼 이미 태그 타입으로 주어지는 경우, 사용자가 자기 태그 타입을 정의해 확장할 수 있게 열어 두고 싶은 경우에 여전히 쓸모가 있습니다. 세 방식 모두 컴파일 타임 선택이라 런타임 비용 차이는 없습니다.

자주 발생하는 문제

문제 1: 태그 타입 누락

advance처럼 반복자 카테고리에 따라 성능이 완전히 갈리는 함수에서 태그 디스패칭을 생략하면, 컴파일은 되지만 런타임에 문제가 됩니다. 아래 “태그 없음” 버전은 it += n 연산자가 정의되지 않은 반복자(예: list의 반복자)에서는 애초에 컴파일 자체가 실패하고, 만약 모든 반복자에 억지로 +=를 흉내 낸 연산자를 정의해 둔다면 이번엔 반대로 입력 반복자에서 순차 접근 O(n) 비용을 감추지 못한 채 잘못된 복잡도 가정을 하게 만듭니다. 즉 이 문제는 단순한 컴파일 에러보다, “어떤 반복자를 넘겨도 같은 함수 이름으로 호출된다”는 착각이 성능 회귀로 이어지는 조용한 함정에 가깝습니다.

// ❌ 태그 없음
template<typename Iter>
void advance(Iter& it, int n) {
    it += n;  // 모든 반복자에서 작동 안함
}

// ✅ 태그 디스패치
template<typename Iter>
void advance(Iter& it, int n) {
    advanceImpl(it, n, typename iterator_traits<Iter>::iterator_category{});
}

문제 2: 잘못된 태그 선택

두 번째 함정은 태그 자체는 잘 만들었지만, 태그를 결정하는 조건식이 실제 의도와 어긋나는 경우입니다. sizeof(T) == 4는 int, float처럼 크기가 4바이트인 타입을 걸러내려는 의도로 보이지만, 사용자 정의 타입 중에도 우연히 4바이트인 non-trivial 타입(예: 가상 함수 포인터 하나만 가진 클래스, 혹은 4바이트 정렬의 커스텀 핸들 타입)이 존재할 수 있습니다. 이런 타입에 memcpy 기반 최적화를 적용하면 생성자를 건너뛰어 정의되지 않은 동작(undefined behavior)으로 이어집니다. 태그를 결정하는 조건은 항상 “무엇을 최적화하려는가”라는 의미론적 속성(is_trivially_copyable_v 같은 표준 타입 특성)을 직접 검사해야지, 크기나 정렬처럼 우연히 비슷해 보이는 대리 지표(proxy)를 조건으로 쓰면 안 됩니다.

// ❌ 잘못된 조건
template<typename T>
using Tag = conditional_t<sizeof(T) == 4, SmallTag, LargeTag>;

// ✅ 의미 있는 조건
template<typename T>
using Tag = conditional_t<is_trivially_copyable_v<T>, TrivialTag, NonTrivialTag>;

FAQ

Q1: Tag Dispatching은 언제 사용하나요?

A:

  • 타입에 따른 최적화
  • STL 알고리즘 구현
  • 컴파일 타임 분기

Q2: if constexpr vs Tag Dispatching?

A: C++17 이상이면 if constexpr이 더 간결합니다. 하지만 복잡한 경우 Tag Dispatching이 더 명확할 수 있습니다.

Q3: 성능 차이는?

A: 둘 다 컴파일 타임에 처리되므로 런타임 차이는 없습니다.

Q4: 태그 타입은 어떻게 정의하나요?

A: 빈 struct로 정의합니다. 데이터가 필요 없습니다.

Q5: STL에서 사용하나요?

A: 네, iterator_category가 대표적인 예입니다.

Q6: Tag Dispatching 학습 리소스는?

A:

  • “Effective STL” (Scott Meyers)
  • cppreference.com
  • STL 소스 코드

같이 보면 좋은 글