구조체 패딩과 멤버 정렬 — 정렬 기준은 버스 폭이 아니라 멤버 타입 크기

NOTE

구조체 안에 컴파일러가 몰래 끼워 넣는 패딩(Padding) 이 왜 생기는지, 멤버 순서를 바꾸면 sizeof가 왜 달라지는지, 그리고 “CPU가 8바이트씩 읽는다”는 말과 “int는 4의 배수 주소”라는 규칙이 어떻게 이어지는지 정리한다. 실무에서는 IPC·소켓 전문 구조체의 크기 불일치, 패딩 쓰레기값으로 인한 memcmp·해시 버그로 이어지는 주제다.


0. 헷갈렸던 지점 한 줄 정리

헷갈린 질문답
int와 char 위치만 바꿨는데 왜 크기가 달라지지?패딩은 “앞 멤버가 끝난 위치”와 “다음 멤버가 요구하는 주소 배수”의 차이로 생긴다. 순서가 바뀌면 그 차이가 달라진다.
CPU가 8바이트씩 읽는다면 구조체도 8바이트 단위로 맞추는 거 아닌가?아니다. 정렬 기준은 버스 폭이 아니라 각 멤버 타입 자신의 크기다 (char=1, short=2, int=4, double·포인터=8).
그럼 CPU 읽기 단위와 구조체 정렬은 별개인가?별개가 아니라 원인과 수단이다. CPU가 “자기 크기 배수 주소에 있는 데이터”를 한 번에 가장 싸게 읽으므로, 컴파일러가 그 조건을 만족하도록 패딩으로 배치를 맞춘다.

1. 두 개념을 분리해서 보기

“읽는 단위”와 “정렬 규칙”은 서로 다른 층위의 개념이다.

구분하드웨어 읽기 단위정렬 요구사항 (Alignment Requirement)
누가 정하나CPU·메모리 버스·캐시 구조ABI(규약) + 컴파일러
크기64비트 시스템이면 8바이트 버스, 캐시 라인은 보통 64바이트타입마다 다름 (1 / 2 / 4 / 8 …)
의미메모리에서 한 번에 퍼 올리는 물리적 덩어리”이 타입 값은 N의 배수 주소에 놓아라”
위반하면두 덩어리에 걸쳐 두 번 읽고 조합컴파일러가 패딩을 넣어 애초에 위반하지 않게 배치
메모리 주소:  0    4    8    12   16
              ├────┼────┼────┼────┤
int  (4B)  →  0, 4, 8, 12 ... 4의 배수에서만 시작
char (1B)  →  아무 주소나 가능
double(8B) →  0, 8, 16 ... 8의 배수에서만 시작

만약 모든 멤버를 “8의 배수”로 강제하면 char 10개짜리 구조체가 80바이트가 되어 버린다. 그래서 타입별 자연 정렬(Natural Alignment) 이라는 타협을 쓴다: “자기 크기의 배수 주소에만 있으면, 하드웨어가 그 값을 한 번에 읽을 수 있다.”

C/Java 비교

  • C: 구조체 멤버를 선언 순서대로 배치하고, 정렬 때문에 생기는 빈틈은 컴파일러가 패딩으로 채운다. sizeof, offsetof로 직접 확인 가능.
  • Java: 객체 필드의 실제 배치는 JVM이 정한다(필드 재배치도 함). 개발자가 sizeof나 오프셋을 볼 수 없고 볼 필요도 없다. 그래서 Java에서는 이 문제를 겪지 않지만, C로 만든 바이너리 전문을 다룰 때(JNI, 소켓)는 같은 문제가 다시 나타난다.

2. 컴파일러의 패딩 규칙 3가지

  1. 멤버 배치 규칙: 각 멤버는 자기 크기(정확히는 자기 정렬 요구사항)의 배수 오프셋에 놓는다. 앞 멤버가 끝난 지점이 배수가 아니면 그 사이를 패딩으로 채운다.
  2. 구조체 정렬: 구조체 자체의 정렬 요구사항은 가장 큰 멤버의 정렬 요구사항이다.
  3. 꼬리 패딩(Tail Padding): 구조체 전체 크기는 구조체 정렬 요구사항의 배수가 되도록 끝에 패딩을 덧붙인다.

규칙 3의 이유는 배열이다. struct S arr[2]에서 arr[1]도 올바르게 정렬되어야 하므로, sizeof(struct S) 자체가 정렬 단위의 배수여야 한다.


