C++ 초보자가 자주 하는 실수 Top 15 | 컴파일 에러부터 런타임 크래시까지

이 글의 핵심

컴파일 에러는 첫 번째 메시지만 고치면 나머지가 연쇄적으로 사라지는 경우가 많고, 런타임 크래시(초기화 안 된 포인터, 범위 초과, 댕글링)는 -fsanitize=address로 정확한 줄을 찾을 수 있습니다. 실수별 실제 에러 메시지와 규칙의 이유를 함께 정리했습니다.

들어가며: “Hello World도 컴파일이 안 돼요”

C++를 처음 배우는 사람들이 반드시 겪는 실수들이 있습니다. 세미콜론 하나, 헤더 파일 하나 빠뜨려도 수십 줄의 에러 메시지가 쏟아집니다. 이 글은 C++ 입문자가 가장 자주 하는 실수들을 컴파일 에러, 런타임 에러, 논리 에러로 나눠 정리하고, 각각의 실제 에러 메시지와 해결법, 그리고 가능하면 왜 언어가 그렇게 동작하는지를 설명합니다.

C++ 에러가 유독 무섭게 느껴지는 이유는 두 가지입니다. 첫째, 컴파일러는 문법이 한 번 어긋나면 그 뒤를 “추측”하며 계속 읽기 때문에, 실수 하나가 에러 수십 개로 불어납니다. 둘째, 런타임 에러의 상당수는 미정의 동작(undefined behavior)이라서, 같은 코드가 어떤 날은 정상 동작하고 어떤 날은 죽습니다. 제가 C++를 처음 가르칠 때 가장 많이 받은 질문도 “제 컴퓨터에서는 잘 되는데 채점 서버에서는 죽어요”였는데, 거의 항상 초기화 안 된 변수나 배열 범위 초과였습니다. 이 글의 실수들은 그 두 부류를 구분해서 보면 훨씬 빨리 잡힙니다.


컴파일 에러 Top 8

실수 1: 세미콜론 누락

가장 흔한 실수: 클래스/구조체 정의 끝에 세미콜론을 빼먹음.

// ❌ 에러 코드
class MyClass {
    int x;
}  // ← 세미콜론 없음!
int main() {
    return 0;
}
// error: expected ';' after class definition

해결:

// ✅ 올바른 코드
class MyClass {
    int x;
};  // ← 세미콜론 필수!

클래스 뒤에 세미콜론이 필요한 이유는 C에서 물려받은 문법 때문입니다. struct Point { int x, y; } p1, p2;처럼 타입 정의 바로 뒤에 변수를 선언할 수 있으므로, 컴파일러는 } 뒤에 변수 이름이 오는지 ;가 오는지를 봐야 선언이 끝났다고 판단합니다. 세미콜론이 없으면 다음 줄의 int main()을 “MyClass 타입의 무언가”로 읽으려 하기 때문에, 헤더 파일 끝에서 세미콜론을 빠뜨리면 에러가 그 헤더를 include한 .cpp 파일의 엉뚱한 줄에서 나기도 합니다. 에러 줄 근처에 이상한 점이 없다면 바로 위에 include한 헤더의 마지막 클래스부터 확인하십시오.

주의사항: 클래스 안의 중첩 클래스·friend 선언 위치를 바꿀 때 세미콜론 위치가 흔들리기 쉽습니다.

주의: 함수 정의 끝에는 세미콜론이 필요 없습니다. 붙여도 대부분 빈 선언으로 무시되지만 -pedantic에서는 extra ';' 경고가 납니다.

void foo() {
    // ...
}  // ← 세미콜론 없음 (올바름)

실수 2: 헤더 파일 미포함

// ❌ 에러 코드
int main() {
    cout << "Hello" << endl;  // cout이 뭔지 모름
    return 0;
}
// error: 'cout' was not declared in this scope
// error: 'endl' was not declared in this scope

해결:

// ✅ 올바른 코드
#include <iostream>
int main() {
    std::cout << "Hello" << std::endl;
    return 0;
}

주의사항: using namespace std;는 교육용 예제에는 편하지만, 실무 코드베이스에서는 이름 충돌을 피하기 위해 지양하는 편입니다. 자주 빠뜨리는 헤더:

기능필요한 헤더
cout, cin<iostream>
string<string>
vector<vector>
sqrt, pow<cmath>
sort, find<algorithm>

헤더 누락이 더 교묘해지는 경우는 “내 컴퓨터에서는 컴파일되는” 경우입니다. 표준 헤더가 서로를 내부적으로 include하는 방식은 구현마다 다릅니다. 예를 들어 어떤 GCC 버전에서는 <iostream>만 넣어도 std::string이 쓸 수 있게 되지만, 다른 컴파일러나 새 버전에서는 'string' is not a member of 'std' 에러가 납니다. 온라인 저지나 다른 팀원 환경에서만 깨지는 코드의 흔한 원인이므로, 쓰는 기능의 헤더는 직접 include하는 것을 원칙으로 삼으십시오. 같은 이유로 #include <bits/stdc++.h>는 GCC 전용이라 MSVC나 macOS의 Clang(libc++)에서는 fatal error: 'bits/stdc++.h' file not found가 납니다.

