C++20 std::views: filter·transform·take 지연 평가와 ranges::to로 구체화하기
이 글의 핵심
C++20 std::views는 range를 소유하지 않고 얕게 참조하는 지연 평가 어댑터입니다. 컨테이너와 다른 이 이중적 성격, semiregular 요구사항, ranges::to로 구체화하는 방법을 댕글링·캐싱 함정 경험담과 함께 설명합니다.
Views란?
C++20 <ranges>가 등장하기 전에는 “범위를 가공한 결과”를 표현하려면 새 컨테이너를 만들어 값을 채워 넣는 수밖에 없었습니다. 짝수만 골라내려면 std::vector 하나를 새로 만들어 push_back을 반복해야 했고, 그 결과를 다시 변환하려면 또 다른 벡터가 필요했습니다. std::views는 이 흐름을 뒤집습니다 — 뷰는 원본 range의 원소를 복사해서 새 컨테이너에 담는 대신, “원본을 이렇게 가공해서 보여주겠다”는 얇은 래퍼만 만듭니다. std::views::filter가 반환하는 값은 필터링된 결과 자체가 아니라, 순회될 때마다 원본을 다시 들여다보며 조건을 검사하는 뷰 객체입니다.
이때 핵심은 뷰가 원본 range를 소유하지 않는다는 점입니다. std::vector<int>가 자신의 원소를 힙에 소유하는 것과 달리, views::filter(v, pred)가 만든 뷰는 v의 메모리를 참조만 할 뿐 어떤 원소도 복사해서 갖고 있지 않습니다. 이건 std::string_view가 문자열 데이터를 소유하지 않고 포인터와 길이만 들고 있는 것과 정확히 같은 설계 원칙입니다. 즉 뷰의 수명 관리 책임은 전적으로 사용자에게 있습니다 — 원본 v가 소멸하거나 재할당되면, 그 위에 얹힌 뷰는 즉시 무효화됩니다. 뷰는 컨테이너처럼 보이고 컨테이너처럼 for로 순회할 수 있지만, 실제로는 원본에 대한 카메라 앵글에 더 가깝습니다 — 카메라를 치워도 피사체가 사라지는 게 아니듯, 뷰를 없애도 원본은 그대로지만 그 반대는 성립하지 않습니다.
#include <ranges>
std::vector<int> v = {1, 2, 3, 4, 5};
// 뷰: 복사 없음
auto filtered = v | std::views::filter([](int x) { return x > 2; });
// 순회 시 평가
for (int x : filtered) {
std::cout << x << " "; // 3 4 5
}
여기서 filtered가 만들어지는 순간에는 어떤 필터링도 실행되지 않습니다. filtered는 v에 대한 포인터 성격의 참조와 조건자 람다를 들고 있을 뿐이고, x > 2 검사는 for 루프가 filtered.begin()을 호출하고 operator++로 전진할 때마다 실행됩니다. 원본 range를 뷰로 감싸는 이 진입점이 바로 std::views::all입니다 — 어떤 range를 어댑터 파이프라인에 넣으면, 표준 라이브러리는 내부적으로 views::all(range)을 호출해 그 range를 뷰 타입으로 정규화합니다. range가 이미 뷰라면 그대로 반환하고, std::vector 같은 소유 컨테이너라면 참조 하나만 담은 ref_view로 감싸서 돌려줍니다. v | std::views::filter(...)라는 파이프 문법이 자연스럽게 동작하는 이유가 바로 이 views::all이 모든 range를 균일하게 “뷰”라는 공통 인터페이스로 맞춰주기 때문입니다.
뷰는 컨테이너가 아니라 “복사 가능한 참조”다
뷰의 이중적 성격은 여기서 나옵니다. 뷰는 컨테이너처럼 값으로 다뤄지도록 설계되어 있습니다 — 함수에 값으로 넘길 수 있고, 대입할 수 있고, 다른 변수에 복사해서 저장할 수 있습니다. 그런데 그 “값”이 가리키는 실제 데이터는 뷰 바깥, 대개는 어딘가의 컨테이너 안에 있습니다. 이건 얼핏 모순처럼 보이지만, std::string_view를 값으로 자유롭게 넘기면서도 그 내용은 원본 문자열 버퍼를 그대로 가리키는 것과 완전히 같은 패턴입니다. 뷰를 “읽기 전용 컨테이너”라고 오해하면 안 되는 이유가 여기에 있습니다 — 뷰는 읽기 전용 여부와 무관하게, 애초에 데이터를 소유하지 않는 range라는 별개의 카테고리입니다. std::views::transform처럼 쓰기 가능한 참조를 그대로 통과시키는 뷰는 원본을 수정할 수도 있습니다.
semiregular 요구사항이 실제로 필요한 이유
C++20 view 콘셉트은 뷰가 되기 위한 조건으로 semiregular(기본 생성 가능, 복사 생성/대입 가능, 이동 생성/대입 가능)를 요구하고, 특히 이 복사와 이동이 상수 시간(O(1)) 이어야 한다고 명시합니다. 처음 보면 왜 이런 제약이 필요한지 의아할 수 있는데, 어댑터 체인이 실제로 컴파일되는 방식을 보면 이유가 분명해집니다. v | filter(p) | transform(f) | take(3)처럼 어댑터를 파이프로 엮으면, 각 단계는 이전 단계의 뷰를 값으로 감싸 저장합니다 — take_view는 내부에 transform_view를 멤버로 갖고, transform_view는 내부에 filter_view를 멤버로 갖는 식입니다. 이 체인이 함수 인자로 전달되거나, 알고리즘에 복사되거나, 람다 캡처에 값으로 잡힐 때마다 이 중첩 구조 전체가 복사됩니다. 만약 뷰의 복사 비용이 원본 컨테이너 크기에 비례한다면(즉 뷰가 데이터를 실제로 복사한다면), 지연 평가로 얻은 성능 이점이 복사 단계에서 전부 사라져 버립니다. semiregular 요구사항은 “뷰는 아무리 복사해도 포인터 몇 개와 조건자 객체만 복사되는 수준이어야 한다”는 것을 타입 시스템 차원에서 강제하는 장치입니다.
기본 뷰
가장 자주 쓰는 뷰 팩토리들은 각각 원본 range의 특정 측면만 바꿔서 보여줍니다. filter는 원소 개수를 줄이고, transform은 원소의 타입이나 값을 바꾸고, take/drop은 순회 범위의 앞뒤를 자릅니다. 이들을 조합하면 SQL의 WHERE, SELECT, LIMIT를 파이프로 이어 붙이는 것과 비슷한 선언적 스타일이 됩니다.
namespace vws = std::views;
// filter: 조건 필터
vws::filter([](int x) { return x % 2 == 0; })
// transform: 변환
vws::transform([](int x) { return x * 2; })
// take: 처음 n개
vws::take(5)
// drop: 처음 n개 제외
vws::drop(3)
// reverse: 역순
vws::reverse
vws::reverse가 다른 뷰들과 조금 다른 점은, 대상 range가 양방향(bidirectional) 반복자 이상을 지원해야 한다는 것입니다. filter나 transform은 입력 반복자만 있어도 동작하지만, 역순 순회는 operator--가 정의되어 있어야 하므로 std::forward_list 같은 단방향 컨테이너에는 reverse를 적용할 수 없습니다. 이런 반복자 카테고리 요구사항은 컴파일 타임에 concept로 검사되므로, 맞지 않는 조합을 쓰면 런타임 버그 대신 (다소 장황하지만) 컴파일 에러로 바로 드러납니다.
실전 예시
예시 1: filter
filter는 조건자(predicate)를 만족하는 원소만 남기고 순회합니다. 중요한 점은 “제거”가 아니라 “건너뛰기”라는 것 — 원본 numbers는 전혀 수정되지 않고, 뷰를 순회할 때 조건을 만족하지 않는 원소를 그냥 지나칠 뿐입니다.
#include <ranges>
#include <vector>
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 짝수만
auto evens = numbers | std::views::filter([](int x) {
return x % 2 == 0;
});
for (int x : evens) {
std::cout << x << " "; // 2 4 6 8 10
}
예시 2: transform
transform은 각 원소에 함수를 적용한 결과를 보여주는 뷰를 만듭니다. 여기서도 원본을 변형해서 저장하는 게 아니라, 순회 시점에 함수를 매번 호출한다는 점이 핵심입니다. 함수가 비싸다면 뷰를 여러 번 순회할 때마다 그 비용이 반복된다는 뜻이기도 합니다.
std::vector<int> numbers = {1, 2, 3, 4, 5};
// 제곱
auto squares = numbers | std::views::transform([](int x) {
return x * x;
});
for (int x : squares) {
std::cout << x << " "; // 1 4 9 16 25
}
예시 3: take & drop
take와 drop은 각각 앞에서부터 n개만 남기거나 n개를 건너뜁니다. 두 개를 조합하면 “부분 구간 슬라이스”를 흉내 낼 수 있는데, 이때도 실제 데이터 이동은 전혀 일어나지 않고 반복자가 시작하는 위치와 끝나는 위치에 대한 정보만 조정됩니다.
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 처음 5개
auto first5 = numbers | std::views::take(5);
// 1 2 3 4 5
// 처음 3개 제외
auto skip3 = numbers | std::views::drop(3);
// 4 5 6 7 8 9 10
// 조합: 3개 제외 후 5개
auto middle = numbers | std::views::drop(3) | std::views::take(5);
// 4 5 6 7 8
예시 4: 복합 파이프라인
뷰의 진짜 위력은 여러 어댑터를 이어 붙였을 때 드러납니다. 아래 예시는 조건 필터링, 두 번의 변환, 개수 제한을 한 문장으로 표현합니다. 명령형 스타일이었다면 임시 벡터를 서너 개 거쳐야 했을 로직이, 각 단계가 무엇을 하는지 이름으로 드러나는 선언적 파이프라인으로 정리됩니다.
#include <ranges>
#include <vector>
#include <string>
struct Person {
std::string name;
int age;
};
std::vector<Person> people = {
{"Alice", 25},
{"Bob", 30},
{"Charlie", 35},
{"David", 40}
};
// 30세 이상 -> 이름만 -> 대문자 -> 처음 2개
auto result = people
| std::views::filter([](const Person& p) { return p.age >= 30; })
| std::views::transform([](const Person& p) { return p.name; })
| std::views::transform([](const std::string& name) {
std::string upper = name;
for (char& c : upper) c = std::toupper(c);
return upper;
})
| std::views::take(2);
for (const auto& name : result) {
std::cout << name << std::endl; // BOB, CHARLIE
}
조건부 뷰
take_while과 drop_while은 take/drop과 달리 개수가 아니라 조건을 기준으로 자릅니다. 정렬된 데이터나 조건이 한 번만 전환되는 시퀀스에서 특히 유용한데, 다만 이름이 암시하듯 조건을 만족하지 않는 첫 원소를 만나는 즉시 멈추므로, 뒤쪽에 다시 조건을 만족하는 원소가 있어도 무시된다는 점을 기억해야 합니다.
namespace vws = std::views;
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// take_while: 조건 만족하는 동안
auto lessThan5 = v | vws::take_while([](int x) { return x < 5; });
// 1 2 3 4
// drop_while: 조건 만족하는 동안 제외
auto from5 = v | vws::drop_while([](int x) { return x < 5; });
// 5 6 7 8 9 10
뷰 구체화하기: ranges::to와 그 이전
뷰는 지연 평가라는 장점이 있지만, 결과를 다른 함수에 넘기거나 오래 보관해야 한다면 결국 실제 컨테이너로 구체화(materialize)해야 합니다. C++20에는 이 작업을 위한 표준 함수가 없었습니다 — std::vector<int> result(view.begin(), view.end())처럼 반복자 쌍을 받는 생성자를 쓰거나, std::ranges::copy(view, std::back_inserter(result))로 직접 채워 넣는 수밖에 없었습니다. 두 방식 모두 뷰를 처음부터 끝까지 한 번 순회하면서 각 원소를 대상 컨테이너에 복사하거나 이동시킨다는 점은 같습니다. range가 크기를 미리 알 수 있는 sized range라면 reserve를 먼저 호출해 재할당을 줄이는 최적화도 직접 챙겨야 했습니다.
C++23의 std::ranges::to<Container>()는 이 반복 작업을 표준화한 것입니다. auto result = view | std::ranges::to<std::vector<int>>();처럼 쓰면, 내부적으로는 range가 sized인지 확인해 가능하면 reserve를 호출하고, 그렇지 않으면 반복자를 순회하며 삽입 연산(push_back 또는 insert)을 선택해 값을 채워 넣습니다. 컨테이너 생성자가 반복자 쌍을 직접 받을 수 있는 경우엔 그 생성자를 우선 사용하기도 합니다. 즉 ranges::to가 하는 일은 마법이 아니라, 개발자가 C++20에서 손으로 반복하던 “크기 힌트 확인 → 생성자 또는 삽입 반복자 선택 → 순회하며 채우기” 절차를 표준 라이브러리 안에 캡슐화한 것뿐입니다. 다만 이 표준화 덕분에 std::map처럼 반복자 쌍 생성자가 없는 컨테이너나, std::vector<std::vector<int>>처럼 중첩된 range-of-range를 재귀적으로 구체화하는 경우까지 일관된 문법으로 처리할 수 있게 됐다는 실질적인 이점이 있습니다. 컴파일러 지원이 아직 C++23 전체를 커버하지 못하는 환경이라면, 여전히 ranges::copy + back_inserter 조합이 가장 이식성 있는 대안입니다.
자주 발생하는 문제
문제 1: 수명
가장 흔하고 가장 위험한 함정입니다. 함수 안에서 지역 컨테이너를 만들고 그 위에 뷰를 씌워 그대로 반환하면, 함수가 끝나는 순간 지역 컨테이너는 소멸하지만 반환된 뷰는 여전히 그 메모리를 가리키고 있습니다. 뷰 자체는 아무 에러도 내지 않고 조용히 댕글링 상태가 되므로, 문제는 호출자가 그 뷰를 순회하는 시점에야 미정의 동작으로 터집니다.
// ❌ 댕글링 참조
auto getView() {
std::vector<int> v = {1, 2, 3};
return v | std::views::filter([](int x) { return x > 1; });
// v 소멸
}
// ✅ 소유
auto getFiltered() {
std::vector<int> v = {1, 2, 3};
std::vector<int> result;
auto view = v | std::views::filter([](int x) { return x > 1; });
std::ranges::copy(view, std::back_inserter(result));
return result;
}
실제로 이 패턴 때문에 프로덕션 코드에서 애를 먹은 적이 있습니다. 로그 항목을 필터링해서 반환하는 헬퍼 함수를 작성하면서, 파싱한 결과를 담은 지역 std::vector<LogEntry>를 만들고 그 위에 views::filter를 씌워 auto로 반환하도록 짰습니다. 컴파일은 문제없이 통과했고, 단위 테스트에서도 곧바로 순회했기 때문에 한동안 정상 동작하는 것처럼 보였습니다. 그런데 호출 쪽에서 그 반환값을 변수에 저장해 두었다가 몇 줄 뒤에 다시 순회하는 코드가 추가되면서, 이미 소멸한 스택 메모리를 읽는 문제가 발생했습니다. 매번 크래시가 나는 게 아니라 어떤 실행에서는 값이 이상하게 섞여 나오는 정도였기 때문에 처음엔 로직 버그로 오인했고, AddressSanitizer로 돌려보고 나서야 use-after-scope로 확인했습니다. 이후로는 “함수가 뷰를 반환한다”는 시그니처를 볼 때마다 그 뷰가 감싸고 있는 range가 함수 인자로 받은 것인지, 아니면 함수 내부에서 새로 만든 것인지부터 확인하는 습관이 생겼습니다. 후자라면 반드시 구체화해서 반환해야 합니다.
문제 2: 복사
뷰가 참조 의미론을 갖는다는 것은 원본이 바뀌면 뷰를 통해 보이는 값도 함께 바뀐다는 뜻입니다. 이는 버그가 아니라 설계상 당연한 동작이지만, 뷰를 “그 시점의 스냅샷”으로 착각하고 있다면 원본이 나중에 수정됐을 때 왜 값이 달라졌는지 한참 헤매게 됩니다.
// 뷰는 복사 안 함
std::vector<int> v = {1, 2, 3};
auto view = v | std::views::transform([](int x) { return x * 2; });
v[0] = 10;
// view는 v 참조
for (int x : view) {
std::cout << x << " "; // 20 4 6
}
문제 3: 재평가와 캐싱
뷰는 매 순회마다 연산을 다시 수행하므로, 같은 뷰를 여러 번 순회하면 필터 조건 검사나 변환 함수 호출이 그만큼 반복됩니다. 계산 비용이 크다면 이는 단순한 비효율을 넘어서는 문제가 될 수 있습니다. 더 미묘한 문제는 filter_view가 begin()을 캐싱한다는 점입니다. 표준 구현은 대개 filter_view::begin()이 처음 호출될 때 조건을 만족하는 첫 원소를 찾아 그 위치를 내부에 저장해 두고, 이후 begin() 호출에서는 다시 찾지 않고 캐시된 값을 반환합니다.
auto view = v | std::views::filter(pred) | std::views::transform(func);
// 매번 재계산
for (int x : view) { /* ... */ } // 계산
for (int x : view) { /* ... */ } // 다시 계산
// ✅ 캐싱
std::vector<int> cached(view.begin(), view.end());
이 begin 캐싱 때문에 실제로 예상과 다른 결과를 본 적이 있습니다. 조건을 만족하는 원소가 하나도 없을 수도 있는 컨테이너에 filter 뷰를 씌워두고, 한 번 순회해서 비어있음을 확인한 뒤 컨테이너에 원소를 추가하고 같은 뷰 객체를 재사용해 다시 순회하는 코드를 짰습니다. 논리적으로는 새로 추가된 원소가 조건을 만족하면 이번엔 보여야 했지만, 첫 순회에서 이미 begin()이 “끝(end)“을 가리키는 위치로 캐싱되어 버렸기 때문에, 두 번째 순회에서도 계속 빈 것으로 나왔습니다. 뷰 객체 자체는 멀쩡했고 원본 컨테이너도 제대로 갱신되어 있었기 때문에 원인을 찾는 데 시간이 걸렸습니다. 결론적으로 원본을 수정한 뒤 뷰를 다시 쓸 일이 있다면, 기존 뷰 객체를 재사용하지 말고 파이프라인을 다시 만들거나(v | views::filter(pred)를 매번 새로 평가) 애초에 뷰를 재사용 가능한 형태로 설계하지 않는 편이 안전합니다.
문제 4: 파이프 순서
어댑터의 순서는 결과뿐 아니라 성능에도 직접 영향을 줍니다. 비싼 연산일수록 뒤로 미루고, 원소 개수를 줄이는 연산(take, filter)을 앞으로 당기면 실제로 처리해야 할 원소 수 자체가 줄어듭니다.
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// ❌ 비효율
auto r1 = v
| std::views::transform([](int x) { return x * x; }) // 10개 제곱
| std::views::take(3); // 3개만 사용
// ✅ 효율적
auto r2 = v
| std::views::take(3) // 3개만
| std::views::transform([](int x) { return x * x; }); // 3개만 제곱
뷰 조합
지금까지 본 어댑터들을 자유롭게 조합하면 복잡한 데이터 가공 로직도 한 줄의 파이프라인으로 표현할 수 있습니다. 다만 조합이 길어질수록 타입은 여러 겹으로 중첩된 템플릿이 되므로, 컴파일 에러 메시지가 길어지고 디버거에서 변수를 살펴보기 번거로워진다는 트레이드오프도 함께 따라옵니다. 실무에서는 파이프라인이 서너 단계를 넘어가면 중간에 이름 있는 변수로 끊어서 각 단계의 의도를 주석 대신 이름으로 드러내는 편이 유지보수에 유리했습니다.
namespace vws = std::views;
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 여러 뷰 조합
auto result = v
| vws::filter([](int x) { return x % 2 == 0; }) // 짝수
| vws::transform([](int x) { return x * x; }) // 제곱
| vws::reverse // 역순
| vws::take(3); // 처음 3개
// 100 64 36
마무리
std::views는 “원본을 소유하지 않고 얕게 참조하는 range”라는 한 가지 설계 원칙에서 지연 평가, semiregular 요구사항, 댕글링 위험이 모두 자연스럽게 파생됩니다. 뷰를 컨테이너의 일종으로 오해하지 않고 std::string_view와 같은 부류의 “값처럼 다뤄지는 참조”로 이해하면, 언제 구체화가 필요한지, 언제 원본의 수명을 신경 써야 하는지가 훨씬 명확해집니다. 파이프라인이 편리하다고 무조건 뷰 상태로 오래 들고 있기보다는, 결과를 다른 스코프로 넘기거나 여러 번 순회해야 하는 시점에는 ranges::to나 ranges::copy로 명시적으로 구체화하는 습관을 들이는 편이 안전합니다.