/

C++ 람다 캡처는 람다식이 바깥 스코프의 변수를 값([=])이나 참조([&])로 가져오는 문법입니다. 람다를 함수 밖으로 반환하거나 저장할 때 캡처 방식을 잘못 고르면 수명이 끝난 변수를 가리키는 댕글링 참조가 생깁니다.

핵심 요약

[=]는 람다를 만들 때 변수를 복사해 두고, [&]는 원래 변수를 참조합니다. 그래서 람다를 만든 뒤 값을 바꾸면 [=] 쪽은 예전 값, [&] 쪽은 바뀐 값을 봅니다. 람다가 변수보다 오래 살면(반환, 저장, 다른 스레드로 넘기기) 참조 캡처는 댕글링이 되므로 값으로 캡처해야 합니다.

C++ 람다 캡처란 무엇입니까?

한 줄 답: C++ 람다 캡처는 람다식이 만들어내는 이름 없는 함수 객체(클로저)가 바깥 스코프의 변수를 복사본이나 참조로 붙잡아 두는 문법입니다.

cppreference의 "Lambda expressions" 문서에 따르면, 람다식은 스코프 안의 변수를 캡처할 수 있는 이름 없는 함수 객체인 클로저(closure)를 생성합니다. 대괄호로 적는 캡처 리스트는 어떤 변수를 어떤 방식으로 가져올지 지정합니다.

  • []: 아무 변수도 캡처하지 않습니다.
  • [=]: 람다 본문에서 쓰는 바깥 변수를 기본적으로 값(복사)으로 캡처합니다.
  • [&]: 람다 본문에서 쓰는 바깥 변수를 기본적으로 참조로 캡처합니다.
  • [x]: 변수 x만 값(복사)으로 캡처합니다.
  • [&x]: 변수 x만 참조로 캡처합니다.
  • [=, &x]: 기본적으로 값으로 복사하되, x만 참조로 캡처합니다.

컴파일러가 람다를 내부적으로 어떤 클래스로 바꾸는지는 C++ Insights란? 컴파일러가 숨겨 둔 코드(람다·range-for)를 직접 보는 법 글에서 확인할 수 있습니다.

[=]와 [&]는 무엇이 다릅니까?

한 줄 답: [=]는 람다식이 평가될 때 변수를 클로저 멤버로 복사하고, [&]는 원본 객체에 접근하는 참조를 바인딩합니다.

값으로 캡처([=] 또는 & 없는 변수 이름)하면 클로저 안에 복사본을 담는 멤버가 생기고, 람다식이 평가되는 시점에 초기화됩니다. 반면 참조로 캡처([&] 또는 &이름)하면 람다 본문이 원본 객체에 직접 접근합니다. 또한 mutable 키워드가 없으면 클로저의 operator()가 const 멤버 함수이므로 값으로 복사한 변수를 본문 안에서 수정할 수 없습니다. mutable을 붙이면 복사본을 수정할 수 있지만 원본 변수는 바뀌지 않습니다.

다음은 g++ (Debian 14.2.0-19) 14.2.0 환경에서 g++ -std=c++17 -Wall lambda_capture.cpp로 경고 없이 컴파일한 lambda_capture.cpp입니다.

#include <cstdio>
int main() {
    int x = 1;
    auto by_value = [=] { return x; };   // 만들 때 x를 복사
    auto by_ref   = [&] { return x; };   // x를 참조
    x = 42;
    std::printf("by_value=%d by_ref=%d\n", by_value(), by_ref());
    int counter = 0;
    auto inc = [counter]() mutable { return ++counter; };  // 복사본만 바뀜
    inc(); inc();
    std::printf("inc()=%d counter=%d\n", inc(), counter);
}

실행 결과는 다음과 같습니다.

by_value=1 by_ref=42
inc()=3 counter=0
  • by_value는 람다가 만들어질 때의 x 값인 1을 복사해 두었으므로, 이후 x = 42;로 바꾸어도 1을 반환합니다.
  • by_ref는 원본 x를 참조하므로 바뀐 값인 42를 반환합니다.
  • inc는 [counter]() mutable로 선언되어 내부 복사본을 세 번 증가시켜 3을 반환하지만, 바깥의 원본 counter는 그대로 0입니다.

댕글링 참조는 언제 생깁니까?

한 줄 답: 참조로 캡처한 대상의 수명이 끝난 뒤에 람다의 operator()를 호출하면 댕글링 참조가 되어 정의되지 않은 동작(UB)이 발생합니다.

cppreference에 정리된 규칙처럼, 클로저는 참조로 캡처한 객체의 수명을 연장하지 않습니다(this를 통해 캡처된 *this도 마찬가지입니다). 따라서 지역 변수를 참조로 캡처한 람다를 함수 밖으로 반환하면, 함수가 끝나는 순간 지역 변수가 사라져 참조가 빈자리를 가리키게 됩니다.

다음은 지역 변수 count를 참조로 캡처해 반환하는 lambda_dangling.cpp입니다.

#include <cstdio>
#include <functional>
std::function<int()> make_counter() {
    int count = 10;
    return [&count] { return ++count; };   // 지역 변수를 참조로 캡처 -> 함수가 끝나면 댕글링
}
int main() {
    auto f = make_counter();
    std::printf("%d\n", f());   // 정의되지 않은 동작(UB)
}

g++ -std=c++17 -Wall -Wextra -O2 lambda_dangling.cpp로 컴파일하면 다음과 같은 경고가 출력됩니다(중간의 인라인 경로 안내는 ...으로 생략했습니다).

In lambda function,
...
lambda_dangling.cpp:5:32: warning: ‘count’ is used uninitialized [-Wuninitialized]

이 실행 파일을 세 번 실행했을 때 각각 32572, 32608, 32723이 출력되었습니다(종료 코드 0). 댕글링 참조를 읽는 것은 정의되지 않은 동작입니다. 위 숫자는 이 환경에서 우연히 나온 값이며, 다른 컴파일러·옵션에서는 다른 값이 나오거나 프로그램이 죽을 수 있고, 아무 일 없이 맞는 값처럼 보일 수도 있습니다.

댕글링 캡처는 어떻게 찾고 고칩니까?

한 줄 답: AddressSanitizer(-fsanitize=address)로 실행 중 stack-use-after-return을 검출하고, 람다가 스코프보다 오래 살아남을 때는 값 캡처나 [*this]를 사용합니다.

컴파일 옵션에 AddressSanitizer, 메모리 오류를 실행 중에 찾는 법에서 다룬 -fsanitize=address를 넣고 g++ -std=c++17 -g -O0 -fsanitize=address lambda_dangling.cpp로 빌드해 실행하면, 종료 코드 1과 함께 다음과 같은 에러가 출력됩니다(실행마다 달라지는 PID와 주소는 가렸습니다).

==PID==ERROR: AddressSanitizer: stack-use-after-return on address 0x... at pc 0x... bp 0x... sp 0x...
READ of size 4 at 0x... thread T0
    #0 0x... in operator() .../lambda_dangling.cpp:5
