모던 C++ (C++11~C++20) 핵심 문법 치트시트 | auto·람다
이 글의 핵심
문법 나열로 끝나지 않도록 optional로 파싱 실패를 표현하고 variant로 타입 안전성을 챙기는 실무 사례를 붙였습니다. auto 남용, 람다 캡처로 인한 댕글링, shared_ptr 순환 참조, optional 값 확인 누락처럼 모던 문법을 쓰다 흔히 부딪히는 문제와 기능을 도입할 순서도 함께 담았습니다.
들어가며
모던 C++(C++11 이후)은 auto, 람다, 스마트 포인터, optional, Concepts 등 많은 기능을 도입했습니다. 이 글은 실무에서 정말 매일 쓰는 것들만 압축해서 한눈에 볼 수 있게 정리한 치트시트입니다.
치트시트는 문법을 빨리 찾아보는 데는 좋지만, “왜 이렇게 쓰는지”를 모르면 같은 문법으로 새로운 버그를 만들기 쉽습니다. 그래서 각 항목마다 코드 아래에 그 기능이 해결하려던 문제와 처음 쓸 때 걸려 넘어지는 지점을 짧게 붙였습니다. 예제는 GCC 11 이상, Clang 14 이상, MSVC 2022에서 -std=c++20(MSVC는 /std:c++20)으로 컴파일된다는 전제로 작성했습니다. 코루틴과 Ranges는 컴파일러 버전에 따라 지원 수준이 달라서, 오래된 툴체인이라면 해당 절의 예제가 컴파일되지 않을 수 있습니다.
C++11: auto, range-for, 람다, 스마트 포인터
auto - 타입 추론
auto x = 42; // int
auto d = 3.14; // double
auto s = std::string("hello"); // std::string
auto v = std::vector<int>{1, 2, 3}; // std::vector<int>
// 반복자
auto it = v.begin();
// 함수 반환 타입
auto add(int a, int b) -> int {
return a + b;
}
auto는 템플릿 인자 추론과 같은 규칙을 따르기 때문에 참조와 최상위 const를 벗겨 냅니다. const std::string& name = user.name; auto copy = name;에서 copy는 const std::string&가 아니라 std::string이 되어 문자열이 복사됩니다. 참조를 유지하려면 auto&나 const auto&를 명시해야 합니다. 또 하나 유명한 함정이 auto x = {1};인데, 이것은 int가 아니라 std::initializer_list<int>로 추론됩니다(C++17부터 auto x{1};은 int).
마지막 줄의 auto add(...) -> int는 C++11의 후행 반환 타입 문법입니다. 이 경우엔 반환 타입을 그냥 앞에 쓰는 것과 차이가 없지만, template<class A, class B> auto add(A a, B b) -> decltype(a + b)처럼 반환 타입이 매개변수에 의존할 때 필요합니다. 매개변수 이름은 선언 뒤에서야 보이기 때문입니다.
range-based for
std::vector<int> v = {1, 2, 3, 4, 5};
// 읽기
for (int x : v) {
std::cout << x << std::endl;
}
// 수정
for (int& x : v) {
x *= 2;
}
// const 참조
for (const auto& x : v) {
std::cout << x << std::endl;
}
세 가지 형태의 차이는 “원소를 복사하느냐”입니다. int처럼 작은 타입은 값으로 받아도 되지만, std::string이나 구조체 원소를 for (auto s : names)로 돌면 원소마다 복사가 일어납니다. 읽기 전용이면 const auto&를 기본으로 쓰는 것이 무난합니다.
range-for는 내부적으로 begin()과 end()를 한 번만 구해 두고 반복자를 증가시키는 코드로 풀립니다. 그래서 루프 안에서 컨테이너에 원소를 추가·삭제하면 안 됩니다. vector에 push_back하다가 재할당이 일어나면 루프가 쥐고 있던 반복자가 무효화되어 미정의 동작이 됩니다. 또 for (auto& x : getVector())처럼 임시 객체를 직접 돌리는 것은 괜찮지만, for (auto& x : getObject().items())처럼 임시 객체의 멤버를 참조로 반환받아 돌면 getObject()가 루프 시작 전에 파괴되어 댕글링이 됩니다. 이 문제는 C++23(P2718)에서야 수명 연장 규칙이 바뀌어 해결되었습니다.
람다 표현식
// 기본
auto add = [](int a, int b) { return a + b; };
std::cout << add(2, 3) << std::endl; // 5
// 캡처
int factor = 10;
auto multiply = [factor](int x) { return x * factor; };
std::cout << multiply(5) << std::endl; // 50
// 캡처 방식
[=] // 값 캡처
[&] // 참조 캡처
[x] // x만 값 캡처
[&x] // x만 참조 캡처
람다는 컴파일러가 만들어 주는 이름 없는 함수 객체 클래스입니다. [factor]로 캡처하면 그 클래스에 int factor 멤버가 생기고, 람다를 만드는 시점의 값이 복사됩니다. 그래서 람다를 만든 뒤 factor = 20;으로 바꿔도 multiply(5)는 여전히 50을 반환합니다. 값 캡처한 변수는 기본적으로 const라서 람다 안에서 수정하려면 [factor]() mutable { ++factor; }처럼 mutable을 붙여야 하며, 이때도 바뀌는 것은 람다 내부 복사본뿐입니다.
[=]와 [&]는 편하지만 무엇이 캡처되는지 코드에서 보이지 않습니다. 특히 멤버 함수 안에서 [=]는 멤버 변수를 복사하는 것이 아니라 this 포인터를 복사합니다. 그래서 객체가 먼저 파괴되면 값 캡처처럼 보이는 람다도 댕글링이 됩니다. C++20부터는 [=]의 암묵적 this 캡처가 deprecated되었고, [this]나 객체 복사본을 캡처하는 [*this](C++17)를 명시하는 것이 권장됩니다. 비동기 콜백이나 스레드로 넘기는 람다라면 필요한 변수만 명시적으로 나열하는 편이 안전합니다.
스마트 포인터
#include <memory>
// unique_ptr (단일 소유권)
auto p1 = std::make_unique<int>(42);
std::cout << *p1 << std::endl;
// shared_ptr (공유 소유권)
auto p2 = std::make_shared<int>(42);
auto p3 = p2; // 참조 카운트 증가
// weak_ptr (순환 참조 방지)
std::weak_ptr<int> wp = p2;
if (auto sp = wp.lock()) {
std::cout << *sp << std::endl;
}
소유권을 기준으로 고르면 됩니다. 기본값은 unique_ptr입니다. 크기가 원시 포인터와 같고(기본 삭제자 기준), 복사가 금지되어 소유자가 한 명이라는 사실이 타입에 드러납니다. 소유권을 넘길 때는 std::move(p1)을 써야 하고, 넘긴 뒤 p1은 nullptr이 됩니다. shared_ptr은 여러 곳이 수명을 함께 책임질 때만 씁니다. 참조 카운트는 스레드 안전하게 원자적으로 증감되므로 복사할 때마다 비용이 들고, 제어 블록 때문에 메모리도 더 씁니다. make_shared는 객체와 제어 블록을 한 번에 할당해서 할당 횟수를 줄여 줍니다.
weak_ptr은 객체를 소유하지 않고 관찰만 합니다. lock()은 객체가 살아 있으면 shared_ptr을, 이미 해제되었으면 빈 shared_ptr을 돌려주므로 위 코드처럼 if로 확인하고 쓰는 것이 정석입니다. wp.expired()로 먼저 확인한 뒤 lock()을 부르는 코드는 멀티스레드에서 두 호출 사이에 객체가 사라질 수 있어 경쟁 조건이 생깁니다.
nullptr
// C++98
int* p = NULL; // 0과 혼동
// C++11
int* p = nullptr; // 타입 안전
NULL은 대부분 구현에서 0이나 0L로 정의된 정수입니다. 그래서 void f(int); void f(char*);가 오버로드되어 있을 때 f(NULL)은 포인터 버전이 아니라 int 버전을 호출하거나 모호성 에러를 냅니다. nullptr은 모든 포인터 타입으로만 변환되는 std::nullptr_t 타입이라 이런 혼동이 없습니다. 템플릿에 널 포인터를 넘길 때도 NULL은 int로 추론되어 포인터로 쓸 수 없게 됩니다.
초기화 리스트
std::vector<int> v = {1, 2, 3, 4, 5};
std::map<std::string, int> m = {{"a", 1}, {"b", 2}};
중괄호 초기화에는 유명한 함정이 있습니다. std::vector<int> a(10);은 원소 10개(모두 0)짜리 벡터지만, std::vector<int> b{10};은 원소가 10 하나인 벡터입니다. 생성자 오버로드 중 std::initializer_list를 받는 것이 있으면 중괄호는 그쪽을 최우선으로 고르기 때문입니다. 반면 중괄호 초기화는 int x{3.7}; 같은 축소 변환(narrowing)을 컴파일 에러로 막아 준다는 장점이 있습니다. 컨테이너 크기를 지정할 땐 소괄호, 원소 목록을 줄 땐 중괄호로 구분해 쓰면 헷갈리지 않습니다.
C++14: 제네릭 람다, make_unique
제네릭 람다
auto print = [](auto x) { std::cout << x << std::endl; };
print(42);
print("hello");
print(3.14);
매개변수에 auto를 쓰면 람다의 operator()가 템플릿이 됩니다. 호출할 때마다 인자 타입에 맞는 버전이 인스턴스화되므로 위 세 호출은 서로 다른 세 함수입니다. print("hello")의 x는 std::string이 아니라 const char*로 추론된다는 점도 기억해 둘 만합니다. C++20에서는 []<typename T>(T x) { ... }처럼 템플릿 매개변수 이름을 직접 쓸 수 있어서, 두 인자가 같은 타입이어야 한다는 제약을 표현하기가 쉬워졌습니다.
make_unique
// C++11
std::unique_ptr<int> p(new int(42));
// C++14
auto p = std::make_unique<int>(42);
make_unique는 단순히 타이핑을 줄이는 도구가 아닙니다. C++14 이전에는 f(std::unique_ptr<A>(new A), g());에서 컴파일러가 new A → g() → unique_ptr 생성 순서로 평가할 수 있었고, g()가 예외를 던지면 new A로 만든 객체가 누수되었습니다(C++17에서 평가 순서 규칙이 강화되어 이 경우는 해결됨). 더 실용적인 이유는 코드에서 new를 없애면 “new가 보이면 누군가 delete를 책임져야 한다”는 규칙을 코드 리뷰에서 그대로 적용할 수 있다는 점입니다. 단, 커스텀 삭제자가 필요하거나 private 생성자를 가진 클래스에는 make_unique를 쓸 수 없습니다.
반환 타입 추론
auto add(int a, int b) {
return a + b; // int 추론
}
C++14의 반환 타입 추론은 return 문에서 타입을 추론합니다. 여러 return이 서로 다른 타입을 반환하면(return 1;과 return 2.0;) inconsistent deduction for auto return type 에러가 납니다. 또 함수 본문이 보여야 추론할 수 있기 때문에, 헤더에 auto f();만 선언하고 .cpp에 정의를 두면 다른 파일에서 f()를 호출할 수 없습니다. 공개 API 함수는 반환 타입을 명시하고, 짧은 내부 헬퍼나 템플릿에서만 추론을 쓰는 편이 읽는 사람에게도 친절합니다.
C++17: 구조화된 바인딩, optional, if constexpr
구조화된 바인딩
// pair
std::pair<int, std::string> p = {1, "hello"};
auto [id, name] = p;
std::cout << id << ", " << name << std::endl;
// map
std::map<std::string, int> m = {{"a", 1}, {"b", 2}};
for (const auto& [key, value] : m) {
std::cout << key << ": " << value << std::endl;
}
// tuple
std::tuple<int, double, std::string> t = {1, 3.14, "hello"};
auto [i, d, s] = t;
구조화된 바인딩은 각 이름이 따로 복사되는 것처럼 보이지만, 실제로는 숨겨진 변수 하나에 p를 복사해 두고 id, name이 그 멤버를 가리키는 별칭이 되는 방식입니다. 그래서 auto [id, name] = p;는 p 전체를 복사하고, auto& [id, name] = p;는 복사 없이 p의 멤버를 직접 가리킵니다. map 순회에서 const auto& [key, value]를 쓰는 이유가 이것입니다. 개수가 맞지 않으면(auto [a, b] = t;처럼 3개짜리를 2개로 받으면) 컴파일 에러가 나고, 필요 없는 요소를 건너뛰는 문법은 C++26 전까지 없습니다.
C++17 기준으로 바인딩된 이름은 진짜 변수가 아니라서 람다에서 캡처할 수 없었습니다(error: 'id' in capture list does not name a variable 류의 에러). C++20에서 이 제한이 풀렸으니, 구 컴파일러에서 이 에러를 만나면 [id = id]처럼 초기화 캡처로 우회하면 됩니다.
std::optional
#include <optional>
std::optional<int> find(const std::vector<int>& v, int target) {
for (int x : v) {
if (x == target) return x;
}
return std::nullopt;
}
auto result = find(v, 5);
if (result) {
std::cout << "찾음: " << *result << std::endl;
} else {
std::cout << "못 찾음" << std::endl;
}
// value_or
int value = result.value_or(0);
optional이 없던 시절에는 “찾지 못함”을 -1 같은 매직 값이나 출력 매개변수 + bool 반환으로 표현했습니다. -1은 실제 값이 음수일 수 있는 순간 깨지고, 출력 매개변수는 호출하는 쪽이 반환값 확인을 잊기 쉽습니다. optional<int>는 “값이 없을 수 있다”는 사실을 타입에 넣어서 호출자가 확인하도록 유도합니다.
접근 방법에 따라 안전성이 다릅니다. *result는 값이 없을 때 검사 없이 미정의 동작이고, result.value()는 std::bad_optional_access 예외를 던지며, value_or(0)은 기본값을 돌려줍니다. 한 가지 주의할 점은 value_or의 인자가 항상 평가된다는 것입니다. result.value_or(loadDefaultFromDisk())처럼 쓰면 값이 있어도 디스크를 읽습니다. 비싼 기본값이라면 if로 분기하거나 C++23의 or_else를 쓰는 것이 맞습니다. 또 optional<T&>는 C++26 전까지 허용되지 않으므로, 참조를 선택적으로 반환하려면 포인터를 쓰는 편이 현실적입니다.
std::variant
#include <variant>
std::variant<int, double, std::string> v;
v = 42;
std::cout << std::get<int>(v) << std::endl;
v = 3.14;
std::cout << std::get<double>(v) << std::endl;
v = "hello";
std::cout << std::get<std::string>(v) << std::endl;
// 방문자 패턴
std::visit([](auto&& arg) {
std::cout << arg << std::endl;
}, v);
variant는 타입 안전한 union입니다. 지금 어떤 타입이 들어 있는지 인덱스를 함께 저장하므로, 다른 타입으로 꺼내려 하면(std::get<int>(v)인데 문자열이 들어 있으면) std::bad_variant_access 예외가 납니다. 예외 없이 확인하려면 std::holds_alternative<int>(v)나 포인터를 돌려주는 std::get_if<int>(&v)를 씁니다.
std::visit는 들어 있는 타입에 맞는 람다 호출을 컴파일 타임에 만들어 줍니다. 모든 대안 타입을 처리하지 않으면 컴파일이 안 되기 때문에, 나중에 variant에 타입을 추가했을 때 처리가 빠진 곳을 컴파일러가 찾아 줍니다. 상속과 가상 함수로도 비슷한 일을 할 수 있지만, variant는 힙 할당 없이 값으로 저장되고 타입 목록이 닫혀 있다는 차이가 있습니다. 새 타입이 자주 추가되는 플러그인 구조라면 상속이, 타입 집합이 고정된 메시지·AST 노드 같은 경우라면 variant가 잘 맞습니다.
if constexpr
template<typename T>
void process(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "정수: " << value << std::endl;
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "실수: " << value << std::endl;
} else {
std::cout << "기타: " << value << std::endl;
}
}
process(42); // 정수
process(3.14); // 실수
process("hello"); // 기타
일반 if와의 차이는 선택되지 않은 분기가 인스턴스화되지 않는다는 점입니다. 일반 if였다면 T가 const char*일 때도 정수 분기 코드가 컴파일되어야 하므로, 분기 안에서 value % 2처럼 정수에만 되는 연산을 쓰면 에러가 납니다. if constexpr는 이런 코드를 SFINAE나 태그 디스패치 없이 한 함수 안에 쓸 수 있게 해 줍니다.
주의할 점은 이 효과가 템플릿 안에서만 있다는 것입니다. 템플릿이 아닌 일반 함수에서 if constexpr (false) { 없는함수(); }를 쓰면 여전히 컴파일 에러가 납니다. 또 마지막 else에 static_assert(false, "지원하지 않는 타입")을 넣으면 C++20까지는 템플릿이 쓰이지 않아도 에러가 났기 때문에 static_assert(sizeof(T) == 0, ...) 같은 우회가 필요했습니다(C++23에서 P2593로 완화).
클래스 템플릿 인자 추론
// C++14
std::pair<int, std::string> p(1, "hello");
// C++17
std::pair p(1, "hello"); // 타입 추론
std::vector v{1, 2, 3}; // std::vector<int>
CTAD(Class Template Argument Deduction)는 생성자 인자에서 템플릿 인자를 추론합니다. 주의할 점은 std::pair p(1, "hello");가 pair<int, std::string>이 아니라 pair<int, const char*>로 추론된다는 것입니다. 위 C++14 코드와 같은 타입을 원한다면 std::pair p(1, std::string("hello"));나 "hello"s 리터럴을 써야 합니다. 또 std::vector v{v2};처럼 벡터 하나를 중괄호로 넘기면 vector<vector<int>>가 아니라 복사로 추론되는 등 직관과 다른 경우가 있어서, 타입이 중요한 곳에서는 명시하는 편이 안전합니다.
C++20: Concepts, Ranges, Coroutine
Concepts
#include <concepts>
template<std::integral T>
T add(T a, T b) {
return a + b;
}
// 커스텀 Concept
template<typename T>
concept Numeric = std::is_arithmetic_v<T>;
template<Numeric T>
T multiply(T a, T b) {
return a * b;
}
Concepts의 가장 큰 가치는 에러 메시지입니다. 제약 없는 템플릿에 std::string을 넘기면 에러가 템플릿 본문 깊숙한 곳에서 수십 줄로 나오지만, std::integral T로 제약하면 “std::string does not satisfy integral”처럼 호출 지점에서 한 줄로 알려 줍니다. 오버로드를 제약 조건으로 나눌 수도 있어서, 예전에 std::enable_if_t<...>로 쓰던 코드가 훨씬 읽기 쉬워집니다.
add(1, 2L)처럼 두 인자 타입이 다르면 T를 하나로 추론할 수 없어 에러가 난다는 점은 Concepts 이전과 같습니다. 또 std::is_arithmetic_v로 만든 Numeric은 bool과 char도 통과시키므로, “숫자”라는 이름과 실제 의미가 어긋나지 않는지 확인해야 합니다. Concept은 문법적 요구사항만 검사할 뿐 의미까지 보장하지는 않습니다.
Ranges
#include <ranges>
#include <vector>
#include <iostream>
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// 필터 + 변환
auto result = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
for (int x : result) {
std::cout << x << " "; // 4 16 36 64 100
}
result는 새 벡터가 아니라 뷰(view)입니다. 이 줄에서는 아무 계산도 일어나지 않고, for 루프가 원소를 하나씩 요청할 때 filter와 transform이 지연 평가됩니다. 중간 벡터를 만들지 않으니 메모리를 아끼지만, 대신 뷰는 원본 v를 참조하고 있으므로 v가 사라지거나 재할당되면 뷰도 무효가 됩니다. 함수에서 지역 벡터로 만든 뷰를 반환하면 댕글링이 되는 것도 같은 이유입니다.
처음 Ranges를 쓸 때 자주 걸리는 부분은 결과를 다시 vector로 모으는 방법입니다. C++20에는 std::ranges::to가 없어서(C++23에 추가) std::vector<int> out(result.begin(), result.end());처럼 써야 하는데, filter_view는 공통 범위(common range)가 아닐 수 있어서 이 코드가 컴파일되지 않는 경우도 있습니다. 그럴 땐 std::views::common을 파이프 끝에 붙이거나 루프로 push_back하면 됩니다. 또 filter_view는 begin()을 처음 호출할 때 결과를 캐시하기 때문에 const 뷰에서 순회할 수 없다는 제약도 있습니다.
Coroutine
#include <coroutine>
#include <iostream>
struct Generator {
struct promise_type {
int current_value;
auto get_return_object() { return Generator{this}; }
auto initial_suspend() { return std::suspend_always{}; }
auto final_suspend() noexcept { return std::suspend_always{}; }
void return_void() {}
void unhandled_exception() {}
auto yield_value(int value) {
current_value = value;
return std::suspend_always{};
}
};
std::coroutine_handle<promise_type> handle;
Generator(promise_type* p) : handle(std::coroutine_handle<promise_type>::from_promise(*p)) {}
~Generator() { if (handle) handle.destroy(); }
bool next() {
handle.resume();
return !handle.done();
}
int value() { return handle.promise().current_value; }
};
Generator counter(int start, int end) {
for (int i = start; i <= end; ++i) {
co_yield i;
}
}
int main() {
auto gen = counter(1, 5);
while (gen.next()) {
std::cout << gen.value() << " "; // 1 2 3 4 5
}
return 0;
}
C++20 코루틴은 언어 기능만 제공하고 라이브러리 타입은 제공하지 않습니다. 그래서 co_yield를 쓰려면 위처럼 promise_type을 가진 반환 타입을 직접 만들어야 합니다. 흐름을 따라가 보면, counter(1, 5)를 호출하면 코루틴 프레임이 힙에 할당되고 initial_suspend()가 suspend_always를 반환하므로 본문을 실행하지 않고 바로 멈춘 채 Generator를 돌려줍니다. next()에서 resume()하면 co_yield i까지 실행되고, yield_value가 값을 저장한 뒤 다시 멈춥니다. 루프가 끝나면 final_suspend에서 멈추고 done()이 true가 됩니다.
이 예제는 문법을 보여 주기 위해 최소한으로 만든 것이라 실무 코드로 쓰기엔 두 가지가 빠져 있습니다. 첫째, Generator가 복사 가능해서 복사하면 두 객체가 같은 핸들을 destroy()하는 이중 해제가 됩니다. 복사 생성자를 delete하고 핸들을 nullptr로 넘기는 이동 생성자를 만들어야 합니다. 둘째, unhandled_exception() {}이 예외를 조용히 삼킵니다. 본문에서 예외가 나면 코루틴이 그냥 끝난 것처럼 보이므로, std::exception_ptr에 저장했다가 next()에서 다시 던지는 것이 일반적입니다. 직접 만들기 부담스럽다면 C++23의 std::generator나 cppcoro 같은 라이브러리를 쓰는 편이 낫습니다.
삼원 비교 연산자 (<=>)
#include <compare>
struct Point {
int x, y;
auto operator<=>(const Point&) const = default;
};
int main() {
Point p1{1, 2};
Point p2{1, 3};
if (p1 < p2) {
std::cout << "p1 < p2" << std::endl;
}
return 0;
}
= default로 선언한 operator<=>는 멤버를 선언 순서대로 비교합니다. 그래서 Point는 x를 먼저 비교하고, 같으면 y를 비교합니다. 예전에는 <, <=, >, >=, ==, != 여섯 개를 손으로 써야 했고, 그중 하나를 실수하면 std::sort나 std::map이 이상하게 동작했습니다. 이제는 한 줄로 일관된 비교가 만들어집니다.
<=>를 직접 구현(= default가 아닌)하면 컴파일러는 ==를 자동으로 만들어 주지 않습니다. 동등 비교는 크기 비교보다 빠르게 구현할 수 있는 경우가 많기 때문에(문자열 길이가 다르면 바로 false) 의도적으로 분리한 설계입니다. 따라서 <=>를 직접 작성했다면 bool operator==(const Point&) const = default;도 함께 선언해야 p1 == p2가 컴파일됩니다. 반환 타입도 strong_ordering, weak_ordering, partial_ordering(부동소수점의 NaN처럼 비교 불가능한 값이 있을 때) 중 멤버에 맞는 것이 추론됩니다.
실무 사례
사례 1: 데이터 파싱 - optional 활용
#include <optional>
#include <string>
#include <sstream>
#include <iostream>
std::optional<int> parseInteger(const std::string& str) {
std::istringstream iss(str);
int value;
if (iss >> value) {
return value;
}
return std::nullopt;
}
int main() {
auto result1 = parseInteger("42");
auto result2 = parseInteger("abc");
std::cout << "result1: " << result1.value_or(-1) << std::endl; // 42
std::cout << "result2: " << result2.value_or(-1) << std::endl; // -1
return 0;
}
이 파서에는 실무에서 자주 문제가 되는 구멍이 있습니다. iss >> value는 앞부분만 읽고 멈추기 때문에 parseInteger("42abc")도 42로 성공합니다. 설정 파일이나 사용자 입력을 검증하는 용도라면 읽은 뒤 iss >> std::ws; if (!iss.eof()) return std::nullopt;처럼 남은 문자가 없는지 확인해야 합니다. 또 istringstream은 로케일 처리 때문에 생각보다 무겁습니다. 대량의 숫자를 파싱한다면 C++17의 std::from_chars가 예외도, 로케일도, 할당도 없어서 훨씬 빠르고, 어디까지 읽었는지 포인터로 알려 주므로 “끝까지 숫자인가” 검사도 쉽습니다.
#include <charconv>
std::optional<int> parseStrict(std::string_view s) {
int v{};
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
return v;
}
사례 2: 리소스 관리 - unique_ptr
#include <memory>
#include <fstream>
#include <iostream>
class FileHandler {
private:
std::unique_ptr<std::ifstream> file;
public:
FileHandler(const std::string& filename) {
file = std::make_unique<std::ifstream>(filename);
if (!file->is_open()) {
throw std::runtime_error("파일 열기 실패");
}
}
std::string readLine() {
std::string line;
if (std::getline(*file, line)) {
return line;
}
return "";
}
};
int main() {
try {
FileHandler handler("data.txt");
std::cout << handler.readLine() << std::endl;
} catch (const std::exception& e) {
std::cout << "에러: " << e.what() << std::endl;
}
return 0;
}
이 예제는 unique_ptr의 사용법을 보여 주지만, 사실 std::ifstream은 이미 RAII 타입이라서 멤버로 직접 두면(std::ifstream file;) 소멸자가 알아서 파일을 닫습니다. unique_ptr로 감싸면 힙 할당만 하나 늘어납니다. unique_ptr이 진짜 필요한 곳은 FILE*이나 OS 핸들처럼 스스로 정리하지 않는 C 스타일 자원입니다. 이때는 std::unique_ptr<FILE, decltype(&fclose)> fp(fopen(name, "r"), &fclose);처럼 커스텀 삭제자를 지정합니다.
생성자에서 예외를 던지는 방식은 “생성에 성공한 객체는 항상 유효하다”는 불변식을 보장해 줍니다. 생성자에서 예외가 나면 소멸자는 호출되지 않지만, 이미 생성이 끝난 멤버(file)의 소멸자는 호출되므로 누수가 없습니다. 멤버를 unique_ptr이나 RAII 타입으로 두면 이 보장이 자동으로 따라옵니다. readLine()이 실패와 빈 줄을 모두 ""로 반환하는 점은 앞 절의 optional<std::string>으로 바꾸면 구분할 수 있습니다.
사례 3: 알고리즘 - 람다 활용
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {5, 2, 8, 1, 9, 3};
// 정렬
std::sort(v.begin(), v.end(), [](int a, int b) {
return a > b; // 내림차순
});
// 필터
auto it = std::remove_if(v.begin(), v.end(), [](int x) {
return x < 5;
});
v.erase(it, v.end());
// 출력
for (int x : v) {
std::cout << x << " "; // 9 8 5
}
return 0;
}
std::remove_if는 이름과 달리 원소를 지우지 않습니다. 남길 원소를 앞으로 당겨 놓고 “새 끝”을 가리키는 반복자를 반환할 뿐이고, 그 뒤의 원소는 유효하지만 값이 정해지지 않은 상태로 남습니다. 그래서 v.erase(it, v.end())까지 해야 실제로 크기가 줄어듭니다. 이 두 단계를 erase-remove 관용구라고 부르는데, erase를 빼먹어도 컴파일 에러가 없어서 크기가 그대로인 벡터를 순회하는 버그가 흔합니다. C++20부터는 std::erase_if(v, [](int x) { return x < 5; }); 한 줄로 끝낼 수 있습니다.
std::sort의 비교 함수는 엄격한 약한 순서(strict weak ordering)를 만족해야 합니다. return a >= b;처럼 같은 값에 true를 반환하면 미정의 동작이며, 실제로 일부 구현에서는 배열 범위를 벗어나 읽다가 크래시가 납니다. 내림차순은 위처럼 >를 쓰거나 std::greater<>{}를 넘기면 됩니다.
사례 4: 타입 안전 - variant 활용
#include <variant>
#include <string>
#include <iostream>
using Value = std::variant<int, double, std::string>;
void printValue(const Value& v) {
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "정수: " << arg << std::endl;
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "실수: " << arg << std::endl;
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "문자열: " << arg << std::endl;
}
}, v);
}
int main() {
printValue(42);
printValue(3.14);
printValue("hello");
return 0;
}
std::decay_t<decltype(arg)>가 필요한 이유는 auto&&로 받은 arg의 타입이 const int&처럼 참조와 const가 붙은 형태이기 때문입니다. 그대로 std::is_same_v<decltype(arg), int>로 비교하면 항상 false가 됩니다.
if constexpr 체인 대신 타입별 람다를 묶는 overloaded 패턴도 널리 쓰입니다. template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };를 정의해 두고 std::visit(overloaded{[](int i){...}, [](double d){...}, [](const std::string& s){...}}, v);처럼 쓰면, 처리하지 않은 타입이 있을 때 컴파일 에러로 알려 줍니다. 위 if constexpr 버전은 마지막 else if가 모두 틀리면 아무것도 출력하지 않고 조용히 넘어가므로, 타입을 추가했을 때 누락을 놓치기 쉽습니다.
printValue("hello")는 const char*가 std::string으로 변환되어 문자열 분기로 갑니다. 그런데 대안 타입에 bool이 있었다면 C++17 초기 구현에서는 포인터가 bool로 변환되어 bool 분기로 가는 유명한 문제가 있었습니다. C++20(P0608)에서 변환 규칙이 고쳐졌지만, 구형 컴파일러를 쓴다면 문자열 리터럴은 std::string{"hello"}로 명시하는 편이 안전합니다.
트러블슈팅
문제 1: auto 남용
증상: 타입이 불명확해 가독성 저하
// ❌ 의도 불명확
auto x = 0; // 항상 int지만, long이나 size_t를 의도했는지 읽는 사람은 알 수 없음
// ✅ 명시적 타입
int x = 0;
// auto 사용이 좋은 경우
auto it = v.begin(); // 반복자
auto lambda = [](int x) { return x * 2; };
auto의 기준은 “타입을 적는 것이 정보를 더하는가”입니다. 반복자나 람다처럼 타입 이름이 길거나 쓸 수 없는 경우, make_unique<Foo>()처럼 오른쪽에 이미 타입이 보이는 경우에는 auto가 낫습니다. 반면 auto count = getCount();처럼 함수 이름만으로는 타입이 보이지 않는 곳에서는 읽는 사람이 IDE에 마우스를 올려야 합니다.
auto가 실제 버그로 이어지는 대표 사례는 프록시 타입입니다. std::vector<bool>의 operator[]는 bool&가 아니라 std::vector<bool>::reference라는 프록시 객체를 반환합니다. auto flag = flags[3];로 받은 뒤 flags가 재할당되거나 파괴되면 flag는 댕글링 프록시가 됩니다. Eigen 같은 표현식 템플릿 라이브러리에서도 auto result = a + b;가 계산 결과가 아니라 “계산식”을 들고 있어서 같은 문제가 생깁니다. 이런 타입에서는 bool flag = flags[3];처럼 명시하는 것이 안전합니다.
문제 2: 람다 캡처 댕글링
증상: 람다가 유효하지 않은 참조 캡처
// ❌ 댕글링 참조
auto makeLambda() {
int x = 42;
return [&x]() { return x; }; // x는 스택에서 사라짐
}
// ✅ 값 캡처
auto makeLambda() {
int x = 42;
return [x]() { return x; };
}
이 버그가 까다로운 이유는 대부분의 경우 동작하는 것처럼 보인다는 점입니다. 스택의 x 자리가 아직 덮어써지지 않았다면 42가 그대로 나오고, 최적화 수준을 바꾸거나 함수 호출이 하나 더 끼면 쓰레기 값이 나옵니다. 제가 경험한 것 중 가장 찾기 어려웠던 유형은 이벤트 콜백 등록이었습니다. 콜백을 등록하는 함수 안에서 지역 변수를 [&]로 캡처하면, 등록 시점에는 문제가 없다가 나중에 이벤트가 발생할 때 이미 사라진 스택 프레임을 읽습니다. 로그를 찍으면 재현이 안 되는 경우도 있어서 AddressSanitizer(-fsanitize=address)의 stack-use-after-return 검출을 켜고 나서야 원인이 드러나는 경우가 많습니다.
규칙은 단순합니다. 람다가 만들어진 스코프를 벗어나 살아남는다면(반환, 컨테이너 저장, 스레드·비동기 전달) 참조 캡처를 쓰지 않습니다. 복사가 비싼 객체는 [data = std::move(data)]처럼 C++14 초기화 캡처로 소유권을 옮기면 됩니다.
문제 3: shared_ptr 순환 참조
증상: 메모리 누수
#include <memory>
struct Node {
std::shared_ptr<Node> next;
};
// ❌ 순환 참조
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();
n1->next = n2;
n2->next = n1; // 순환 참조 (메모리 누수)
// ✅ weak_ptr 사용
struct NodeFixed {
std::weak_ptr<NodeFixed> next;
};
n1과 n2가 스코프를 벗어나면 각각의 참조 카운트는 2에서 1로 줄어들 뿐 0이 되지 않습니다. 서로가 서로를 붙잡고 있기 때문에 어느 쪽 소멸자도 호출되지 않고, 이 메모리는 프로그램이 끝날 때까지 회수되지 않습니다. 가비지 컬렉터가 있는 언어와 달리 참조 카운팅은 순환을 감지하지 못합니다.
위의 NodeFixed처럼 모든 링크를 weak_ptr로 바꾸면 이번에는 아무도 노드를 소유하지 않아서 곧바로 해제됩니다. 실무에서는 소유 방향을 하나로 정하는 것이 핵심입니다. 트리라면 부모 → 자식은 shared_ptr(또는 unique_ptr), 자식 → 부모는 weak_ptr이나 원시 포인터로 둡니다. 옵저버 패턴에서 주체가 옵저버 목록을 shared_ptr로 들고 있고 옵저버도 주체를 shared_ptr로 들고 있는 구조가 흔한 누수 원인입니다. 람다가 자기 자신을 소유한 객체의 shared_ptr을 캡처하는 경우([self = shared_from_this()]를 멤버 콜백에 저장)도 같은 순환을 만듭니다.
문제 4: optional 값 접근 전 체크 누락
증상: std::bad_optional_access 예외
std::optional<int> opt;
// ❌ 체크 없이 접근
// int x = *opt; // 미정의 동작 (예외 없음)
// int y = opt.value(); // std::bad_optional_access 예외
// ✅ 체크 후 접근
if (opt) {
int x = *opt;
}
// 또는 value_or
int x = opt.value_or(0);
원래 예제 주석처럼 *opt가 예외를 던진다고 오해하기 쉬운데, operator*와 operator->는 검사를 하지 않습니다. 값이 없을 때 역참조하면 미정의 동작이고, 대부분 쓰레기 값이 나오거나 디버그 빌드의 assert에 걸립니다. 예외(std::bad_optional_access)를 던지는 것은 value()뿐입니다. 성능이 중요한 루프에서 이미 확인한 뒤라면 *opt, 그렇지 않다면 value()나 value_or()를 쓰는 식으로 구분하면 됩니다.
optional<bool>은 특히 조심해야 합니다. if (opt)는 값이 있는지를 검사하므로, false가 들어 있어도 참이 됩니다. 값 자체를 검사하려면 if (opt && *opt)처럼 두 번 확인해야 하고, 이 차이를 놓쳐서 설정값 false가 무시되는 버그가 종종 생깁니다. 포인터를 담은 optional<int*>도 같은 이유로 헷갈리기 쉽습니다.
마무리
모던 C++은 코드 품질과 생산성을 크게 향상시킵니다.
핵심 요약
- C++11
- auto, range-for, 람다, 스마트 포인터
- nullptr, 초기화 리스트
- C++14
- 제네릭 람다, make_unique
- 반환 타입 추론
- C++17
- 구조화된 바인딩, optional, variant
- if constexpr, 클래스 템플릿 인자 추론
- C++20
- Concepts, Ranges, Coroutine
- 삼원 비교 연산자
도입 순서
| 단계 | 기능 | 이유 |
|---|---|---|
| 1단계 | auto, range-for, 스마트 포인터 | 버그 감소 |
| 2단계 | 람다, optional | 코드 간결성 |
| 3단계 | 구조화된 바인딩, variant | 가독성 |
| 4단계 | Concepts, Ranges | 표현력 |
버전별 핵심 기능
// C++11
auto x = 42;
for (auto& x : v) { /* ... */ }
auto lambda = [](int x) { return x * 2; };
auto p = std::make_shared<int>(42);
// C++14
auto print = [](auto x) { std::cout << x; };
auto p = std::make_unique<int>(42);
// C++17
auto [key, value] = *map.begin(); // 반복자가 아니라 원소(pair)를 분해
std::optional<int> opt = find(v, 5);
if constexpr (std::is_integral_v<T>) { /* ... */ }
// C++20
template<std::integral T>
T add(T a, T b) { return a + b; }
auto even = v | std::views::filter([](int x) { return x % 2 == 0; });
다음 단계
- auto와 decltype: C++ auto와 decltype
- 범위 기반 for: C++ 범위 기반 for문
- C++ 개요: C++이란?
참고 자료
- “Effective Modern C++” - Scott Meyers
- “C++17 The Complete Guide” - Nicolai M. Josuttis
- “C++20 The Complete Guide” - Nicolai M. Josuttis
- cppreference: https://en.cppreference.com/ 한 줄 정리: 모던 C++은 auto, 람다, 스마트 포인터, optional, Concepts로 코드 품질과 생산성을 크게 향상시킵니다.
자주 묻는 질문 (FAQ)
Q. 함수에서 람다를 반환할 때 [&]로 캡처하면 왜 위험한가요?
A. 참조 캡처는 변수의 복사본이 아니라 원래 변수를 가리키므로, 지역 변수를 [&]로 캡처한 람다를 반환하면 함수가 끝나는 순간 그 변수가 사라지고 람다는 댕글링 참조를 들게 됩니다. 이후 람다를 호출하면 미정의 동작이 되며, 테스트에서는 우연히 동작하다가 다른 환경에서 깨지기 쉽습니다. 람다가 만들어진 스코프보다 오래 살아남는다면 [x]처럼 값으로 캡처하거나 필요한 데이터를 std::move로 옮겨 캡처해야 합니다.
같이 보면 좋은 글
- C++ auto와 decltype: 타입 추론 규칙, decltype(auto), AAA 스타일
- C++ nullptr vs NULL: NULL이 정수라서 생기는 오버로드·템플릿 버그
- C++17·20·23 주요 기능 정리: 구조화 바인딩부터 Concepts·Ranges·std::print까지