3. 예제로 오프셋 따라가기 (실측)

아래 크기는 sizeof/offsetof로 실제 컴파일해서 확인한 값이다 (64비트 arm64, gcc/clang 기본 설정. x86-64 리눅스도 이 예제에서는 동일).

3.1 char 뒤에 int — 가장 단순한 경우

struct E0 {
    char a;   /* offset 0, 1B */
    int  b;   /* offset 4, 4B — 4의 배수여야 하므로 1~3번지는 패딩 */
};            /* sizeof = 8 */
offset: 0   1   2   3   4   5   6   7
       [a] [pad][pad][pad][ b   b   b   b ]

3.2 같은 멤버, 순서만 다른 두 구조체

struct E1 {          /* sizeof = 12 */
    char a;          /* offset 0 */
    int  b;          /* offset 4  (앞에 패딩 3B) */
    char c;          /* offset 8  */
};                   /* 꼬리 패딩 3B → 12 (int 정렬 4의 배수로 맞춤) */
 
struct E2 {          /* sizeof = 8 */
    int  b;          /* offset 0 */
    char a;          /* offset 4 */
    char c;          /* offset 5 */
};                   /* 꼬리 패딩 2B → 8 */
E1: [a|pad pad pad][b b b b][c|pad pad pad]   = 12바이트 (패딩 6B)
E2: [b b b b][a][c][pad pad]                  =  8바이트 (패딩 2B)

멤버 종류는 같지만 패딩이 6바이트 vs 2바이트다. 이것이 “위치를 바꿔도 똑같지 않나?”에 대한 답이다.

3.3 8바이트 멤버가 섞이면 차이가 더 커진다

struct M  { char a; double d; char c; int i; short s; };   /* sizeof = 32 */
struct M2 { double d; int i; short s; char a; char c; };   /* sizeof = 16 */
struct M 멤버offset설명
char a0뒤에 패딩 7B (다음이 8바이트 정렬)
double d8
char c16뒤에 패딩 3B (다음이 int)
int i20
short s24뒤에 꼬리 패딩 6B (구조체 정렬 8의 배수로)

같은 데이터인데 32바이트 → 16바이트로 절반이 된다. 이 구조체를 100만 개 배열로 쓰면 16MB 차이다.

실무 요령: 큰 타입부터(내림차순) 선언

double/포인터(8) → int(4) → short(2) → char(1) 순으로 쓰면 패딩이 대부분 사라진다. 단, 구조체가 외부 규격(프로토콜 전문, 파일 포맷)과 1:1 대응해야 하면 순서를 마음대로 바꿀 수 없다 — 6번 항목 참고.


4. 왜 굳이 정렬하는가 — 그리고 “8바이트씩 읽는다”의 정확한 의미

원문 설명(AI 대화)의 핵심은 맞지만, 몇 군데는 단순화된 모델이라 보충한다.

원문 설명보충·정정
”CPU는 8바이트 단위로 읽는다”맞지만 단순화된 모델이다. 실제로는 캐시 라인(보통 64바이트) 단위로 메모리를 가져오고, 그 안에서 필요한 바이트를 꺼낸다. “데이터가 읽기 단위 경계에 걸치면 느리다”는 원리는 같지만, 경계의 기준이 캐시 라인·페이지까지 확장된다.
”x86은 정렬 안 맞아도 허용하지만 느려진다”맞다. 다만 현대 x86은 캐시 라인 안에 들어오는 비정렬 접근은 페널티가 거의 없고, 캐시 라인이나 페이지 경계를 걸칠 때 확실히 느려진다.
”ARM, MIPS는 비정렬 접근 시 Bus Error”일부 코어·일부 명령어(예: 정렬을 요구하는 SIMD 로드, 원자적 연산)에서 그렇다. 최신 ARM 일반 로드/스토어는 허용하기도 한다. 이식성을 위해 “정렬되어 있다고 가정”하는 것이 안전하다.
패딩 = 메모리 낭비를 감수맞다. 대신 멤버 순서 정리로 낭비를 줄일 수 있다 (3.3).

정리: 정렬은 “CPU가 읽는 크기 = 구조체 정렬 기준”이 아니라, “CPU가 효율적으로(또는 안전하게) 접근할 수 있는 주소 조건” 을 컴파일러가 만족시켜 주는 것이다.


