[system programming] Debugging
본 게시글은
서울대학교 데이터사이언스대학원 성효진 교수님의
데이터사이언스 응용을 위한 컴퓨팅 시스템 강의를
학습을 목적으로 재구성하였습니다
이번 수업시간에서는 디버깅에 대해서 다룬다
시스템 프로그래밍보다는 약간 소프트웨어 엔지니어링 적인 주제인데
쉽다고 여길 수도 있기 때문에
디버깅할 때 그냥 이렇게 하면 좋구나~하는 정보를 얻는 느낌으로
약간 가볍게 들어도 좋다고 한다

디버깅에 대해 알아보자

우선 기본적인 디버깅의 정의에 대해 배우고
만약 scientific한 debugging에 대해 배우고 싶다면
마지막에 소개된 책을 읽으라고 하셨다
이 scientific debugging이란 과학적으로
무언가를 검증하는 가설을 세우고 가설을 검증하는
과학적인 방식으로 디버깅을 하는 것인데
좀 더 체계적인 디버깅 방법이라고 한다
tools는 debugging을 도와주는 툴들에 대한 설명인데
low level debugger인 gdb와 같은 툴들을
몇 가지 배워본다고한다

우선 용어부터 좀 정의를 하고 가자
defects는 우리가 코드를 작성하면서
잘못 작성한 실수들을 말을 하고
error는 코드를 작성하면서 잘못된 것들을 말한다
failures는 따라서 이런 에러때문에
그 결과가 프로그램에서 드러나는 것들을 말한다
따라서 디버깅의 목표는 이러한 failures를 보고
defects를 backtracking하는 것이다

프로그래머가 실수를 했을 때
사실 에러가 발생할 수도 있고 안날 수도 있다
그리고 이러한 에러는
값이 잘못 나온 것일수도 있고
control flow가 잘못된 것일 수도 있다
에러와 failure가 똑같지 않은 이유는
에러가 마스크가 될 수 있기 때문이다

또 모든 defect가 failure가 되는 것은 아니다
그래서 testing을 하는 이유는 숨겨진 defects를 찾기 위함이다
그렇다고 또 testing을 성공했다고 해서 defects가 없는 것도 아니다

디버깅을 한다는 것은 결국 코드를 잘 거슬러 올라가서
defect를 찾아내는 방법이다
에러가 항상 드러나는게 아니라 에러가 퍼지는 과정이
계속 전달되고 전달되다가 마지막에
failure를 보기 때문이다

디버깅 프로세스에 대한 내용이다
당연한 것 같지만 사실 당연하지 않은 내용이다
디버깅을 할 때 정말 기본적으로 해야하는 것들이 있는데
생각보다 정말 많은 학생들이 지키지 않는 경우가 있다고 한다
그래서 이 방법들을 꼭 알아뒀으면 좋겠다고 하셨다
우선 컴파일에러만 보고서는 실행하지 못하는 경우가 있다
그래서 내가 원인을 찾아야겠다고 시작하는 시점부터
디버깅 프로세스는 시작되는 것이다
코드가 단순하지 않아서 바로 원인을 찾지 못한다면
어디서 어떤 문제가 있는지 모르기 때문에
failure를 재생산 할 수 있는 아주 작은 test case를 만드는 것이 좋다
이를 regression test case라고 하는데
코드를 변경했을 때 우리의 코드가 잘못될 수가 있는데
regression test case는
코드가 잘못되지 않았다는 것을 보장해주는 코드이다
코드를 업데이트 했지만 버그를 가져오지 않았다는 것을
확인해주는 test case이고
이걸 만들기전까지는 두번째 단계로 넘어가선 안되는데
한 번에 하나씩만 디버깅 해라는 소리이다
이제 failure의 이유가 무엇인지 생각해보자
여기서 이유를 찾기 위해서 할 수 있는 것은 3가지 단계를 반복하는 것이다
이게 과학적인 디버깅 방식인데
1. 어디에 문제가 있을지 관찰을 하고
2. 거기에 대해서 가설을 세우고
3. 가설을 증명하기 위한 실험을 하고
가설을 support하거나 reject한 다음에 계속 반복하는 것이다
그렇게 원인을 찾고나면 우리의 실수를 고쳐야한다
또 이렇게 실수를 고치면서
고치는 과정에서 또 새로운 mistake가 발생하지는 않는지 확인해야한다

이 conatin이라는 함수로 예시를 들어보자
sub에 very happy를 넣었고
full string은 아래와 같다고 하면
사실 True를 반환해야하는데
결과가 True가 나오지 않는다