실수 3: using namespace std 없이 std:: 생략

// ❌ 에러 코드
#include <iostream>
int main() {
    cout << "Hello" << endl;  // std:: 없음
    return 0;
}
// error: 'cout' was not declared in this scope

해결법 1: std:: 접두사 사용 (권장)

// ✅ 올바른 코드
#include <iostream>
int main() {
    std::cout << "Hello" << std::endl;
    return 0;
}

해결법 2: using namespace std

// ✅ 동작하지만 권장하지 않음
#include <iostream>
using namespace std;
int main() {
    cout << "Hello" << endl;
    return 0;
}

주의: using namespace std;는 이름 충돌을 일으킬 수 있으므로 헤더 파일에는 절대 쓰지 마세요.

충돌이 실제로 어떻게 생기는지 예를 들면, using namespace std; 아래에서 전역 변수 이름을 count, distance, data, size로 지으면 std::count, std::distance 같은 표준 함수와 겹쳐 reference to 'count' is ambiguous 에러가 납니다. 헤더에 이 한 줄을 넣으면 그 헤더를 포함하는 모든 파일에 같은 위험을 퍼뜨리게 되므로 금지하는 것입니다. 타협안으로는 .cpp 파일 안에서 using std::cout;처럼 필요한 이름만 가져오는 방법이 있습니다.

실수 4: main 함수 반환 타입 오류

// ❌ 에러 코드
void main() {  // ❌ void는 표준이 아님
    std::cout << "Hello\n";
}
// error: '::main' must return 'int'   (GCC/Clang)

해결:

// ✅ 올바른 코드
int main() {
    std::cout << "Hello\n";
    return 0;
}

Visual Studio(MSVC)는 void main()을 확장으로 허용하기 때문에, 윈도우에서 배운 코드를 리눅스 서버나 온라인 저지에 올렸을 때 처음 이 에러를 만나는 경우가 많습니다. main의 반환값은 운영체제에 전달되는 종료 코드입니다. 셸 스크립트나 CI는 0이면 성공, 0이 아니면 실패로 판단하므로 실제로 의미가 있는 값입니다. 참고로 main에 한해서는 return 0;을 생략해도 표준이 0을 반환한 것으로 처리해 줍니다.

실수 5: 변수 선언 위치 오류

for (int i = 0; ...)에서 선언한 i의 유효 범위는 루프 안까지입니다. 표준화 이전의 오래된 컴파일러(예: Visual C++ 6.0)는 i를 루프 밖까지 살려 두었기 때문에, 오래된 책이나 예제를 따라 하면 아래 에러를 만나게 됩니다.

// ❌ 표준화 이전 컴파일러에서만 통하던 코드
int main() {
    for (int i = 0; i < 10; ++i) {
        // ...
    }
    
    std::cout << i << '\n';  // ❌ i는 for 블록 밖에서 접근 불가
}
// error: 'i' was not declared in this scope

해결:

// ✅ 올바른 코드
int main() {
    int i;  // 밖에서 선언
    for (i = 0; i < 10; ++i) {
        // ...
    }
    
    std::cout << i << '\n';  // 10
}

실수 6: const 불일치

// ❌ 에러 코드
void print(std::string& s) {  // 비const 참조
    std::cout << s << '\n';
}
int main() {
    print("Hello");  // 임시 객체는 비const 참조에 바인딩 불가
}
// error: cannot bind non-const lvalue reference of type 'std::string&' 
//        to an rvalue of type 'std::string'

해결:

// ✅ 올바른 코드
void print(const std::string& s) {  // const 참조
    std::cout << s << '\n';
}
int main() {
    print("Hello");  // OK
}

"Hello"는 std::string이 아니라 const char[6] 배열입니다. std::string& 매개변수에 넘기려면 컴파일러가 임시 std::string을 만들어야 하는데, C++는 임시 객체를 비const 참조에 묶는 것을 금지합니다. 함수가 그 임시 객체를 수정해 봐야 호출이 끝나면 사라지므로, 수정이 호출자에게 전달된다고 착각하는 버그를 막기 위한 규칙입니다. const 참조에는 묶을 수 있고 이때 임시 객체의 수명이 참조의 수명만큼 연장됩니다. 함수가 인자를 읽기만 한다면 처음부터 const std::string&(C++17 이상이면 std::string_view)로 받는 습관을 들이면 이 에러는 다시 볼 일이 없습니다.

실수 7: 배열 초기화 오류

// ❌ 에러 코드
int arr[5];
arr = {1, 2, 3, 4, 5};  // ❌ 선언 후에는 이렇게 할당 불가
// error: invalid array assignment

