본문으로 건너뛰기
Previous
Next
C++ 람다 캡처 | 'Lambda Capture' 완벽 가이드

C++ 람다 캡처 | 'Lambda Capture' 완벽 가이드

C++ 람다 캡처 | 'Lambda Capture' 완벽 가이드

이 글의 핵심

람다는 이름 없는 클로저 타입으로 lowering되며, 캡처는 멤버로 붙습니다. 참조 캡처 수명·init-capture 이동·const operator()와 mutable·프로덕션 패턴까지 정리합니다.

람다 캡처란?

람다 캡처(Lambda Capture) 는 람다 함수가 외부 변수에 접근하는 방법을 정의합니다. 람다는 자신이 정의된 스코프의 변수를 캡처하여 사용할 수 있으며, 캡처 방식에 따라 값 복사 또는 참조로 접근합니다.

C/C++ 예제 코드입니다.

int x = 10;

// 값 캡처
auto f1 = [x]() { return x; };

// 참조 캡처
auto f2 = [&x]() { return x; };

// 모든 변수 값 캡처
auto f3 = [=]() { return x; };

// 모든 변수 참조 캡처
auto f4 = [&]() { return x; };

왜 필요한가?:

  • 클로저: 람다가 외부 상태를 “기억”할 수 있음
  • 유연성: 값 또는 참조로 선택적 캡처
  • 간결성: 함수 객체 대신 간단한 문법
  • 타입 안전: 컴파일러가 캡처 검증
// ❌ 함수 객체: 복잡
struct Adder {
    int x;
    Adder(int x) : x(x) {}
    int operator()(int y) const { return x + y; }
};

Adder add10(10);
std::cout << add10(5) << '\n';  // 15

// ✅ 람다 캡처: 간결
int x = 10;
auto add10 = [x](int y) { return x + y; };
std::cout << add10(5) << '\n';  // 15

캡처의 동작 원리:

람다는 내부적으로 익명 함수 객체(Functor) 로 변환됩니다. 캡처된 변수는 함수 객체의 멤버 변수가 됩니다.

int x = 10;
auto f = [x]() { return x; };

// 개념적으로 다음과 같이 변환됨:
struct __lambda {
    int x;  // 캡처된 변수
    __lambda(int x) : x(x) {}
    int operator()() const { return x; }
};

__lambda f(x);

클로저 타입과 멤버 생성(컴파일러 관점)

C++ 표준은 람다를 고유한(unnamed) 클로저 타입의 임시 객체로 정의합니다. 구현 세부는 컴파일러마다 다르지만, 모델은 동일합니다. 캡처 목록에 나온 각 엔티티는 클로저 객체의 비정적 데이터 멤버가 됩니다. 값 캡처는 해당 타입의 멤버(복사 또는 이동으로 초기화), 참조 캡처는 참조 타입 멤버(레퍼런스가 바인딩된 객체의 수명은 여전히 원래 스코프 규칙을 따름)로 생성됩니다.

  • 기본 캡처 한정자 [=] / [&]: 암시적으로 캡처되는 자동 저장 기간 변수마다 멤버가 생깁니다. [=]는 멤버를 으로, [&]참조로 둡니다.
  • mutable 없는 람다: operator()는 기본적으로 const 멤버 함수입니다. 따라서 값으로 캡처된 멤버는 본문 안에서 논리적으로 const 객체처럼 취급되어(복사본이지만) 수정할 수 없습니다. 이는 일반 함수 객체가 const 호출에서 상태를 바꾸지 않도록 하는 관용과 맞춥니다.
  • 제네릭 람다: auto 매개변수가 있으면 operator()가 함수 템플릿이 되고, 클로저 타입은 여전히 고정된 멤버 집합을 가집니다(캡처는 람다 정의 시점에 확정).
// 개념적 펼침: [a, &b, c = std::move(ptr)]() { ... }
struct __closure /* 고유 타입, 이름 없음 */ {
    int a;                    // 값 캡처
    int& b;                   // 참조 캡처
    std::unique_ptr<int> c;   // init-capture로 만든 멤버(이름은 람다 매개체계에 따름)

    __closure(int a_, int& b_, std::unique_ptr<int>&& c_)
        : a(a_), b(b_), c(std::move(c_)) {}