그렇다면 우리가 할 수 있는 것은
어디에서 문제가 발생할 수 있는지
divide and conquer 방식으로 찾는 것이다
에러를 재현할 수 있는 가장 단순한 test case를 만들라는 것이다
very 다음에 happy가 나와야한다고 생각해서
첫 번째 very에서 끝나는 것이었다

test case를 또 넣으면서 원인을 파악할 수 있다
이런 단순한 test case만으로
결과가 제대로 나오는지도 확인할 수 있다

우리의 failure를 재현할 수 있는
가장 단순한 Test case를 만들어야한다
굉장히 단순한 형태일 수록 좋고
binary search의 방식으로 데이터를 잘라서
디버깅 해볼 수도 있다
이게 무슨말이냐면 문제가 있을 것 같은 데이터를
반 씩 짤라서 해보고
이런식으로 반복해서 문제가 어디에서 발생하는지
확인할 수 있다는 뜻이다
만약 함수 호출 여러개가 얽혀있는데 문제가 생겼다면
어떤 입력을 주고 이런 sequence를 하나의 test case로
생각을 하고 어떤 조합이나 순서를 가지고 test case를
사용할 수도 있다

만약의 위의 방법만으로도 안된다면?
가설을 세우고 검증할 수 있다

첫 번째로 할 수 있는건 상황을 한 번 써보는 것이다
그것만으로도 디버깅을 할 수 있는 경우가 생긴다
객관적으로 이 코드에 뭐가 문제가 있는지 분석하려고 하는 것이다
뭐가 문제인지에 대한 가설을 세우는데
그거에 대해서 왜 failure가 생겼는지 설명을 하는 것이다
그런데 뭐가 문젠지 도저히 모르겠어서
가설을 세우지도 못하겠다면?
실험을 조금 해봐야한다
예를 들어서 입력을 조금 바꾼다던지
컴파일 플래그를 다른걸 넣는다던지
실험을 통해서 어떨 때 failure가 발생하고 아닌지를 본다던지..
이런 실험들을 바탕으로 가설을 만들려고 해야한다
여러 개의 가설이 있다면 가장 단순하면서도
우리의 failure를 설명할 수 있는게 좋은 가설이다
코드를 검토해보면서 이게 문제구나! 이걸 찾을 수 있다고 한다

데이터를 가지고 실험을 하면서
우리의 가설이 맞는지 아닌지를 확인한다
디버깅을 할 때는 가장 쉬운 것부터 찾아서
점점 복잡해지도록 해야한다
그러면서 가설을 계속 세우는 것을 반복해야한다

그래서 실험이 맞았다면 diagnosis를 해야하는데
이는 우리나라 말로는 진단한다는 것이고
문제를 해결하는 것을 말한다
만약에 틀렸다면 범위를 좁혀가면서
다시 가설을 세울 수도 있다
이러한 과정을 scientific debugging이라고 한다

만약에 가설이 증명이 됐다면
우리가 진단을 내릴 수가 있다
또 다시 실험을 해서 내가 내린 진단이
제대로 된 것인지를 확인해야한다
그렇다면 어떻게하면 내가 제대로 고쳤는지를 알 수 있을까?
test case를 또 만들면서 실험을 해보는 것인데
이 fix가 제대로 된 것인지를 확인하는
Test case를 만들어야한다
우리가 경험한 input에 대해서만
결과를 확인하는 test case는 안된다

위 예제는 피보나치 수열을 계산하는 예제이다
9부터 숫자를 줄여가면서 피보나치 함수를 불러서 프린트한다

위 코드를 실행을 해보면 n=1일 때 문제가 발생한다
garbage 값이 찍히게 되는 것을 볼 수 있고
이게 failure이다

지금 이 문제는 fib(1)을 실행시켰을 때
1이 나와야하는데 나오지 않는 것이 문제이다
이전것은 다 정상적으로 작동을 하는데 fib(1)이 문제인 것이다
그래서 이 경우 3가지 가설을 위 ppt에서 세웠다

따라서 이 세가지 가설로 실험을 할 수 있다
우리가 brute force로 할 수 있는 것은
컴파일러 flag로 모든 에러메세지를 출력하도록 하는 것이다
원래는 Warning으로 무시되는 것도 컴파일러가
에러로 취급해서 프린트를 하도록 해줄 수 있다
위 사례를 보면 아마도 f가 초기화가 안된 상태로
쓰일 수도 있다는 것을 알려주는 warning이 에러로 뜬다

또 다른 경우는 컴파일러 플래그를 다르게 해서
실험을 해보면 다른 값이 나올 수가 있다
만약 최적화를 안하고 코드를 돌렸는데 됐다?
그럼 최적화 과정에서 에러가 났다는 것을 알 수 있다

