CodeGym /행동 /C++ SELF /미니 진단 툴박스: 출력, assert, 로그 메시지

미니 진단 툴박스: 출력, assert, 로그 메시지

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

1. 문제

컴파일러가 여러분의 코드를 이미 컴파일해 주었다고 해서, 프로그램이 여러분이 의도한 대로 동작한다는 뜻은 아닙니다. 어떤 때는 잘못된 답을 출력하고, 어떤 때는 멈추고, 어떤 때는 “가끔은 동작”합니다. 바로 이 지점에서 프로그래머의 진짜 삶이 시작됩니다. 여러분은 단지 코드를 쓰는 것이 아니라, 그 안에서 무슨 일이 일어나는지에 대한 가설을 검증합니다. 좋은 소식은 디버거와 고급 도구가 없어도, 오류를 빠르게 잡아낼 가능성을 크게 높일 수 있다는 점입니다.

이 단계에서는 거의 항상 통하는 미니 기법 세트를 다룹니다. 깔끔한 출력(일반 출력과 진단의 분리), assert를 통한 핵심 가정 검증, 그리고 아주 단순한 로그 메시지(열 개의 std::cout로 혼란을 만들지 않기 위해서)입니다.

너무 추상적으로 느껴지지 않도록, 하나의 작은 콘솔 애플리케이션을 계속 발전시켜 보겠습니다. MiniTasks는 작업 목록 프로그램으로, 작업을 추가하고, 목록을 출력하고, 작업을 완료 처리할 수 있습니다.

학습 예제: MiniTasks

지금은 진단을 보여 주기 위한 максимально 단순한 프로그램 골격이 필요합니다. 중요한 점은, 오늘은 의도적으로 “정면 돌파”식으로 코드를 작성한다는 것입니다. 아키텍처적인 세련미는 잠시 미뤄 두고, 프로그램이 실제로 무엇을 하는지 보는 능력을 훈련합니다.

먼저 기본 뼈대를 만들어 봅시다. 작업은 std::vector<std::string>에 저장하고, 명령은 std::getline으로 한 줄씩 읽은 뒤, “첫 번째 단어 + 나머지 부분” 형태로 파싱합니다. 완벽한 파서는 아니지만, 정직하고 투명합니다.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks;

    while (true) {
        std::cout << "cmd> ";

        std::string line;
        if (!std::getline(std::cin, line)) {
            break; // EOF / 입력 오류
        }

        if (line == "quit") {
            break;
        } else if (line.rfind("add ", 0) == 0) { // "add "로 시작함
            std::string text = line.substr(4);
            tasks.push_back(text);
            std::cout << "added\n"; // added
        } else if (line == "list") {
            for (std::size_t i = 0; i < tasks.size(); ++i) {
                std::cout << i << ": " << tasks[i] << '\n';
            }
        } else {
            std::cout << "unknown command\n"; // unknown command
        }
    }
}

아직 여기에는 done도 없고, 검사도 없고, 아름다움도 없습니다. 하지만 진단을 연습하기에는 아주 좋은 실험장입니다. 인덱스, 파싱, 분기 로직에서 쉽게 실수할 수 있고, 그 뒤에 그것을 빠르게 잡아내는 방법을 배울 수 있습니다.

2. 진단용 출력: std::cout vs std::cerr

프로그램이 이상하게 동작할 때 초보자가 가장 먼저 보이는 반응은 “일단 std::cout으로 다 찍어 보자”입니다. 이는 아주 정상적인 본능입니다(지하실에서 손전등을 켜는 것과 비슷하죠). 하지만 한 가지 함정이 있습니다. std::cout은 종종 기대되는 출력의 일부입니다. 프로그램이 채점 플랫폼의 문제를 풀거나, 단순히 정해진 형식으로 정확히 출력해야 한다면, std::cout에 넣은 진단 메시지는 결과를 망칩니다.

그래서 규칙은 간단합니다. 일반 출력은 std::cout, 진단은 std::cerr입니다. 그러면 프로그램의 “정상적인” 답은 깨끗하게 유지되고, 내부에서 무슨 일이 일어나는지에 대한 메시지는 별도의 오류 스트림으로 흘러갑니다.

표로 비교해 봅시다.

도구 용도 누가 읽는가 그 안에 디버깅 출력을 “쏟아 넣으면” 어떻게 되는가
std::cout
프로그램 실행 결과 사용자 / 채점 시스템 출력 형식을 망칠 수 있음
std::cerr
진단, 오류, 디버깅 여러분(개발자) 결과 형식은 손상되지 않음
assert
내부 가정 검증 여러분(개발자) 가정이 깨지면 프로그램이 중단됨