해결:

// ✅ 올바른 코드
int arr[5] = {1, 2, 3, 4, 5};  // 선언과 동시에 초기화
// 또는
int arr[5];
for (int i = 0; i < 5; ++i) {
    arr[i] = i + 1;
}
// 또는 C++11
int arr[] = {1, 2, 3, 4, 5};  // 크기 자동 추론

실수 8: 함수 선언과 정의 불일치

// ❌ 에러 코드
// header.h
void foo(int x);
// main.cpp
void foo(double x) {  // ❌ 타입이 다름
    // ...
}
int main() {
    foo(42);
}
// error: undefined reference to 'foo(int)'

해결:

// ✅ 올바른 코드
// header.h
void foo(int x);
// main.cpp
void foo(int x) {  // 타입 일치
    // ...
}

이 실수는 엄밀히 말하면 컴파일 에러가 아니라 링크 에러입니다. main.cpp는 헤더의 foo(int) 선언만 보고 정상적으로 컴파일되고, foo(double)은 C++에서 이름이 같은 다른 함수(오버로드)이므로 역시 문제없이 컴파일됩니다. 링커가 실행 파일을 합칠 때 가서야 foo(int)의 본체가 없다는 것을 발견하고 undefined reference to 'foo(int)'(MSVC는 LNK2019: unresolved external symbol)를 냅니다. 정의하는 .cpp 파일에서도 같은 헤더를 include해 두면, 선언과 정의가 어긋났을 때 적어도 경고나 에러로 더 일찍 발견할 수 있습니다.


런타임 에러 Top 4

실수 9: 포인터 초기화 안 함

// ❌ 크래시 코드
int main() {
    int* ptr;  // 초기화 안 함 (쓰레기 값)
    *ptr = 42;  // ❌ 임의의 메모리에 쓰기 → 크래시
}
// Segmentation fault

초기화하지 않은 지역 포인터에는 그 스택 위치에 남아 있던 아무 값이나 들어 있습니다. 그 값이 우연히 쓸 수 있는 메모리 주소라면 크래시 없이 다른 변수를 조용히 덮어쓰고, 프로그램은 한참 뒤 전혀 상관없는 곳에서 이상하게 동작합니다. 크래시가 나는 쪽이 오히려 운이 좋은 경우입니다. nullptr로 초기화하면 역참조했을 때 거의 확실하게 즉시 죽으므로 원인 위치를 찾기가 쉬워집니다. -Wall을 켜면 GCC는 'ptr' is used uninitialized 경고로 대부분의 경우를 미리 알려 줍니다.

해결:

// ✅ 올바른 코드
int main() {
    int* ptr = nullptr;  // 초기화
    
    if (ptr == nullptr) {
        ptr = new int(42);
    }
    
    delete ptr;
}
// ✅ 더 좋은 방법
int main() {
    auto ptr = std::make_unique<int>(42);
    // 자동 해제
}

실수 10: 배열 범위 초과

// ❌ 크래시 코드
int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    
    for (int i = 0; i <= 5; ++i) {  // ❌ i=5는 범위 밖
        std::cout << arr[i] << '\n';
    }
}
// 미정의 동작 (쓰레기 값 또는 크래시)

C++의 배열과 vector::operator[]는 속도를 위해 범위 검사를 하지 않습니다. arr[5]는 그냥 “arr 시작 주소 + 5칸” 위치를 읽을 뿐이라, 대개 옆에 있는 다른 변수의 값이 출력되고 아무 일도 없던 것처럼 넘어갑니다. 이런 버그는 -fsanitize=address로 빌드하면 ERROR: AddressSanitizer: stack-buffer-overflow와 함께 정확한 줄 번호가 나오므로, 아래 디버깅 절의 Sanitizer 사용법을 꼭 익혀 두십시오. 해결:

// ✅ 올바른 코드
int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    
    for (int i = 0; i < 5; ++i) {  // i < 5
        std::cout << arr[i] << '\n';
    }
}
// ✅ 더 안전한 방법: 범위 기반 for
int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    
    for (int x : arr) {
        std::cout << x << '\n';
    }
}

실수 11: 문자열 비교를 == 로

// ❌ 잘못된 비교
int main() {
    char str1[] = "hello";
    char str2[] = "hello";
    
    if (str1 == str2) {  // ❌ 주소 비교 (항상 false)
        std::cout << "Same\n";
    } else {
        std::cout << "Different\n";  // 이게 출력됨
    }
}

해결:

// ✅ C 스타일 문자열
#include <cstring>
int main() {
    char str1[] = "hello";
    char str2[] = "hello";
    
    if (strcmp(str1, str2) == 0) {  // 내용 비교
        std::cout << "Same\n";
    }
}
// ✅ C++ std::string (권장)
#include <string>
int main() {
    std::string str1 = "hello";
    std::string str2 = "hello";
    
    if (str1 == str2) {  // OK
        std::cout << "Same\n";
    }
}

