C++ struct vs class: 기본 접근 제어, POD, C 호환성의 차이

이 글의 핵심

기능이 같다면 무엇을 기준으로 골라야 할까요? Google C++ Style Guide의 사용 규칙을 바탕으로 게임 데이터 구조, 네트워크 프로토콜 메시지, DTO, 설정 관리 사례를 살펴보고, struct에 private을 섞거나 class를 전부 public으로 두는 경우, POD 조건을 깨뜨리는 실수를 짚습니다.

들어가며

C++에서 struct와 class는 기본 접근 제어만 다를 뿐, 기능은 완전히 동일합니다. 비유로 말씀드리면, 문법상 차이는 회의실 문 앞에 ‘공개’ 스티커를 붙이느냐, ‘비공개’가 기본이냐 정도이며, 방 안에서 할 수 있는 일(메서드, 상속 등)은 같습니다. 관례적으로는 데이터 묶음에는 struct, 불변식을 지키는 객체에는 class를 쓰는 경우가 많습니다.

그렇다면 왜 두 키워드가 모두 있을까요? struct는 C에서 물려받은 키워드이고, C++은 C 코드를 그대로 컴파일할 수 있어야 했기 때문에 struct를 없앨 수 없었습니다. Stroustrup은 새 키워드 class를 도입하면서 기본 접근을 private으로 두어 “캡슐화가 기본”이라는 의도를 담았고, struct는 C 호환을 위해 public 기본값을 유지했습니다. 결국 컴파일러에게는 같은 것이고, 두 키워드의 차이는 코드를 읽는 사람에게 보내는 신호로 남았습니다. 이 글은 그 신호를 어떻게 쓰는 것이 좋은지, 그리고 C 호환·memcpy·바이너리 직렬화처럼 실제로 동작 차이가 생기는 “타입의 성질”(POD, 표준 레이아웃)이 무엇인지 설명합니다.


struct vs class 차이

유일한 차이: 기본 접근 제어

항목structclass
기본 접근 제어publicprivate
기본 상속publicprivate
생성자✅✅
소멸자✅✅
멤버 함수✅✅
가상 함수✅✅
상속✅✅
템플릿✅✅

실전 구현

기본 접근 제어

#include <iostream>
// struct: 기본 public
struct Point {
    int x, y;  // public (기본)
};
// class: 기본 private
class Point2 {
    int x, y;  // private (기본)
    
public:
    Point2(int x, int y) : x(x), y(y) {}
    
    int getX() const { return x; }
    int getY() const { return y; }
};
int main() {
    Point p;
    p.x = 10;  // ✅ OK
    p.y = 20;
    
    Point2 p2(10, 20);
    // p2.x = 10;  // ❌ 컴파일 에러: private 멤버
    std::cout << p2.getX() << std::endl;
    
    return 0;
}

상속 기본 접근 제어

#include <iostream>
class Base {
public:
    void foo() {
        std::cout << "Base::foo" << std::endl;
    }
};
// struct: 기본 public 상속
struct DerivedStruct : Base {  // public 상속
};
// class: 기본 private 상속
class DerivedClass : Base {  // private 상속
};
int main() {
    DerivedStruct ds;
    ds.foo();  // ✅ OK (public 상속)
    
    DerivedClass dc;
    // dc.foo();  // ❌ 컴파일 에러 (private 상속)

    return 0;
}

기본 상속 접근은 파생 클래스를 선언할 때 쓴 키워드로 정해집니다. 기저가 class인지 struct인지는 상관없습니다. class DerivedClass : Base에서 public을 빠뜨리면 Base의 public 멤버가 DerivedClass 안에서 private이 되어, 외부에서 dc.foo()를 호출하면 'Base' is not an accessible base of 'DerivedClass' 또는 'foo' is a private member of 'Base' 계열 에러가 납니다. 더 헷갈리는 것은 Base* p = &dc; 같은 업캐스트도 막힌다는 점인데, private 상속은 “is-a” 관계가 아니라 “구현을 빌려 쓴다”는 뜻이기 때문입니다. 이런 혼동을 피하려고 대부분의 스타일 가이드는 상속할 때 접근 지정자를 항상 명시하라고 권합니다.