    auto operator()() const /* 또는 mutable에 따라 const 아님 */ {
        /* 본문 */
        return 0;
    }
};

왜 타입이 이름 없나요? 각 람다 표현식마다 서로 다른 타입이 생성되므로, std::function이나 함수 포인터에 넣기 전에는 서로 다른 람다를 같은 정적 타입으로 취급할 수 없습니다. 이것이 auto로 받거나 템플릿(std::forward 등)으로 전달하는 패턴이 기본인 이유입니다.

멤버 생성 규칙: 어떤 이름이 “클로저 필드”가 되나

표준 모델에서 캡처 절에 등장하는 각 항목은 클로저 타입의 비정적 데이터 멤버에 대응합니다. 단순 [x]는 “외부 x라는 이름”이 아니라, 클로저 안의 멤버 x가 생기고 생성 시점에 외부 x로부터 복사·이동으로 초기화됩니다. 참조 캡처 [&x]는 멤버 타입이 T&처럼 참조가 되며, 초기화는 외부 x에 대한 바인딩입니다.

  • 암시적 캡처 [=] / [&]: 본문에서 ODR-used 되는 자동 저장 기간 변수가 캡처 대상이 됩니다(구현·경고 수준은 컴파일러마다 다를 수 있으나, “본문이 실제로 읽거나 건드리는 변수”가 붙는다고 이해하면 안전합니다). 기본 캡처 한정자는 그 변수들마다 값/참조 멤버를 일괄 생성합니다.
  • 명시 캡처 [a, &b, c = expr]: 목록에 적힌 것만 멤버가 됩니다. 나중에 본문을 고치며 암시적 의존이 생기면 캡처 누락으로 컴파일 오류가 나므로, 리팩터링에 유리합니다.
  • 클로저 객체의 복사·이동: 람다 표현식은 클로저 타입의 prvalue입니다. 캡처 멤버들의 복사·이동 가능성은 각 멤버 타입을 따릅니다. unique_ptr를 값으로 캡처했다면 클로저 자체는 이동만 가능하고, 참조 멤버가 있으면 복사 생성이 막히거나 위험해질 수 있어(참조가 같은 객체를 가리키며 복제) 설계상 복사 대신 이동·shared_ptr을 택하는 경우가 많습니다.
  • 크기: 클로저 객체의 크기는 대략 캡처 멤버들의 합(+ 구현이 넣는 stateless 여유)입니다. “람다는 항상 작다”는 통념은 캡처가 없거나 소수일 때만 성립합니다.

컴파일러 lowering(개념적 단계)

구현 세부는 제각각이지만, 분석할 때는 다음 순서로 생각하면 캡처 관련 버그의 원인이 잘 보입니다.

  1. 캡처 목록 해석: 각 항목에 대해 멤버 이름·타입(값/참조/init-capture)을 확정합니다.
  2. 클로저 타입 합성: 고유한 이름 없는 클래스를 만들고, 위 멤버와 사용자가 적지 않은 특수 멤버 함수(복사/이동/소멸)를 규칙에 맞게 생성합니다.
  3. operator() 생성: 매개변수 목록·반환형·mutable 여부·noexcept·트레일링 반환형 등에 맞춰 호출 연산자를 만듭니다. mutable이 없으면 const 멤버 함수입니다.
  4. 호출부: 람다가 나타난 자리에서 클로저 임시 객체를 만들고, 캡처에 필요한 lvalue/xvalue를 넘겨 멤버를 초기화합니다.

이 관점에서 “참조 캡처 = 클로저 안에 참조 멤버가 있다”는 사실 하나만으로도, 수명·스레드·비동기 이슈가 대부분 설명됩니다.

값 캡처 vs 참조 캡처

C/C++ 예제 코드입니다.

int x = 10;

// 값 캡처: 복사본
auto f1 = [x]() mutable {
    x++;  // 복사본 수정
    return x;
};

cout << f1() << endl;  // 11
cout << x << endl;     // 10 (원본 변경 안됨)

// 참조 캡처: 원본
auto f2 = [&x]() {
    x++;  // 원본 수정
    return x;
};

