Coding Test 준비를 위해 GyuChul 코딩테스트 준비를 하며 백준 문제를 풀다보면 종종 등장하는 낯선 녀석이 바로 std::sort 뒤에 따라오는 []로 시작하는 기묘한 문법, **람다(Lambda)**이다.
처음 C++을 배울 때는 단순히 코드를 짧게 줄여주는 '문법적 설탕(Syntactic Sugar)' 정도로 생각하기 쉽다. 하지만 컴파일러의 시선으로 바라본 람다는 단순한 단축키 정도가 아니다.
그 작은 괄호 뒤에는 **익명 클래스(Anonymous Class)**의 생성, 템플릿 인스턴스화, 그리고 시스템 프로그래밍의 핵심인 인라인 최적화와 메모리 스코프의 미묘한 줄타기가 숨어 있다.
따라서 Lambda를 사용할 때 컴파일러가 부리는 마법과, 그 뒤에 숨겨진 '스코프의 함정'까지 알아보도록 하겠다.
1. 람다의 해부학: 정의와 호출의 분리
람다를 사용할 때는 두 가지 변수가 사용될 수 있다.
- 이미 지역변수로 사용되고 있는 변수를 람다 객체 내부로 가져오는 Capture된 변수
- 람다를 호출하는 시점에 새로 지정되어 사용하는 변수
int factor = 10;
// [1. 정의 단계] 캡처(Capture) vs 매개변수(Parameter)
auto lambda = [factor](int n) { return n * factor; };
// [2. 호출 단계]
int result = lambda(5);
[factor](Capture): 람다 생성 시점에 외부 변수를 람다 객체 내부로 가져온다. (객체의 멤버 변수 초기화)(int n)(Parameter): 람다 호출 시점에 스택을 통해 전달받는 인자이다.
2. 컴파일러의 시선: 람다의 실체는 '객체'다
2-1. 클로저 타입(Closure Type)과 클로저 객체(Closure Object)
람다를 이해하는 핵심 키워드는 클로저(Closure)이다.
컴파일러 입장에서 클로저 타입과 클로저 객체에 대해서 조금 다루고 넘어가겠다.
클로저 타입 (The Type):
람다 표현식을 위해 컴파일러가 생성하는 고유하고 이름 없는 클래스 타입.
소스 코드 어디에도 이름이 없기 때문에 우리가
auto키워드를 써서 타입을 추론하게 만드는 주범이다.클로저 객체 (The Instance):
그 클래스 타입으로 생성된 실제 메모리상의 객체.
이 객체는 캡처된 변수들의 '복사본' 또는 '참조'를 품고 있다.
2-2. 컴파일러 생성 코드 상세 분석
앞서 보여드린 개념적 코드를 한 줄씩 뜯어보며, 람다의 각 요소가 클래스의 어떤 부품으로 변하는지 매핑해 보자.
// [작성한 코드]
int factor = 10;
auto lambda = [factor](int n) { return n * factor; };
① 멤버 변수: 캡처 블록 `[factor]`의 실체
private:
int __factor; // 캡처된 factor가 클래스의 '데이터 멤버'가 됨
람다는 함수처럼 보이지만, 사실 상태(State)를 가질 수 있는 객체이다. [] 안에 들어가는 모든 변수는 이 클래스의 멤버 변수로 등록된다. 람다가 정의되는 순간, 당시의 factor 값이 이 멤버 변수로 복사된다.
② 생성자: 데이터 주입
public:
__Lambda_Unique_Name_X(int _f) : __factor(_f) {}
람다 객체가 생성될 때(코드 흐름이 람다 정의부를 지날 때), 외부의 factor 값을 클래스 내부 멤버 변수(__factor)로 전달해주는 역할을 한다.
③ 함수 호출 연산자: 본문 `{ ... }`의 실체
int operator()(int n) const {
return n * __factor;
}
이 부분이 핵심이다. 람다를 lambda(5)처럼 호출할 수 있는 이유는 컴파일러가 () 연산자를 오버로딩해주기 때문인 것이다.
int n: 람다의 매개변수(int n)가 그대로 연산자의 인자로 들어온다.const: 람다는 기본적으로 캡처한 변수를 수정할 수 없는const함수로 생성된다. (수정하려면mutable키워드 필요)- 본문: 우리가
{}안에 적은 로직이 이 연산자 내부에 생성
이렇게 컴파일러가 생성한 클래스 정의 자체가 “클로저 타입”이 되고,
auto lambda = __Lambda_Unique_Name_X(factor);
이와 같이 실제로 Instance를 찍어내면 메모리에 공간을 차지하는 변수(lambda) 그 자체가 된다.
2-3. 왜 굳이 '클래스'로 만들어야 할까? (The "Why")
단순히 함수 포인터로 처리하지 않고 복잡하게 클래스를 만드는 이유는 "상태(Context)와 로직의 결합" 때문이다.
- 독립적인 메모리 공간: 함수 포인터는 외부 변수를 가질 수 없지만, 클래스 객체는 멤버 변수를 통해 외부 환경을 기억할 수 있다.
- 타입 안전성: 컴파일러는 모든 람다에 고유한 타입을 부여한다. 똑같은 코드의 람다를 두 번 써도 타입이 다르다. 이는 이어서 설명할 **템플릿 최적화(Inlining)**를 가능하게 하는 원동력이 된다. (여기서 말하는 Type이 다르다는 것은 실제 int, char 등의 Type과는 차이가 있다. 인라인 최적화에서 이어서 설명하도록 하겠다.)
3. 템플릿과 인라인 최적화 (Why Lambda is Fast)
std::sort 같은 템플릿 함수에 함수 포인터 대신 람다를 넘기면 왜 빨라질까?
단순한 함수 포인터와 람다의 대결에서 람다가 승리하는 비결은, 역설적으로 **"모든 람다는 세상에 단 하나뿐인 고유한 타입을 가진다"**는 설계에 있다.
3-1. 타입이 곧 '코드'인 이유
C++에서 int나 char 같은 기본 타입은 규격이 정해진 기성품이다.
하지만 람다의 타입은 컴파일러가 그 자리에서 즉석으로 설계하고 찍어내는 '한정판 익명 클래스'이다.
컴파일러는 설령 코드가 100% 일치하는 람다를 두 번 보더라도, 각각에게 전혀 다른 이름을 부여한다.
- 람다 A:
class __Lambda_UUID_123 - 람다 B:
class __Lambda_UUID_456
이렇게 타입을 엄격하게 구분하는 이유는 **"타입을 알면 실행할 코드(본문)를 100% 확신할 수 있기 때문"**이다.
3-2. 함수 포인터 vs 람다: 컴파일러의 시야 차이
Case A: 함수 포인터 (안개가 낀 시야)
std::sort에 함수 포인터를 넘기면, 컴파일러는 bool (*comp)(int, int)라는 범용적인 '투입구'만 본다.
- 컴파일러의 고민: "지금 들어온 이 주소(
0x401000)에 무슨 코드가 있지? 런타임에 다른 주소가 들어올 수도 있나?" → 여러 가능성을 열어두어야하네 - 결과: 컴파일러는 불확실성 때문에 코드를 합치지 못하고, 매번 정렬할 때마다 해당 주소로 점프하여 함수를 호출하는 비용(Call/Return)을 지불해야 한다.
Case B: 람다 (투명한 시야)
반면 람다를 넘기면 std::sort는 __Lambda_UUID_123이라는 구체적인 타입 전용으로 새롭게 찍혀 나온다(Template Instantiation).
- 컴파일러의 확신: "이 타입의 객체(
comp)가 호출되면, 무조건 내가 아까 정의한 그 코드가 실행될 거야!" - 결과: 컴파일러는 함수 호출이라는 중간 단계마저 생략하고, 람다의 본문 로직을
sort알고리즘 내부에 직접 박아넣는 인라인(Inline) 최적화가 가능하다.
즉, 함수 포인터는 런타임에 호출되는 형태에 따라 달라질 수 있으므로 매번 호출해야하는 형태여야 하지만, 람다는 본인만의 고유한 타입이므로 무조건 사용하는 로직이 고정되어 있어 그것을 그냥 Inline으로 박아두면 호출 오버헤드를 삭제할 수 있는 것이다.
3-3. 이름 없는 타입을 다루는 법: `decltype`
람다의 타입은 컴파일러만 아는 암호 같은 이름이라 우리가 직접 타이핑할 수는 없다. 이때 필요한 도구가 바로 decltype이다.
decltype은 "이 변수의 타입을 그대로 따와줘"라고 요청하는 키워드이다.
auto lambda = [](int a, int b) { return a < b; };
// 람다의 타입을 직접 적을 순 없지만, decltype으로 훔쳐올 순 있습니다.
using LambdaType = decltype(lambda);
// 이제 이 고유한 타입을 활용해 다른 곳에서도 타입을 명시할 수 있죠.
std::vector<int, LambdaType> customVec;
결국 람다가 int나 char와 다른 점은, 타입 그 자체가 곧 실행될 특정한 '로직'을 상징한다는 점에 있다.
모든 람다의 타입이 다르다는 것은, 컴파일러가 모든 호출처마다 최적화된 맞춤형 코드를 선물해줄 수 있다는 뜻이기도 하다.
4. Deep Dive: 스코프(Scope)의 함정과 진실
여기까지만 본다면 람다를 쓰면서
"캡처 목록
[=]을 썼으니 현재 스코프의 모든 외부 변수가 안전하게 복사되었겠지?"
라고 생각할 수 있다.
하지만 이는 반만 맞고 반은 틀린 생각이다.
컴파일러는 매우 효율적으로 움직이며, 필요 없는 것은 절대 캡처하지 않는다.
4-1. 전역 변수와 정적 변수의 비밀 (Non-Capturable)
람다가 캡처할 수 있는 대상은 오직 **지역 변수(Local Variable)**뿐이다.
전역 변수나 static 변수는 캡처 대상에서 아예 제외된다.
static int staticVar = 10;
void test() {
// [=]을 썼지만, staticVar는 람다 객체 내부에 복사되지 않습니다.
auto lambda = [=](int n) {
return n + staticVar;
};
}
여기서 컴파일러는 아주 냉철하게 판단한다.
컴파일러의 생각:
"staticVar는 스택(Stack)이 아니라 메모리의 **데이터 영역(Data Segment)**에 박혀 있어. 프로그램이 끝날 때까지 주소가 안 변하는데, 굳이 람다 객체 안에 복사해서 메모리를 낭비할 필요가 있나? 그냥 그 주소를 직접 참조하게 코드를 짜면 되지."
따라서 위 람다는 내부적으로 멤버 변수를 생성하지 않고 전역 주소를 직접 읽는다.
즉, 람다 외부에서 staticVar 값을 바꾸면 람다의 실행 결과도 실시간으로 바뀐다는 뜻이다.
"복사본(Snapshot)"을 기대했다면 뒤통수를 맞을 수 있는 지점이다.
4-2. 클래스 멤버 변수 캡처의 함정: 'this'의 습격
시스템 프로그래밍에서 가장 치명적인 함정ㅇ 다. 클래스 멤버 함수 안에서 람다를 정의할 때, 우리는 흔히 멤버 변수를 직접 캡처한다고 착각할 수 있다.
class Scale {
int value = 100;
public:
void process() {
// 의도: value의 현재 값을 복사해서 람다에 저장하고 싶음
auto lambda = [=](int n) {
return n + value;
};
}
};
개발자는 value가 람다 내부의 멤버 변수(int __value)로 복사되었다고 생각하겠지만, 사실 컴파일러는 **value**를 캡처한 적이 없다.
실제 컴파일러가 생성하는 코드:
// 사실은 value가 아니라 [this]를 캡처한 것입니다.
auto lambda = [this](int n) {
return n + this->value;
};
앞선 섹션에서 람다는 **'익명 클래스의 객체'**라고 표현했다.
람다 객체 입장에서 value는 자신의 멤버가 아니라, 자신을 만든 상위 객체(Scale 객체)의 멤버다.
그래서 컴파일러는 value를 복사하는 대신, 상위 객체의 주소인 this 포인터를 몰래 복사해둔다.
🚨 위험 시나리오: Dangling Pointer
만약 process()가 이 람다를 리턴하여 외부로 내보냈는데, 그 사이 Scale 객체가 소멸(Destruct)되었다면 어떻게 될까?
람다는 이미 사라진 객체의 주소(this)를 붙잡고 value를 읽으려 시도합니다. 결과는 참혹한 Segmentation Fault 혹은 Undefined Behavior가 발생하는 것이다.
4-3. 해결책: C++14 일반화된 캡처 (Generalized Capture)
이 수명 문제를 해결하기 위해 C++14에서는 **초기화 캡처(Init-capture)**라는 강력한 도구를 도입했다.
이제 람다 객체 내부에 진짜 새로운 멤버 변수를 만들어서 값을 복사할 수 있다.
// C++14: "새로운 변수 val을 정의하고, 현재 value 값을 복사해서 넣어줘!"
auto lambda = [val = value](int n) {
return n + val;
};
이 코드를 통해 컴파일러는 비로소 this 포인터에 의존하지 않는, 독립적인 int val 멤버 변수를 가진 클로저 타입을 생성한다.
이제 Scale 객체가 죽든 살든, 람다는 자신이 복사해온 val을 가지고 안전하게 연산할 수 있다.
5. 캡처(Capture) 방식: 스택을 넘나드는 기술
마지막으로 스택 메모리와의 상호작용을 정리하자.
`[=]` (Value Capture): 스냅샷 찍기
동작: 현재 스택 프레임의 변수 값을 **복사(memcpy)**하여 람다 객체의 멤버 변수로 저장한다.
특징: 독립적인 "스냅샷"이므로 원본이 사라져도 안전하다.
mutable: 기본적으로 람다의
operator()는const이므로 내부에서 캡처한 변수를 수정하려면mutable키워드가 필요하다.(예:
[cnt]() mutable { cnt++; })
`[&]` (Reference Capture): 주소 연결하기
동작: 스택 메모리의 **주소(Pointer)**를 저장한다.
위험성: "람다의 수명 > 변수의 수명"인 경우, Dangling Reference가 발생한다.
따라서 비동기 프로그래밍에서
[&]를 남발하면 디버깅 지옥을 맛볼 수 있을 듯 ,,,
6. 마치며
우리는 편의를 위해 []를 사용하지만, 그 이면에는 컴파일러가 만들어내는 익명 클래스, this 포인터의 묵시적 캡처, 그리고 스택과 힙을 오가는 데이터의 흐름이 존재한다.
- 람다는 객체다. (함수가 아니다)
- 전역/정적 변수는 캡처되지 않고 직접 접근한다.
- 클래스 멤버 함수에서 **
[=]**은 **this**를 캡처하는 것이다. (객체 수명 주의!)
람다를 사용하기 위해서는 위의 세가지 포인트를 분명히 이해하고 활용해야 목적에 맞게, 에러 없이 사용할 수 있을 것이다.