C++ 람다 참조 캡처 심화 — 수명 의미론, 캡처 규칙, ASAN, 실무 안전 패턴

이 글의 핵심

참조 캡처는 클로저에 ‘참조 멤버’를 싣는 것과 같습니다. 컴파일러는 대부분의 잘못된 수명을 막지 않으므로, 캡처 목록이 어떻게 해석되는지와 ASAN·실무 패턴을 함께 알아야 합니다.

왜 참조 캡처만 따로 짚어야 하는가

값 캡처([x])는 클로저 객체 안에 복사본을 두므로, 외부 스코프가 끝난 뒤에도 그 복사본은 클로저 수명에 묶입니다. 반면 참조 캡처([&x], [&])는 클로저가 원본 객체에 대한 별칭(alias) 을 유지할 뿐이며, 원본의 저장 기간(storage duration)은 변하지 않습니다. 따라서 “람다가 언제 실행되느냐”와 “참조 대상이 그 시점에 유효하느냐”가 분리되어, 잘못 설계하면 미정의 동작(UB) 으로 이어집니다. 이 글에서는 표준 용어에 가깝게 수명(lifetime), 캡처 목록 해석, 검출 수단, 실무 패턴 순으로 정리합니다.

참조 캡처의 수명 의미론

클로저 모델: 참조 캡처는 원본을 가리킬 뿐

람다 표현식은 고유한 클로저 타입의 객체(prvalue)로 평가됩니다. 값 캡처는 그 클로저의 데이터 멤버가 되지만, 참조 캡처에 대해서는 표준이 데이터 멤버를 만들지 여부를 정하지 않았습니다(unspecified). 구현은 보통 원본의 주소나 원본이 있는 스택 프레임의 주소를 클로저에 넣어 두는 식으로 처리합니다. 어느 쪽이든 클로저는 원본 객체를 가리키기만 하므로, 클로저가 얼마나 오래 살든 대상의 유효성은 원래 객체의 수명 규칙을 따릅니다. 즉, 클로저가 살아 있어도 참조가 가리키는 스택 프레임의 지역 변수는 이미 파괴되었을 수 있습니다.

“나중에 호출”이 문제의 핵심

동일 스코프에서 즉시 f()를 호출하는 경우, 참조 캡처는 종종 안전하게 보입니다. 그러나 다음 경우에는 스택 변수의 수명이 클로저의 사용 구간보다 짧아질 수 있습니다.

  • std::function, 컨테이너, 작업 큐 등에 저장 후 비동기 호출
  • 반환되는 람다(return [&]{ ... };)
  • 다른 스레드가 나중에 실행 (조인·조건 변수 전까지 대상이 유효함을 보장하지 않는 경우)

이때 참조 캡처는 댕글링 참조가 되며, 읽기·쓰기 모두 UB입니다.

[this]와 *this: 포인터 수명과 객체 수명

멤버 함수 안에서 [this]는 객체의 주소(Widget*)를 값으로 캡처합니다. [*this](C++17)는 객체 전체를 복사해 클로저에 스냅샷을 둡니다. [this]는 객체가 파괴된 뒤 콜백이 호출되면 UB이고, [*this]는 복사 비용과 복사 시점의 상태라는 의미론을 갖습니다. 비동기·지연 실행에서는 [this]보다 약한 참조(weak_ptr) + 잠금이나 객체 스냅샷([*this])·값 의미 설계가 필요한 경우가 많습니다.

#include <functional>
#include <iostream>

struct Widget {
    int v = 0;
    std::function<void()> defer() {
        // this는 Widget* — 객체 수명은 호출자 책임
        return [this] { ++v; };
    }
};

int main() {
    std::function<void()> f;
    {
        Widget w;
        f = w.defer();
    } // w 파괴 → f()는 UB
    // f();  // ASAN 등에서 잡히는 전형적 패턴
    (void)f;
}

댕글링 참조 탐지

컴파일러가 하지 않는 것

C++는 Rust와 달리 대부분의 수명 오류를 컴파일 타임에 검사하지 않습니다. 참조 캡처가 문법적으로 허용되면, 대상이 실행 시점에 살아 있는지는 프로그래머가 지켜야 할 불변식(invariant)으로 남습니다.

정적 분석과 경고

클랭 계열에서는 -Wall -Wextra를 기본으로 두며, clang-tidy의 cppcoreguidelines-*, clang-analyzer 계열 규칙으로 일부 패턴을 짚을 수 있습니다. 컴파일러·버전마다 경고 이름이 다르므로, 팀 빌드 설정에 캡처·람다 관련 경고가 포함돼 있는지 확인하는 편이 좋습니다. 다만 가짜 양성과 미검출이 공존하므로, “경고 없음 = 안전”이 아닙니다.

