본문 바로가기
Topos Uranos

요약

틀린 결과를 내는 프로그램은 줄을 아무렇게나 바꾸어서가 아니라 조사해서 고친다. 이 수업은 디버깅을 실험적 방법으로 소개한다. 장애를 재현하고, 그 원인에 대한 가설을 세우고, 가설을 확인하거나 기각하는 실험을 설계하고, 결함의 위치를 좁혀 가는 것이다. 이분 탐색은 프로그램 크기의 로그처럼 늘어나는 단계 수로 결함을 찾아낸다. 이 수업은 도구(-g 옵션을 주고 최적화 없이 하는 컴파일, 세 운영체제에서의 Visual Studio Code 설정, 편집기가 내부에서 하는 일을 보여 주는 실제 GDB 세션), 단순 중단점과 조건부 중단점, 단계별 실행, 호출 스택, 상태 살펴보기를 하나의 중심 예제로 전개한다. 데이터 개수가 짝수일 때만 틀리는 중앙값이다. 마지막으로 도구보다 값진 전략(사례 축소하기, 마지막으로 올바른 지점 찾기, assert 매크로로 불변식 확인하기, 새니타이저 보고서 읽기)과 세 가지 사례, 곧 하나 차이 오류, 끝나지 않는 재귀, 무효화된 참조를 다룬다.

학습 목표

  1. 디버그 정보를 넣은 컴파일(-g 옵션, 최적화 없음)을 설정하고, 자기 시스템에서 Visual Studio Code로 디버거를 시작한다.
  2. 단순 중단점과 조건부 중단점, 단계별 실행(들어가기, 건너뛰기, 나오기), 그리고 변수, 식, 호출 스택 살펴보기를 활용한다.
  3. 체계적인 전략(재현, 축소, 이분, 가설 세우기와 검증)을 적용하여 주어진 결함의 위치를 찾고, 각 가설을 기록한다.
  4. 디버거를 새니타이저 및 테스트와 결합하여, 보고서로부터 메모리 오류의 위치를 찾는다.

디버깅은 조사다

결함과 장애

장애는 관찰되는 것이다. 틀린 결과, 끝나지 않는 프로그램, 새니타이저 보고서가 그렇다. 결함(또는 오류)은 원인이다. 명세에 맞지 않는 한 줄, 하나의 조건, 또는 하나의 설계 결정이다. 장애는 보이지만 결함은 찾아내야 하며, 장애에서 멀리 떨어져 있을 수도 있다. 그러므로 장애가 보이는 곳에서 고치는 것, 예컨대 “항상 2만큼 더 나온다”는 이유로 결과에서 2를 빼는 것은 증상을 고치고 원인은 남겨 두는 일이며, 그 원인은 다른 데이터에서 다시 나타난다.

그렇다면 디버깅은 원인에 대한 조사이고, 그 방법은 실험 과학의 방법이다.

  1. 관찰과 재현. 장애를 정확히 기술하고(어떤 입력으로, 무엇을 기대했고, 무엇을 얻었는지), 원할 때마다 일어나게 만든다. 재현되지 않는 결함은 조사할 수 없고, 고쳐졌는지 확인할 수도 없다.
  2. 가설 세우기. 원인에 대한 주장으로서 거짓으로 드러날 수 있는 것이다. “정렬이 정렬하지 않는다”, “두 번째 가운데 데이터의 인덱스가 하나 크다”.
  3. 실험 설계. 프로그램의 한 지점을 골라, 가설이 참이면 거기서 어떤 상태가 보여야 하고 거짓이면 어떤 상태가 보여야 하는지 예측한다.
  4. 결론과 범위 좁히기. 가설을 기각하거나 확인한다. 어느 경우든 의심스러운 구간이 줄어든다. 구간이 한 줄이 될 때까지 이를 되풀이한다.

그러나 이 방법은 규율을 요구한다. 한 번에 한 가지만 바꾸고, 각 가설을 그 결과와 함께 적어 두고, 무엇을 찾는지 모르는 동안에는 코드를 건드리지 않는다. 출력이 맞아 보일 때까지 줄을 아무렇게나 바꾸면 결함은 없애지 못한 채 장애만 숨길 수 있다.

예시데이터 개수가 짝수일 때의 중앙값

데이터 목록의 중앙값은 개수가 홀수이면 정렬한 목록의 가운데 데이터이고, 짝수이면 가운데 두 데이터의 평균이다. 프로그램 테스트 수업에서는 테스트가 함수가 언제 장애를 일으키는지는 드러내지만 그 결함이 어디 있는지는 알려 주지 않는다는 것을 보았다. 아래 함수는 데이터가 다섯 개일 때는 테스트를 통과하고 네 개일 때는 통과하지 못한다.

