본문으로 건너뛰기

안동민 개발노트

본문 시작

C++ 20 기능 소개

모듈·코루틴·범위·컨셉과 컴파일 시간 기능을 훑으며 C++20 이후 언어가 해결하려는 설계 문제를 파악합니다.

C++는 3년마다 새로운 표준을 발표하며 끊임없이 발전하고 있습니다.

C++11, C++14, C++17에 이어, 2020년에는 또 한 번 언어와 표준 라이브러리에 대대적인 변화를 가져온 C++20 표준이 발표되었습니다.

C++20은 C++의 빅 3라 불리는 모듈(Modules), 코루틴(Coroutines), 범위(Ranges)를 포함하며, 동시성(Concurrency)과 메타프로그래밍(Metaprogramming)에도 많은 개선을 가져왔습니다.

C++23에서는 이 흐름을 이어 std::expected, std::print 같은 실무 친화 기능이 더해져 도입 장벽이 한층 낮아졌습니다.

이 장에서는 C++20의 주요 기능들을 간략하게 소개하고, 각 기능이 C++ 프로그래밍에 어떤 영향을 미치는지 정리합니다.

이 내용들은 당장 모든 프로젝트에 쓰이지는 않지만, 현대 C++의 변화 방향과 이후 심화 학습 주제를 파악하는 데 기준이 됩니다.

C++20의 변화는 문법 자체보다 빌드 경계, 비동기 흐름, 데이터 처리, 타입 제약을 어떻게 분리해서 다루는지로 먼저 잡으면 이해가 쉽습니다.

C++20은 네 경계를 분리해 코드를 읽기 쉽게 만든다

기능 이름을 외우기보다, 어떤 경계를 정리하는 도구인지로 묶으면 C++20의 큰 그림이 잡힌다.

  1. modules

    헤더 의존과 빌드 경계를 분리

  2. coroutine

    멈춤과 재개를 함수 안에 표현

  3. ranges

    데이터 변환을 파이프라인으로 읽음

  4. concepts

    템플릿 요구 조건을 이름 붙임

문제먼저 볼 기능읽히는 기준
빌드가 복잡함modules공개 인터페이스와 구현 분리
대기 흐름이 많음coroutine상태 기계 대신 함수 흐름 유지
템플릿 오류가 난해함concepts요구 조건을 선언부에서 확인

모듈 (Modules)

C++ 컴파일 시간의 가장 큰 걸림돌 중 하나는 #include 전처리기 지시문이었습니다.

#include는 단순히 파일 내용을 복사-붙여넣기 하는 방식으로 동작하며, 이는 대규모 프로젝트에서 중복 컴파일을 야기하고 컴파일 시간을 매우 길게 만들었습니다.

모듈은 이러한 #include 시스템을 대체하기 위해 도입된 기능입니다.

모듈은 컴파일된 바이너리 형식으로 제공되어 컴파일러가 소스 코드를 매번 파싱할 필요 없이 빠르게 가져다 쓸 수 있도록 합니다.

특징
  • 빠른 컴파일: 소스 파일을 한 번만 컴파일하고 그 결과를 재사용하여 전체 빌드 시간을 단축합니다.
  • 매크로 문제 해결: 매크로 오염(macro pollution) 문제로부터 자유롭습니다. 모듈은 매크로를 외부에 노출하지 않습니다.
  • 선언 순서 독립성: 헤더 파일처럼 선언 순서에 제약을 받지 않습니다.
  • 더 나은 격리: 모듈 내에서 선언된 이름들은 명시적으로 export하지 않는 한 외부에 노출되지 않습니다.
모듈 인터페이스 기본 형식
// math_module.ixx (모듈 인터페이스 파일)
export module MathModule; // 모듈 선언

export namespace Math { // export를 통해 외부에 노출
    export double add(double a, double b) {
        return a + b;
    }
    export double subtract(double a, double b); // 선언만 export
}

// math_module.cpp (모듈 구현 파일)
module MathModule; // 해당 파일이 MathModule의 일부임을 선언

