icon
16장 : 성능 최적화와 디버깅 기법

디버거 사용법

아무리 숙련된 개발자라도 코드를 작성하는 과정에서 오류(버그)를 피할 수는 없습니다.

프로그램이 예상대로 동작하지 않을 때, 가장 강력하고 효율적인 문제 해결 도구는 바로 디버거(Debugger) 입니다.

디버거는 실행 중인 프로그램의 내부 상태를 검사하고, 실행 흐름을 제어하여 논리적 오류를 식별하고 수정하는 데 도움을 줍니다.

이번 장에서는 C++ 개발에서 널리 사용되는 디버거의 기본적인 사용법과 핵심 기능들을 학습합니다.

여러분이 효과적으로 버그를 찾아 해결할 수 있도록 돕겠습니다.


왜 디버거를 사용해야 하는가?

많은 개발자가 std::cout을 이용한 출력으로 디버깅을 시도합니다.

물론 간단한 경우에는 유용하지만, 복잡한 오류나 예측 불가능한 동작을 추적하는 데는 한계가 있습니다.

  • 실시간 상태 확인: 프로그램이 특정 시점에서 어떤 변수 값을 가지고 있는지, 어떤 함수가 호출되었는지 등을 실시간으로 정확하게 파악할 수 있습니다.
  • 실행 흐름 제어: 코드를 한 줄씩 실행하거나, 특정 함수 호출을 건너뛰는 등 프로그램의 실행 흐름을 원하는 대로 제어할 수 있습니다.
  • 복잡한 오류 추적: 여러 스레드 간의 상호작용 문제, 메모리 손상, 예측 불가능한 크래시(crash) 등 cout 디버깅으로는 찾기 어려운 문제를 해결할 수 있습니다.
  • 시간 절약: 문제를 재현하고 cout을 삽입하며 재빌드하는 과정을 반복하는 것보다 훨씬 효율적입니다.

디버깅 빌드 (Debugging Build)

디버거를 효과적으로 사용하려면, 프로그램을 디버깅 모드로 빌드해야 합니다.

  • 디버깅 심볼 포함: 컴파일러에게 소스 코드의 라인 번호, 변수 이름, 함수 이름 등 디버깅 정보를 실행 파일에 포함하도록 지시합니다. (GCC/Clang: -g, MSVC: /Zi)
  • 최적화 비활성화: 컴파일러 최적화(-O2, -O3 등)를 비활성화합니다. 최적화는 코드의 실행 순서를 바꾸거나 일부 코드를 제거할 수 있으므로, 디버거가 소스 코드와 일치하지 않게 동작하게 만들 수 있습니다. (GCC/Clang: -O0, MSVC: /Od)

일반적으로 IDE(통합 개발 환경)에서 'Debug' 구성을 선택하면 이러한 설정이 자동으로 적용됩니다.


주요 디버거와 기본적인 사용법

널리 사용되는 C++ 디버거는 다음과 같습니다:

  • GDB (GNU Debugger): Linux/macOS 환경에서 가장 보편적으로 사용되는 커맨드라인 기반 디버거.
  • LLDB: macOS 환경에서 주로 사용되는 LLVM 프로젝트의 디버거. GDB와 유사한 인터페이스를 가집니다.
  • Visual Studio Debugger: Windows 환경의 Visual Studio IDE에 통합된 강력한 GUI 기반 디버거.
  • IDE 통합 디버거: CLion (JetBrains), VS Code (Microsoft) 등 대부분의 현대 IDE는 GDB나 LLDB를 백엔드로 사용하는 강력한 통합 디버깅 환경을 제공합니다. 이 책에서는 IDE 통합 디버거를 사용하는 일반적인 방법을 위주로 설명하겠습니다.