코드 리뷰에서 보는 신호

리뷰에서 가장 먼저 의심할 것은 std::function이나 콜백으로 반환·저장되는 람다가 [&]나 지역 변수 참조를 캡처하는 경우입니다. 루프 변수를 [&i]로 캡처해 비동기 작업에 넘기면 반복이 끝난 뒤 실행될 때 이미 사라진 변수를 읽게 되고, 지역 버퍼를 참조로 캡처해 네트워크 콜백에 넘기는 것도 같은 문제입니다. 이런 패턴은 값 복사, init-capture로 복사본 멤버 생성, shared_ptr로 수명 연장 등으로 바꾸는 것이 안전합니다.

댕글링 참조 탐지 패턴(카탈로그)

아래는 코드 리뷰·정적 분석에서 반복적으로 등장하는 패턴입니다. 원리는 모두 “참조·포인터가 가리키는 저장소의 수명이 클로저 실행보다 짧다”는 점입니다.

패턴증상안전한 대안
반환 람다 return [&]{ ... };호출자가 받은 후 지역이 파괴[=]·이동 캡처·shared_ptr
비동기·큐에 [&buf]워커 실행 시 스택 버퍼 소멸vector 복사·string 소유·힙 버퍼
루프 변수 [&i]로 스레드 생성반복 종료 후 i는 유효하지 않음[i] 값 캡처·std::size_t idx = i 복사
std::async + 참조future가 지역 참조를 붙잡음필요한 인자만 값·스마트 포인터로
콜백에 this객체 파괴 후 디스패치weak_ptr·[*this]·명시 수명 계약
임시 객체의 메서드foo().defer([&]{ ... })류임시 수명 연장 범위 확인·값 캡처

코루틴(C++20)에서는 값 캡처조차 안전하지 않을 수 있습니다. 람다 자체가 코루틴이면 코루틴 프레임에 복사되는 것은 매개변수뿐이고, 캡처는 클로저 객체 안에 남습니다. 그래서 [x]() -> task<int> { co_await ...; co_return x; }()처럼 임시 클로저로 바로 호출하면, 첫 co_await에서 일시 중단된 사이 임시 클로저가 파괴되어 재개 후 x를 읽는 순간 댕글링이 됩니다. 이 경우에는 필요한 값을 캡처 대신 매개변수로 넘기거나, C++23의 명시적 객체 매개변수(this auto self)를 값으로 받아 클로저를 프레임에 복사하는 방식을 씁니다.

IIFE와 참조 캡처의 경계

IIFE([](){ }())는 보통 같은 블록 안에서 즉시 실행되므로 [&]가 그 블록의 지역 변수를 참조하는 한 수명이 맞아 떨어져 안전한 경우가 많습니다. 하지만 IIFE의 결과로 람다를 반환하거나 std::function에 저장하는 순간 일반 규칙으로 돌아가고, IIFE 안에서 만든 std::thread를 detach하면 참조 대상이 스코프를 벗어난 뒤에 실행될 수 있습니다.

std::function<int()> make_bad() {
    int x = 42;
    return [&] { return x; };  // make_bad() 반환 후 x는 소멸 — 호출 시 UB
}

void iife_ok() {
    int base = 10;
    const int v = [&] {
        return base + 1;  // 즉시 실행되므로 base가 살아 있는 동안만 참조
    }();
    (void)v;
}

첫 번째는 지역을 참조로 들고 나가는 전형적 UB입니다. 두 번째는 IIFE가 같은 블록에서 끝나므로 참조가 실행 구간에만 유효합니다. 프로덕션에서는 IIFE를 순수 계산·const 초기화에 두며, 저장·반환되는 클로저는 값 캡처·이동으로 수명을 코드에 드러냅니다.

캡처 목록 해석 규칙

이 절에서는 표준의 의미 모델에 가깝게, 캡처 목록이 어떤 순서로 해석되고 어떤 이름이 “포획”되는지를 정리합니다. 구현 세부는 컴파일러마다 다르지만, 프로그래머가 예측해야 할 불변식은 동일합니다.

이름 조회와 “캡처 가능한” 엔티티