cout << f2() << endl;  // 11
cout << x << endl;     // 11 (원본 변경됨)

참조 캡처와 수명 분석

참조 캡처는 클로저 안에 참조 멤버를 싣는 것과 같습니다. C++의 수명 규칙상, 참조가 유효하려면 참조 대상 객체가 람다가 실행되는 시점에도 살아 있어야 합니다. 컴파일러는 대부분의 경우 지역 변수를 람다가 스택에서 “오래” 가져가는 패턴을 막지 못하고, 이 경우 미정의 동작(UB) 이 됩니다.

컴파일러가 보장하는 것과 보장하지 않는 것

  • 보장: 캡처 문법이 올바르다면, 람다 정의 시점에 이름 조회(name lookup)와 캡처 대상 바인딩이 일어납니다.
  • 비보장: 비동기·저장·반환된 람다가 나중에 실행될 때, 참조 캡처한 지역 변수가 아직 유효한지는 프로그래머 책임입니다. 이는 Rust의 borrow checker와 달리, C++는 대부분 런타임 전 수명 검사를 하지 않습니다.

반환·저장 시 안전한 선택

상황권장
std::function/vector에 넣어 나중에 호출값 캡처, shared_ptr, 또는 이동 가능한 리소스를 init-capture
같은 블록 안에서만 즉시 호출참조 캡처로 할당·복사 최소화
스레드에 넘김수명이 확실히 끝나지 않게 join 전 스코프 보장 또는 힙/스마트 포인터

댕글링의 본질: 참조는 수명을 “연장”하지 않는다

참조 캡처는 클로저가 객체를 소유하는 것이 아니라, 이미 존재하는 객체에 대한 별칭을 멤버로 들고 가는 것입니다. 따라서 참조가 유효한 동안 대상 객체가 살아 있어야 하며, 클로저를 힙에 넣거나 컨테이너에 오래 보관해도 대상의 스토리지 수명은 자동으로 늘지 않습니다.

특히 다음 패턴은 표면상 “캡처만 바꾼 것”처럼 보여도 결과가 극단적으로 달라집니다.

  • 지역 변수를 참조로 캡처한 뒤 람다를 반환: 스택 프레임이 사라진 뒤 실행되면 미정의 동작입니다. 값 캡처는 복사본이 멤버에 남습니다.
  • 참조로 캡처한 대상이 임시(temporary)인 경우: 임시는 전체 표현식 끝에서 파괴될 수 있습니다. 람다를 그 표현식 안에서 즉시 호출하지 않으면 위험합니다.
  • this 또는 멤버에 대한 암시적 참조: [&] 남용 시 클래스 멤버·this에 대한 장기 참조가 생기기 쉬워, 비동기 콜백에서 특히 문제가 됩니다.

컴파일러는 일부 경우(지역 변수를 참조로 캡처해 반환 등) 경고를 내지만, 모든 수명 실수를 잡아주지는 않습니다. ASan/UBSan, 코드 리뷰에서의 “저장/반환 여부” 확인이 여전히 필요합니다.

void enqueue(std::vector<std::function<void()>>& q);

void bad() {
    int local = 1;
    enqueue([&]() { (void)local; });  // 큐에 쌓인 뒤 local이 사라지면 UB
}

void good() {
    int local = 1;
    enqueue([local]() { (void)local; });  // 복사본이 클로저에 저장됨
}

정적·전역은 수명이 프로그램 전체이므로 참조 캡처가 상대적으로 안전하지만, 스레드 안전성은 별도 문제입니다.

혼합 캡처

C/C++ 예제 코드입니다.

int x = 10;
int y = 20;

// x는 값, y는 참조
auto f = [x, &y]() {
    // x++;  // 에러: 값 캡처는 const
    y++;     // OK: 참조 캡처
    return x + y;
};

cout << f() << endl;  // 31
cout << x << endl;    // 10
cout << y << endl;    // 21

초기화 캡처(C++14)와 이동 의미론

C++14 init-capture(초기화 캡처)는 캡처 목록에 이름 = 표현식 형태를 허용합니다. 이는 외부 스코프의 변수를 그대로 붙잡는 것이 아니라, 클로저의 새 멤버를 선언하고 그 초기값으로 복사·이동·계산 결과를 넣는 문법입니다.

