CodeGym /행동 /C++ SELF /디버깅 전략

디버깅 전략

C++ SELF
레벨 34, 레슨 4
사용 가능

1. 디버깅 전략은 디버거의 마법보다 중요하다

프로그램이 잘못 동작하면 머릿속에서 쉽게 '패닉과 샤먼식 해결' 모드가 켜집니다: 몇 줄 바꾸고 재시작해보고, 운이 좋으면 해결된 것처럼 보이는 경우가 있습니다. 때로는 실제로 도움되기도 합니다. 때로는 상황을 더 악화시키고 버그가 '숨겨졌다'가 나중에(예: 평가나 프로덕션에서) 다시 나타납니다.

디버깅 전략이 필요한 이유는 '어른들이 그렇게 하니까'가 아니라 시간 절약 때문입니다. 디버거는 돋보기와 손전등 같은 도구이지만, 무엇을 찾아야 할지 모르면 손전등을 천장에 비추며 바퀴벌레가 왜 안 보이냐고 놀랄 뿐입니다. 우리는 엔지니어처럼 디버깅할 것입니다: 문제 고정, 탐색 범위 축소, 구체적 가설 검증.

오늘의 실무 공식:

flowchart TD
    A[버그 재현] --> B[발생 지점/조건 분리]
    B --> C[가설 수립]
    C --> D[디버거로 가설 확인]
    D --> E{확인되었나?}
    E -- 예 --> F[최소한의 변경으로 수정]
    E -- 아니오 --> C

2. 1단계: 버그 재현

가장 골치 아픈 버그는 '가끔 발생하는' 것들입니다. 오늘은 실패, 내일은 괜찮음, 모레 다시 실패하는 식으로 마치 미스틱처럼 보입니다. 실제로는 거의 항상 발생 조건이 존재하지만 아직 포착하지 못한 경우가 많습니다.

재현이란 동일한 입력으로 반복해서 동일한 잘못된 동작을 얻을 수 있도록 만드는 것을 의미합니다: 같은 입력 데이터 → 같은 잘못된 결과. 그래야만 수정이 실제로 효과가 있었는지, 단순한 '운'이었는지 알 수 있습니다.

학습용 콘솔 앱 ExpenseTracker(지출 트래커)를 개발한다고 가정해봅시다. 이 앱은 지출을 std::vector에 저장하고 항목을 추가하며 평균을 계산합니다.

핵심의 미니 버전(복잡한 인터페이스는 제외):

#include <iostream>
#include <vector>

int main() {
    std::vector<int> expenses{100, 50, 150};

    int sum = 0;
    for (int x : expenses) sum += x;

    std::cout << "Sum=" << sum << '\n'; // Sum=300
}

학생이 '평균'을 추가했는데 이상한 결과를 얻었다고 합시다: Average=100이 나오는데 기대값은 100.0입니다. 다른 데이터셋에서는 더 문제가 됩니다(예: 100과 101이 100 대신 100.5가 되어야 함). 이럴 때는 작은 시나리오가 있어야 오류가 확실히 드러납니다.

정수 나눗셈 오류를 잘 보여주는 재현 예제:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> expenses{100, 101};

    int sum = 0;
    for (int x : expenses) sum += x;

    double avg = sum / expenses.size();
    std::cout << "Average=" << avg << '\n'; // Average=100 (원래는 100.5)
}

우리는 '뭔가 대충 이상하다'가 아니라, 오류가 명확하게 드러나는 입력을 만들었습니다. 이것이 절반의 승리입니다.

디버깅에서 재현은 규율 문제입니다: 정확한 입력과 정확한 단계, '예상/실제'를 기록합니다. 인터랙티브한 프로그램이라면 임시로 입력을 코드에 하드코딩해 여러 번 입력할 필요를 없애는 것이 유용합니다.

3. 2단계: 원인 분리

