본문 바로가기

리뷰/책 리뷰

[IT/리뷰] Debug It! 실용주의 디버깅

728x90

Debug It! 실용주의 디버깅 : 네이버 도서 (naver.com)

 

Debug It! 실용주의 디버깅 : 네이버 도서

네이버 도서 상세정보를 제공합니다.

search.shopping.naver.com

디버깅에 대한 새로운 관점을 열어주고 다양한 디버깅 툴에 대해서도 알 수 있지 않을까 해서 읽기 시작했다. 개인적으로는 크게 도움이 된 것은 아니긴 했다. 그래도 일부 내용들은 생각해볼 만했고 공감되는 내용도 있었다.

 

이 책에서도 역시 디버깅을 하기 위해 CI/CD, 자동 테스트 프레임워크, 컴파일러로부터 미리 정적 테스트 수행하기 등 컴파일과 빌드 단계에서 테스트할 수 있는 것을 중요하게 강조했다. 하루빨리 CI/CD, 자동 테스트 프레임워크를 업무에 도입시키고 싶다.

 

아래는 책 내용에 대해서 간단하게 정리겸, 느낀 점에 대해 쭉 적어보았다.


들어가는 글 쪽에 써있는 내용들이 인상 깊었다.

설계, 구현, 분석, 방법론 같은 책들은 많은데 왜 디버깅 책은 관심이 별로 없는 걸까? 그건 개발자들이 모두 디버깅을 '할 줄 안다'라고 생각하기 때문이다.

사람들은 일반적으로 처음부터 수영을 할 줄 모르기에 배운다. 하지만 달리기를 배우려하지는 않는다. 이미 수없이 넘어지며 경험했기 때문이다. 그런데 달리기를 '할 줄 아는 것'과 '잘하는 것'은 차이가 있다. 일반 사람과 육상선수의 차이다. 우리는 개발자이기 때문에 디버깅을 '잘해야'한다.


디버깅은 코드나 디버거로 하는 것이 아니라 머리로 하는 것이다. 근본적인 원인을 찾는 것이 디버깅이며, 단순히 버그를 없애는 것이 아니다. 아래와 같은 순서 모두가 디버깅이다.

  1. 왜 이상하게 작동하는지 알아낸다.
  2. 문제를 수정한다.
  3. 다른 곳이 깨지지 않게 한다.
  4. 코드의 전반적인 품질(가독성, 구조, 테스트 커버리지, 성능 등)을 유지하거나 향상한다.
  5. 같은 문제가 다른 부분에 없는지 살펴보고 재발 방지책을 마련한다.

1번이 가장 중요하다. 이해 없이 마냥 문제를 고친다면, 고친 코드가 아무런 효과가 없거나 또 다른 문제만 야기할 수도 있다.

이해 없이 문제를 고치더라도, 해결한 것처럼 보일 때가 있다. 이는 문제를 고친 게 아니라 오히려 근본 원인을 안 보이게 한 것일 가능성이 높다.

어떤 버그는 사실 여러 문제가 종합적으로 발생한 경우일 수도 있다. 이때는 한 번에 한 개의 문제씩 해결해 나가야 한다.

디버깅에서 재현은 아래의 이유 때문에 중요하다.

  1. 잘못된 모습을 살펴봐야 경험주의 과정에서의 디버깅이 가능하다.
  2. 왜 이상한지 이론을 도출하더라도 증명할 방법이 있어야 한다.
  3. 버그를 수정했다고 생각되더라도 실제 수정됐는지 확인할 수 없다.

재현을 할 때 가장 먼저 해볼 것은 버그 리포트에 적혀있는 방법을 그대로 따라 하는 것이다. 어쩌면 버그 리포트에서 어느 운영체제인지, 당시 설정이 어땠는지, 그때 어떤 다른 일을 했는지 등에 대해 템플릿에 포함된 표준 정보가 없는 경우도 있을 것이다. 하지만, 어떤 버그는 그 표준 정보 모두가 필요하지는 않을 것이다.

성공적인 재현은 제어에 달려있다. 관련 변수를 모두 제어한다면 문제를 재현할 수 있을 것이다. 물론 그 변수가 무엇이고 어떤 경우에 버그가 재현되는지를 알기란 어렵다. 그러니 버그 발생 환경과 최대한 동일하게 맞춰야 한다. 대부분의 경우 버그 재현과 관련된 것은 극히 일부다.


