Zig 메모리 레이아웃 심층 분석: 바이트 단위로 뜯어보는 구조체 최적화


최근 Andrew Kelley(Zig 창시자)의 Data Oriented Design (DoD) 강연을 다시 돌려보다가, 문득 초심으로 돌아간 기분을 느꼈습니다. 강연 시작 10분 만에 청중들에게 u32bool 같은 기본 타입의 메모리 얼라인먼트(Alignment)와 사이즈를 계산해보라고 시키는데, 의외로 이걸 명확하게 대답하지 못하는 시니어 엔지니어들이 많습니다.

요즘 고수준 언어들은 가비지 컬렉터(GC) 뒤에 숨어서 메모리 관리를 “마법”처럼 처리해주니까요. 하지만 성능 최적화의 끝판왕은 결국 데이터가 메모리에 어떻게 놓이느냐(Layout) 에 달려 있습니다. 캐시 미스(Cache Miss)를 줄이고 대역폭을 아끼는 것이야말로 진정한 엔지니어링의 정수죠.

오늘은 최근 Hacker News에서 화제가 된 Memory layout in Zig with formulas라는 글을 바탕으로, Zig가 메모리를 다루는 방식과 우리가 놓치고 있던 Padding 의 존재를 파헤쳐보려 합니다.

왜 우리가 얼라인먼트(Alignment)를 신경 써야 할까?

“그냥 컴파일러가 알아서 해주잖아?”라고 생각한다면 반은 맞고 반은 틀립니다. CPU는 데이터를 아무 주소에서나 1바이트씩 긁어오지 않습니다. 워드(Word) 단위나 캐시 라인 단위로 접근하죠. 그래서 얼라인먼트가 맞지 않으면 CPU 사이클을 낭비하거나, 아예 하드웨어 폴트(Fault)가 발생할 수도 있습니다.

이 글에서 제시하는 핵심 공식은 꽤 직관적입니다.

  • Size: 해당 타입의 인스턴스가 정보를 표현하는 데 필요한 바이트 수 (패딩 포함)
  • Alignment: 컴파일러가 데이터를 배치할 때 지켜야 하는 주소의 배수 규칙

Primitives: 기본 중의 기본

Zig에서 bool, u8, i32 같은 기본 타입들은 아래 규칙을 따릅니다.

@sizeOf(primitive) = @alignOf(primitive)

재미있는 점은 u17 같은 변태적인(?) 비트 크기를 가진 타입들입니다. 17비트는 2바이트(16비트)보다 크니 3바이트면 될 것 같지만, 실제로는 4바이트 를 차지합니다. 아키텍처 효율성을 위해 항상 2의 거듭제곱(Power of 2) 으로 반올림되기 때문입니다.

원문 저자가 정리한 공식은 이렇습니다:

$$\texttt{bytes}(\texttt{bits}) := \max\left{1, 2^{\left\lceil\log_2(\frac{\texttt{bits}}{8}) \right\rceil}\right}.$$

즉, 비트 수를 바이트로 환산하고, 그보다 크거나 같은 가장 작은 2의 거듭제곱을 찾는 겁니다. 간단하죠?

Structs: 패딩(Padding)이 숨어있다

여기가 진짜 재미있는 부분입니다. C언어를 해보신 분들은 익숙하겠지만, 구조체 필드 순서에 따라 메모리 크기가 달라집니다. Zig의 일반 struct는 컴파일러가 알아서 필드 순서를 재배치(Reordering)해 최적화해주지만, extern struct를 쓰면 C ABI 호환성을 위해 선언된 순서를 그대로 따릅니다.

아래 두 구조체를 봅시다.

const ABAB = extern struct {
    a1: u8,
    b1: u16,
    a2: u8,
    b2: u16,
};

const ABBA = extern struct {
    a1: u8,
    a2: u8,
    b1: u16,
    b2: u16,
};

내용물은 똑같은데 메모리 크기는 다릅니다.

  • ABAB: u8 뒤에 u16이 오려면 2바이트 얼라인먼트를 맞춰야 해서 1바이트 패딩이 들어갑니다. 결과적으로 8바이트 가 됩니다.
  • ABBA: u8 두 개가 나란히 붙고, 그 뒤에 u16이 오면 자연스럽게 2바이트 정렬이 맞습니다. 패딩 없이 6바이트 면 충분합니다.

이걸 수식으로 표현하면, 다음 필드의 오프셋은 이전 필드 끝 위치를 다음 필드의 얼라인먼트 배수로 올림(Ceiling) 한 값이 됩니다. 이 작은 차이가 수백만 개의 객체를 다룰 때는 GB 단위의 메모리 낭비로 이어집니다.

Enums와 Unions: 태그의 비용

Zig의 enum은 내부적으로 정수입니다. enum(u8)이라면 1바이트죠. 그럼 union은 어떨까요?

  • Untagged Union: 가장 큰 멤버의 사이즈를 따라갑니다.
  • Tagged Union: 이게 물건입니다. union(enum) 형태인데, 내부적으로 “현재 어떤 타입인지”를 나타내는 태그(Tag)가 붙습니다.

여기서도 얼라인먼트 규칙이 적용됩니다. 태그가 u8이라도 유니언 내부의 가장 큰 얼라인먼트를 가진 필드가 u64(8바이트)라면, 태그 뒤에 7바이트의 패딩이 생길 수 있습니다. 배보다 배꼽이 더 커지는 경우를 조심해야 합니다.

DoD의 핵심: ArrayList vs MultiArrayList

Andrew Kelley의 강연에서 강조하는 Data Oriented Design 의 정수가 바로 여기에 있습니다. 객체 지향 프로그래밍(OOP)에 익숙한 우리는 흔히 AoS (Array of Structs) 방식을 씁니다. Zig의 ArrayList(T)가 이에 해당하죠.

반면, SoA (Struct of Arrays) 방식인 MultiArrayList(T)는 구조체의 각 필드를 별도의 배열로 쪼개서 저장합니다.

원문의 벤치마크 예시를 보면 그 차이가 명확합니다.

// T = { a: u8, b: u16 } (패딩 때문에 4바이트 차지)

// ArrayList(T)
// [a, pad, b], [a, pad, b], ... 
// 요소당 4바이트 소모

// MultiArrayList(T)
// [a, a, ...], [b, b, ...] 
// 패딩 없이 꽉 채움. 요소당 1(u8) + 2(u16) = 3바이트 소모

10,000개를 생성했을 때:

  • ArrayList: 51,416 bytes
  • MultiArrayList: 33,450 bytes

메모리 사용량이 35%나 줄어듭니다. 더 중요한 건 캐시 로컬리티(Cache Locality)입니다. 특정 필드만 순회하며 연산할 때 SoA 방식은 CPU 캐시 적중률을 비약적으로 높여줍니다.

마치며: 바이트를 아끼는 것이 곧 기술력이다

요즘 하드웨어가 좋아졌다고 메모리를 펑펑 쓰는 경향이 있습니다. 하지만 클라우드 비용 절감이나 고성능 시스템, 혹은 임베디드 환경에서는 이런 1바이트의 차이가 승패를 가릅니다.

Zig는 언어 차원에서 alignOf, sizeOf 같은 도구를 제공하고 MultiArrayList 같은 자료구조를 표준으로 지원함으로써 엔지니어가 메모리 레이아웃을 직접 통제하도록 유도합니다. 이것이 제가 Zig를 높게 평가하는 이유입니다.

굳이 Zig를 쓰지 않더라도, 여러분이 사용하는 언어(Go, Rust, 심지어 Java의 Project Valhalla)에서 내 데이터가 메모리에 어떻게 박혀있는지 한 번쯤 상상해보시길 권합니다. “보이지 않는 패딩” 을 찾아내는 순간, 여러분의 엔지니어링 레벨은 한 단계 올라갈 것입니다.