MiniTasks에 한 줄 읽기 진단을 추가해 봅시다. 하지만 std::cerr로 말입니다.

#include <iostream>
#include <string>

int main() {
    std::string line;
    std::getline(std::cin, line);

    std::cerr << "[debug] got line: '" << line << "'\n";
    std::cout << "ok\n"; // ok
}

직접 실행해 보면 두 개의 서로 다른 스트림을 보게 됩니다. 대부분의 IDE와 Web IDE에서는 둘이 함께 표시되는 경우가 많지만, 논리적으로는 두 채널입니다. 하나는 “사용자를 위한 것”, 다른 하나는 “개발자를 위한 것”입니다.

4. 유용한 출력: 양보다 맥락이 중요하다

출력이 정말 유용해지는 순간은 “지금 무엇을 검증하고 있는가?”와 “어떤 값이 중요한가?”에 답할 때입니다. 그냥 “여기 바샤가 있었음”, “여기 페탸가 있었음” 같은 식으로 찍기만 하면, 금방 자기 메시지에 익사하고 디버깅을 싫어하게 됩니다(그리고 인류도 조금은 싫어질 수 있습니다).

좋은 디버그 출력에는 보통 맥락이 들어 있습니다. 명령 이름, 컨테이너 크기, 인덱스, 중간 계산값 같은 것들입니다. 예를 들어 add 명령을 추가했는데, 때때로 작업이 빈 문자열로 추가된다고 합시다. 우리가 알고 싶은 것은 이것입니다. 입력 문자열이 정말 비어 있는가, 아니면 substr를 잘못 잘라냈는가?

진단용 한 줄을 추가해 봅시다.

// ... add 분기 내부
std::string text = line.substr(4);
std::cerr << "[debug] add text length=" << text.size() << "\n";
tasks.push_back(text);

이제 누군가가 add만 입력하고(그 뒤에 공백만 있고 더 이상 아무것도 없다면), length=0을 명확히 보게 됩니다. 반대로 어딘가에서 substr의 시작 위치를 잘못 옮겼다면, 예상치 못한 길이나 예상치 못한 내용이 보일 것입니다.

그리고 여기서 중요한 “신비 없는 팁”이 하나 있습니다. 출력은 “아무 데나” 두는 것이 아니라 가설에 따라 둡니다. 가설: “text에 엉뚱한 값이 들어간다.” 그러면 text를 출력합니다. 가설: “인덱스가 범위를 벗어난다.” 그러면 i와 tasks.size()를 출력합니다. 가능하다면 요소에 접근하기 전에 말이죠.

5. 검사와 조기 종료: if가 혼란을 이긴다

많은 런타임 오류, 특히 초보자에게서 자주 보이는 오류는 “복잡한 수학” 때문이 아니라 경계와 빈 경우에 대한 기본적인 검사가 없기 때문에 발생합니다. 그리고 여기서 진단은 단순한 질문으로 시작합니다. “데이터가 없다면 어떻게 될까?”

이제 done N 명령을 추가해 봅시다. 이 명령은 인덱스로 작업을 삭제합니다(편의상 “완료”는 목록에서 제거하는 것이라고 가정합니다). 순진한 구현은 다음처럼 생길 수 있습니다.

// BAD: 아직 검사가 없음
std::size_t index = /* somehow parsed */;
tasks.erase(tasks.begin() + index);

index가 잘못되면 결과는 좋지 않습니다. 충돌할 수도 있고, “이상하게 엉뚱한 것이 지워지는” 상황이 생길 수도 있습니다. 반면, 깔끔한 버전은 검사와 오류 메시지로 시작합니다.

#include <iostream>
#include <vector>
#include <string>

bool remove_task(std::vector<std::string>& tasks, std::size_t index) {
    if (index >= tasks.size()) {
        std::cerr << "[error] index out of range: index=" << index
                  << " size=" << tasks.size() << "\n";
        return false;
    }

    tasks.erase(tasks.begin() + index);
    return true;
}

스타일에 주목해 보세요. 우리는 “그냥 죽는” 것이 아니라, 무엇이 정확히 잘못되었는지 말하고 false를 반환합니다. 이것만으로도 꽤 정상적인 엔지니어링 습관처럼 보입니다. 먼저 프로그램이 위험한 행동을 하지 못하게 막고, 그다음 왜 거부했는지를 설명하는 것이죠.

assert도 검사를 수행하지만, 철학은 다릅니다. 이제 그 차이를 보겠습니다.

6. assert: 내부 가정을 검증한다

assert는 “이 조건이 거짓이라면, 프로그램에서 일어나서는 안 되는 일이 일어난 것이다”라고 말하는 방법입니다. 의미상 이것은 “사용자가 잘못 입력했다”가 아니라, “우리가 내부 계약을 스스로 어겼다”에 가깝습니다. 즉, assert는 프로그래머의 실수에 대한 안전장치입니다.

포함은 이렇게 합니다.

#include <cassert>

사용은 일반적인 검사와 같습니다.

#include <cassert>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    std::size_t i = 2;
    assert(i < v.size());   // 거짓이면 비정상 종료

    int x = v[i];
}

조건이 깨지면 프로그램은 비정상적으로 멈추고, 정확히 어디에서 멈췄는지 알려 줍니다(보통 파일과 줄 번호). 이것은 “열 줄 뒤에서 알 수 없는 크래시”를 보는 대신, “일찍 실패”하고 싶을 때 아주 편리합니다.

assert에는 중요한 특징이 두 가지 있습니다.

첫째, assert는 릴리스 빌드에서 종종 비활성화됩니다(보통 특정 매크로가 정의될 때). 오늘은 빌드 구성의 세부 사항까지 들어가지는 않겠지만, 핵심은 이것입니다. assert는 개발 도구이지, “사용자를 위한 친절한 메시지”가 아닙니다.

둘째, assert 안에는 부수 효과가 있으면 안 됩니다. 왜냐하면 assert가 꺼져 있으면 그 부수 효과도 사라지고, 프로그램이 다르게 동작하기 시작하기 때문입니다. 나쁜 코드의 예를 봅시다.

#include <cassert>

int main() {
    int x = 0;
    assert(++x == 1); // 나쁨: assert 안에서 x를 변경함
}

여기서는 겉보기에는 x를 증가시킨 것처럼 보이지만, assert가 비활성화된 빌드에서는 x가 아예 바뀌지 않습니다. 아주 교묘한 오류가 생기는 셈입니다. debug에서는 동작하는데 release에서는 동작하지 않는 것이죠. 이런 건 좋아하지 않습니다(하지만 버그는 무척 좋아하죠).

7. MiniTasks에서의 assert: 더 일찍 오류를 잡기

이제 MiniTasks를 done N 명령까지 확장해 봅시다. 숫자를 파싱해야 하므로, 현재 수준에서는 std::stoi를 예시로 사용할 수 있습니다(잘못된 입력에서 문제가 생길 수 있다는 점은 이미 보았습니다). 최대한 신중하지만 짧은 버전으로 가겠습니다. 파싱에 실패하면 메시지를 출력하고 명령을 건너뜁니다.

done 처리용 코드 조각은 다음과 같습니다.

#include <cassert>
#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"learn C++", "drink tea"};

    std::size_t index = 1;

    assert(index < tasks.size()); // 내부 가정
    tasks.erase(tasks.begin() + index);

    std::cout << tasks.size() << "\n"; // 1
}

이것은 “내부적 확신”의 예시입니다. index가 이미 올바르다고 보장되는 곳에서 왔다면 assert가 적절합니다. 하지만 index가 사용자에게서 온다면, if를 사용하고 std::cerr에 기록하는 편이 더 낫습니다. 사용자는 얼마든지 실수할 권리가 있고, 우리는 프로그램이 충돌하는 방식으로 “화낼” 필요가 없기 때문입니다.

조합은 이렇게 됩니다. 먼저 사용자 입력에는 if를 사용하고, “깨지면 안 되는” 내부 로직에는 assert를 사용합니다.

8. 로그: 함수와 플래그

간단한 log_debug와 log_error

프로젝트가 조금만 커져도 전형적인 문제가 나타납니다. 디버그 메시지가… 뭐랄까, 생물 교과서 속 토끼처럼 번식하기 시작합니다. [debug]를 손으로 복붙하다가, 어디선가 \n를 빼먹고, “오류”와 “그냥 확인용” 메시지를 섞어 쓰게 되죠. 그리고 나중에는 그 부끄러운 코드의 절반을 지워야 합니다.

그래서 초급 단계에서도 log_debug와 log_error라는 작은 함수 두 개를 만드는 것이 유익합니다. 이것은 “진짜 로깅 시스템”이 아니라, 출력을 조금 더 규율 있게 만드는 방법입니다.