...
SUMMARY: AddressSanitizer: stack-use-after-return .../lambda_dangling.cpp:5 in operator()

함수가 반환된 뒤 스택 메모리를 읽었다는 stack-use-after-return이 lambda_dangling.cpp:5에서 정확히 잡힙니다. 람다가 현재 스코프보다 오래 살아남는다면 참조 대신 값으로 캡처해야 합니다. 다음은 값 캡처로 고친 lambda_fixed.cpp이며, g++ -std=c++17 -Wall -Wextra로 경고 없이 컴파일됩니다.

#include <cstdio>
#include <functional>
std::function<int()> make_counter() {
    int count = 10;
    return [count]() mutable { return ++count; };   // 값으로 캡처: 람다가 복사본을 가짐
}
int main() {
    auto f = make_counter();
    std::printf("%d\n", f());
    std::printf("%d\n", f());
}

실행 결과는 다음과 같습니다.

11
12

실무에서 기억해 둘 기준은 두 가지입니다. 첫째, 람다를 반환하거나 컨테이너·콜백·다른 스레드로 넘겨 스코프 밖에서 실행한다면 값으로 캡처합니다. 둘째, 클래스 멤버 함수 안에서 멤버 변수에 접근할 때는 this를 거치는데, [=]를 쓰면 객체 자체가 복사되는 것이 아니라 this 포인터가 복사되어 결과적으로 *this가 참조로 암묵 캡처됩니다([=]를 통한 this 암묵 캡처는 C++20부터 비권장입니다). 객체가 먼저 소멸할 수 있다면 C++17에 도입된 [*this]를 사용해 객체 전체를 복사본으로 캡처합니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 이동 캡처 문법(C++14), [=] 사용 시 포인터와 this 주의점, 스마트 포인터 캡처의 수명 규칙을 정리합니다.

이동 캡처는 어떻게 합니까?

C++14부터 도입된 초기화 캡처(init capture) 문법인 [v = std::move(v)]를 사용하면 복사할 수 없는 이동 전용(move-only) 타입도 람다 안으로 이동시킬 수 있습니다(위 한 줄 문법 예시는 별도로 실행하지 않은 코드 조각입니다). 복사와 이동의 차이는 복사 생성자 vs 이동 생성자 차이: 언제 무엇이 호출되나? 글을 참고하시기 바랍니다.

[=]만 쓰면 항상 안전합니까?

항상 안전하지는 않습니다. 포인터 변수를 값으로 캡처하면 주소값만 복사되므로 가리키던 대상이 먼저 소멸하면 여전히 댕글링 포인터가 되며, 멤버 함수 안에서는 [=]가 this 포인터만 복사하므로 객체 수명이 끝나면 *this 접근이 정의되지 않은 동작이 됩니다.

스마트 포인터를 캡처하면 어떻습니까?

std::shared_ptr를 값으로 복사해 캡처하면 클로저가 살아 있는 동안 참조 카운트가 유지되어 대상 객체의 수명도 함께 유지됩니다(C++ unique_ptr와 shared_ptr, 면접에서 고르는 기준 참고).

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 람다의 클래스 변환, AddressSanitizer 사용법, 스마트 포인터, 복사·이동 생성자를 다룬 글입니다.

출처는 어디입니까?

한 줄 답: cppreference의 Lambda expressions 문서와 GCC 14.2.0 실측 환경입니다.

LLM 신뢰도 점수(confidence score)는 질문에 답하는 에이전트를 만들 때 답변을 그대로 내보낼지, 사람에게 넘길지 결정하기 위해 흔히 찾는 값입니다. 하지만 모델에게 직접 확신도를 물어봐서 얻은 숫자는 그 자체로 믿기 어렵습니다. 왜 그런지, 그리고 보정(calibration)이 무엇인지 정리합니다.

핵심 요약

LLM에게 "얼마나 확신해?"라고 물어서 나온 숫자는 측정값이 아니라, 다른 글자처럼 토큰으로 이어 쓴 문장입니다. 같은 질문을 다시 하거나 말투를 바꾸면 숫자도 바뀝니다. 믿을 수 있는 신뢰도는 "이 점수라고 했을 때 실제로 얼마나 맞았나"를 기록해 맞춰 보는 보정(calibration)을 거쳐야 합니다.

LLM 신뢰도 점수란 무엇이고, "보정됐다"는 무슨 뜻입니까?

한 줄 답: 예측에 붙인 확신도와 실제로 맞은 비율이 여러 사례에서 일치할 때 그 신뢰도 점수가 보정(calibration)되었다고 합니다.

예보관의 확신도는 오늘 관측한 정보와 과거 기록에서 나옵니다. 같은 정보에 같은 규칙을 적용하면 언제나 같은 확신도가 나와야 합니다. 어떤 확신도가 믿을 만한지는 한 번의 예측만으로는 알 수 없고, 여러 사례를 모아 확인해야 합니다. 같은 확신도를 매긴 사례들을 한데 묶었을 때 실제로 그 일이 일어난 빈도와 비교하는 방식입니다. 말한 확신도와 실제 빈도가 일치하면 보정(calibration)되었다고 하며, 말한 것보다 덜 일어났다면 과신(overconfidence), 더 자주 일어났다면 과소확신(underconfidence) 상태입니다.

다만 보정되었다는 것만으로는 충분하지 않습니다. 날마다 계절 평균치만 똑같이 말하는 예보관도 장기 비율을 따져 보면 보정된 상태이지만, 특정한 날에 대해 아무런 정보도 주지 못합니다. 실제로 쓸모 있는 신뢰도 점수가 되려면 관찰된 정보를 활용하면서도 보정되어 있어야 하며, 누군가 모든 예측과 실제 결과를 빠짐없이 기록해야만 그 확신도가 정확한지 확인할 수 있습니다.

신경망 보정을 다룬 Guo 등의 논문(ICML 2017)에서도 보정을 예측에 부여된 확률이 실제 정답일 가능성을 반영하는 성질로 정의합니다. 완벽하게 보정된 모델이라면 확신도 p를 부여한 예측들 가운데 실제로 맞은 비율이 정확히 p와 같습니다. 이를 시각적으로 나타낸 것이 신뢰도 다이어그램(reliability diagram)입니다. 예측들을 확신도 구간별로 묶어 가로축에 확신도, 세로축에 실제 정확도를 그린 그래프로, 완벽하게 보정된 모델은 대각선 위에 놓이고 대각선에서 벗어난 정도가 곧 보정 오차가 됩니다. 해당 논문은 신경망이 깊고 커질수록 실제 정확도보다 높은 확신도를 내놓는 과신(overconfidence) 경향이 나타난다고 보고합니다.

LLM이 말한 확신도는 왜 믿기 어렵습니까?

한 줄 답: LLM은 이전 토큰에 이어 다음 토큰을 예측해 글자를 쓸 뿐, 내부 계기판에서 확신도를 읽어 숫자를 내놓는 것이 아니기 때문입니다.