디버거의 핵심 기능

  1. 브레이크포인트 (Breakpoint)

    • 프로그램 실행을 특정 지점에서 일시 중지시키는 마커입니다.
    • 코드 라인에 설정하는 라인 브레이크포인트, 특정 조건이 참일 때만 중지하는 조건부 브레이크포인트, 특정 함수가 호출될 때 중지하는 함수 브레이크포인트 등이 있습니다.
    • IDE에서는 보통 코드 라인 옆 여백을 클릭하여 설정/해제합니다.
  2. 실행 제어 (Execution Control)

    • 계속 실행 (Continue): 다음 브레이크포인트에 도달하거나 프로그램이 끝날 때까지 실행합니다.
    • 한 단계씩 실행 (Step Over): 현재 라인을 실행하고 다음 라인으로 이동합니다. 함수 호출이 있을 경우, 함수 내부로 들어가지 않고 함수 전체를 실행합니다.
    • 한 단계씩 코드 안으로 (Step Into): 현재 라인을 실행하고 다음 라인으로 이동합니다. 함수 호출이 있을 경우, 함수 내부로 들어가서 실행을 계속합니다.
    • 한 단계씩 코드 밖으로 (Step Out): 현재 함수에서 호출자 함수로 돌아갈 때까지 실행합니다.
    • 커서까지 실행 (Run to Cursor): 커서가 있는 라인까지 실행합니다.
  3. 변수 검사 (Variable Inspection)

    • 프로그램이 중지된 시점에서 현재 스코프 내의 모든 지역 변수와 매개변수의 값을 확인합니다.
    • 전역 변수나 정적 변수의 값도 확인할 수 있습니다.
    • 대부분의 IDE는 "Variables" 또는 "Locals" 창을 통해 이 기능을 제공합니다.
    • 조사식 (Watch Window): 특정 변수나 표현식의 값을 지속적으로 모니터링할 수 있는 창입니다. 복잡한 표현식의 값을 실시간으로 평가하는 데 유용합니다.
  4. 호출 스택 (Call Stack / Call Trace)

    • 현재 실행 중인 함수가 어떤 함수에 의해 호출되었는지, 그리고 그 함수는 또 어떤 함수에 의해 호출되었는지 등 함수 호출의 계층 구조를 보여줍니다.
    • 이를 통해 프로그램의 실행 경로를 역추적하여 오류의 근원을 파악할 수 있습니다.
  5. 메모리 검사 (Memory Inspection)

    • 특정 메모리 주소의 내용을 바이트 단위로 확인합니다.
    • 포인터가 가리키는 데이터의 실제 내용, 배열의 원시 바이트 데이터 등을 확인하는 데 사용됩니다.
    • IDE의 "Memory" 또는 "Disassembly" 창을 통해 접근할 수 있습니다.
  6. 어설션 (Assertion)

    • 프로그램의 특정 조건이 항상 참이어야 한다는 것을 명시하는 문입니다.
    • assert() 매크로 (C++의 <cassert>)를 사용합니다.
    • 조건이 거짓일 경우 프로그램이 비정상 종료되며, 디버거가 연결되어 있다면 해당 시점에서 중지됩니다.
    • 릴리스 빌드에서는 NDEBUG 매크로를 정의하여 어설션을 비활성화할 수 있습니다.
    • #include <cassert>
      // ...
      int denominator = 0;
      assert(denominator != 0 && "Denominator cannot be zero!"); // 디버깅 시에만 활성화
      int result = 10 / denominator;

실제 디버깅 과정 (가상 시나리오)

간단한 프로그램에서 디버거를 사용하는 가상 시나리오를 통해 디버깅 과정을 이해해 봅시다.

문제: 다음 코드는 1부터 5까지의 숫자를 더해야 하지만, 예상과 다른 결과가 나온다.

디버깅 예제 코드
#include <iostream>

int calculate_sum(int max_val) {
    int total_sum = 0;
    for (int i = 0; i <= max_val; ++i) { // 오류 가능성이 있는 부분
        total_sum += i;
    }
    return total_sum;
}

int main() {
    int limit = 5;
    int final_result = calculate_sum(limit);
    std::cout << "Sum up to " << limit << " is: " << final_result << std::endl; // 예상: 1+2+3+4+5=15
    return 0;
}

