C++ Ranges Views와 파이프라인 | 지연 연산으로 효율적으로 다루기 [#25-2]
이 글의 핵심
C++ Ranges Views와 파이프라인에 대한 실전 가이드입니다. 지연 연산으로 효율적으로 다루기 [#25-2] 등을 예제와 함께 상세히 설명합니다.
들어가며: “중첩된 루프와 임시 컨테이너가 많아요”
실제 겪는 문제 시나리오
로그 파일에서 에러 레벨만 필터링하고, 타임스탬프를 파싱한 뒤, 앞 10개만 처리해야 한다고 가정해 봅시다. 전통적인 C++ 스타일로 작성하면 다음과 같습니다.
// ❌ 문제: 중간 벡터가 3개나 생성됨
std::vector<LogEntry> all_logs = load_logs("app.log");
// 1단계: 에러만 필터 → 임시 벡터 1
std::vector<LogEntry> errors;
for (const auto& e : all_logs) {
if (e.level == LogLevel::Error) errors.push_back(e);
}
// 2단계: 타임스탬프 파싱 → 임시 벡터 2
std::vector<ParsedLog> parsed;
for (const auto& e : errors) {
parsed.push_back(parse_timestamp(e));
}
// 3단계: 앞 10개만 → 임시 벡터 3
std::vector<ParsedLog> result;
for (size_t i = 0; i < std::min(size_t(10), parsed.size()); ++i) {
result.push_back(parsed[i]);
}
문제점:
- 메모리:
errors,parsed,result세 개의 임시 벡터가 동시에 존재 - 복사 비용: 각 단계마다
push_back으로 요소 복사 - 가독성: 중첩 루프와 반복적인 패턴으로 의도가 흐려짐
- 확장성: 100만 개 로그면 수 MB 이상의 중간 메모리 사용
View 파이프라인으로 해결:
// ✅ View: 중간 컨테이너 없이 한 번의 순회로 처리
namespace vw = std::ranges::views;
auto result = all_logs
| vw::filter( { return e.level == LogLevel::Error; })
| vw::transform(parse_timestamp)
| vw::take(10)
| std::ranges::to<std::vector>(); // 필요할 때만 구체화
주의사항: 뷰는 원본 컨테이너 수명에 묶입니다. 임시 객체에 대한 뷰를 저장해 두면 댕글링이 될 수 있습니다.
이점:
- 지연 평가:
take(10)이면 10개 찾는 순간 순회 중단 가능 - 단일 순회: filter → transform → take가 한 요소씩 파이프라인으로 흐름
- 메모리 절약: 중간 벡터 없이 최종 결과만 저장
문제 시나리오 2: API 응답 데이터 처리
REST API에서 1000개의 사용자 데이터를 받아 활성 사용자만 추출하고, 이메일 도메인을 파싱한 뒤 상위 50명만 캐시에 저장해야 합니다.
// ❌ 문제: 중간 컨테이너 3개 + 전체 순회 3번
std::vector<User> users = api.fetch_users();
std::vector<User> active;
for (const auto& u : users) {
if (u.is_active) active.push_back(u);
}
std::vector<std::string> domains;
for (const auto& u : active) {
domains.push_back(extract_domain(u.email));
}
std::vector<std::string> top50(domains.begin(), domains.begin() + 50);
문제점: API 응답이 크면 active, domains 두 벡터가 메모리를 차지하고, 50개만 필요하면 나머지 950개는 불필요한 처리입니다.
// ✅ View: 50개 찾는 순간 즉시 중단, 중간 메모리 없음
auto top_domains = users
| vw::filter( { return u.is_active; })
| vw::transform( { return extract_domain(u.email); })
| vw::take(50)
| std::ranges::to<std::vector>();
문제 시나리오 3: 대용량 파일 스트리밍
10GB 로그 파일에서 에러 라인만 추출해 첫 100개를 분석해야 합니다. 전체를 메모리에 올리면 OOM이 발생합니다.
// ❌ 문제: 전체 파일을 메모리에 로드 → OOM
std::vector<std::string> all_lines = load_entire_file("10gb.log"); // 불가능!
// ✅ 스트리밍: 한 줄씩 읽으며 조건 체크 (메모리 고정)
void process_errors_streaming(const std::string& path) {
std::ifstream file(path);
std::string line;
int count = 0;
while (count < 100 && std::getline(file, line)) {
if (line.find("ERROR") != std::string::npos) {
analyze_error(line);
++count;
}
}
}
핵심: 파일 전체를 로드하지 않고 한 줄씩 처리하면 메모리 사용량이 일정하게 유지됩니다. 이미 메모리에 올린 range에 대해서는 view 파이프라인을 적용할 수 있습니다.
문제의 근본 원인
전통적인 방식은 각 단계가 완료된 결과를 다음 단계에 넘깁니다. “필터 결과 전체” → “변환 결과 전체” → “앞 N개” 순서로, 중간 결과물이 항상 메모리에 존재해야 합니다. 반면 view 파이프라인은 “요소 하나가 filter를 통과하면 → 바로 transform 적용 → take 카운트”처럼 스트리밍 방식으로 동작합니다. 그래서 중간 컨테이너가 필요 없습니다.
목표:
- view 개념: 복사하지 않는 range
- 파이프
|로 view 연결 - filter, transform, take, drop 등 완전한 예제
- 지연 평가 동작 원리와 다이어그램
- 흔한 실수(dangling reference, materialization)와 해결법
- 성능 비교와 프로덕션 패턴
이 글을 읽으면:
- view와 일반 range의 차이를 이해할 수 있습니다.
range | views::filter(...) | views::transform(...)형태로 실전 코드를 작성할 수 있습니다.- 언제 복사가 일어나는지(뷰 vs
to<vector>) 알 수 있습니다. - dangling reference 등 자주 발생하는 실수를 피할 수 있습니다.
목차
- View란
- 파이프 연산자와 완전한 파이프라인 예제
- 지연 평가와 동작 원리
- 자주 쓰는 뷰 (split, join, reverse, zip)
- 흔한 실수와 해결법
- 베스트 프랙티스
- 성능 비교: Views vs Eager 평가
- 프로덕션 데이터 파이프라인 패턴
- 실전 예제
1. View란
복사 없이 “보는” 범위
view는 range이면서 복사/이동이 O(1)인 것. 즉 “원본을 그대로 두고, 그 위에 겹쳐 보는 레이어”입니다.
#include <ranges>
namespace vw = std::ranges::views;
std::vector<int> v = {1, 2, 3, 4, 5};
auto even = v | vw::filter( { return x % 2 == 0; });
// even은 view. 순회할 때마다 필터 적용. 복사 없음.
view의 특성:
- Non-owning: 원본 데이터를 소유하지 않음
- O(1) 복사: view 객체 복사는 포인터/참조만 복사
- 지연 평가: 순회할 때만 연산 수행
view vs range
| 구분 | range | view |
|---|---|---|
| 소유 | 소유할 수 있음 (vector 등) | Non-owning |
| 복사 비용 | O(n) 가능 | O(1) |
| 재순회 | 대부분 가능 | 원본에 의존 |
| 예시 | vector, list, map | filter_view, transform_view |
2. 파이프 연산자와 완전한 파이프라인 예제
range | view
| 연산자로 range와 view를 이어 붙이면, 왼쪽 범위에 오른쪽 view가 순서대로 적용됩니다. result는 view이므로 아직 연산이 실행되지 않은 상태입니다. for로 순회하거나 ranges::to<std::vector>로 담을 때 비로소 filter → transform → take가 한 요소씩 적용됩니다.
완전한 파이프라인 예제: filter
vw::filter(조건)은 조건을 만족하는 원소만 통과시키는 view입니다. 조건자(predicate)는 bool을 반환하는 함수/람다입니다.
#include <ranges>
#include <vector>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 짝수만 필터
auto evens = v | vw::filter( { return x % 2 == 0; });
for (int x : evens) {
std::cout << x << " "; // 2 4 6 8 10
}
std::cout << "\n";
// 복합 조건: 3보다 크고 8보다 작은 값
auto in_range = v | vw::filter( { return x > 3 && x < 8; });
for (int x : in_range) std::cout << x << " "; // 4 5 6 7
}
주의: filter가 아무 요소도 통과시키지 않으면 빈 range가 됩니다. 순회 시 아무것도 출력되지 않습니다.
완전한 파이프라인 예제: transform
vw::transform(함수)는 각 원소에 함수를 적용한 결과로 “보이게” 하는 view입니다. 반환 타입이 원본과 달라도 됩니다.
#include <ranges>
#include <vector>
#include <string>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::vector<int> v = {1, 2, 3, 4, 5};
// 각 원소를 2배로 변환
auto doubled = v | vw::transform( { return x * 2; });
for (int x : doubled) std::cout << x << " "; // 2 4 6 8 10
std::cout << "\n";
// int → string 변환 (타입 변경 가능)
auto as_strings = v | vw::transform( { return std::to_string(x); });
for (const auto& s : as_strings) std::cout << s << " "; // "1" "2" "3" "4" "5"
}
핵심: transform은 지연 적용됩니다. 순회할 때마다 함수가 호출되며, 결과는 저장되지 않습니다.
완전한 파이프라인 예제: take / drop
vw::take(n)은 앞에서부터 n개만 보여 주고, vw::drop(n)은 앞 n개를 건너뛴 나머지를 보여 줍니다. take는 조기 종료의 핵심입니다.
#include <ranges>
#include <vector>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 앞 3개만
auto first3 = v | vw::take(3);
for (int x : first3) std::cout << x << " "; // 1 2 3
std::cout << "\n";
// 앞 2개 건너뛰고 나머지
auto skip2 = v | vw::drop(2);
for (int x : skip2) std::cout << x << " "; // 3 4 5 6 7 8 9 10
std::cout << "\n";
// take(n)이 n보다 크면 전체 반환 (에러 아님)
auto all = v | vw::take(100);
// 1 2 3 ... 10 (10개만 출력)
}
주의: drop(n)에서 n이 범위 크기보다 크면 빈 range가 됩니다. take(0)도 빈 range입니다.
filter + transform + take + drop 조합
#include <ranges>
#include <vector>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// "2보다 큰 값만 → 2배로 변환 → 앞 2개만"
auto result = v
| vw::filter( { return x > 2; })
| vw::transform( { return x * 2; })
| vw::take(2);
for (int x : result) {
std::cout << x << " "; // 6 8 (3*2, 4*2)
}
}
동작 순서:
v에서 3, 4, 5, … (2보다 큰 것)- 각각 2배 → 6, 8, 10, …
- 앞 2개만 → 6, 8
- take(2) 도달 시 순회 중단 (지연 평가의 이점)
완전한 체이닝 예제: filter → transform → drop → take
#include <ranges>
#include <vector>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
// 짝수만 → 제곱 → 앞 2개 스킵 → 3개만 → 36, 64, 100
auto pipeline = v
| vw::filter( { return x % 2 == 0; })
| vw::transform( { return x * x; })
| vw::drop(2)
| vw::take(3);
for (int x : pipeline) std::cout << x << " "; // 36 64 100
}
체이닝 순서: filter → transform → drop → take 순서가 효율적입니다. filter를 먼저 적용하면 불필요한 transform 호출을 줄일 수 있습니다.
drop + take 조합 (페이지네이션)
// 2번째 페이지 (인덱스 10~19)
auto page2 = v | vw::drop(10) | vw::take(10);
transform에서 인덱스 활용
// enumerate 비슷한 효과 (C++23에는 views::enumerate)
int i = 0;
for (auto x : v | vw::take(5)) {
std::cout << "[" << i++ << "] " << x << "\n";
}
3. 지연 평가와 동작 원리
순회 시점에만 계산
view는 iterator를 움직일 때 비로소 다음 요소를 계산합니다. 파이프라인을 구성해도 실제 순회가 일어나기 전까지 아무 연산도 수행되지 않습니다.
flowchart LR
subgraph eager["Eager (기존 방식)"]
E1[전체 필터] --> E2[전체 벡터 생성]
E2 --> E3[전체 변환]
E3 --> E4[전체 벡터 생성]
E4 --> E5[take 2]
end
subgraph lazy["Lazy (View 파이프라인)"]
L1[요소 1] --> L2{조건?}
L2 -->|Yes| L3[변환]
L3 --> L4[출력]
L4 --> L5{2개?}
L5 -->|Yes| L6[중단]
L2 -->|No| L1
L5 -->|No| L1
end
지연 평가 흐름 다이어그램
sequenceDiagram
participant User as 사용자
participant View as View 파이프라인
participant Source as 원본 (vector)
User->>View: for (x : pipeline)
loop 각 요소마다
View->>Source: 다음 요소 요청
Source->>View: 원본 요소
View->>View: filter 적용
View->>View: transform 적용
View->>View: take 카운트
View->>User: 결과 요소 전달
Note over User,View: take(2) 도달 시 루프 종료
end
take(1)의 효율
take(1)이면, 첫 번째로 조건을 만족하는 것만 계산하고 더 이상 진행하지 않습니다. 무한 range에서도 “첫 번째 짝수”를 찾는 데 유용합니다.
#include <ranges>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
// 1부터 시작하는 무한 range
auto naturals = std::views::iota(1);
// 첫 번째 7의 배수만 찾기 (7에서 중단)
auto first_multiple_of_7 = naturals
| vw::filter( { return x % 7 == 0; })
| vw::take(1);
for (int x : first_multiple_of_7) {
std::cout << x; // 7만 출력, 8, 9, 10... 은 계산 안 함
}
}
파이프라인 구성 vs 실행 시점
// 이 시점에는 아무 연산도 수행되지 않음
auto pipeline = v | vw::filter(pred) | vw::transform(f) | vw::take(10);
// 여기서 비로소 filter → transform → take가 요소마다 적용됨
for (auto x : pipeline) {
use(x);
}
핵심: pipeline 변수는 “어떻게 순회할지”에 대한 설명만 담고 있습니다. 실제 연산은 for 루프가 begin()/end()를 통해 iterator를 움직일 때 발생합니다.
결과를 컨테이너로 모으려면: materialization
view만으로는 “실제 vector가 필요할 때”(예: 여러 번 순회하거나, API가 vector를 받을 때)가 있습니다.
C++23:
#include <ranges>
auto vec = v
| vw::filter( { return x % 2 == 0; })
| std::ranges::to<std::vector>();
C++20:
auto filtered = v | vw::filter( { return x % 2 == 0; });
std::vector<int> vec(filtered.begin(), filtered.end());
4. 자주 쓰는 뷰 (split, join, reverse, zip)
reverse
vw::reverse는 범위를 역순으로 보여 주는 view입니다.
auto rev = v | vw::reverse;
// 10 9 8 7 6 5 4 3 2 1
split
vw::split(구분자)는 범위를 구분자 기준으로 나눕니다. 문자열 스플릿에 유용합니다.
#include <ranges>
#include <string>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::string s = "apple,banana,cherry";
auto tokens = s | vw::split(',');
for (auto token : tokens) {
// token은 subrange (char의 range)
std::string part(token.begin(), token.end());
std::cout << part << "\n"; // apple, banana, cherry
}
}
join
vw::join은 범위의 범위(range of ranges)를 하나의 평탄한 범위로 만듭니다.
std::vector<std::vector<int>> nested = {{1, 2}, {3, 4}, {5}};
auto flat = nested | vw::join;
for (int x : flat) {
std::cout << x << " "; // 1 2 3 4 5
}
zip
vw::zip은 여러 범위를 (C++23) 묶어서 쌍(pair/tuple)으로 순회합니다.
#include <ranges>
#include <vector>
#include <iostream>
namespace vw = std::ranges::views;
int main() {
std::vector<int> a = {1, 2, 3};
std::vector<std::string> b = {"one", "two", "three"};
for (auto [x, y] : vw::zip(a, b)) {
std::cout << x << ":" << y << "\n";
// 1:one, 2:two, 3:three
}
}
C++20에서는 zip_view가 없으므로, boost::range 또는 직접 구현이 필요합니다.
iota (무한/유한 수열)
std::views::iota(start, end)는 [start, end) 범위의 정수 수열을 생성합니다. end를 생략하면 무한 range가 됩니다.
// 1, 2, 3, 4, 5
auto nums = std::views::iota(1, 6);
// 0부터 무한 (take와 함께 사용)
auto from_zero = std::views::iota(0) | vw::take(100);
chunk (고정 크기 청크, C++23)
vw::chunk(n)은 범위를 n개씩 묶은 subrange의 range로 만듭니다. C++23에서 표준에 추가되었습니다.
// C++23
std::vector<int> v = {1, 2, 3, 4, 5, 6};
for (auto chunk : v | vw::chunk(2)) {
// chunk는 {1,2}, {3,4}, {5,6}
for (int x : chunk) std::cout << x << " ";
std::cout << "| ";
}
// 출력: 1 2 | 3 4 | 5 6 |
keys / values (map 등)
std::map은 (key, value) 쌍의 range이므로, vw::keys로 키만, vw::values로 값만 순회할 수 있습니다.
std::map<int, std::string> m = {{1, "a"}, {2, "b"}};
for (int k : m | vw::keys) { /* ... */ }
for (const auto& val : m | vw::values) { /* ... */ }
5. 흔한 실수와 해결법
실수 1: Dangling Reference (매달린 참조)
view는 원본을 참조합니다. 원본이 소멸한 뒤 view를 사용하면 undefined behavior입니다.
// ❌ 위험: get_range()가 반환한 임시 vector가 즉시 소멸
auto bad = get_range() // vector<int> 임시 반환
| vw::filter( { return x > 0; });
// bad는 이미 소멸한 vector를 참조 → UB
for (int x : bad) { /* 크래시 또는 쓰레기 값 */ }
해결법: 원본의 수명을 확실히 하거나, 먼저 구체화합니다.
// ✅ 방법 1: 원본을 변수에 저장
auto data = get_range();
auto good = data | vw::filter( { return x > 0; });
// ✅ 방법 2: 즉시 구체화
auto safe = get_range()
| vw::filter( { return x > 0; })
| std::ranges::to<std::vector>();
실수 2: Materialization 시점 오해
view를 여러 번 순회하면, 매번 원본을 다시 순회합니다. 비용이 큰 연산이면 구체화하는 것이 낫습니다.
// ❌ 비효율: 3번 순회 (filter+transform 매번 적용)
auto view = expensive_range | vw::filter(pred) | vw::transform(f);
for (auto x : view) { /* ... */ }
for (auto x : view) { /* ... */ }
for (auto x : view) { /* ... */ }
// ✅ 효율: 1번 순회 후 재사용
auto vec = expensive_range | vw::filter(pred) | vw::transform(f)
| std::ranges::to<std::vector>();
for (auto x : vec) { /* ... */ }
for (auto x : vec) { /* ... */ }
실수 3: reverse와 bidirectional range
vw::reverse는 bidirectional range가 필요합니다. std::forward_list는 단방향이라 reverse를 쓸 수 없습니다.
std::forward_list<int> fl = {1, 2, 3};
// auto rev = fl | vw::reverse; // ❌ 컴파일 에러
std::list<int> lst = {1, 2, 3};
auto rev = lst | vw::reverse; // ✅ OK
실수 4: filter 람다에서 참조 캡처
원본이 임시이면, 람다가 참조를 캡처해도 위험할 수 있습니다.
// ❌ 위험: e가 참조인데 원본이 소멸하면 UB
auto bad = get_entries() | vw::filter( {
return e.valid; // e가 이미 소멸한 객체를 참조할 수 있음
});
// ✅ 안전: 값으로 받거나, 원본 수명 확보
auto data = get_entries();
auto good = data | vw::filter( { return e.valid; });
실수 5: view를 반환하는 함수
함수가 view를 반환할 때, 원본 range도 함께 반환되거나 수명이 보장되어야 합니다.
// ❌ 위험: data는 지역 변수, 함수 반환 시 소멸
auto get_filtered() {
std::vector<int> data = {1, 2, 3};
return data | vw::filter( { return x > 1; });
}
// ✅ 안전: vector를 함께 반환 (구체화)
auto get_filtered() {
std::vector<int> data = {1, 2, 3};
return data | vw::filter( { return x > 1; })
| std::ranges::to<std::vector>();
}
실수 6: 빈 range와 take(0)
take(0)은 빈 range를 만듭니다. 순회해도 아무것도 나오지 않습니다. 의도한 동작이지만, “왜 아무것도 안 나오지?”라고 헷갈릴 수 있습니다.
auto empty = v | vw::take(0);
for (int x : empty) { /* 실행 안 됨 */ }
실수 7: transform/filter에서 원본 수정
transform이나 filter의 람다가 순회 중 원본 range를 수정하면 undefined behavior입니다. view는 읽기 전용으로 사용해야 합니다.
// ❌ 위험: 순회 중 원본 컨테이너 수정 → UB
std::vector<int> v = {1, 2, 3};
auto bad = v | vw::transform([&v](int x) {
v.push_back(x); // 순회 중 v 수정 → undefined behavior
return x * 2;
});
// ✅ 안전: 값으로 받아 새 값만 반환
auto good = v | vw::transform( { return x * 2; });
실수 8: split 결과의 수명
vw::split의 각 토큰은 subrange입니다. std::string으로 변환하지 않고 참조만 저장하면 원본 문자열이 소멸할 때 dangling이 됩니다.
// ❌ 위험: token은 원본 s를 참조, s가 소멸하면 UB
std::vector<std::string_view> tokens;
for (auto token : s | vw::split(',')) {
tokens.push_back(std::string_view(token.begin(), token.end()));
}
// s가 스코프를 벗어나면 tokens의 모든 요소가 dangling
// ✅ 안전: 즉시 std::string으로 복사
std::vector<std::string> tokens;
for (auto token : s | vw::split(',')) {
tokens.push_back(std::string(token.begin(), token.end()));
}
실수 9: take/drop에 음수 또는 매우 큰 값
take와 drop은 std::ranges::range_difference_t 타입을 받습니다. 음수를 넘기면 undefined behavior입니다.
// ❌ UB: 음수
auto bad = v | vw::take(-1);
// ❌ 주의: size_t와 signed 혼용 시 음수 변환
size_t n = 10;
int limit = -1;
auto bad2 = v | vw::take(limit); // limit이 큰 양수로 해석될 수 있음
// ✅ 안전: 0 이상의 값만 사용
auto good = v | vw::take(std::max(0, limit));
실수 10: 파이프라인 순서로 인한 비효율
filter를 transform 뒤에 두면, 변환이 먼저 모든 요소에 적용된 뒤 필터링됩니다. 비용이 큰 변환이면 낭비입니다.
// ❌ 비효율: 100만 개 모두 sqrt 적용 후 필터
auto bad = big_range
| vw::transform( { return std::sqrt(x); }) // 비용 큼
| vw::filter( { return x > 10.0; });
// ✅ 효율: 먼저 필터 후 변환 (조건 만족하는 것만 sqrt)
auto good = big_range
| vw::filter( { return x > 100; }) // 정수 비교로 먼저 걸러냄
| vw::transform( { return std::sqrt(x); });
6. 베스트 프랙티스
1. filter를 transform보다 먼저
비용이 큰 변환은 필터로 먼저 범위를 좁힌 뒤 적용하세요.
// ✅ 권장: 필터 → 변환
auto result = data
| vw::filter(cheap_predicate)
| vw::transform(expensive_function);
2. take/drop으로 조기 종료 활용
“처음 N개만 필요”할 때는 반드시 take를 파이프라인 끝에 두세요.
// ✅ N개 찾는 순간 즉시 중단
auto first_10 = huge_range | vw::filter(pred) | vw::take(10);
3. 재사용 시 구체화
파이프라인 결과를 두 번 이상 순회할 경우 to<vector>로 구체화하세요.
// ✅ 여러 번 순회할 때
auto vec = range | vw::filter(pred) | std::ranges::to<std::vector>();
process(vec);
aggregate(vec);
4. 함수 반환 시 수명 확보
view를 반환하는 함수는 구체화해서 반환하는 것이 가장 안전합니다.
// ✅ 권장: 구체화해서 반환
std::vector<int> get_filtered() {
auto data = load_data();
return data | vw::filter(pred) | std::ranges::to<std::vector>();
}
5. 파이프라인 가독성
긴 파이프라인은 한 단계씩 줄바꿈하면 가독성이 좋아집니다.
auto result = data
| vw::filter( { return x.valid(); })
| vw::transform( { return x.to_parsed(); })
| vw::take(100)
| std::ranges::to<std::vector>();
6. C++ 버전별 대응
- C++20:
to<vector>없음 →std::vector(begin, end)또는ranges::copy사용 - C++23:
std::ranges::to,views::zip,views::chunk사용 가능
// C++20: 구체화
auto filtered = rng | vw::filter(pred);
std::vector<int> vec(filtered.begin(), filtered.end());
// C++23: to 사용
auto vec = rng | vw::filter(pred) | std::ranges::to<std::vector>();
7. 성능 비교: Views vs Eager 평가
메모리 사용량 비교
| 방식 | 중간 벡터 수 | 100만 요소 기준 (int) |
|---|---|---|
| Eager (기존) | 3개 | ~12MB (필터+변환+결과) |
| View 파이프라인 | 0개 | 0MB (순회 시 스택만 사용) |
| View + to<vector> | 1개 | ~4MB (최종 결과만) |
순회 비용 비교
take(10) 같은 조기 종료가 있을 때:
| 방식 | 실제 연산 수 |
|---|---|
| Eager | 전체 필터 + 전체 변환 후 take |
| View | 10개 찾을 때까지만 연산 (조기 종료) |
벤치마크 예시 (개념)
// Eager: 100만 개 모두 처리
std::vector<int> a = /* 1M elements */;
std::vector<int> b;
for (int x : a) if (pred(x)) b.push_back(f(x));
std::vector<int> c(b.begin(), b.begin() + 10);
// View: 10개 찾을 때까지만
auto result = a | vw::filter(pred) | vw::transform(f) | vw::take(10);
std::vector<int> c(result.begin(), result.end());
take(10)이면 View 방식이 훨씬 적은 요소만 처리합니다. 조건을 만족하는 요소가 뒤쪽에 있으면 차이가 더 커집니다.
언제 Eager가 나을까?
- 여러 번 순회할 결과가 필요할 때
- 인덱스 접근
result[i]가 필요할 때 - 작은 데이터에서 가독성이 더 중요할 때
컴파일 타임 오버헤드
view 파이프라인은 템플릿 중첩으로 인해 컴파일 시간이 다소 늘어날 수 있습니다. 매우 긴 파이프라인(10단계 이상)에서는 타입 이름이 길어지고 컴파일이 느려질 수 있으나, 런타임 성능에는 거의 영향이 없습니다.
8. 프로덕션 데이터 파이프라인 패턴
패턴 1: 로그 처리 파이프라인
// 에러/경고만 추출 → 파싱 → 상위 N개
auto critical_logs = log_stream
| vw::filter( {
return L.level >= LogLevel::Warning;
})
| vw::transform(parse_log_line)
| vw::take(1000)
| std::ranges::to<std::vector>();
패턴 2: CSV 파싱
// 줄 단위 → 쉼표 split → 필드 파싱
auto parse_csv_line = {
return line | vw::split(',')
| vw::transform( {
return std::string(rng.begin(), rng.end());
})
| std::ranges::to<std::vector>();
};
for (std::string_view line : lines | vw::split('\n')) {
auto fields = parse_csv_line(line);
process(fields);
}
패턴 3: 배치 처리 (청크 단위)
// 큰 범위를 1000개씩 청크로 처리 (C++23: vw::chunk)
// C++20에서는 수동으로 반복자 활용
auto chunked = big_range | vw::chunk(1000); // C++23
for (auto chunk : chunked) {
process_batch(chunk);
}
패턴 4: 조건부 파이프라인
auto base = data | vw::filter(pred);
auto result = (need_reversed ? base | vw::reverse : base)
| vw::take(limit)
| std::ranges::to<std::vector>();
패턴 5: 에러 처리와 함께
auto safe_pipeline = {
return std::forward<decltype(rng)>(rng)
| vw::filter( {
try { return validate(x); }
catch (...) { return false; }
})
| vw::transform(parse_safe);
};
패턴 6: 조건부 필터 체인
auto build_pipeline = {
return [=](auto&& rng) {
auto base = std::forward<decltype(rng)>(rng);
if (only_errors) {
base = base | vw::filter( { return e.is_error(); });
}
if (limit_results) {
base = base | vw::take(100);
}
return base;
};
};
패턴 7: 스트리밍 처리 (대용량 파일)
// 파일을 한 줄씩 읽으며, 각 줄의 토큰에 파이프라인 적용
void process_large_file(const std::string& path) {
std::ifstream file(path);
for (std::string line; std::getline(file, line); ) {
auto tokens = line | vw::split(',')
| vw::transform( { return std::string(r.begin(), r.end()); });
auto first_valid = tokens
| vw::filter(is_valid)
| vw::transform(parse)
| vw::take(1);
for (auto x : first_valid) {
handle(x);
}
}
}
패턴 8: 다중 range 병렬 처리 (zip, C++23)
두 개의 range를 짝지어 처리할 때 zip을 사용합니다.
std::vector<int> ids = {1, 2, 3};
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
for (auto [id, name] : vw::zip(ids, names)) {
std::cout << "ID " << id << ": " << name << "\n";
}
패턴 9: 인덱스와 함께 순회 (enumerate)
C++23의 views::enumerate 또는 iota + zip으로 인덱스를 붙입니다.
// C++23: enumerate
for (auto [i, x] : v | vw::enumerate) {
std::cout << "[" << i << "] " << x << "\n";
}
// C++20: iota + zip (또는 수동 카운터)
int i = 0;
for (auto x : v) {
std::cout << "[" << i++ << "] " << x << "\n";
}
패턴 10: 재사용 가능한 파이프라인 팩토리
설정에 따라 다른 파이프라인을 반환하는 팩토리 함수입니다.
#include <ranges>
#include <vector>
namespace vw = std::ranges::views;
auto make_pipeline(bool filter_positive, size_t limit) {
return [=](auto&& rng) {
auto result = std::forward<decltype(rng)>(rng);
if (filter_positive) {
result = result | vw::filter( { return x > 0; });
}
if (limit > 0) {
result = result | vw::take(limit);
}
return result;
};
}
// 사용: pipeline(data)로 range를 넘겨 파이프라인 적용
std::vector<int> data = {1, -2, 3, -4, 5};
auto pipeline = make_pipeline(true, 100);
auto result = pipeline(data) | std::ranges::to<std::vector>();
// result: {1, 3, 5}
패턴 11: 에러 허용 파이프라인 (optional 처리)
변환 중 실패할 수 있는 경우 optional을 반환하고 filter로 걸러냅니다.
std::optional<int> parse_safe(const std::string& s) {
try { return std::stoi(s); } catch (...) { return std::nullopt; }
}
auto parsed = strings
| vw::transform(parse_safe)
| vw::filter( { return opt.has_value(); })
| vw::transform( { return *opt; })
| std::ranges::to<std::vector>();
9. 실전 예제
조건 만족하는 처음 N개
auto firstTwoEvens = v
| vw::filter( { return x % 2 == 0; })
| vw::take(2);
범위 기반 for와 함께
for (auto x : v | vw::filter(pred) | vw::transform(f)) {
process(x);
}
문자열 토큰화
std::string input = "hello world cpp ranges";
for (auto token : input | vw::split(' ')) {
std::string s(token.begin(), token.end());
std::cout << s << "\n";
}
맵 키/값 변환
std::map<int, std::string> m = {{1, "a"}, {2, "b"}, {3, "c"}};
// 키가 짝수인 항목의 값만
auto even_values = m
| vw::filter( { return p.first % 2 == 0; })
| vw::values;
중복 제거 (unique)
std::ranges::unique는 연속된 중복 요소를 제거합니다. 정렬된 range에서 전체 중복 제거는 sort 후 unique를 사용합니다.
std::vector<int> v = {1, 1, 2, 2, 2, 3, 1};
std::ranges::sort(v); // 1, 1, 1, 2, 2, 2, 3
auto [first, last] = std::ranges::unique(v);
v.erase(first, last); // 1, 2, 3
C++23의 vw::adjacent_filter를 사용하면 view로 연속 중복 제거가 가능합니다.
구현 체크리스트
View 파이프라인을 도입할 때 확인할 사항:
- 원본 range의 수명이 view 사용 기간보다 긴가?
-
get_xxx()같은 함수 반환값에 바로 파이프했다면, 즉시 순회 또는 구체화하는가? - 여러 번 순회할 결과라면
to<vector>로 구체화했는가? -
reverse를 쓸 때 range가 bidirectional인가? -
take(n)으로 조기 종료가 가능한 경우, view가 유리한가?
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Ranges | “함수형 프로그래밍” C++20 가이드
- C++20 Ranges | begin/end 반복 탈출하고 ranges 알고리즘 쓰기
- C++ Views | “뷰” 가이드
이 글에서 다루는 키워드 (관련 검색어)
C++20 views, 파이프라인, ranges view, 지연 평가, filter transform, split join zip, dangling reference 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| view | 복사 없이 범위를 “보는” range |
| 파이프 | range | views::filter(...) | views::take(n) |
| 지연 | 순회할 때만 계산, take로 조기 종료 가능 |
| 복사 | 컨테이너로 쓰려면 to<vector> 또는 생성자로 구체화 |
| 주의 | dangling reference, 여러 번 순회 시 비용 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 로그 필터링, CSV 파싱, 데이터 변환 파이프라인, 대용량 스트림 처리 등에서 view 파이프라인을 쓰면 중간 메모리를 줄이고 가독성을 높일 수 있습니다. take(n)으로 조기 종료가 가능한 경우 특히 유리합니다.
Q. get_range() 같은 함수 반환값에 파이프해도 되나요?
A. 반환된 임시 객체가 곧 소멸하므로, view를 즉시 순회하거나 to<vector>로 구체화해야 합니다. view를 변수에 저장해 두었다가 나중에 순회하면 dangling reference가 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference - Ranges library와 Ranges TS 문서를 참고하세요.
관련 글
- C++20 Ranges | begin/end 반복 탈출하고 ranges 알고리즘 쓰기
- C++ 커스텀 Range 작성 | range 개념을 만족하는 타입 만들기 [#25-3]
- C++20 Modules |
- C++ 기존 프로젝트를 Module로 전환 | 단계별 마이그레이션 [#24-2]
- C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]