버그가 안정적으로 재현되면 다음 함정은 프로젝트 전체를 디버깅하려 하는 것입니다. 잃어버린 열쇠를 찾는 것과 비슷합니다: 집 전체를 뒤질 수도 있지만 최근에 어디에 있었는지 떠올리고 코트를 먼저 확인하는 편이 현명합니다.

분리란 탐색 범위를 줄이는 것을 의미합니다: 버그가 프로그램의 어느 부분에서 발생하는지 찾습니다. 증상은 한 곳에 나타나지만 원인은 다른 곳(예: 잘못된 계산이나 잘못된 입력)일 수 있습니다.

실용적인 분리 방법이 두 가지 있습니다.

첫째는 큰 프로그램을 작은 것으로 만드는 것입니다: 재현에 필요한 부분만 임시로 남깁니다. 'Average=100 대신 100.5' 문제라면 전체 CLI, 메뉴, 명령 처리, 예쁜 출력 등이 필요 없습니다. 필요한 것은 평균 계산입니다.

계산을 함수로 추출해 봅시다:

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;
    return sum / v.size(); // 의심되는 지점
}

그리고 main에는 최소한의 호출만 남깁니다:

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses{100, 101};
    std::cout << average_expense(expenses) << '\n'; // 100 (기대 100.5)
}

이제 디버거를 위한 작은 '실험실' 장면이 생깁니다: 입력은 작고 코드도 짧아서 브레이크포인트 수가 적고 세부사항에 빠지지 않습니다.

두번째 방법은 실행 경로를 통한 분리입니다: 표시 지점에 breakpoint를 두고 call stack을 확인해서 위로 올라가며 누가 잘못된 데이터를 전달했는지 파악합니다. 작은 함수 자체는 올바르지만 잘못된 인자가 들어오는 경우에 특히 유용합니다.

4. 3단계: 가설 검증

이제 가장 성숙한 단계입니다: 가설. 디버깅에서 가설은 관찰로 확인 가능한 주장입니다.

나쁜 가설은 '디버거가 멍청하다', 'vector가 망가졌다', 'C++가 이상하다' 같은 것입니다. 좋은 가설은 'sum과 v.size()가 정수 타입이라서 정수 나눗셈이 발생한다'처럼 구체적입니다.

가설이 유용하려면 두 질문에 답해야 합니다: '무엇이 잘못되었나?'와 '어디에서 볼 수 있나?'

증상을 빠르게 검증 가능한 가설로 바꾸는 작은 표:

증상 흔한 가설 디버거에서 어떻게 확인할지
잘못된 평균/백분율 정수 나눗셈 Watch: sum, v.size(), 표현식 sum / v.size()
요소가 사라지거나 모두 처리되지 않음 루프 경계 오류 (< vs <=) Watch: 인덱스와 size(), 루프 내부 브레이크포인트
간헐적 충돌 vector 범위 초과 접근 지점에 Breakpoint, i와 size()를 watch
if 분기가 '작동하지 않음' 조건이 기대와 다름 조건식 watch, 분기별로 step
'정지됨' 입력을 기다림 현재 라인 확인: 대개는 std::cin >> ...

핵심은 '수정안을 추측하려 들지 않는다'는 것입니다. 우리는 원인을 확인하려고 합니다. 수정은 그 다음 단계이며, 원인이 확인되면 보통 명확합니다.

5. 미니 사례: ExpenseTracker의 평균

앱에 평균을 출력하는 avg 명령이 있다고 합시다. 코드를 작성했더니 이상한 결과가 나옵니다.

다음은 간략화한 계산 코드입니다:

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;
    return sum / v.size(); // 여기에서 버그가 발생
}

재현

이미 최소 입력을 찾았습니다: {100, 101}. 기대값은 100.5인데 실제로는 100이 나옵니다. 이건 안정적으로 재현됩니다.

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses{100, 101};
    std::cout << average_expense(expenses) << '\n'; // 100
}

분리

분리는 이미 되어 있습니다: 함수 하나와 호출 하나뿐입니다. 메뉴, 문자열 파싱, 포맷팅 등은 없습니다.

가설