디버깅 단계

  1. 디버깅 빌드: 프로그램을 디버그 모드로 빌드합니다. (IDE에서 Debug 구성 선택)

  2. 브레이크포인트 설정:

    • main 함수의 int final_result = calculate_sum(limit); 라인에 브레이크포인트를 설정합니다.
    • calculate_sum 함수의 for (int i = 0; i <= max_val; ++i) 라인에 브레이크포인트를 설정합니다.
  3. 디버거 시작: IDE에서 디버깅 모드로 프로그램을 실행합니다. 프로그램은 첫 번째 브레이크포인트에서 멈출 것입니다.

  4. 변수 검사 및 실행 제어

    • 첫 번째 브레이크포인트 (main 함수):

      • "Variables" 창에서 limit 변수가 5임을 확인합니다.
      • "Step Into" (F11 또는 해당 키)를 눌러 calculate_sum 함수 내부로 진입합니다.
    • 두 번째 브레이크포인트 (calculate_sum 함수):

      • total_sum 변수가 0이고, max_val5임을 확인합니다.
      • 루프가 시작되기 전이므로 i는 아직 정의되지 않았거나 알 수 없는 값을 가질 수 있습니다.
      • "조사식(Watch)" 창에 itotal_sum을 추가하여 루프가 돌면서 이 값들이 어떻게 변하는지 실시간으로 관찰합니다.
    • 루프 내부 단계별 실행

      • "Step Over" (F10 또는 해당 키)를 반복하여 루프를 한 번씩 실행합니다.
      • 첫 번째 반복 (i=0): total_sum0이 됩니다. (문제 발견! 1부터 더해야 하는데 0부터 더하고 있음)
      • 두 번째 반복 (i=1): total_sum1이 됩니다.
      • ...
      • 여섯 번째 반복 (i=5): total_sum1+2+3+4+5=15가 됩니다.
      • 일곱 번째 반복 (i=6): 루프 조건 i <= max_val (6 <= 5)이 거짓이 되어 루프를 빠져나가야 하지만, i6일 때도 루프에 진입하려고 합니다. (문제 추가 발견! 루프 조건이 i <= max_val이라 max_val까지 포함하여 한 번 더 돈다.)
  5. 오류 수정

    • 두 가지 오류를 발견했습니다.
      1. 루프가 0부터 시작: for (int i = 1; i <= max_val; ++i)로 수정해야 합니다.
      2. 루프 조건이 max_val을 포함: 만약 0부터 시작한다면 i < max_val로 수정해야 하지만, 1부터 시작한다면 현재 i <= max_val은 그대로 두면 됩니다. (예상 값이 1부터 5까지의 합 15이므로)
    • 여기서는 for (int i = 1; i <= max_val; ++i)로 수정합니다.
  6. 재빌드 및 재확인

    • 코드를 수정한 후 다시 빌드하고 디버거로 실행하여 수정된 코드가 올바르게 동작하는지 확인합니다.

고급 디버깅 기법 (간략 소개)

  • 로그포인트 (Logpoint)

    • 브레이크포인트와 유사하지만, 프로그램 실행을 중지시키지 않고 변수 값을 출력하거나 특정 메시지를 출력합니다. cout 디버깅의 장점과 디버거의 편리함을 결합한 기능입니다.
  • 데이터 브레이크포인트 (Data Breakpoint / Watchpoint)

    • 특정 메모리 주소의 값이 변경될 때 프로그램 실행을 중지시킵니다.
    • 어떤 코드가 의도치 않게 변수 값을 변경하는지 찾을 때 매우 유용합니다. (하드웨어 지원이 필요하며, 모든 디버거에서 지원하지 않을 수 있습니다.)
  • 조건부 브레이크포인트

    • 특정 조건(i > 100, ptr == nullptr 등)이 참일 때만 브레이크포인트에서 멈추도록 설정합니다.
    • 수백만 번의 반복이 있는 루프에서 특정 조건이 만족하는 지점에서만 멈추고 싶을 때 유용합니다.
  • 덤프 파일 (Dump File) 분석

    • 프로그램이 비정상 종료(크래시)되었을 때 생성되는 덤프 파일(core dump on Linux, .dmp on Windows)을 디버거로 로드하여 크래시 시점의 스택 트레이스 및 메모리 상태를 분석할 수 있습니다.
  • 스레드 디버깅

    • 멀티스레드 프로그램에서 각 스레드의 실행 상태, 호출 스택, 지역 변수를 개별적으로 검사하고 제어할 수 있습니다.