struct도 class처럼 사용 가능

#include <iostream>
struct MyStruct {
private:  // private 명시 가능
    int x_;
    
public:
    MyStruct(int x) : x_(x) {
        std::cout << "생성자: " << x_ << std::endl;
    }
    
    virtual void foo() {  // 가상 함수
        std::cout << "MyStruct::foo: " << x_ << std::endl;
    }
    
    virtual ~MyStruct() {
        std::cout << "소멸자: " << x_ << std::endl;
    }
};
struct Derived : MyStruct {
    Derived(int x) : MyStruct(x) {}
    
    void foo() override {
        std::cout << "Derived::foo" << std::endl;
    }
};
int main() {
    MyStruct* p = new Derived(42);
    p->foo();  // Derived::foo
    delete p;

    return 0;
}

이 예제는 문법적으로 가능하다는 것을 보여 줄 뿐, 권장하는 스타일은 아닙니다. private 멤버와 가상 함수가 있는 순간 이 타입은 더 이상 “데이터 묶음”이 아니고, 읽는 사람은 struct라는 키워드를 보고 “필드를 직접 읽고 써도 되는 타입”이라고 오해할 수 있습니다. virtual 소멸자를 둔 것은 MyStruct*로 delete하기 때문인데, 이것이 빠지면 Derived의 소멸자가 호출되지 않는 정의되지 않은 동작이 됩니다.


사용 규칙 (Google C++ Style Guide)

struct: 데이터만 담는 수동적 객체

// ✅ struct 사용
struct Point {
    int x, y;
};
struct Color {
    uint8_t r, g, b, a;
};
struct Config {
    std::string host;
    int port;
    bool useSSL;
};

Google C++ Style Guide의 기준은 “struct는 데이터를 담는 수동적 객체에만 쓰고, 모든 필드는 public이며, 필드 사이에 지켜야 할 불변식이 없어야 한다”입니다. Config를 보면 host, port, useSSL 중 어느 하나를 바꿔도 나머지가 깨지지 않습니다. 반대로 “잔액은 음수가 될 수 없다”처럼 필드 값에 규칙이 있으면, 외부에서 필드를 직접 바꿀 수 없게 막아야 하므로 class가 됩니다.

class: 캡슐화와 메서드가 있는 능동적 객체

// ✅ class 사용
class BankAccount {
private:
    double balance_;
    
public:
    BankAccount(double initial) : balance_(initial) {}
    
    void deposit(double amount) {
        if (amount > 0) {
            balance_ += amount;
        }
    }
    
    void withdraw(double amount) {
        if (amount > 0 && balance_ >= amount) {
            balance_ -= amount;
        }
    }
    
    double getBalance() const {
        return balance_;
    }
};

BankAccount가 class여야 하는 이유는 withdraw의 조건문에 있습니다. balance_가 public이면 누구든 account.balance_ = -1000;으로 규칙을 우회할 수 있습니다. 멤버를 private으로 두고 변경 경로를 deposit/withdraw로 한정해야 “잔액은 음수가 아니다”라는 불변식을 클래스 한 곳에서 보장할 수 있습니다. (실제 금액 계산에는 부동소수점 오차 때문에 double 대신 최소 화폐 단위의 정수를 씁니다.)


고급 활용

POD 타입

POD(Plain Old Data)는 C와 호환되는 단순 타입입니다. POD 조건 (C++11):

  • Trivial 생성자
  • Trivial 소멸자
  • Trivial 복사/이동 연산자
  • Standard layout (가상 함수·가상 기저 클래스 없음, 모든 비정적 데이터 멤버의 접근 지정이 같음 등)