캡처 목록에 나오는 각 항목은 바깥 블록 스코프에서 이름 조회됩니다. 캡처 대상이 될 수 있는 것은 자동 저장 기간을 가진 변수(지역 변수와 매개변수)와 this뿐입니다. static 지역 변수, 전역 변수, thread_local 변수는 캡처되지 않고 람다 본문에서 그대로 접근하며, [&g]처럼 명시적으로 캡처하려 하면 컴파일 오류입니다. 그래서 [=]로 값 캡처했다고 생각한 static 카운터가 실제로는 공유 상태로 수정되는 일이 생깁니다.

  • 람다 본문에서 실제로 사용되는 자동 변수·this 등은, 기본 캡처가 있으면 기본 규칙에 따라 멤버로 옮겨집니다(값 또는 참조). 본문에서 전혀 쓰이지 않는 이름은 캡처되지 않습니다.
  • 명시 캡처에 등장하는 이름은 반드시 그 방식(값·참조·init)으로 캡처됩니다. 기본 캡처와 같은 방식으로 다시 쓰는 [=, x]나 [&, &x]는 컴파일 오류이고, 같은 이름을 두 번 쓰는 것도 오류입니다.
  • 중첩 람다에서는 바깥 람다가 만든 클로저의 멤버를 안쪽에서 참조할 수 있습니다. 이때 안쪽 람다의 [&]는 “직접 바깥 블록”만이 아니라 상위 캡처 체인과 얽히므로, 어느 스코프의 수명에 묶이는지를 단계별로 추적해야 합니다.

기본 캡처와 명시 캡처의 조합

[=, &x]는 기본은 값 캡처이고 x만 참조로 캡처합니다. [&, x]는 반대로 기본은 참조이고 x만 값으로 캡처합니다. 즉 기본 캡처가 본문에서 쓰이는 자동 변수 전체의 정책을 정하고, 명시 항목은 그 예외를 지정합니다. 스타일 가이드에서는 [&] 단독 사용을 지양하고 [buf, &ctx]처럼 읽히는 목록을 요구하기도 합니다.

init-capture [id = expr]는 새 이름을 도입하므로, [x = x]처럼 바깥 이름과 같은 식별자를 쓰면 람다 본문의 x는 클로저 쪽 복사본을 가리킵니다.

this·*this와 기본 캡처의 상호작용

비정적 멤버 함수 안에서 멤버에 접근하면 암시적으로 this가 관여합니다. C++17까지는 [=]만 써도 멤버를 사용하면 this가 암시적으로 캡처되었는데, 이름과 달리 객체가 아니라 포인터가 복사되므로 값 캡처라는 착각을 낳았습니다. 그래서 C++20에서 [=]를 통한 this 암시 캡처는 deprecated되었고, 필요하면 [=, this]처럼 명시해야 합니다. [*this]는 “객체 전체 복사”로 값 의미를 분리하며, [this]는 주소만 들고 갑니다. 다형 객체에서 [*this]는 슬라이싱을 일으킬 수 있으므로, 기본 클래스 서브객체만 복사되는지 반드시 검토합니다.

구조화 바인딩·std::tie와 참조 캡처

구조화 바인딩은 C++20부터 람다로 캡처할 수 있습니다(그 전에는 컴파일 오류). auto [a, b] = tup;에서 a와 b는 새 변수가 아니라, 컴파일러가 만든 숨은 복사본의 원소를 가리키는 이름입니다. auto& [a, b] = tup;이면 tup 자체의 원소를 가리킵니다. 어느 쪽이든 그 저장소는 선언된 블록의 지역 객체이므로, [&a]로 비동기 작업에 넘기면 블록이 끝난 뒤 댕글링이 됩니다. 안전한 패턴은 [tup = std::move(tup)]로 통째로 이동 캡처하거나, 필요한 필드를 값으로 복사해 캡처하는 것입니다.

init-capture(C++14)와 참조

[name = expr] 형태는 auto name = expr;처럼 타입을 추론해 새 클로저 멤버를 만듭니다. auto 추론이므로 expr이 참조를 반환해도 복사본이 됩니다. 참조가 필요하면 [&name = expr]로 명시해야 하고, 이때는 일반 참조 캡처와 같은 수명 문제가 생깁니다. [r = std::ref(x)]도 reference_wrapper를 복사할 뿐 결국 x를 가리키므로 참조 캡처와 다를 바 없습니다.

#include <iostream>
#include <utility>
#include <vector>

void example() {
    std::vector<int> heavy = {1, 2, 3};
    // 이동으로 클로저가 소유. 이후 heavy는 이동된 상태(보통 비어 있음)
    auto ok = [v = std::move(heavy)]() { return v.size(); };
    std::cout << ok() << '\n';
}

AddressSanitizer로 검출하기

AddressSanitizer(ASAN) 은 힙·스택·전역 등 잘못된 주소 접근을 런타임에 잡는 데 유리합니다. 참조 캡처로 생기는 오류는 대개 블록이 끝난 지역 변수에 접근하는 stack-use-after-scope나, 함수가 반환된 뒤 그 지역 변수에 접근하는 stack-use-after-return입니다. 앞의 것은 Clang과 최신 GCC에서 기본으로 검사하지만, 뒤의 것은 컴파일러 버전과 플랫폼에 따라 ASAN_OPTIONS=detect_stack_use_after_return=1 같은 런타임 옵션을 켜야 잡힙니다. 다만 UB 전부를 잡는 것은 아니므로 “ASAN 통과 = 증명”은 불가합니다.

