[모던 C++ #10] 직접 구현한 알고리즘 대신 C++20 Ranges와 병렬 알고리즘으로!

과거 C++98/03 시절에는 표준 라이브러리가 제공하는 알고리즘도 제한적이었고, 특정 요구사항을 만족하기 위해 개발자가 직접 알고리즘을 구현하거나, 반복자 기반 STL 알고리즘을 복잡하게 조합해야 하는 경우가 많았습니다. 또한 병렬 처리나 고성능 연산을 위해서는 플랫폼별 스레드 관리나 OpenMP, TBB 같은 서드파티 라이브러리에 의존해야 했습니다.

etc-image-0

모던 C++20에서는 Ranges 라이브러리를 통해 알고리즘 파이프라인을 선언적으로 구성할 수 있으며, 병렬 알고리즘 지원을 통해 표준 라이브러리 레벨에서 병렬화를 쉽게 도입할 수 있습니다. 이렇게 하면 코드 가독성과 유지보수성이 대폭 개선되며, 성능 향상을 위한 병렬화도 간단히 적용할 수 있습니다.

관련 참고 자료:

과거: 직접 구현한 알고리즘과 번거로운 반복자 사용

C++98/03에서는 STL 알고리즘도 유용했지만, 필터링, 변환, 슬라이싱 등의 고급 연산을 위해서는 개발자가 직접 반복자를 조합하거나, 별도의 알고리즘을 구현해야 했습니다. 코드가 장황해지고, 유지보수가 어려워집니다.

#include <vector>
#include <algorithm>
#include <iostream>

int main() {
    std::vector<int> vec = {1,2,3,4,5,6,7,8};

    // 짝수만 골라내어 제곱하는 로직을 직접 구현 (과거 스타일)
    std::vector<int> even_squares;
    for (int v : vec) {
        if (v % 2 == 0) {
            even_squares.push_back(v * v);
        }
    }

    for (int v : even_squares) {
        std::cout << v << " ";
    }
    std::cout << "\n";
    return 0;
}

이 예제처럼 단순한 필터링, 변환도 별도의 루프가 필요하고, 요구사항이 복잡해질수록 코드가 늘어납니다. 또한 병렬 처리가 필요하다면 스레드를 직접 관리하거나, 별도의 라이브러리를 사용해야 했습니다.

현재: Ranges와 병렬 알고리즘

Ranges를 통한 선언적 알고리즘 파이프라인

C++20 Ranges를 사용하면 필터링, 변환, 슬라이싱 등을 파이프(|) 연산자로 연결하여 선언적이고 간결하게 표현할 수 있습니다.

#include <vector>
#include <iostream>
#include <ranges>

int main() {
    std::vector<int> vec = {1,2,3,4,5,6,7,8};

    // 짝수만 필터한 뒤 제곱한 결과를 바로 범위 기반 for로 순회
    for (int v : vec 
                 | std::views::filter([](int x){ return x % 2 == 0; }) 
                 | std::views::transform([](int x){ return x * x; })) {
        std::cout << v << " ";
    }
    std::cout << "\n";
    return 0;
}

이 예제는 과거 코드에 비해 훨씬 직관적이며, "짝수를 필터하고 제곱한다"는 의도가 직접적으로 드러납니다. 또한 중간 결과를 별도 컨테이너에 저장할 필요 없이 파이프라인으로 처리할 수 있습니다.

병렬 알고리즘으로 성능 향상

C++17부터 도입된 병렬 알고리즘(std::transform, std::reduce 등)은 실행 정책(Execution Policy)을 통해 간단히 병렬화를 적용할 수 있습니다. 예를 들어, 대규모 벡터에 대한 연산을 병렬 처리하여 성능을 높일 수 있습니다.

#include <vector>
#include <iostream>
#include <algorithm>
#include <execution>

int main() {
    std::vector<int> large_vec(1000000, 1);

    // 병렬로 모든 원소를 제곱
    std::transform(std::execution::par_unseq,
                   large_vec.begin(), large_vec.end(),
                   large_vec.begin(),
                   [](int x) { return x * x; });

    std::cout << "Processing done\n";
    return 0;
}

std::execution::par_unseq 같은 실행 정책을 지정하면, 구현체에 따라 자동으로 병렬화나 벡터화가 적용되어 성능 향상을 기대할 수 있습니다. 이를 통해 개발자는 병렬 알고리즘 세부사항에 얽매이지 않고도, 표준화된 방식으로 고성능 코드를 작성할 수 있습니다.

왜 이런 변화가 필요한가?

  1. 가독성과 유지보수성 개선
    Ranges를 통해 알고리즘을 파이프라인 형태로 구성하면, 코드가 선언적이고 명확하게 바뀝니다. 이는 코드 리뷰나 협업 시에도 의도를 쉽게 전달할 수 있으며, 유지보수성을 크게 높입니다.
  2. 성능 최적화 용이
    병렬 알고리즘과 실행 정책을 활용하면, 개발자가 저수준 스레드 관리나 SIMD 최적화에 직접 관여하지 않고도 성능을 향상시킬 수 있습니다. 이는 코드 복잡도를 줄이면서도 성능을 확보하는 데 도움을 줍니다.
  3. 표준화된 솔루션 활용
    모던 C++은 언어와 라이브러리 수준에서 점점 더 많은 기능을 제공하고 있습니다. Ranges와 병렬 알고리즘을 사용하면, 서드파티 의존성 없이도 높은 수준의 추상화와 성능 최적화를 동시에 달성할 수 있습니다.
반응형