#include <iostream>
#include <type_traits>
// ✅ POD
struct Point {
    int x, y;
};
static_assert(std::is_pod_v<Point>);  // true
// ❌ 비POD (생성자 있음)
struct Point2 {
    int x, y;
    Point2(int x, int y) : x(x), y(y) {}
};
static_assert(!std::is_pod_v<Point2>);  // is_pod_v<Point2>는 false
int main() {
    std::cout << "Point is POD: " << std::is_pod_v<Point> << std::endl;
    std::cout << "Point2 is POD: " << std::is_pod_v<Point2> << std::endl;

    return 0;
}

POD는 사실 두 가지 독립된 성질의 합입니다. 트리비얼(trivial)은 “생성·복사·소멸 때 특별한 코드가 실행되지 않는다”는 뜻이라 memcpy로 복사해도 안전한지를 결정하고, 표준 레이아웃(standard layout)은 “멤버가 C와 같은 규칙으로 메모리에 배치된다”는 뜻이라 C와 구조체를 공유할 수 있는지를 결정합니다. Point2는 사용자 정의 생성자 때문에 기본 생성자가 트리비얼하지 않아 POD가 아니지만, 여전히 표준 레이아웃이고 트리비얼하게 복사 가능(std::is_trivially_copyable_v<Point2>가 true)합니다. 즉 생성자를 추가해도 C와의 메모리 배치 호환성이나 memcpy 가능성은 깨지지 않습니다.

이 구분이 헷갈려서 C++20은 std::is_pod를 폐기 예정으로 바꿨습니다. C++20 모드에서 위 코드를 컴파일하면 deprecated 경고가 나오므로, 새 코드에서는 목적에 맞게 std::is_trivially_copyable_v(memcpy·바이트 직렬화)나 std::is_standard_layout_v(C 호환 레이아웃)를 쓰는 것이 좋습니다.


C 호환성

// common.h
#ifdef __cplusplus
extern "C" {
#endif
struct Point {
    int x, y;
};
void processPoint(struct Point* p);
#ifdef __cplusplus
}
#endif
// common.cpp
#include "common.h"
#include <iostream>
void processPoint(struct Point* p) {
    std::cout << "Point: (" << p->x << ", " << p->y << ")" << std::endl;
}
// main.cpp
#include "common.h"
int main() {
    Point p = {10, 20};  // C++에서는 struct 키워드 생략 가능
    processPoint(&p);

    return 0;
}

extern "C"는 구조체가 아니라 함수 이름에 영향을 줍니다. C++ 컴파일러는 오버로딩을 지원하려고 함수 이름에 인자 타입 정보를 붙여(name mangling) _Z12processPointP5Point 같은 심볼을 만드는데, C 컴파일러는 그냥 processPoint를 찾습니다. extern "C"가 빠지면 C 쪽 링크 단계에서 undefined reference to 'processPoint' 에러가 납니다. 헤더를 C와 C++이 함께 include하므로 #ifdef __cplusplus 가드로 감싸는 것이 관례이고, 구조체 선언 자체는 C 문법(생성자·멤버 함수·기본 멤버 초기화 없음) 안에서만 작성해야 C 컴파일러가 읽을 수 있습니다.


memcpy 가능 (POD)

#include <cstring>
#include <iostream>
struct Point {
    int x, y;
};
int main() {
    Point p1 = {10, 20};
    Point p2;
    
    std::memcpy(&p2, &p1, sizeof(Point));  // ✅ POD는 memcpy 가능
    
    std::cout << "p2: (" << p2.x << ", " << p2.y << ")" << std::endl;

    return 0;
}

정확히는 memcpy의 조건은 POD가 아니라 트리비얼하게 복사 가능(trivially copyable)한 것입니다. std::string이나 std::vector 멤버가 있는 구조체를 memcpy하면 내부 힙 포인터까지 그대로 복사되어 두 객체가 같은 메모리를 가리키게 되고, 둘 다 소멸할 때 이중 해제로 죽습니다. 템플릿 코드에서 memcpy 최적화를 쓴다면 static_assert(std::is_trivially_copyable_v<T>)로 조건을 걸어 두면, 누군가 나중에 구조체에 std::string 멤버를 추가했을 때 컴파일 단계에서 막을 수 있습니다.