입력 제어의 세부 방법으로는 여러 가지가 있다.

  1. 입력 추론하기
  2. 결과로부터 입력과 상황을 거꾸로 추론하며 작업하기
  3. 탐색하기 (경계값 분석, 분기 커버리지)
  4. 억지로 성공 경로를 벗어나는 에러 상태 만들기
  5. 타이밍에 의한 버그일 때는 임의성(퍼지 테스팅) 도입하기
  6. 입력값 기록(로깅)
  7. 로그(로그 프레임워크)
  8. 외부 로그(로깅 프락시)
  9. 부하와 스트레스

외국 유머로 있는 디버깅 6단계다. 세상 사람들 다 똑같다고 느꼈다.

  1. 그건 불가능해
  2. 내 컴퓨터에서는 재현 안 되는걸
  3. 이렇게 안 돼야 하는데
  4. 왜 이렇게 된 거지?
  5. 오, 알겠다.
  6. 이게 어떻게 지금껏 시행되고 있었지?

중요한 건 마지막 6번이다. 6번의 생각이 든다면 아직 정확히 원인을 이해하지 못한 것이다. 이걸 이해할 수 있을 때까지 계속 살펴보면 뭔가를 배울 가능성이 굉장히 높다.

어떤 버그가 코드 안에 들어갈 수 있었다는 사실은 개발 프로세스 어딘가에 무엇인가가 잘못됐다는 것이다. 정확히 언제? 왜?

  1. 요구사항
    - 요구사항이 완전하고 올바른가? 애매하거나, 이상하게 해석되거나, 잘못 이해되지는 않았는가?
  2. 아키텍처나 설계
    - 아키텍처나 설계에서 놓친 부분은 없는지? 설계는 괜찮은데 구현을 제대로 못한 것은 아닌지?
  3. 테스팅
    - 테스트가 충분한지? 테스트 자체가 문제가 있는 것이 아닌지?
  4. 구현
    - 단순히 코드 작성을 실수한 것인지? 기초 기술(라이브러리, 컴파일러 등)의 어떤 부분을 잘못 이해한 것인지?

 

어떤 게 좋은 버그 리포트일까? '작동 안 됨' 외에는 아무 정보가 없어 도움이 전혀 안 되는 버그 리포트를 받고 좌절해본 적이 다들 한 번쯤 있을 것이다.

당연히 문제 진단에 필요한 모든 정보가 있어야겠지만 진단해보기 전에 어떤 정보가 관련 있을지 알 수 없기 때문에 보통 좋은 버그 리포트에는 필요한 것 이상으로 많은 정보가 있어야 한다. 버그 리포트는 상세하고, 분명하며, 구체적이어야 한다. 에러 메시지는 정확하게 뭐라고 돼있는지? 데이터는 어떻게 손상됐는지? 무엇을 하다가 문제가 생겼는지? 출력이 어떻게 틀렸는지? 와 같은 참고자료가 있어야 된다.

하지만, 동시에 버그 리포트는 최대한 작아야 한다. 버그 재현에 필요한 만 줄짜리 파일의 크기를 최대한 줄일 수 있는가? 버그 재현에 필요 없는 스탭은 무엇인가? 버그가 안 생기는 버전은 있을까? 와 같은 추적이 필요하다.

더불어 버그 리포트는 유일해야 한다. 이미 보고된 문제를 다시 보고해봐야 도움이 안 될 것이다.

 

거의 모든 버그 추적 시스템에는 환경 입력 창이 있다. 단순히 OS나 브라우저를 입력할 텐데, 이것은 두 가지 이유에서 부족하다. 먼저, 일반 사용자들의 환경에 대해서 전혀 모른다. 두 번째로는 컴퓨터 환경은 언제나 생각보다 훨씬 복잡하다. 같은 브라우저라도 버전과 플랫폼, 플러그인, 쿠키, 자바 스크립트 설정 여부 등이 다양하게 영향을 준다.

이런 영향을 줄만한 모든 환경을 기록한다면 해결할 수 있다. 불필요한 정보도 많겠지만 정보를 자동으로 얻을 수만 있다면 수집에 드는 비용은 없을 것이고 정보는 신뢰할 수 있을 것이다. 혹시 관련이 있을지 모르니까 중요하다.

리포팅되는 대상 소프트웨어에서 지원하는 모든 설정 옵션도 자동으로 기록해주는 게 좋다. 그래야 'X 기능을 활성화했나요?'와 같은 질문을 더 이상 하지 않을 수 있다.

 

가끔 버그가 너무 심각해 정상적인 릴리스 일정을 깨고 기존 릴리스를 패치해야 할 때가 있다. 이런 버그도 진단할 때는 여느 버그와 다를 게 없다. 하지만 버그를 어떻게 고칠까 고민하는 시점부터는 어려워진다. 평상시와 목표가 다르기 때문이다. 평상시의 제일 목표는 근본 원인을 고치는 것이다. 하지만 기존 릴리스를 패치할 때는 리스크를 최소화하는 게 제일 목표다.