값 의미와 이동 의미

  • [x = expr]: 클로저 멤버 xexpr으로 초기화합니다. expr이동 가능하고(예: unique_ptr), 멤버 타입이 이동 생성 가능하면 이동으로 초기화되는 것이 자연스럽습니다.
  • [p = std::move(ptr)](): 외부 이름 ptr은 이동 후 빈 상태가 되고, 소유권은 클로저 멤버 p로 옮겨집니다. 스마트 포인터·대용량 vector를 비동기 작업으로 넘길 때 흔한 패턴입니다.
  • 복사 생략(RVO 등)과의 관계: 클로저 생성 시점의 초기화는 일반 멤버 초기화와 동일한 규칙을 따릅니다. 컴파일러가 복사/이동을 생략할 수 있으면 생략합니다.
#include <memory>
#include <iostream>

int main() {
    // 새 멤버만 만들고 외부 이름과 독립
    auto f1 = [x = 42]() { return x; };

    // 이동: unique_ptr 소유권을 클로저로 이전
    auto ptr = std::make_unique<int>(10);
    auto f2 = [p = std::move(ptr)]() {
        return *p;  // ptr은 nullptr에 가까운 상태
    };

    // 외부 x를 읽어 멤버 y를 계산해 캡처
    int x = 10;
    auto f3 = [y = x * 2]() { return y; };

    std::cout << f1() << '\n';  // 42
    std::cout << f2() << '\n';  // 10
    std::cout << f3() << '\n';  // 20
}

[=]로는 부족한 이유

[=]는 기본적으로 복사 캡처입니다. 이동만 가능한 타입(unique_ptr 등)은 [=]만으로는 “이동 캡처”를 표현하기 어렵고, init-capture로 멤버 이름을 정한 뒤 std::move로 넣는 방식이 표준적인 해결입니다.

이동 의미가 일어나는 지점과 주의점

init-capture의 우변은 클로저 멤버를 초기화하는 표현식으로 평가됩니다. p = std::move(ptr)에서 ptr은 일반적으로 이동되고 빈 상태가 됩니다. 이후 외부 스코프에서 ptr역참조하면 안 됩니다. 이동은 “복사 비용을 없애는 최적화”가 아니라 소유권 이전일 때가 많습니다.

완벽 전달을 클로저로 넘길 때는 아래처럼 init-capture + std::forward를 쓰면, 람다가 한 번 감싼 호출 연산자가 되면서도 내부 callable의 값/참조 성격을 보존하기 쉽습니다.

template <class F>
void defer(F&& f) {
    std::thread([fn = std::forward<F>(f)]() mutable {
        fn();  // F가 lvalue면 복사, rvalue면 이동에 가깝게 캡처된 fn이 동작
    }).detach();
}

여기서 중요한 관찰은 두 가지입니다. 첫째, fn은 클로저의 멤버이므로 스레드가 살아 있는 동안 수명이 유지됩니다. 둘째, 외부 f 자체를 참조로 잡지 않았다면 f의 지역 수명 문제는 클로저 설계에서 분리됩니다(대신 F가 참조 타입이면 의미가 달라지므로 템플릿 설계를 주의합니다).

일반 캡처와의 조합

같은 람다에서 [a, p = std::move(ptr), &b]처럼 값·참조·init-capture를 섞을 수 있습니다. 이때도 참조 멤버의 수명은 앞 절의 규칙을 그대로 따릅니다.

실전 예시

예시 1: 카운터

makeCounter 함수의 구현 예제입니다.

auto makeCounter() {
    int count = 0;
    
    return [count]() mutable {
        return ++count;
    };
}

int main() {
    auto counter = makeCounter();
    
    cout << counter() << endl;  // 1
    cout << counter() << endl;  // 2
    cout << counter() << endl;  // 3
}

예시 2: 필터

vector<int> filterGreaterThan(const vector<int>& vec, int threshold) {
    vector<int> result;
    
    copy_if(vec.begin(), vec.end(), back_inserter(result),
        [threshold](int x) {
            return x > threshold;
        });
    
    return result;
}