이는 새로운 환경이 주어졌을 때
어떤 일이 일어날지를 예측하는 것이다
첫 번째 가정이 맞다고 한다면
피보나치 9부터 내려가는 것이 아니고
1을 먼저 불렀다면 동작할 것이다
만약에 loop의 boundary가 잘못된거라면?
while문의 range를 수정해야할 것이다
만약 f의 초기화가 문제였다면?
f를 초기화해봐야한다

실험할 때 정말정말 중요하고 기본적인 것인데
학생들이 꽤나 안지킨다고 한다
실험할 때 정말 중요한 것은
한 번에 여러 개를 절대 고치면 안된다는 것이다
한 번에 하나씩만 바꿔서 실험을 해봐야한다
첫 번째 가설을 실험을 해봤더니 여전히 에러가 뜬다
그렇다면 이 경우는 아닌 것이다
두 번째는 원래는 n>1이었던 것을 n>=1로 변경해서
실험을 해봤다
값이 나오긴하는데 여전히 틀린 값이 나온다
마지막으로 f 초기화를 진행한 후 실험을 해봤다
그랬더니 우리가 원하는 답이 나오는 것을 확인할 수 있다
그렇다면 이게 정답이 되는 것이고
이렇게 fix를 해주어야한다

보통 가장 흔히 디버깅을 하는 방식은
print를 넣어서 출력하게 만드는 것이다
교수님께서 나쁜 방식은 절대 아니라고 한다
하지만 가끔 print를 넣음으로서
어떤 경우는 실험 자체의 변화가 발생하는 경우가 있다고 한다
타이밍이 바뀐다던가하는 상황이 생길 수가 있는데
sequential한 프로그램에서는 크게 문제가 없지만
병렬프로그래밍을 디버깅하는 경우는
printf는 항상 문제를 일으킨다고 한다
왜냐하면 printf는 스레드가 실행되는 타이밍을 바꾸기 때문이다
또 실험을 할 때는 항상 체계적으로 수행하는 것이 좋다
반드시 한 번 할 때 한개의 컨디션만 바꿔야한다
버그가 여러 개일 때 앞에 버그를 절대 넘어가지말고
순차적으로 고쳐야한다

이건 디버깅 툴들이다
뒤에가면 더욱 자세하게 배운다

가설이 설명이 되면 우리가 이제 진단을 내릴 수가 있다
그리고 이 진단을 바탕으로 버그를 고칠 수가 있다
왜 이 버그가 만들어졌는지
그리고 어떻게 fix했는지 정확하게 진단을 해야한다
그리고 defect와 failure간의 chain이 있는데
이걸 어떻게 하면 완전히 끊을지 방법을 찾아야한다

우리가 이것을 통해서 배워야하는 것은
우리가 이걸 왜 틀렸는지
앞으로 어떻게 해야하는지이다
코드에 문제가 생길만한 부분에다가
assertion을 넣을 수 있다
이런 방식으로도 디버깅을 수행할 수 있고
assertion을 넣을 때는
read only의 assertion이어야만 한다

모든 문제를 복잡하고 스케일 크게
디버깅을 해야하는 것은 아니다
valgrind라는 디버깅 툴이 있는데
이걸 넣으면 메모리 관련 에러를 쭈욱 띄워준다고한다
그리고 정말로 이건 책에서도 나오는 방법인데
디버깅하다가 시간이 너무 오래 걸려서 힘들어지면
take a break...
한숨자라고 한다 ㅋㅋㅋㅋ
붙잡고 있는다고 해결되는게 아니라서
잠깐 떨어져서 딴일도 하고 산책도 좀 하고..
그러다가 다시 돌아와서 보면
그 문제에 대해서 훨씬 분명하게 보이는 경우가 많다

이건 이렇게 디버깅을 하면 안된다는 것을 말해준다
추측만 계속 한다거나
뭔지도 모르면서 이것저것 고쳐본다거나
이전코드 백업하지 않은 채로 디버깅하는.. 그런걸 말한다

어떤 오류가 발생했을 때
예를 들어서 17을 넣었을 때 오류가 발생한다고 해서
if (y==17)...
이런식으로 흔히말해 땜질하지말라고 하신다
이런식으로 디버깅을 하면
궁극적으로 엄청나게 큰 문제가 된다고 하신다

디버깅 툴에 대해서 알아보자

adhoc technique이라는게 있다고 한다
가장 먼저 할 수 있는 것은 코드를 보는 것이다
그다음에 가장 많이 하는 것은
print해서 확인을 하는 것인데
해당 부분에 어떤 정보들을 출력시켜 그 정보를 확인하는 것인데
단순한 문제일 때 이런 방법은 효과적이다

