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++ Views와 C++ Range Adaptor에서 깊이 다루므로, 이 글은 그 아래에 있는 “애초에 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 수준으로 끌어올린 것입니다.

flowchart TD
    R["range<br/>(begin/end만 있으면 됨)"] --> IR["input_range<br/>한 방향으로 한 번만 순회"]
    IR --> FR["forward_range<br/>여러 번 다시 순회 가능"]
    FR --> BR["bidirectional_range<br/>앞뒤로 이동 가능"]
    BR --> RAR["random_access_range<br/>O(1) 임의 위치 접근"]
    RAR --> CR["contiguous_range<br/>메모리가 연속함 (배열/vector)"]
    R -.->|"독립적 요건"| SR["sized_range<br/>size()를 O(1)에 구할 수 있음"]

이렇게 세분화한 이유는 순전히 실용적입니다. 표준 알고리즘은 자신이 실제로 필요로 하는 최소한의 능력만 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())처럼 컴파일은 되지만 반복자 산술 연산이 정의되지 않은 타입이라 애초에 함수 자체가 존재하지 않거나, 억지로 컴파일된다 해도 성능이 끔찍하게 나쁜 코드가 조용히 만들어지는 상황으로 이어질 수 있었습니다. 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) 타입에 순회 기능을 붙인 적이 있는데, 그때 멤버 함수로만 begin()/end()를 정의해두었습니다. 그런데 팀 동료가 작성한 제네릭 유틸리티 함수 하나가 using std::begin; begin(container)처럼 비한정(unqualified) 이름 조회 + ADL 관용구로 반복자를 얻으려 했고, 제 타입은 멤버 함수만 있고 그에 대응하는 자유 함수 오버로드가 없어서 그 관용구와 맞지 않았습니다. 결과는 컴파일 에러였는데, 실제 원인(“멤버 함수만 있고 자유 함수 begin이 없다”)과는 전혀 상관없어 보이는 곳 — 그 유틸리티 함수 내부의, 다시 그 함수를 호출하는 또 다른 템플릿의, 그 안에서 호출하는 알고리즘의 템플릿 인스턴스화 실패 메시지가 수백 줄에 걸쳐 출력됐습니다. 진짜 원인을 찾는 데만 30분 가까이 걸렸습니다.

같은 실수를 C++20 concept이 적용된 코드베이스에서 다시 해봤을 때는 상황이 완전히 달랐습니다. std::ranges::begin을 쓰는 코드에 멤버 함수만 있는 타입을 넘기면(실제로는 이제 멤버 함수든 자유 함수든 다 인식하지만, 일부러 둘 다 없는 상태로 시험해보면) 에러 메시지 맨 위에 “제약 조건을 만족하지 않습니다(constraint not satisfied)“와 함께 정확히 어떤 concept — 이 경우 range인지 input_iterator인지 — 을 위반했는지가 몇 줄 안에 나타났습니다. 문제를 진단하는 시간이 30분에서 30초로 줄어든 셈인데, 이게 concept이 실무에서 주는 가장 체감되는 이득이라고 생각합니다. 에러가 짧아지는 게 사소해 보일 수 있지만, 템플릿이 여러 겹 중첩된 코드베이스에서는 “어디서부터 봐야 할지”를 아는 것 자체가 디버깅 시간의 대부분을 차지합니다.

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)이라고만 쓰면 항상 옳은 방식으로 반복자를 얻도록 만들었습니다.

제가 겪은 또 다른 사례는, 멤버 begin()과 비멤버 begin(x) 자유 함수를 둘 다 정의해둔 타입이었습니다. 레거시 코드와의 호환을 위해 자유 함수 오버로드를 남겨뒀는데, 두 구현이 반환하는 반복자의 동작이 미묘하게 달랐습니다(자유 함수 쪽은 리팩터링 중 갱신을 빠뜨렸습니다). std::ranges::begin을 쓰는 새 코드는 멤버 함수를 우선시하는 CPO 규칙 덕분에 항상 최신 구현을 탔지만, 옛날 방식대로 using std::begin; begin(x);를 쓰던 레거시 코드는 상황에 따라 ADL로 자유 함수 쪽을 잡아서 다른 결과를 냈습니다. 이 버그는 “왜 새 코드와 옛날 코드가 같은 객체를 순회하는데 다른 결과가 나오는가”로 시작해서, 결국 두 오버로드 중 하나를 삭제하고 CPO 규칙에 맞춰 정리하는 것으로 마무리됐습니다. CPO의 탐색 순서를 명확히 이해하고 있지 않았다면 훨씬 오래 걸렸을 문제입니다.

뷰 파이프라인과 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++ Range Adaptor에서, std::ranges::sort·find·projection 같은 알고리즘 자체의 세부 사용법은 C++ Range Algorithms에서 각각 더 깊게 다룹니다. 이 글에서 짚은 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

Q1: range와 컨테이너는 같은 개념인가요?

A: 아닙니다. 모든 컨테이너는 range이지만, 모든 range가 컨테이너는 아닙니다. std::views::iota(1)처럼 값을 소유하지 않고 무한히 생성만 하는 뷰도 begin/end만 있으면 range입니다.

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

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

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

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

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

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

관련 글