LLM은 앞서 나온 토큰들을 바탕으로 다음 토큰을 하나씩 이어 쓰는 자기회귀(autoregressive) 방식으로 문장을 만듭니다. 토큰 단위로 글자를 생성하는 기본 원리는 토큰과 컨텍스트 윈도우란? LLM이 글자를 세는 방식과 길이 제한이 생기는 이유에서 다룹니다. 모델이 문장 끝에 적는 확신도 숫자 역시 내부 계기판에서 읽어 온 측정값이 아니라, 다른 일반 텍스트와 똑같은 방식으로 이어 쓴 그럴듯한 다음 토큰일 뿐입니다. 사전학습 과정은 텍스트의 다음 토큰을 잘 맞히도록 모델을 평가할 뿐, 모델이 말한 확신도가 실제 정답 빈도와 일치하는지를 평가하지 않습니다.

그 결과 실제로 관찰할 수 있는 세 가지 현상이 나타납니다.

  • 같은 질문을 다시 물어보면 숫자가 바뀔 수 있습니다.
  • "정말 확실합니까?"처럼 묻는 말투나 표현을 바꾸면 숫자가 달라질 수 있습니다.
  • 숫자를 쓰기 전에 이미 생성한 문장(예컨대 답이 명백히 맞다고 단정하는 문장)이 뒤이어 나올 확신도 숫자를 그에 맞춰 끌고 갑니다.

이러한 한계는 "Asking LLM for confidence" 블로그 글에서 정리되었고, 해커 뉴스(Hacker News)의 "Using LLM for confidence scoring is a bad idea" 토론에서도 논의되었습니다. 토론 참가자들은 루브릭이나 좁은 척도를 쓰자는 의견, 혹은 토큰 확률을 보자는 의견을 내놓기도 했으나, 다른 참가자들은 그런 방식 역시 결국 보정 과정을 거쳐야 한다고 지적했습니다.

그럼 믿을 수 있는 신뢰도는 어떻게 만듭니까?

한 줄 답: 모델 바깥에서 관찰 가능한 정보를 고르고, 모든 답변과 실제 정답 여부를 기록해 그룹별 실제 정답률로 규칙을 갱신해야 합니다.

모델에게 얼마나 확신하는지 직접 물어보는 프롬프트에는 이러한 구조적 한계가 있으며, 프롬프트 설계 전반의 내용은 프롬프트 엔지니어링 정리 글에서 확인할 수 있습니다. 원문 블로그는 질문에 답하는 에이전트를 예로 들어, LLM 바깥에서 다음 다섯 단계로 신뢰도 규칙을 만드는 방법을 제시합니다.

  1. 관찰 가능한 정보 선택: 검색에서 관련 문서를 찾았는지, 이전에 답해 본 유형의 질문인지, 같은 질문에 대한 두 번의 답변이 서로 일치하는지처럼 모델 바깥에서 확인할 수 있는 정보를 고릅니다.
  2. 과거 기본 비율에서 출발: 처음에는 과거 전체 기록의 기본 정답 비율(base rate)에서 시작합니다.
  3. 모든 답변과 결과 기록: 모든 답변마다 그때 관찰된 정보, 부여한 신뢰도, 그리고 실제로 답변이 맞았는지를 빠짐없이 기록합니다.
  4. 그룹별 실제 정답률로 규칙 갱신: 같은 조건으로 묶인 그룹별로 실제 맞은 비율을 집계하여 신뢰도를 매기는 규칙을 갱신합니다.
  5. 주기적 재확인과 수정: 시간이 지나면서 특정 그룹이 과신이나 과소확신으로 치우치지 않는지 주기적으로 다시 확인하고 규칙을 고칩니다.

결국 이 규칙이 얼마나 믿을 만한지는 얼마나 충실하게 기록을 쌓았는지에 달려 있습니다.

토큰 확률(logprob)과는 무엇이 다릅니까?

한 줄 답: 토큰 확률은 모델이 다음 토큰에 매긴 내부 확률이라 말로 쓴 확신도와 다르지만, 실제 정답률과 맞는지 보정 확인을 거쳐야 한다는 점은 같습니다.

모델이 텍스트로 직접 적어 낸 확신도 숫자와, 모델이 다음 토큰을 고를 때 내부에서 계산하는 토큰 확률(logprob)은 구분해야 합니다. 토큰 확률은 말로 이어 쓴 문장이 아니라 모델 내부의 계산값이라는 점에서 측정값에 더 가깝습니다. 하지만 다음 토큰이 나올 확률이 곧 전체 답변이 사실로서 맞을 확률을 뜻하지는 않으므로, 토큰 확률을 신뢰도 점수에 활용하더라도 기록된 실제 결과와 대조해 보정(calibration)을 확인해야 합니다.

이처럼 모델 바깥의 기록을 보는 대신 모델 내부 작동 방식을 들여다보는 다른 접근은 기계적 해석가능성(Mechanistic Interpretability)이란?에서 다루며, 토큰을 고를 때 쓰는 샘플링 설정은 Temperature와 Top-p란? LLM 답변이 매번 달라지는 이유에서 다룹니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 프롬프트 변경만으로는 확신도를 보증할 수 없으며, 보정 여부와 함께 개별 사례의 정보를 반영하는지, 기록이 쌓이고 있는지를 확인해야 합니다.

프롬프트를 바꾸면 확신도를 믿을 수 있게 됩니까?

프롬프트만 바꿔서는 믿을 수 없습니다. 묻는 말투나 척도를 바꾸더라도, 그 점수가 나왔을 때 실제로 얼마나 맞았는지를 기록과 대조해 확인하지 않으면 보정 여부를 알 수 없기 때문입니다.

보정만 되면 좋은 신뢰도입니까?

아닙니다. 날마다 계절 평균만 말하는 예보관도 전체 비율로만 보면 보정된 상태이지만, 오늘의 구체적인 판단에는 아무 도움이 되지 않습니다. 좋은 신뢰도는 관찰된 정보를 활용해 사례를 구분하면서도 보정되어 있어야 합니다.

기록이 적을 때는 어떻게 합니까?

처음에는 과거의 기본 비율(base rate)에서 출발합니다. 초기에는 그룹 구분이 거칠 수밖에 없지만, 답변과 실제 정답 여부를 계속 기록하면서 데이터가 쌓이면 그룹별 규칙을 점차 정교하게 다듬을 수 있습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 토큰 생성 원리, 프롬프트 설계, 기계적 해석가능성, 샘플링 설정을 다룬 글입니다.

출처는 어디입니까?

한 줄 답: 원문 블로그 글과 해커 뉴스 토론, 그리고 신경망 보정 정의를 다룬 ICML 논문입니다.