실수 12: 지역 변수 주소 반환

// ❌ 댕글링 포인터
int* createNumber() {
    int x = 42;
    return &x;  // ❌ 지역 변수 주소 반환
}  // x는 소멸됨
int main() {
    int* ptr = createNumber();
    std::cout << *ptr << '\n';  // ❌ 미정의 동작
}
// warning: address of local variable 'x' returned

이 코드가 무서운 점은 대부분 42가 잘 출력된다는 것입니다. 함수가 끝나도 스택 메모리가 즉시 지워지지는 않기 때문에, 다른 함수 호출이 그 자리를 덮어쓰기 전까지는 옛 값이 남아 있습니다. 그래서 “테스트할 때는 됐는데 코드를 조금 고치니 값이 이상해졌다”는 식으로 나타납니다. 참조를 반환할 때도 똑같아서 int& f() { int x = 1; return x; } 역시 댕글링입니다. 최근 GCC는 최적화 빌드에서 이런 함수가 아예 nullptr을 반환하도록 만들어 버리기도 하므로, 이 경고는 절대 무시하면 안 됩니다. 해결:

// ✅ 해결 1: 힙 할당
int* createNumber() {
    return new int(42);  // 호출자가 delete 해야 함
}
// ✅ 해결 2: 스마트 포인터 (권장)
std::unique_ptr<int> createNumber() {
    return std::make_unique<int>(42);
}
// ✅ 해결 3: 값 반환
int createNumber() {
    return 42;
}

논리 에러 Top 3

실수 13: = 와 == 혼동

// ❌ 버그 (컴파일은 됨)
int main() {
    int x = 5;
    
    if (x = 10) {  // ❌ 대입 연산자 (항상 true)
        std::cout << "x is 10\n";  // 항상 실행됨
    }
    
    std::cout << "x = " << x << '\n';  // x = 10
}
// Clang: warning: using the result of an assignment as a condition without parentheses
// GCC:   warning: suggest parentheses around assignment used as truth value [-Wparentheses]

해결:

// ✅ 올바른 코드
int main() {
    int x = 5;
    
    if (x == 10) {  // 비교 연산자
        std::cout << "x is 10\n";
    }
}
// 팁: 상수를 왼쪽에 두면 실수 방지
if (10 == x) {  // 실수로 10 = x 쓰면 컴파일 에러
    // ...
}

실수 14: 정수 나눗셈

// ❌ 버그
int main() {
    int a = 5;
    int b = 2;
    
    double result = a / b;  // ❌ 정수 나눗셈 → 2.0
    std::cout << result << '\n';  // 2 (2.5가 아님!)
}

해결:

// ✅ 올바른 코드
int main() {
    int a = 5;
    int b = 2;
    
    double result = static_cast<double>(a) / b;  // 2.5
    std::cout << result << '\n';
}
// 또는
double result = a / static_cast<double>(b);
// 또는
double result = static_cast<double>(a) / static_cast<double>(b);

핵심은 나눗셈의 타입은 결과를 담을 변수가 아니라 피연산자로 결정된다는 점입니다. a / b는 int / int이므로 먼저 정수 나눗셈으로 2가 되고, 그 2가 double에 담기면서 2.0이 됩니다. static_cast<double>(a / b)처럼 괄호 밖에서 캐스팅하면 이미 잘린 뒤라 소용이 없다는 점도 흔한 실수입니다. 음수의 정수 나눗셈은 C++11부터 0 방향으로 버림(-5 / 2 == -2, -5 % 2 == -1)으로 정해져 있어, 파이썬의 //(내림, -3)과 결과가 다릅니다.

실수 15: 부호 없는 정수 언더플로우

// ❌ 버그
int main() {
    unsigned int x = 5;
    unsigned int y = 10;
    
    unsigned int diff = x - y;  // ❌ 음수가 될 수 없음 → 큰 양수
    std::cout << diff << '\n';  // 4294967291 (2^32 - 5)
}

해결:

// ✅ 올바른 코드
int main() {
    int x = 5;  // 부호 있는 정수
    int y = 10;
    
    int diff = x - y;  // -5
    std::cout << diff << '\n';
}
// 또는 조건 확인
unsigned int x = 5, y = 10;
if (x > y) {
    unsigned int diff = x - y;
} else {
    // x <= y인 경우 처리
}

초보자가 이 실수를 가장 많이 만나는 곳은 vector::size()입니다. size()는 부호 없는 size_t를 반환하므로, 빈 벡터에서 for (size_t i = 0; i < v.size() - 1; ++i)를 돌리면 0 - 1이 18446744073709551615(64비트)가 되어 루프가 범위 밖까지 돌다 죽습니다. i + 1 < v.size()처럼 뺄셈 대신 덧셈으로 조건을 쓰거나, C++20의 std::ssize(v)로 부호 있는 크기를 받는 것이 안전합니다. 부호 없는 정수의 오버플로는 “2^n으로 나눈 나머지”로 정의된 동작이라 Sanitizer도 기본적으로는 잡지 않는다는 점이 이 버그를 더 찾기 어렵게 만듭니다.