namespace Math {
    double subtract(double a, double b) { // 위에서 선언된 함수 구현
        return a - b;
    }
}
모듈 사용 예시
// main.cpp
import MathModule; // 모듈 가져오기

int main() {
    double sum = Math::add(10.0, 5.0);
    double diff = Math::subtract(10.0, 5.0);
    // ...
}

모듈은 헤더 의존성을 줄이고 번역 단위의 공개 경계를 명시하는 방향으로 C++ 빌드 구조를 바꿉니다.

실무 적용 판단 기준 (Modules)
  • 도입 가치가 큰 경우: 공통 라이브러리가 크고 전체 빌드 시간이 병목일 때.
  • 권장 도입 순서: 변경이 잦지 않은 코어 라이브러리부터 모듈화하고, 애플리케이션 경계는 #include와 혼용해 점진 전환.
  • 사전 점검: 컴파일러/IDE/CMake의 모듈 지원 수준과 CI 캐시 전략을 함께 검증해야 합니다.

코루틴 (Coroutines)

코루틴은 함수 호출이 한 번에 끝난다는 가정을 깨고, 중단과 재개 사이에 프레임과 수명 경계를 남깁니다.

코루틴은 함수 실행을 멈췄다가 같은 자리에서 다시 이어간다

코루틴은 프레임을 남겨 대기, 생성, 종료 흐름을 함수 안에 유지한다.

  1. 1
    1 호출

    호출 코루틴 프레임 생성

  2. 2
    2 중단

    중단 co_await/co_yield 지점 저장

  3. 3
    3 재개

    재개 저장된 자리에서 계속 실행

  4. 4
    4 종료

    종료 결과 전달과 프레임 정리

구분판단 기준다음 행동
일반 함수호출 후 반환으로 끝남스택 프레임 소멸
코루틴프레임을 남겨 대기 가능상태를 보존하고 재개
주의수명과 소유권이 길어짐핸들 정리와 예외 경로 확인

코루틴은 함수 실행을 일시 중지하고 나중에 다시 시작할 수 있게 해주는 기능입니다.

이는 비동기 프로그래밍, 이벤트 기반 프로그래밍, 제너레이터(Generator) 구현 등에 활용될 수 있습니다.

기존의 스레드(Thread) 기반 동시성과는 달리, 코루틴은 협동적(cooperative) 멀티태스킹 방식으로 동작하며, 컨텍스트 스위칭 비용이 매우 낮습니다.

특징
  • 함수 일시 중지 및 재개: co_await, co_yield, co_return 키워드를 사용하여 함수 실행을 제어합니다.
  • 비동기 I/O: 복잡한 콜백(callback) 지옥을 피하고 동기 코드처럼 자연스럽게 비동기 작업을 작성할 수 있습니다.
  • 제너레이터 구현: 값의 시퀀스를 생성하고 필요할 때마다 다음 값을 제공하는 함수를 쉽게 구현할 수 있습니다.
기본 키워드
  • co_await: 비동기 작업의 완료를 기다립니다.
  • co_yield: 값을 반환하고 실행을 일시 중지합니다 (제너레이터).
  • co_return: 코루틴의 종료 값을 반환합니다.
코루틴을 이용한 제너레이터 예시
// 이 예시를 직접 컴파일하려면 코루틴 라이브러리 및 복잡한 설정이 필요합니다.
// 여기서는 개념 이해를 돕기 위한 유사 코드입니다.
#include <iostream>
#include <coroutine> // 실제 사용시 필요한 헤더

// int를 생성하는 간단한 제너레이터 (코루틴)
struct IntGenerator {
    struct promise_type {
        int value;
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        IntGenerator get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }
        void unhandled_exception() { std::terminate(); }
        void return_value(int v) { value = v; } // co_return int;
        std::suspend_always yield_value(int v) { // co_yield int;
            value = v;
            return {};
        }
    };
    std::coroutine_handle<promise_type> handle;
    int next() {
        handle.resume();
        return handle.promise().value;
    }
    bool done() { return handle.done(); }
};