제대로 수정하려면 광범위한 리팩터링이나 아키텍 깊숙이 수정해야 할 필요가 있을 수 있다. 평소와 달리 패치할 때는 전체 릴리스 과정에서의 견제와 균형(QA 테스트 기간, 베타 테스트 등)이 없기 때문에 회귀가 발생하기 쉽고, 고친 결과가 더 안 좋을 수도 있다.

따라서 패치할 때는 근본 원인을 고치기보다는 증상만 막아주는 궁여지책이 더 좋을 수도 있다. 당연히 평소라면 '땜질'을 피해야 하기 때문에 양쪽 균형 잡기가 굉장히 어렵다. 땜질하기로 했다면 '대강해도 되겠지'라고 생각하는 함정에 빠지지 않게 주의해야 한다. 땜질로 고치기 때문에 여러 문제를 더 신경 써야 한다.

릴리스 패치뿐만 아니라 개발 버전도 같이 수정해야 한다. 하지만 무작정 똑같이 고치면 안 된다. 개발 버전은 릴리스 주기를 거치기 때문에 근본 원인을 제거할 수 있게 고쳐야 한다. 하지만 패치에서 고친 것과 다음 버전에서 고친 방식이 다르면 릴리스끼리 작동 호환이 안 되는 문제가 생길 수 있다. 하위 호환성을 고려해야 하는데, 패치는 개발자와 사용자 모두에게 비용이 많이 든다.


병렬 소프트웨어는 재현, 진단, 수정하기 어려운 문제가 넘친다 종종 비결정적이고 미묘한 부분에 의존하고, 상호 작용을 이해하기도 어렵고, 이상하게 죽는다. 병렬 소프트웨어에 디버깅을 도와줄 것들을 이것저것 집어넣을 수 있다. 핵심적인 것 두 가지는 단순함과 제어다.

단순함은 병렬성을 다룰 때 특히 중요하다. 스레드 간의 상호작용을 간단하게 유지하고, 이런 코드를 최소한으로 제한하자. 상호작용을 얼마나 간단히 만들 수 있는지 알면 깜짝 놀랄 것이다. 

병렬 소프트웨어의 버그 대부분은 지극히 일반적이며 병렬과는 상관없다. 하지만 진단 도중에 여러 스레드가 실행되고 있다는 것이 성가시게 만든다. 따라서 어떻게든지 소프트웨어를 병렬이 아니게 빌드할 수 있다면 좋다. 스레드를 제한하던지, 스케줄러 마음대로 컨텍스트 스위칭 되는 것을 막아 잘 정의된 순서대로 실행되게 하던지 방법이야 상관없다.

병렬성 버그 대부분은 정확한 순간과 정확한 위치에서 컨텍스트 스위칭이 발생해야 재현된다. 이런 컨텍스트 스위칭 발생 시점을 정확하게 제어할 수 있어야 버그를 안정적으로 재현할 수 있다.

다만 다중 스레드에서 sleep을 적절하게 호출하는 경우는 거의 없다. sleep 자체가 올바른 선택인 경우 자체가 거의 없는 것이다. sleep은 병렬 프로그래밍의 goto문이다.

 

성능 문제의 근본 원인은 열에 아홉이 전체 성능을 제한하는 일부 코드, 즉 병목에 있다. 일단 병목을 찾아야 그다음으로 병목의 원인을 알아볼 수 있다.

성능 버그를 찾는 데 있어 다른 모든 도구보다도 월등히 뛰어난 도구가 바로 프로파일러다. 성능 버그를 진단하기 전에 먼저 코드를 프로파일링 하자. 프로파일러 종류는 어떻게 작동하는가와 얼마나 상세하게 검사하는가에 따라 다양하다. 공통점은 어디에서 실행 시간이 가장 많이 걸리는지를 보기 위해 우리 코드를 실행시켜 놓고 검사한다는 것이다. 병목을 찾는 확실하면서도 유일한 방법은 소프트웨어를 실행시켜 본 뒤 얻은 실제 데이터를 기반으로 찾는 것이다.

그렇기 때문에 어떻게 하면 프로파일 결과가 소프트웨어의 설계 작동을 정확하게 반영할 수 있을지를 가장 많이 고민해야 한다.

 

