디버깅 전략과 통합 디버거 (중단점 break point) 사용

이 주제를 공부하는 것으로 복잡한 프로그램에도 문제를 진단하고 해결하는 능력이 향상되기를..

 

 

디버깅이 필요할 때

 

오류는 일반적으로 구문 오류의미 오류(논리 오류)라는 두 가지 범주 중 하나로 분류된다.

C++언어의 문법에 따라 유효하지 않은 문을 작성하면 구문 오류가 발생한다. 컴파일러는 이러한 구문 오류를 경고하므로 쉽게 식별하고 해결할 수 있다.

하지만 의미론적 오류는 명령문이 구문적으론 유효하지만 프로그래머가 의도한 대로 수행되지 않을 때 발생한다.

이러한 경우 필요한 작업이 디버깅이다.

 

 

 

 

디버깅 전략

 

디버깅 전략 #1: 코드 주석 처리

 

잘못된 동작을 일으키는 것으로 추측되는 일부 코드를 주석 처리하고 문제가 지속되는지 확인하는 것이다. 

문제가 변경되지 않으면 주석 처리된 코드에 책임이 없을 가능성이 높다.

이렇게 우리가 살펴봐야 하는 코드의 양을 줄여가는 것을 목표로 한다.

 

 

 

 

디버깅 전략 #2: 코드 흐름 검증

 

복잡한 프로그램에서 흔히 발생하는 문제는 프로그램이 함수를 너무 많이 또는 너무 적게 호출하는 것이다.

이러한 경우 함수 상단에 명령문을 배치하여 함수 이름을 인쇄하는 것이 도움이 된다. 

이렇게 하면 프로그램이 실행될 때 어떤 함수가 호출되는지 확인할 수 있다.

 

함수 이름 인쇄 예시

더보기

여기에서 디버깅 목적으로 정보를 인쇄할 때는 std::cout 대신 std::cerr을 사용하자.

 

std::cout이 버퍼링될 수 있기 때문인데, 이것은 std::cout에 정보 출력을 요청하는 시점과 실제로 출력하는 시점 사이에 일시 중지가 있을 수 있다는 것을 의미한다. 

 

std::cout을 사용하여 출력한 후 프로그램이 즉시 충돌한다면, std::cout은 실제로 아직 출력했을 수도 있고 그렇지 않을 수도 있다. 이로 인해 문제가 어디에 있는지 오해할 수 있다. 

반면에 std::cerr은 버퍼링되지 않는다. 즉, 보내는 모든 내용이 즉시 출력된다. 

이렇게 하면 모든 디버그 출력이 가능한 한 빨리 표시되도록 할 수 있다(디버깅할 때 일반적으로 신경쓰지 않는 일부 성능을 희생하더라도).

 

std::cerr을 사용하면 출력되는 정보가 일반 사례가 아닌 오류 사례에 대한 것임을 분명히 하는 데도 도움이 된다.

 

예를 들어 보자

#include <iostream>

int getValue()
{
	return 4;
}

int main()
{
    std::cout << getValue << '\n';

    return 0;
}

이 코드를 출력하면 결과값으로 4가 나올것을 예상할 수 있지만 1이 출력된다.

 

예상과 다른 내용이 출력된 상황에서, 그렇다면 코드흐름검증 전략을 사용하여 위의 내용에 디버깅 문을 추가해보자

#include <iostream>

int getValue()
{
std::cerr << "getValue() called\n";
	return 4;
}

int main()
{
std::cerr << "main() called\n";
    std::cout << getValue << '\n';

    return 0;
}

 

참고로 std::cerr를 사용할 때는 들여쓰기를 하지 말자. 수정 후 해당 코드를 지우기 위해 식별하는 것도 일이다.

 

이제 이러한 함수가 실행되면 함수 이름이 출력되어 호출되었음을 나타낸다.

main() 호출됨
1

이제 getValue 함수가 호출되지 않았음을 알 수 있다. 함수를 호출하는 코드에 문제가 있는 것 같다. 해당 라인을 자세히 살펴보자.

std::cout << getValue << '\n';
 