성능 비교

struct vs class

#include <chrono>
#include <iostream>
struct PointStruct {
    int x, y;
};
class PointClass {
public:
    int x, y;
};
int main() {
    auto start1 = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000000; ++i) {
        PointStruct p = {i, i};
        int sum = p.x + p.y;
    }
    auto end1 = std::chrono::high_resolution_clock::now();
    auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
    
    auto start2 = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 10000000; ++i) {
        PointClass p = {i, i};
        int sum = p.x + p.y;
    }
    auto end2 = std::chrono::high_resolution_clock::now();
    auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
    
    std::cout << "struct: " << time1 << "ms" << std::endl;
    std::cout << "class: " << time2 << "ms" << std::endl;
    
    return 0;
}

결론: 성능 차이 없음. 두 타입은 멤버와 접근 지정이 같으므로 컴파일러가 만드는 코드도 완전히 같습니다. 사실 이 벤치마크는 측정 자체가 의미 없는데, sum을 어디에도 쓰지 않으므로 최적화 빌드(-O2 이상)에서는 두 루프가 통째로 제거되어 거의 0ms가 나옵니다. 이렇게 결과를 사용하지 않는 마이크로벤치마크는 “아무것도 하지 않는 코드”를 재는 흔한 실수입니다. struct와 class의 선택은 성능이 아니라 의도 표현의 문제입니다.


실무 사례

사례 1: 게임 엔진 - 데이터 구조

#include <iostream>
#include <vector>
// struct: 데이터만
struct Transform {
    float x, y, z;
    float rotX, rotY, rotZ;
    float scaleX, scaleY, scaleZ;
};
// class: 캡슐화
class Entity {
private:
    int id_;
    Transform transform_;
    bool active_;
    
public:
    Entity(int id) : id_(id), transform_{0, 0, 0, 0, 0, 0, 1, 1, 1}, active_(true) {}
    
    void setPosition(float x, float y, float z) {
        transform_.x = x;
        transform_.y = y;
        transform_.z = z;
    }
    
    Transform getTransform() const {
        return transform_;
    }
    
    bool isActive() const {
        return active_;
    }
};
int main() {
    Entity entity(1);
    entity.setPosition(10.0f, 20.0f, 30.0f);
    
    Transform t = entity.getTransform();
    std::cout << "Position: (" << t.x << ", " << t.y << ", " << t.z << ")" << std::endl;
    
    return 0;
}

사례 2: 네트워크 - 프로토콜 메시지

#include <cstring>
#include <iostream>
// struct: 네트워크 메시지 (POD)
struct Message {
    uint32_t type;
    uint32_t length;
    char data[256];
};
// class: 메시지 핸들러
class MessageHandler {
public:
    void handleMessage(const Message& msg) {
        std::cout << "Type: " << msg.type << ", Length: " << msg.length << std::endl;
        std::cout << "Data: " << msg.data << std::endl;
    }
};
int main() {
    Message msg;
    msg.type = 1;
    msg.length = 5;
    std::strncpy(msg.data, "Hello", 255);
    msg.data[255] = '\0';
    
    MessageHandler handler;
    handler.handleMessage(msg);

    return 0;
}