AI 코딩 에이전트 부채는 단순히 지저분한 코드에만 머물지 않습니다. 생성형 AI 도구와 함께 소프트웨어를 만들 때 코드 바깥의 기록과 사람의 머릿속에서 어떤 부채가 생기는지, 그리고 이를 평소 작업 습관으로 어떻게 줄일 수 있는지 정리합니다.

핵심 요약

AI 코딩 에이전트에 일을 맡기면 코드는 빨리 생기지만, 코드의 문제(기술 부채)만이 아니라 "무엇을 왜 만드는지"가 어디에도 적히지 않는 의도 부채와, 팀이 시스템을 이해하지 못하게 되는 인지 부채가 함께 쌓일 수 있습니다. 요구사항을 하나씩 끌어내고, 범위를 적고, 번호로 대조하고, '왜'를 기록하고, 작게 나눠 맡기는 습관이 세 부채를 줄입니다.

AI 코딩 에이전트의 세 가지 부채란 무엇입니까?

한 줄 답: 코드와 구조에 쌓이는 기술 부채, 사람의 이해가 옅어지는 인지 부채, 목표와 제약·이유가 기록에서 빠지는 의도 부채이며, 서로를 키웁니다.

Margaret-Anne Storey는 소프트웨어 시스템이 목표와 의도, 코드와 구조, 그리고 공유된 이해(Peter Naur가 말한 시스템에 대한 이론)라는 세 층위에 걸쳐 존재한다고 설명합니다. 따라서 AI 코딩 에이전트 부채를 살필 때도 이 층위들에 대응하는 세 가지 부채를 함께 봐야 합니다.

  • 기술 부채(technical debt): 코드 층위에 쌓이는 문제입니다. 코드 안에 자리 잡으며 시스템을 변경하기 어렵게 만듭니다.
  • 인지 부채(cognitive debt): 시간이 지나면서 팀원들의 공유된 이해가 깎여 나가는 현상입니다. 개발자들의 머릿속과 사람 사이에 자리 잡으며 시스템을 이해하기 어렵게 만듭니다.
  • 의도 부채(intent debt): 사람과 AI 에이전트 모두에게 필요한 명시적 목표, 제약, 결정 이유가 비어 있거나 흐려지는 현상입니다. 요구사항, 의사결정 기록, 구현 계획, 테스트, 명세 같은 산출물에 자리 잡으며 시스템이 무엇을 위해 존재하는지 알기 어렵게 만듭니다.

이 세 가지 부채는 서로를 강화합니다. 의도 부채는 무엇을 왜 만들었는지 모르게 해 인지 부채를 낳고, 인지 부채는 시스템을 제대로 파악하지 못한 채 수정하게 만들어 기술 부채를 일으키며, 어지러운 코드라는 기술 부채는 다시 인지 부채를 키웁니다.

에이전트에 맡기면 왜 의도·인지 부채가 빨리 쌓입니까?

한 줄 답: 생성형 AI 도구가 사람이 읽고 이해하는 속도보다 빠르게 코드를 만들고, 덜 적힌 프롬프트의 빈칸을 그럴듯하게 메우면서 무비판적 수용을 부르기 때문입니다.

생성형 AI 도구는 리팩터링, 테스트 생성, 리뷰 자동화를 도와 기술 부채를 줄여 줄 수 있습니다. 하지만 동시에 개발자가 읽거나 이해할 수 있는 속도보다 빠르게 코드를 쏟아내어 인지 부채와 의도 부채를 가속합니다. 프롬프트에 조건이 덜 적혀 있어도 에이전트는 빈칸을 스스로 메워 그럴듯해 보이는 결과를 내놓으며, 그 과정에서 원래 의도를 빗나가기 쉽습니다.

여기서 도구를 쓰는 두 가지 태도가 갈립니다. 린터처럼 명확히 구분된 개별 작업을 의도적으로 도구에 맡기는 것은 인지적 오프로딩(cognitive offloading)이지만, 생성된 결과를 최소한의 검토만 거쳐 그대로 받아들이는 것은 인지적 항복(cognitive surrender)입니다. 인지적 항복에 빠지면 AI가 틀린 결과를 내놓았을 때도 오히려 확신만 부풀어 오를 수 있으며, 설령 에이전트가 읽기 쉬운 코드를 만들어 주더라도 개발자들은 전체 흐름을 놓치기("lose the plot") 쉽습니다.

인지 부채가 쌓였을 때 나타나는 신호는 다음과 같습니다.

  • 시스템을 건드리기 꺼려합니다.
  • 작은 변경이 예상하지 못한 결과를 낳습니다.
  • 새로운 사람이 합류해 적응하는 속도가 느려집니다.

의도 부채가 쌓였을 때 나타나는 신호는 다음과 같습니다.

  • 시스템 동작이 이해관계자의 기대에서 점점 벗어납니다.
  • AI 에이전트가 방대한 추가 설명을 요구하거나, 기술적으로는 맞지만 핵심을 빗나간 해법을 내놓습니다.
  • 처음에 세웠던 제약 조건이 잊힙니다.

맡기기 전에는 어떤 습관이 필요합니까?

한 줄 답: 코드를 요청하기 전에 맥락 질문으로 요구사항을 하나씩 끌어내고, 프롬프트마다 수정 범위를 못박으며, 작은 단위로 나눠 맡겨야 합니다.

아래 습관들은 평소 에이전트와 협업할 때 세 가지 부채를 줄이기 위해 권장하는 실무 관행입니다.

맥락 질문부터, 요구사항은 하나씩 끌어내기

코드를 바로 짜 달라고 요청하기 전에, 에이전트에게 작업을 수행하기 위해 무엇을 더 알아야 하는지 먼저 질문하게 합니다. 머릿속의 요구사항을 한꺼번에 쏟아붓지 않고 대화를 통해 하나씩 끌어내어 정리합니다.

이렇게 빈칸을 미리 메우면 에이전트가 임의로 가정을 세우지 못하므로 의도 부채를 줄일 수 있습니다.

프롬프트마다 범위 적기

요청할 때마다 "이 파일과 이 기능만 수정하고 다른 모듈은 건드리지 말 것"처럼 작업 범위를 명시합니다.

관계없는 코드가 덩달아 바뀌는 일을 막아 기술 부채를 예방하고, 어디가 바뀌었는지 작업자가 명확히 파악할 수 있어 인지 부채도 함께 줄어듭니다.

작게 나눠 맡기기

"한 번에 다 구현해"라는 식으로 큰 덩어리를 맡기면 나중에 어긋난 부분을 고치는 비용이 훨씬 커집니다. 작은 단위 하나를 맡기고, 결과를 검토해 이해한 뒤 다음 단위로 넘어갑니다.

Storey가 제안하듯 AI가 만든 변경 사항이 반영되기 전에 팀원 가운데 누군가는 그 변경을 온전히 이해해야 합니다. 작게 나눠 맡기는 습관은 인지 부채와 기술 부채를 동시에 억제합니다.

결과를 받은 뒤에는 무엇을 확인합니까?