int main() {
    vector<int> nums = {1, 5, 3, 8, 2, 9, 4};
    auto filtered = filterGreaterThan(nums, 5);
    
    for (int n : filtered) {
        cout << n << " ";
    }
    cout << endl;  // 8 9
}

예시 3: 이벤트 핸들러

class Button {
private:
    function<void()> onClick;
    
public:
    void setOnClick(function<void()> handler) {
        onClick = handler;
    }
    
    void click() {
        if (onClick) {
            onClick();
        }
    }
};

int main() {
    Button button;
    int clickCount = 0;
    
    // clickCount 참조 캡처
    button.setOnClick([&clickCount]() {
        clickCount++;
        cout << "클릭 " << clickCount << "회" << endl;
    });
    
    button.click();  // 클릭 1회
    button.click();  // 클릭 2회
    button.click();  // 클릭 3회
}

예시 4: 정렬

struct Person {
    string name;
    int age;
};

int main() {
    vector<Person> people = {
        {"Alice", 30},
        {"Bob", 25},
        {"Charlie", 35}
    };
    
    // age로 정렬 (지역 변수 캡처 없음: 비교에만 사용)
    sort(people.begin(), people.end(),
         [](const Person& a, const Person& b) {
            return a.age < b.age;
        });
    
    for (const auto& p : people) {
        cout << p.name << ": " << p.age << endl;
    }
    // Bob: 25
    // Alice: 30
    // Charlie: 35
}

this 캡처

class Counter {
private:
    int count = 0;
    
public:
    auto getIncrementer() {
        // this 캡처 (멤버 접근)
        return [this]() {
            return ++count;
        };
    }
    
    auto getIncrementerCopy() {
        // this 복사 (C++17)
        return [*this]() mutable {
            return ++count;  // 복사본 수정
        };
    }
    
    int getCount() const {
        return count;
    }
};

int main() {
    Counter counter;
    auto inc = counter.getIncrementer();
    
    cout << inc() << endl;  // 1
    cout << inc() << endl;  // 2
    cout << counter.getCount() << endl;  // 2
}

mutable 람다와 const operator()

기본 람다의 operator()const 멤버 함수로 생성됩니다. 그 결과 값으로 캡처된 멤버operator() 안에서 const 객체처럼 다뤄져, 멤버 자체를 수정할 수 없습니다(참조 캡처는 참조를 통해 비-const 대상을 수정할 수 있음).

mutable을 붙이면

mutable을 지정하면 operator()비-const 멤버 함수가 됩니다. 이제 값 캡처한 복사본을 본문에서 수정할 수 있습니다. 원본 자동 변수는 그대로이며, 바뀌는 것은 클로저 객체 내부의 멤버입니다.

int x = 10;

// 기본: operator() const → 값 캡처 멤버는 수정 불가
auto f1 = [x]() {
    // x++;  // 컴파일 오류
    return x;
};

// mutable: operator() → 값 캡처 멤버 수정 가능 (복사본)
auto f2 = [x]() mutable {
    x++;  // 멤버 x의 복사본
    return x;
};

std::cout << f2() << '\n';   // 11
std::cout << x << '\n';      // 10 (원본 유지)

const 람다를 호출하는 쪽과의 계약

std::sort, std::for_each 등 많은 알고리즘이 요구하는 함수 객체 호출이 const 호출인지 여부는 API마다 다르지만, 상태를 바꾸지 않는 술어(predicate) 라는 전제와 맞물려 기본 람다가 const인 설계가 이해하기 쉽습니다. 호출마다 내부 카운터를 올려야 한다면 mutable 값 캡처가 필요합니다(아래 makeCounter 예시와 동일한 논리).

스레드와 mutable

mutable로 같은 클로저를 여러 스레드에서 동시에 호출하면 데이터 경합이 됩니다. 카운터·캐시처럼 가변 상태가 필요하면 std::mutex, std::atomic, 또는 호출 단위로 분리된 상태를 설계해야 합니다.

const operator()가 값 캡처를 “막는” 정확한 이유

mutable이 없을 때 생성되는 operator()const T::operator()(...) const 형태입니다. C++에서 const 멤버 함수 안에서는 비정적 데이터 멤버const가 아닌 타입이라도, 멤버 접근을 통해 얻는 객체는 const 한정으로 취급됩니다(값 캡처된 int x 멤버는 const int처럼 동작). 그래서 값 캡처 변수를 본문에서 증가시키려면 mutable로 const 한정을 제거해야 합니다.

반면 참조 캡처 [&x]에서 x의 타입이 int&이면, const 멤버 함수 안에서도 참조를 통해 가리킨 객체const로 바뀌지는 않습니다(참조 자체는 const가 아니라, “가리키는 대상”의 const 여부가 따로 결정). 그래서 흔히 값 캡처는 mutable 없이 불변, 참조 캡처는 원본을 변경 가능이라는 대비가 생깁니다. 다만 이것은 동시성 안전을 의미하지 않습니다.

알고리즘·STL과의 계약: const 호출 가능성

많은 STL 구현은 내부에서 함수 객체를 const lvalue로 호출합니다. 그래서 호출 연산자가 const가 아닌 람다는 일부 시그니처에서 컴파일에 실패할 수 있습니다. 상태를 바꾸는 mutable 람다를 넘길 때는 std::ref로 래핑하거나, 설계를 std::shared_ptr/std::atomic으로 바깥에 두는 방식 등으로 맞추는 경우가 있습니다. “술어는 순수하게, 상태는 밖으로”가 기본 가이드입니다.

자주 발생하는 문제

문제 1: 댕글링 참조

C/C++ 예제 코드입니다.

// ❌ 댕글링 참조
function<int()> makeFunc() {
    int x = 10;
    return [&x]() { return x; };  // x는 소멸됨
}

auto f = makeFunc();
// cout << f() << endl;  // UB: x는 이미 소멸

// ✅ 값 캡처
function<int()> makeFunc() {
    int x = 10;
    return [x]() { return x; };  // 복사본
}

문제 2: 캡처 누락

C/C++ 예제 코드입니다.

int x = 10;
int y = 20;

// ❌ y 캡처 누락
auto f = [x]() {
    return x + y;  // 에러: y 캡처 안됨
};

// ✅ y 캡처
auto f = [x, y]() {
    return x + y;
};

// 또는 모든 변수 캡처
auto f = [=]() {
    return x + y;
};

문제 3: this 수명

class Widget {
public:
    auto getCallback() {
        // ❌ this 댕글링
        return [this]() {
            // Widget이 소멸되면 UB
        };
    }
    
    // ✅ shared_ptr 사용
    auto getCallback(shared_ptr<Widget> self) {
        return [self]() {
            // 안전
        };
    }
};

캡처 방식 정리

C/C++ 예제 코드입니다.

[]        // 캡처 없음
[x]       // x를 값으로 캡처
[&x]      // x를 참조로 캡처
[=]       // 모든 변수 값 캡처
[&]       // 모든 변수 참조 캡처
[=, &x]   // x는 참조, 나머지는 값
[&, x]    // x는 값, 나머지는 참조
[this]    // this 포인터 캡처
[*this]   // this 객체 복사 (C++17)
[x = 42]  // 초기화 캡처 (C++14)

프로덕션에서의 람다 캡처 패턴

실서비스 코드에서는 “캡처가 객체 수명·스레드·예외 안전성에 미치는 영향”을 기준으로 패턴을 고릅니다. 아래는 자주 쓰이는 결정과 예시입니다.

안전성 체크리스트(요약)

  • 저장·반환·비동기가 한 번이라도 있으면: 참조 캡처 금지를 기본 가정하고, 값/shared_ptr/init-capture 이동으로 증명 가능한 수명을 만든다.
  • 명시적 캡처: [=]/[&]보다 [x, &y]처럼 의도를 드러내 리뷰·리팩터링 비용을 줄인다.
  • this: 비동기·지연 호출이면 [this] 대신 weak_ptr + lock, shared_from_this, 또는 [*this](복사 스냅샷) 등을 검토한다.
  • 스레드: mutable 내부 상태·static 지역 변수는 경합이 기본값이다. atomic/mutex/불변 설계로 분리한다.
  • 검증: 수명·UB 의심 구간은 ASan/UBSan, 스레드는 TSan으로 교차 검증한다.

1. 지연 실행·큐: 값 캡처 vs 참조

작업을 컨테이너에 저장했다가 나중에 실행한다면, 지역 변수 참조 캡처는 금지에 가깝습니다. 큐에 넣는 시점에 필요한 값은 복사하거나, 무거우면 이동 init-capture로 소유권을 넘깁니다.

class TaskScheduler {
    std::vector<std::function<void()>> tasks_;
public:
    void schedule(std::function<void()> task) { tasks_.push_back(std::move(task)); }
    void executeAll() {
        for (auto& task : tasks_) task();
        tasks_.clear();
    }
};

void example() {
    TaskScheduler scheduler;
    int x = 10;

    scheduler.schedule([x]{ std::cout << "작업 1: " << x << '\n'; });  // 저장 지연에도 안전

    scheduler.schedule([&x]{ x++; std::cout << "작업 2: " << x << '\n'; });  // executeAll()이
    // 같은 함수 안·같은 스코프에서 바로 도는 한 OK. 큐에 오래 두고 스코프 밖에서 실행하면 UB

    scheduler.executeAll();
}

2. 비동기·콜백: shared_ptr로 수명 연장

객체 this[this]로 넘기면 객체 파괴 후 호출 시 UB입니다. 콜백이 비동기로 살아남으면 weak_ptr/shared_ptr 패턴이나 값 캡처 복사([*this] C++17) 를 검토합니다.

#include <memory>
#include <iostream>

struct Worker : std::enable_shared_from_this<Worker> {
    void asyncWork() {
        auto self = shared_from_this();
        std::thread([self]() {
            std::cout << "안전: 수명이 shared_ptr으로 연장됨\n";
        }).detach();
    }
};

3. 알고리즘에 넘기는 술어: 캡처 최소화

sort, find_if 등에 넘기는 람다는 가능하면 캡처 없이([]) 작성하고, 필요한 값만 명시적으로 캡처합니다. [=]/[&] 남용은 나중에 리팩터링 시 의도치 않은 캡처를 숨깁니다.

int threshold = 5;
std::vector<int> v = {1, 6, 3, 8};
const auto it = std::find_if(v.begin(), v.end(),
    [threshold](int n) { return n > threshold; });  // threshold만 명시

4. 이동만 가능한 리소스: init-capture

unique_ptr, 소켓 핸들 등 이동 전용 자원은 [p = std::move(ptr)] 패턴으로 클로저가 소유하게 만듭니다. 앞 절 “초기화 캡처와 이동 의미론”과 동일합니다.

5. 스레드에 넘기기: std::forward와 값 캡처

스레드 함수에 또 다른 callable을 넘길 때는 템플릿 + init-capture로 완벽한 전달 자격을 유지합니다.

template<typename F>
void runLater(F&& f) {
    std::thread([fn = std::forward<F>(f)]() mutable {
        fn();
    }).detach();
}

6. 기존 예시: 콜백 체인·상태 머신

아래는 지연 실행·비동기 콜백·상태 머신의 기본 형태입니다. 상태 머신에서 [&]로 서로를 참조하면 수명이 꼬이기 쉬우므로, 객체 설계를 명시적 상태 구조체로 두는 편이 유지보수에 유리합니다.

class AsyncOperation {
public:
    template<typename F>
    void then(F&& callback) {
        std::thread([callback = std::forward<F>(callback)]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            callback();
        }).detach();
    }
};

class StateMachine {
    std::function<void()> currentState_;
public:
    void setState(std::function<void()> state) { currentState_ = std::move(state); }
    void execute() { if (currentState_) currentState_(); }
};

요약: 저장·비동기·반환되는 람다는 값·스마트 포인터·이동 캡처를 우선하고, 짧은 동기 스코프에서만 참조 캡처로 비용을 줄입니다.

7. 예외·조기 반환에서도 수명은 동일하다

람다를 호출하기 전에 캡처 대상이 파괴되면(예: 스코프 중간의 조기 return, 예외) 참조 캡처는 동일하게 위험합니다. “콜백 등록은 성공했는데 객체는 이미 끝났다”는 형태는 비동기 프레임워크에서 특히 잘 나옵니다. 이때는 약한 참조 + 생존 확인 또는 값 복사 비용과 트레이드오프를 명시적으로 선택합니다.

