

"CPU는 편식쟁이입니다. 아무 주소에서나 데이터를 먹지 않습니다."
이것은 낭비처럼 보이지만, CPU의 메모리 접근 횟수를 줄여 속도를 극대화하기 위한 필수 전략입니다.
다음과 같은 구조체가 있다고 가정해 봅시다.
struct Weird {
char a; // 1 byte
int b; // 4 bytes
char c; // 1 byte
};
상식적으로는 1 + 4 + 1 = 6바이트여야 합니다. 하지만 실제 sizeof(struct Weird)를 찍어보면 12바이트가 나옵니다. (32bit/64bit 시스템 기준)
왜 6바이트나 낭비되었을까요? 메모리 내부를 투시해 봅시다.
| Address | Value | 비고 |
|---|---|---|
| 0x0000 | [a] (1 byte) | |
| 0x0001 | [Padding] | 3 Bytes Wasted! |
| 0x0002 | [Padding] | (CPU: "int는 4의 배수 주소에 줘!") |
| 0x0003 | [Padding] | |
| 0x0004 | [b] (4 bytes) | 0x04는 4의 배수이므로 OK! |
| 0x0005 | [b] | |
| 0x0006 | [b] | |
| 0x0007 | [b] | |
| 0x0008 | [c] (1 byte) | |
| 0x0009 | [Padding] | 3 Bytes Wasted! |
| 0x000A | [Padding] | (구조체 배열을 위해 전체 크기를 4의 배수로 맞춤) |
| 0x000B | [Padding] |
int는 4바이트이므로, 시작 주소가 4의 배수여야 합니다. 그래서 char a 뒤에 3바이트를 비워둡니다.int)의 배수로 끝나야 합니다. 그래서 char c 뒤에도 3바이트를 채웁니다.Q: 왜 아깝게 빈 공간을 두나요? 그냥 꽉 채우면 안 되나요?
A: "CPU가 두 번 일하게 하지 않기 위해서입니다."
여러분이 4인승 버스(32bit Data Bus)를 운전한다고 상상해 봅시다. 이 버스는 한 번에 4명(4바이트)씩만 태울 수 있고, 좌석은 항상 1번부터 4번까지 채워야 출발합니다.
만약 int b가 주소 0x0001부터 0x0004에 걸쳐 있다고 칩시다.
0x0000~0x0003을 읽습니다. 여기서 b의 앞부분 3바이트만 가져옵니다.0x0004~0x0007을 읽습니다. 여기서 b의 나머지 1바이트를 가져옵니다.int b를 0x0004에 딱 맞췄습니다.
0x0004~0x0007을 읽습니다. b가 한 번에 쏙 들어옵니다.경고: 일부 엄격한 하드웨어(예: 구형 ARM, SPARC)는 정렬되지 않은 주소에 접근하면 아예 "Bus Error"를 내며 프로그램을 강제 종료시킵니다. 인텔 CPU는 종료시키진 않지만 성능이 급격히 떨어집니다.
하드웨어 엔지니어처럼 코드를 짜는 법을 알려드리겠습니다.
멤버 변수의 선언 순서만 바꿔도 메모리 낭비를 줄일 수 있습니다.
원칙: 크기가 큰 멤버부터 작은 순서대로 선언하십시오.
/* 나쁜 예 (12 Bytes) */
struct Bad {
char a; // 1
// 3 bytes padding
int b; // 4
char c; // 1
// 3 bytes padding
};
/* 좋은 예 (8 Bytes) - 4바이트 절약! */
struct Good {
int b; // 4 (0x00 시작)
char a; // 1 (0x04 위치)
char c; // 1 (0x05 위치)
// 2 bytes padding (전체 크기 4의 배수 맞춤)
};
__attribute__((packed)))네트워크 패킷을 보내거나, 파일 헤더를 읽을 때는 "빈 공간"이 있으면 안 됩니다. 프로토콜은 패딩 따위를 모릅니다. 이때는 컴파일러에게 "속도가 느려져도 좋으니 빈 공간 없이 꽉 채워!"라고 명령해야 합니다.
// GCC, Clang 스타일
struct __attribute__((packed)) Packet {
char cmd;
int payload;
};
// 결과: sizeof(Packet) == 5 (1 + 4)
// 주의: 이 멤버에 접근할 때 CPU는 느린 연산을 수행합니다.
기본 개념을 잡았다면, 이제 면접과 실무에서 차이를 만드는 "한 끗"을 채울 시간입니다.
"구조체 내부 패딩은 이해가는데, 왜 마지막에도 패딩을 넣나요?"
정답: "배열(Array) 때문입니다."
struct Example {
int a; // 4 bytes (offset 0)
char b; // 1 byte (offset 4)
// 3 bytes padding (offset 5~7)
}; // sizeof = 8
만약 끝 패딩이 없어서 sizeof가 5라면?
struct Example arr[2];
// arr[0].a : 0x0000 (OK)
// arr[1].a : 0x0005 (X) -> 4의 배수가 아님! 정렬 위반 발생!
배열의 두 번째 원소도 정확히 정렬된 위치에서 시작하기 위해, 구조체 전체 크기를 가장 큰 멤버(int)의 배수로 맞춰야 합니다.
"Bus Error"는 언제 발생할까요? 플랫폼마다 다릅니다.
| 플랫폼 | Unaligned Access 시 동작 | 비고 |
|---|---|---|
| x86/x64 | 허용 (성능 저하) | 인텔은 매우 관대함 |
| ARMv7 (Cortex-M3/M4) | 대부분 허용 | 단, LDM/STM 등 일부 다중 로드 명령어는 예외 발생 |
| ARMv6 이하 (구형) | Bus Error (SIGBUS) | 구형 모바일/임베디드는 매우 엄격 |
| SPARC, MIPS | Bus Error | 하드웨어적으로 정렬 필수 |
| RISC-V | 구현체에 따라 다름 | 스펙상 선택적 기능 |
자동차 SW (ASIL) Tip:
ECU용 Cortex-R/M 시리즈는 Unaligned Access를 허용하더라도, 안전성(Safety)과 예측 가능성을 위해 컴파일러 옵션(-mno-unaligned-access)으로 정렬을 강제하는 경우가 많습니다. "허용된다고 막 쓰면 안 됩니다."
alignas, aligned)packed가 빈 공간을 없애는 것이라면, aligned는 더 큰 정렬을 강제하는 것입니다.
// C11 표준: alignas
#include <stdalign.h>
struct alignas(16) SIMDFriendly {
float data[4]; // 16바이트, SIMD 벡터 연산에 최적화
};
// GCC/Clang 확장: Cache Line 정렬
struct __attribute__((aligned(64))) CacheLineFriendly {
long value;
// 자동으로 64바이트 경계에 배치됨 (False Sharing 방지)
};
실제로 계산해봐야 늡니다. 다음 구조체의 크기는? (64bit 시스템 기준)
struct Challenge {
char a; // 1 byte
double b; // 8 bytes
short c; // 2 bytes
int d; // 4 bytes
};
정답 해설:
1. a (1 byte) → 오프셋 0
2. b (8 bytes) → 오프셋 1 (X) → 패딩 7바이트 → 오프셋 8 (OK)
3. c (2 bytes) → 오프셋 16
4. d (4 bytes) → 오프셋 18 (X) → 패딩 2바이트 → 오프셋 20 (OK)
5. 끝 패딩 → 현재 24바이트. 가장 큰 멤버 double(8)의 배수인가? → 24는 8의 배수 (OK).
6. 총 크기: 24 bytes
패딩과 정렬은 단순한 메모리 낭비 문제를 넘어, 멀티코어 프로그래밍의 성능과도 직결됩니다.
멀티코어 환경에서 서로 다른 쓰레드가 인접한 변수를 각각 자주 수정할 때 발생하는 심각한 성능 저하 현상입니다.
상황:
Thread A는 변수 X를 수정함.Thread B는 변수 Y를 수정함.X와 Y가 우연히 메모리 상에서 바로 옆에 붙어 있어서, 같은 캐시 라인(Cache Line, 보통 64바이트)에 들어감.결과:
CPU 코어들은 캐시 일관성(Cache Coherency)을 유지하기 위해 서로의 캐시 라인을 계속 무효화(Invalidate)하고 다시 로딩해야 합니다. 변수는 다르지만 캐시 라인이 같아서 성능이 곤두박질칩니다.
해결책: 강제 패딩 (Padding for Cache Line Alignment)
struct ThreadSafeCounter {
long value;
// 64바이트(캐시 라인 크기)를 채우기 위한 패딩
char padding[64 - sizeof(long)];
};
이렇게 하면 value는 자신만의 전용 캐시 라인을 가지게 되어, 다른 코어의 간섭 없이 빠르게 처리됩니다.
생각해볼만한 것:
"구조체를 설계할 때는 단순히 논리적인 연관성뿐만 아니라, 변수들의 물리적 크기와 배치를 고려하는 습관을 들이세요. 또한 멀티쓰레드 환경에서는 데이터가 캐시 라인 경계에 어떻게 놓이는지까지 생각하는 것이 진짜 시스템 프로그래머의 센스(Sense)입니다."