C++20 ranges의 개념 계층: range 요건, input·forward·random_access_range, CPO 설계

이 글의 핵심

커스텀 컨테이너를 views나 ranges 알고리즘에 넘겼더니 concept을 만족하지 않는다는 긴 컴파일 에러가 나는 경우, 원인은 대개 반복자가 계층별 요건 하나를 놓친 데 있습니다. 한 번만 순회할 수 있는 입력과 여러 번 순회할 수 있는 입력을 구분해야 하는 이유, sized_range가 따로 분리된 이유를 짚고, static_assert로 자기 타입이 어떤 concept을 만족하는지 미리 확인하는 방법까지 정리했습니다.

Ranges란?

C++20 이전까지 반복(iteration)을 표현하는 표준 방법은 반복자 쌍이었습니다. std::sort(v.begin(), v.end())처럼 시작과 끝을 따로 넘기는 방식은 유연하지만, 실수하기도 쉬웠습니다 — 서로 다른 컨테이너의 begin()과 다른 컨테이너의 end()를 실수로 섞어 넘겨도 컴파일은 되고, 런타임에 미정의 동작으로 이어지는 경우가 드물지 않았습니다. std::ranges(범위 라이브러리)는 “시작-끝 반복자 쌍”이라는 개념을 “범위(range)“라는 하나의 값으로 승격시켜, std::ranges::sort(v)처럼 컨테이너 하나만 넘기면 되도록 만들었습니다. 여기에 더해 std::views(뷰) 네임스페이스는 필터링·변환 같은 가공을 | 파이프 연산자로 이어붙일 수 있게 해서, 중간 컨테이너를 만들지 않고도 데이터 흐름을 선언적으로 표현할 수 있게 해줍니다.

#include <ranges>
#include <vector>

std::vector<int> v = {1, 2, 3, 4, 5};

// 파이프라인: 짝수만 걸러서 2배로
auto result = v
    | std::views::filter([](int x) { return x % 2 == 0; })
    | std::views::transform([](int x) { return x * 2; });

여기서 중요한 것은 문법이 아니라 v가 왜 이 파이프라인의 입력이 될 수 있는가입니다. std::views::filter는 “range를 받아서 range를 돌려주는” 함수인데, 컴파일러가 v를 range로 인정해주는 근거가 바로 이 글의 핵심 주제인 range concept입니다. views의 파이프라인 패턴 자체(지연 평가, 어댑터 조합, 댕글링 위험)는 C++20 std::views: filter·transform·take 지연 평가와 ranges::to로 구체화하기와 C++20 range adaptor: views::filter·transform 파이프라인의 지연 평가와 댕글링 함정에서 깊이 다루므로, 이 글은 그 아래에 있는 “애초에 range란 무엇으로 정의되는가”에 집중합니다.

range concept의 최소 요건: begin()/end()만 있으면 된다

C++20의 <ranges> 헤더는 std::ranges::range라는 concept을 정의합니다. 이 concept의 요건은 놀라울 정도로 단순합니다 — std::ranges::begin(r)과 std::ranges::end(r)을 호출할 수 있고, begin(r) != end(r)을 비교할 수 있으면 그것으로 충분합니다. 컨테이너일 필요도, 값을 소유할 필요도, size()가 있을 필요도 없습니다. std::vector는 물론이고 C 스타일 배열, std::views::iota가 만들어내는 무한 정수 시퀀스, 직접 만든 자료구조도 begin/end 한 쌍만 노출하면 range입니다.

#include <ranges>
#include <vector>
#include <forward_list>

static_assert(std::ranges::range<std::vector<int>>);
static_assert(std::ranges::range<std::forward_list<int>>);

// 최소 요건만 만족하는 커스텀 타입도 range가 될 수 있다
struct IntBox {
    int data[3] = {1, 2, 3};
    int* begin() { return data; }
    int* end() { return data + 3; }
};
static_assert(std::ranges::range<IntBox>);

이 최소 요건이 왜 중요한지는 반대로 생각해보면 드러납니다. C++20 이전에는 “이 타입이 range처럼 동작하는가”를 언어가 검증해주지 않았습니다. 표준 알고리즘 문서에 “InputIterator 요건을 만족하는 반복자 쌍을 넘기라”고 적혀 있을 뿐이었고, 실제로 요건을 만족하는지는 컴파일러가 그 알고리즘의 템플릿 본문을 인스턴스화해보고 나서야(대개 실패하고 나서야) 드러났습니다. range concept은 이 암묵적 계약을 이름이 있고 컴파일 타임에 검사 가능한 술어로 바꿔 놓은 것입니다.