"가설: sum / v.size()가 정수 나눗셈으로 수행되고 있다. 두 피연산자가 정수 타입이므로 소수 부분이 버려지고 그 결과가 double에 할당된다."

디버거에서 가설 확인

실행 절차(단축키가 아니라 행동 관점):

return sum / v.size()(); 라인에 breakpoint를 두고 디버거로 실행해서 해당 라인에 도달하면 값을 확인합니다.

watch/locals에서 관심 있는 항목은 세 가지: sum, v.size(), 그리고 표현식 sum / v.size()의 실제 값입니다.

정지했을 때 대략 다음과 같은 값을 보게 됩니다:

  • sum == 201
  • v.size() == 2
  • sum / v.size() == 100 (100.5가 아님)

이것이 가설을 입증합니다: 나눗셈이 이미 정수형으로 계산되었습니다.

최소한의 변경으로 수정

우리는 함수를 '혹시 몰라서' 전체 재작성하지 않습니다. 확인된 원인만 제거하는 최소한의 수정을 합니다: 나눗셈을 실수형으로 만듭니다.

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;

    return static_cast<double>(sum) / v.size(); // 이제 실수 나눗셈
}

그리고 테스트 실행:

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses{100, 101};
    std::cout << average_expense(expenses) << '\n'; // 100.5
}

아름다운 점은: 우리는 추측하지 않았습니다. 사실을 보고 가설을 확인한 뒤 원인만 고쳤고, 다시 시나리오를 재현해 문제가 사라졌음을 확인했습니다.

6. 경계 케이스: 빈 리스트와 입력 축소

때로는 오류가 수학 문제가 아니라 경계 케이스에 있습니다. 예를 들어, avg를 호출했을 때 지출이 아직 없으면 프로그램이 크래시하는 경우입니다. 흔한 실수죠: 개발자와 사용자가 그 상황을 생각하지 않았습니다.

전형적인 '순진한' 코드:

#include <vector>

double average_expense(const std::vector<int>& v) {
    int sum = 0;
    for (int x : v) sum += x;
    return static_cast<double>(sum) / v.size(); // size()==0이면 0으로 나눔
}

재현

제로 입력이 필요합니다. main에서 직접:

#include <iostream>
#include <vector>

double average_expense(const std::vector<int>& v);

int main() {
    std::vector<int> expenses;
    std::cout << average_expense(expenses) << '\n'; // 비정상 종료/쓰레기 값
}

분리

이미 분리되어 있습니다: 함수 하나뿐입니다.

가설

"가설: v.size()가 0이고 우리가 0으로 나누고 있다."

검증

나눗셈 라인에 breakpoint를 두고 v.size()를 watch합니다. 정지했을 때 0을 보게 되고, 가설이 확인됩니다.

수정

현재 코스 수준에서의 해결책은 단순합니다: 빈 경우를 명시적으로 처리합니다(실제 프로젝트에서는 반환값에 대해 더 논의할 수 있지만 여기서는 예측 가능성이 중요합니다).

#include <vector>

double average_expense(const std::vector<int>& v) {
    if (v.empty()) return 0.0;

    int sum = 0;
    for (int x : v) sum += x;
    return static_cast<double>(sum) / v.size();
}

다시 재현하면: 빈 리스트 → 이제 0.0이 출력됩니다(혹은 출력 포맷에 따라 단순히 0으로 나올 수도 있습니다).

입력 축소를 통한 분리

많은 경우에는 프로그램의 절반을 잘라낼 수 없습니다: 예를 들어, 특정 명령 시퀀스나 특정 데이터에서만 버그가 발생합니다. 이럴 때 분리는 '코드 제거'보다는 입력 축소로 이뤄집니다.

실무적 로직은 이렇습니다: 버그가 10,000개 항목에서 발생하면, 최소한으로 줄여서 여전히 재현되는 최소 크기를 찾습니다. 이는 소리를 줄여가며 문제가 아직 들리는 지점까지 조정하는 것과 비슷합니다.

