디버깅, 개발자라면 누구라도 한 번쯤 겪었고 앞으로도 꾸준히 겪어야 할 피할 수 없는 숙명이다. 우리는 간단한 논리적 실수부터, 도저히 원인을 알 수 없어 며칠 동안 머리를 싸매는 버그를 해결해왔다. 도대체 우리는 왜 계속해서 디버깅을 해야 하는 걸까?

필자를 포함하여 대부분의 개발자가 만드는 것은 생명과 직결된 항공이나 핵 발사 프로그램 같은 것은 아닐 것이다. 따라서 우리가 만드는 제품은 생산성과 안정성 사이에서 생산성을 택하는 경우가 많으며 이로 인해 발생하는 품질 확인 공백은 결함으로 이어지는 경우가 많다. 즉, 생산성을 포기하면서까지 꼼꼼하게 확인하지는 않기 때문에 결함을 막기 위한 다양한 방법론과 도구를 사용하더라도 완전히 제거하는 것은 불가능에 가깝다. 당연하게도 이는 규모가 클 수록 더 힘들어진다.

다행히 프로그램이란 것은 물리적인 제약이 없기 때문에 문제가 발견되더라도 제품이 릴리즈되기 전에 문제를 해결하는 것이 가능하다. 그래서 대부분의 개발자는 생산성에 지장이 없는 수준에서 구현하되 문제가 발생할 경우 디버깅을 통해 문제를 신속하게 해결하는 전략을 사용한다. 이러한 환경과 문화를 고려했을 때 디버깅은 사실상 개발자의 필수 역량이라고 할 수 있다.

어느 정도 경험이 쌓인 개발자라면 누구나 자신만의 디버깅 원칙을 가지고 있을 것이다. 말 그대로 자신만의 원칙이기 때문에 사람마다 접근하는 방식이 다를 수 있다. 이 글에 대해 동의하지 못하는 개발자가 있을 수 있지만 필자는 그래도 어느 정도 디버깅을 잘하는 편이라 자부하며 이 글에서는 나만의 디버깅 원칙을 정리해 보고자 한다.

마인드셋

어떻게보면 디버깅은 잡기술이라 볼 수도 있지만 그렇기 때문에 어떻게 접근할 것인가라는 마인드셋이 중요하다. 디버깅을 하다보면 "분명 이곳이 잘못된 부분일 것 같은데" 같은 생각으로 인해 엉뚱한 부분에 집착하는 경우가 많다. 이는 가설에 대한 나의 생각이 의심을 넘어 확신으로 변하기 때문이다. 많은 개발자들이 디버깅을 할 때 이러한 상황에 빠지게 되는데 이는 엄청난 시간을 소비하게 되며 개발자의 멘탈적인 부분에서도 타격을 주게 된다.

혹시 매몰비용의 오류를 범하고 있지 않은가?

따라서 디버깅을 할 때는 한 부분이 아닌 모든 것을 의심하는 사고방식이 중요하다.

직관과 탐정

디버깅을 하는 개발자는 마치 추리 소설 속 탐정과도 같다. 탐정은 상황과 증거를 기반으로 경우의 수를 줄이고 간혹 주변인의 도움을 받기도 하며 최종적으로 뛰어난 통찰력으로 범인을 추리한다. 버그를 찾는 개발자도 탐정과 크게 다르지 않다. 디버깅이란 주어진 상황과 데이터를 기반으로 문제의 원인을 찾아나가는 과정이기 때문이다.

이 친구들이 개발자면 매일 버그가 생길지도 모른다...

디버깅을 통한 문제 해결은 보통 개발자의 직관에서 나온다.

잠깐, 직관이라니? 그런 애매모호한 방법을 사용해도 되는 건가? 라고 생각할 수도 있지만 아마 대부분 직관을 통해 문제를 해결해본 경험이 있을 것이다. 직관은 모 아니면 도라는 식으로 찍는 방법이 아닌, 내가 가지고 있는 지식과 경험을 바탕으로 문제를 해결하는 방법1이다. 이미 이전에 한 번 유사한 경험을 했을 수 있고, 관련된 지식이 있기에 문제를 찾을 수 있는 것이다.