왜 range 하나로 뭉치지 않고 계층을 나눴는가

range concept 자체는 최소 요건만 요구하지만, 표준 라이브러리는 여기서 더 세분화된 계층을 정의합니다 — input_range, forward_range, bidirectional_range, random_access_range, contiguous_range, 그리고 이들과 독립적인 sized_range. 각 단계는 대응하는 반복자 카테고리(input iterator, forward iterator …)를 range 수준으로 끌어올린 것입니다.

vector·deque·list·forward_list·iota·istream_view·filter 뷰의 range 계층 판정과 멤버·자유 함수 begin 탐색 결과, forward_list reverse와 list ranges::sort 컴파일 오류

그림: 실제 실행 결과 — vector는 contiguous, deque는 random_access, list는 bidirectional, forward_list는 forward이고 sized_range가 아니다. vector에 filter를 씌운 뷰는 bidirectional이면서 sized가 아니다. 멤버 begin()과 자유 함수 begin()이 모두 있는 타입에서 std::ranges::begin은 멤버를, using std::begin 방식은 자유 함수를 호출한다. forward_list에 reverse 뷰, list에 ranges::sort를 쓰면 컴파일되지 않는다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · -std=c++20)

왜 range 하나로 뭉치지 않고 계층을 나눴는가 다이어그램(주요 항목: 독립적 요건, range(begin/end만 있으면 됨), input_range한 방향으로 한 번만 순회, forward_range여러 번 다시 순회 가능, bidirectional_range앞뒤로 이동 가능, random_acces)

이렇게 세분화한 이유는 순전히 실용적입니다. 표준 알고리즘은 자신이 실제로 필요로 하는 최소한의 능력만 concept으로 명시합니다. std::ranges::find는 원소를 한 번씩 순서대로 비교하기만 하면 되므로 input_range로 충분합니다. 반면 std::ranges::sort는 분할 정복 과정에서 임의 위치의 원소를 O(1)에 접근하고 두 반복자 사이의 거리를 계산해야 하므로 random_access_range를 요구합니다. std::ranges::binary_search 역시 이분 탐색을 하려면 중간 지점에 바로 뛰어들 수 있어야 하니 random_access_range가 필요합니다.

이 구분이 실무에서 갖는 의미는 명확합니다. std::list나 std::forward_list에 std::ranges::sort를 호출하면 컴파일 에러가 납니다 — “요건 불충족”이라는 이유가 명시된 채로 말이죠. C++20 이전에도 std::sort(list.begin(), list.end())는 컴파일되지 않았지만, 에러는 알고리즘 구현 내부에서 list 반복자에 operator-가 없다는 식으로 깊숙한 곳을 가리켜 원인을 읽어 내기 어려웠습니다. concept 계층은 “이 알고리즘이 이 타입에 대해 의미가 있는가”라는 질문에 컴파일러가 대신 답해주는 안전장치입니다.

#include <ranges>
#include <vector>
#include <list>

static_assert(std::ranges::random_access_range<std::vector<int>>);
static_assert(!std::ranges::random_access_range<std::list<int>>);
static_assert(std::ranges::bidirectional_range<std::list<int>>); // list는 양방향까지는 지원

C++20 이전: range는 문서에만 존재하는 컨벤션이었다

concept이 생기기 전에는 “이 타입은 range다”라는 사실을 강제할 방법이 없었습니다. 예를 들어 커스텀 링 버퍼(ring buffer)에 직접 만든 반복자를 붙였는데, 반복자에 operator!=나 iterator_traits가 요구하는 value_type·iterator_category 같은 타입 별칭이 빠져 있다고 해 봅시다. 이 타입을 제네릭 유틸리티 함수에 넘기면, 컴파일러는 그 유틸리티가 다시 호출하는 템플릿과 그 안의 표준 알고리즘까지 인스턴스화를 내려가다가 가장 안쪽에서 실패합니다. 에러 메시지는 실제 원인(“반복자에 무엇이 빠졌다”)과 무관해 보이는 알고리즘 내부 코드를 가리키며 수백 줄로 출력되고, 어느 층에서부터 읽어야 할지 찾는 데 대부분의 시간이 듭니다.