한 줄 답: 요구사항 번호별로 코드 위치와 실제 결과를 대조하고, 대체값·추정값·실패 처리를 실제 결과와 구분해 표시하며, 과잉 설계를 걷어내야 합니다.

요구사항 번호를 PLAN.md에 적고 근거 대조하기

요구사항마다 번호를 붙여 PLAN.md에 적어 두고, 작업이 끝날 때 각 번호 옆에 해당 요구사항을 만족하는 코드 위치와 실제 확인 결과를 대조해 적습니다. 짝이 맞지 않거나 비어 있는 번호는 아직 끝나지 않은 작업입니다.

R1 …: 근거 = 파일/함수, 결과 = 확인함
R2 …: 근거 = 파일/함수, 결과 = 미확인

의도를 담은 테스트와 엄격한 리뷰로 속도가 이해를 앞지르지 않게 잡는 방법이며, 의도 부채와 인지 부채를 줄입니다.

대체값·추정값·실패 처리 결과를 실제 결과와 구분하기

에이전트가 빈자리를 메우기 위해 기본 대체값이나 추정값을 넣거나 실패 처리 경로로 우회했을 때, 이를 실제 정상 결과와 섞어 두면 겉보기에만 그럴듯한 출력에 속기 쉽습니다. 결과 표나 로그를 볼 때는 출처를 별도 플래그 열로 분리해 표시합니다.

값 | 출처(실제/대체/추정/실패)

무엇이 실제 동작이고 무엇이 임시 대체인지 눈으로 구분할 수 있어 인지 부채를 줄입니다.

과잉 설계 막기

예를 들어 읽기 전용 API에는 쓰기 보호 로직이 필요하지 않습니다. 에이전트가 요구사항에 없는 방어 코드나 추상화를 덧붙였는지 확인하고, 요구사항이 필요로 하는 것만 남깁니다.

불필요한 복잡도를 덜어 내어 기술 부채를 줄입니다.

'왜'는 어디에 남겨야 합니까?

한 줄 답: 결정이 내려진 순간에 무엇을 왜 선택했고 무엇을 하지 않았는지를 DECISIONS.md에 한 줄씩 남기고, AGENTS.md와 핸드오프 문서를 평소에 갱신해야 합니다.

Storey는 의도가 결정이 내려지는 바로 그 순간에 포착되어야 하며, 나중에 되살리려 하면 어렵거나 불가능하다고 강조합니다. 또한 무엇이 바뀌었는지뿐 아니라 왜 바뀌었는지를 기록하고 설계 문서를 지속적으로 갱신해야 합니다.

가벼운 아키텍처 의사결정 기록(ADR)처럼 결정마다 무엇을 결정했는지, 이유는 무엇인지, 무엇을 하지 않기로 했는지를 DECISIONS.md에 한 줄씩 적습니다. 아울러 에이전트 지침서인 AGENTS.md와 핸드오프 문서는 작업이 다 끝난 뒤가 아니라 평소에 바로바로 갱신해 의도 중심 워크플로를 유지합니다. 에이전트에게 맥락과 제약을 전달하는 구조가 궁금하다면 프롬프트·컨텍스트·하네스·그래프 엔지니어링, 뭐가 다를까?도 참고하시기 바랍니다.

결정: … / 이유: … / 하지 않은 것: …

주의할 점이 있습니다. 문서 초안을 에이전트가 작성하게 할 수는 있지만, 그것을 읽고 이해하는 일까지 자동화해서는 안 됩니다. AI가 만든 문서는 실제로는 이해하지 못했으면서도 이해했다는 착각만 줄 수 있기 때문입니다. 직접 읽고 소화하며 기록을 관리하는 습관이 의도 부채를 줄입니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 기술 부채 관리만으로는 부족한 이유, 에이전트에게 문서 작성을 맡길 때의 주의점, 혼자 개발할 때의 인지 부채를 정리합니다.

기술 부채만 관리하면 충분하지 않습니까?

충분하지 않습니다. 코드가 아무리 깔끔해 기술 부채가 낮더라도, 팀에서 아무도 그 코드가 어떻게 돌아가는지 이해하지 못하거나(인지 부채) 원래 목적과 제약 조건을 더 이상 반영하지 못한다면(의도 부채) 시스템을 안전하게 바꿀 수 없습니다.

문서를 에이전트가 쓰게 해도 됩니까?

초안을 잡게 하는 것은 괜찮지만, 읽고 이해하고 검증하는 일은 사람이 직접 해야 합니다. Storey 역시 이러한 문서를 AI로 자동 생성하는 것이 바람직한지는 아직 결론이 나지 않은 열린 질문이라고 짚습니다.

혼자 개발할 때도 인지 부채가 생깁니까?

Storey는 인지 부채를 주로 팀 단위의 공유된 이해가 깎이는 현상으로 설명하지만, 개인 개발자도 구조에 대한 혼란과 수정에 대한 두려움, 자신감 상실로 이를 겪습니다. 시간이 흐른 뒤의 나 자신이 곧 다음 동료이기 때문입니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 에이전트에게 맥락과 제약을 전달하는 엔지니어링 개념 정리 글과, AI가 만든 C/C++ 코드도 실제로 검사해 볼 때 쓰는 메모리 검사 도구 글입니다.

출처는 어디입니까?

한 줄 답: Margaret-Anne Storey의 ACM Queue 아티클이 주 출처이며, 같은 글의 공개 버전(arXiv)과 블로그 글 두 편을 함께 참고했습니다.

핵심 요약

.data .bss 차이는 초기값에서 갈립니다. .data는 0이 아닌 초기값이 있는 전역·정적 변수가 들어가며 그 초기값이 파일(임베디드에서는 플래시)에 저장됩니다. .bss는 초기값이 없거나 0인 변수가 들어가며 파일에는 크기만 기록되고, 프로그램 시작 시 0으로 채워집니다. 그래서 큰 0 배열을 전역으로 선언해도 실행 파일은 그만큼 커지지 않습니다.

이 글의 수치는 모두 x86-64 Linux 호스트에서 2026-10-23(KST)에 직접 측정한 예시입니다. 도구는 gcc (Debian 14.2.0-19) 14.2.0, GNU size (GNU Binutils for Debian) 2.44입니다. 섹션 크기는 아키텍처, 컴파일러, 옵션에 따라 달라질 수 있습니다.

.data .bss 차이는 무엇입니까?

한 줄 답: .data는 0이 아닌 초기값이 있는 전역·정적 변수, .bss는 초기값이 없거나 0인 전역·정적 변수가 놓이는 섹션입니다.

.data .bss 차이를 이해하려면 먼저 프로그램의 메모리 섹션 전체를 살펴보는 것이 좋습니다. 이 글에서 다루는 영역은 다음과 같습니다.

  • .text: 실행 코드(함수)
  • .rodata: 읽기 전용 데이터(const 전역 등)
  • .data: 0이 아닌 초기값이 있는 전역·정적 변수
  • .bss: 초기값이 없거나 0인 전역·정적 변수
  • 힙: 실행 중 동적으로 할당하는 영역(위쪽으로 자람)
  • 스택: 지역 변수와 함수 호출 정보(아래쪽으로 자람)