직관은 어디서 나오는가?

개발자는 에러 메시지, 코드, 상황 등의 데이터를 수집하고 이를 분석하여 '이 문제는 어떤 것과 관련이 있을 것 같다'라는 정보를 얻는다. 이렇게 얻은 정보를 바탕으로 연관된 지식을 개발자의 경험, 인터넷, 도서, 동료 등을 통해 얻을 수 있다. 그리고 이러한 지식을 문제와 연결함으로서 생기는 가설을 테스트하고 결과를 분석하여 문제를 해결할 수 있다. 여기서 내가 아는 지식을 문제와 연결하는 것이 직관이다.

보통 '어 설마 이건가?'하고 정답이 뿅 나오는 것은 이미 지식이 충분하고 유사한 경험을 했기 때문이다. 즉, 경험이 직관으로 통하는 숏컷 역할을 해주는 것이다. 만약 경험과 지식이 부족하다면 바로 정답으로 가지 못할 수도 있다. 그럴 때는 느리더라도 직관2을 만들기 위한 과정을 거쳐야 한다. 가장 쉬운 방법은 가능성을 좁히는 훈련을 하는 것이다. 이어서 이에 대한 이야기를 해볼 것이다.

가능성을 좁혀라

불가능을 제외하고 남은 것은 아무리 믿을 수 없어도 진실이다.
    — 셜록 홈즈, 네 사람의 서명에서

개발자는 디버깅은 예상하지 못한 동작의 원인을 찾기 위하여 본능적으로 가능성을 좁혀나가는 행위를 하게된다. 하나씩 아닌 것을 걸러내면 결국엔 정답을 찾게 된다. 이때 자신만의 원칙을 가진 개발자도 있을 것이고 일단 주먹구구식으로 찾아보는 개발자도 있을 것이다.

개인적으로는 원칙을 가지고 하나씩 좁혀나가는 것이 효율적이라 생각한다. 일단 디버거를 켜고 시작하는 바텀업 방식은 여러 함정에 빠질 위험이 크다. 여기서부터 필자가 디버깅을 할 때 사용하는 방법인 문제를 찾기 위한 네 개의 단계를 소개하고자 한다. 만약 특별한 원칙이 없었다면 이 글을 참고하여 자신만의 원칙을 만들어보는 것도 좋을 것이다.

첫 번째, 의심하기

코드, 로그, 에러 메시지, 모니터링 데이터, 요구사항, 하드웨어 등 모든 것은 문제 해결을 위한 정보가 될 수 있다. 이러한 정보를 수집하는 것은 가능성을 좁히기 위한 첫 번째 단계라고 할 수 있다.

수집할 수 있는 정보는 다양하다. 문제와 조금이라도 연관이 있을 것 같다면 가능한 모든 것을 의심하는 것이 좋다. 다음과 같은 예를 들 수 있다.

  • 에러 메시지를 확인한다.
  • 에러의 결과를 확인한다.
  • 에러가 발생한 지점을 확인하고 이를 의존하는 코드를 확인한다.
  • 에러 발생 시점 로그를 확인한다.
  • 에러가 발생한 시점의 하드웨어 상태를 확인한다.
  • 에러의 발생 주기를 확인한다.
  • 이전 코드와 달라진 점이 있는지 확인한다.
  • 네트워크 상태를 확인한다.
  • ...

체크리스트를 작성하라

체크리스트를 작성하면 가능성을 좁히기 위하여 내가 확인한 부분과 앞으로 확인해야 하는 부분을 확인하는 데 큰 도움이 된다. 앞서 이야기한 내용과 연결하면 정보를 수집하고 기록하는 일이라 할 수 있다. 번거롭게 느껴질 수도 있지만 체크리스트를 작성하는 것이 오히려 시간을 절약하는 경우가 많다.