같은 타입을 std::ranges 알고리즘이나 static_assert(std::ranges::forward_range<RingBuffer>)에 넘기면 상황이 달라집니다. 에러 메시지 앞부분에 “constraints not satisfied”와 함께 어떤 concept이 실패했는지, 그 concept 안에서 어떤 하위 요건(std::sentinel_for, std::input_iterator의 iter_value_t 등)이 만족되지 않았는지가 호출 지점 기준으로 나타납니다. 에러가 짧아지는 것이 사소해 보일 수 있지만, 템플릿이 여러 겹 중첩된 코드베이스에서는 “어디서부터 봐야 할지”를 아는 것 자체가 디버깅 시간의 대부분을 차지합니다.

range-based for는 처음부터 이 규약을 쓰고 있었다

사실 for (auto x : r)라는 range-based for 문법은 C++11부터 있었고, concept보다 훨씬 먼저 “begin/end 규약”에 의존하고 있었습니다. 이 문법은 컴파일 타임에 대략 다음과 같이 풀어 쓴 코드로 치환됩니다.

{
    auto&& __range = r;
    auto __it = begin(__range);   // 멤버 begin() 우선, 없으면 ADL
    auto __end = end(__range);
    for (; __it != __end; ++__it) {
        auto x = *__it;
        // 루프 본문
    }
}

즉 range-based for는 이미 “begin과 end를 어떻게든 구할 수 있으면 순회할 수 있다”는 duck typing을 언어 기능으로 내장하고 있었던 것입니다. C++20의 range concept이 한 일은 이 오래된 암묵적 규약에 이름을 붙이고, 컴파일 타임에 검사 가능하게 만든 것입니다. 그래서 std::ranges::range<T>를 만족하는 타입은 예외 없이 range-based for로 순회할 수 있고, 반대로 range-based for로 순회할 수 있던 기존 타입들은 대부분 자동으로 range concept도 만족합니다 — 새 개념이 기존 문법을 대체한 게 아니라, 기존 문법이 암묵적으로 요구하던 것을 명시적으로 검증 가능하게 만든 것에 가깝습니다.

std::ranges::begin/end가 평범한 함수가 아닌 이유

range concept을 실제로 검사하는 std::ranges::begin과 std::ranges::end는 일반적인 함수 템플릿이 아니라 커스터마이제이션 포인트 객체(Customization Point Object, CPO) 로 구현되어 있습니다. CPO는 함수처럼 호출되는 전역 inline 객체인데, 내부적으로 다음과 같은 우선순위로 탐색합니다.

  1. r.begin()처럼 멤버 함수가 있으면 그것을 사용
  2. 멤버 함수가 없으면 begin(r)을 ADL(인자 종속 조회) 로 찾아서 사용
  3. 배열처럼 언어가 이미 알고 있는 형태는 특별 처리

이런 설계가 필요한 이유는, C++ 생태계에 begin/end를 제공하는 방식이 두 갈래로 갈라져 있기 때문입니다. 표준 컨테이너는 대부분 멤버 함수(v.begin())로 제공하지만, C 스타일 배열이나 일부 서드파티 타입은 자유 함수(begin(arr))로만 제공합니다. C++11 시절 std::begin이라는 자유 함수를 도입해 이 둘을 통일하려 했지만, using std::begin; begin(x);처럼 매번 using 선언을 곁들여야 ADL과 std::begin 폴백이 동시에 동작하는, 잘 알려진 함정이 있었습니다(이른바 “two-step” 관용구). std::ranges::begin은 이 탐색 순서를 CPO 안에 캡슐화해서, 호출하는 쪽은 using 선언 없이 그냥 std::ranges::begin(r)이라고만 쓰면 항상 옳은 방식으로 반복자를 얻도록 만들었습니다.

CPO의 탐색 순서를 알아 두면 풀리는 함정도 있습니다. 멤버 begin()과 비멤버 자유 함수 begin(x)를 둘 다 정의해 둔 타입에서, 레거시 호환용으로 남긴 자유 함수 쪽이 리팩터링 때 갱신되지 않아 두 구현의 동작이 달라지는 경우입니다. std::ranges::begin을 쓰는 새 코드는 멤버 함수를 먼저 찾으므로 멤버 구현을 쓰지만, using std::begin; begin(x);를 쓰는 기존 코드에서는 오버로드 해석 결과 ADL로 찾은 비템플릿 자유 함수가 함수 템플릿인 std::begin보다 우선해 자유 함수 구현이 호출됩니다. 그래서 같은 객체를 순회하는데 새 코드와 기존 코드의 결과가 달라집니다. 멤버와 자유 함수를 둘 다 두지 말고 한쪽으로 정리하는 것이 근본적인 해결책입니다.

뷰 파이프라인과 range 알고리즘: 계층이 실제로 쓰이는 곳