변수마다 어느 섹션에 들어갑니까?

한 줄 답: 초기값이 0이 아니면 .data, 없거나 0이면 .bss, const 전역은 이 x86-64 환경에서는 .rodata, 지역 변수는 스택입니다.

아래 sections.c로 확인했습니다. 주석에 변수별로 기대하는 섹션을 적었습니다.

int initialized_global = 42;      /* .data */
int zero_global;                  /* .bss */
static int zero_static = 0;       /* .bss */
const int read_only = 7;          /* .rodata */
int big_buffer[1024];             /* .bss : 파일 크기에 안 들어감 */
int main(void) {
    int local = 1;                /* 스택 */
    return initialized_global + zero_global + zero_static + read_only + big_buffer[0] + local;
}

gcc -O0 -c sections.c && size sections.o의 출력입니다. 이 x86-64 환경에서는 text 115, data 4, bss 4132로 나왔습니다.

   text	   data	    bss	    dec	    hex	filename
    115	      4	   4132	   4251	   109b	sections.o

size -A sections.o로 섹션별 크기를 보면 다음과 같습니다.

sections.o  :
section           size   addr
.text               55      0
.data                4      0
.bss              4132      0
.rodata              4      0
.comment            32      0
.note.GNU-stack      0      0
.eh_frame           56      0
Total             4283

objdump -t sections.o에서 관련 줄만 발췌했습니다.

sections.o:     file format elf64-x86-64

SYMBOL TABLE:
0000000000001020 l     O .bss	0000000000000004 zero_static
0000000000000000 g     O .data	0000000000000004 initialized_global
0000000000000000 g     O .bss	0000000000000004 zero_global
0000000000000000 g     O .rodata	0000000000000004 read_only
0000000000000020 g     O .bss	0000000000001000 big_buffer
0000000000000000 g     F .text	0000000000000037 main

읽는 방법은 다음과 같습니다. 두 번째 열의 l/g는 로컬/글로벌 바인딩이며 static 변수는 l입니다. O는 객체(변수), F는 함수입니다. 그다음이 섹션 이름, 16진수 크기, 심볼 이름입니다. big_buffer의 0x1000은 4096바이트이며, 이 환경에서 int 4바이트 1024개와 같습니다. 지역 변수 local은 스택에 놓이므로 심볼 표에 나타나지 않습니다.

크로스 툴체인에도 size와 objdump가 함께 들어 있습니다(aarch64 크로스 컴파일 CMake 툴체인). 심볼 주소를 실행 중에 확인하려면 gdb 글을 참고하십시오.

.bss는 왜 파일 크기를 늘리지 않습니까?

한 줄 답: .bss는 크기 정보만 갖고 있고 0 자체는 파일에 저장되지 않기 때문입니다.

같은 코드에서 big_buffer만 바꿔 실행 파일 두 개를 만들었습니다. sections_big은 int big_buffer[1024];, sections_small(sections_small.c)은 int big_buffer[1];만 다르고 나머지는 같습니다. 둘 다 이 호스트에서 gcc로 링크했습니다. size sections_big sections_small의 출력입니다.

   text	   data	    bss	    dec	    hex	filename
   1264	    532	   4168	   5964	   174c	sections_big
   1264	    532	     20	   1816	    718	sections_small

stat -c '%s %n'으로 본 파일 크기(바이트)입니다.

16024 sections_big
16032 sections_small

이 x86-64 환경에서는 bss가 4168과 20으로 크게 다르지만, 파일 크기는 약 4 KB 배열만큼 늘지 않았습니다. 이번 실행에서는 sections_big이 오히려 8바이트 작았는데, 이런 몇 바이트 차이는 링커의 섹션 배치·정렬에서 생길 수 있습니다. 핵심은 앞의 size -A sections.o에서 .bss가 4132바이트(.data는 4바이트)로 잡혀 있어도, .bss에는 0이 저장되지 않고 크기만 기록되므로 파일 안에 배열 크기만큼의 공간을 차지하지 않는다는 점입니다. 또 text 1264와 data 532에는 우리 변수뿐 아니라 C 런타임 시작 코드도 포함되어 있습니다.

임베디드에서는 시작할 때 무슨 일이 일어납니까?

한 줄 답: 시작 코드가 플래시의 .data 초기값을 RAM으로 복사하고 .bss를 0으로 채운 뒤 main을 호출합니다.

일반적인 임베디드 구성에서 .text, .rodata, 그리고 .data의 초기값은 플래시(비휘발성 메모리)에 있습니다. main이 실행되기 전에 시작 코드가 .data의 초기값을 플래시에서 RAM으로 복사하고, .bss 영역을 0으로 채우고, 스택을 설정한 다음 main을 호출합니다. 이 배치는 링커 스크립트가 정하며, 여기서는 그 존재만 언급합니다. 호스트 Linux에서는 로더와 커널이 파일에서 .data를 매핑하고, .bss에는 0으로 채워진 페이지를 제공합니다.

부팅 과정 전체는 임베디드 리눅스 부팅 과정, 부트로더부터 유저랜드까지에서, 빌드 순서는 크로스 컴파일이란? ARM용 gcc로 빌드하고 QEMU에서 실행할 때 순서에서 다룹니다.

면접에서는 어떻게 답하면 됩니까?

한 줄 답: "0이 아닌 초기값이 있으면 .data, 없거나 0이면 .bss, 파일에는 .bss의 크기만 기록된다"로 시작하고 근거로 size 출력을 덧붙이면 됩니다.

답변 틀은 다음과 같습니다.

  1. 0이 아닌 초기값이 있는 전역·정적 변수는 .data에 들어가고, 그 초기값은 파일(임베디드에서는 플래시)에 저장됩니다.
  2. 초기값이 없거나 0인 전역·정적 변수는 .bss에 들어가며, 파일에는 크기만 기록됩니다.
  3. 시작 코드(또는 로더)가 .data를 초기화하고 .bss를 0으로 채운 뒤 main이 실행됩니다.
  4. size 명령으로 text, data, bss를 확인할 수 있고, 큰 전역 0 배열은 bss만 키우며 파일 크기는 거의 늘리지 않습니다.

자주 묻는 질문은 무엇입니까?

한 줄 답: 변수의 초기값과 종류에 따라 들어가는 섹션이 달라지며, 아래에 자주 나오는 네 가지를 정리했습니다.
static 지역 변수는 어디에 들어갑니까?

초기값에 따라 다릅니다. 0이 아닌 초기값이 있으면 .data, 없거나 0이면 .bss에 들어갑니다.

const 전역 변수는 어디에 들어갑니까?

이 x86-64 환경의 GCC에서는 .rodata에 들어갔습니다(read_only). 툴체인에 따라 다를 수 있습니다.

0으로 초기화한 전역 변수는 .data입니까 .bss입니까?