IntGenerator generate_numbers() {
    for (int i = 0; i < 5; ++i) {
        co_yield i; // 값을 반환하고 일시 중지
    }
    co_return 100; // 최종 반환 값
}

int main() {
    auto gen = generate_numbers();
    while (!gen.done()) {
        std::cout << "Generated: " << gen.next() << std::endl;
    }
    std::cout << "Done generating.\n";
    return 0;
}

코루틴은 C++에서 비동기 처리와 이벤트 기반 흐름을 함수 단위로 표현할 수 있게 하는 기능입니다.

실무 적용 판단 기준 (Coroutines)
  • 도입 가치가 큰 경우: 네트워크/파일 I/O 중심 서비스처럼 대기 시간이 긴 작업이 많은 경우.
  • 설계 우선 항목: 취소(cancellation), 타임아웃, 에러 전파 정책을 먼저 정하고 co_await 체인에 일관되게 반영.
  • 주의점: 코루틴 자체가 성능을 자동 보장하지 않으므로, 런타임 스케줄링/컨텍스트 전환 비용을 프로파일링으로 검증해야 합니다.

범위 (Ranges)

STL 알고리즘은 강력하지만, begin()end() 반복자를 항상 명시해야 하고, 여러 알고리즘을 체인처럼 연결할 때 중간 결과가 복잡해지며, 컨테이너를 변형하는 알고리즘은 원본 데이터를 손상시킬 수 있다는 단점이 있었습니다.

범위(Ranges)라이브러리는 이러한 STL 알고리즘의 문제점을 해결하고, 더욱 함수형 프로그래밍 스타일에 가까운 방식으로 데이터를 처리할 수 있게 해줍니다.

특징
  • begin()/end() 쌍 불필요: 알고리즘에 직접 컨테이너를 전달할 수 있습니다.
  • 파이프 연산자 |: 여러 알고리즘을 파이프라인 형태로 연결하여 데이터 흐름을 명확하게 표현할 수 있습니다.
  • 뷰(Views): 원본 데이터를 복사하지 않고, 데이터를 필터링하거나 변환하는 를 생성하여 효율성을 높입니다.
범위 라이브러리 사용 예시
#include <iostream>
#include <vector>
#include <algorithm> // for std::sort, std::for_each (traditional)
#include <ranges>    // C++20 Ranges (requires C++20 compiler)

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

    // 전통적인 방식:
    // std::sort(numbers.begin(), numbers.end());
    // for (int n : numbers) { std::cout << n << " "; }

    std::cout << "--- Ranges: Sorting and Printing ---\n";
    // 범위 기반 정렬 (numbers 전체에 적용)
    std::ranges::sort(numbers);
    // 범위 기반 for 루프 (numbers 전체 출력)
    for (int n : numbers) {
        std::cout << n << " ";
    }
    std::cout << std::endl; // 출력: 1 2 3 4 5 6 7 8 9

    std::cout << "\n--- Ranges: Filtering and Transforming with Views ---\n";
    // 파이프 연산자 '|'를 사용하여 체인 형태로 표현
    // 짝수만 필터링하고, 각 요소에 10을 더한 후 출력
    for (int n : numbers | std::views::filter([](int x){ return x % 2 == 0; })
                         | std::views::transform([](int x){ return x + 10; })) {
        std::cout << n << " ";
    }
    std::cout << std::endl; // 출력: 12 14 16 18

    // 원본 numbers 벡터는 변경되지 않았음을 확인
    std::cout << "Original numbers (unchanged): ";
    for (int n : numbers) {
        std::cout << n << " ";
    }
    std::cout << std::endl; // 출력: 1 2 3 4 5 6 7 8 9

    return 0;
}

범위 라이브러리는 뷰, 파이프라인, 지연 평가를 조합해 데이터 처리 흐름을 선언적으로 표현합니다.

Ranges는 원본을 바로 바꾸는 알고리즘 호출보다, 필요한 변환을 파이프라인으로 겹쳐 읽는 데 초점이 있습니다.

Ranges는 컨테이너를 데이터 흐름으로 읽게 한다

