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 세 벡터를 차례로 만들고 단계마다 요소를 복사합니다. 최종적으로 10개만 필요한데도 모든 에러 로그의 타임스탬프를 파싱하며, 로그가 많을수록 중간 메모리도 그만큼 커집니다. 같은 일을 view 파이프라인으로 쓰면 다음과 같습니다(std::ranges::to는 C++23).

// ✅ View: 중간 컨테이너 없이 한 번의 순회로 처리
namespace vw = std::ranges::views;
auto result = all_logs
    | vw::filter( [](const auto& e) { return e.level == LogLevel::Error; })
    | vw::transform(parse_timestamp)
    | vw::take(10)
    | std::ranges::to<std::vector>();  // 필요할 때만 구체화

filter → transform → take가 요소 하나씩 차례로 흐르므로 중간 벡터가 없고, take(10)이 10개를 채우는 순간 순회가 멈춰 나머지 로그는 파싱하지 않습니다. 다만 view는 보통 원본 컨테이너를 참조하므로 원본의 수명에 묶입니다(뒤의 dangling 절 참고).

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);  // 50개 미만이면 범위 밖 접근(UB)

active, domains 두 벡터가 메모리를 차지하고, 50개만 필요한데 나머지 도메인까지 모두 파싱합니다. 결과가 50개보다 적으면 마지막 줄은 범위 밖을 읽습니다. view의 take(50)은 개수가 모자라도 있는 만큼만 돌려줍니다.

// ✅ View: 50개 찾는 순간 즉시 중단, 중간 메모리 없음
auto top_domains = users
    | vw::filter( [](const auto& u) { return u.is_active; })
    | vw::transform( [](const auto& u) { return extract_domain(u.email); })
    | vw::take(50)
    | std::ranges::to<std::vector>();

대용량 파일 스트리밍

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: 복사 없이 보는 범위

복사 없이 “보는” 범위

view는 이동(복사 가능하다면 복사도)이 O(1)인 range입니다. 원본을 그대로 두고 그 위에 겹쳐 보는 레이어라고 생각하면 됩니다.

#include <ranges>
namespace vw = std::ranges::views;
std::vector<int> v = {1, 2, 3, 4, 5};
auto even = v | vw::filter( [](int x) { return x % 2 == 0; });
// even은 view. 순회할 때마다 필터 적용. 복사 없음.

대부분의 view는 원본 데이터를 소유하지 않고(예외로 rvalue 컨테이너를 감싸는 owning_view가 있음), view 객체를 복사해도 원본 요소는 복사되지 않으며, 순회할 때만 연산을 수행합니다.

view vs range

구분rangeview
소유소유할 수 있음 (vector 등)Non-owning
복사 비용O(n) 가능O(1)
재순회대부분 가능원본에 의존
예시vector, list, mapfilter_view, transform_view

파이프 연산자로 filter, transform, take 조합하기

range | view