체크리스트는 큰 범주에서 작게는 세세한 부분까지 작성할 수 있다. 이 말이 처음부터 모든 부분에 대해 체크리스트를 작성하라는 말은 아니다. 디버깅은 문제가 있는 부분을 좁혀나가는 것이 중요하다. 그래서 의심되는 부분을 하나씩 체크하면서 새롭게 발견한 것은 다시 체크리스트에 등록하는 식으로 작업하는 것이 좋다. 그리고 체크리스트를 이용하면 특정 부분만을 파고들거나 확인하는 과몰입 방지에 도움이 된다. 그리고 당연하게도 무엇을 확인했는지 알 수 있기 때문에 중복 작업을 방지할 수 있으며 다음으로 의삼할 부분을 빠르게 떠올릴 수 있다.

요약하자면 체크리스트를 작성하는 것은 다음과 같은 이유로 유용하다.

  • 체크리스트를 작성하면 무엇을 확인해야 하는지, 무엇을 확인했는지 알 수 있다.
  • 한 영역에 대해 과몰입하는 것을 어느정도 방지할 수 있다.

어이없는 실수를 찾아라

본격적으로 디버깅을 시작하기 전에 정말 어이없는 실수를 하지 않았는지 한 번 생각해보는 것이 좋다. 대표적인 사례로는 다음과 같은 것들이 있다.

  • 오타
  • 잘못된 변수 사용
  • 잘못된 조건문 사용
  • 계산 실수

위와 같은 실수는 정보를 수집할 때 무시하고 넘어가는 경우가 많다. 하지만 의외로 위와 같은 실수가 문제의 원인이 되는 경우가 적지 않다. 다음과 같은 사례가 있다.

  • 하드 코딩된 문자열을 체크할 때 오타를 낸 경우
  • 조건문에서 if (a == 1)을 사용해야 하는데 if (a = 1)을 사용한 경우
  • 계산식을 잘못 작성한 경우. 예를 들면 연산자 우선순위를 잘못 작성하는 경우
  • 멤버 변수를 사용해야 하는데 동일한 이름의 지역 변수를 사용한 경우
fun convertToCode(fruit: String): Int {
  return when (fruit) {
    "apple" -> 1
    "banana" -> 2
    "cherry" -> 3
    "berry" -> 4
    "orenge" -> 5
    "kiwi" -> 6
    "grape" -> 7
    "mango" -> 8
    "pear" -> 9
    else -> throw IllegalArgumentException("Unknown fruit")
  }
}
의외로 자주 보이는 코드. 어디가 문제인지 보이는가?

물론 이런 실수는 흔하지 않다. 하지만 없다고 말할 수도 없다. 애초에 이런 문제가 발생하지 않게 구현하는 것이 좋겠지만 상황이 여의치 않을 수 있다.

이러한 실수는 디버거를 사용하더라도 찾기 어려울 수 있기 때문에 본격적으로 디버깅을 시작하기 전에 어이없는 실수를 한 번쯤 의심하는 것이 좋다. 참고로 특정 실수는 IDE에서 경고로 보여주는 경우도 있다. 그러니 IDE의 경고를 무시하지 말자.

필요없는 정보를 제거하라

정보를 수집했다면 그 중엔 아는 것과 잘 모르는 것으로 나뉠 것이다. 잘 아는 것은 바로 여과를 할 수 있는지 확인할 수 있다. 사실 예상 가능한 단순한 문제라면 데이터를 수집하는 단계에서 바로 여과되는 정보라고 할 수 있다. 이 부분을 작은 직관이라 할 수 있다. 잘 모르는 것은 일단 냅둔다.

다만, 체크리스트에서 바로 지우는 것은 별로 좋지 않다. 잘 안다고 생각했지만 사실 잘 몰랐던 경우나 알고보니 진짜 문제는 제거한 정보에 있었던 경우가 있을 수 있기 때문이다. 따라서 조금 고민해보고 애매하면 우선 별도로 빼두자. 그리고 모든 경우를 확인한 후에도 문제를 파악하지 못했다면 별도로 빼둔 리스트를 다시 한 번 검토하면 된다.

두 번째, 분류하기

