C++ 0 초기화: 전역 int x;는 0이고 지역 변수는 쓰레기 값인 이유
이 글의 핵심
전역 카운터는 초기화하지 않아도 0에서 시작하지만 같은 코드를 함수 안으로 옮기면 미정의 동작이 됩니다. 이 글은 0 초기화가 어떤 저장 기간의 객체에 적용되는지, 값 초기화·기본 초기화와 어떻게 이어지는지, 클래스 멤버와 배열은 어떻게 처리되는지를 예제로 정리해 쓰레기 값 버그를 미리 막도록 돕습니다.
0 초기화란?
0 초기화(zero initialization) 는 변수를 0(또는 nullptr, false 등)으로 채우는 초기화입니다. 정적·전역 저장 기간 객체는 프로그램 로드 시 자동으로 0 초기화되며, 값 초기화 {}로 지역 변수도 0으로 맞출 수 있습니다. 기본 초기화와 상수 초기화·집합체 초기화와 구분해 두면 좋습니다.
// 변수 선언 및 초기화
int global; // 0 (전역)
static int s; // 0 (정적)
void func() {
static int x; // 0 (정적 지역)
int y; // 쓰레기 값 (지역)
}
왜 필요한가?:
- 안전성: 정적/전역 변수가 쓰레기 값을 갖지 않도록 보장
- 예측 가능성: 프로그램 시작 시 일관된 상태
- 자동화: 명시적 초기화 없이도 안전한 기본값
이 규칙이 저장 기간에 따라 갈리는 이유는 비용입니다. 전역·정적 변수는 프로그램 전체에서 한 번만 만들어지고, 운영체제가 실행 파일을 올릴 때 0으로 채운 페이지를 주는 것이 사실상 공짜라서 표준이 0을 보장할 수 있습니다. 반면 지역 변수는 함수가 호출될 때마다 스택에 새로 잡히므로, 매번 0으로 채우면 반복 호출되는 작은 함수에서 무시할 수 없는 비용이 생깁니다. C++은 “쓰지 않는 기능에는 비용을 치르지 않는다”는 원칙에 따라 지역 변수의 초기화를 프로그래머에게 맡겼고, 그 대가가 쓰레기 값 버그입니다.
0 초기화 규칙:
| 타입 | 0 초기화 결과 |
|---|---|
int, long, short | 0 |
float, double | 0.0 |
bool | false |
| 포인터 | nullptr |
| 배열 | 모든 요소 0 |
| 클래스 | 모든 멤버 0 초기화 |
// 타입 정의
struct Data {
int a;
double b;
int* ptr;
};
static Data d; // {0, 0.0, nullptr}
적용 대상
// 0 초기화됨
// 변수 선언 및 초기화
int global;
static int s;
static double d;
static int* ptr;
// 0 초기화 안 됨
void func() {
int local; // 쓰레기 값
}
0 초기화 적용 조건:
-
정적 저장 기간(static storage duration):
- 전역 변수
static변수 (전역, 지역, 클래스 멤버)thread_local변수
-
프로그램 로드 시 자동 적용:
- 프로그래머가 명시적으로 초기화하지 않아도 자동으로 0으로 설정
- 다른 초기화(상수 초기화, 동적 초기화) 전에 수행
#include <iostream>
int g1; // 0 초기화
static int g2; // 0 초기화
thread_local int g3; // 0 초기화
void func() {
static int s1; // 0 초기화
int local; // ❌ 0 초기화 안 됨
std::cout << "g1: " << g1 << '\n'; // 0
std::cout << "g2: " << g2 << '\n'; // 0
std::cout << "s1: " << s1 << '\n'; // 0
// std::cout << "local: " << local << '\n'; // 정의되지 않은 동작
}
실무 권장:
- 전역/정적 변수: 0 초기화에 의존해도 안전
- 지역 변수: 항상 명시적으로 초기화 (
int x{};)
초기화하지 않은 지역 변수를 읽는 것은 “어떤 값이 나올지 모른다”보다 더 나쁜 정의되지 않은 동작입니다. 디버그 빌드에서는 스택에 우연히 0이 남아 있어 잘 동작하다가, 최적화 빌드에서는 컴파일러가 “읽을 수 없는 값”이라고 가정하고 분기를 통째로 지우는 식으로 전혀 다른 결과가 나오기도 합니다. 그래서 “디버그에서는 되는데 릴리스에서만 이상하다”는 버그의 흔한 원인이 이것입니다. GCC·Clang의 -Wall에 포함된 -Wuninitialized와 -Wmaybe-uninitialized(GCC)는 'local' is used uninitialized 같은 경고로 상당수를 잡아 주지만, 포인터를 통해 넘기거나 여러 분기를 거치면 놓치므로, 런타임에는 Clang의 MemorySanitizer(-fsanitize=memory)나 Valgrind가 “Conditional jump or move depends on uninitialised value(s)“로 알려 줍니다. 최근 GCC와 Clang은 -ftrivial-auto-var-init=zero 옵션으로 모든 자동 변수를 0으로 채우게 할 수도 있는데, 보안이 중요한 코드에서 정보 유출을 막는 안전망으로는 유용하지만 코드의 버그 자체를 고치는 것은 아니므로 명시적 초기화를 대신하지는 못합니다.
다른 초기화와의 관계: 0 초기화는 “한 단계”
정적·전역 객체의 초기화는 표준에서 여러 단계로 설명됩니다. 0 초기화는 그중 첫 단계로, 비트를 0으로 맞추는 쪽에 가깝습니다. 이후 상수 초기화·동적 초기화가 덧붙을 수 있습니다.
| 구분 | 예시 | 0 초기화와의 관계 |
|---|---|---|
값 초기화 int x{} | 지역 int를 0으로 | 0 초기화가 포함될 수 있는 복합 규칙(타입에 따라) |
기본 초기화 int x; | 지역 int | 0 초기화 아님 → 쓰레기 |
집합체 S{} | 멤버를 0으로 채우는 부분 | 0 초기화 + 나머지 규칙 |
“지역 변수도 0으로 두고 싶다”면 0 초기화만 기다리면 안 되고, int x{} 같은 값 초기화를 써야 합니다.
static·전역 vs 지역: 같은 int x;가 다른 이유
저장 기간이 다르면 같은 문법이라도 결과가 달라집니다.
int g; // 정적 저장 기간 → 먼저 0 초기화
void f() {
static int s; // 정적 지역 → 0 초기화
int a; // 자동(지역) → 기본 타입은 미정의 값
thread_local int t; // 스레드 지역 정적 → 0 초기화
}
- 전역·정적·
thread_local: 프로그램/스레드 수명에 걸쳐 한 번만 존재하므로, 구현은 보통 BSS 등에 올려 0으로 채우는 방식으로 맞춥니다. - 일반 지역 변수: 스택(또는 레지스터)에 매 호출마다 새로 잡히므로, 비용을 줄이기 위해 자동으로 0을 쓰지 않는 것이 일반적입니다. 필요하면 값 초기화로 명시하세요.
흔한 오해: “전역은 0이므로 지역도 0에 가깝지 않을까?” → 아닙니다. 지역 int는 읽기 전에 반드시 대입하거나 int x{}로 초기화하세요.
클래스 vs 기본 타입
- 기본 타입의 정적/전역: 0 초기화로 0, nullptr, false 등으로 시작합니다.
- 클래스 타입의 정적/전역: 0 초기화 단계에서 멤버가 기본 타입이면 0에 가깝게 맞으며, 이어서 생성자·필요 시 동적 초기화가 올 수 있습니다. POD가 아닌 타입은 “전부 비트 0이 항상 유효한 상태”는 아니므로, 실무에서는 동적 초기화에서 제대로 된 생성자를 두는 편이 안전합니다.
// 타입 정의
struct Count {
int n; // 정적 정의 시 0 초기화로 n은 0부터
};
Count global_count; // 전역 Count — 집합체 규칙과 조합 시 멤버 0
인스턴스 멤버 int n;는 객체가 자동 저장 기간이면 기본 초기화 경로로 가기 쉬워 쓰레기가 될 수 있으므로, int n{} 또는 생성자에서 초기화하세요.
여기서 가장 헷갈리는 부분은 생성자의 종류에 따라 {}의 의미가 달라진다는 점입니다.
struct A { int n; }; // 생성자 없음
struct B { int n; B() = default; }; // 기본 생성자를 default로 선언
struct C { int n; C() {} }; // 사용자가 작성한 생성자
A a{}; // 집합체 초기화 → n == 0
B b{}; // 값 초기화: 먼저 0 초기화 후 기본 생성자 → n == 0
C c{}; // 값 초기화지만 사용자 생성자만 호출 → n은 쓰레기 값
C() {}처럼 비어 있는 생성자라도 직접 작성하면 컴파일러는 “초기화는 이 생성자가 책임진다”고 보고 0 초기화 단계를 건너뜁니다. 그래서 리팩터링 중에 로그 한 줄을 넣으려고 빈 생성자를 추가했을 뿐인데, 그동안 Widget w{};로 0이 보장되던 멤버가 갑자기 쓰레기 값이 되는 일이 생깁니다. 제 경험상 이런 버그를 가장 확실하게 막는 방법은 생성자 규칙을 외우는 것이 아니라, 모든 멤버 선언에 int n{};처럼 기본 멤버 초기화자를 적는 습관이었습니다. 그러면 생성자가 어떻게 바뀌든 멤버는 항상 초기화됩니다.
동적 할당에서도 같은 차이가 있습니다. new int[100]은 기본 초기화라 원소가 쓰레기 값이고, new int[100]()나 new int[100]{}는 값 초기화라 모두 0입니다. std::vector<int> v(100);과 std::make_unique<int[]>(100)은 원소를 값 초기화해 0으로 채우고, C++20의 std::make_unique_for_overwrite<int[]>(100)는 곧 덮어쓸 버퍼의 초기화 비용을 아끼려고 일부러 기본 초기화를 합니다.
실전 예시
예시 1: 전역 변수
int counter; // 0
int main() {
std::cout << counter << std::endl; // 0
counter++;
std::cout << counter << std::endl; // 1
}
예시 2: 정적 지역 변수
void func() {
static int callCount; // 0 (프로그램 시작 시 0 초기화, 첫 호출 때가 아님)
callCount++;
std::cout << "호출 횟수: " << callCount << std::endl;
}
int main() {
func(); // 1
func(); // 2
func(); // 3
}
예시 3: 클래스 정적 멤버
class Counter {
static int count; // 선언
public:
Counter() {
count++;
}
static int getCount() {
return count;
}
};
// 정의 (0 초기화)
int Counter::count;
int main() {
Counter c1, c2, c3;
std::cout << Counter::getCount() << std::endl; // 3
}
예시 4: 배열
static int arr[5]; // {0, 0, 0, 0, 0}
int main() {
for (int x : arr) {
std::cout << x << " "; // 0 0 0 0 0
}
}
초기화 순서
// 1. 0 초기화 (정적/전역)
int global1; // 0
// 2. 상수 초기화 (constexpr)
constexpr int global2 = 42;
// 3. 동적 초기화 (런타임)
int global3 = compute();
초기화 단계 상세:
정적/전역 변수는 다음 순서로 초기화됩니다:
-
Zero Initialization (0 초기화):
- 프로그램 로드 시 모든 정적/전역 변수를 0으로 설정
- 바이너리의
.bss세그먼트에 배치 (디스크 공간 절약)
-
Constant Initialization (상수 초기화):
- 컴파일 타임에 값이 결정된 변수 초기화
- 바이너리의
.data세그먼트에 값 내장
-
Dynamic Initialization (동적 초기화):
main()전에 런타임 함수 호출로 초기화- 파일 내 순서는 보장, 파일 간 순서는 미정의
#include <iostream>
int compute() {
std::cout << "compute() 호출\n";
return 100;
}
int g1; // 1단계: 0
constexpr int g2 = 50; // 2단계: 50 (바이너리에 내장)
int g3 = compute(); // 3단계: main() 전 호출
int main() {
std::cout << "main 시작\n";
std::cout << "g1: " << g1 << ", g2: " << g2 << ", g3: " << g3 << '\n';
}
// 출력:
// compute() 호출
// main 시작
// g1: 0, g2: 50, g3: 100
실무 팁:
- 0 초기화: 명시적 초기화 없이도 안전
- 상수 초기화: 성능 최적화
- 동적 초기화: 초기화 순서 문제 주의
.bss에 놓인 변수는 실행 파일에 실제 바이트가 저장되지 않고 “이 크기만큼 0으로 채울 것”이라는 정보만 들어가므로, static char buffer[1 << 20]; 같은 큰 배열도 실행 파일 크기를 늘리지 않습니다. 반면 static char buffer[1 << 20] = {1};처럼 0이 아닌 값을 하나라도 넣으면 배열 전체가 .data로 가서 실행 파일이 그만큼 커집니다. 임베디드 환경에서 펌웨어 크기가 갑자기 늘었다면 이런 초기화 하나가 원인인 경우가 있습니다. 반대로 임베디드에서는 시작 코드(startup code)가 .bss를 0으로 지우는 일을 직접 해야 하는데, 커스텀 링커 스크립트나 부트로더에서 이 단계가 빠지면 “전역 변수는 0”이라는 C++ 규칙 자체가 깨진다는 점도 알아 두면 좋습니다.
자주 발생하는 문제
문제 1: 지역 vs 전역
int global; // 0
void func() {
int local; // 쓰레기 값
std::cout << global << std::endl; // 0
// std::cout << local << std::endl; // 정의되지 않은 동작
}
문제 2: 정적 초기화 순서
// file1.cpp
int x = 10;
// file2.cpp
extern int x;
int y = x + 1; // 순서 보장 안 됨
문제 3: 클래스 멤버
class Widget {
int value; // 기본 초기화 (쓰레기 값)
public:
Widget() {}
};
// ✅ 멤버 초기화
class Widget {
int value = 0; // 기본 멤버 초기화자 (엄밀히는 0 초기화가 아니라 생성자가 0을 대입)
};
문제 4: const 변수
// ❌ const는 초기화 필수
// const int x; // 에러
// ✅ 초기화
const int x = 0;
const int y{};
명시적 0 초기화
// 모두 동일
int x = 0;
int y{};
int z = int();
int w{0};
명시적 0 초기화 방법 비교:
| 방법 | 예시 | 특징 |
|---|---|---|
| 직접 대입 | int x = 0; | 명확, 전통적 |
| 중괄호 | int x{}; | 값 초기화, 좁히기 방지 |
| 함수 스타일 | int x = int(); | 값 초기화된 임시값으로 초기화 (최적화 후 차이 없음) |
| 중괄호 + 값 | int x{0}; | 명시적, 좁히기 방지 |
실무 권장:
// ✅ 지역 변수: 중괄호 사용 (안전)
void func() {
int counter{};
double sum{};
int* ptr{};
}
// ✅ 전역/정적 변수: 명시적 초기화 (가독성)
int g_counter = 0;
static int s_total = 0;
// ✅ 배열: 중괄호
int arr[5]{}; // {0, 0, 0, 0, 0}
// ✅ 구조체: 중괄호
struct Point { int x, y; };
Point p{}; // {0, 0}
좁히기 변환 방지:
// ❌ 좁히기 변환 허용
int x = 3.14; // OK: 3 (소수점 버려짐)
// ✅ 좁히기 변환 방지
int y{3.14}; // 에러: narrowing conversion
// 실무 활용
double getValue();
int result{getValue()}; // 컴파일 에러 (의도하지 않은 변환 방지)
실무 패턴
패턴 1: 카운터
class RequestCounter {
static int count_; // 0 초기화
public:
RequestCounter() { ++count_; }
static int getCount() { return count_; }
};
int RequestCounter::count_; // 정의 (0 초기화)
패턴 2: 플래그
static bool g_initialized; // false (0 초기화)
void initialize() {
if (!g_initialized) {
// 초기화 로직
g_initialized = true;
}
}
패턴 3: 버퍼
class Buffer {
static char data_[1024]; // 모두 '\0' (0 초기화)
public:
static void clear() {
// 이미 0으로 초기화됨
}
};
char Buffer::data_[1024]; // 정의
FAQ
Q1: 0 초기화는 언제 발생하나요?
A: 정적/전역 변수는 프로그램 로드 시 자동으로 0 초기화됩니다. 지역 변수는 0 초기화되지 않습니다.
Q2: 지역 변수는 0 초기화되나요?
A: 아닙니다. 지역 변수는 쓰레기 값을 가집니다. 명시적으로 int x{};로 초기화해야 합니다.
Q3: 클래스 멤버는 0 초기화되나요?
A:
- 정적 멤버: 0 초기화됨
- 인스턴스 멤버: 기본 초기화 (쓰레기 값), 명시적 초기화 권장
class Widget {
static int s; // 0 초기화
int x; // 쓰레기 값
int y = 0; // 명시적 초기화
};
Q4: 성능 영향은?
A: 프로그램 로드 시 한 번만 수행되며, 일반적으로 성능 영향은 미미합니다. 운영체제가 효율적으로 메모리를 0으로 설정합니다.
Q5: 초기화 순서는?
A:
- Zero Initialization: 0으로 설정
- Constant Initialization: 컴파일 타임 상수
- Dynamic Initialization: 런타임 함수 호출
Q6: 배열은 어떻게 0 초기화되나요?
A: 정적/전역 배열은 모든 요소가 0으로 초기화됩니다.
static int arr[1000]; // 모두 0
static double darr[100]; // 모두 0.0
Q7: 0 초기화 학습 리소스는?
A:
- “C++ Primer” by Lippman, Lajoie, Moo
- “Effective C++” by Scott Meyers
- cppreference.com - Zero initialization
0 초기화는 정적/전역 변수를 프로그램 로드 시 자동으로 0으로 설정하는 초기화입니다.
관련 글: 값 초기화, 기본 초기화, 상수 초기화, 집합체 초기화.
같이 보면 좋은 글
- C++ 값 초기화: int x{}와 int()가 0이 되는 이유와 {}·()의 차이
- C++ 기본 초기화: 지역 변수가 쓰레기 값을 갖는 이유와 전역·정적 변수와의 차이
- C++ 상수 초기화: constexpr·constinit으로 정적 초기화 순서 문제 피하기
- C++ 집합체 초기화: 집합체 조건, 중괄호 초기화, C++20 지정 초기화 순서 규칙
- C++ Dynamic Initialization
- C++ 정적 초기화 순서
- C++ call_once