| 연산자로 range와 view를 이어 붙이면, 왼쪽 범위에 오른쪽 view가 순서대로 적용됩니다. result는 view이므로 아직 연산이 실행되지 않은 상태입니다. for로 순회하거나 std::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( [](int x) { 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( [](int x) { 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( [](int x) { 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( [](int x) { 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( [](int x) { return x > 2; })
        | vw::transform( [](int x) { return x * 2; })
        | vw::take(2);
    for (int x : result) {
        std::cout << x << " ";  // 6 8 (3*2, 4*2)
    }
}

동작 순서:

  1. v에서 3, 4, 5, … (2보다 큰 것)
  2. 각각 2배 → 6, 8, 10, …
  3. 앞 2개만 → 6, 8
  4. 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( [](int x) { return x % 2 == 0; })
        | vw::transform( [](int x) { 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";
}

지연 평가와 동작 원리

순회 시점에만 계산

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( [](int x) { 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( [](int x) { return x % 2 == 0; })
    | std::ranges::to<std::vector>();

C++20:

auto filtered = v | vw::filter( [](int x) { return x % 2 == 0; });
std::vector<int> vec(filtered.begin(), filtered.end());

자주 쓰는 뷰 (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 <string>
#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) { /* ... */ }

dangling 참조, materialization 시점, view 반환 실수

실수 1: Dangling Reference (매달린 참조)

view가 원본을 참조하는 경우, 원본이 소멸한 뒤 view를 사용하면 undefined behavior입니다. 흔히 get_range() | vw::filter(...)처럼 임시 vector에 바로 파이프하는 코드를 위험하다고 소개하지만, 여기에는 버전 차이가 있습니다. 초기 C++20 구현에서는 rvalue 컨테이너를 view로 감쌀 수 없어 컴파일 에러였고, 이후 C++20 결함 보고로 채택된 P2415가 적용된 컴파일러(GCC 12, Clang 16, MSVC 2022 17.2 이후 등)에서는 임시 vector를 owning_view로 옮겨 담으므로 안전합니다. 실제로 dangling이 생기는 것은 view가 lvalue를 참조하는 경우입니다.

std::vector<int> make();
// ❌ 위험: 지역 변수(lvalue)를 참조하는 view가 스코프 밖으로 나감
auto make_view() {
    std::vector<int> data = make();
    return data | vw::filter([](int x) { return x > 0; });  // ref_view → data 소멸 후 dangling
}
// ❌ 위험: 임시 객체의 멤버를 참조
auto bad = get_config().items | vw::filter(pred);  // get_config() 임시가 이 줄 끝에서 소멸

원본의 수명을 확실히 하거나, 먼저 구체화합니다.

// ✅ 방법 1: 원본을 변수에 저장
auto data = get_range();
auto good = data | vw::filter( [](int x) { return x > 0; });
// ✅ 방법 2: 즉시 구체화
auto safe = get_range()
    | vw::filter( [](int x) { 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 람다에서 참조 캡처

조건자 람다가 바깥 지역 변수를 참조로 캡처하면, 그 변수가 view보다 먼저 사라질 때 위험합니다.

// ❌ 위험: threshold는 함수가 끝나면 사라지는데 view는 반환됨
auto filtered_view(const std::vector<int>& v) {
    int threshold = 10;
    return v | vw::filter([&threshold](int x) { return x > threshold; });
}
// ✅ 안전: 값으로 캡처
auto filtered_view_ok(const std::vector<int>& v) {
    int threshold = 10;
    return v | vw::filter([threshold](int x) { return x > threshold; });
}

실수 5: view를 반환하는 함수

함수가 view를 반환할 때, 원본 range도 함께 반환되거나 수명이 보장되어야 합니다.

// ❌ 위험: data는 지역 변수, 함수 반환 시 소멸
auto get_filtered() {
    std::vector<int> data = {1, 2, 3};
    return data | vw::filter( [](int x) { return x > 1; });
}
// ✅ 안전 1: 구체화해서 반환
auto get_filtered() {
    std::vector<int> data = {1, 2, 3};
    return data | vw::filter( [](int x) { return x > 1; })
        | std::ranges::to<std::vector>();
}
// ✅ 안전 2: 원본을 view 안으로 옮김 (P2415 적용 컴파일러에서 owning_view)
auto get_filtered_view() {
    std::vector<int> data = {1, 2, 3};
    return std::move(data) | vw::filter( [](int x) { return x > 1; });
}

실수 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( [](int x) { 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);
// ❌ 주의: 계산 결과가 음수가 될 수 있는 값을 그대로 넘김
int limit = compute_limit();  // -1이 나올 수 있다면
auto bad2 = v | vw::take(limit);  // -1은 difference_type(-1)로 전달되어 UB
// ✅ 안전: 0 이상의 값만 사용
auto good = v | vw::take(std::max(0, limit));

실수 10: 파이프라인 순서로 인한 비효율

filter를 transform 뒤에 두면, 변환이 먼저 모든 요소에 적용된 뒤 필터링됩니다. 비용이 큰 변환이면 낭비입니다.

// ❌ 비효율: 100만 개 모두 sqrt 적용 후 필터
auto bad = big_range
    | vw::transform( [](int x) { return std::sqrt(x); })  // 비용 큼
    | vw::filter( [](double x) { return x > 10.0; });
// ✅ 효율: 먼저 필터 후 변환 (조건 만족하는 것만 sqrt)
auto good = big_range
    | vw::filter( [](int x) { return x > 100; })  // 정수 비교로 먼저 걸러냄
    | vw::transform( [](int x) { return std::sqrt(x); });

filter 먼저, 조기 종료, 재사용 시 구체화

filter를 transform보다 먼저

비용이 큰 변환은 필터로 먼저 범위를 좁힌 뒤 적용하세요.

// ✅ 권장: 필터 → 변환
auto result = data
    | vw::filter(cheap_predicate)
    | vw::transform(expensive_function);

take/drop으로 조기 종료 활용

“처음 N개만 필요”할 때는 반드시 take를 파이프라인 끝에 두세요.

// ✅ N개 찾는 순간 즉시 중단
auto first_10 = huge_range | vw::filter(pred) | vw::take(10);

재사용 시 구체화

파이프라인 결과를 두 번 이상 순회할 경우 to<vector>로 구체화하세요.

// ✅ 여러 번 순회할 때
auto vec = range | vw::filter(pred) | std::ranges::to<std::vector>();
process(vec);
aggregate(vec);

함수 반환 시 수명 확보

view를 반환하는 함수는 구체화해서 반환하는 것이 가장 안전합니다.

// ✅ 권장: 구체화해서 반환
std::vector<int> get_filtered() {
    auto data = load_data();
    return data | vw::filter(pred) | std::ranges::to<std::vector>();
}

파이프라인 가독성

긴 파이프라인은 한 단계씩 줄바꿈하면 가독성이 좋아집니다.

auto result = data
    | vw::filter( [](const auto& x) { return x.valid(); })
    | vw::transform( [](const auto& x) { return x.to_parsed(); })
    | vw::take(100)
    | std::ranges::to<std::vector>();

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>();

성능 비교: Views vs Eager 평가

메모리 사용량 비교

방식중간 벡터 수추가 메모리
Eager (기존)단계마다 1개각 단계 결과 크기의 합
View 파이프라인0개view 객체 자체(포인터·조건자 수준)
View + to<vector>1개최종 결과 크기만

순회 비용 비교

take(10) 같은 조기 종료가 있을 때:

방식실제 연산 수
Eager전체 필터 + 전체 변환 후 take
View10개 찾을 때까지만 연산 (조기 종료)

벤치마크 예시 (개념)

// 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() + std::min<std::size_t>(10, b.size()));
// View: 10개 찾을 때까지만
auto result = a | vw::filter(pred) | vw::transform(f) | vw::take(10);
std::vector<int> c2(result.begin(), result.end());

take(10)이면 View 방식이 훨씬 적은 요소만 처리합니다. 조건을 만족하는 요소가 뒤에 있으면 차이가 더 커집니다.

언제 Eager가 나을까?

  • 여러 번 순회할 결과가 필요할 때
  • 인덱스 접근 result[i]가 필요할 때
  • 작은 데이터에서 가독성이 더 중요할 때

컴파일 타임 오버헤드

view 파이프라인은 템플릿 중첩으로 인해 컴파일 시간이 늘고, 에러 메시지의 타입 이름이 매우 길어집니다. 런타임 성능은 최적화 빌드에서 손으로 쓴 루프와 비슷한 경우가 많지만, filter 뒤에 reverse나 drop을 붙이는 조합처럼 반복자 연산이 늘어나는 경우에는 느려질 수 있으므로 핫 루프는 측정해 봅니다.


로그 처리, CSV 파싱, 청크 배치 파이프라인

패턴 1: 로그 처리 파이프라인

// 에러/경고만 추출 → 파싱 → 상위 N개
auto critical_logs = log_stream
    | vw::filter( [](const auto& L) {
        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 = [](std::string_view line) {
    return line | vw::split(',')
        | vw::transform( [](auto&& rng) {
            return std::string(rng.begin(), rng.end());
        })
        | std::ranges::to<std::vector>();
};
for (auto r : lines | vw::split('\n')) {
    std::string_view line(r.begin(), r.end());  // subrange는 string_view로 암묵 변환되지 않음
    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: 조건부 파이프라인

view 어댑터를 붙일 때마다 타입이 바뀌므로 cond ? base | vw::reverse : base처럼 삼항 연산자로 고를 수 없고(두 피연산자의 타입이 다름), 변수에 재대입할 수도 없습니다. 분기마다 끝까지 소비하거나, 조건을 조건자 안으로 옮깁니다.

auto base = data | vw::filter(pred);
std::vector<int> result;
if (need_reversed) {
    result = base | vw::reverse | vw::take(limit) | std::ranges::to<std::vector>();
} else {
    result = base | vw::take(limit) | std::ranges::to<std::vector>();
}

패턴 5: 에러 처리와 함께

auto safe_pipeline = [](auto&& rng) {
    return std::forward<decltype(rng)>(rng)
        | vw::filter( [](const auto& x) {
            try { return validate(x); }
            catch (...) { return false; }
        })
        | vw::transform(parse_safe);
};

패턴 6: 스트리밍 처리 (대용량 파일)

// 파일을 한 줄씩 읽으며, 각 줄의 토큰에 파이프라인 적용
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( [](auto&& r) { 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);
        }
    }
}

패턴 7: 두 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";
}

패턴 8: 인덱스와 함께 순회 (enumerate)

C++23의 views::enumerate로 인덱스를 붙입니다. C++20에는 zip도 enumerate도 없으므로 카운터를 직접 둡니다.

// C++23: enumerate
for (auto [i, x] : v | vw::enumerate) {
    std::cout << "[" << i << "] " << x << "\n";
}
// C++20: 수동 카운터
int i = 0;
for (auto x : v) {
    std::cout << "[" << i++ << "] " << x << "\n";
}

패턴 9: 재사용 가능한 파이프라인 팩토리

설정에 따라 다른 파이프라인을 만들 때도 타입이 바뀌는 재대입은 할 수 없으므로, 어댑터 구성은 고정하고 설정값을 조건자와 개수에 넣습니다.

#include <cstddef>
#include <ranges>
#include <vector>
namespace vw = std::ranges::views;
auto make_pipeline(bool filter_positive, std::size_t limit) {
    return [=](auto&& rng) {
        return std::forward<decltype(rng)>(rng)
            | vw::filter([=](int x) { return !filter_positive || x > 0; })
            | vw::take(static_cast<std::ptrdiff_t>(limit));
    };
}
// 사용: lvalue를 넘기면 원본을 참조하는 view가 됨
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}

패턴 10: 에러 허용 파이프라인 (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( [](const auto& opt) { return opt.has_value(); })
    | vw::transform( [](const auto& opt) { return *opt; })
    | std::ranges::to<std::vector>();

처음 N개, 토큰화, 맵 키·값 변환 예제

조건 만족하는 처음 N개

auto firstTwoEvens = v
    | vw::filter( [](int x) { 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( [](const auto& p) { 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

view로 연속 중복을 건너뛰려면 C++23의 vw::chunk_by(std::ranges::equal_to{})로 같은 값끼리 묶은 뒤 각 묶음의 첫 요소만 꺼내는 방법이 있습니다(표준에 adjacent_filter 같은 view는 없습니다).

같이 보면 좋은 글