5. 실무에서 만나는 함정 4가지

5.1 같은 구조체인데 프로세스마다 sizeof가 다르다

IPC(메시지 큐, 공유 메모리)나 소켓으로 구조체를 통째로 보내는 코드(msgsnd(&st, sizeof(st)))에서, 송신/수신 쪽이 서로 다른 컴파일러·옵션·아키텍처(32비트 vs 64비트)로 빌드되면 패딩 위치와 총 크기가 달라져 필드가 어긋난다.

  • long은 32비트 리눅스에서 4바이트, 64비트 리눅스에서 8바이트 → 같은 구조체라도 오프셋이 바뀐다.
  • 대응: int32_t/uint16_t 같은 고정폭 타입 사용, 필요하면 필러 필드(char pad[N])를 명시적으로 넣어 패딩을 눈에 보이게 만든다. (→ 가변 길이 구조체와 필러 필드)

5.2 #pragma pack / __attribute__((packed)) — 패딩 제거의 대가

struct P { char a; int b; } __attribute__((packed));   /* sizeof = 5, 패딩 없음 */

프로토콜 헤더를 바이트 단위로 정확히 매핑할 때 쓴다. 하지만:

  • b가 offset 1(4의 배수 아님)에 놓이므로 비정렬 접근이 된다 → x86에서는 느려질 뿐이지만 엄격한 아키텍처에서는 SIGBUS.
  • 멤버의 주소를 포인터로 뽑으면(&p->b) 비정렬 포인터가 되어 위험하다. 값만 복사해서(memcpy) 읽는 것이 안전하다.
  • 규격 헤더에는 packed, 내부 자료구조에는 기본 정렬 — 용도를 구분해서 쓴다.

5.3 패딩 바이트는 쓰레기값이다 — memcmp·해시·로그

패딩 영역은 컴파일러가 초기화해 주지 않는다. 구조체를 { .a = 1, .b = 2 }로 채워도 패딩에는 이전 스택 값이 남아 있을 수 있다.

struct E1 x, y;
x.a = 1; x.b = 2; x.c = 3;
y.a = 1; y.b = 2; y.c = 3;
memcmp(&x, &y, sizeof(x));   /* 멤버가 같아도 패딩 때문에 0이 아닐 수 있음! */
  • 구조체를 해시 키(HASH_INSERT(&key, sizeof(key)))나 바이너리 비교 대상으로 쓸 때, 또는 통째로 파일·소켓으로 보낼 때 문제가 된다. (보안 관점에서는 패딩에 남은 스택 쓰레기값이 외부로 새는 정보 노출이 된다.)
  • 대응: 사용 전 memset(&st, 0, sizeof(st)). 이 vault의 안전 할당 래퍼가 할당 직후 memset을 강제하는 이유 중 하나다. 멤버별 비교 함수를 따로 만드는 것도 방법이다.

5.4 크기·오프셋을 눈으로 확인하는 습관

#include <stddef.h>
printf("sizeof=%zu, offset(b)=%zu\n", sizeof(struct E1), offsetof(struct E1, b));
 
/* 규격과 어긋나면 컴파일 단계에서 막기 (C11) */
_Static_assert(sizeof(struct MsgHeader) == 16, "MsgHeader size mismatch");

전문 구조체를 정의했다면 _Static_assert로 크기를 못 박아 두면, 누군가 멤버를 추가하거나 타입을 바꿔서 레이아웃이 깨질 때 컴파일이 실패해 바로 알 수 있다.


6. 한눈에 정리

항목핵심
패딩이 생기는 이유각 멤버를 자기 타입 크기의 배수 오프셋에 두기 위해 (CPU가 효율적·안전하게 접근하는 조건)
정렬 기준버스 폭(8B)이 아니라 멤버 타입별 크기 (char1 / short2 / int4 / double·포인터 8)
구조체 전체 크기가장 큰 멤버 정렬의 배수 (꼬리 패딩 때문, 배열 원소 정렬 보장)
멤버 순서큰 타입 → 작은 타입(내림차순)이면 패딩 최소 (예: 32B → 16B)
packed패딩 제거, 대신 비정렬 접근 위험. 규격 헤더 매핑용
패딩 바이트초기화 안 됨 → memcmp·해시 키·전송 전 memset 필수
확인 도구sizeof, offsetof, _Static_assert

관련 문서