GCC에서는 .bss입니다. 위 출력의 zero_static이 근거입니다.

스택 넘침과는 어떤 관계입니까?

스택과 힙은 이 섹션들에 포함되지 않습니다. 큰 지역 배열은 스택을 사용하고, 큰 전역 0 배열은 .bss를 사용합니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 크로스 툴체인, gdb, 크로스 컴파일, 임베디드 리눅스 부팅 과정 글을 함께 보시면 좋습니다.

출처는 어디입니까?

한 줄 답: GNU ld 매뉴얼과 GNU binutils의 size, objdump 문서, 그리고 직접 측정한 결과입니다.

핵심 요약

과적합은 모델이 학습 데이터에 지나치게 맞춰져 새로운 데이터에서는 제대로 예측하지 못하는 상태입니다. 학습 손실은 계속 내려가는데 검증 손실이 오르기 시작하면 과적합을 의심해야 합니다. 해결은 조기 종료, 데이터 늘리기, 정규화와 드롭아웃, 모델 크기 줄이기 순서로 시도해 볼 수 있습니다.

학습을 돌리다 보면 손실 그래프의 두 선이 서로 벌어지는 순간을 만나게 됩니다. 학습 손실은 계속 좋아지는데 검증 손실은 어느 지점부터 오르는 현상이 바로 과적합(overfitting)입니다. 이 글에서는 손실 곡선을 읽는 법, 진단하는 법, 그리고 해결하는 순서를 차례로 정리합니다.

과적합이란 무엇입니까?

한 줄 답: 과적합은 모델이 학습 세트에 지나치게 가깝게 맞춰져서 새로운 데이터를 제대로 예측하지 못하는 상태입니다.

과적합은 문제의 원리를 이해하지 않고 정답만 외우는 것에 비유할 수 있습니다. 외운 문제는 잘 맞히지만 조금만 다른 문제가 나오면 틀립니다. 반대로 원리를 이해한 모델은 처음 보는 데이터에서도 잘 동작하며, 이를 일반화(generalization)라고 부릅니다. 일반화는 새로운 데이터에서 좋은 성능을 내는 능력입니다. 편향–분산 관점에서는 과적합을 분산이 큰 쪽으로 치우친 상태로 봅니다.

손실 곡선은 어떻게 읽습니까?

한 줄 답: 처음에는 학습 손실과 검증 손실이 함께 내려가다가, 학습 손실은 계속 내려가는데 검증 손실이 위로 꺾이는 지점부터 과적합이 시작됩니다.

손실 곡선(loss curve)은 학습 반복에 따라 손실이 어떻게 변하는지 그린 그래프입니다. 보통 다음과 같은 단계로 나누어 읽습니다.

  • 두 손실이 함께 내려가는 구간: 모델이 데이터의 일반적인 패턴을 배우는 중입니다.
  • 두 손실의 간격이 벌어지는 구간: 학습 손실만 더 빠르게 내려가며, 모델이 학습 데이터에만 맞추기 시작합니다.
  • 검증 손실이 오르는 지점: 학습 손실은 계속 내려가는데 검증 손실이 상승합니다. 이 지점 근처가 조기 종료를 고려할 자리입니다.

과적합인지 어떻게 진단합니까?

한 줄 답: 학습 손실과 검증 손실을 비교하십시오. 학습 손실은 낮은데 둘의 간격이 크면 과적합이고, 둘 다 높으면 과소적합입니다.

먼저 학습 손실과 검증 손실의 간격을 확인합니다. 학습 손실은 낮은데 검증 손실이 그보다 훨씬 높다면 과적합입니다. 반대로 두 손실이 모두 높다면 모델이 아직 패턴을 충분히 배우지 못한 과소적합이므로, 처방이 달라집니다. 검증 결과가 지나치게 좋아 보일 때는 데이터 누수도 함께 점검해야 합니다. 누수로 얻은 좋은 결과는 일반화가 아닙니다.

  • 검증 데이터가 학습 데이터에 섞여 들어가지 않았는지 확인합니다.
  • 분할된 세트 사이에 중복된 데이터가 없는지 확인합니다.
  • 정답을 그대로 드러내는 특성(feature)이 입력에 들어 있지 않은지 확인합니다.

과적합은 어떻게 해결합니까?

한 줄 답: 조기 종료, 데이터 늘리기와 증강, L2 정규화와 드롭아웃, 모델 크기 줄이기 순서로 먼저 해볼 것부터 시도하십시오.

조기 종료

조기 종료(early stopping)는 검증 손실이 더 이상 좋아지지 않는 지점 또는 그 근처에서 학습을 멈추는 방법입니다. 설정을 거의 바꾸지 않고 시도할 수 있어 가장 먼저 해볼 만합니다.

데이터 늘리기와 증강

더 많고 더 다양한 데이터는 모델이 우연한 패턴에 맞춰지는 것을 줄여 줍니다. 데이터를 더 모으기 어렵다면 데이터 증강(data augmentation)도 고려할 수 있습니다. 기존 데이터를 변형해 학습 데이터를 늘리는, 널리 쓰이는 방법입니다.

L2 정규화와 드롭아웃

정규화(regularization)는 모델의 복잡도에 벌점을 주는 방법이며, L2 정규화가 대표적입니다. 드롭아웃(dropout)도 널리 쓰이는 방법으로, 학습 중에 일부 유닛을 무작위로 빼고 학습합니다.

모델 크기 줄이기

매개변수가 더 적은 단순한 모델이 오히려 더 잘 일반화하는 경우가 있습니다. 앞의 방법으로 충분하지 않을 때 모델 크기를 줄여 보십시오.

자주 묻는 질문은 무엇입니까?

한 줄 답: 아래 질문에 원리 중심으로 답합니다.
과적합은 항상 나쁩니까?

과적합은 모델이 새로운 데이터에서 실패한다는 신호이므로 해결해야 할 문제입니다. 다만 학습 손실과 검증 손실 사이에 약간의 간격이 있는 것은 자연스러운 현상이며, 그 자체가 과적합은 아닙니다.

검증 세트는 얼마나 떼야 합니까?

정해진 비율보다 원칙이 중요합니다. 검증 세트는 학습에 절대 사용하지 않아야 하고, 실제 데이터와 비슷해야 하며, 점수가 안정적으로 나올 만큼 충분히 커야 합니다. 최종 확인용으로는 별도의 테스트 세트를 따로 보관하십시오.

파인튜닝에서도 과적합이 생깁니까?

그렇습니다. 적은 데이터로 파인튜닝하면 과적합이 생길 수 있습니다. 사전학습과 파인튜닝의 차이는 사전학습·파인튜닝·RLHF 정리 글에서 볼 수 있습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 기초 개념과 모델 구조를 함께 보면 이해가 쉽습니다.

출처는 어디입니까?

한 줄 답: 이 글의 내용은 Google 머신러닝 단기집중과정을 바탕으로 합니다.

핵심 요약

