메모리
이 글은 엔디안(Endian), 패딩(및 정렬), 그리고 캐시 메모리에 대해 초심자도 이해할 수 있도록 자세히 설명합니다.
1) 엔디안(Endianness)
정의: 여러 바이트로 이루어진 데이터(예: 32비트 정수)를 메모리에 어떤 바이트 순서로 저장하는지를 규정하는 방식입니다. 대표적으로 리틀엔디안(little-endian)과 빅엔디안(big-endian)이 있습니다.
- 리틀엔디안(Little-endian)
- 가장 작은(가장 덜 중요한) 바이트(least significant byte)가 낮은 주소(작은 주소)에 저장됩니다.
- 예: 32비트 정수 0x12345678은 메모리에 [0x78][0x56][0x34][0x12] 순으로 저장됩니다.
- 빅엔디안(Big-endian)
- 가장 큰(가장 중요한) 바이트(most significant byte)가 낮은 주소에 저장됩니다.
- 같은 값 0x12345678은 메모리에 [0x12][0x34][0x56][0x78] 순으로 저장됩니다.
왜 중요한가?
- 파일 포맷, 네트워크(프로토콜) 등에서 바이트 순서가 다르면 서로 다른 시스템 간 데이터 해석이 달라집니다. 네트워크 바이트(order)는 전통적으로 빅엔디안으로 규정되어 있습니다.
- 이진 데이터를 읽거나, 메모리-바이트 레벨로 디버깅(예: 디버거로 메모리 덤프 확인)할 때 엔디안을 이해하면 값이 왜 다르게 보이는지 알 수 있습니다.
간단한 C 예제(리틀/빅 확인):
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint32_t x = 0x12345678;
unsigned char *p = (unsigned char*)&x;
printf("bytes: %02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);
return 0;
}
이 프로그램을 실행하면 대부분의 x86/x86_64 시스템(리틀엔디안)에서는 78 56 34 12가 출력됩니다.
교환/호환 팁
- 네트워크 바이트(order)로 송수신한다면
htonl,ntohl같은 표준 함수를 사용해 변환하세요.
2) 패딩과 정렬(Padding & Alignment)
정의: 구조체나 변수가 메모리에 배치될 때 특정 경계(예: 4바이트, 8바이트)에 맞춰 정렬(alignment)되어야 하는 규칙과, 구조체 멤버들 사이에 컴파일러가 삽입하는 빈 공간(padding)을 의미합니다.
왜 존재하나?
- 일부 CPU는 특정 바이트 경계로 정렬된 메모리 접근만 빠르게(혹은 올바르게) 지원합니다. 예: 4바이트 정수는 4바이트 경계에 정렬되면 읽기/쓰기 속도가 빠릅니다.
간단한 예시:
struct A { char c; int x; };
메모리 배치(전형적):
c는 1바이트이므로 offset 0에 위치합니다.- 다음
int x는 4바이트 경계에 정렬되므로 컴파일러는c와x사이에 3바이트의 패딩을 넣어x를 offset 4에 배치합니다.
따라서 sizeof(struct A)는 8이 됩니다(1 + 3(padding) + 4).
패딩의 영향
- 메모리 사용량 증가: 많은 작은 멤버를 가진 구조체는 패딩 때문에 예상보다 커질 수 있습니다.
- 캐시 효율: 불필요한 패딩은 캐시 라인 사용을 비효율적으로 만들 수 있습니다.
정렬을 제어하는 방법
#pragma pack(컴파일러별)이나__attribute__((packed))(GCC/Clang)로 패킹을 강제할 수 있지만, 잘못 사용하면 성능 저하나 잘못된 동작을 초래할 수 있습니다.
실무 팁
- 구조체 설계 시 큰 타입(예: int, double)을 앞에 배치하면 패딩을 줄일 수 있습니다.
- 바이너리 파일 포맷을 설계할 때는 멤버 정렬과 엔디안을 명확히 문서화하세요.
https://eeeuns.github.io/2024/02/12/alignment/
3) CPU 캐시(Cache) — 왜 중요한가?
개요: CPU는 메모리(RAM)보다 훨씬 빠른 레지스터와 캐시를 사용해 성능을 높입니다. 캐시는 L1, L2, L3 같은 여러 레벨로 구성되며, 작고 빠른 레벨일수록 CPU에 가깝습니다.
- L1: 가장 작고 가장 빠름(수십 KB 수준), 레지스터 다음 단계
- L2: 중간 크기(수백 KB)
- L3: 더 큰 공유 캐시(수 MB), 코어 간 공유되는 경우가 많음
캐시 작동 원리(간단)
- CPU는 메모리 주소를 읽을 때 먼저 캐시에서 찾습니다(cache hit). 캐시에 없으면 메모리에서 읽어 캐시에 채운 뒤 사용(cache miss).
- 캐시는 보통 ‘캐시 라인’(예: 64바이트) 단위로 메모리를 가져옵니다. 즉, 인접한 주소를 함께 가져와 ‘공간 지역성’을 활용합니다.
지역성(Locality)의 두 가지 유형
- 시간 지역성(Temporal locality): 최근에 참조한 데이터는 곧 다시 참조될 가능성이 높음.
- 공간 지역성(Spatial locality): 한 데이터 근처의 데이터도 곧 참조될 가능성이 높음.
왜 프로그래머가 신경 써야 하나?
- 데이터가 연속 메모리에 있으면(예: 배열) 캐시 활용도가 높아 성능이 좋아집니다.
- 포인터로 흩어진 메모리(연결 리스트)는 캐시 미스가 많아 성능이 떨어질 수 있습니다.
예제: 배열 순회 vs 연결 리스트 순회
// 배열 순회
for (int i=0; i<n; ++i) sum += a[i];
// 연결 리스트 순회
for (Node* p = head; p; p = p->next) sum += p->v;
배열 버전은 인접 원소를 순서대로 읽기 때문에 캐시 라인 재활용이 잘 됩니다. 리스트 버전은 노드가 메모리에 흩어져 있으면 매번 캐시 미스가 발생할 가능성이 큽니다.
캐시 관련 최적화 팁
- 큰 데이터를 처리할 때는 연속적 접근을 선호하세요(예: 블록(block) 단위로 접근).
- 구조체의 멤버 순서를 캐시 친화적으로 배치하면 성능이 향상될 수 있습니다.
- 알고리즘에서 메모리 접근 패턴(순차 vs 랜덤)을 고려하세요.
측정과 도구
- Linux:
perf,valgrind --tool=cachegrind등으로 캐시 미스와 메모리 패턴을 측정할 수 있습니다. - Windows: Visual Studio Profiler, Intel VTune 등.
부록: 빠른 요약과 실습 아이디어
- 엔디안: 바이트 순서. 리틀엔디안 vs 빅엔디안.
- 네트워크 바이트는 보통 빅엔디안.
- 패딩/정렬: 구조체 멤버 사이에 컴파일러가 넣는 빈 공간. 성능 및 메모리 크기 영향.
- 캐시: 메모리 계층에서 매우 중요한 성능 요인. 연속 접근이 유리.
실습 아이디어:
- 위의 바이트 출력 예제를 컴파일해 실행해 엔디안 확인하기.
- 구조체 멤버 순서를 바꿔
sizeof가 어떻게 변하는지 확인해 보기. - 간단한 배열 vs 리스트 순회 프로그램을 만들어
valgrind --tool=cachegrind로 캐시 미스 차이 확인하기(리눅스).