전체 범위에 필터와 변환 뷰를 겹쳐 원본을 바꾸지 않는 파이프라인을 만든다.

  1. 1
    source

    원본 범위를 그대로 둔다

  2. 2
    filter

    조건에 맞는 원소만 통과

  3. 3
    transform

    필요한 형태로 지연 변환

  4. 4
    consume

    순회하는 순간 계산

구분판단 기준다음 행동
기존 방식begin/end와 중간 컨테이너 반복범위 경계가 흩어짐
ranges뷰를 겹쳐 데이터 흐름 표현원본 변경 없이 파이프라인 구성
주의lazy evaluation과 수명원본 범위가 살아 있는지 확인

컨셉 (Concepts)

컨셉 (Concepts)은 템플릿 매개변수에 대한 제약 조건(constraints)을 명시적으로 표현할 수 있게 해주는 기능입니다.

기존에는 템플릿 인자의 요구 사항을 주석으로 설명하거나 컴파일 오류 메시지를 통해서만 알 수 있었습니다.

컨셉은 이러한 요구 사항을 언어 레벨에서 정의하고 검증할 수 있게 하여, 템플릿 메타프로그래밍의 가독성과 유용성을 크게 향상시킵니다.

특징
  • 개선된 컴파일 오류 메시지: 템플릿 사용 시 요구 사항을 충족하지 못하면 명확하고 이해하기 쉬운 오류 메시지를 제공합니다.
  • 더 나은 인터페이스: 템플릿이 어떤 타입의 인자를 기대하는지 명확하게 명시할 수 있습니다.
  • 오버로드 해결 개선: 컨셉을 통해 템플릿 함수의 오버로드를 보다 정교하게 제어할 수 있습니다.
컨셉 기본 형식
template<typename T>
concept MyConcept = requires(T val) {
    { val.some_method() } -> std::same_as<int>; // val.some_method()가 int를 반환해야 함
    { val + val } -> std::integral; // val + val 결과가 정수 타입이어야 함
    // ...
};

template<MyConcept T> // MyConcept을 만족하는 타입 T만 허용
void func(T arg) {
    // ...
}
컨셉을 이용한 템플릿 함수 예시
#include <iostream>
#include <string>
#include <vector>
#include <concepts> // C++20 concepts

// '정수' 컨셉: std::integral은 C++ 표준 컨셉 중 하나
// template<std::integral T>
// void print_square(T n) {
//     std::cout << n * n << std::endl;
// }

// 'Addable' 컨셉: 두 개의 같은 타입이 더해질 수 있고, 그 결과가 정수여야 함
template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::integral; // a + b 의 결과가 std::integral 컨셉을 만족해야 함
};

template<Addable T> // Addable 컨셉을 만족하는 타입 T만 허용
void print_sum_and_type(T a, T b) {
    std::cout << "Sum: " << a + b << std::endl;
    // std::cout << "Type of sum: " << typeid(a + b).name() << std::endl; // typeid는 런타임 정보
}

struct MyStruct {
    int value;
    MyStruct operator+(const MyStruct& other) const {
        return {value + other.value};
    }
};

// MyStruct에 대한 Addable 컨셉의 요구사항을 충족시키기 위해
// MyStruct + MyStruct의 결과가 std::integral이 되도록 특수화하거나
// MyStruct가 std::integral을 만족하도록 만들 수는 없음 (객체이므로)
// 즉, MyStruct는 현재 Addable 개념을 만족하지 않음.
// 만약 Addable을 '더해질 수 있는' 정도로만 정의한다면:
template<typename T>
concept SimpleAddable = requires(T a, T b) {
    { a + b }; // 더하기 연산이 유효하기만 하면 됨
};

template<SimpleAddable T>
void print_sum_simple(T a, T b) {
    std::cout << "Simple Sum: " << (a + b).value << std::endl;
}

