1. 왜 조사 체크리스트가 필요한가
프로그램이 크래시할 때, 뇌는 금방 두 가지 게임을 시작합니다: “지금 원인을 맞춰보겠다” 와 “지금 바로 고쳐버리겠다”. 두 게임 모두 통제감을 줘서 유쾌하지만, 실제로는 증상만 치료하고 근본 원인을 놓치는 경우가 많습니다. 체크리스트는 엔지니어처럼 행동하게 해줍니다: 반복 가능하고, 사실을 기록하며, 마법을 최소화하는 방식으로요.
조사에서 가장 유용한 사고 전환은 버그를 “사건”으로 보지 않고 프로세스로 보는 것입니다. 프로세스는 작은 단계들로 구성되며, 각 단계는 재현성, 최소 시나리오, 코드의 구체적 지점, 구체적 수정과 그것이 돌아오지 않는다는 짧은 검증 같은 검증 가능한 산출물을 남겨야 합니다.
아래는 우리가 다룰 일반적 경로이며, 코드를 대상으로 ‘현장 적용’할 것입니다:
flowchart TD
A[재현하기] --> B[신호 기록: ASan/assert/크래시]
B --> C[시나리오 최소화]
C --> D[근본 원인 국지화]
D --> E[수정]
E --> F[진단 하에 재검증]
F --> G[회귀 검증: 시나리오가 다시 발생하지 않아야 함]
실무적으로 쓰려면, 머리에 작은 치트시트를 갖고 계세요:
| 단계 | 원하는 결과 | 보관할 것(산출물) | 단계가 완료되었음을 어떻게 알까 |
|---|---|---|---|
| 재현하기 | 버그가 동일하게 반복됨 | 입력/커맨드/조건 | 연속 3–5번 실행에서 크래시/발생 |
| 최소화 | 여전히 깨지는 가장 짧은 시나리오 | 짧은 입력/코드 조각 | 불필요한 것을 제거했지만 문제는 남아 있음 |
| 국지화 | “처음 잘못되기 시작한” 지점 | 구체적 라인/함수/계약 | 말로 근본 원인을 서술할 수 있음 |
| 수정 | 근본 원인 제거 | diff / 코드 변경 | 진단이 침묵하고 동작이 올바름 |
| 회귀 검증 | 버그가 조용히 돌아오지 않음 | assert/자체검사 | 이전 버그를 되돌리면 검증이 실패함 |
2. 재현: “가끔 죽음”을 “항상 죽음”으로 바꾸기
“재현”이라는 말은 음악을 틀듯 들리지만, 실제로는 조사에서 가장 과소평가된 부분입니다. 안정적인 재현이 없으면 실제 원인과 우연을 구분할 수 없습니다. 코드를 바꿨더니 버그가 “사라졌다”면 뇌는 기쁘게 “고쳤다”라고 결론 내리지만, 이틀 뒤 버그가 돌아올 수 있습니다. 그건 단지 타이밍이나 메모리 레이아웃에 다시 들어맞지 않았기 때문입니다.
‘어떻게 실행하는지’ 기록하기
간단한 것부터 시작하세요: 하나의 명령 / 하나의 입력 / 하나의 시나리오. 교육용 CLI 프로그램에서는 보통 고정된 콘솔 입력, 문자열에서 읽기, 혹은 --demo 같은 특수 플래그를 의미합니다.
예시로, 콘솔 미니 작업 관리자 TaskBook을 계속 사용하겠습니다. 작업은 std::vector에 저장됩니다. 어느 날 “편의상” 선택된 작업의 포인터를 저장하도록 최적화를 넣었고, 이후 새로운 작업을 추가할 때 갑작스러운 크래시가 발생했다고 합시다.
다음은 우리가 안정적으로 재현하고 싶은 나쁜 아이디어의 최소 데모 코드입니다:
#include <iostream>
#include <string>
#include <vector>
struct Task { int id; std::string title; };
int main() {
std::vector<Task> tasks{{1, "Write report"}};
Task* selected = &tasks[0]; // vector 요소의 주소를 기억함
tasks.push_back({2, "Drink tea"}); // 메모리를 다시 할당하면 요소가 "이동"할 수 있음
std::cout << selected->title << '\n';// UB: selected가 dangling이 될 수 있음
}
핵심은 “UB가 무엇인가” 자체가 아니라, 문제를 반복 재현할 수 있는 제어된 실행 환경이 필요하다는 점입니다.
재현을 결정론적으로 만들기
단순한 하나의 입력만으로는 부족한 경우가 있습니다: 버그는 메모리 재할당 방식에 따라 달라집니다. 이때 목표는 “아름답게 만들기”가 아니라 “예측 가능하게 만들기”입니다.
예를 들어, vector가 거의 확실히 재할당을 유발하도록 capacity를 제한하는 조건을 만들 수 있습니다.
#include <iostream>
#include <string>
#include <vector>
struct Task { int id; std::string title; };
int main() {
std::vector<Task> tasks;
tasks.reserve(1); // capacity를 1로 제한(또는 그 근처)
tasks.push_back({1, "A"});
Task* selected = &tasks[0];
tasks.push_back({2, "B"}); // 높은 확률로 재할당 발생
std::cout << selected->title << '\n';// UB (운이 좋으면 오류가 안 날 수도 있지만 경고 신호)
}
이건 실험을 설정하는 것과 비슷합니다. 조사하는 동안 완벽한 프로덕션 코드를 쓰려는 것이 아니라, 버그를 실험적으로 다루는 것입니다.
‘재현 패스포트’에 기록할 항목
실전 팁: 주석(또는 별도 노트)에 세 가지를 명확히 적어두세요: 어떤 입력, 어떤 명령, 어떤 기대 신호(ASan/크래시/assert).
코드 내부에서는 이렇게 남길 수 있습니다:
// Repro: run -> crashes/ASan complains after push_back
// Scenario: store pointer to tasks[0], then push_back one more task, then read old pointer.
이건 형식적 절차가 아니라 “어제는 크래시했는데 오늘은 안 되네, 어떻게 실행했더라” 상황을 방지하는 보험입니다.
3. 최소화: 원인 외의 모든 것을 버리기
최소화는 “어디선가 닫을 때 죽는다”는 큰 프로젝트를 가져와서 “두 번째 push_back에서 죽음”처럼 15줄짜리 예제로 바꾸는 작업입니다. 이 단계는 수색 범위를 줄이고 가능한 원인 수를 줄여주기 때문에 시간을 크게 절약합니다. 그리고 예: 최소화는 첫 시도의 ‘바로 고치기’보다 거의 항상 더 중요합니다.
입력 최소화와 코드 최소화는 다르다
문제가 특정 입력에서 발현되는 경우가 있습니다: 긴 문자열, 큰 파일, 특정 명령 순서 등. 이때는 입력을 최소화하고 코드는 건드리지 않습니다. 반면 아키텍처 때문에 발생하는 문제라면—예: 포인터가 오래 저장되고 컨테이너가 이동하거나 메모리가 해제되는 경우—그때는 코드를 최소화합니다.
TaskBook 예에서는 코드를 최소화하는 편이 편리합니다: 전체 CLI, 메뉴, 파서가 필요 없습니다. 필요한 것은 오직: vector, 포인터 저장, push_back, 오래된 포인터로 읽기뿐입니다.
“하나씩 잘라내기” 기법
프로그램의 절반을 한 번에 삭제하면, 우연히 버그 자체도 제거해 버릴 수 있습니다. 그러면 무엇이 핵심인지 알 수 없게 됩니다.
좋은 규칙은 각 변경 후에 “버그가 여전히 재현되는가”를 정직하게 확인할 수 있게 하는 것입니다. 이건 마치 젠가 게임처럼 하나씩 블록을 빼고 탑이 무너지지 않는지 확인하는 과정입니다.
예를 들어, 원래 버그가 선택된 작업을 출력하는 함수에서 발생했다면, “아름답게 출력”하는 코드를 임시로 한 줄짜리 출력으로 대체할 수 있습니다. 이것은 최종 코드가 아니라 문제를 찾기 위한 손전등입니다:
#include <iostream>
#include <string>
struct Task { int id; std::string title; };
void print_selected(const Task* t) {
std::cout << t->title << '\n'; // t가 dangling이면 여기서 크래시하거나 ASan가 경고함
}
이제 최소 시나리오에는 포인터 저장과 사용 지점만 남아 있습니다.
오해의 원인을 잡는 최소화
가장 흔한 함정은 “문제가 문자열의 소멸자에서 발생”하거나 “vector 내부에서 발생”하는 것을 보고 문자열 자체가 문제라고 단정하는 것입니다. 최소화는 종종 문자열이 원인이 아니라, 메모리 이동/해제 이후에 참조를 계속 하는 것이 문제였음을 보여줍니다.
작은 코드에서는 불필요한 상상이 줄어들고 인과관계가 분명해집니다.
4. 기록: 조사를 반복 가능하게 만들기
기록은 “이 작은 시나리오가 우리의 표준 버그”라고 선언하는 것입니다. 기록은 단지 지금 고치기 위해서가 아니라 일주일 뒤에 자신과 논쟁하지 않기 위해 필요합니다: “도대체 무엇이 깨졌지?”
여기서는 두 가지 기록을 구분하는 것이 중요합니다: 시나리오 기록과 기대 동작 기록입니다.
코드에 시나리오를 고정하기: demo_*
학습용 프로젝트에서는 이런 시나리오를 별도 함수로 두고 켜고 끄기 쉽게 만드는 것이 편합니다. 예를 들면:
#include <iostream>
#include <string>
#include <vector>
struct Task { int id; std::string title; };
void demo_dangling_pointer() {
std::vector<Task> tasks{{1, "A"}};
Task* selected = &tasks[0];
tasks.push_back({2, "B"});
std::cout << selected->title << '\n'; // UB (데모 버그)
}
int main() {
demo_dangling_pointer();
}
지금은 이것이 “데모 버그”입니다. 수정 후에는 같은 함수가 “데모 검증”이 될 수 있습니다: 이 함수는 깨지지 않아야 하며(또는 계약상 허용되지 않는 시나리오라면 assert로 실패해야 합니다).
기대 동작을 기록하기
여기에서 엔지니어링이 본격적으로 시작됩니다: 이 상황에서 애플리케이션이 무엇을 해야 하는지 정의해야 합니다.
옵션(원칙 수준에서, 거대한 디자인 문서로 바꾸지 않고):
- 계약이 “vector 요소의 로우 포인터를 저장하지 않는다”면, 올바른 동작은 코드에서 그런 가능성을 제거하는 것입니다. 그렇다면 기록은: “리팩토링 후 포인터가 사라지고 선택된 작업은 다른 방식으로 저장된다”가 됩니다.
- 계약이 “selectedIndex를 저장하고 매번 요소를 다시 가져온다”면, 올바른 동작은 인덱스로 작업을 출력하고 출력 전 경계 검사를 하는 것입니다.
- 계약이 “선택된 작업은 컨테이너와 독립적으로 살아야 한다”면, 올바른 동작은 포인터가 아니라 복사본(또는 소유권)을 저장하는 것입니다.
소유권 아키텍처는 별도의 주제지만, 기대 동작을 명확히 하는 아이디어는 필수입니다: 기록이 없으면 “수정”이 우연한 코드 변경으로 끝나버립니다.
5. 회귀 사고: 버그가 다시 돌아오지 않게 하기
회귀 사고는 “지금만 고치자”가 아니라 “다시는 이런 식으로 깨지지 않게 하자”는 습관입니다. 이건 편집증이 아니라 시간 절약입니다. 한 번 발견된 버그는 현실적임이 증명된 것이므로 회귀할 가능성이 0이 아닙니다—특히 팀이 커지거나 한 달 후에 자신이 상세를 잊어버렸을 때 더 그렇습니다.
테스트 프레임워크 없이 하는 미니 회귀 검사: self_check()와 assert
오늘은 테스트 프레임워크를 도입하지 않으므로 가장 단순한 ‘못 박기’ 기법—작은 자체검사와 assert를 사용합니다.
예를 들어 선택된 작업을 인덱스로 저장하기로 했다면, 회귀 검사는 다음과 같을 수 있습니다:
#include <cassert>
#include <string>
#include <vector>
struct Task { int id; std::string title; };
void self_check_selected_index() {
std::vector<Task> tasks{{1, "A"}};
std::size_t selected = 0;
tasks.push_back({2, "B"}); // 재할당이 더 이상 두렵지 않음
assert(tasks.at(selected).title == "A"); // 기대 동작을 확인
}
여기서 주목할 점: at()은 경계 검사 용도로 유용합니다. 인덱스가 잘못되면 명확한 신호(예외나 진단)를 받게 되고, 조용한 UB가 발생하지 않습니다.
설계에서의 회귀 장벽
때로는 최고의 회귀는 assert가 아니라 잘못된 상태를 금지하는 것입니다.
예컨대 프로젝트 규칙이 “vector<Task>의 요소에 대한 Task* 를 저장하지 않는다”라면, API 스타일로 이를 지원할 수 있습니다: 함수는 TaskId나 인덱스를 반환하고 주소는 반환하지 않습니다. 작은 프로젝트라도 외부로 요소 주소를 넘기지 않는 조치를 취할 수 있습니다.
학습용 TaskBook에서 간단한 변경은 find_task(...)가 포인터를 반환하는 대신 find_task_index(...)를 반환하게 하고, 이후 컨테이너를 통해 작업을 다루는 것입니다.
한 화면 분량의 체크리스트
조사 중일 때 감정에 휩싸여 “왜 또 떨어지지?” “아무것도 안 건드렸는데” 같은 생각이 들기 쉽습니다. 그때 짧은 메모가 방향을 되찾게 해줍니다.
아래는 동일한 프로세스를 보다 운영적인 문구(“하라 → 얻어라 → 확인하라”)로 정리한 것입니다.
flowchart TD
A[1 재현하기] --> A1[입력/커맨드/조건 기록]
A1 --> A2[안정화 달성: 연속 3–5회]
A2 --> B[2 최소화]
B --> B1[입력 단축 / 불필요 코드 제거]
B1 --> B2["최소 시나리오(MRE) 남기기"]
B2 --> C[3 국지화]
C --> C1[코드에서 첫 번째 지점 찾기]
C1 --> C2[근본 원인을 말로 서술]
C2 --> D[4 수정]
D --> D1[증상이 아닌 원인을 제거하는 수정]
D1 --> E[5 재검증]
E --> E1[진단/ assert 아래에서 실행]
E1 --> F[6 회귀 검증]
F --> F1[시나리오에 대한 짧은 self_check]
가장 중요한 점: “나 이미 뭐가 문제인지 알겠어”라는 생각이 들더라도 체크리스트를 꼭 따라가 보세요. 이 절차는 종종 재현이 불안정하다는 사실을 밝혀줍니다. 그리고 하루를 통째로 “존재하지 않는 버그”를 고치느라 날리지 않게 해줍니다.
6. 실용 예: 버그에서 회귀 검증까지
사건을 간단히 요약하면: dangling 포인터로 인한 UB가 있었고, 우리는 “인덱스를 저장”하는 해결책을 선택했으며 자체검증을 추가했습니다.
나쁜 버전
#include <iostream>
#include <string>
#include <vector>
struct Task { int id; std::string title; };
int main() {
std::vector<Task> tasks{{1, "A"}};
Task* selected = &tasks[0];
tasks.push_back({2, "B"});
std::cout << selected->title << '\n'; // UB
}
수정된 버전과 회귀 검증
#include <cassert>
#include <string>
#include <vector>
struct Task { int id; std::string title; };
void self_check() {
std::vector<Task> tasks{{1, "A"}};
std::size_t selected = 0;
tasks.push_back({2, "B"});
assert(tasks.at(selected).title == "A");
}
int main() {
self_check();
}
이것은 전체 설계가 아니라, 회귀 접근 방식의 최소 예시입니다: 단순히 코드를 고치는 것이 아니라, “다시 고장나면 알려주는 작은 등대”를 남기는 것입니다.
7. 조사에서 흔한 실수
실수 #1: 안정적 재현을 확보하지 않고 바로 “수정”을 시작한다.
버그가 “한 번은 발생하고 한 번은 안 발생”할 때 즉시 수정을 하고 싶어집니다—빨리 끝내고 싶으니까요. 실제로는 룰렛을 돌리는 셈입니다: 코드를 바꿨더니 버그가 안 발생해서 고쳤다고 생각했지만, 다음 날 다시 나타납니다. 안정적 재현은 출발점입니다. 없으면 지도 없이 숲을 헤매는 것과 같습니다.
실수 #2: 과하게 최소화해서 버그 자체를 잃어버린다.
학생들이 한 번에 프로그램의 절반을 지워 버린 후 버그가 사라지면 혼란스러워합니다: “그럼 원인이 아니었나?” 대부분 경우 중요한 시나리오 요소를 실수로 제거한 것입니다. 최소화는 단계별로 하고 각 단계마다 버그가 여전히 재현되는지 확인하세요—그래야 인과 관계를 잃지 않습니다.
실수 #3: “크래시 지점”만 고치고 근본 원인을 무시한다.
std::cout << ... 같은 라인에서 크래시가 나면 “출력 문제”라고 생각하기 쉽습니다. 그러나 출력은 종종 이미 손상된 메모리를 먼저 건드린 것일 뿐입니다. 증상만 치료하면 버그는 다른 곳에서 다른 형태로 다시 나타날 수 있습니다. 그러면 “새 버그”가 생겼다고 생각하게 됩니다. 보통은 같은 오래된 문제입니다.
실수 #4: “이제 안 깨진다”를 수정 증거로 간주한다.
기억에 남는 버그는 교활해서 잠시 조용할 수 있습니다. 약간의 연산 순서 변경으로 UB가 안 보일 수 있지만, UB는 반드시 매번 나타날 필요가 없습니다(그게 위험한 점입니다). 따라서 수정 후에는 진단 환경에서 재검증하고 간단한 회귀 검증(예: self_check())을 남겨야 합니다.
실수 #5: 조사 후 산출물(시나리오와 검증)을 남기지 않는다.
“고쳤다” 후에 최소 시나리오도, 재현 방법도, 작은 회귀 검증도 남기지 않으면 조사는 한 번성 영웅담에 불과합니다. 멋있지만 대가가 큽니다: 다음 번엔 같은 고생을 또 해야 합니다. 회귀 사고는 한번 고생하고 끝내는 것을 목표로 합니다.
GO TO FULL VERSION