대표적인 컴파일러 플래그 (Clang/GCC)

개발·CI용 바이너리에 예시로 다음을 고려할 수 있습니다.

-fsanitize=address -g -O1 -fno-omit-frame-pointer

-g와 -fno-omit-frame-pointer는 보고서의 스택 트레이스를 읽기 쉽게 해 주고, -O1은 ASan 문서가 적당한 실행 속도를 위해 권장하는 수준입니다. 최적화를 더 올리면 인라이닝 때문에 트레이스가 덜 정확해질 수 있습니다. Windows MSVC에서는 /fsanitize=address 계열(버전에 따름)을 별도로 확인합니다.

기대할 수 있는 것과 한계

ASan은 파괴된 스택 변수 접근이나 힙 오버플로 같은 메모리 오류를 실행 중에 잡아 줍니다. 하지만 데이터 레이스는 보지 못하므로 스레드에 넘기는 람다는 ThreadSanitizer(-fsanitize=thread) 빌드로 따로 확인해야 하고, 부호 있는 정수 오버플로 같은 다른 종류의 미정의 동작은 UBSan(-fsanitize=undefined)이 담당합니다. 또 해당 경로가 테스트에서 실행되지 않으면 아무것도 보고되지 않습니다.

프로덕션에서 안전한 캡처 패턴

제네릭 람다와 참조 캡처의 결합

제네릭 람다의 operator()가 템플릿이어도, 캡처 멤버의 수명 규칙은 변하지 않습니다. auto 매개변수에 완벽 전달을 하면서 바깥 [&r]를 붙잡으면, 인스턴스화된 각 호출에서 r이 유효하다는 보장은 여전히 사용자가 책임져야 합니다. 템플릿 인자 추론은 호출 인자에만 적용되고, 캡처는 클로저 생성 시점에 고정됩니다. 따라서 비동기로 넘기는 제네릭 람다는 [=]·스마트 포인터·값 의미로 소유권을 분리하는 편이 안전합니다.

선택 기준

클로저가 스코프 밖으로 나가는가권장
예 (저장·반환·큐·스레드)값 캡처, init-capture 이동, shared_ptr로 수명 공유
아니오 (동기·즉시 호출 한정)참조 캡처 가능 — 그래도 명시적 목록 권장

명시적 캡처와 작은 복사

[&]는 “지금 이 블록의 모든 것”을 참조로 붙잡아 리팩터링에 취약합니다. 프로덕션 코드에서는 [data, &ctx] 처럼 의존성을 목록에 드러내는 편이 유지보수에 유리합니다.

비동기 콜백: 소유권 이전

unique_ptr·대용량 vector는 [p = std::move(p)] 패턴으로 클로저가 소유하도록 옮기면, 지역 스택과의 수명 문제를 끊을 수 있습니다. 공유가 필요하면 std::shared_ptr 과 weak_ptr로 순환 참조 방지를 함께 설계합니다.

스레드 풀·작업 큐

조인 전에만 유효한 지역 변수를 참조 캡처하면 안 됩니다. 풀 워커가 나중에 실행한다면 값이나 힙에 둔 상태의 스마트 포인터로 넘깁니다.

관찰자·콜백 등록 API

GUI·네트워크 라이브러리에서 등록한 콜백이 객체 소멸 이후 호출되면 UB입니다. [this]만 두지 말고 weak_ptr로 객체 존재를 확인하거나, 명시적 해제 API를 제공합니다. 참조 캡처로 “가볍게” 보이게 만들수록 수명 계약이 주석에 남지 않아 사고가 납니다.

컨테이너 알고리즘과 무효화

erase로 요소를 지우거나 push_back으로 재할당이 일어난 뒤에도, 참조로 캡처한 반복자·포인터를 다른 람다가 들고 있으면 무효화된 반복자 사용으로 UB입니다. 알고리즘 체인에서는 인덱스나 값 복사로 안정화하거나, 한 단계의 람다 안에서만 참조를 쓰도록 범위를 제한합니다.

마지막으로, 비동기 작업에 수명을 넘기는 가장 단순한 형태는 shared_ptr를 값으로 캡처하는 것입니다. 작업이 끝날 때까지 클로저가 참조 카운트를 하나 들고 있으므로 호출자 쪽에서 먼저 포인터를 놓아도 안전합니다.

#include <future>
#include <iostream>
#include <memory>

int main() {
    auto p = std::make_shared<int>(42);
    auto fut = std::async(std::launch::async, [p] { return *p; });
    std::cout << fut.get() << '\n';
}

같이 보면 좋은 글