예를 들어 '가장 큰 지출 찾기'가 때때로 틀린 답을 준다면, 코드:

#include <vector>

int max_expense(const std::vector<int>& v) {
    int m = v[0];
    for (std::size_t i = 1; i < v.size(); ++i) {
        if (v[i] > m) m = v[i];
    }
    return m;
}

'가끔 틀리다'는 얘기를 들으면 먼저 재현 가능한 입력을 찾고, 그다음 집합을 축소하며 오류가 남아있는지 확인합니다. 종종 특정 엣지케이스에서만 문제가 발생하는데, 예를 들어 음수 값, 중복된 최대값, 빈 vector 등이 있습니다. 그러면 가설은 구체적이 됩니다: 'v가 비어있을 때 v[0]에 접근한다'.

디버깅 절차는 거의 형식적입니다: int m = v[0];에 breakpoint를 두고 v.size()를 watch하면 왜 문제가 터졌는지 바로 이해할 수 있습니다.

7. 흔한 실수들

실수 #1: 재현할 수 없는 버그를 고치려 들기.
만약 안정적으로 잘못된 동작을 재현할 수 없다면, 모든 '수정'은 점쟁이 짓이 됩니다. 때때로 고친 것처럼 보이지만 실제로는 단순히 그때 버그가 나타나지 않은 것뿐입니다. 먼저 매번 프로그램을 깨뜨리는 최소 시나리오를 생각해내는 것이 옳습니다.

실수 #2: 프로젝트의 절반을 갈아엎어 '분리'하려 하기.
문제가 불명확하면 사람들은 극단적으로 변경하려 듭니다: '다른 루프를 쓰자'거나 '데이터 구조를 바꾸자' 같은 생각이요. 이는 원인의 흔적을 지우고 새로운 버그를 만들기 쉽습니다. 분리는 감소를 의미합니다: 입력, 실행 경로, 재현에 관여하는 함수의 수를 줄이는 것입니다.

실수 #3: '모든 게 망가졌다'식의 가설.
'vector가 이상하게 동작한다' 또는 '조건문이 작동하지 않는다' 같은 말은 검증 불가능합니다. 좋은 가설은 관찰 가능해야 합니다: '인덱스가 size()와 같아졌다', 'sum/n에서 양쪽이 정수다', '이 if 분기에 전혀 들어가지 않는다' 등. 그러면 브레이크포인트를 두고 정확히 확인할 수 있습니다.

실수 #4: 가설을 잘못된 위치에서 검증(잘못된 frame / 잘못된 중단 지점).
작은 함수 내부에서 멈췄는데 원인이 전달자라면 로컬 변수만 보는 것으로는 부족합니다. call stack을 따라 올라가서 값을 망가뜨린 지점을 확인해야 합니다. 그렇지 않으면 '그 함수는 자체적으로 잘못했다'고 잘못 결론내리기 쉽습니다.

실수 #5: watch에서 v[i]를 경계 확인 없이 바로 보는 것.
Watches는 강력하지만 논리를 대신해주지는 않습니다. 만약 i가 이미 범위를 벗어났다면 v[i]를 보려 하는 것은 쓰레기 값이나 관찰로 인한 이상 부작용을 초래할 수 있습니다. 좋은 습관은 먼저 i와 v.size()를 확인하고, i < v.size()일 때만 요소를 확인하는 것입니다.

1
과제
C++ SELF, 레벨 34, 레슨 4
잠금
두 가지 나눗셈
두 가지 나눗셈
1
과제
C++ SELF, 레벨 34, 레슨 4
잠금
나눗셈 진단 리포트
나눗셈 진단 리포트
1
과제
C++ SELF, 레벨 34, 레슨 4
잠금
평균을 들여다보기
평균을 들여다보기
1
과제
C++ SELF, 레벨 34, 레슨 4
잠금
최대값 추적
최대값 추적
1
설문조사/퀴즈
디버거, 레벨 34, 레슨 4
사용 불가능
디버거
디버거
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION