Go

Go에서 구조체 패킹(structure packing) 이해하기

드리프트2 2024. 12. 28. 10:20

 

Go 언어에서 구조체 두 개가 정확히 똑같은 필드를 가지고 있더라도, 하나가 다른 하나보다 메모리를 더 많이 또는 더 적게 요구할 수 있다는 사실이 놀라울 수 있는데요.

 

일반적으로 필요한 것보다 더 많은 메모리를 사용하지 않도록 하는 것이 좋기 때문에, 구조체 패킹(structure packing)이라는 기술과 Go 프로그래밍 언어에 어떻게 적용할 수 있는지 알아보겠습니다.

예제 구조체 만들기

다음 예제 코드를 살펴보겠습니다. 이 코드는 두 개의 사용자 정의 타입을 정의하는데, 그 중 하나는 세 개의 필드를 포함하는 구조체입니다.

package main

type City uint8

const (
        NewYork City = iota
        London
        Paris
        Mumbai
)

type Person struct {
        currentResidence City
        uniqueID         int64
        passportNumber   int16
}

 

여기서 몇 가지 예시로 City 상수를 선언했는데요.

 

이 타입이 전 세계 네 곳의 다른 위치를 나타내는 데 어떻게 사용될 수 있는지 알 수 있습니다.

iota 키워드는 각 상수에 고유한 숫자 값이 할당되도록 합니다.

 

이제 Person 변수를 선언하고 얼마나 많은 메모리가 필요한지 확인해 보겠습니다.

package main

import (
        "fmt"
        "unsafe"
)

func main() {
        me := Person{
                currentResidence: London,
                uniqueID:         9248511308,
                passportNumber:   10564,
        }

        fmt.Printf(
                "My Person struct uses %d bytes.\n",
                unsafe.Sizeof(me),
        )
}

 

이 코드를 실행하면 Person 구조체가 데이터를 저장하는 데 총 24바이트를 사용한다는 것을 알 수 있습니다.

 

그런데 어떻게 그럴 수 있을까요?

 

단계별로 생각해 보겠습니다.

uniqueID 필드는 64비트 숫자이므로 8바이트의 저장 공간이 필요합니다.

passportNumber 필드는 16비트 숫자이므로 2바이트의 저장 공간만 필요합니다.

currentResidence 필드는 8비트 숫자로 저장되는 사용자 정의 City 타입이므로 1바이트의 저장 공간만 필요합니다.

 

(필드의 숫자 값이 부호가 있는지 여부는 고려하지 않았는데요.

 

부호 없는 정수는 부호 있는 정수와 메모리에서 정확히 동일한 공간을 차지하기 때문입니다.

 

따라서 부호 없는 정수의 최댓값이 부호 있는 정수의 최댓값보다 항상 큰 것입니다. 부호 있는 정수는 이미 숫자의 부호를 저장하는 데 1비트를 사용했을 것이기 때문입니다.)

 

세 필드를 별도로 저장하는 데 필요한 저장 공간을 합산하면 11바이트(8 + 2 + 1 == 11)에 불과합니다.

 

하지만 방금 본 것처럼 Person 구조체는 그보다 두 배 이상 많은 메모리를 사용하는 것 같습니다!

 

데이터 정렬에 대해 생각해보기

데이터 크기를 측정할 때 바이트 단위로 이야기했습니다.

 

하지만 컴퓨터의 CPU는 데이터를 바이트 단위로 읽지 않고, 워드(word) 단위로 읽습니다.

 

워드는 64비트 시스템에서 8바이트와 같습니다.

 

여러분이 여전히 사용하고 있을 수 있는 구형 32비트 시스템에서는 워드가 4바이트와 같습니다.

 

컴퓨터는 데이터를 워드의 배수로 읽는 경향이 있다는 점을 기억하는 것이 중요한데요.

 

이것이 바로 Person 구조체가 24바이트를 사용한 이유를 설명해줍니다.

 

uniqueID 필드(64비트 정수)만 워드를 완전히 사용했지만, 각 필드에 대해 단일 워드를 사용하고 있었습니다.

 

제 컴퓨터는 64비트 프로세서를 사용하므로 각 워드가 8바이트이면 24라는 숫자가 어떻게 나오는지 쉽게 이해할 수 있습니다(8 * 3 == 24).

 

거대한 주차 공간에 작은 노란색 자동차가 주차되어 있습니다. 양쪽에는 두 대의 거대한 흰색 버스가 있습니다.


주차 공간은 정해진 크기이지만 큰 버스뿐만 아니라 작은 자동차도 주차할 수 있습니다.

 

마찬가지로 작은 필드는 더 큰 필드와 같은 방식으로 메모리의 워드에 들어갈 수 있습니다.


passportNumber 필드는 16비트 정수이므로 2바이트의 저장 공간만 필요하지만, 여전히 메모리에서 전체 워드에 액세스하지만 그 중 첫 1/4만 사용하고 나머지 3/4는 사용하지 않습니다.

 

마찬가지로 currentResidence 필드는 궁극적으로 8비트 정수와 같은 사용자 정의 City 타입이므로 실제로 1바이트의 저장 공간만 필요하지만 여전히 메모리에서 전체 워드가 액세스되고 마지막 7바이트는 전혀 사용되지 않습니다.

 

구조체가 필요로 하는 메모리 양 줄이기

uniqueID 필드는 64비트 숫자를 나타낼 수 없게 되므로 전체 워드를 사용해야 하므로 메모리 사용 공간을 줄이기 위해 할 수 있는 일이 많지 않습니다.

 

하지만 passportNumbercurrentResidence 필드를 합쳐서 3바이트만 사용하기 때문에 단일 워드에 넣을 수 있다는 것을 눈치채셨나요?

 

실제로 Person 구조체를 정의할 때 이 두 필드의 순서를 변경하기만 하면 Go 컴파일러가 이 두 필드에 대해 메모리의 한 워드를 공유하도록 강제할 수 있습니다.

 

이전 예제에서 정의했을 때 uniqueID 필드를 다른 두 필드 사이에 배치했기 때문에 작은 필드가 그들 사이에 있는 전체 워드로 분리되어 메모리를 공유할 수 없었습니다.

 

반면에 passportNumbercurrentResidence 필드를 구조체의 첫 번째 또는 마지막 필드로 함께 배치하면 아래 예제와 같이 갑자기 메모리를 공유할 수 있습니다.

package main 

type Person struct {
        uniqueID         int64
        passportNumber   int16
        currentResidence City // remember that this is really a uint8
}

 

이 약간 수정된 Person 정의로 이전에 작성한 main 함수를 실행하면 이제 동일한 데이터가 제 컴퓨터에서 16바이트의 메모리만 사용하여 저장되는 것을 볼 수 있습니다.

 

이것은 여전히 필드가 총 11바이트의 메모리가 필요하다고 계산한 것보다 많지만, 64비트 워드인 8바이트의 배수만 사용할 수 있기 때문에 8바이트는 충분하지 않으므로 구조체를 가능한 한 작게 만들 수 있습니다.

 

Person 구조체의 순서는 우리가 시작했던 것보다 확실히 더 효율적이며, 실제로 이제 가능한 한 효율적입니다.

 

구조체의 필드를 재정렬하여 메모리 사용량을 줄이는 것이 바로 이 글의 맨 처음에 사용했던 구조체 패킹이라는 용어의 의미입니다.

 

fieldalignment 도구 사용하기

Go 팀은 코드에서 필드가 효율적으로 정렬되지 않은 구조체를 검색하는 fieldalignment라는 공식 도구를 개발했습니다.

다음 명령을 실행하여 이 도구를 설치할 수 있습니다.

go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest

 

그런 다음 다음과 같이 적절한 경로를 지정하여 Go 파일 또는 패키지에서 프로그램을 실행할 수 있습니다.

fieldalignment main.go

 

초기 예제 코드에서 fieldalignment 도구를 실행하면 다음과 같은 출력이 생성됩니다.

main.go:12:13: struct of size 24 could be 16

 

아래와 같이 프로그램을 실행할 때 --fix 플래그를 포함하면 Go 파일을 자동으로 편집하여 구조체 필드가 가장 효율적인 순서가 되도록 합니다.

fieldalignment --fix main.go

 

이와 같이 도구를 사용하면 메모리를 가장 효율적으로 사용하기 위해 구조체의 필드를 수동으로 재정렬하는 것에 대해 걱정할 필요가 없습니다.

 

앞에서 fieldalignment 도구를 사용하는 방법을 소개했는데요, 이 도구를 활용하면 Go 코드에서 구조체의 필드 순서가 최적화되지 않은 부분을 쉽게 찾고 수정할 수 있습니다.

 

이 도구를 사용하면 메모리 사용을 효율화하기 위해 구조체 필드를 직접 재정렬하는 번거로움을 덜 수 있습니다. 정말 편리한 도구라고 할 수 있겠습니다.

 

마무리

Go 언어에서 구조체의 메모리 사용량을 최적화하는 방법에 대해 자세히 살펴봤는데요.

 

구조체 패킹이라는 개념을 이해하고 fieldalignment 도구를 활용하면, 메모리 사용량을 줄이고 프로그램의 성능을 향상시킬 수 있습니다.

 

특히, fieldalignment 도구는 구조체 필드 순서를 자동으로 최적화해주기 때문에, 개발자가 직접 필드 순서를 고민해야 하는 수고를 덜어줍니다. 이 도구를 사용하면 메모리 최적화를 손쉽게 달성할 수 있습니다.

 

이 글이 Go 언어에서 구조체의 메모리 사용량을 이해하고 최적화하는 데 도움이 되었기를 바랍니다.

 

구조체 패킹과 fieldalignment 도구를 잘 활용하여 더 효율적인 Go 프로그램을 작성해 보시길 바랍니다.

 

전체 정리 및 최종 다듬기

지금까지 Go 언어에서 구조체 메모리 최적화에 대해 알아봤습니다. 처음에는 두 구조체가 같은 필드를 가지고 있어도 메모리 사용량이 다를 수 있다는 사실에 놀라셨을 수도 있는데요. 이제 그 이유와 해결 방법을 이해하셨을 겁니다.

 

핵심은 구조체 패킹(Structure Packing) 이라는 기술과 데이터 정렬(Data Alignment) 이라는 개념인데요.

1. 데이터 정렬 (Data Alignment)

  • 컴퓨터 CPU는 데이터를 워드(Word) 단위로 읽습니다.
  • 64비트 시스템에서 1 워드는 8바이트, 32비트 시스템에서는 4바이트입니다.
  • CPU는 효율적인 처리를 위해 데이터를 워드 단위로 정렬합니다.

2. 구조체 패킹 (Structure Packing)

  • 데이터 정렬로 인해 발생하는 메모리 낭비를 최소화하기 위해 구조체 필드 순서를 조정하는 기술입니다.
  • 작은 크기의 필드를 묶어서 하나의 워드에 배치하면 메모리 사용량을 줄일 수 있습니다.
  • uniqueID(int64, 8바이트), passportNumber(int16, 2바이트), currentResidence(City, 1바이트) 예시에서 uniqueID를 먼저 배치하고, 나머지 작은 필드들을 뒤에 배치하면 16바이트로 줄일 수 있었습니다.

3. fieldalignment 도구

  • Go 팀에서 제공하는 공식 도구입니다.
  • 코드 내에서 구조체 필드 순서가 최적화되지 않은 부분을 찾아줍니다.
  • --fix 옵션을 사용하면 자동으로 필드 순서를 최적화하도록 코드를 수정해줍니다.
  • 설치 및 사용 방법:
  • # 설치 go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest # 검사 fieldalignment main.go # 자동 수정 fieldalignment --fix main.go