구조체 패딩과 멤버 정렬 — 정렬 기준은 버스 폭이 아니라 멤버 타입 크기
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가지
- 멤버 배치 규칙: 각 멤버는 자기 크기(정확히는 자기 정렬 요구사항)의 배수 오프셋에 놓는다. 앞 멤버가 끝난 지점이 배수가 아니면 그 사이를 패딩으로 채운다.
- 구조체 정렬: 구조체 자체의 정렬 요구사항은 가장 큰 멤버의 정렬 요구사항이다.
- 꼬리 패딩(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 a | 0 | 뒤에 패딩 7B (다음이 8바이트 정렬) |
double d | 8 | |
char c | 16 | 뒤에 패딩 3B (다음이 int) |
int i | 20 | |
short s | 24 | 뒤에 꼬리 패딩 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 |
관련 문서
- 메모리 정렬(Memory Alignment)과 aligned_alloc — CPU 워드 경계와 안전한 할당 래퍼 — 힙 할당 주소의 정렬 (이 노트는 구조체 내부 멤버의 정렬)
- 가변 길이 구조체(Struct Hack)와 필러 필드 — 헤더 뒤에 매달리는 가변 payload — IPC 호환용 필러 필드
- 버퍼를 구조체로 캐스팅해 읽기 — 구조체 안의 구조체 배열과 인덱스 접근 — 캐스팅 시 패딩 주의
- 정수형 크기(uint16_t)와 비트·바이트 메모리 저장 원리 — 고정폭 타입