조금 더 효율적으로 확인하려면 어떻게 해야할까? 컴퓨터 시스템이라는 닫힌 계에서 발생할 수 있는 원인은 제한적이고 분류하는 것이 가능하다. 따라서 정보를 수집하며 적절하게 분류하고 그에 따라 우선 순위를 부여하는 것이 효율적이다. 이러한 정보를 분류하는 것이 가능성을 좁히기 위한 두 번째 단계라고 할 수 있다.

결함 분류

어떤 분류를 먼저 살펴볼지는 상황에 따라 다르다. 문제가 발생했을 때 어느 정도 예상되는 심증이 있을 것이다. 예를 들어, 오늘 작성한 코드에서 발생한 문제라면 논리적 결함일 가능성이 높다. 그렇다면 코드와 직접적으로 연관이 있는 에러 메시지, 코드, 로그, 버그로 인한 결과를 먼저 살펴보고 이후에 연관이 있는 하드웨어 상태, 네트워크 상태 등을 살펴보는 것이 효율적일 것이다.

반면, 이미 잘 돌아가던 제품에서 인프라 환경과 관련된 문제가 발생했다면 코드와 상관 없을 가능성이 높다. 그렇다면 먼저 기반 기술을 먼저 확인하는 것이 효율적일 수 있다.

논리적 결함

보통 대부분의 문제는 작업자의 실수로 인해 발생한다. 로직을 잘못 작성하거나 데이터를 잘못 처리하는 경우, 요구사항이 잘못된 경우가 대부분이다.

어디가 문제일지 고민해보자

로직이라 하더라도 꼭 코드에만 있는 것은 아니다. 로직은 데이터의 흐름이나 요구사항에 대한 것도 포함한다. 따라서 로직에 대한 결함을 확인할 때는 코드만 확인하는 것이 아니라 데이터의 흐름이나 요구사항에 대한 것도 확인해야 한다.

먼저 로직에 대한 결함은 당연히 작성한 코드에 대한 결함이다. 이는 대부분 디버거를 사용하여 로직의 흐름을 파악하면 쉽게 문제가 있는지 확인할 수 있다. 물론 디버거를 사용하지 않고도 확인할 수 있지만 디버거를 사용하면 훨씬 효율적으로 확인할 수 있다.

데이터에 대한 결함은 데이터의 상태를 확인하는 것이 중요하다. 이는 로그를 확인하거나 디버거를 사용하여 데이터의 상태를 확인하는 것이 중요하다. 데이터 상태 전환에 대한 논리적 구멍이 있을 수 있으므로 이를 확인하는 것이 중요하다.

요구사항에 대한 결함은 올바르게 구현했는지를 떠나 애초에 잘못 설계된 경우를 말한다. 과정이 복잡하거나 모호한 경우 은근히 자주 볼 수 있다. 이런 경우는 요구사항을 다시 검토하여 기획한 사람 혹은 고객과 상의하여 해결해야 한다.

의존 기술 결함

만약 논리적 결함이 없다면 의존하고 있는 기술에 문제가 있는지 확인해야 한다.

이 글에서 말하는 의존 기술이란 프레임워크나 라이브러리, 언어 등을 말한다. 발생 할 수 있는 문제는 크게 다음과 같다.

의존 기술을 점검해보자

설정 문제는 대부분 해당 기술의 문서를 참고하는 것으로 해결할 수 있다. 의심 되는 부분이 있다면 문서를 잘 확인해보자.

버전 문제는 대부분 버전을 올리거나 내리는 것으로 해결할 수 있다. 특히 라이브러리의 경우 의존성 설정 중 자동으로 업데이트 되어 버전이 올라가는 경우가 많다. 이런 경우에 간혹 버전이 올라가며 라이브러리 자체에 버그가 생겼거나 기능이 변경되는 등 문제가 발생할 수 있다. 이런 경우에는 버전을 내리는 것이 해결책이 될 수 있다. 혹은 문서는 최신 버전을 보고있지만 낮은 버전을 사용하여 문제가 되는 경우도 있다. 그러니 버전을 확인하는 것은 중요하다.

