Go 데이터 타입의 메모리 사용량을 이해하는 것은 메모리 효율적인 프로그램을 작성하는 데 매우 중요합니다. 특히 대용량 데이터를 처리하거나 임베디드 시스템, 고성능 애플리케이션에서는 데이터 구조 선택이 성능에 직접적인 영향을 미칩니다.
데이터 타입별 메모리 크기
| 데이터 타입 | 크기 (바이트) | 비고 |
|---|---|---|
| int / uint | 8 / 8 | 64비트 시스템에서는 8바이트, 32비트에서는 4바이트 |
| int8 / uint8 | 1 / 1 | |
| int16 / uint16 | 2 / 2 | |
| int32 / uint32 | 4 / 4 | |
| int64 / uint64 | 8 / 8 | |
| float32 | 4 | IEEE 754 단정밀도 부동소수점 |
| float64 | 8 | IEEE 754 배정밀도 부동소수점 |
| complex64 | 8 | float32 × 2 (실수부와 허수부) |
| complex128 | 16 | float64 × 2 (실수부와 허수부) |
| byte (uint8의 별칭) | 1 | |
| rune (int32의 별칭) | 4 | 유니코드 코드 포인트 |
| bool | 1 | 내부적으로 1바이트 사용 |
| string | 16 | 포인터(8) + 길이(8), 실제 데이터는 별도 영역 |
| 슬라이스 (예: []int) | 24 | 포인터(8) + 길이(8) + 용량(8) |
| 맵 (예: map[string]int) | 8 | 내부 구조체로의 포인터 |
| 채널 (예: chan int) | 8 | 내부 구조체로의 포인터 |
| 구조체 (예: struct{}) | 0 | 필드가 없으면 0 |
| 인터페이스 | 16 | 타입 정보(8) + 값의 포인터(8) |
※ 크기는 64비트 시스템의 표준 값입니다. 32비트 시스템에서는 포인터 크기가 4바이트입니다.
샘플 구현
크기 확인
unsafe.Sizeof를 사용하여 런타임에 데이터 타입의 크기를 확인할 수 있습니다.
package main
import ( "fmt" "unsafe")
func main() { // 기본 타입 var i int var i8 int8 var f32 float32 var f64 float64 var b bool var r rune
fmt.Printf("int: %d bytes\n", unsafe.Sizeof(i)) fmt.Printf("int8: %d bytes\n", unsafe.Sizeof(i8)) fmt.Printf("float32: %d bytes\n", unsafe.Sizeof(f32)) fmt.Printf("float64: %d bytes\n", unsafe.Sizeof(f64)) fmt.Printf("bool: %d bytes\n", unsafe.Sizeof(b)) fmt.Printf("rune: %d bytes\n", unsafe.Sizeof(r))
// 문자열, 슬라이스, 맵 var s string var slice []int var m map[string]int
fmt.Printf("string: %d bytes\n", unsafe.Sizeof(s)) fmt.Printf("slice: %d bytes\n", unsafe.Sizeof(slice)) fmt.Printf("map: %d bytes\n", unsafe.Sizeof(m))
// 인터페이스 var iface interface{} fmt.Printf("interface{}: %d bytes\n", unsafe.Sizeof(iface))}구조체 패딩 확인
구조체는 필드 배치 순서에 따라 메모리 효율성이 달라집니다. 패딩으로 인해 예상보다 많은 메모리를 소비할 수 있습니다.
package main
import ( "fmt" "unsafe")
// 비효율적인 배치type BadLayout struct { a bool // 1 byte + 7 bytes padding b int64 // 8 bytes c bool // 1 byte + 7 bytes padding d int64 // 8 bytes}
// 효율적인 배치type GoodLayout struct { b int64 // 8 bytes d int64 // 8 bytes a bool // 1 byte c bool // 1 byte + 6 bytes padding}
func main() { fmt.Printf("BadLayout: %d bytes\n", unsafe.Sizeof(BadLayout{})) // 32 bytes fmt.Printf("GoodLayout: %d bytes\n", unsafe.Sizeof(GoodLayout{})) // 24 bytes}슬라이스 용량과 메모리 할당
슬라이스 용량을 적절히 관리하면 불필요한 재할당을 피할 수 있습니다.
package main
import ( "fmt")
func main() { // 용량을 지정하지 않으면 추가할 때마다 재할당이 발생할 수 있음 s1 := []int{} for i := 0; i < 1000; i++ { s1 = append(s1, i) }
// 사전에 용량을 확보하여 재할당 방지 s2 := make([]int, 0, 1000) for i := 0; i < 1000; i++ { s2 = append(s2, i) }
fmt.Printf("s1 len: %d, cap: %d\n", len(s1), cap(s1)) fmt.Printf("s2 len: %d, cap: %d\n", len(s2), cap(s2))}맵 사전 할당
맵도 초기 용량을 지정하면 동적 확장으로 인한 비용을 줄일 수 있습니다.
package main
import ( "fmt")
func main() { // 용량을 지정하지 않은 경우 m1 := make(map[int]string) for i := 0; i < 1000; i++ { m1[i] = fmt.Sprintf("value%d", i) }
// 용량을 사전에 확보 m2 := make(map[int]string, 1000) for i := 0; i < 1000; i++ { m2[i] = fmt.Sprintf("value%d", i) }
fmt.Printf("m1 length: %d\n", len(m1)) fmt.Printf("m2 length: %d\n", len(m2))}응용 아이디어 및 최적화 기법
1. 구조체 필드 배치 최적화
큰 필드를 먼저 배치하고 작은 필드를 나중에 배치하여 패딩을 최소화합니다.
// 최적화 전: 32 bytestype Before struct { flag1 bool // 1 + 7 padding num1 int64 // 8 flag2 bool // 1 + 7 padding num2 int64 // 8}
// 최적화 후: 24 bytestype After struct { num1 int64 // 8 num2 int64 // 8 flag1 bool // 1 flag2 bool // 1 + 6 padding}2. 적절한 슬라이스 용량 설정
최종 요소 수를 예측할 수 있으면 make로 용량을 지정하여 메모리 재할당을 피합니다.
// 1000개 요소를 추가할 경우items := make([]Item, 0, 1000)3. 배열과 슬라이스 구분 사용
요소 수가 고정되어 변경되지 않으면 슬라이스보다 배열이 메모리 효율적입니다.
// 슬라이스: 24 bytes (헤더) + 실제 데이터s := []int{1, 2, 3}
// 배열: 24 bytes (int × 3, 데이터만)a := [3]int{1, 2, 3}4. 빈 인터페이스 사용 최소화
interface{}는 타입 정보와 포인터로 16바이트를 소비하므로, 타입이 명확하면 제네릭이나 구체적 타입을 사용합니다.
// 빈 인터페이스 사용 (16 bytes)var v interface{} = 42
// 구체적 타입 사용 (8 bytes)var v int = 425. 문자열 연결에 Builder 사용
문자열은 불변이므로 + 연산자로 연결하면 매번 새 메모리 영역을 할당합니다. 대량 연결에는 strings.Builder를 사용합니다.
import "strings"
var b strings.Builderfor i := 0; i < 1000; i++ { b.WriteString("item")}result := b.String()6. sync.Pool로 객체 재사용
자주 생성/삭제되는 객체는 sync.Pool로 재사용하여 가비지 컬렉션 부하를 줄입니다.
import "sync"
var bufferPool = sync.Pool{ New: func() interface{} { return make([]byte, 1024) },}
// 사용buf := bufferPool.Get().([]byte)defer bufferPool.Put(buf)7. 포인터로 큰 구조체 복사 방지
큰 구조체를 함수에 전달할 때는 값 전달 대신 포인터 전달로 복사 비용을 줄입니다.
// 값 전달: 구조체 전체가 복사됨func ProcessValue(data BigStruct) { // ...}
// 포인터 전달: 포인터(8 bytes)만 복사됨func ProcessPointer(data *BigStruct) { // ...}8. 메모리 프로파일링으로 문제 영역 식별
pprof를 사용하여 메모리 사용량을 시각화하고 최적화할 부분을 식별합니다.
import _ "net/http/pprof"import "net/http"
func main() { go func() { http.ListenAndServe("localhost:6060", nil) }() // 애플리케이션 로직}브라우저에서 http://localhost:6060/debug/pprof/heap에 접속하거나 go tool pprof로 프로파일을 분석할 수 있습니다.
요약
- 기본 타입은 명시적인 크기를 가지며, 문자열·슬라이스·맵·인터페이스는 포인터 기반
- 구조체 필드 배치 순서가 패딩에 영향을 미쳐 메모리 효율성이 달라짐
- 슬라이스와 맵에 초기 용량을 지정하면 재할당 비용을 줄일 수 있음
- 큰 구조체는 포인터 전달하고, 자주 생성되는 객체는
sync.Pool로 재사용 pprof로 메모리 프로파일링을 수행하여 최적화 대상 파악
이러한 지식을 활용하면 메모리 효율적인 Go 프로그램을 작성할 수 있습니다.
hsb.horse