C++ 집합체 초기화: 집합체 조건, 중괄호 초기화, C++20 지정 초기화 순서 규칙

이 글의 핵심

생성자를 하나 추가하거나 상속을 넣는 순간 중괄호 초기화가 컴파일되지 않는 이유는 집합체 조건이 깨졌기 때문입니다. = default·= delete 생성자에 대한 C++20의 규칙 변화, 멤버 순서와 지정 초기화 순서가 어긋날 때의 오류, 값·리스트 초기화와의 차이를 짚어 구조체 API를 안전하게 설계할 수 있게 합니다.

집합체 초기화란?

집합체(aggregate) 는 사용자 정의 생성자·비공개 멤버·가상 등이 없는 구조체·배열이며, 중괄호 {}로 한 번에 초기화할 수 있습니다. 리스트 초기화, 값 초기화, 지정 초기화와 함께 보면 초기화 규칙을 정리하기 좋습니다.

// 타입 정의
struct Point {
    int x;
    int y;
};

Point p = {10, 20};  // 집합체 초기화

왜 필요한가?:

  • 간결성: 생성자 없이 멤버를 직접 초기화
  • 안전성: 모든 멤버를 명시적으로 초기화
  • 가독성: 구조체 정의와 초기화가 명확
// ❌ 생성자 방식: 코드가 길어짐
// 타입 정의
struct Point {
    int x, y;
    Point(int x_, int y_) : x(x_), y(y_) {}
};
Point p(10, 20);

// ✅ 집합체 초기화: 간결
struct Point {
    int x, y;
};
Point p = {10, 20};

집합체 조건

// ✅ 집합체
struct Aggregate {
    int x;
    double y;
};

// ❌ 비집합체
struct NonAggregate {
    int x;
private:
    int y;  // 비공개 멤버
};

집합체 조건 상세:

C++17/C++20 기준으로 다음 조건을 모두 만족해야 집합체입니다:

  1. 배열 또는 클래스 타입 (구조체, 클래스, 공용체)
  2. 생성자 조건 — 표준 버전마다 다릅니다
    • C++17까지: 사용자 제공(user-provided) 생성자가 없을 것. 첫 선언에서 = default나 = delete로 쓴 생성자는 “사용자 제공”이 아니어서 허용됩니다.
    • C++20부터: 사용자 선언(user-declared) 생성자가 없을 것. = default, = delete를 포함해 생성자를 하나라도 선언하면 집합체가 아닙니다.
  3. 비공개/보호 비정적 데이터 멤버 없음 (모든 멤버가 public)
  4. 가상 함수 없음
  5. 가상/비공개/보호 기반 클래스 없음 (C++17부터 public 상속은 OK)
// ✅ 집합체 예시
struct Aggregate1 {
    int x;
    double y;
};

struct Aggregate2 : Aggregate1 {  // C++17: public 상속 OK
    int z;
};

// ❌ 비집합체 예시
struct NonAggregate1 {
    int x;
    NonAggregate1() = default;  // C++20부터 비집합체 (C++17까지는 집합체!)
};

struct NonAggregate2 {
    int x;
private:
    int y;  // 비공개 멤버
};

struct NonAggregate3 {
    virtual void func() {}  // 가상 함수
};

집합체 판별 방법:

#include <type_traits>

struct Point { int x, y; };

static_assert(std::is_aggregate_v<Point>);  // true

// 사용 예시
template<typename T>
void initIfAggregate(T& obj) {
    if constexpr (std::is_aggregate_v<T>) {
        obj = {};  // 집합체 초기화
    }
}

실전 예시

예시 1: 구조체 초기화

struct Person {
    std::string name;
    int age;
    double height;
};

// 집합체 초기화
Person p1 = {"Alice", 30, 165.5};
Person p2{"Bob", 25, 175.0};

// 일부 생략
Person p3 = {"Charlie", 35};  // height는 0.0
Person p4 = {"David"};         // age는 0, height는 0.0

위치 기반 집합체 초기화의 가장 큰 약점은 멤버 순서에 의존한다는 점입니다. 나중에 누군가 Person의 name과 age 사이에 int id;를 끼워 넣으면 {"Alice", 30, 165.5}는 에러 없이 id = 30, age = 165(double에서 int로의 축소 변환이 아니라… 실제로는 중괄호라 축소 변환 에러)처럼 해석이 바뀝니다. 이 예에서는 165.5가 int로 줄어드는 축소 변환 때문에 운 좋게 컴파일 에러가 나지만, 새 멤버와 기존 멤버의 타입이 같다면 값이 한 칸씩 밀린 채 조용히 컴파일됩니다. 멤버가 서너 개를 넘는 구조체라면 C++20 지정 초기화를 쓰거나, GCC·Clang의 -Wmissing-field-initializers(-Wextra에 포함) 경고로 일부만 초기화한 곳을 찾아 두는 편이 안전합니다.