int main() {
    print_sum_and_type(10, 20); // int는 Addable
    print_sum_and_type(10LL, 20LL); // long long도 Addable

    // print_sum_and_type(10.5, 20.5); // 컴파일 오류: double은 Addable을 만족하지 않음 (std::integral이 아님)
    // print_sum_and_type("hello", "world"); // 컴파일 오류: 문자열 + 문자열은 정수 결과가 아님

    MyStruct s1{10}, s2{20};
    // print_sum_and_type(s1, s2); // 컴파일 오류: MyStruct는 Addable을 만족하지 않음

    print_sum_simple(s1, s2); // MyStruct는 SimpleAddable을 만족

    return 0;
}

컨셉은 템플릿 메타프로그래밍을 훨씬 더 안전하고, 읽기 쉽고, 디버그하기 쉽게 만듭니다.

실무 적용 판단 기준 (Concepts)
  • 공개 API 우선: 라이브러리 public 템플릿 함수/클래스부터 적용하면 오용을 빠르게 차단할 수 있습니다.
  • 과도한 제약 회피: 내부 구현 템플릿은 최소 제약으로 두고, 외부 경계면에서만 강한 제약을 둡니다.
  • 팀 생산성: SFINAE 중심 오류보다 메시지가 명확해 코드 리뷰/온보딩 비용이 줄어듭니다.

컴파일 타임 분기와 초기화 제어 (if constexpr, consteval, constinit)

C++17/20에서는 컴파일 타임에 더 많은 결정을 내리는 도구가 크게 강화되었습니다.

특히 템플릿 코드 품질을 높일 때 아래 세 기능의 역할을 구분해 이해하는 것이 중요합니다.

if constexpr (C++17)

컴파일 타임 조건 분기입니다.

조건이 거짓인 분기 코드는 인스턴스화 대상에서 제외되므로, 템플릿 분기 코드를 훨씬 안전하게 작성할 수 있습니다.

if constexpr로 타입별 분기
#include <iostream>
#include <type_traits>
#include <string>

template<typename T>
void print_value(const T& value) {
    if constexpr (std::is_arithmetic_v<T>) {
        std::cout << "[number] " << value << "\n";
    } else if constexpr (std::is_same_v<T, std::string>) {
        std::cout << "[string] " << value << "\n";
    } else {
        // 사용자 정의 타입은 보통 operator<<가 필요
        std::cout << "[custom] " << value << "\n";
    }
}

위 예시의 마지막 분기는 사용자 정의 타입의 출력 연산자 설계와 연결됩니다.

관련 시그니처/패턴은 ‘연산자 오버로딩 기초’, ‘프렌드 함수와 프렌드 클래스’에서 함께 확인하세요.

consteval (C++20)

즉시 함수(immediate function)입니다.

호출 결과가 반드시 컴파일 타임에 계산되어야 합니다.

런타임 값으로 호출하려고 하면 컴파일 오류가 발생합니다.

consteval 즉시 함수
consteval int square(int x) {
    return x * x;
}

constexpr int a = square(5); // OK (컴파일 타임)
// int n = 7;
// int b = square(n);         // 컴파일 오류: n은 런타임 값
constinit (C++20)

정적 저장 기간 객체가 상수 초기화되도록 강제합니다.

constexpr처럼 값이 상수 식이어야 한다까지 요구하지는 않지만, 초기화 시점 안전성을 보장하는 데 유용합니다.

constinit으로 정적 초기화 보장
#include <iostream>

constinit int g_retry_limit = 3; // 정적 초기화 보장

int main() {
    std::cout << g_retry_limit << "\n";
}
선택 기준 요약
  • 템플릿 분기: if constexpr
  • 반드시 컴파일 타임 계산 함수: consteval
  • 전역/정적 객체 초기화 안정성: constinit

아래 그림은 C++20 기능을 문제 종류별로 나눠 어떤 도구를 먼저 선택할지 정리합니다.

문제의 종류가 기능 선택을 결정한다

Concepts, if constexpr, consteval, constinit은 모두 컴파일 타임을 다루지만 질문이 다르다.

  1. concepts

    타입이 조건을 만족하는가

  2. if constexpr

    템플릿 안에서 분기를 지울 것인가

  3. consteval

    반드시 컴파일 타임에 계산할 것인가

  4. constinit

    정적 초기화를 보장할 것인가

