C++ 기본 초기화: 지역 변수가 쓰레기 값을 갖는 이유와 전역·정적 변수와의 차이
이 글의 핵심
초기화되지 않은 변수는 읽기만 해도 정의되지 않은 동작이라, 테스트에서는 우연히 멀쩡하다가 다른 환경에서 엉뚱한 값을 내는 버그로 이어지기 쉽습니다. 생성자가 멤버를 빠뜨리는 경우와 조건문 한쪽에서만 값을 넣는 경우 같은 흔한 실수를 짚고, 멤버 기본값과 중괄호 초기화로 이를 막는 습관을 제안합니다.
기본 초기화란?
초기화자 없이 변수를 선언하면 기본 초기화(default initialization) 가 적용됩니다. 지역 변수에서는 쓰레기 값이 될 수 있어, 안전한 0/기본값이 필요하면 값 초기화나 0 초기화를 쓰며, 정의되지 않은 동작을 피하기 위해 초기화 여부를 구분해 두는 것이 좋습니다.
int x; // 기본 초기화 (쓰레기 값)
int y = 10; // 명시적 초기화
기본 초기화의 규칙 자체는 짧습니다. 클래스 타입이면 기본 생성자를 호출하고, 배열이면 각 요소를 기본 초기화하며, 그 밖의 타입(int, double, 포인터, enum 등)은 아무것도 하지 않습니다. “아무것도 하지 않는다”는 말은 그 메모리에 원래 있던 비트가 그대로 남는다는 뜻입니다. 지역 변수라면 직전에 같은 스택 위치를 쓰던 함수가 남긴 값이고, new int로 할당했다면 이전에 해제된 힙 블록의 내용일 수 있습니다.
C++이 이렇게 설계된 이유는 성능입니다. 큰 배열을 선언한 직후에 어차피 다른 값으로 채울 거라면 0으로 먼저 채우는 작업은 낭비이고, C와의 호환성도 지켜야 했습니다. 대가는 개발자가 “읽기 전에 반드시 값을 넣는다”는 책임을 지는 것입니다.
기본 타입
void func() {
int x; // 쓰레기 값
double d; // 쓰레기 값
int* ptr; // 쓰레기 값
// 사용 전 초기화 필요
x = 10;
d = 3.14;
ptr = nullptr;
}
실전 예시
예시 1: 지역 변수
void func() {
int x; // 쓰레기 값
// ❌ 초기화 전 사용
std::cout << x << std::endl; // 정의되지 않은 동작
// ✅ 초기화 후 사용
x = 10;
std::cout << x << std::endl; // OK
}
“정의되지 않은 동작”은 “아무 값이나 출력된다”보다 훨씬 넓은 의미입니다. 컴파일러는 UB가 일어나지 않는다고 가정하고 최적화하므로, 초기화되지 않은 값을 읽는 코드는 실행할 때마다 다른 값을 보이거나, if (x > 0)과 if (x <= 0)이 둘 다 참처럼 동작하거나, 해당 분기가 통째로 사라지는 결과가 실제로 나옵니다. 디버그 빌드에서는 멀쩡하다가 -O2 릴리스 빌드에서만 이상해지는 버그의 흔한 원인이 이것입니다. MSVC 디버그 빌드는 초기화되지 않은 스택을 0xCC로 채워 두기 때문에, 디버거에서 -858993460(0xCCCCCCCC) 같은 값이 보이면 초기화 누락을 의심하면 됩니다.
참고로 C++26은 이 상황을 “잘못된 동작(erroneous behavior)“이라는 새 범주로 바꿨습니다. 초기화되지 않은 지역 변수를 읽는 것은 여전히 버그지만, 컴파일러가 임의의 최적화를 하는 UB 대신 구현이 정한 값을 읽고 진단을 낼 수 있게 됩니다. 표준이 바뀌기 전에도 GCC 12+와 Clang은 -ftrivial-auto-var-init=zero(또는 =pattern) 옵션으로 지역 변수를 자동으로 채울 수 있습니다. 보안이 중요한 코드(커널, 브라우저)에서 초기화되지 않은 스택 값으로 정보가 새는 것을 막기 위해 쓰이는 옵션입니다.
예시 2: 클래스
class Widget {
int value; // 기본 초기화
public:
Widget() {} // value는 쓰레기 값
};
// ✅ 멤버 초기화
class Widget {
int value;
public:
Widget() : value(0) {} // 명시적 초기화
};
클래스에서 헷갈리는 부분은 “생성자가 호출되었으니 초기화된 것 아닌가”라는 생각입니다. 생성자는 초기화 리스트나 멤버 기본값에 적힌 멤버만 초기화하고, 나머지 멤버는 각자 기본 초기화됩니다. std::string 멤버라면 기본 생성자가 빈 문자열을 만들어 주지만, int 멤버는 아무것도 하지 않습니다. 그래서 Widget() {}는 생성자가 있어도 value가 쓰레기 값입니다.
더 미묘한 것은 Widget w{};처럼 중괄호로 값 초기화를 요청한 경우입니다. 기본 생성자가 사용자 제공(user-provided)이면(Widget() {}) 값 초기화는 그 생성자만 호출하고 value는 여전히 쓰레기 값입니다. 반면 생성자를 선언하지 않았거나 Widget() = default;로 첫 선언에서 기본값으로 지정했다면, 값 초기화가 먼저 객체 전체를 0으로 채운 뒤 생성자를 호출합니다. 즉 빈 본문 {}와 = default는 같아 보이지만 Widget w{};의 결과가 다릅니다. new Widget과 new Widget()의 차이도 같은 규칙에서 나옵니다. 이 규칙을 매번 기억하기보다는, 아래 “문제 3”의 Good처럼 멤버 선언에 기본값을 쓰는 습관이 가장 확실합니다.
예시 3: 배열
void func() {
int arr[5]; // 쓰레기 값
// ❌ 초기화 전 사용
for (int x : arr) {
std::cout << x << " "; // 정의되지 않은 동작
}
// ✅ 초기화
for (int& x : arr) {
x = 0;
}
}
예시 4: 전역 vs 지역
int global; // 0 (전역)
int main() {
int local; // 쓰레기 값 (지역)
std::cout << global << std::endl; // 0
// std::cout << local << std::endl; // 정의되지 않은 동작
}
전역 변수가 0인 이유는 기본 초기화가 다르게 동작해서가 아니라, 정적 저장 기간(전역, 네임스페이스 변수, static 지역 변수, static 멤버)을 가진 모든 변수가 프로그램 시작 전에 먼저 0 초기화를 거치기 때문입니다. 이런 변수는 실행 파일의 .bss 영역에 놓이고, 운영체제가 프로그램을 적재할 때 이 영역을 0으로 채운 페이지로 매핑하므로 실행 시 비용도 들지 않습니다. 반면 지역 변수는 함수가 호출될 때마다 스택에 새로 잡히므로 0으로 채우려면 매번 비용이 듭니다. 같은 int x;라도 선언 위치에 따라 결과가 달라지는 이유가 여기에 있습니다.
정의되지 않은 동작
void func() {
int x;
// ❌ 정의되지 않은 동작 (값을 읽는 연산)
if (x > 0) {}
int y = x + 10;
std::cout << x;
// 주소를 얻는 것 자체는 값을 읽지 않으므로 UB가 아님
int* ptr = &x; // 단, *ptr을 읽으면 UB
}
UB가 되는 것은 값을 읽는 것입니다. 주소를 얻거나 새 값을 대입하는 것은 문제가 없습니다. 그래서 int x; std::cin >> x;나 int x; read(fd, &x, sizeof x);처럼 선언한 뒤 바로 채우는 패턴은 합법입니다. 표준에는 예외도 하나 있습니다. unsigned char와 std::byte로 초기화되지 않은 값을 복사하는 것은 UB가 아닌데, memcpy처럼 원시 메모리를 다루는 코드가 동작하도록 남겨 둔 규칙입니다.
자주 발생하는 문제
문제 1: 초기화 누락
// ❌ 초기화 안함
int sum;
for (int i = 0; i < 10; i++) {
sum += i; // 정의되지 않은 동작
}
// ✅ 초기화
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
문제 2: 포인터
// ❌ 초기화 안함
int* ptr;
if (ptr) {} // 정의되지 않은 동작
// ✅ nullptr
int* ptr = nullptr;
if (ptr) {} // OK
문제 3: 클래스 멤버
// ❌ 초기화 안함
class Bad {
int value;
public:
Bad() {} // value는 쓰레기 값
int getValue() { return value; } // 위험
};
// ✅ 초기화
class Good {
int value = 0; // 멤버 초기화
public:
Good() = default;
};
문제 4: 조건문
int x;
// ❌ 초기화 전 사용
if (condition) {
x = 10;
}
std::cout << x; // condition이 false면 쓰레기 값
// ✅ 초기화
int x = 0;
if (condition) {
x = 10;
}
조건문 문제는 컴파일러 경고로도 잘 잡히지 않는 경우가 많습니다. GCC의 -Wmaybe-uninitialized는 최적화 단계의 데이터 흐름 분석에 의존하므로 -O0에서는 경고가 나오지 않다가 -O2에서만 나오기도 하고, 반대로 실제로는 안전한 코드에 거짓 경고를 내기도 합니다. 변수를 다른 함수에 참조로 넘기면 분석이 거기서 끊겨 경고가 사라집니다. 제가 이런 버그를 잡을 때 가장 믿는 도구는 컴파일러 경고가 아니라 런타임 검사기였습니다. Clang의 MemorySanitizer(-fsanitize=memory)나 Valgrind의 memcheck는 초기화되지 않은 값이 분기 조건에 쓰이는 순간을 호출 스택과 함께 알려 줍니다. AddressSanitizer는 이 종류의 버그를 잡지 못한다는 점도 알아 두면 좋습니다.
가장 좋은 해결책은 분기로 값을 채우지 않는 것입니다. int x = condition ? 10 : 0;처럼 삼항 연산자로 한 번에 초기화하거나, 로직이 복잡하면 즉시 호출 람다(const int x = [&] { if (...) return 10; return 0; }();)를 쓰면 변수를 const로 만들 수도 있고 “값이 없는 순간” 자체가 사라집니다.
초기화 권장사항
// ✅ 항상 초기화
int x = 0;
int y{};
int* ptr = nullptr;
// ✅ 멤버 초기화
class MyClass {
int value = 0;
double data = 0.0;
};
// ✅ 배열 초기화
int arr[5]{};
흔한 실수와 함정 (추가 정리)
memset으로 해결하려는 습관
C 스타일로 구조체를 0으로 두고 싶다고memset(&s, 0, sizeof s)를 쓰면, 비 POD나std::string같은 멤버가 있을 때 정의되지 않은 동작이 될 수 있습니다. C++에서는 값 초기화T{}, 멤버 초기화, 생성자를 사용하는 것이 안전합니다.- 출력으로 “확인”하기
쓰레기 값을std::cout으로 찍어 보면 “그럴듯한 숫자”가 나올 수 있지만, 그건 정의된 동작이라는 뜻이 아닙니다. 경고를 끄지 말고,-Wall/-Wuninitialized등으로 미사용·미초기화를 잡는 편이 낫습니다. - 배열의 일부만 채우기
int a[10] = {1};는 나머지를 0으로 채우지만,int a[10];는 전부 미정의입니다. “일부만 쓸 거야”라도 읽기 전에 인덱스마다 대입했는지 검토하세요.
FAQ
Q1: 기본 초기화는 정확히 언제 일어나나요?
A: 초기화자 없이 선언할 때(int x;, T t;), 초기화자 없는 new T, 그리고 생성자의 초기화 리스트와 멤버 기본값 어디에도 적히지 않은 멤버에서 일어납니다. T t{};, T t = T();, new T()는 값 초기화라서 결과가 다를 수 있습니다.
Q2: 컴파일러 경고만 켜면 충분한가요?
A: 충분하지 않습니다. -Wall의 -Wuninitialized/-Wmaybe-uninitialized는 한 함수 안의 단순한 경우를 잘 잡지만, 변수를 참조로 다른 함수에 넘기거나 조건이 복잡하면 놓칩니다. 경고는 1차 방어선으로 두고, 선언과 동시에 초기화하는 습관과 MemorySanitizer·Valgrind 같은 런타임 검사를 함께 쓰는 것이 확실합니다.
Q3: 전역 변수는 왜 0인가요?
A: 정적 저장 기간 변수는 다른 어떤 초기화보다 먼저 0 초기화를 거치기 때문입니다. 함수 안의 static int count;도 같은 규칙이라 0에서 시작합니다. 다만 이 0에 의존하는 코드는 읽는 사람이 의도를 알기 어려우므로, static int count = 0;처럼 명시하는 편이 좋습니다.
Q4: 클래스 객체는 기본 초기화되면 어떻게 되나요?
A: 기본 생성자가 호출되고, 그 생성자가 초기화하지 않은 멤버는 각자 기본 초기화됩니다. 멤버가 std::string, std::vector 같은 클래스 타입이면 안전한 빈 상태가 되지만, int·double·포인터 멤버는 값이 정해지지 않습니다. 생성자를 직접 쓰지 않은 집합체(aggregate) 구조체도 Point p;로 선언하면 멤버가 모두 쓰레기 값입니다.
Q5: 가장 효과적인 방지 방법은?
A: 멤버는 선언에 기본값을 쓰고(int value = 0;), 지역 변수는 쓰기 직전에 선언하면서 바로 값을 주는 것입니다. 값을 정할 수 없는 시점에 변수를 먼저 만들어야 한다면 대개 함수를 나누거나 std::optional로 “아직 값 없음”을 명시할 신호입니다. clang-tidy의 cppcoreguidelines-init-variables와 cppcoreguidelines-pro-type-member-init 검사를 켜 두면 누락을 코드 리뷰 전에 찾을 수 있습니다.
Q6: 기본 초기화 학습 리소스는?
A:
- “C++ Primer”
- “Effective C++”
- cppreference.com
관련 글: 값 초기화, 0 초기화, 집합체 초기화, 정의되지 않은 동작.
같이 보면 좋은 글
- C++ 값 초기화: int x{}와 int()가 0이 되는 이유와 {}·()의 차이
- C++ Zero Initialization | “0 초기화” 가이드
- C++ 집합체 초기화: 집합체 조건, 중괄호 초기화, C++20 지정 초기화 순서 규칙
- 디버그에선 되고 릴리스에서 죽는 코드: C++ 미정의 동작 15가지 패턴과 UBSan
- C++ call_once
- C++ Copy Initialization
- C++ Dynamic Initialization