이런 고정 크기 메시지 구조체를 소켓으로 그대로 send(&msg, sizeof(msg))하는 코드를 흔히 보는데, 같은 프로그램끼리만 통신할 때는 동작하다가 다른 플랫폼과 붙는 순간 깨집니다. 원인은 세 가지입니다. 첫째 엔디언: x86은 리틀 엔디언이고 네트워크 바이트 순서는 빅 엔디언이라 type과 length는 htonl/ntohl로 변환해야 합니다. 둘째 패딩: 컴파일러는 정렬을 위해 멤버 사이와 끝에 빈 바이트를 넣을 수 있고, 그 크기는 컴파일러와 옵션에 따라 다릅니다. 셋째 신뢰할 수 없는 입력: 수신한 length가 256보다 크거나 data가 널 문자로 끝나지 않을 수 있어, 위 handleMessage처럼 msg.data를 그대로 출력하면 버퍼를 넘어 읽습니다. 그래서 실무 프로토콜은 필드를 하나씩 바이트 순서를 맞춰 직렬화하거나 Protocol Buffers 같은 직렬화 형식을 씁니다. 이 예제에 uint32_t가 쓰였으므로 <cstdint> include도 필요합니다.


사례 3: 데이터베이스 - DTO

#include <iostream>
#include <string>
#include <vector>
// struct: DTO (Data Transfer Object)
struct UserDTO {
    int id;
    std::string name;
    std::string email;
    int age;
};
// class: 서비스
class UserService {
public:
    UserDTO getUserById(int id) {
        // 데이터베이스 조회
        return {id, "홍길동", "[email protected]", 30};
    }
    
    void saveUser(const UserDTO& user) {
        std::cout << "저장: " << user.name << std::endl;
    }
};
int main() {
    UserService service;
    
    UserDTO user = service.getUserById(1);
    std::cout << "이름: " << user.name << std::endl;
    
    user.age = 31;
    service.saveUser(user);

    return 0;
}

UserDTO는 std::string 멤버가 있어 POD가 아니지만 여전히 좋은 struct 사용 예입니다. struct를 고르는 기준은 POD 여부가 아니라 “필드 사이에 불변식이 없는 데이터 묶음인가”입니다. return {id, "홍길동", ...};처럼 중괄호로 바로 만들 수 있는 것은 이 타입이 생성자가 없는 집합체(aggregate)이기 때문인데, 필드 순서를 바꾸면 이런 초기화 코드가 조용히 엉뚱한 필드에 값을 넣습니다(name과 email이 둘 다 문자열이라 컴파일 에러도 나지 않습니다). C++20의 지정 초기화({.id = id, .name = "홍길동"})를 쓰면 이 위험을 줄일 수 있습니다.


사례 4: 설정 관리

#include <iostream>
#include <string>
// struct: 설정 데이터
struct ServerConfig {
    std::string host;
    int port;
    int maxConnections;
    bool useSSL;
};
// class: 설정 관리자
class ConfigManager {
private:
    ServerConfig config_;
    
public:
    ConfigManager(const ServerConfig& config) : config_(config) {}
    
    void validate() {
        if (config_.port < 1 || config_.port > 65535) {
            throw std::invalid_argument("잘못된 포트 번호");
        }
        
        if (config_.maxConnections < 1) {
            throw std::invalid_argument("잘못된 최대 연결 수");
        }
    }
    
    ServerConfig getConfig() const {
        return config_;
    }
};
int main() {
    ServerConfig config = {"localhost", 8080, 100, false};
    
    ConfigManager manager(config);
    
    try {
        manager.validate();
        std::cout << "설정 유효" << std::endl;
    } catch (const std::exception& e) {
        std::cerr << e.what() << std::endl;
    }
    
    return 0;
}

설정 데이터(ServerConfig)와 검증 로직(ConfigManager)을 나눈 구조는 흔하지만, validate()를 호출하지 않아도 ConfigManager가 만들어진다는 약점이 있습니다. 생성자 안에서 검증하고 실패하면 예외를 던지게 하면 “검증되지 않은 설정을 가진 매니저”라는 상태 자체가 사라집니다. 불변식을 생성 시점에 확정하는 것이 class로 캡슐화하는 가장 큰 이유입니다. 예제에서 std::invalid_argument를 쓰려면 <stdexcept>도 include해야 합니다.


트러블슈팅

문제 1: struct에 private 멤버

증상: 의도와 다른 접근 제어