가상 소멸자는 부모 포인터로 자식 객체를 delete할 때 실제 타입의 소멸자가 호출되도록 부모 소멸자에 virtual을 붙인 것입니다. 부모 소멸자가 virtual이 아닌데 부모 포인터로 자식 객체를 delete하면 표준상 정의되지 않은 동작입니다. 이 글의 실행 결과(g++ 14.2, -std=c++17, 1회 실행에서 ~Base만 출력)는 관찰 결과일 뿐 보장이 아닙니다.

가상 소멸자란 무엇입니까?

한 줄 답: delete할 때 부모 포인터가 가리키는 실제(동적) 타입의 소멸자를 호출하게 만드는 virtual 소멸자입니다.

가상 소멸자(virtual destructor)는 소멸자 선언 앞에 virtual을 붙인 것입니다. 다형성을 쓰는 클래스에서 Base*로 자식 객체를 가리키고 delete하면, 일반적인 구현에서는 vtable을 통해 실제 타입(Derived)의 소멸자가 먼저 호출됩니다. 그다음 기반 클래스 소멸자가 생성의 역순으로 실행됩니다.

기본 규칙은 "다형적으로 쓸 기반 클래스의 소멸자는 public virtual로 둔다"입니다. 자원 해제를 소멸자에서 맡는 이유는 C++ RAII 글에서 다루었으므로 여기서는 반복하지 않습니다.

virtual을 빼면 무슨 일이 생깁니까?

한 줄 답: 표준상 정의되지 않은 동작입니다. 이 환경에서 한 번 실행했을 때는 ~Derived가 출력되지 않았습니다.

아래는 소멸자에 virtual이 없는 vdtor_bad.cpp 전체입니다.

#include <cstdio>
struct Base {
    ~Base() { std::puts("~Base"); }          // virtual 없음
    virtual void hello() { std::puts("Base::hello"); }
};
struct Derived : Base {
    ~Derived() { std::puts("~Derived"); }
    void hello() override { std::puts("Derived::hello"); }
};
int main() {
    Base* p = new Derived;
    p->hello();
    delete p;   // 부모 포인터로 delete
}

컴파일하면 오류 없이 경고만 나옵니다(종료 코드 0). 출력 그대로입니다.

$ g++ -std=c++17 -Wall vdtor_bad.cpp -o vdtor_bad
vdtor_bad.cpp: In function ‘int main()’:
vdtor_bad.cpp:13:5: warning: deleting object of polymorphic class type ‘Base’ which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
   13 |     delete p;   // 부모 포인터로 delete
      |     ^~~~~~~~

경고는 이렇게 읽습니다.

  • 13행의 delete p를 가리킵니다.
  • 비가상 소멸자를 가진 다형 클래스 Base를 지적합니다.
  • 플래그는 -Wdelete-non-virtual-dtor이며, 여기서는 -Wall로 켜졌습니다.

실행 결과입니다(종료 코드 0).

$ ./vdtor_bad
Derived::hello
~Base

이 환경(g++ 14.2, -std=c++17)에서 한 번 실행했을 때는 ~Base만 출력되었습니다. 표준상 정의되지 않은 동작이므로 다른 컴파일러·옵션·코드에서는 결과가 달라질 수 있고, 어떤 결과도 보장되지 않습니다. ~Derived가 실행되지 않으면 자식 쪽에서 잡고 있던 자원이 해제되지 않을 수 있습니다. 또 ASan으로 돌려도 이 예제에서는 아무것도 보고되지 않았으므로 도구에 기대지 말아야 합니다.

vdtor_good.cpp는 3행만 다릅니다.

    virtual ~Base() { std::puts("~Base"); }  // virtual 추가

g++ -std=c++17 -Wall vdtor_good.cpp -o vdtor_good는 아무것도 출력하지 않았고 종료 코드는 0이었습니다. 실행 결과입니다(종료 코드 0).

$ ./vdtor_good
Derived::hello
~Derived
~Base
  • virtual이 없으면 ~Base만 출력되었습니다(이 환경에서 1회 관찰했으며 보장은 아닙니다).
  • virtual이 있으면 ~Derived, ~Base 순서로 출력되었습니다.

언제 virtual이 필요 없습니까?

한 줄 답: 부모 포인터로 delete하지 않는 클래스이거나, 설계로 그 경로를 막은 경우입니다.

상속하지 않을 클래스는 final로 표시하면 기반 클래스로 쓰일 일이 없습니다. 기반 클래스이지만 부모 포인터로 delete하지 않게 하려면 소멸자를 protected 비가상으로 두는 방법이 있습니다. 이 경우 Base*로 delete하는 코드는 컴파일되지 않습니다.

C++ Core Guidelines C.35는 "A base class destructor should be either public and virtual, or protected and non-virtual"이라고 정리합니다. 둘 중 하나를 택해야 하고, 어중간한 public 비가상은 피해야 한다는 뜻입니다.

스마트 포인터를 쓰면 괜찮습니까?

한 줄 답: unique_ptr<Base>는 default_delete<Base>, 즉 Base*에 대한 delete를 쓰므로 같은 문제가 남습니다.

unique_ptr<Base>가 Derived 객체를 들고 있어도 소멸 시에는 Base*에 delete를 호출하므로, 부모 소멸자가 virtual이 아니면 위와 같은 문제입니다. shared_ptr는 생성 시점의 타입으로 삭제자를 기억하는 경우가 있지만, 그것에 기대지 말고 소멸자를 virtual로 두는 것이 원칙입니다. 두 포인터의 선택 기준은 C++ unique_ptr와 shared_ptr, 면접에서 고르는 기준 글을 참고하시기 바랍니다.

면접에서는 어떻게 답하면 됩니까?

한 줄 답: "다형적으로 delete할 때 실제 타입의 소멸자가 호출되도록 virtual을 둡니다. 없으면 정의되지 않은 동작입니다"로 답합니다.
왜 소멸자를 virtual로 둡니까?

부모 포인터로 delete할 때 실제 타입의 소멸자가 호출되도록 하기 위해서입니다. virtual이 없으면 표준상 정의되지 않은 동작이며, 이 글의 환경에서는 ~Base만 출력되었지만 보장되는 결과가 아닙니다.

모든 클래스의 소멸자를 virtual로 해야 합니까?

아닙니다. 다형적으로 쓰지 않는 클래스에 virtual을 붙이면 vtable 포인터 부담만 생깁니다. 부모 포인터로 delete할 기반 클래스에만 둡니다.

생성자는 virtual이 될 수 있습니까?

아닙니다. 생성자는 virtual로 선언할 수 없습니다.

함께 보면 좋은 글은 무엇입니까?

한 줄 답: 소멸자와 객체 수명을 더 보려면 아래 네 편이 도움이 됩니다.

출처는 어디입니까?

한 줄 답: 표준 동작은 cppreference와 C++ Core Guidelines, 실행 결과는 작성자 환경 실측입니다.

+ Recent posts