잘못된 사용 방법은 말 그대로 올바르게 사용하지 않은 경우를 말한다. 이런 경우는 대부분 오래된 문서나 스택오버플로우, 블로그 문서 등의 잘못된 정보를 보며 작업했을 때 발생한다. 이런 경우엔 해당 기술의 최신 문서를 참고하는 것으로 해결할 수 있다. 이는 설정 문제와 비슷하다.

간혹 의존하는 기술에 버그가 있는 경우가 있다. 이런 경우엔 정말 찾기 힘들고 커뮤니티가 활발하지 않은 경우엔 해결하기 어려울 수 있다. 의존 기술 자체에 버그가 있는 것이 의심된다면 커뮤니티에 문의하거나 해당 기술의 이슈 트래커를 확인해보는 것이 가장 좋다.

기반 기술 결함

기반 기술 결함은 기반이 되는 컴퓨터 과학적인 지식 부족, 운영체제, 통신 등의 문제로 인해 발생하는 결함이다. 주로 프로세스나 쓰레드를 잘못 사용했거나 메모리, 통신, OS나 JVM 같은 환경에서 발생하는 문제다. 이에 대한 문제를 해결하기 위해선 어느정도 컴퓨터 과학 지식이 필요하며 관련 디버깅 툴을 잘 사용하는 것이 중요하다.

잘 모르면 해결하기 힘든 문제들

메모리 문제는 대부분 메모리 누수로 인해 발생한다. 이는 메모리 점검 툴을 이용하여 확인할 수 있다. 간혹 메모리 덤프와 같은 방법을 사용하여 확인할 수도 있다.

프로세스나 쓰레드를 잘못 사용해서 동기화 문제가 발생할 수 있다. 병목 현상이 발생하거나 오히려 성능이 저하되는 경우가 있다. 이는 동시성, 병렬 처리에 대한 올바른 지식을 익히고 접근하는 것이 좋다.

통신 문제는 대부분 네트워크 문제로 인해 발생한다. 타임 아웃 혹은 커넥션 문제가 발생하는 경우가 대부분이다. 이는 네트워크 툴을 사용하여 확인할 수 있다.

혹은 환경에 대해 잘 몰라서 발생하는 경우도 있다. 메모리가 부족하거나 디스크 용량이 꽉찬 것을 모르고 엉뚱한 곳을 찾아 볼 때도 있고 권한 설정을 잘못해서 문제가 발생하는 경우도 있다. 가끔 이로인해 보안 문제가 생길 수 있기 때문에 내가 운영하는 환경에 대해선 어느 정도 공부하는 것이 좋다.

물리적 결함

물리적 결함은 하드웨어적인 문제인 가능성이다. 디버깅 중 여기까지 오는 경우는 드물지만 전혀 없는 사례는 아니다. 도저히 원인을 못찾겠다면 혹시모르니 다른 장비에서 테스트해보거나 하드웨어에 고장이 있는지 확인해보는 것이 좋다.

세 번째, 학습하기

앞서 잘 모르는 것으로 분류한 정보는 여과하기 어려울 것이다. 이런 경우에는 정보를 바탕으로 학습을 해야한다. 이 과정은 가능성을 좁히기 위한 세 번째 단계라고 할 수 있다.

학습은 다양한 방법으로 할 수 있다. 인터넷 검색, 도서, 동료, 커뮤니티 등을 활용하여 지식 공백을 채울 수 있다. 이렇게 얻은 지식은 정보가 문제와 관련이 없는지 판단하는 데 도움이 된다.

만약 그럼에도 불구하고 제거하는게 좋을지 판단이 안된다면 가능성 점수3를 매기고 일단 냅두는 것도 방법이 될 수 있다.

점수대로 확인해보자

지식 공백을 찾아라

아직 내가 무엇을 모르는지 모르는 경우가 있다. 이런 경우에는 먼저 내가 모르는 것을 찾아야 한다. 가장 좋은 방법은 내가 아는 것을 설명해보는 것이다. 동료에게 설명하는 것도 좋고 시간을 뺏는 것이 부담스럽다면 러버덕 디버깅을 이용하는 것도 좋다.