에러 메시지 읽는 법

컴파일 에러 메시지 구조

main.cpp:10:5: error: 'cout' was not declared in this scope
^^^^^^^  ^^  ^^ ^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
파일     줄  열 타입    에러 내용
   10 |     cout << "Hello" << endl;
      |     ^~~~
      |     std::cout

읽는 순서:

  1. 파일:줄: main.cpp:10 - 어느 파일의 몇 번째 줄
  2. 에러 타입: error (컴파일 실패) vs warning (경고)
  3. 에러 내용: 'cout' was not declared - 무엇이 문제인지
  4. 제안: std::cout - 어떻게 고칠지

자주 나오는 에러 메시지 패턴

에러 메시지의미해결법
expected ';' before세미콜론 누락이전 줄 끝에 ; 추가
was not declared in this scope변수/함수를 찾을 수 없음선언 추가 또는 헤더 포함
no matching function함수 오버로드를 찾지 못함인자 타입·개수 확인
cannot convert타입 변환 불가타입 일치시키기 또는 캐스팅
undefined reference링커 에러 (정의 없음)소스 파일 추가 또는 라이브러리 링크
invalid use of incomplete type전방 선언만 있음헤더 포함
redefinition of중복 정의헤더 가드 추가

더 많은 실수 패턴

실수 16: cin으로 문자열 입력 시 공백 처리

cin >> 연산자는 공백(스페이스, 탭, 엔터)을 구분자로 사용합니다. 따라서 공백이 포함된 문자열을 입력받을 수 없습니다.

// ❌ 공백 이후 잘림
#include <iostream>
#include <string>
int main() {
    std::string name;
    std::cout << "Enter name: ";
    std::cin >> name;  // "John Doe" 입력 시 "John"만 저장
    
    std::cout << "Hello, " << name << '\n';  // Hello, John
    
    // "Doe"는 입력 버퍼에 남아있음!
    std::string remaining;
    std::cin >> remaining;
    std::cout << "Remaining: " << remaining << '\n';  // Remaining: Doe
}

해결법 1: getline 사용 (권장)

// ✅ getline으로 전체 줄 읽기
#include <iostream>
#include <string>
int main() {
    std::string name;
    std::cout << "Enter name: ";
    std::getline(std::cin, name);  // 엔터까지 전체 줄 읽기
    
    std::cout << "Hello, " << name << '\n';  // Hello, John Doe
}

해결법 2: cin과 getline 혼용 시 주의

// ❌ 문제 상황
#include <iostream>
#include <string>
int main() {
    int age;
    std::string name;
    
    std::cout << "Enter age: ";
    std::cin >> age;  // "25" 입력 후 엔터 → 엔터가 버퍼에 남음
    
    std::cout << "Enter name: ";
    std::getline(std::cin, name);  // 버퍼의 엔터를 읽어서 빈 문자열!
    
    std::cout << "Age: " << age << ", Name: " << name << '\n';
    // Age: 25, Name:  (빈 문자열)
}
// ✅ 해결: cin.ignore()로 버퍼 비우기
#include <iostream>
#include <string>
#include <limits>
int main() {
    int age;
    std::string name;
    
    std::cout << "Enter age: ";
    std::cin >> age;
    
    // 버퍼에 남은 엔터 제거
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    
    std::cout << "Enter name: ";
    std::getline(std::cin, name);
    
    std::cout << "Age: " << age << ", Name: " << name << '\n';
    // Age: 25, Name: John Doe
}

cin.ignore() 설명:

  • std::cin.ignore(n, delim): 최대 n개 문자를 무시하며, delim을 만나면 중단
  • std::numeric_limits<std::streamsize>::max(): 버퍼 전체 크기
  • '\n': 엔터까지 무시

실수 17: 벡터 크기 미확인

vector는 동적 배열이지만, operator[]는 범위 검사를 하지 않습니다. 빈 벡터에 접근하면 미정의 동작이 발생합니다.

// ❌ 크래시 코드
#include <vector>
#include <iostream>
int main() {
    std::vector<int> vec;  // 빈 벡터 (size=0, capacity=0)
    
    vec[0] = 42;  // ❌ 범위 밖 접근 (미정의 동작)
    std::cout << vec[0] << '\n';  // 쓰레기 값 또는 크래시
}
// 미정의 동작: 컴파일은 되지만 실행 시 문제 발생

해결법 1: 크기 지정 초기화

// ✅ 올바른 코드 - 크기 지정
#include <vector>
#include <iostream>
int main() {
    std::vector<int> vec(5);  // 크기 5, 모든 요소 0으로 초기화
    vec[0] = 42;  // OK
    std::cout << vec[0] << '\n';  // 42
    
    // 크기와 초기값 지정
    std::vector<int> vec2(5, 10);  // 크기 5, 모든 요소 10
    // vec2: [10, 10, 10, 10, 10]
}