C++ 프로그램
#include <algorithm>#include <cstddef>#include <print>#include <vector>​// 데이터 목록의 중앙값.// 사전 조건: values는 비어 있지 않다.// 사후 조건: 정렬한 목록의 가운데 데이터, 또는 가운데 두 데이터의 평균을 돌려준다.double median(std::vector<double> values){    std::ranges::sort(values);    const std::size_t n{values.size()};    const std::size_t middle{n / 2};    if (n % 2 == 1) {        return values[middle];    }    return (values[middle] + values[middle + 1]) / 2.0;}​int main(){    const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0};    const std::vector<double> even{7.0, 1.0, 5.0, 3.0};    std::println("{}의 중앙값: {}", odd, median(odd));    std::println("{}의 중앙값: {}", even, median(even));}
출력
[7, 1, 5, 3, 9]의 중앙값: 5[7, 1, 5, 3]의 중앙값: 6

관찰은 정확하다. {7,1,5,3}\{7, 1, 5, 3\} 데이터를 정렬하면 {1,3,5,7}\{1, 3, 5, 7\} 순서가 되고, 기대한 것은 (3+5)/2=4(3 + 5)/2 = 4 값이었지만 얻은 값은 6이었다. 데이터가 다섯 개이면 결과가 맞다. 적어도 세 가지 가설이 가능하다. (H1) 정렬이 정렬하지 않는다. (H2) 두 가운데 데이터의 인덱스가 틀렸다. (H3) 덧셈이나 나눗셈이 잘못 쓰였다. 각 가설은 17번 줄을 실행하기 전의 상태에 대해 서로 다른 것을 예측한다. H1이 참이면 values 벡터는 정렬되어 있지 않다. H2가 참이면 values[middle] 원소와 values[middle + 1] 원소는 3과 5가 아니다. H3 가설이 참이면 두 데이터는 3과 5인데도 계산 결과가 6이다. 홀수인 경우가 제대로 동작하므로 H1은 가능성이 낮지만, “가능성이 낮다”는 “기각되었다”가 아니다. 디버거로 하는 실험은 17번 줄에서 프로그램을 멈추고 살펴보는 것이다.

이분 탐색

뚜렷한 가설이 없을 때에도 실패하지 않는 방법이 남아 있다. 이분 탐색이다. 프로그램의 상태가 처음에는 올바르고 끝에서는 틀리며, 중간의 각 지점에서 상태가 올바른지 판정할 수 있다고(손으로 계산한 값과 비교하거나 불변식을 확인해서) 가정한다. 의심스러운 구간의 한가운데에서 상태를 살펴본다. 올바르면 결함은 뒤쪽 절반에, 그렇지 않으면 앞쪽 절반에 있다. 이것은 탐색과 정렬 수업의 이진 탐색을 프로그램의 줄들(또는 반복문의 회차들, 또는 파일의 버전들)에 적용한 것이다.

그림은 결함이 11번 줄에 있는 열여섯 줄에 대한 이분 탐색을 따라간다. 각 눈금은 그 줄 뒤의 상태에 대한 하나의 실험이며, 결과는 올바름(✓) 또는 틀림(✗)이다.

증명

  1. 처음에는 열여섯 줄 모두가 의심스럽다. 상태가 1번 줄 앞에서는 올바르고 16번 줄 뒤에서는 틀렸다는 것만 알고 있다.

  2. 8번 줄 뒤에서 상태는 올바르다. 1번 줄부터 8번 줄까지는 혐의를 벗는다.

  3. 12번 줄 뒤에서는 상태가 이미 틀렸다. 결함은 9번 줄과 12번 줄 사이에 있다.

  4. 10번 줄 뒤에서는 올바르다. 11번 줄과 12번 줄이 남는다.

  5. 11번 줄 뒤에서는 틀렸다. 마지막으로 올바른 지점과 처음으로 틀린 지점이 이웃하므로, 결함은 11번 줄에 있다.

개수 세기는 이진 탐색과 같다. 실험마다 구간을 둘로 나누므로, kk번의 실험이면 2k2^k줄에 충분하다. 천 줄짜리 프로그램이라면 열 번이면 된다. 210=10242^{10} = 1024 관계가 성립하기 때문이다. 여기서 이 방법의 장점이 나온다. 그 비용은 크기의 로그처럼 늘어나고, 프로그램 전체를 이해할 것을 요구하지 않으며, 한 지점에서 상태가 마땅히 그래야 하는 상태인지 판정할 줄 알기만 하면 된다. 그러나 바로 그 판정이 어려운 부분이다. 명세와 불변식이 각 중간 상태를 판단하는 기준이다.