// ❌ struct에 private (혼란스러움)
struct Point {
private:  // struct인데 private?
    int x, y;
    
public:
    Point(int x, int y) : x(x), y(y) {}
    int getX() const { return x; }
};
// ✅ class 사용
class Point {
private:
    int x, y;
    
public:
    Point(int x, int y) : x(x), y(y) {}
    int getX() const { return x; }
};

문제 2: class에 모두 public

증상: 의도와 다른 접근 제어

// ❌ class에 모두 public (혼란스러움)
class Point {
public:
    int x, y;  // class인데 public?
};
// ✅ struct 사용
struct Point {
    int x, y;
};

문제 3: POD 조건 위반

증상: std::is_pod 검사 실패 (C와의 레이아웃 호환 자체는 표준 레이아웃 조건으로 판단)

// ❌ 비POD (생성자 있음)
struct Point {
    int x, y;
    Point(int x, int y) : x(x), y(y) {}
};
static_assert(!std::is_pod_v<Point>);  // false
// ✅ POD
struct Point2 {
    int x, y;
};
static_assert(std::is_pod_v<Point2>);  // true
// C 호환
extern "C" {
    void processPoint(Point2* p);
}

문제 4: 상속 접근 제어 혼란

증상: 의도와 다른 상속

class Base {
public:
    void foo() {}
};
// ❌ struct인데 private 상속 (명시 필요)
struct Derived : private Base {  // private 명시
};
Derived d;
// d.foo();  // ❌ 컴파일 에러 (private 상속)
// ✅ struct는 기본 public 상속
struct Derived2 : Base {  // public 상속 (기본)
};
Derived2 d2;
d2.foo();  // ✅ OK

마무리

struct와 class의 차이는 기본 접근 제어뿐입니다.

핵심 요약

  1. struct vs class
    • struct: 기본 public
    • class: 기본 private
    • 기능은 완전히 동일
  2. 사용 규칙
    • 데이터만: struct
    • 메서드 + 캡슐화: class
    • POD 필요: struct
  3. POD 타입
    • C 호환
    • memcpy 가능
    • 바이너리 직렬화 가능
  4. 성능
    • struct와 class는 성능 차이 없음
    • 캡슐화가 성능에 영향 없음

선택 가이드

상황권장이유
데이터만struct의도 명확
캡슐화 필요class불변식 보호
C 호환struct (POD)바이너리 호환
메서드 많음class관례
간단한 값 타입struct간결

코드 예제 치트시트

// struct: 데이터만
struct Point {
    int x, y;
};
// class: 캡슐화
class BankAccount {
private:
    double balance_;
    
public:
    BankAccount(double initial) : balance_(initial) {}
    void deposit(double amount) { balance_ += amount; }
    double getBalance() const { return balance_; }
};
// POD 확인
static_assert(std::is_pod_v<Point>);
// C 호환
extern "C" {
    void processPoint(Point* p);
}

다음 단계

참고 자료

  • “Effective C++” - Scott Meyers
  • “C++ Primer” - Stanley Lippman
  • Google C++ Style Guide: https://google.github.io/styleguide/cppguide.html 한 줄 정리: struct는 데이터 컨테이너, class는 캡슐화된 객체로 사용하며, 기능은 완전히 동일하지만 의도를 명확히 표현하는 것이 중요합니다.

자주 묻는 질문 (FAQ)

Q. struct와 class는 상속할 때도 기본 접근 지정이 다른가요?

A. 네, 다릅니다. 접근 지정자를 생략하면 struct Derived : Base는 public 상속, class Derived : Base는 private 상속이 되는데, 이 기준은 파생 클래스를 struct로 선언했는지 class로 선언했는지에 따라 정해집니다. class로 선언하면서 public을 빼먹으면 Base의 public 멤버를 외부에서 호출할 수 없어 컴파일 에러가 납니다. 혼동을 피하려면 상속할 때 항상 public이나 private을 명시하는 것이 좋습니다.


같이 보면 좋은 글