해결법 2: push_back 사용

// ✅ 올바른 코드 - push_back으로 추가
#include <vector>
#include <iostream>
int main() {
    std::vector<int> vec;  // 빈 벡터
    
    vec.push_back(42);  // 자동 확장 (size=1)
    vec.push_back(10);  // size=2
    vec.push_back(20);  // size=3
    
    std::cout << vec[0] << '\n';  // 42
    std::cout << vec.size() << '\n';  // 3
}

해결법 3: at() 사용 (범위 검사)

// ✅ 안전한 접근 - at()은 범위 검사
#include <vector>
#include <iostream>
#include <stdexcept>
int main() {
    std::vector<int> vec = {1, 2, 3};
    
    try {
        std::cout << vec.at(0) << '\n';  // 1 (OK)
        std::cout << vec.at(10) << '\n';  // 예외 발생
    } catch (const std::out_of_range& e) {
        std::cerr << "오류: " << e.what() << '\n';
        // 오류: vector::_M_range_check: __n (which is 10) >= this->size() (which is 3)
    }
}

operator[] vs at() 비교:

방법범위 검사예외 발생성능사용 시기
vec[i]❌ 없음❌ 없음빠름인덱스가 확실히 유효할 때
vec.at(i)✅ 있음✅ out_of_range약간 느림안전성이 중요할 때

실전 패턴:

#include <vector>
#include <iostream>
int main() {
    std::vector<int> vec;
    
    // ✅ 패턴 1: 크기 확인 후 접근
    if (vec.size() > 0) {
        std::cout << vec[0] << '\n';
    }
    
    // ✅ 패턴 2: empty() 확인
    if (!vec.empty()) {
        std::cout << vec[0] << '\n';
    }
    
    // ✅ 패턴 3: 범위 기반 for (안전)
    for (int x : vec) {
        std::cout << x << '\n';
    }
    
    // ✅ 패턴 4: reserve로 용량 미리 확보
    vec.reserve(100);  // 100개 요소를 위한 메모리 할당
    for (int i = 0; i < 100; ++i) {
        vec.push_back(i);  // 재할당 없이 추가
    }
}

실수 18: switch문에서 break 누락

// ❌ 버그 (의도하지 않은 fall-through)
int main() {
    int x = 1;
    
    switch (x) {
        case 1:
            std::cout << "One\n";
            // break 없음!
        case 2:
            std::cout << "Two\n";
            break;
    }
    
    // 출력: One\nTwo\n (의도하지 않음)
}

해결:

// ✅ 올바른 코드
int main() {
    int x = 1;
    
    switch (x) {
        case 1:
            std::cout << "One\n";
            break;  // 필수
        case 2:
            std::cout << "Two\n";
            break;
        default:
            std::cout << "Other\n";
            break;
    }
}

초보자를 위한 디버깅 가이드

디버깅 프로세스

1단계: 에러 메시지 읽기

에러 메시지 구조:
main.cpp:10:5: error: 'cout' was not declared in this scope
^^^^^^^  ^^  ^^ ^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
파일     줄  열 타입    에러 내용
읽는 순서:
1. 파일과 줄 번호 확인 (main.cpp:10)
2. 에러 타입 확인 (error vs warning)
3. 에러 내용 이해 ('cout' was not declared)
4. 제안 확인 (note: suggested alternative: 'std::cout')

템플릿이 섞이면 에러 메시지가 수백 줄로 길어지는데, 이때도 원칙은 같습니다. required from here가 붙은 줄 중 내 파일을 가리키는 줄을 찾으면 그것이 실제로 고쳐야 할 위치이고, 그 위아래의 /usr/include/c++/... 경로는 표준 라이브러리 내부에서 실패가 전파된 흔적입니다. 저는 긴 템플릿 에러를 만나면 먼저 출력에서 제 소스 파일 이름만 검색해 보는데, 이것만으로 대부분의 원인을 찾을 수 있었습니다.

2단계: 첫 번째 에러만 집중

main.cpp:10:5: error: 'cout' was not declared in this scope
main.cpp:10:5: note: suggested alternative: 'std::cout'
main.cpp:11:5: error: 'endl' was not declared in this scope
main.cpp:12:5: error: expected ';' before 'return'
... (중략 50줄) ...
첫 번째 에러만 고치세요!
→ #include <iostream> 추가
→ 나머지 에러도 자동으로 해결될 가능성 높음

3단계: 이진 탐색으로 에러 위치 찾기

// 500줄 코드에서 에러가 나는데 위치를 모를 때
// 1. 코드를 절반으로 나누어 주석 처리
// 상반부 주석 처리 → 컴파일
// 에러 없으면 하반부에 문제
// 에러 있으면 상반부에 문제
// 2. 문제 있는 절반을 다시 절반으로
// 반복하여 에러 위치를 좁혀감
// 예제:
int main() {
    // 코드 1-250줄
    /* 주석 처리 */
    
    // 코드 251-500줄
    // ...
}