가장 단순한 버전은 다음과 같습니다.

#include <iostream>
#include <string>

void log_debug(const std::string& msg) {
    std::cerr << "[debug] " << msg << "\n";
}

void log_error(const std::string& msg) {
    std::cerr << "[error] " << msg << "\n";
}

사용은 이렇게 합니다.

log_debug("start reading command");
log_error("unknown command");

이렇게 하면 읽기도 쉬워지고, 제거하기도 쉬워집니다. 검색으로 log_debug를 빠르게 찾아서 삭제하거나 비활성화할 수 있기 때문입니다.

debug 로그 켜기와 끄기

때로는 log_debug를 코드에 남겨 두고 싶지만, 항상 출력되길 원하지는 않을 수 있습니다. 이때 아주 잘 맞는 아이디어가 “플래그”입니다. 진단을 켜면 자세한 정보가 나오고, 끄면 조용해집니다.

이미 전처리기를 알고 있으니, 간단한 것을 만들 수 있습니다. 바로 ENABLE_DEBUG_LOGS 매크로입니다. 값이 1이면 출력하고, 0이면 조용히 있습니다.

#include <iostream>
#include <string>

#define ENABLE_DEBUG_LOGS 1

void log_debug(const std::string& msg) {
#if ENABLE_DEBUG_LOGS
    std::cerr << "[debug] " << msg << "\n";
#else
    (void)msg; // 컴파일러가 unused 경고를 내지 않도록
#endif
}

이제 숫자 하나만 바꾸면 프로그램의 “말 많음” 정도를 전환할 수 있습니다. 버그를 수정한 뒤에도, 나중에 다시 뭔가 잘못될 경우 빠르게 추적을 켤 수 있어서 매우 편리합니다.

중요한 점은 이것을 숭배 대상으로 만들지 않는 것입니다. 매크로는 강력한 도구이지만, 텍스트 수준에서 동작하고 C++의 의미를 “이해”하지는 못합니다. 여기서는 아주 절제해서, 진단을 켜고 끄는 단순한 용도로만 사용합니다.

9. MiniTasks: 명령과 진단

이제 아이디어를 좀 더 완결된 미니 예제로 합쳐 봅시다. 한 줄을 읽고, add, list, done 명령을 인식하고, 오류는 std::cerr에 출력하며, debug 메시지는 플래그로 켜고 끕니다.

코드는 아직 작지만, 이제는 이미 “프로그램처럼” 보입니다.

#include <cassert>
#include <iostream>
#include <string>
#include <vector>

#define ENABLE_DEBUG_LOGS 1

void log_debug(const std::string& msg) {
#if ENABLE_DEBUG_LOGS
    std::cerr << "[debug] " << msg << "\n";
#else
    (void)msg;
#endif
}

int main() {
    std::vector<std::string> tasks;

    while (true) {
        std::cout << "cmd> ";

        std::string line;
        if (!std::getline(std::cin, line)) break;

        log_debug("line='" + line + "'");

        if (line == "quit") break;

        if (line.rfind("add ", 0) == 0) {
            std::string text = line.substr(4);
            if (text.empty()) {
                std::cerr << "[error] task text is empty\n";
                continue;
            }
            tasks.push_back(text);
            continue;
        }

        if (line == "list") {
            for (std::size_t i = 0; i < tasks.size(); ++i) {
                std::cout << i << ": " << tasks[i] << '\n';
            }
            continue;
        }

        if (line.rfind("done ", 0) == 0) {
            std::string arg = line.substr(5);
            int idx = std::stoi(arg); // 시연용: 잘못된 입력이면 프로그램이 "죽을" 수 있음
            if (idx < 0 || static_cast<std::size_t>(idx) >= tasks.size()) {
                std::cerr << "[error] bad index: " << idx << "\n";
                continue;
            }

            std::size_t index = static_cast<std::size_t>(idx);
            assert(index < tasks.size()); // if 이후에는 이것이 이미 "내부적 확신"
            tasks.erase(tasks.begin() + index);
            continue;
        }

        std::cerr << "[error] unknown command\n";
    }
}

물론 여기서도 파싱을 더 개선할 수 있습니다(예를 들어 stoi 예외를 처리하는 방식 등). 다만 예외라는 메커니즘 자체는 별도의 날에 자세히 다룰 예정입니다. 중요한 것은 다른 점입니다. 이제 여러분은 거의 어떤 학습용 프로그램에도 가져다 쓸 수 있는 진단의 “골격”을 이미 보고 있습니다.

