C++/Concepts

C++ Callback 코드에서 Lambda Capture를 안전하게 쓰는 법

Enchantée 2026. 7. 16. 21:34
728x90
반응형

C++ lambda는 문법만 보면 짧고 편합니다.

하지만 lambda가 std::function에 저장되거나 callback queue, event handler, async task로 넘어가면 capture한 값의 lifetime이 훨씬 중요해집니다.

특히 reference capture는 코드가 짧아 보이지만, lambda가 local variable보다 오래 살아남는 순간 dangling reference를 만들 수 있습니다.

이번 글에서는 C++17 기준으로 lambda capture의 lifetime을 예제 코드와 함께 정리해보겠습니다.

 

lambda capture에서 중요한 질문은 “무엇을 capture했는가”보다 “lambda가 capture 대상보다 오래 살아남을 수 있는가”입니다.

 

lambda가 즉시 실행되는지, 저장되어 나중에 실행되는지에 따라 capture 방식의 안전성이 달라집니다.

 


1. lambda capture는 무엇을 저장하는가?

lambda는 주변 scope의 변수를 capture해서 나중에 함수처럼 호출할 수 있는 closure object를 만듭니다.

capture 방식은 크게 value capture와 reference capture로 나눌 수 있습니다.

방식 예시 의미 주요 위험
value capture [count] lambda 객체 안에 값을 복사해서 저장 복사 비용, 오래된 snapshot 사용
reference capture [&count] 외부 변수 자체를 참조 외부 변수가 먼저 사라지면 dangling reference
default value capture [=] 사용한 외부 값을 기본적으로 복사 의도하지 않은 복사, this capture 혼동
default reference capture [&] 사용한 외부 값을 기본적으로 참조 저장형 callback에서 lifetime 추적이 어려움

value capture는 lambda가 만들어지는 시점의 값을 lambda 객체 안에 저장합니다.

reference capture는 값을 복사하지 않고 기존 변수를 바라봅니다.

따라서 reference capture는 참조 대상이 lambda 호출 시점까지 살아 있다는 보장이 있을 때만 안전합니다.

 


2. 왜 중요한가?

문제는 lambda가 항상 그 자리에서 바로 실행되지 않는다는 점입니다.

실무 코드에서는 lambda를 std::function에 넣어 저장하거나, task queue에 등록하거나, UI event handler와 network callback으로 넘기는 일이 많습니다.

이 경우 lambda의 실행 시점은 capture가 일어난 scope보다 늦을 수 있습니다.

  1. 함수 안에서 local variable을 만든다.
  2. lambda가 local variable을 reference로 capture한다.
  3. lambda를 std::function이나 queue에 저장한다.
  4. 함수가 return되며 local variable의 lifetime이 끝난다.
  5. 나중에 저장된 lambda를 호출하면 이미 사라진 객체를 참조한다.

이 흐름은 컴파일러가 항상 막아주지 못합니다.

코드는 컴파일될 수 있지만, 실행 시점에는 undefined behavior가 됩니다.

 


3. 핵심 개념: closure object와 lifetime

lambda expression은 호출 가능한 객체를 만듭니다.

이 객체를 closure object라고 볼 수 있고, capture한 값들은 이 객체의 내부 상태가 됩니다.

value capture라면 내부 상태에 값이 복사됩니다.

reference capture라면 내부 상태에 참조가 저장된 것처럼 동작합니다.

따라서 lambda 자체의 lifetime과 capture 대상의 lifetime을 따로 봐야 합니다.

상황 reference capture 안전성 권장 판단
lambda를 즉시 호출 대체로 안전 읽기 쉬운 범위에서 사용 가능
algorithm 호출에 잠깐 전달 대체로 안전 std::sort, std::for_each처럼 호출 범위가 명확하면 가능
std::function에 저장 위험 가능성 높음 value capture 또는 명시적 ownership 검토
async, event, callback으로 등록 위험 가능성 높음 lifetime 보장을 코드로 드러내야 함

reference capture는 짧은 synchronous scope에서는 유용합니다.

하지만 callable이 저장되는 순간부터는 lifetime을 증명해야 하는 비용이 생깁니다.

 


4. 예제 코드

다음 예제는 value capture와 reference capture의 차이, 그리고 std::function에 저장되는 lambda에서 value capture가 어떤 의미를 갖는지 보여줍니다.

#include <functional>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class TaskQueue {
public:
    void push(std::function<void()> task)
    {
        tasks_.push_back(std::move(task));
    }

    void run_all()
    {
        for (const auto& task : tasks_) {
            task();
        }
    }

private:
    std::vector<std::function<void()>> tasks_;
};

int main()
{
    int retry_count = 1;

    auto by_value = [retry_count]() {
        std::cout << "by value: " << retry_count << '\n';
    };

    auto by_reference = [&retry_count]() {
        std::cout << "by reference: " << retry_count << '\n';
    };

    retry_count = 3;

    by_value();
    by_reference();

    TaskQueue queue;
    std::string message = "upload finished";

    queue.push([message]() {
        std::cout << "stored task: " << message << '\n';
    });

    message = "changed after enqueue";

    queue.run_all();
}

 

실행 결과

by value: 1
by reference: 3
stored task: upload finished

 

by_value는 lambda가 만들어진 시점의 retry_count 값인 1을 저장합니다.

by_reference는 외부 변수 retry_count를 바라보므로 변경 후 값인 3을 출력합니다.

queue.push([message] { ... })는 message를 value로 capture했기 때문에, 원본 문자열이 나중에 바뀌어도 저장된 task는 처음 값을 유지합니다.

 


5. 위험한 예제: 저장된 reference capture

아래 코드는 컴파일될 수 있지만, 실제 코드에서는 사용하면 안 되는 패턴입니다.

함수가 끝난 뒤 사라질 local variable을 reference로 capture한 lambda를 반환하기 때문입니다.

#include <functional>
#include <iostream>
#include <string>

std::function<void()> make_bad_task()
{
    std::string message = "temporary";
    return [&message]() {
        std::cout << message << '\n';
    };
}

 

상태 변화

make_bad_task() 진입
message local object 생성
lambda가 message를 reference로 capture
std::function에 lambda 저장 후 return
make_bad_task() 종료
message local object 파괴
저장된 lambda는 더 이상 유효하지 않은 message를 바라봄

 

핵심은 std::function이 lambda 객체를 저장할 수는 있어도, reference capture 대상의 lifetime을 자동으로 연장하지는 않는다는 점입니다.

이 패턴은 테스트 환경에서는 우연히 정상처럼 보일 수 있지만, undefined behavior이므로 결과를 신뢰할 수 없습니다.

 


6. 그림으로 이해하기

value capture는 lambda 객체 안에 독립적인 값을 남기지만, reference capture는 원본 객체의 lifetime이 끝나면 더 이상 안전하지 않습니다.

 

그림에서 중요한 차이는 저장 위치가 아니라 ownership입니다.

value capture는 lambda가 사용할 값을 closure object 안에 들고 있습니다.

reference capture는 외부 객체를 빌려 쓰는 구조이므로, 외부 객체가 먼저 사라지면 lambda 내부에는 유효하지 않은 연결만 남습니다.

 


7. 자주 하는 오해

  1. std::function에 넣으면 capture 대상도 함께 안전해진다.
    std::function은 callable 객체를 저장할 뿐입니다. reference capture 대상의 lifetime을 연장하지 않습니다.
  2. [&]를 쓰면 불필요한 복사를 피하므로 항상 좋다.
    즉시 실행되는 작은 scope에서는 편할 수 있지만, 저장되는 callback에서는 lifetime 추적 비용이 커집니다.
  3. [=]는 모든 것을 value로 안전하게 복사한다.
    member function 안에서 this가 관련되면 생각보다 복잡해집니다. C++17에서는 [=]가 member 접근에 대해 this를 capture하는 흐름을 만들 수 있으므로 객체 lifetime을 별도로 봐야 합니다.
  4. string literal처럼 보이는 값은 항상 안전하다.
    std::string_view나 reference가 local std::string을 가리키고 있다면 안전하지 않을 수 있습니다. 겉보기 타입보다 실제로 가리키는 대상의 lifetime이 중요합니다.
  5. 컴파일이 되면 lifetime도 검증된 것이다.
    dangling reference 문제는 컴파일이 통과해도 남을 수 있습니다. 저장형 callback에서는 코드 리뷰와 테스트 설계가 필요합니다.

lambda capture는 간결한 문법 때문에 위험이 작아 보이기 쉽습니다.

하지만 capture list는 작은 ownership 설계라고 보고 읽는 편이 더 안전합니다.

 


8. 실무에서는 어떻게 볼까?

실무에서는 lambda가 저장되는지를 먼저 봐야 합니다.

저장되지 않고 바로 실행되는 lambda라면 reference capture가 자연스러운 경우도 많습니다.

반대로 std::function, thread, timer, event loop, network callback, UI callback으로 넘어가는 lambda는 보수적으로 다뤄야 합니다.

코드 리뷰 질문 확인할 내용 권장 방향
lambda가 저장되는가? std::function, queue, callback registry에 들어가는지 확인 저장된다면 reference capture를 의심
capture 대상이 local variable인가? 함수 종료 후 사라지는 객체인지 확인 value capture 또는 소유권 있는 객체 사용
member function에서 this를 capture하는가? 객체가 callback보다 오래 사는지 확인 shared_ptr, weak_ptr, 명시적 해제 흐름 검토
큰 객체를 value capture하는가? 복사 비용과 snapshot 의미를 확인 필요한 데이터만 복사하거나 shared ownership 검토

저장형 callback에서 가장 단순하고 안전한 기본값은 필요한 데이터를 명시적으로 value capture하는 것입니다.

큰 객체를 매번 복사하기 부담스럽다면 std::shared_ptr로 lifetime을 공유하거나, std::weak_ptr로 객체가 살아 있을 때만 작업하는 구조를 검토할 수 있습니다.

중요한 것은 capture list만 보고도 lifetime 의도가 드러나야 한다는 점입니다.

 


9. 정리

lambda capture는 closure object가 외부 값을 어떻게 보관할지 정하는 문법입니다.

value capture는 생성 시점의 값을 lambda 객체 안에 저장하고, reference capture는 외부 객체를 빌려 씁니다.

lambda가 즉시 실행된다면 reference capture도 자연스러울 수 있지만, 저장형 callback에서는 위험해질 수 있습니다.

std::function은 callable을 저장할 뿐 reference capture 대상의 lifetime을 연장하지 않습니다.

async, event, timer, callback 코드에서는 capture list를 ownership 설계로 보고 명시적으로 작성하는 편이 안전합니다.

 

저장되는 lambda라면 reference capture를 먼저 의심하고, 안전성을 설명할 수 있을 때만 사용하세요.

 


728x90
반응형