예시 2: 배열 초기화

int arr1[5] = {1, 2, 3, 4, 5};
int arr2[5] = {1, 2};  // 나머지는 0

// 2차원 배열
int matrix[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

예시 3: 중첩 구조체

struct Address {
    std::string city;
    int zipCode;
};

struct Person {
    std::string name;
    Address address;
};

Person p = {
    "Alice",
    {"Seoul", 12345}
};

std::cout << p.name << std::endl;
std::cout << p.address.city << std::endl;

예시 4: C++20 지정 초기화

struct Config {
    int width = 800;
    int height = 600;
    bool fullscreen = false;
};

// C++20: 지정 초기화
Config cfg = {
    .width = 1920,
    .height = 1080,
    .fullscreen = true
};

다른 초기화 방식과 비교 (집합체 관점)

집합체 초기화는 보통 중괄호 리스트로 이루어지며, 리스트 초기화·값 초기화와 겹칩니다.

문법의미 (집합체 S)
S a;기본 초기화 — 지역이면 멤버가 쓰레기일 수 있음
S a{};값 초기화 — 멤버를 0·빈 값으로 맞추는 쪽(타입에 따라 0 초기화 포함)
S a = {1, 2};집합체 초기화(복사 리스트 형태)
S a{1, 2};집합체 + 리스트 초기화

비집합체(사용자 정의 생성자가 있는 클래스)는 S a{...}가 생성자 호출로 가므로, “집합체 초기화”라는 용어는 엄격히는 집합체 타입에만 해당합니다.

C++20부터는 괄호로도 집합체를 초기화할 수 있습니다(P0960). Point p(10, 20);이 생성자 없이도 컴파일되며, 이 덕분에 std::make_unique<Point>(10, 20)이나 vector<Point>::emplace_back(10, 20)처럼 내부에서 괄호로 객체를 만드는 표준 함수에 집합체를 그대로 쓸 수 있게 되었습니다(C++17까지는 no matching function for call to 'Point::Point(int, int)' 에러가 났습니다). 다만 괄호 형태는 중괄호와 달리 축소 변환을 허용하고 중첩 중괄호 생략(brace elision)도 하지 않으므로, 직접 쓰는 코드에서는 여전히 중괄호가 더 안전합니다.

구조체 초기화 실전 패턴

  • 한 줄에 의미 부여: API 응답·설정값을 struct로 묶고 return { ok, msg };처럼 쓰면 필드 이름이 문서 역할을 합니다.
  • 기본 멤버 초기화 + {}: C++11 이후 멤버에 = 0 또는 = {}를 두며, 전체는 Config c{}로 통일하면 “어떤 필드도 빠뜨리지 않았는가”를 코드 리뷰하기 쉽습니다.
  • 중첩: 바깥 구조체 초기화 시 안쪽은 또 다른 집합체로 {...}를 한 단계 더 두면 됩니다(이미 예시 3).

C++20 지정 초기화(designated initializers) 심화

C++20에서는 C99 스타일의 .멤버 = 값 문법이 집합체 초기화에 들어옵니다. 선언 순서와 동일한 순서로만 써야 하며, 건너뛰면 생략된 멤버는 값 초기화됩니다.

struct Point { int x; int y; int z = 0; };

Point p = {.x = 1, .y = 2};       // z는 기본값 0 또는 값 초기화 규칙
// Point q = {.y = 2, .x = 1};   // 순서 어긋남 → C++20에서 ill-formed

장점

  • 필드 의미가 이름으로 드러나 큰 struct에서 실수(인자 순서 바꿈)가 줄어듭니다.
  • 리스트 초기화와 같이 쓰면 가독성이 좋아집니다.

주의

  • C와의 호환: C99의 지정 초기화와 달리 C++20은 순서를 바꿀 수 없고, {.x = 1, 2}처럼 지정자와 위치 기반을 섞을 수 없으며, .inner.a = 1 같은 중첩 지정자와 [3] = 5 같은 배열 지정자도 지원하지 않습니다. C 헤더의 초기화 코드를 C++로 옮길 때 이 부분에서 에러가 많이 납니다. 중첩 구조체는 .inner = {.a = 1}처럼 중괄호를 한 겹 더 써서 표현합니다.
  • 비집합체에서는 집합 규칙 밖이므로, 생성자를 쓰는 타입은 생성자 인자로 설계하는 편이 맞습니다.

기본값

struct Data {
    int x = 10;      // 기본값
    int y = 20;
    int z = 30;
};

Data d1 = {};           // {10, 20, 30}
Data d2 = {100};        // {100, 20, 30}
Data d3 = {100, 200};   // {100, 200, 30}

기본값 동작 원리:

집합체 초기화 시 일부 멤버를 생략하면, 생략된 멤버는:

  1. 멤버 기본값이 있으면 그 값 사용
  2. 멤버 기본값이 없으면 값 초기화 (0, nullptr, false 등)
// 타입 정의
struct Config {
    int width = 800;    // 기본값 있음
    int height = 600;   // 기본값 있음
    bool fullscreen;    // 기본값 없음
};

Config c1 = {};                    // {800, 600, false} (모두 기본값)
Config c2 = {1920};                // {1920, 600, false} (width만 지정)
Config c3 = {1920, 1080};          // {1920, 1080, false}
Config c4 = {1920, 1080, true};    // {1920, 1080, true} (모두 지정)

실무 활용: 옵셔널 파라미터:

struct HttpRequest {
    std::string url;
    std::string method = "GET";  // 기본값
    int timeout = 30;            // 기본값
    bool followRedirects = true; // 기본값
};

// 필수 파라미터만 지정
HttpRequest req1 = {"https://example.com"};
// {url: "https://example.com", method: "GET", timeout: 30, followRedirects: true}

// 일부 파라미터 지정
HttpRequest req2 = {"https://example.com", "POST", 60};
// {url: "https://example.com", method: "POST", timeout: 60, followRedirects: true}

자주 발생하는 문제

문제 1: 순서

struct Point {
    int x;
    int y;
};

// ❌ 순서 바뀜
// Point p = {.y = 20, .x = 10};  // C++20에서 에러

// ✅ 순서 유지
Point p = {.x = 10, .y = 20};

문제 2: 생성자 있는 클래스

// ❌ 비집합체
struct NonAggregate {
    int x;
    NonAggregate(int v) : x(v) {}
};

// NonAggregate obj = {10};  // 에러

// ✅ 생성자 호출
NonAggregate obj(10);

문제 3: 상속

// C++17: 상속된 집합체도 가능
struct Base {
    int x;
};

struct Derived : Base {
    int y;
};

Derived d = {{10}, 20};  // Base{10}, y=20

// ❌ private/protected 상속이면 집합체가 아님
struct Hidden : private Base {
    int y;
};
// Hidden h = {{10}, 20};  // 에러
static_assert(!std::is_aggregate_v<Hidden>);

베이스 클래스의 멤버는 중괄호 한 겹을 더 씌워 먼저 초기화합니다({{10}, 20}). 베이스가 여러 개면 선언 순서대로 각각 한 겹씩 들어가고, 바깥 중괄호를 생략하는 brace elision도 허용되지만(Derived d = {10, 20};) 베이스가 여러 개가 되는 순간 읽기 어려워지므로 명시하는 편이 낫습니다.

문제 3-1: = default / = delete 생성자와 C++20의 변화

struct Config {
    Config() = delete;   // "기본 생성은 막겠다"는 의도
    int port;
};

Config c{};  // C++17: 컴파일됨 (!), C++20: error: use of deleted function 'Config::Config()'

C++17까지는 = delete한 기본 생성자가 “사용자 제공”이 아니므로 Config가 여전히 집합체였고, Config c{};는 생성자를 부르지 않고 집합체 초기화로 통과했습니다. 기본 생성을 막으려고 쓴 코드가 아무 경고 없이 무시되는 셈입니다(GCC 10에서 -std=c++17은 통과, -std=c++20은 위 에러를 확인). 이 구멍 때문에 C++20은 조건을 “사용자 선언 생성자 없음”으로 바꿨고(P1008), 그 결과 반대 방향의 문제가 생겼습니다. Point() = default;처럼 생성자를 명시적으로 기본값으로 선언해 둔 구조체는 C++20으로 올리는 순간 집합체가 아니게 되어, 기존의 Point p{1, 2};가 “일치하는 생성자 없음” 에러로 깨집니다. 표준 버전을 올릴 때 가장 흔하게 부딪히는 소스 호환성 문제 중 하나라서, 집합체로 쓸 구조체에는 생성자를 아예 선언하지 않는 것이 가장 안전합니다. 기본값이 필요하면 생성자 대신 멤버 기본 초기화(int port = 8080;)를 쓰면 C++14부터 집합체 조건을 깨지 않습니다.

문제 4: 배열 크기

// ✅ 크기 명시
int arr1[5] = {1, 2, 3};

// ✅ 크기 추론
int arr2[] = {1, 2, 3};  // 크기 3

// ❌ 초과
// int arr3[2] = {1, 2, 3};  // 에러

초기화 방식 비교

struct Point {
    int x, y;
};

// 집합체 초기화
Point p1 = {10, 20};
Point p2{10, 20};

// 생성자가 있으면 생성자 호출, 없으면 C++20부터 괄호 집합체 초기화
Point p3(10, 20);

// 기본 초기화
Point p4;  // x, y는 쓰레기 값

초기화 방법 비교표:

방법문법집합체비집합체특징
집합체 초기화Point p = {10, 20};✅❌멤버 직접 초기화
리스트 초기화Point p{10, 20};✅✅좁히기 방지
직접 초기화Point p(10, 20);C++20부터 ✅✅생성자 호출, 축소 변환 허용
기본 초기화Point p;⚠️⚠️쓰레기 값 가능
값 초기화Point p{};✅✅0 또는 기본값

실무 권장:

// ✅ 집합체: 중괄호 초기화
struct Config {
    int width, height;
};
Config cfg = {800, 600};

// ✅ 비집합체: 생성자 호출
class Widget {
    int value_;
public:
    Widget(int v) : value_(v) {}
};
Widget w(10);

// ✅ 안전한 초기화: 값 초기화
Config cfg2{};  // {0, 0}

실무 패턴

패턴 1: 설정 구조체

struct ServerConfig {
    std::string host = "localhost";
    int port = 8080;
    int maxConnections = 100;
    bool enableLogging = true;
};

// 기본값 사용
ServerConfig cfg1 = {};

// 일부만 변경
ServerConfig cfg2 = {"0.0.0.0", 3000};

// C++20: 지정 초기화
ServerConfig cfg3 = {
    .host = "192.168.1.1",
    .port = 9000
};

패턴 2: 반환값 최적화

struct Result {
    bool success;
    std::string message;
    int errorCode;
};

Result processData(const std::string& data) {
    if (data.empty()) {
        return {false, "Empty data", 1};
    }

    // 처리 로직
    return {true, "Success", 0};
}

패턴 3: 테스트 데이터

struct TestCase {
    std::string input;
    std::string expected;
    bool shouldPass;
};

std::vector<TestCase> tests = {
    {"hello", "HELLO", true},
    {"world", "WORLD", true},
    {"", "", false}
};

흔한 실수와 함정 (추가)

  • std::string이 있는 집합체를 {0}만으로… 멤버가 std::string이면 전부 0으로 두려면 각 멤버 규칙에 맞는 값 초기화가 필요합니다. S{}가 일반적으로 안전합니다. memset으로 0 채우기는 절대 하지 마세요.
  • 지정 초기화와 순서 선언 순서와 다르게 쓰면 C++20에서 에러입니다. 리팩터링으로 멤버 순서를 바꾸면 지정 초기화 목록도 함께 고쳐야 합니다.
  • 집합체가 아니게 된 뒤 virtual이나 사용자 정의 생성자를 추가하면 갑자기 T a = {1,2,3}가 깨지거나 생성자 경로로 바뀝니다. 공개 API의 struct는 변경 시 영향 범위를 넓게 봐야 합니다.

FAQ

Q1: 집합체 조건은 무엇인가요?

A:

  • 모든 비정적 데이터 멤버가 public
  • C++17까지는 사용자 제공 생성자 없음, C++20부터는 사용자 선언 생성자 없음(= default도 불가)
  • 가상 함수 없음
  • 가상·비공개·보호 기반 클래스 없음 (C++17부터 public 상속 허용)

Q2: 일부 멤버를 생략할 수 있나요?

A: 가능합니다. 생략된 멤버는 멤버 기본값 또는 값 초기화(0, nullptr 등)됩니다.

struct Point { int x, y, z = 100; };
Point p = {10};  // {10, 0, 100}

Q3: 초기화 순서는?

A: 선언 순서대로 초기화됩니다. C++20 지정 초기화도 선언 순서를 따라야 합니다.

Q4: C++20에서 어떤 변화가 있나요?

A: 세 가지가 바뀌었습니다. 지정 초기화(designated initializers)가 추가되었고, 괄호로도 집합체를 초기화할 수 있게 되었으며, = default/= delete 생성자를 선언한 타입은 더 이상 집합체가 아닙니다.

struct Point { int x, y; };
Point p = {.x = 10, .y = 20};  // C++20 지정 초기화
Point q(10, 20);               // C++20 괄호 집합체 초기화

Q5: 배열 크기는 어떻게 결정되나요?

A:

  • 명시적: int arr[5] = {1, 2, 3}; (크기 5)
  • 추론: int arr[] = {1, 2, 3}; (크기 3)

Q6: 중첩 구조체는 어떻게 초기화하나요?

A: 중괄호를 중첩하여 초기화합니다.

struct Inner { int a, b; };
struct Outer { Inner inner; int c; };

Outer o = {{10, 20}, 30};  // inner={10, 20}, c=30

관련 글: 리스트 초기화, 값 초기화, 지정 초기화, 기본 초기화.


같이 보면 좋은 글