C++ 값 초기화: int x{}와 int()가 0이 되는 이유와 {}·()의 차이
이 글의 핵심
지역 변수와 멤버를 초기화하지 않아 생기는 쓰레기 값 버그는 대부분 기본 초기화와 값 초기화를 헷갈린 데서 나옵니다. 이 글은 0을 보장하려면 어떤 문법을 써야 하는지, 사용자 정의 생성자가 있을 때 값 초기화가 어떻게 달라지는지, 0 초기화·기본 초기화와 어떤 관계인지를 비교표와 예제로 정리합니다.
값 초기화란?
값 초기화(Value Initialization) 는 빈 괄호 {} 또는 ()로 변수를 초기화하는 방법입니다. 기본 타입은 0으로 초기화되고, 클래스 타입은 기본 생성자가 호출됩니다.
int x{}; // 0
int* ptr{}; // nullptr
double d{}; // 0.0
왜 필요한가?: 기본 초기화는 쓰레기 값을 남길 수 있지만, 값 초기화는 안전한 기본값을 보장합니다.
// 기본 초기화: 쓰레기 값
int x; // 예: 0x7fff5fbff5e0 (쓰레기)
// 값 초기화: 0
int y{}; // 0
“쓰레기 값”이라는 표현은 편의상 쓰는 말이고, 표준 용어로는 미확정 값(indeterminate value) 입니다. 차이는 중요합니다. 미확정 값을 읽는 것 자체가 미정의 동작이라, 컴파일러는 “이 값은 읽히지 않는다”고 가정하고 최적화할 수 있습니다. 그래서 초기화하지 않은 변수는 “스택에 남아 있던 이전 값”이 아니라 디버그 빌드에서는 0, 릴리스 빌드에서는 엉뚱한 값, 때로는 if (x)와 if (!x)가 둘 다 참인 것처럼 동작하는 기묘한 결과를 낼 수 있습니다. 디버그 빌드에서 잘 돌던 코드가 릴리스에서만 이상해지는 흔한 원인이 이것입니다. MSVC 디버그 빌드는 스택을 0xCCCCCCCC로 채워서 초기화 누락이 눈에 띄게 해 주고, GCC·Clang의 -Wuninitialized(-Wall에 포함)와 -Wmaybe-uninitialized는 많은 경우를 경고로 잡아 줍니다.
참고로 전역 변수와 static 변수는 초기화자가 없어도 0으로 초기화되므로 이 문제가 없습니다. 쓰레기 값은 지역 변수와 new로 만든 객체, 그리고 멤버를 초기화하지 않은 생성자에서만 생깁니다.
다른 초기화와의 관계: 언제 “값 초기화”인가?
값 초기화는 문맥에 따라 다음처럼 나타납니다.
- 빈 중괄호
T{},T{ }— 리스트 초기화 문법이지만, 인자가 없으면 객체에 대해 값 초기화 규칙으로 이어집니다(리스트 초기화 참고). - 빈 괄호
T()— 임시 객체를 만들 때 값 초기화입니다.new T()도 마찬가지입니다. - T t; — 초기화자가 없으면 기본 초기화이지, 값 초기화가 아닙니다(지역 기본 타입은 쓰레기 가능).
즉, “0을 보장하고 싶다”면 기본 초기화가 아니라 값 초기화(또는 0 초기화가 포함된 경로) 를 선택해야 합니다.
{} vs (): 둘 다 값 초기화일 때
많은 경우 int()와 int{}는 같은 값 초기화 결과(정수 0)를 줍니다. 차이는 문법적 함정과 리스트 초기화 규칙에 있습니다.
| 항목 | () | {} |
|---|---|---|
값 초기화 (예: int) | int() → 0 | int{} → 0 |
| 좁히기(narrowing) | int(3.14)는 허용(잘림) | int{3.14}는 일반적으로 컴파일 거부 |
| 가장 성가신 파싱 | T f();는 함수 선언일 수 있음 | T f{};는 객체 한 개 |
std::vector | vector<int>(10, 0)는 크기·값 | vector<int>{10, 0}는 요소 두 개 |
int a = int(); // 값 초기화, 0
int b{}; // 값 초기화, 0
// int c(3.14); // 좁히기 허용 → 3
// int d{3.14}; // 에러: narrowing
// 가장 성가신 파싱 회피
Widget w1; // 기본 초기화(Widget이 어떻게 정의됐는지에 따름)
Widget w2{}; // 값 초기화(기본 생성자)
// Widget w3(); // Widget w3() — 함수 선언!
auto p1 = new double; // 기본 초기화(쓰레기 가능)
auto p2 = new double(); // 값 초기화 → 0.0
auto p3 = new double{}; // 값 초기화 → 0.0
실무 팁: “기본값 0·빈 객체”가 목적이면 지역 변수와 멤버에는 {}를 우선 고려하면, 기본 초기화로 인한 쓰레기 값을 줄이면서도 좁히기 실수를 컴파일 단계에서 잡을 수 있습니다.
클래스 vs 기본 타입에서의 값 초기화
- 기본 타입 (
int,char, 포인터 등): 값 초기화의 결과는 0 또는 nullptr 등으로 정해진 값입니다. - 클래스 타입
T: 기본 생성자가 호출됩니다. 이때 기본 생성자가 없거나 접근 불가하면 컴파일 오류입니다. - 집합체(aggregate) 인 클래스: 값 초기화
T{}는 종종 0 초기화 후 집합체 규칙과 맞물립니다(집합체 초기화, 0 초기화 참고).
struct Pod { int x; int y; };
Pod p{}; // x, y 모두 정확히 0 (빈 집합체 초기화 → 각 멤버 값 초기화)
class NonDefault {
NonDefault(int) {}
};
// NonDefault nd{}; // 기본 생성자 없음 → 오류
사용자가 작성한 기본 생성자가 있으면 달라진다
값 초기화에서 가장 놓치기 쉬운 규칙은 기본 생성자를 누가 만들었는가에 따라 결과가 갈린다는 점입니다.
struct A { int x; }; // 암시적 기본 생성자
struct B { int x; B() = default; }; // 선언 안에서 = default (사용자 제공 아님)
struct C { int x; C() {} }; // 본문을 직접 작성 (사용자 제공)
A a{}; // x == 0
B b{}; // x == 0 (먼저 0으로 채운 뒤 기본 생성자 호출)
C c{}; // x는 미확정 값! 생성자가 x를 초기화하지 않았기 때문
표준 규칙은 이렇습니다. 기본 생성자가 사용자 제공(user-provided) 이 아니면(암시적이거나 첫 선언에서 = default) 값 초기화는 객체를 먼저 0으로 채우고 그다음 기본 생성자를 호출합니다. 반면 사용자가 본문을 작성한 기본 생성자가 있으면 값 초기화는 그 생성자를 호출하는 것으로 끝이고, 0 채우기는 일어나지 않습니다. C() {}처럼 빈 생성자를 “습관적으로” 추가했을 뿐인데 C c{};의 멤버가 0에서 쓰레기 값으로 바뀌는 셈입니다. 저도 기존 구조체에 로그용 생성자를 추가했다가 그동안 {} 덕분에 0이던 카운터 멤버가 초기화되지 않는 버그를 만든 적이 있습니다. 이 함정을 가장 확실히 피하는 방법은 아래 “예시 4”처럼 멤버 선언에 직접 기본값(int x{};)을 적는 것입니다. 그러면 어떤 생성자를 쓰든, 어떤 초기화 문법을 쓰든 멤버가 초기화됩니다.
기본 타입
int x{}; // 0
double d{}; // 0.0
bool b{}; // false
char c{}; // '\0'
int* ptr{}; // nullptr
실전 예시
예시 1: 배열 초기화
int arr1[5]; // 쓰레기 값
int arr2[5]{}; // 모두 0
struct Point {
int x, y;
};
Point points[3]{}; // 모두 {0, 0}
예시 2: 클래스
class Widget {
int value;
public:
Widget() : value{} {} // value = 0
};
int main() {
Widget w{}; // 기본 생성자 호출
}
예시 3: new 연산자
// 기본 초기화
int* p1 = new int; // 쓰레기 값
// 값 초기화
int* p2 = new int(); // 0
int* p3 = new int{}; // 0
// 배열
int* arr1 = new int[5]; // 쓰레기 값
int* arr2 = new int[5](); // 모두 0
int* arr3 = new int[5]{}; // 모두 0
괄호 한 쌍의 차이라 코드 리뷰에서 놓치기 쉽습니다. 스마트 포인터를 쓸 때도 같은 구분이 있습니다. std::make_unique<int[]>(n)은 값 초기화라 모든 요소가 0이고, 곧 덮어쓸 버퍼라서 0 채우기 비용을 피하고 싶다면 C++20의 std::make_unique_for_overwrite<int[]>(n)을 쓰면 기본 초기화가 됩니다. 이름에 “for_overwrite”가 들어간 것은 “초기화되지 않았으니 반드시 먼저 쓰고 나서 읽으라”는 경고의 의미입니다.
예시 4: 멤버 초기화
class Data {
int x{}; // 0
double y{}; // 0.0
int* ptr{}; // nullptr
public:
Data() = default;
};
int main() {
Data d;
// d.x = 0, d.y = 0.0, d.ptr = nullptr
}
멤버 기본값(NSDMI, C++11)은 엄밀히 말하면 값 초기화가 아니라 “생성자가 멤버 초기화 목록에서 그 멤버를 언급하지 않으면 이 초기화자를 쓴다”는 규칙입니다. 덕분에 Data d;처럼 기본 초기화 문법으로 만들어도 멤버가 초기화되고, 생성자를 여러 개 추가해도 각 생성자에서 멤버 초기화를 반복할 필요가 없습니다. 대신 멤버 기본값을 두면 C++11에서는 그 구조체가 집합체가 아니게 되어 Data d{1, 2.0, nullptr}; 같은 집합체 초기화를 쓸 수 없었고, C++14부터 이 제약이 풀렸습니다.
초기화 비교
// 기본 초기화
int x; // 쓰레기 값
// 값 초기화
int y{}; // 0
int z = int(); // 0
// 직접 초기화
int w(10); // 10
초기화 방법 정리:
| 초기화 방법 | 문법 | 결과 (기본 타입) | 결과 (클래스) |
|---|---|---|---|
| 기본 초기화 | int x; | 쓰레기 값 | 기본 생성자 |
| 값 초기화 | int x{}; | 0 | 기본 생성자 |
| 직접 초기화 | int x(10); | 10 | 생성자 호출 |
| 복사 초기화 | int x = 10; | 10 | 변환 생성자 (explicit 생성자는 사용 불가) |
실무 권장:
- 안전성 우선:
{}사용 (값 초기화) - 명시적 값:
= value또는(value)사용
// ✅ 안전한 패턴
int counter{}; // 0으로 시작
int* ptr{}; // nullptr로 시작
// ✅ 명시적 값
int max_size = 100;
std::string name("Alice");
자주 발생하는 문제
문제 1: 쓰레기 값
// ❌ 초기화 안함
int x;
std::cout << x << std::endl; // 쓰레기 값
// ✅ 값 초기화
int x{};
std::cout << x << std::endl; // 0
문제 2: 배열
// ❌ 일부만 초기화
int arr[5] = {1, 2}; // {1, 2, 0, 0, 0}
// ✅ 모두 0
int arr[5]{}; // {0, 0, 0, 0, 0}
문제 3: 포인터
// ❌ 초기화 안함
int* ptr;
if (ptr) {} // 정의되지 않은 동작
// ✅ nullptr
int* ptr{};
if (ptr) {} // OK
문제 4: 클래스 멤버
// ❌ 초기화 안함
class Bad {
int value; // 쓰레기 값
};
// ✅ 값 초기화
class Good {
int value{}; // 0
};
성능 고려사항
// 큰 배열
int arr[1000000]{}; // 모두 0으로 초기화 (시간 소요)
// 필요한 경우만 초기화
int arr[1000000];
for (int i = 0; i < 1000000; i++) {
arr[i] = compute(i);
}
성능 분석:
#include <chrono>
#include <iostream>
using namespace std::chrono;
// 값 초기화 (모두 0) — 40MB라 스택이 아닌 힙에 할당
auto start = steady_clock::now();
int* arr1 = new int[10000000]{};
auto elapsed1 = steady_clock::now() - start;
// 기본 초기화 (쓰레기 값)
start = steady_clock::now();
int* arr2 = new int[10000000];
auto elapsed2 = steady_clock::now() - start;
std::cout << "값 초기화: " << duration_cast<milliseconds>(elapsed1).count() << "ms\n";
std::cout << "기본 초기화: " << duration_cast<milliseconds>(elapsed2).count() << "ms\n";
// 값 초기화가 더 느림 (0으로 채우는 비용)
delete[] arr1;
delete[] arr2;
원래 이런 예제는 int arr[10000000]{};처럼 지역 배열로 쓰는 경우가 많은데, 4천만 바이트는 Linux 기본 스택 8MB와 Windows 1MB를 훨씬 넘어서 측정 전에 스택 오버플로우로 죽습니다. 그래서 위처럼 힙에 할당해야 합니다. 측정 결과를 해석할 때도 주의가 필요합니다. 운영체제는 새로 받은 메모리 페이지를 처음 접근할 때 0으로 채워서 넘겨주므로, 기본 초기화 쪽은 “할당만 하고 아무것도 안 한” 상태라 거의 0ms가 나오고 실제 페이지 할당 비용은 처음 쓸 때로 미뤄집니다. 즉 두 숫자의 차이는 “0 채우기 비용”과 “페이지를 실제로 건드리는 비용”이 섞인 값입니다. 또 최적화 빌드에서는 사용하지 않는 배열 연산을 컴파일러가 통째로 없앨 수 있으므로, 결과를 읽어서 출력하는 등 사용하는 코드가 있어야 측정이 의미 있습니다.
실무 결론은 단순합니다. 수십 MB 이상의 버퍼를 곧바로 전부 덮어쓸 것이 확실한 경우에만 기본 초기화를 고려하고, 그 외에는 값 초기화의 비용이 대부분 무시할 만한 수준입니다.
실무 권장:
- 작은 배열/변수: 항상 값 초기화 (안전성)
- 큰 배열: 즉시 덮어쓸 예정이면 기본 초기화 고려
- 의심스러우면: 값 초기화 (안전성 > 성능)
// ✅ 작은 변수: 항상 초기화
int counter{};
double sum{};
// ✅ 큰 배열: 즉시 덮어쓰기 (4MB라 스택 대신 힙에)
auto buffer = std::make_unique_for_overwrite<int[]>(1000000); // C++20
read_from_file(buffer.get(), 1000000 * sizeof(int)); // 즉시 덮어씀
// ✅ 큰 배열: 부분 사용
std::vector<int> cache(1000000); // 0으로 초기화 (안전)
실무 패턴
패턴 1: 안전한 카운터
class Counter {
int count_{}; // 0으로 초기화
public:
void increment() { ++count_; }
int get() const { return count_; }
};
패턴 2: 옵셔널 포인터
class Resource {
int* data_{}; // nullptr로 초기화
public:
void allocate(int size) {
data_ = new int[size]{}; // 배열도 0으로 초기화
}
~Resource() {
delete[] data_; // nullptr 삭제는 안전
}
};
data_{}로 nullptr을 보장해 두면 allocate()를 호출하지 않은 객체가 소멸할 때도 delete[] nullptr이 되어 안전합니다. 초기화하지 않았다면 소멸자가 쓰레기 주소를 해제하려다 크래시했을 것입니다. 다만 이 클래스는 복사 생성자를 정의하지 않아서 Resource r2 = r1;처럼 복사하면 두 객체가 같은 포인터를 갖고 둘 다 delete[]하는 이중 해제가 생깁니다(Rule of Three). 실제 코드라면 std::unique_ptr<int[]>나 std::vector<int>로 바꾸는 것이 근본적인 해결입니다. allocate()를 두 번 호출하면 이전 메모리가 누수된다는 점도 같이 고쳐야 합니다.
패턴 3: 통계 구조체
struct Statistics {
int count{};
double sum{};
double min{};
double max{};
void add(double value) {
if (count == 0) {
min = max = value;
} else {
if (value < min) min = value;
if (value > max) max = value;
}
sum += value;
++count;
}
double average() const {
return count > 0 ? sum / count : 0.0;
}
};
FAQ
Q1: 값 초기화는 무엇인가요?
A: {} 또는 ()로 초기화하는 방법입니다. 기본 타입은 0, 포인터는 nullptr, 클래스는 기본 생성자가 호출됩니다.
Q2: 기본 초기화와 차이는?
A:
- 기본 초기화 (
int x;): 쓰레기 값 가능 - 값 초기화 (
int x{};): 0 또는 기본값
Q3: 성능 영향은?
A: 0 초기화 비용이 있습니다. 큰 배열은 주의하되, 안전성이 더 중요하면 값 초기화를 사용하세요.
Q4: 언제 사용해야 하나요?
A:
- 안전한 초기화가 필요할 때
- 0 또는 기본값으로 시작해야 할 때
- 쓰레기 값을 방지하고 싶을 때
Q5: 클래스는 어떻게 되나요?
A: 기본 생성자가 호출됩니다. 기본 생성자가 없으면 컴파일 에러입니다. 기본 생성자를 직접 작성하지 않은 클래스(암시적이거나 = default)는 호출 전에 0으로 채워지지만, 직접 작성한 생성자가 있으면 0 채우기 없이 그 생성자만 실행됩니다.
class Widget {
public:
Widget() { std::cout << "Default constructor\n"; }
};
Widget w{}; // "Default constructor" 출력
Q6: {} vs () 차이는?
A: 대부분 동일하지만, {}는 좁히기 변환(narrowing conversion) 을 방지합니다.
int x(3.14); // OK: 3 (소수점 버려짐)
int y{3.14}; // 에러: narrowing conversion
Q7: auto x = T{};와 T x{};는 같은가요?
A: 결과는 같습니다. C++17부터 auto x = T{};는 임시 객체를 만든 뒤 복사하는 것이 아니라 복사 생략이 보장되어 x를 직접 값 초기화합니다. 그래서 복사·이동 생성자가 삭제된 타입(std::mutex 등)에도 쓸 수 있습니다. 규칙 자체는 cppreference의 Value initialization 문서에 사례별로 정리되어 있습니다.
관련 글: Zero Initialization, Default Initialization, List Initialization.
값 초기화는 {}로 변수를 안전하게 0 또는 기본값으로 초기화하는 방법입니다.
같이 보면 좋은 글
- C++ Zero Initialization | “0 초기화” 가이드
- C++ 기본 초기화: 지역 변수가 쓰레기 값을 갖는 이유와 전역·정적 변수와의 차이
- C++11 중괄호 초기화: narrowing 방지, Most Vexing Parse, vector
{10}과 (10)의 차이 - C++ Aggregate Initialization
- C++ call_once
- C++ Copy Initialization
- C++ default와 delete