valgrind라고 리눅스 툴인데
메모리 에러를 탐지할 수 있는 툴이라고 한다
메모리 밖에 있는 데이터를 탐지한다
메모리를 free하지 않은 상태로 끝난다던지
메모리 leak이 발생한다던지
메모리와 관련된 다양한 버그를 디텍션해주는 툴이다
우리가 흔히 보는 segmentation fault는
대부분 메모리 access에 대한 에러인데
실제로 메모리를 dynamic allocation 하는 경우
메모리 leak 발생이 잦기 때문에
이러한 valgrind같은 툴은 유용하게 사용될 수 있다
이러한 툴이 동작하는 방식은
코드를 주면 원래 코드 사이사이에 valgrind가
추가적인 메모리 값을 확인하는 instruction을 삽입한다
사이사이마다 메모리 상태를 확인하는데
그렇기 때문에 이걸 실행하면 굉장히 느려지게 될 수 있다

위 예시에서 보면 에러가 두군데에서 발생한다
x[10]에서 발생하고
free를 안해줘서 발생한다

이런 경우 valgrind를 돌리면 에러 메세지가
위와 같이 뜬다고 한다
잘못된 주소에다가 썼다는 invalid write error와
block이 garbage collection이 안된 상태로 사라져서
memory leak이 발생했다는 것을 알려준다

뭐 이런식으로 valgrind를 사용할 수 있는데
검색해서 매뉴얼을 보면 된다고 한다

이런식으로 출력이 된다고 한다

그 다음으로 소개할 툴은 gdb이다
gdb는 우리의 프로그램이 어떻게 동작하는지
하나하나 다 뜯어볼 수 있는 매우 강력한 툴이다
우리의 컴파일러가 컴파일을 할 때 디버그로 컴파일을 하면
디버거가 필요로하는 정보를 다 넣어서 컴파일을 한다
바이너리에 디버깅 정보가 다 들어간 상태로 바이너리가 만들어지고
이게 디버거와 컴파일러간의 약속한 형태로 만들어진다
따라서 이미 gdb가 동작할 수 있는 형태로 만들어져있기 때문에
gdb compatible한 코드로 gdb 실행을 하면
정보 읽기가 가능해진다고 한다
debug mode로 컴파일하지 않고 그냥 정보없이 컴파일 할 수도 있는데
그럼 코드도 굉장히 작아지고 빨라진다
그런데 그렇게 되면 당연히 gdb가 안되는 것..

보통 gdb를 가장 많이 사용할 때는
break point를 걸 때이다
그 시점에서 멈춰서 어떤 변수에 어떤 값이 있는지를 확인 가능하다
watch point라는 것이 있는데
변수에 break point를 걸어서
변수의 값이 변할 때마다 멈춘다
그리고 멈췄을 때마다 값 출력이 가능하다
실행을 statement별로 하나씩 실행할 수 있고
backtracking도 가능한데
함수 호출이 깊게 되어있으면
어디에서 어떤 에러가 났는지 이전 함수들을
다 tracking을 해서 call stack에 있는 함수들을 추적가능하다

gdb 실행법이라고 한다
저런 명령어들이 있다고 한다

이런 것들으 gdb에서 가장 많이 쓰는 것들이라고 한다
next line은 해당 라인의 다음을 실행하는 것인데
함수는 들어가지 않고
step으로 하면 함수 안으로 들어간다고 한다

disassemble로 하면 assembly code도 확인할 수 있고
list를 하면
주변 코드를 볼 수 있고
코드를 보면서 step, next를 사용할 수도 있다

뭐 이렇게 breakpoint를 걸 수 있다고 한다

아까 앞에서 설명헀던 watch point이다
break point인데 메모리에 걸려있는
break point이다
값이 변할 때 멈춘다
위와 같이 watch foo 이런식으로 하면
foo가 바뀔 때마다 실행이 멈추고
어디서 멈췄는지 list를 통해서 확인하고
주변 값들도 다 확인하고 할 수 있다고 한다

가장 유용한 기능이 print라고 한다
어디에선가 멈췃을 때 print가 가능하다고 한다

backtrace도 있다
이건 함수 call stack을 trace하는 것이다
특정 함수에서 interrupt가 걸렸으면
그 시점에서 back trace를 하면
거기에 도달하기까지 어떤 함수들을 부르고 불러서
저기까지 도달했다는 정보를 얻을 수가 있다
어떤 경로로 함수를 호출했는지 알기 어려운 경우가 많은데
그걸 도와준다
또 calls tack 내부에 있는 값들도 읽을 수가 있어서
call stack을 움직이면서 다음에 argument로 무슨 값을 넣어줬는지
local variables는 어떤 것들이 있는지 다 확인할 수 있다

아까 말한 과학적 디버깅에 관한 책이라고 한다
심심하면 읽어보라고..ㅎㅎ