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
읽는 순서:
- 파일:줄:
main.cpp:10- 어느 파일의 몇 번째 줄 - 에러 타입:
error(컴파일 실패) vswarning(경고) - 에러 내용:
'cout' was not declared- 무엇이 문제인지 - 제안:
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 작성 단계:
- 에러가 나는 부분만 추출
- 불필요한 코드 제거
- 필요한 헤더만 포함
- 독립적으로 컴파일 가능하게 작성
팁 3: 온라인 컴파일러 활용
온라인 컴파일러는 환경 설정 없이 빠르게 테스트할 수 있습니다. 추천 사이트:
- Compiler Explorer (godbolt.org)
- 여러 컴파일러 비교 (GCC, Clang, MSVC)
- 어셈블리 코드 확인
- 최적화 레벨별 결과 비교
- cpp.sh
- 간단한 테스트용
- 빠른 실행
- Wandbox
- 다양한 컴파일러 버전
- 빠른 응답 속도
- 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가 있는가?
같이 보면 좋은 글
- C++ 입문 개요
- C++ 포인터 처음 배우기: &와 * 연산자, swap·배열 순회, 초보자가 하는 첫 실수
- C++ 개발 환경 설정 | Visual Studio·GCC·Clang 설치
- C++ LNK2019 | ‘unresolved external symbol’ 링커 에러 원인 5가지와 해결법
- C++ 시리즈 전체 보기
자주 묻는 질문 (FAQ)
Q. 디버그 빌드에서는 괜찮은데 릴리스(-O2) 빌드에서만 결과가 이상해요.
A. 거의 항상 미정의 동작이 원인입니다. 초기화 안 된 변수, 배열 범위 초과, 댕글링 참조, 부호 있는 정수 오버플로 같은 코드는 최적화 수준에 따라 결과가 달라질 수 있고, 컴파일러는 “미정의 동작은 일어나지 않는다”고 가정하고 코드를 재배치합니다. 최적화를 끄는 것이 해결책이 아니라, -fsanitize=address,undefined로 원인이 되는 줄을 찾아 고쳐야 합니다.
Q. void main()을 쓰면 안 되나요?
A. 표준이 아닙니다. 일부 컴파일러는 허용하지만, 이식성이 없습니다. 항상 int main()을 사용하세요.
Q. using namespace std를 쓰면 안 되나요?
A. 헤더 파일에는 절대 쓰지 마세요 (이름 충돌). .cpp 파일에서는 괜찮지만, std:: 접두사를 쓰는 것이 더 명확합니다.
Q. 포인터와 참조의 차이는 뭔가요?
A.
- 포인터: 주소를 저장,
nullptr가능, 재할당 가능 - 참조: 별칭,
nullptr불가, 재할당 불가 초보자는 참조를 먼저 익히는 것을 권장합니다.