귀엽다

설명하다 보면 막히는 부분이 있을 것이다. 그 지점이 바로 내가 모르는 부분이다. 이런 부분을 찾아내고 학습한다면 문제를 해결하는데 도움이 될 것이다.

네 번째, 연결하기

지금까지 앞서 모으고 학습한 정보를 문제와 연결해야 한다. 그 과정에서 가설을 세우고 실험을 하며 문제를 해결할 때까지 체크리스트를 하나씩 지워나갈 수 있다. 이 과정은 가능성을 좁히기 위한 마지막 단계라고 할 수 있다.

가설을 세워라

먼저 '아마 이 부분이 문제 아닐까?' 같은 가설을 세워야 한다. 가설을 세우는 것은 정보를 바탕으로 문제와 연결짓는 행위라고 볼 수 있다. 가설이 논리적으로 설명이 되어야 하며, 가설을 테스트할 수 있는 방법이 있어야 한다.

그냥 생각으로 가설을 세울 수도 있지만 좀처럼 정리가 안된다면 글로 작성하거나 도식화를 해보자. 이렇게 하면 머릿속에 있는 생각을 정리할 수 있을 뿐만 아니라 논리적으로 말이 되는지 확인할 수 있다.

실험하라

가설을 세웠다면 이를 검증하기 위한 실험을 해야 한다. 실험은 다양한 방법으로 할 수 있다. 코드를 수정하거나 설정을 변경하거나, 다른 환경에서 테스트하는 것도 실험의 한 방법이다. 실험을 통해 가설이 맞는지 확인하고, 가설이 틀렸다면 새로운 가설을 세워 실험을 반복한다.

되돌아가라

가설을 세우고 실험을 하면서 새로운 통찰이 생길 수 있다. 이러한 통찰을 통해 새로운 정보를 찾을 수 있으며 이를 바탕으로 다시 처음부터 단계를 밟으며 가능성을 좁힘으로서 문제 해결에 더 가까워 질 수 있다. 이러한 행위가 일종의 피드백 루프인 샘이다.

문제 해결에 가까워지는 방법

마치며

어쩌면 너무 과한거 아냐?라고 생각할 수 있다. 사실 과한 것이 맞다. 그렇지만 위에서 소개한 내용은 앞서 언급한 직관을 연습하기에 유용한 방법이다. 바로 문제 해결과 연결되지는 않더라도 중간에 작은 직관이 발생할 수도 있다. 이런 감각을 익혀나가면 이후 디버깅할 때 더 빠르게 문제를 해결할 수 있을 것이다.

또한, 이 원칙은 한 가지 가설에 매몰되는 것을 방지하기 위한 것이기도 하다. 한 가지 가설에만 집중하다 보면 다른 가능성을 놓치기 쉽다. 디버깅을 하다가 한 가지에 너무 몰입하는 것 같다면 이 글에서 소개한 원칙을 다시 한 번 살펴보는 것도 좋을 것이다.

. . .

AI가 코드를 생산해주는 시대가 왔기에 역설적으로 디버깅이 더 중요해질 것이다. 그만큼 디버깅을 잘하는 것은 개발자로서 중요한 역량이 될 것이다. 그러니 만약 나만의 유용한 디버깅 원칙이 존재한다면 필자처럼 정리해보는 것을 추천한다. 더 나아가 좋은 팁을 공유한다면 더할 나위 없을 것이다.

Footnotes

  1. 재밌게도 유레카로 유명한 아르키메데스의 밀도의 발견, 뉴턴이 중력의 법칙을 생각해 낸 것도 직관을 통한 것이라고 한다.

  2. 직관은 무의식적으로 이루어지는 것이기 때문에 정확한 정의는 어렵다. 이 글에서는 직관을 경험과 지식을 바탕으로 문제를 해결하는 방법이라고 정의한다.

  3. 필자는 가능성에 대해 1점부터 5점까지 점수를 매긴다. 물론 이 점수는 주관적이다.