질문선택효과
타입 요구를 설명?concepts오류 지점을 선언부로 올림
분기 코드를 제거?if constexpr맞지 않는 가지는 인스턴스화하지 않음
항상 컴파일 타임 계산?consteval런타임 호출 자체를 허용하지 않음
초기화 순서가 위험?constinit런타임 초기화 의존을 차단

기타 C++20 주요 기능들

  • <=> (Three-way comparison operator / Spaceship operator)
    • 객체 간의 관계(= =, <>, >, <)를 한 번의 연산으로 반환하는 연산자입니다.
    • 복잡한 operator==, operator!=, operator<, operator>, operator<=, operator>=를 한 번의 구현으로 자동 생성할 수 있게 합니다.
  • constexpr 확장
    • C++11부터 컴파일 시간 계산을 위한 constexpr 키워드가 도입되었지만, C++20에서는 더욱 강력해져 new, delete, try-catch, 가상 함수 등도 constexpr 함수 내에서 사용할 수 있게 되었습니다.
    • 이를 통해 컴파일 타임에 더 많은 로직을 실행하여 런타임 성능을 개선할 수 있습니다.
  • [[likely]], [[unlikely]] 속성
    • 코드에서 특정 분기(branch)가 발생할 확률이 높거나 낮음을 컴파일러에게 힌트를 줍니다.
    • 컴파일러가 이 힌트를 사용하여 CPU의 분기 예측(branch prediction)을 최적화하고 성능을 향상시키는 데 도움을 줄 수 있습니다.
  • 즉시 초기화 람다 (Immediate Invoked Lambda Expressions, IILE)
    • 람다를 정의와 동시에 호출하는 문법입니다.
    • 특정 스코프에서만 필요한 일회성 로직을 간결하게 작성할 때 유용합니다.
  • std::jthread
    • std::thread의 개선 버전으로, 스레드가 생성될 때 자동으로 join 또는 detach를 설정할 수 있는 join_on_destruction 속성을 가집니다.
    • std::jthread 객체가 스코프를 벗어나 소멸될 때 자동으로 join을 호출하여 자원 누수를 방지합니다.

C++23로 이어지는 실전 확장

C++20 기능을 도입한 코드베이스는 C++23 기능을 결합할 때 운영 안정성이 더 좋아집니다.

  • std::expected: 예외 대신 명시적인 성공/실패 타입을 사용해 API 오류 흐름을 타입으로 표현할 수 있습니다.
  • std::print: 포맷 기반 출력으로 로그/디버깅 코드를 간결하게 유지할 수 있습니다.
  • 권장 결합 순서: Concepts로 템플릿 경계 고정 → Coroutines로 I/O 흐름 단순화 → expected로 오류 경로 표준화.

C++20 기능은 modules, coroutines, ranges, concepts가 빌드·비동기·제약 표현을 어떻게 바꾸는지로 묶어 볼 수 있습니다.

C++23 기능은 C++20의 경계 정리 위에 얹을 때 효과가 크므로, 도입 순서를 작은 검증 단위로 나누는 편이 안전합니다.

새 표준은 작은 검증 단위로 도입한다

표준 기능은 한꺼번에 바꾸지 말고, 실패해도 되돌리기 쉬운 경계부터 적용한다.

  1. 1. concepts

    concepts 템플릿 계약을 먼저 좁힌다

  2. 2. ranges

    ranges 지역적인 데이터 처리부터 바꾼다

  3. 3. expected

    expected 예외 대신 명시 오류가 필요한 API에 적용

  4. 4. modules

    modules 빌드 경계는 팀 단위로 검증한다

도입 단위확인 기준멈출 신호
함수/파일ranges, if constexpr처럼 범위가 작음가독성이 오히려 떨어짐
API 경계expected로 오류 계약이 명확해짐호출부 변경이 지나치게 큼
빌드 경계modules가 의존을 줄임툴체인·CI 지원이 불안정