팁 1: 컴파일러 경고를 에러로 취급

컴파일러 경고는 잠재적 버그를 알려줍니다. 경고를 무시하지 말고 에러로 취급하세요.

# GCC/Clang - 권장 플래그
g++ -std=c++17 -Wall -Wextra -Werror -pedantic main.cpp -o main
# 플래그 설명:
# -Wall: 기본 경고 활성화
# -Wextra: 추가 경고 활성화
# -Werror: 경고를 에러로 취급 (컴파일 실패)
# -pedantic: 표준 준수 엄격 검사
# MSVC
cl /W4 /WX main.cpp
# /W4: 경고 레벨 4 (최대)
# /WX: 경고를 에러로 취급

경고 예제:

// 경고가 나는 코드
#include <iostream>
int main() {
    int x = 10;
    unsigned int y = 5;
    
    if (x > y) {  // warning: comparison of integer expressions of different signedness
        std::cout << "x > y\n";
    }
    
    int arr[5];
    for (int i = 0; i <= 5; ++i) {  // warning: array subscript is above array bounds
        arr[i] = i;
    }
}
// 경고를 고친 코드
int main() {
    int x = 10;
    int y = 5;  // unsigned 제거
    
    if (x > y) {
        std::cout << "x > y\n";
    }
    
    int arr[5];
    for (int i = 0; i < 5; ++i) {  // <= 를 < 로 수정
        arr[i] = i;
    }
}

팁 2: 최소 재현 코드 (MCVE) 작성

MCVE (Minimal, Complete, Verifiable Example): 에러를 재현하는 최소한의 코드

// ❌ 복잡한 코드 (500줄)
// 어디서 에러가 나는지 모름
// ✅ 최소 재현 코드 (10줄)
#include <iostream>
#include <vector>
int main() {
    std::vector<int> vec;
    std::cout << vec[0] << '\n';  // 에러 발생!
    return 0;
}

MCVE 작성 단계:

  1. 에러가 나는 부분만 추출
  2. 불필요한 코드 제거
  3. 필요한 헤더만 포함
  4. 독립적으로 컴파일 가능하게 작성

팁 3: 온라인 컴파일러 활용

온라인 컴파일러는 환경 설정 없이 빠르게 테스트할 수 있습니다. 추천 사이트:

  1. Compiler Explorer (godbolt.org)
    • 여러 컴파일러 비교 (GCC, Clang, MSVC)
    • 어셈블리 코드 확인
    • 최적화 레벨별 결과 비교
  2. cpp.sh
    • 간단한 테스트용
    • 빠른 실행
  3. Wandbox
    • 다양한 컴파일러 버전
    • 빠른 응답 속도
  4. OnlineGDB
    • 디버거 지원
    • 단계별 실행 가능 사용 시나리오:
  • 문법 확인 (예: C++17 기능 테스트)
  • 컴파일러별 동작 차이 확인
  • 에러 메시지 비교
  • 최적화 효과 확인

팁 4: 디버거 사용법

GDB (Linux/Mac):

# 디버그 정보 포함하여 컴파일
g++ -g main.cpp -o main
# GDB 실행
gdb ./main
# GDB 명령어
(gdb) break main        # main 함수에 중단점
(gdb) run               # 프로그램 실행
(gdb) next              # 다음 줄 (함수 호출 건너뜀)
(gdb) step              # 다음 줄 (함수 내부로 진입)
(gdb) print x           # 변수 x 값 출력
(gdb) continue          # 다음 중단점까지 실행
(gdb) quit              # 종료

Visual Studio (Windows):

1. F9: 중단점 설정/해제
2. F5: 디버깅 시작
3. F10: 다음 줄 (Step Over)
4. F11: 함수 내부로 (Step Into)
5. Shift+F11: 함수 밖으로 (Step Out)
6. 변수에 마우스 올리면 값 표시

VS Code (크로스 플랫폼):

// .vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "C++ Debug",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/main",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "preLaunchTask": "build"
        }
    ]
}

팁 5: 로깅으로 디버깅

printf 디버깅 (간단하지만 효과적):

#include <iostream>
int main() {
    int x = 10;
    std::cout << "DEBUG: x = " << x << '\n';  // 변수 값 확인
    
    std::vector<int> vec = {1, 2, 3};
    std::cout << "DEBUG: vec.size() = " << vec.size() << '\n';  // 크기 확인
    
    for (size_t i = 0; i < vec.size(); ++i) {
        std::cout << "DEBUG: vec[" << i << "] = " << vec[i] << '\n';
    }
}

조건부 디버그 출력:

#include <iostream>
// 디버그 모드 플래그 (명령줄 -DDEBUG=0 이 있으면 그 값을 우선)
#ifndef DEBUG
#define DEBUG 1
#endif
#if DEBUG
    #define LOG(x) std::cout << "[DEBUG] " << x << '\n'
#else
    #define LOG(x)  // 릴리스 빌드에서는 무시
#endif
int main() {
    int x = 10;
    LOG("x = " << x);  // 디버그 모드에서만 출력
    
    // 릴리스 빌드: g++ -DDEBUG=0 main.cpp
}

#ifndef DEBUG로 감싸지 않고 #define DEBUG 1을 그냥 두면, 명령줄에서 -DDEBUG=0을 넘겨도 파일 안의 정의가 덮어써서 warning: "DEBUG" redefined만 나오고 디버그 출력이 계속 켜진 채로 남습니다. 표준에 가까운 방법을 원한다면 NDEBUG 매크로(릴리스 빌드에서 assert를 끄는 표준 매크로)를 기준으로 삼는 것도 좋습니다.

팁 6: Sanitizer로 런타임 버그의 정확한 위치 찾기

런타임 버그(실수 9~12, 17)는 증상이 나타나는 곳과 원인이 있는 곳이 다른 경우가 많아서, 출력문을 찍는 것만으로는 찾기 어렵습니다. GCC와 Clang에는 AddressSanitizer(ASan)와 UndefinedBehaviorSanitizer(UBSan)가 들어 있어, 컴파일 옵션 하나로 문제가 생긴 바로 그 순간에 프로그램을 멈추고 위치를 알려 줍니다.

g++ -std=c++17 -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer main.cpp -o main
./main
# ==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd...
# READ of size 4 at 0x7ffd... thread T0
#     #0 0x... in main main.cpp:9

배열 범위 초과는 stack-buffer-overflow/heap-buffer-overflow로, 해제한 메모리 사용은 heap-use-after-free로, delete를 빠뜨린 누수는 프로그램 종료 시 LeakSanitizer: detected memory leaks로 보고됩니다. UBSan은 부호 있는 정수 오버플로나 널 포인터 역참조를 runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'처럼 알려 줍니다. 실행 속도가 2배 정도 느려지므로 배포용이 아니라 개발·테스트용이지만, 과제나 연습 문제를 풀 때 기본 옵션으로 켜 두면 “가끔 죽는” 버그 대부분을 처음 실행에서 잡을 수 있습니다. Visual Studio 2019 16.9 이상에서도 프로젝트 속성에서 AddressSanitizer를 켤 수 있습니다. 더 자세한 사용법은 Sanitizer 가이드를 참고하십시오.


체크리스트

컴파일 전 체크리스트

  • 모든 클래스/구조체 정의 끝에 세미콜론이 있는가?
  • 사용하는 모든 기능의 헤더를 포함했는가?
  • std:: 접두사를 붙였는가? (또는 using 선언)
  • main 함수가 int main()인가?
  • 함수 선언과 정의의 시그니처가 일치하는가?

런타임 전 체크리스트

  • 모든 포인터를 초기화했는가?
  • 배열 인덱스가 범위 내인가?
  • new/delete 짝이 맞는가?
  • 문자열 비교를 == 로 하지 않았는가? (C 스타일)
  • 지역 변수 주소를 반환하지 않았는가?

코드 리뷰 체크리스트

  • 컴파일러 경고가 없는가? (-Wall -Wextra)
  • 스마트 포인터를 사용하는가?
  • const 참조를 적절히 사용하는가?
  • 범위 기반 for문을 사용하는가? (배열 순회)
  • switch문에 break가 있는가?

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 디버그 빌드에서는 괜찮은데 릴리스(-O2) 빌드에서만 결과가 이상해요.

A. 거의 항상 미정의 동작이 원인입니다. 초기화 안 된 변수, 배열 범위 초과, 댕글링 참조, 부호 있는 정수 오버플로 같은 코드는 최적화 수준에 따라 결과가 달라질 수 있고, 컴파일러는 “미정의 동작은 일어나지 않는다”고 가정하고 코드를 재배치합니다. 최적화를 끄는 것이 해결책이 아니라, -fsanitize=address,undefined로 원인이 되는 줄을 찾아 고쳐야 합니다.

Q. void main()을 쓰면 안 되나요?

A. 표준이 아닙니다. 일부 컴파일러는 허용하지만, 이식성이 없습니다. 항상 int main()을 사용하세요.

Q. using namespace std를 쓰면 안 되나요?

A. 헤더 파일에는 절대 쓰지 마세요 (이름 충돌). .cpp 파일에서는 괜찮지만, std:: 접두사를 쓰는 것이 더 명확합니다.

Q. 포인터와 참조의 차이는 뭔가요?

A.

  • 포인터: 주소를 저장, nullptr 가능, 재할당 가능
  • 참조: 별칭, nullptr 불가, 재할당 불가 초보자는 참조를 먼저 익히는 것을 권장합니다.