FAQ

Q1: 값 캡처 vs 참조 캡처?

A:

  • 값 캡처 [x]: 안전 (복사본 사용), 복사 비용 발생, 원본 변경 불가
  • 참조 캡처 [&x]: 빠름 (복사 없음), 댕글링 위험, 원본 변경 가능

C/C++ 예제 코드입니다.

int x = 10;

// 값 캡처: 안전하지만 복사 비용
auto f1 = [x]() { return x; };

// 참조 캡처: 빠르지만 수명 주의
auto f2 = [&x]() { return x; };

선택 기준:

  • 람다가 함수 밖으로 반환되면: 값 캡처
  • 람다가 로컬에서만 사용되면: 참조 캡처

Q2: mutable은 언제 사용하나요?

A: 값 캡처한 변수를 수정할 때 사용합니다. 값 캡처는 기본적으로 const이므로 mutable이 필요합니다.

C/C++ 예제 코드입니다.

int x = 10;

// ❌ 에러: 값 캡처는 const
auto f1 = [x]() {
    // x++;  // 에러
};

// ✅ mutable: 복사본 수정 가능
auto f2 = [x]() mutable {
    x++;  // OK (복사본 수정)
    return x;
};

std::cout << f2() << '\n';  // 11
std::cout << x << '\n';     // 10 (원본 유지)

Q3: [=] vs [&]?

A:

  • [=]: 모든 변수를 값으로 캡처 (안전, 복사 비용)
  • [&]: 모든 변수를 참조로 캡처 (빠름, 댕글링 위험)

C/C++ 예제 코드입니다.

int x = 10, y = 20;

// [=]: 모든 변수 값 캡처
auto f1 = [=]() { return x + y; };

// [&]: 모든 변수 참조 캡처
auto f2 = [&]() { return x + y; };

실무 권장: 명시적 캡처 [x, &y]가 더 명확하고 안전합니다.

Q4: this 캡처는 언제 사용하나요?

A: 멤버 함수에서 멤버 변수나 멤버 함수에 접근할 때 사용합니다.

class Counter {
    int count_ = 0;
    
public:
    auto getIncrementer() {
        // [this]: this 포인터 캡처
        return [this]() {
            return ++count_;
        };
    }
    
    auto getIncrementerCopy() {
        // [*this]: 객체 복사 (C++17)
        return [*this]() mutable {
            return ++count_;  // 복사본 수정
        };
    }
};

Q5: 초기화 캡처는 무엇인가요?

A: C++14에서 도입된 기능으로, 캡처 시 새 변수를 생성하거나 이동 캡처를 수행합니다.

C/C++ 예제 코드입니다.

// 새 변수 생성
auto f1 = [x = 42]() { return x; };

// 이동 캡처
auto ptr = std::make_unique<int>(10);
auto f2 = [p = std::move(ptr)]() {
    return *p;
};

// 표현식 캡처
int x = 10;
auto f3 = [y = x * 2]() { return y; };

Q6: 람다 캡처 시 성능 고려사항은?

A:

  • 값 캡처: 복사 비용 (큰 객체는 참조 권장)
  • 참조 캡처: 복사 없음 (빠름)
  • 이동 캡처: 복사 없이 소유권 이전 (C++14)

C/C++ 예제 코드입니다.

std::vector<int> vec(1000000);

// ❌ 값 캡처: 큰 복사 비용
auto f1 = [vec]() { return vec.size(); };

// ✅ 참조 캡처: 복사 없음
auto f2 = [&vec]() { return vec.size(); };

// ✅ 이동 캡처: 소유권 이전
auto f3 = [vec = std::move(vec)]() { return vec.size(); };

Q7: 람다 캡처 학습 리소스는?

A:

관련 글: Lambda Complete, Init Capture.

한 줄 요약: 람다 캡처는 외부 변수를 값 또는 참조로 캡처하여 람다 내부에서 사용할 수 있게 합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 람다 캡처 | ‘Lambda Capture’ 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 람다 캡처 | ‘Lambda Capture’ 완벽 가이드」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, lambda, capture, 람다, 클로저 등으로 검색하시면 이 글이 도움이 됩니다.