함수 뒤에 괄호()를 넣는 것을 잊어서 발생한 오류였다.

 

 

 

 

디버깅 전략 #3: 값 인쇄

 

일부 type의 버그로 인해 프로그램이 잘못된 값을 계산하거나 전달할 수 있다.

이런 경우 변수(매개변수 포함) 또는 표현식의 값을 따로 출력하여 올바른지 확인할 수 있다. 방식은 위에서 설명했던 것과 거의 같다.

int main()
{
	int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';

	std::cout << x << " + " << y << '\n';

	int z{ add(x, 5) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);

	return 0;
}

이렇게 직접 어떤 값이 입력되는지 눈으로 확인할 수 있으며 쉽게 디버깅할 수 있다.

물론 전략 1,2,3을 합쳐서 더 유동적으로 사용할 수 있다.

 

 

 


 

 

 

추가 디버깅 전략

 

 지금까지 알아본 디버깅 방법은 코드와 출력을 너무 복잡하게 만들어서 보기 힘들었다.

게다가 따로 디버그 문을 추가하고 제거하려면 코드를 수정해야 해서 새로운 버그가 발생할 수도 있다.

이렇게 힘들게 디버깅을 해도 작업을 마친 후엔 제거해야 하므로 재사용을 할 수도 없다.

여기서 이와 같은 문제 중 일부를 완화하는 방법을 알아보자.

 

 

전략 1 완화 - 디버깅 코드 조건화

프로그램 전체에서 디버깅을 더 쉽게 비활성화/활성화하는 방법은 전처리기 지시문을 사용하여 디버깅문을 조건부로 만드는 것이다.

#include <iostream>

#define ENABLE_DEBUG // comment out to disable debugging

int getUserInput()
{
#ifdef ENABLE_DEBUG
std::cerr << "getUserInput() called\n";
#endif
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}

int main()
{
#ifdef ENABLE_DEBUG
std::cerr << "main() called\n";
#endif
    int x{ getUserInput() };
    std::cout << "You entered: " << x << '\n';

    return 0;
}

이렇게 전처리기 지시문을 추가하면 이제 #define ENABLE_DEBUG 에 주석을 달거나 주석을 제거하여 디버깅을 활성화할 수 있다. 

 

이를 통해 이전에 추가한 디버그 문을 재사용할 수 있으며 작업이 끝나면 실제로 코드에서 제거할 필요 없이 비활성화할 수 있다.

하지만.. 코드가 더 복잡해진다.. 아직 개선의 여지가 남아있다.

 

 

로거 사용 (log)

위의 디버깅에 대한 또 다른 접근 방식은 디버깅 정보를 로그로 보내는 것이다. 

로그는 발생한 이벤트에 대한 순차적인 기록이며 일반적으로 타임스탬프가 표시된다. 로그는 나중에 검토할 수 있도록 디스크의 파일( 로그 파일 이라고 함)에 기록한다.

 

로그 파일에는 몇 가지 장점이 있다. 

로그 파일에 기록된 정보는 프로그램의 출력과 분리되어 있으므로 일반 출력과 디버그 출력이 혼합되어 발생하는 혼란을 피할 수 있다.

 

C++에는 로깅 정보를 쓰는 데 사용되는 std::clog 라는 이름의 출력 스트림이 포함되어 있다.

(우리한테 익숙한 것은  std::cout) 

그러나 기본적으로 std::clog는 표준 오류 스트림에 사용한다.(std::cerr 와 동일)

둘 중 무엇을 사용해도 딱히 상관 없다.

 

 

 


 

 

 

통합 디버거 사용 (단계별 실행)

 

지금까지는 프로그램 실행 후 프로그램 상태를 검사하는 간단한 방법이었다.

적절하게 사용하면 효과적일 수 있지만, 코드를 변경해야 하므로 시간이 오래 걸리고 새로운 버그가 발생할 수 있으며, 코드를 복잡하게 만들어 기존 코드를 이해하기 어렵게 만든다.

 

하지만 최신 IDE에는 이와 같은 단점을 완화시켜주는 기술이 있다.

