디버거 사용법
디버그 빌드와 중단점·단계 실행·변수·호출 스택을 활용해 실행 상태를 관찰하고 논리 오류를 추적합니다.
프로그램이 틀린 결과를 내면 흔히 출력문을 여기저기 넣어 값을 확인합니다. 간단한 경우에는 이것도 통하지만, 어디에 넣을지 짐작해야 하고 넣을 때마다 다시 빌드해야 합니다. 디버거(debugger)는 실행 중인 프로그램을 원하는 곳에서 멈추고, 그 순간의 변수 값과 호출 경로를 보며 한 줄씩 실행하게 해 주는 도구입니다.
이 글은 디버깅에 알맞은 빌드, 디버거의 핵심 기능, 작은 예제로 오류를 찾는 과정, GDB 명령, assert의 쓰임, 그리고 증상에 맞는 기능을 고르는 기준을 다룹니다. 아래 그림처럼 디버깅은 실패를 재현하고, 기대한 값과 실제 값이 처음 달라지는 곳을 찾아, 그곳을 고친 뒤 같은 입력과 다른 입력으로 다시 확인하는 과정입니다.
디버깅은 첫 divergence를 증거로 좁힌다
calculate_sum(5)의 실제 10을 재현해 i=4 뒤의 total_sum=10과 다음 검사 5<5가 거짓인 최초 차이를 찾고 수정 후 15를 검증할 수 있다.
C++ · DEBUGGING EVIDENCE
기대와 실제가 처음 갈라지는 경계를 찾는다
본문 calculate_sum(5)는 0부터 4까지만 더해 10을 반환한다. 마지막 5가 제외되는 조건을 관찰한 뒤 의도한 1부터 5의 범위로 수정한다.
재현
limit=5 · 실제 결과 10
중단
함수 진입 · for 조건
관찰
i=4 뒤 total_sum=10
첫 차이
5<5 거짓 · 마지막 5 제외
최소 수정
i=1 · i<=max_val
재검증
1..5 합계 15 · 경계값
본문처럼 시작을 1, 조건을 i <= max_val로 바꾸면 입력 5의 결과는 15이다. 실패 입력과 유효한 경계값의 회귀 검증을 남긴다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 노드·연결선 라벨 | 실제 결과 10 |
| 노드·연결선 라벨 | 마지막 5 제외 |
| 노드·연결선 라벨 | 1..5 합계 15 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 실패 재현 calculate_sum(5)의 기대값과 실제값을 기록한다. |
|
|
| 경계 검사 i가 4인 회차까지 total_sum은 10이고, 다음 i=5에서는 반복 조건이 거짓이다. |
|
|
| 수정 확인 포함해야 할 끝값 5를 처리하는 수정으로 재검사한다. |
|
|
디버깅은 첫 divergence를 증거로 좁힌다
calculate_sum(5)의 실제 10을 재현해 i=4 뒤의 total_sum=10과 다음 검사 5<5가 거짓인 최초 차이를 찾고 수정 후 15를 검증할 수 있다.
C++ · DEBUGGING EVIDENCE
기대와 실제가 처음 갈라지는 경계를 찾는다
본문 calculate_sum(5)는 0부터 4까지만 더해 10을 반환한다. 마지막 5가 제외되는 조건을 관찰한 뒤 의도한 1부터 5의 범위로 수정한다.
재현
limit=5 · 실제 결과 10
중단
함수 진입 · for 조건
관찰
i=4 뒤 total_sum=10
첫 차이
5<5 거짓 · 마지막 5 제외
최소 수정
i=1 · i<=max_val
재검증
1..5 합계 15 · 경계값
본문처럼 시작을 1, 조건을 i <= max_val로 바꾸면 입력 5의 결과는 15이다. 실패 입력과 유효한 경계값의 회귀 검증을 남긴다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 노드·연결선 라벨 | 실제 결과 10 |
| 노드·연결선 라벨 | 마지막 5 제외 |
| 노드·연결선 라벨 | 1..5 합계 15 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 실패 재현 calculate_sum(5)의 기대값과 실제값을 기록한다. |
|
|
| 경계 검사 i가 4인 회차까지 total_sum은 10이고, 다음 i=5에서는 반복 조건이 거짓이다. |
|
|
| 수정 확인 포함해야 할 끝값 5를 처리하는 수정으로 재검사한다. |
|
|
디버그 빌드
디버거가 실행 위치를 소스의 줄과 변수 이름으로 보여 주려면, 실행 파일에 디버그 정보가 들어 있어야 합니다. GCC와 Clang은 -g, MSVC는 /Zi로 디버그 정보를 넣습니다.
최적화도 끄는 편이 좋습니다. 최적화는 실행 순서를 바꾸고 필요 없는 변수를 지우므로, 최적화한 빌드에서는 한 줄씩 실행할 때 줄이 건너뛰어지거나 변수가 "optimized out"으로 표시되기도 합니다. GCC와 Clang은 -O0, MSVC는 /Od로 최적화를 끄며, GCC의 -Og는 디버깅을 해치지 않는 최적화만 해서 디버그 빌드에 쓰기 좋습니다. 에디터의 Debug 구성은 보통 이런 옵션을 자동으로 적용합니다.
디버거의 핵심 기능
개발 환경 설정에서 갖춘 VS Code는 MSYS2의 GDB를 디버거로 쓰고, Linux에서는 GDB, macOS에서는 LLDB, Visual Studio는 자체 디버거를 씁니다. 화면과 명령은 달라도 핵심 기능은 같습니다.
| 기능 | 하는 일 |
|---|---|
| 중단점(breakpoint) | 지정한 줄에 도달하면 실행을 멈춤 |
| 계속(continue) | 다음 중단점이나 프로그램 끝까지 실행 |
| 한 줄 실행(step over) | 현재 줄을 실행하고, 함수 호출은 안으로 들어가지 않고 통째로 실행 |
| 안으로 들어가기(step into) | 현재 줄의 함수 호출 안으로 들어가 멈춤 |
| 밖으로 나가기(step out) | 현재 함수가 끝나 호출한 곳으로 돌아갈 때까지 실행 |
| 지역 변수와 조사식(watch) | 멈춘 순간의 변수 값과, 지정한 식의 값을 계속 보여 줌 |
| 호출 스택(call stack) | 지금 함수에 이르기까지 어떤 함수들이 차례로 호출되었는지 보여 줌 |
| 메모리 보기 | 지정한 주소의 바이트를 그대로 보여 줌 |
한 줄 실행은 소스의 줄을 단위로 하지만, 버튼을 한 번 누를 때 반복문의 한 회가 반드시 끝나는 것은 아닙니다. for (int i = 0; i < n; ++i) 한 줄 안에 초기화, 조건, 증가가 모두 있어서, 같은 줄에서 여러 번 멈출 수 있습니다.
예제로 오류 찾기
다음 프로그램은 1부터 5까지 더한 15를 출력해야 하지만, 실행하면 10을 출력합니다. 코드가 짧아 눈으로 원인을 찾을 수도 있지만, 여기서는 원인을 모른다고 보고 calculateSum의 반복문에서 i와 totalSum이 어떻게 바뀌는지를 디버거로 지켜봅니다. 그래서 빌드 명령에는 앞에서 본 -g와 -O0을 함께 줍니다.
#include <iostream>
int calculateSum(int maxVal) {
int totalSum = 0;
for (int i = 0; i < maxVal; ++i) {
totalSum += i;
}
return totalSum;
}
int main() {
int limit = 5;
int finalResult = calculateSum(limit);
std::cout << "Sum up to " << limit << " is: " << finalResult << '\n';
}g++ -std=c++20 -g -O0 sum_debug.cpp -o sum_debug.exe
.\sum_debug.exeSum up to 5 is: 10main의 calculateSum(limit) 줄에 중단점을 두고 디버거로 실행하면 그 줄에서 멈추며, 지역 변수 창에서 limit이 5인 것을 확인할 수 있습니다. 안으로 들어가기로 calculateSum에 들어가면 첫 줄 int totalSum = 0;을 실행하기 직전에 멈추므로, 이때 maxVal은 5이지만 totalSum에는 아직 아무 값이나 들어 있을 수 있습니다. 한 줄 실행을 한 번 해서 totalSum이 0이 된 것을 본 뒤, 조사식에 i와 totalSum을 추가하고 한 줄씩 실행하며 값이 바뀌는 것을 지켜봅니다. 반복문의 초기화가 실행되기 전에는 i가 아직 범위에 없으므로, 표시되는 값은 의미가 없습니다.
조건을 검사할 때의 i | i < maxVal | 본문 뒤의 totalSum |
|---|---|---|
| 0 | 참 | 0 |
| 1 | 참 | 1 |
| 2 | 참 | 3 |
| 3 | 참 | 6 |
| 4 | 참 | 10 |
| 5 | 거짓 | 반복 끝 |
표처럼 i가 4인 회차에서 totalSum이 10이 되고, 다음 검사에서 5 < 5가 거짓이 되어 5를 더하지 않은 채 끝납니다. 기대한 1부터 5까지의 합과 처음 달라지는 곳은 마지막 값 5가 빠지는 이 조건입니다. 0을 더한 첫 회차는 합을 바꾸지 않으므로 오류의 직접적인 원인은 아니지만, 의도한 범위가 1부터라는 점을 드러내도록 함께 고칩니다.
for (int i = 1; i <= maxVal; ++i) {
totalSum += i;
}고친 뒤에는 같은 입력으로 다시 실행해 Sum up to 5 is: 15가 나오는지 확인하고, 0이나 1 같은 경계값도 함께 확인합니다. 고친 입력 하나만 확인하면 다른 입력에서 생긴 새 오류를 놓칠 수 있기 때문입니다.
GDB 명령으로 해 보기
에디터 없이 터미널에서 GDB를 쓸 수도 있습니다. GDB는 개발 환경 설정에서 설치한 MSYS2 툴체인 그룹에 이미 들어 있습니다. 다음은 위 예제에 쓸 수 있는 명령이며, 첫 줄은 터미널에서 실행하고 나머지는 GDB가 띄우는 (gdb) 프롬프트에 한 줄씩 입력합니다.
gdb ./sum_debug.exe
break calculateSum
run
next
print totalSum
watch totalSum
continue
backtrace
quitbreak calculateSum은 함수에 중단점을 두고, run으로 실행하면 그 함수에 들어갈 때 멈춥니다. next는 한 줄 실행, step은 안으로 들어가기, finish는 밖으로 나가기이고, print totalSum은 변수의 값을, backtrace는 호출 스택을 보여 줍니다. watch totalSum은 그 변수의 값이 바뀔 때마다 멈추는 감시점(watchpoint)을 둡니다.
더 깊이: GDB가 표준 컨테이너를 읽기 쉽게 보여 주는 방법
print로 std::vector나 std::string을 출력했을 때 내부의 포인터 대신 원소가 보이는 것은 GDB의 프리티 프린터(pretty printer) 덕분입니다. GDB는 7.0부터 Python으로 출력 형식을 정의하는 기능을 지원하고, GCC는 4.5.0부터 libstdc++의 컨테이너와 여러 클래스를 위한 프리티 프린터를 함께 배포합니다. libstdc++의 디버깅 문서에 따르면 이 프린터들은 libstdc++ 공유 라이브러리 옆에 설치되어 GDB가 스스로 찾지만, 설치 위치에 따라 GDB가 자동으로 불러오기를 거부하고 경고만 낼 수도 있으며, 그때는 .gdbinit에 auto-load safe-path를 설정합니다.
반대로 컨테이너의 실제 내부 구조를 보고 싶을 때는 print/r처럼 /r를 붙여 프리티 프린터를 거치지 않은 원래 모습을 출력합니다. 같은 문서는 인라인 확장된 생성자와 소멸자 안까지 한 줄씩 따라가려면 -g -O0 -fno-inline으로 빌드하고, 디버그 정보를 더 자세히 담으려면 -g3 같은 옵션을 쓸 수 있다고 적습니다.
assert
<cassert>의 assert(조건)은 조건이 거짓이면 그 위치와 조건을 출력하고 프로그램을 끝냅니다. 디버거와 함께 실행 중이라면 그 자리에서 멈추므로, 어느 함수에서 전제가 깨졌는지 호출 스택으로 바로 확인할 수 있습니다.
#include <cassert>
int average(int total, int count) {
assert(count > 0 && "count must be positive");
return total / count;
}NDEBUG 매크로를 정의하고 빌드하면 assert는 아무 코드도 남기지 않으므로, 릴리스 빌드에서는 검사가 사라집니다. 그래서 assert는 프로그래머가 지켜야 할 전제가 깨졌는지 개발 중에 확인하는 데 쓰고, 사용자 입력처럼 실제로 틀릴 수 있는 값의 검사는 예외 던지기에서 본 예외나 반환값으로 따로 합니다. count가 0인데 assert가 사라지면 이 함수는 0으로 나누어 정의되지 않은 동작이 됩니다.
증상에 맞는 기능 고르기
디버거의 기능은 모두 한꺼번에 쓰기보다, 무엇을 알고 싶은지에 따라 고릅니다.
C++ · DEBUGGER QUESTIONS
관찰하려는 상태에 맞춰 디버거 기능을 고른다
값·조건·쓰기 위치·호출 경로·스레드 대기를 구분하면 필요한 기능과 다음 관찰을 명확히 정할 수 있다.
값이 틀어짐 → Locals·Watch·Step
기대 값과 실제 값이 처음 달라지는 위치를 따라간다.
limit = 5
i = 4; total_sum = 10본문 예제에서는 i=5 조건 검사에서 루프가 끝나 마지막 5가 제외된다.
특정 반복 → 조건부 중단점
필요한 조건에서만 멈춰 관찰할 실행 범위를 좁힌다.
i > 100
ptr == nullptr조건은 실제로 검사하려는 실패를 표현해야 한다. 최적화 빌드에서는 변수·줄 매핑이 달라질 수 있다.
쓰기 위치·크래시 → Watchpoint·Stack
값을 바꾼 쓰기 위치와 현재 함수의 호출 경로를 구분한다.
데이터 중단점은 주소의 변경을, Call Stack은 호출 관계를 보여 준다. 크래시 후에는 dump도 확인한다.
스레드 정지 → 각 Thread·Stack
각 스레드의 실행 위치와 보유·대기 관계를 비교한다.
중단 시점의 상태는 단서이다. 멈추거나 한 단계씩 실행하는 행위가 타이밍을 바꿀 수 있다.
Watchpoint·dump 지원, 단축키와 명령은 hardware·OS·IDE에 따라 다르다. 관찰된 상태와 C++ 코드의 의미를 함께 확인한다.
값이 틀리다면 지역 변수와 조사식을 보며 한 줄씩 실행해, 기대와 처음 달라지는 곳을 찾습니다. 백만 번 반복하는 루프의 특정 회차에서만 문제가 생긴다면 i > 100이나 ptr == nullptr 같은 조건을 건 조건부 중단점(conditional breakpoint)으로 그 회차에서만 멈춥니다. 어떤 코드가 변수를 몰래 바꾸는지 모른다면 감시점을 두고, 프로그램이 비정상적으로 끝났다면 호출 스택으로 그 순간의 호출 경로를 봅니다.
비정상 종료가 개발 환경 밖에서 일어났다면, Linux의 코어 덤프(core dump)나 Windows의 덤프 파일(dump file)을 디버거로 열어 종료 순간의 호출 스택과 메모리를 볼 수 있습니다. Linux에서 코어 덤프를 남기고 gdb로 여는 과정은 운영체제 교재의 시스템 모니터링과 디버깅에서 따라 해 볼 수 있습니다. 여러 스레드를 쓰는 프로그램에서는 디버거의 스레드 목록에서 스레드마다 호출 스택을 보며, 어느 스레드가 어떤 잠금을 기다리는지 확인해 뮤텍스와 락에서 본 교착 상태를 찾을 수 있습니다.
정리
- 디버거는 실행 중인 프로그램을 원하는 곳에서 멈추고, 변수와 호출 경로를 보며 한 줄씩 실행하게 합니다.
- 디버그 빌드는
-g로 디버그 정보를 넣고-O0이나-Og로 최적화를 끄거나 줄입니다. - 중단점, 한 줄 실행, 안으로 들어가기, 밖으로 나가기, 조사식, 호출 스택이 핵심 기능입니다.
- 디버깅은 실패를 재현하고, 기대와 실제가 처음 달라지는 곳을 찾아 고친 뒤, 같은 입력과 경계값으로 다시 확인하는 과정입니다.
assert는 개발 중에 전제를 확인하는 도구이며NDEBUG로 사라지므로, 입력 검사는 따로 합니다.- 특정 회차에는 조건부 중단점을, 몰래 바뀌는 변수에는 감시점을, 비정상 종료에는 호출 스택과 덤프 파일을 씁니다.
연습 문제
1. 다른 입력으로 확인하기
1부터 maxVal까지의 합을 구하는 것이 목적이고 maxVal은 0부터 100까지라고 합시다. maxVal이 4와 0일 때, 원래 함수와 고친 함수는 각각 무엇을 돌려주나요? 0으로만 확인해도 고친 것이 충분하다고 할 수 있나요?
해설 보기
4일 때 원래 함수는 0 + 1 + 2 + 3 = 6, 고친 함수는 1 + 2 + 3 + 4 = 10을 돌려줍니다. 0일 때는 두 함수 모두 본문을 한 번도 실행하지 않아 0을 돌려주므로, 0만 확인하면 마지막 값이 빠지는 오류가 드러나지 않습니다. 실패했던 양수 입력과 0, 1 같은 경계값을 함께 확인해야 합니다.
2. 몰래 바뀌는 변수
프로그램 어딘가에서 전역 변수 config.timeout이 0으로 바뀌는데 어느 코드가 바꾸는지 모릅니다. 어떤 디버거 기능을 쓰면 찾을 수 있나요?
해설 보기
config.timeout에 감시점을 두면, 그 값이 바뀌는 순간 실행이 멈추고 바꾼 줄을 보여 줍니다. 멈춘 뒤 호출 스택을 보면 어떤 경로로 그 코드에 왔는지도 알 수 있습니다. 바꿀 수 있는 곳마다 중단점을 두는 것보다 훨씬 빠르게 찾을 수 있습니다.
3. 보이지 않는 변수
-O2로 빌드한 프로그램을 디버거로 멈췄더니, 지역 변수 하나가 값 대신 "optimized out"으로 표시되었습니다. 왜 그렇고, 어떻게 하면 값을 볼 수 있나요?
해설 보기
최적화가 그 변수를 레지스터에만 두었다가 버리거나 계산을 합쳐 없애서, 그 시점에 디버거가 읽을 수 있는 값이 남아 있지 않기 때문입니다. -O0이나 -Og로 다시 빌드하면 변수가 그대로 남아 값을 볼 수 있습니다. 다만 최적화한 빌드에서만 생기는 문제라면, 최적화를 끄면 문제가 사라질 수도 있다는 점도 함께 기억합니다.
다음 글 메모리 누수 탐지에서는 해제하지 않은 메모리를 찾는 방법과 도구, 그리고 스마트 포인터로 누수를 미리 막는 방법을 다룹니다.