range concept 계층은 추상적인 이야기로 끝나지 않고, 매일 쓰는 뷰 어댑터와 std::ranges 알고리즘의 타입 시그니처에 그대로 반영됩니다.

namespace vws = std::views;

// 필터·변환처럼 순서만 지키면 되는 어댑터는 input_range로 충분
vws::filter(pred)
vws::transform(func)

// reverse는 양방향(bidirectional_range) 이상을 요구 — 뒤에서부터 훑어야 하므로
vws::reverse

// take/drop, take_while/drop_while — 개수·조건 기반으로 자르는 어댑터
vws::take(n)
vws::drop(n)
vws::take_while(pred)
vws::drop_while(pred)

// 평탄화, 생성
vws::join
vws::iota(start, end)

vws::reverse가 bidirectional_range가 아닌 타입(예: std::forward_list)에는 애초에 적용할 수 없는 것도 같은 원리입니다. 뷰 어댑터가 요구하는 concept 요건을 만족하지 못하면 파이프 연산자 |를 쓰는 시점에 바로 컴파일 에러가 나므로, 런타임까지 가서야 “역순회가 안 되는 타입이었다”는 사실을 알게 되는 상황을 막아줍니다. 지연 평가·댕글링 뷰처럼 파이프라인을 실제로 조합할 때 마주치는 함정은 C++20 range adaptor: views::filter·transform 파이프라인의 지연 평가와 댕글링 함정에서, std::ranges::sort·find·projection 같은 알고리즘 자체의 세부 사용법은 C++20 std::ranges 알고리즘: projection으로 중복 줄이기와 concept 기반 에러에서 각각 더 깊게 다룹니다. 이 글에서 짚은 concept 계층을 이해하고 나면, 그 두 글에서 “왜 이 알고리즘엔 되고 저 알고리즘엔 안 되는지”에 대한 에러 메시지를 훨씬 빠르게 해석할 수 있습니다.

자주 발생하는 함정: concept 요건을 놓치는 경우

가장 흔한 실수는 컨테이너의 실제 반복자 카테고리를 확인하지 않고 알고리즘을 그대로 옮겨 쓰는 것입니다.

#include <ranges>
#include <list>
#include <algorithm>

std::list<int> lst = {3, 1, 4, 1, 5};

// ❌ 컴파일 에러: std::list는 random_access_range가 아니다
// std::ranges::sort(lst);

// ✅ list는 자체 멤버 함수 sort()를 제공한다
lst.sort();

// std::ranges::find는 input_range로 충분하므로 문제없이 동작한다
auto it = std::ranges::find(lst, 4);

std::list가 sort()를 멤버 함수로 별도 제공하는 이유도 결국 이 concept 계층과 맞닿아 있습니다 — 연결 리스트는 임의 접근이 안 되므로 std::ranges::sort의 요건(random_access_range)을 만족할 수 없지만, 노드 포인터만 다시 이어붙이는 방식의 정렬은 O(n log n)에 가능하기 때문에 표준 라이브러리가 별도의 최적화된 멤버 함수로 제공하는 것입니다. concept 에러를 만났을 때 “이 알고리즘을 억지로 맞추는 방법”을 찾기보다, “이 타입에 맞는 다른 도구가 따로 있는가”를 먼저 확인하는 습관이 유용합니다.

참고 자료

FAQ

sized_range는 왜 다른 계층과 독립적으로 분리되어 있나요?

“크기를 O(1)에 알 수 있는가”는 반복 방향(input/forward/random access)과는 별개의 성질이기 때문입니다. std::forward_list는 forward_range이지만 크기를 구하려면 순회해야 하므로 sized_range가 아닙니다.

커스텀 타입이 concept을 만족하는지 어떻게 미리 확인하나요?

static_assert(std::ranges::random_access_range<MyType>);처럼 원하는 concept으로 static_assert를 걸어보면 됩니다. 실패하면 컴파일 에러 메시지에 정확히 어떤 하위 요건이 빠졌는지 나타납니다.

range concept을 직접 만들어 써야 할 때도 있나요?

흔치는 않지만, 직접 만든 알고리즘에 표준 알고리즘처럼 재사용 가능한 제약을 걸고 싶다면 std::ranges::range나 하위 concept을 그대로 함수 템플릿의 제약으로 가져다 쓰는 것이 일반적입니다. concept과 constraints의 문법 자체는 C++20 Concepts: SFINAE를 대체하는 제약 문법과 Subsumption에서 다룹니다.