프로파일링에서도 '관찰자 효과'가 적용된다. 이론상으로라도 검사하려는 소프트웨어에 변화가 생긴다. 따라서 개발자들은 영향을 최소화하려고 온갖 노력을 다했다. 따라서 대부분의 경우는 프로파일러 자체 때문에 조사 결과가 들쑥날쑥해질지 모른다는 우려는 접어도 된다. 다음을 꼭 지키자.

  • 가능한 한 실제 제품과 비슷하게 빌드해 프로파일링 한다. 같은 수준으로 최적화해서 빌드해야 한다.
  • 실행 환경을 최대한 실제 제품이 실행되는 환경과 비슷하게 만든다. 개발용 머신으로 이렇게 할 수 있을지는 개발 중인 소프트웨어의 종류에 따라 다르다.
  • 전형적인 데이터로 소프트웨어를 실행한다. 실제 제품용 데이터보다 적은 데이터는 여러모로 편리하겠지만 오해를 불러일으키는 프로파일링 결과가 나올 수 있다.

가끔은 소수의 병목지점 대신 소프트웨어가 그냥 전반적으로 느리거나 느려지는 부분이 임의로 나타나는 것처럼 보일 때가 있다. 성능에 영향을 미칠 수 있는 부분을 전체적으로 살펴봐야 한다.

  • 자원 고갈
  • 가비지 컬렉션
  • 캐싱

 

서드파티 소프트웨어 버그가 있을 수 있다. 우리가 만든 버그가 아닐 가능성이 있다는 것이다. 하지만 우리 코드를 먼저 의심하자. 버그가 다른 곳에 있다는 결론이 나더라도 다시 한번 우리 코드를 세심히 보자. 모든 방법을 동원해도 우리 문제가 아니면 그제야 서드파티 코드를 비난하자. 서드파티 코드는 우리 코드보다 훨씬 여러 다른 제품, 여러 많은 사람이 사용하고 있을 가능성이 높다. 즉, 이미 테스트가 잘 돼 있고, 대부분의 명백한 버그는 이미 발견됐을 거라는 뜻이다.

 

효과적인 자동 테스팅에서 최대한 크게 이득을 얻으려면 다음 목표를 만족해야 한다.

  • 명확한 성공/실패
    - 해석이 필요해서는 안 된다.
  • 독립성
    - 테스트 실행 전 설정 작업이 필요하면 안 된다. 어떤 환경이 필요하다면 테스트가 알아서 설치하고 테스트가 끝나고 처음 상태로 되돌려놔야 한다.
  • 한 번 클릭으로 실행
    - 테스트는 서로 간섭하지 않고 한 번에 전부 실행할 수 있어야 한다.
  • 광범위한 커버리지
    - 모든 중요한 코드에 대해 완전한 커버리지를 달성하기에는 비용이 너무 많이 든다. 하지만 이론적인 제약 때문에 포기하지 말자. 충분히 완전에 가까운 커버리지를 달성해 사실상 큰 차이 없게 만드는 것이 가능하다.

 

브랜치 길들이기를 할 때 경험적으로 다음 규칙들이 괴로움을 줄여 준다.

  • 최대한 늦게 브랜치 한다. 안정화 브랜치를 미리 만들고 싶을 수도 있지만 그러면 중복 작업(패치 등)으로 생산성이 저하될 수 있다.
  • 브랜치 단계를 한 단계만 유지한다. 브랜치를 브랜치하고 있다면 문제가 있다.
  • 지속적 통합 서버를 구축해 실제 사용 중인 모든 브랜티치를 빌드한다.
  • 변경 사항을 조금씩 자주 체크인한다. 조금씩 변경하면 이해하기도 쉽고, 필요할 때 병합하거나 되돌리기도 쉽다.
  • 브랜치에는 꼭 수정해야 하는 것만 수정한다.
  • 항상 브랜치에서 트렁크로 병합하고 반대로는 하지 않는다. 브랜치는 릴리스 된 소프트웨어를 대표한다. 즉, 브랜치에 어떤 문제가 있다면 트렁크에 어떤 문제가 있을 때보다 결과가 더 심각하다.
  • 브랜치에서 트렁크로 병합할 때는 재빨리 한다. 까먹기 전에 전부 다 병합하기 위해서다. 여러 변경사항을 한 번에 모아서 병합하지 않는다.
  • 변경 사항을 기록해 언제 뭐가 바뀌었는지 알 수 있게 한다.

 

소프트웨어는 제품 상태에서는 견고해야 하고, 디버깅할 때는 잘 깨져야 한다. 그러니 assert 문을 사용하라. c/c++에서는 #define으로 빌드 상태에 따라 assert 동작을 활성화시키는 방법이 있을 것이다. 제품 상태에서는 견고하기 위해 조건문으로 방어를 해야 한다. 여기에 방어문 바로 위에 assert 문을 추가해보자. 이러면 제품 상태에서는 assert 가 동작하지 않아 안전하고, 디버깅 상태에서는 assert 가 동작해 효과적으로 확인이 가능하다.

728x90