코드가 완료되기 전, 그러니까 프로그램 실행 중 우리가 개입하여 작업을 정확하게 수행하도록 설계된 디버거라는 통합 도구가 있다.

 

 

디버거 기능 ( visual studio 기준)

 

해당 프로그램이 실행되는 동안 프로그램 상태를 검사할 수 있도록 하는 컴퓨터 프로그램이다.

( 우리가 앞에서 했던 디버그를 하는 과정인 디버깅이랑 오해X )

 

예를 들어, 프로그래머는 디버거를 사용하여 프로그램을 한 줄씩 실행하면서 변수 값을 검사할 수 있다.

변수의 실제 값을 예상되는 값과 비교하거나 코드를 통해 실행 경로를 관찰함으로써 디버거는 의미론적(논리적) 오류를 추적하는 데 큰 도움이 된다.

이제부터 디버거 사용 방법을 알아보자.

 

 

Stepping

Stepping은 코드 문을 문별로 실행(단계별)할 수 있는 관련 디버거 기능 세트의 이름이다. 아래는 stepping 세트 안에 들어있는 기능들이다.

 

step into (디버그 메뉴 > step into 또는 F11)

이 명령은 프로그램의 상태를 검사할 수 있도록 프로그램 실행을 일시 중지한다.

// 어디서든 한 줄씩 차례차례 실행. 함수 안으로 들어감.

 

step over  (디버그 메뉴 > step over 또는 F10)

step into 와 비슷하다.

그러나 step into  함수 호출을 입력하고 한 줄씩 실행하는 반면, step over 는 중지하지 않고 전체 함수를 실행하고 함수가 실행된 후에 제어권을 사용자에게 반환한다.

// 함수 안으로 들어가지 않고 바로 다음줄 실행

 

step out (디버그 메뉴 > step out 또는 Shift + F11)

위의 실행 명령들과는 달리 step out은 현재 실행 중인 함수의 나머지 코드를 모두 실행한 다음, 함수가 반환되면 제어권을 사용자에게 반환한다.

이 명령은 실수로 디버그하고 싶지 않은 함수에 들어갔을 때  유용하다.

// 디버거 화살표가 함수 안에 있는 경우 step out을 하면 함수 밖으로 나감(해당 함수 종료)

 

 

 

 

통합 디버거 사용 ( 실행 및 중단점 )

 

stepping은 코드를 개별적으로 검사하는 데 유용하지만, 대규모 프로그램에서는 코드를 단계별로 실행하기엔 너무 오래 걸린다.

다행히 최신 디버거는 프로그램을 효율적으로 디버깅하는데 도움이 되는 여러가지 도구를 제공한다.

 

 

Run to cursor (코드 문을 마우스 오른쪽 버튼으로 클릭 후 선택 또는 ctrl + F10)

이 명령은 커서가 선택한 명령문에 도달할 때까지 프로그램을 실행한다. 

그런 다음 해당 지점에서 디버깅을 시작할 수 있도록 제어권을 사용자에게 반환한다. 

 

Continue (디버그 메뉴 > Continue(디버깅 도중) 또는 F5 )

디버깅 세션 도중에 해당 지점부터 프로그램을 실행하고 싶을 수도 있다. 이 경우 continue 명령을 사용한다. 

continue debug 명령은 프로그램이 종료될 때까지 또는 제어가 다시 반환될 때까지(예: Breakpoints) 정상적으로 프로그램을 계속 실행한다.

 

Breakpoints (마우스 왼쪽 버튼 클릭 또는 F9 바로가기 키)

디버그 모드에서 실행될 때 중단점에서 프로그램 실행을 중지하도록 디버거에 지시하는 특수 표시이다. (빨간색 아이콘)

 

set next statement (마우스 오른쪽 버튼 클릭 또는 Ctrl + Shift + F10 바로가기 키)

자주 사용되는건 아니지만 알아 둘 가치가 있는 디버깅 명령이다.

set next statement 명령을 사용하면 실행 지점을 다른 명령문( 점프 라고도 함 )으로 변경할 수 있다.