10. 디버깅할 때의 사고 방식: 짧은 사이클

모든 것이 망가질 때는 хаотичные 수정을 시작하고 싶은 유혹이 큽니다. “여기 +1 해볼까?”, “여기 <=로 바꿔볼까?” 같은 식이죠. 가끔은 정말 우연히 고쳐지기도 합니다… 하지만 더 자주 생기는 일은, 버그 1번이 심심하지 않도록 버그 2번을 만들어 주는 것입니다.

디버깅 과정을 머릿속에서 짧은 사이클로 유지하는 것이 유익합니다.

flowchart TD
    A[문제를 관찰함] --> B[가설을 세움]
    B --> C[std::cerr에 지점 출력 추가]
    C --> D[assert 또는 if로 불변식 확인]
    D --> E[기대가 깨지는 지점을 찾음]
    E --> F[코드를 수정함]
    F --> G[debug 로그를 제거하거나 끔]
    G --> A

여기서 핵심 단어는 “지점”입니다. 진단은 방 전체에 페인트를 쏟아붓는 것이 아니라, 외과용 도구처럼 정확할 때 좋습니다.

11. 전형적인 실수

실수 1: std::cout으로 디버깅하다가 출력 형식을 망침.
진단 메시지를 std::cout에 찍으면, “프로그램의 결과”와 “개발자의 내부 생각”을 한데 섞게 됩니다. 출력이 정해진 형식을 따라야 하는 경우, 스스로 발목을 잡는 셈입니다. 디버그 한 줄을 추가했다는 이유만으로 프로그램이 “오답”이 될 수 있습니다. 습관을 들이세요. 진단은 std::cerr에서 살아야 합니다.

실수 2: “그냥 전부 출력해 보자” 하다가 노이즈에 익사함.
많이 출력한다고 좋은 진단이 되는 것은 아닙니다. 메시지가 너무 많으면 읽지 않게 되고, 중요한 것조차 무시하기 시작합니다. 문제를 의심하는 위치 근처에 구체적인 맥락을 담은 한두 줄(예: index와 size)을 두는 편이 훨씬 유용합니다.

실수 3: 사용자 입력 오류를 assert로 처리함.
assert는 “프로그램 논리상 이렇게 되면 안 된다”를 위한 것입니다. 하지만 사용자는 충분히 실수할 수 있습니다. 잘못 입력할 수도 있고, 인수를 빼먹을 수도 있고, done kitty라고 쓸 수도 있습니다. 이런 경우에는 if와 오류 메시지가 필요하지, 비정상 종료가 필요한 것이 아닙니다. assert는 내부 계약에 남겨 두세요. 검사 후의 인덱스, 도달할 수 없어야 하는 분기, 컨테이너 상태에 대한 가정 같은 것들 말입니다.

실수 4: assert 안에 부수 효과를 넣음.
assert(++i < n)은 “편해” 보이지만, 함정입니다. assert가 비활성화되는 구성에서는 ++i가 실행되지 않아 프로그램 동작이 바뀝니다. assert 안에는 변수 변경, 입출력, 부수 효과가 있는 함수 호출 없이 순수한 조건만 있어야 합니다.

실수 5: 원인 대신 증상만 치료함.
프로그램이 범위 밖 접근으로 죽는다면, “인덱스를 적당히 보정”해 볼 수도 있습니다. 하지만 더 올바른 접근은 먼저 왜 그 인덱스가 잘못되었는지 밝히는 것입니다. 좋은 진단은 먼저 데이터의 경로를 보여 줍니다. 인덱스가 어디서 왔는지, 어떻게 변환되었는지, 그 순간 컨테이너 크기가 얼마였는지를 본 다음에야 코드를 바꿉니다.

1
과제
C++ SELF, 레벨 30, 레슨 4
잠금
디버그 확인
디버그 확인
1
과제
C++ SELF, 레벨 30, 레슨 4
잠금
제어 가능한 로거
제어 가능한 로거
1
과제
C++ SELF, 레벨 30, 레슨 4
잠금
안전한 문자
안전한 문자
1
과제
C++ SELF, 레벨 30, 레슨 4
잠금
콘솔 플래너
콘솔 플래너
1
설문조사/퀴즈
컴파일러는 무엇을 하나요?, 레벨 30, 레슨 4
사용 불가능
컴파일러는 무엇을 하나요?
컴파일러는 무엇을 하나요?
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION