Go 구조체 정렬 및 성능 영향 이해
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
로우 레벨 프로그래밍 및 성능 최적화의 세계에서 메모리 내 데이터가 어떻게 배치되는지 이해하는 것은 매우 중요합니다. Go 개발자에게는 종종 기본 구성 요소 중 하나인 struct
에 대한 더 깊은 탐구가 이어집니다. 겉보기에는 간단해 보이지만, Go가 메모리 내에서 구조체 필드를 배열하는 방식, 즉 메모리 정렬이라고 알려진 과정은 애플리케이션 성능과 메모리 사용량 모두에 상당한 영향을 미칠 수 있습니다. 정렬을 고려하지 않으면 예상치 못한 메모리 패딩, CPU 캐시 누락 증가, 궁극적으로는 프로그램 속도 저하를 초래할 수 있습니다. 이 문서는 Go의 구조체 메모리 정렬을 명확히 밝히고, 그 원칙을 설명하며, 신중한 구조체 설계가 어떻게 더 효율적이고 성능이 뛰어난 Go 애플리케이션으로 이어질 수 있는지 보여줄 것입니다.
메모리 정렬의 핵심 개념
Go의 특정 내용을 알아보기 전에 메모리 정렬과 관련된 핵심 개념에 대한 기본적인 이해를 확립해 봅시다.
- 메모리 주소: 컴퓨터 메모리의 각 바이트는 고유한 숫자 주소를 가집니다. 특정 주소에 데이터가 저장되어 있다고 언급할 때, 그것은 해당 데이터 블록의 시작 바이트를 의미합니다.
- 워드 크기: CPU가 단일 연산으로 효율적으로 처리할 수 있는 데이터의 기본 단위입니다. 64비트 시스템의 경우 워드 크기는 일반적으로 8바이트이며, 32비트 시스템의 경우 4바이트입니다. 여러 워드 경계를 가로지르는 데이터를 액세스하는 것은 덜 효율적일 수 있습니다.
- 정렬 요구 사항: 기본 데이터 타입(예:
int
,float64
,bool
)의 경우 내재된 정렬 요구 사항이 있습니다. 예를 들어, 64비트 시스템에서는 다음과 같습니다:byte
(1 바이트)는 어떤 주소에든 저장될 수 있습니다.short
(2 바이트)는 일반적으로 짝수 주소(2로 나누어지는)에서 시작해야 합니다.int
(4 바이트)는 일반적으로 4로 나누어지는 주소에서 시작해야 합니다.long
또는double
(8 바이트)은 일반적으로 8로 나누어지는 주소에서 시작해야 합니다. 이러한 요구 사항은 CPU가 내부 데이터 버스와 정렬된 상태로 데이터를 단일 연산으로 가져올 수 있도록 보장합니다.
- 패딩: 구조체의 필드가 CPU 아키텍처 및 해당 타입의 요구 사항에 따라 완벽하게 정렬되지 않으면, 컴파일러는 필드 사이 또는 구조체 끝에 "패딩" 바이트를 삽입합니다. 이 패딩 바이트는 후속 필드 또는 배열 요소가 올바르게 정렬되도록 삽입되는 본질적으로 낭비된 메모리입니다.
- 캐시 라인: 최신 CPU는 메모리 액세스를 가속화하기 위해 캐싱이라는 기술을 사용합니다. 데이터는 메인 메모리에서 캐시 라인(일반적으로 64 바이트)이라고 하는 청크로 더 작고 빠른 CPU 캐시로 가져옵니다. 데이터 조각에 액세스할 때 해당 데이터를 포함하는 전체 캐시 라인이 캐시로 로드됩니다. 데이터가 효율적으로 배치되면 관련 데이터가 동일한 캐시 라인에 존재하게 되어 캐시 누락이 줄어들고 액세스가 빨라집니다.
Go 구조체 정렬 심층 분석
다른 많은 컴파일 언어와 마찬가지로 Go는 구조체에 대한 메모리 정렬을 자동으로 처리합니다. 이를 달성하기 위해 다음과 같은 일련의 규칙을 따릅니다.
- 필드 정렬: 구조체의 각 필드는 해당 타입의 자연스러운 정렬 요구 사항 또는 구조체의 정렬 중 더 작은 쪽으로 정렬됩니다.
- 구조체 정렬: 구조체 자체의 정렬은 해당 필드 중 가장 큰 정렬 요구 사항과 같습니다.
- 구조체 크기: 구조체의 총 크기는 해당 정렬 요구 사항의 배수입니다. 이 규칙을 충족하기 위해 필요한 경우 구조체 끝에 패딩 바이트가 추가됩니다.
실제 Go 코드 예제를 통해 이러한 규칙을 설명해 보겠습니다. 메모리 레이아웃을 검사하기 위해 unsafe
패키지의 Sizeof
(바이트 단위 크기), Alignof
(정렬 요구 사항) 및 Offsetof
(구조체 내 필드 오프셋) 함수를 사용합니다.
다음 구조체 정의를 고려하십시오.
package main import ( "fmt" "unsafe" ) type S1 struct { A bool // 1 byte B int32 // 4 bytes C bool // 1 byte } type S2 struct { A bool // 1 byte C bool // 1 byte B int32 // 4 bytes } type S3 struct { A bool // 1 byte B int64 // 8 bytes C float64 // 8 bytes D int32 // 4 bytes E bool // 1 byte } type S4 struct { B int64 // 8 bytes C float64 // 8 bytes D int32 // 4 bytes A bool // 1 byte E bool // 1 byte } func main() { // S1 분석 fmt.Println("=== S1 (A bool, B int32, C bool) ===") fmt.Printf("Sizeof(S1): %d bytes\n", unsafe.Sizeof(S1{})) fmt.Printf("Alignof(S1): %d bytes\n", unsafe.Alignof(S1{})) fmt.Printf("Offsetof(S1.A): %d bytes, Sizeof(A): %d bytes, Alignof(A): %d bytes\n", unsafe.Offsetof(S1{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S1.B): %d bytes, Sizeof(B): %d bytes, Alignof(B): %d bytes\n", unsafe.Offsetof(S1{}.B), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Printf("Offsetof(S1.C): %d bytes, Sizeof(C): %d bytes, Alignof(C): %d bytes\n", unsafe.Offsetof(S1{}.C), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Println() // S2 분석 fmt.Println("=== S2 (A bool, C bool, B int32) ===") fmt.Printf("Sizeof(S2): %d bytes\n", unsafe.Sizeof(S2{})) fmt.Printf("Alignof(S2): %d bytes\n", unsafe.Alignof(S2{})) fmt.Printf("Offsetof(S2.A): %d bytes, Sizeof(A): %d bytes, Alignof(A): %d bytes\n", unsafe.Offsetof(S2{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S2.C): %d bytes, Sizeof(C): %d bytes, Alignof(C): %d bytes\n", unsafe.Offsetof(S2{}.C), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S2.B): %d bytes, Sizeof(B): %d bytes, Alignof(B): %d bytes\n", unsafe.Offsetof(S2{}.B), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Println() // S3 분석 fmt.Println("=== S3 (A bool, B int64, C float64, D int32, E bool) ===") fmt.Printf("Sizeof(S3): %d bytes\n", unsafe.Sizeof(S3{})) fmt.Printf("Alignof(S3): %d bytes\n", unsafe.Alignof(S3{})) fmt.Printf("Offsetof(S3.A): %d, Sizeof(A): %d, Alignof(A): %d\n", unsafe.Offsetof(S3{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S3.B): %d, Sizeof(B): %d, Alignof(B): %d\n", unsafe.Offsetof(S3{}.B), unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0))) fmt.Printf("Offsetof(S3.C): %d, Sizeof(C): %d, Alignof(C): %d\n", unsafe.Offsetof(S3{}.C), unsafe.Sizeof(float64(0)), unsafe.Alignof(float64(0))) fmt.Printf("Offsetof(S3.D): %d, Sizeof(D): %d, Alignof(D): %d\n", unsafe.Offsetof(S3{}.D), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Printf("Offsetof(S3.E): %d, Sizeof(E): %d, Alignof(E): %d\n", unsafe.Offsetof(S3{}.E), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Println() // S4 분석 fmt.Println("=== S4 (B int64, C float64, D int32, A bool, E bool) ===") fmt.Printf("Sizeof(S4): %d bytes\n", unsafe.Sizeof(S4{})) fmt.Printf("Alignof(S4): %d bytes\n", unsafe.Alignof(S4{})) fmt.Printf("Offsetof(S4.B): %d, Sizeof(B): %d, Alignof(B): %d\n", unsafe.Offsetof(S4{}.B), unsafe.Sizeof(int64(0)), unsafe.Alignof(int64(0))) fmt.Printf("Offsetof(S4.C): %d, Sizeof(C): %d, Alignof(C): %d\n", unsafe.Offsetof(S4{}.C), unsafe.Sizeof(float64(0)), unsafe.Alignof(float64(0))) fmt.Printf("Offsetof(S4.D): %d, Sizeof(D): %d, Alignof(D): %d\n", unsafe.Offsetof(S4{}.D), unsafe.Sizeof(int32(0)), unsafe.Alignof(int32(0))) fmt.Printf("Offsetof(S4.A): %d, Sizeof(A): %d, Alignof(A): %d\n", unsafe.Offsetof(S4{}.A), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Printf("Offsetof(S4.E): %d, Sizeof(E): %d, Alignof(E): %d\n", unsafe.Offsetof(S4{}.E), unsafe.Sizeof(true), unsafe.Alignof(true)) fmt.Println() }
출력을 분석해 봅시다 (64비트 시스템에서 int32
는 4바이트, int64
및 float64
는 8바이트, bool
은 1바이트라고 가정).
S1 분석 (A bool
, B int32
, C bool
)
=== S1 (A bool, B int32, C bool) ===
Sizeof(S1): 12 bytes
Alignof(S1): 4 bytes
Offsetof(S1.A): 0 bytes, Sizeof(A): 1 bytes, Alignof(A): 1 bytes
Offsetof(S1.B): 4 bytes, Sizeof(B): 4 bytes, Alignof(B): 4 bytes
Offsetof(S1.C): 8 bytes, Sizeof(C): 1 bytes, Alignof(C): 1 bytes
A
(bool, 1바이트)는 오프셋 0에서 시작합니다.B
(int32, 4바이트)를 정렬하기 위해 (4바이트 정렬 필요),A
뒤에 3바이트의 패딩이 삽입됩니다.B
는 사실상 오프셋 4에서 시작합니다.C
(bool, 1바이트)는 오프셋 8에서 시작합니다 (B
바로 뒤). 총 사용된 메모리는 1(A) + 3(패딩) + 4(B) + 1(C) = 9바이트입니다.- S1 필드 중 가장 큰 정렬 요구 사항은
int32
의 4바이트입니다. 따라서Alignof(S1)
는 4바이트입니다. - 구조체의 총 크기는 정렬(4)의 배수여야 합니다. 9는 4의 배수가 아니므로, 끝에 3바이트의 패딩이 추가되어 총 크기가 12바이트가 됩니다.
S1 메모리 레이아웃:
[A][P][P][P][B][B][B][B][C][P][P][P]
0 1 2 3 4 5 6 7 8 9 10 11
(P = 패딩)
S2 분석 (A bool
, C bool
, B int32
)
=== S2 (A bool, C bool, B int32) ===
Sizeof(S2): 8 bytes
Alignof(S2): 4 bytes
Offsetof(S2.A): 0 bytes, Sizeof(A): 1 bytes, Alignof(A): 1 bytes
Offsetof(S2.C): 1 bytes, Sizeof(C): 1 bytes, Alignof(C): 1 bytes
Offsetof(S2.B): 4 bytes, Sizeof(B): 4 bytes, Alignof(B): 4 bytes
A
(bool, 1바이트)는 오프셋 0에서 시작합니다.C
(bool, 1바이트)는 오프셋 1에서A
바로 뒤에 올 수 있습니다.- 이제 2바이트가 사용되었습니다.
B
(int32, 4바이트)를 정렬하기 위해 (4바이트 정렬 필요),C
뒤에 2바이트의 패딩이 삽입됩니다.B
는 사실상 오프셋 4에서 시작합니다. - 총 사용된 메모리는 1(A) + 1(C) + 2(패딩) + 4(B) = 8바이트입니다.
Alignof(S2)
는 4바이트입니다 (int32
때문에).- 구조체의 총 크기(8바이트)는 이미 4의 배수이므로, 끝에 추가 패딩은 없습니다.
S2 메모리 레이아웃:
[A][C][P][P][B][B][B][B]
0 1 2 3 4 5 6 7
(P = 패딩)
필드를 재정렬하기만 해도 구조체 크기가 12바이트에서 8바이트로 줄어든 것을 확인하세요. 이는 33%의 메모리 절약입니다!
S3 분석 (A bool
, B int64
, C float64
, D int32
, E bool
)
=== S3 (A bool, B int64, C float64, D int32, E bool) ===
Sizeof(S3): 32 bytes
Alignof(S3): 8 bytes
Offsetof(S3.A): 0, Sizeof(A): 1, Alignof(A): 1
Offsetof(S3.B): 8, Sizeof(B): 8, Alignof(B): 8
Offsetof(S3.C): 16, Sizeof(C): 8, Alignof(C): 8
Offsetof(S3.D): 24, Sizeof(D): 4, Alignof(D): 4
Offsetof(S3.E): 28, Sizeof(E): 1, Alignof(E): 1
A
(bool, 1바이트)는 오프셋 0에 있습니다.B
(int64, 8바이트)를 정렬하기 위해, 7바이트의 패딩이 추가됩니다.B
는 오프셋 8에서 시작합니다.C
(float64, 8바이트)는 오프셋 16에서 시작합니다 (8 + 8).D
(int32, 4바이트)는 오프셋 24에서 시작합니다 (16 + 8).E
(bool, 1바이트)는 오프셋 28에서 시작합니다 (24 + 4).- 총 원시 크기: 1+7+8+8+4+1 = 29바이트.
Alignof(S3)
는 8바이트입니다 (int64
및float64
).- 후행 패딩: 29바이트는 8의 다음 배수(32)로 반올림해야 합니다. 따라서 끝에 3바이트의 패딩이 추가됩니다.
S4 분석 (B int64
, C float64
, D int32
, A bool
, E bool
)
=== S4 (B int64, C float64, D int32, A bool, E bool) ===
Sizeof(S4): 24 bytes
Alignof(S4): 8 bytes
Offsetof(S4.B): 0, Sizeof(B): 8, Alignof(B): 8
Offsetof(S4.C): 8, Sizeof(C): 8, Alignof(C): 8
Offsetof(S4.D): 16, Sizeof(D): 4, Alignof(D): 4
Offsetof(S4.A): 20, Sizeof(A): 1, Alignof(A): 1
Offsetof(S4.E): 21, Sizeof(E): 1, Alignof(E): 1
B
(int64, 8바이트)는 오프셋 0에서 시작합니다.C
(float64, 8바이트)는 오프셋 8에서 시작합니다.D
(int32, 4바이트)는 오프셋 16에서 시작합니다.A
(bool, 1바이트)는 오프셋 20에서 시작합니다.E
(bool, 1바이트)는 오프셋 21에서 시작합니다.- 총 원시 크기: 8+8+4+1+1 = 22바이트.
Alignof(S4)
는 8바이트입니다.- 후행 패딩: 22바이트는 8의 다음 배수(24)로 반올림해야 합니다. 따라서 끝에 2바이트의 패딩이 추가됩니다.
다시 한번, S4는 단순히 필드를 재정렬함으로써 S3에 비해 상당한 메모리 절감(32바이트 대 24바이트)을 달성했습니다.
성능 영향
메모리 정렬은 여러 가지 방식으로 성능에 영향을 미칩니다:
- 메모리 사용량: 위에서 보았듯이 불필요한 패딩은 구조체에 소비되는 총 메모리를 증가시킵니다. 이는 특히 대규모 구조체 슬라이스나 배열을 다룰 때 중요합니다. 더 많은 메모리는 더 높은 메모리 대역폭 사용량, 가비지 컬렉터에 대한 더 큰 부담, 그리고 물리적 RAM이 부족할 경우 디스크로의 스와핑 가능성을 의미합니다.
- CPU 캐시 효율성: 이것이 가장 중요한 성능 요소일 경우가 많습니다. CPU가 데이터를 가져올 때, 전체 캐시 라인을 메인 메모리에서 L1/L2/L3 캐시로 로드합니다.
- 정렬된 액세스: 데이터가 정렬된 경우, CPU는 일반적으로 캐시 라인 내에서 단일 메모리 액세스로 이를 가져올 수 있습니다.
- 정렬되지 않은 액세스 (단일 필드의 경우): 단일 필드가 캐시 라인 경계를 가로지르는 경우, CPU는 해당 단일 데이터 조각을 가져오기 위해 두 번의 메모리 액세스를 수행해야 할 수 있으며, 이는 검색 속도를 크게 저하시킵니다. Go는 이러한 시나리오를 피하기 위해 필드 정렬을 보장합니다.
- 잘못된 공유(False Sharing): 이것은 동시 프로그래밍에서 더 미묘하지만 중요한 문제입니다. 두 개의 다른 고루틴이 동일한 구조체의 동일한 캐시 라인 내의 다른 필드에 자주 액세스하는 경우, 해당 필드가 완전히 관련이 없더라도 CPU의 캐시 일관성 프로토콜은 코어 간에 해당 캐시 라인을 반복적으로 무효화하고 재동기화합니다. 이는 과도한 캐시 트래픽을 유발하고 성능을 저하합니다. 자주 액세스되거나 관련 필드를 그룹화하고 관련 없거나 동시 액세스되는 필드를 분리하여 필드를 재정렬함으로써 잘못된 공유를 최소화할 수 있습니다. 예를 들어, 고루틴 A가
FieldA
를 자주 업데이트하고 고루틴 B가FieldB
를 자주 업데이트하는데,FieldA
와FieldB
가 같은 캐시 라인에 있다면 잘못된 공유가 발생합니다.FieldB
를 다른 캐시 라인으로 이동할 수 있다면 (예: 작은 배열이나 명시적으로 패딩된 구조체를 필드로 사용하여) 이 페널티를 피할 수 있습니다.
구조체 필드 순서 지정을 위한 실용 지침
Go에서 메모리 및 성능을 최적화하려면 다음 지침을 따르십시오.
- 크기별 정렬 (가장 큼에서 가장 작음): 일반적인 규칙으로, 필드를 크기별로 내림차순으로 선언합니다 (예:
int64
,float64
, 그 다음int32
,int16
,bool
,byte
). 이렇게 하면 작은 필드가 끝에 조밀하게 채워져 내부 패딩이 최소화됩니다. - 관련 필드 그룹화: 특정 필드가 자주 함께 액세스되는 경우, 이를 연속적으로 배치하려고 합니다. 이는 캐시 지역성을 개선하여 가져올 때 동일한 캐시 라인에 있을 가능성을 높입니다.
- 동시성 고려 (잘못된 공유): 동시적으로 액세스되는 구조체의 경우, 서로 다른 고루틴에 의해 자주 수정되는 필드를 식별합니다. 가능하다면, 필드 사이에 패딩 바이트를 삽입하여 (예: 작은 배열 또는 명시적으로 패딩된 구조체를 필드로 사용하여) 이러한 "핫" 필드를 서로 다른 캐시 라인으로 분리합니다. 이것은 더 고급 최적화이지만 고성능 동시 시스템에는 중요합니다.
go vet
및printfunsafestrs
사용:go vet
는 최적화되지 않은 구조체 패킹에 대해 직접 경고하지는 않지만,unsafe
패키지 출력 (예제에서와 같이)을 이해하는 데 도움이 됩니다. 특정 최적화에 대한 내장 기능은 없지만 커뮤니티 도구 및 린터도 있습니다.printfunsafestrs
를 통해 최적의 구조체 레이아웃을 제안할 수 있습니다.
"가장 큼에서 가장 작음" 규칙을 적용하는 예제를 살펴 보겠습니다.
type OptimizedStruct struct { BigInt int64 // 8 bytes BigFloat float64 // 8 bytes MediumInt int32 // 4 bytes SmallInt int16 // 2 bytes TinyByte byte // 1 byte TinyBool bool // 1 byte } // 총계: 8 + 8 + 4 + 2 + 1 + 1 = 24 bytes (8에 정렬될 경우 0개의 패딩 가능성 있음)
잘못 정렬된 구조체와 비교하십시오.
type UnoptimizedStruct struct { TinyBool bool // 1 byte BigInt int64 // 8 bytes TinyByte byte // 1 byte MediumInt int32 // 4 bytes BigFloat float64 // 8 bytes SmallInt int16 // 2 bytes }
OptimizedStruct
에 대한 unsafe
분석을 실행하면 UnoptimizedStruct
보다 더 컴팩트하다는 것을 보여줄 가능성이 높으며, 특히 64비트 시스템에서는 더욱 그렇습니다. OptimizedStruct
는 8바이트의 총계(8바이트 정렬 요구 사항, 24는 8의 배수)가 될 가능성이 높은 반면, UnoptimizedStruct
는 상당한 내부 및 후행 패딩을 포함할 것입니다.
결론
Go의 구조체 메모리 정렬을 이해하는 것은 단순한 학술적 연습이 아니라 효율적인 Go 프로그램을 작성하기 위한 실용적인 기술입니다. 구조체 필드를 의도적으로 정렬함으로써 Go 개발자는 메모리 소비를 크게 줄이고 CPU 캐시 활용도를 향상시켜 더 빠르고 리소스 친화적인 애플리케이션을 만들 수 있습니다. Go 컴파일러가 정확성을 위해 정렬을 처리하지만, 최적의 레이아웃은 개발자의 책임이며, 특히 대규모 데이터 컬렉션이나 고도로 동시적인 워크로드를 다룰 때 성능에 놀라울 정도로 큰 영향을 미칠 수 있습니다. 신중한 구조체 필드 순서 지정은 더 컴팩트한 데이터 구조와 더 나은 캐시 성능으로 이어집니다.