
- 메모리 최적화 방법
- 1. Object Pooling
- sync.Pool을 이용한 Object Pooling의 동작 방식
- sync.Pool을 사용하는 경우
- sync.Pool을 피해야 하는 경우
- 2. Memory Preallocation
- 왜 Preallocation이 중요한가?
- Slice Preallocation
- Map Preallocation
- 3. 구조체 필드 Alignment
- Concurrent Workload에서 False Sharing 피하기 기법
- 4. Interface Boxing을 피하라.
- Interface Boxing이 무엇인가?
- 왜 중요하냐면
- Interface Boxing이 쓸모 있는 경우가 있습니다.
- 5. Zero-copy 테크닉
- Golang의 zero-copy 전략
- 1. io.Reader와 io.Writer interface 활용하기
- 2. 데이터 access 효율화를 위한 slicing
- 3. Memory Mapping (mmap)
- 6. 메모리 효율화 : Golang의 GC 마스터하기
- 1. Non-Generational
- 2. Concurrent
- 3. Tri-color Mark and Sweep
- GC 튜닝도 가능합니다.
- 7. Stack allocation and Escape Analysis
- Escape Analysis란 무엇인가?
- 어떤게 변수를 heap으로 escape 하게 만드는가?

Go 애플리케이션 성능 최적화 가이드 | GeekNews
Go 애플리케이션 성능 최적화 가이드고성능 Go 애플리케이션 개발을 위한 기술 자료 모음고성능 API, 마이크로서비스, 분산 시스템을 개발하는 엔지니어를 대상으로 실용적인 패턴과 사례, 저수
news.hada.io
Go Optimization Guide
Patterns and Techniques for Writing High-Performance Applications with Go The Go App Optimization Guide is a collection of technical articles aimed at helping developers write faster, more efficient Go applications. Whether you're building high-throughput
goperf.dev
Golang 최적화 방법을 정리한 문서가 있어서 번역 및 정리를 해두려고합니다.
이번 포스트에서는 메모리 최적화 관련해서 정리를 합니다.
메모리 최적화 방법
1. Object Pooling
Object Pooling은 Performance가 중요한 Go application에서 메모리 할당을 줄일 수 있는 실용적인 방법입니다.
이를 통해서 메모리를 재활용할 수 있고, GC를 하는 CPU time을 줄일 수 있죠.
Golang의 `sync.Pool`은 이를 쉽게 구현할 수 있게 도와줍니다.
sync.Pool을 이용한 Object Pooling의 동작 방식
매번 새로운 메모리를 Heap에서 할당하기보다 Pool에서 가져오는것입니다.
먼저 Object Pooling 없이 코드를 짜보겠습니다.
package main
import (
"fmt"
)
type Data struct {
Value int
}
func createData() *Data {
return &Data{Value: 42}
}
func main() {
for i := 0; i < 1000000; i++ {
obj := createData() // 매번 object를 생성합니다.
_ = obj // Simulate usage
}
fmt.Println("Done")
}
이는 불필요한 할당과 GC pressure를 늘립니다.
Object Pooling을 사용해서 구현해보겠습니다.
package main
import (
"fmt"
"sync"
)
type Data struct {
Value int
}
var dataPool = sync.Pool{
New: func() any {
return &Data{}
},
}
func main() {
for i := 0; i < 1000000; i++ {
obj := dataPool.Get().(*Data) // Pool에서 Object를 가져옵니다.
obj.Value = 42 // 해당 object를 사용하고
dataPool.Put(obj) // 재활용을 위해서 object를 release합니다.
}
fmt.Println("Done")
}
효율적인 I/O를 위해서 Byte Buffer를 Pooling합니다.
Object Pooling은 큰 용량의 byte slices를 작업할때 효율적입니다.
아래와 같이 구현할 수 있습니다.
package main
import (
"bytes"
"fmt"
"sync"
)
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func main() {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset() // 버퍼 초기화
buf.WriteString("Hello, pooled world!")
fmt.Println(buf.String())
bufferPool.Put(buf) // 재활용을 위해서 buffer를 release합니다.
}
이에 대한 벤치마크 자료가 있습니다.
Benchmark | Iterations | Time per op (ns) | Bytes per op | Allocs per op |
BenchmarkWithoutPooling-14 | 1,692,014 | 705.4 | 8,192 | 1 |
BenchmarkWithPooling-14 | 160,440,506 | 7.455 | 0 | 0 |
그래서 sync.Pool은 언제 쓰면 좋을지 아닐지를 아래와 같이 정리할 수 있습니다.
sync.Pool을 사용하는 경우
1. 단기, 재활용가능한 object (e.g. buffers, scratch memory, request state).
- 지속적인 메모리 할당을 피하고 메모리를 효율적으로 재활용 가능
2. 메모리 할당 overhead나 GC가 측정되고 중요한 경우
- heap allocation의 수를 줄이고 재활용해서 GC 주기와 pause time을 줄임
3. object의 수명이 local이고 사용 사이에 reset이 되는 경우
- object가 복잡한 삭제 과정이 없고 안전하게 재활용이 가능한 경우
4. 고 성능을 위해서 GC의 성능 부담을 줄여주고 싶은 경우
- 초당 수천개 이상의 요청을 처리하는 경우 좋다.
sync.Pool을 피해야 하는 경우
1. Object가 수명이 길고, 여러개의 goroutine 사이에서 사용되는 경우
- sync.Pool은 수명이 짧은 object에 최적화 되어있고, 한번쓰는 object에 최적화되어 있습니다.
2. reuse 빈도가 낮고, object가 자주 access 되지 않는 경우
- object가 pool에서 사용빈도가 엄청 낮고 idle하면 오히려 이득은 없고 메모리 낭비가 될 수 있습니다.
3. 예측가능성과 lifecycle control이 메모리 할당 속도보다 중요한 경우
- Pooling 자체가 이를 트래킹 하기 힘들게하기에 그렇습니다.
4. 메모리 아끼는게 무시 가능하고 코드 복잡성을 높이는 경우.
2. Memory Preallocation
Memory Preallocation의 경우 map이나 slice를 다룰때 중요합니다.
왜 Preallocation이 중요한가?
slice나 map은 새로운 element를 받아들이고 확장가능한 형태입니다.
편하지만 이는 overhead를 지속적으로 늘리죠.
slice와 map의 용량이 가득 차면, 새로 할당하고 데이터를 복사해야합니다.
이게 자주 일어날수록 성능을 떨어뜨리죠.
Golang은 조금 특별한 메모리 확장 방식을 씁니다.
1. 처음에 slice 최대 용량은 2배씩 늘어납니다.
2. 1024개의 element가 되면 그때부터는 25%씩 늘립니다.
s := make([]int, 0)
for i := 0; i < 10_000; i++ {
s = append(s, i)
fmt.Printf("Len: %d, Cap: %d\n", len(s), cap(s))
}
결과
Len: 1, Cap: 1
Len: 2, Cap: 2
Len: 3, Cap: 4
Len: 5, Cap: 8
...
Len: 1024, Cap: 1024
Len: 1025, Cap: 1280
Slice Preallocation
// Inefficient
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i)
}
// Efficient
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}
// Efficient
result := make([]int, 10000)
for i := range result {
result[i] = i
}
Map Preallocation
// Inefficient
m := make(map[int]string)
for i := 0; i < 10000; i++ {
m[i] = fmt.Sprintf("val-%d", i)
}
// Efficient
m := make(map[int]string, 10000)
for i := 0; i < 10000; i++ {
m[i] = fmt.Sprintf("val-%d", i)
}
벤치마크 결과는 아래와 같습니다.
Benchmark | Iterations | Time per op (ns) | Bytes per op | Allocs per op |
BenchmarkAppendNoPrealloc-14 | 41,727 | 28,539 | 357,626 | 19 |
BenchmarkAppendWithPrealloc-14 | 170,154 | 7,093 | 81,920 | 1 |
3. 구조체 필드 Alignment
CPU는 메모리 layout에 영향을 많이 받습니다.
다양한 언어에서도 적용됩니다. Golang도 마찬가지입니다.
// BAD : 24byte
type PoorlyAligned struct {
flag bool // 1byte
count int64 // 8byte
id byte // 1byte
}
// GOOD : 16byte
type WellAligned struct {
count int64 // 8byte
flag bool // 1byte
id byte // 1byte
}
Concurrent Workload에서 False Sharing 피하기 기법
memory layout에 관련해서 concurrent system에서 구조체 필드 alignment는 중요한 역할을 합니다.
많은 Goroutine이 같은 CPU cache에 있는 같은 구조체의다른 필드에 접근하는 상황이면 아마 false sharing을 겪게 될것입니다.
즉, 논리적으로 두개가 전혀 관련이 없어도 서로의 변경이 다른 하나의 invalidation을 만들게됩니다.
현대 CPU는 일반적인 Cache line은 64byte입니다.
구조체가 메모리에서 접근 가능하면 CPU는 이 모든것을 cache line에 담습니다.
그래서 같은 64byte block 안의 2개의 관련없는 field가 같은 cache line으로 들어가게됩니다.
이 두개는 서로 다른 go routine에서 독립적으로 사용되더라도 말이죠.
만약 하나의 goroutine이 하나의 field에 쓰기를 하게 되면 그 Cache line은 invalidate 되고 이는 False sharing이나 성능 저하를 만듭니다.
코드는 아래와 같은 예시를 들 수 있습니다.
type SharedCounterBad struct {
a int64
b int64
}
type SharedCounterGood struct {
a int64
_ [56]byte // 패딩을 해서 a,b가 서로 같은 cache line에 못들어가게합니다.
b int64
}
아래와 같은 예시에서 진가를 발휘합니다.
func BenchmarkFalseSharing(b *testing.B) {
var c SharedCounterBad //
var wg sync.WaitGroup
b.ResetTimer()
for i := 0; i < b.N; i++ {
wg.Add(2)
go func() {
for i := 0; i < 1_000_000; i++ {
c.a++
}
wg.Done()
}()
go func() {
for i := 0; i < 1_000_000; i++ {
c.b++
}
wg.Done()
}()
wg.Wait()
}
}
성능은 대략 3.8%정도 더 빠릅니다.
4. Interface Boxing을 피하라.
이게 무슨말일까요.
Golang의 interface는 코드를 쓰기 편하게 해주지만 그런 편안함은 성능을 낮춥니다.
특정 값을 interface에 할당하면, Go는 이를 숨겨진 structure에 감쌉니다.
이것을 interface boxing이라고 하죠.
좀 더 알아보겠습니다.
Interface Boxing이 무엇인가?
interface boxing은 특정 value를 interface(any) type 으로 변환하는 과정을 의미합니다.
interface value는 내부적으로 2개로 구성됩니다.
- type descriptor : 구체적 type을 의미
- data pointer : 실제 value를 가리키는 포인터
특정 값을 Inteface에 할당하면, Golang은 2개의 word structure를 생성하게됩니다.
만약 값이 포인터가 아니고 heap-allocated가 되어 있지 않다면 Go는 아마 heap에 이를 복사합니다.
그래서 값이 크거나 여러 값을 할당하면 문제가 생기는 것이죠.
간단한 예제를 보겠습니다.
var i interface{}
i = 42
위와 같은 경우 42는 interface로 들어가서 type intormation인 int와 value인 42를 둘 다 저장합니다.
이건 int를 쓰는것 보다 비싸죠
다른 예제입니다.
type Shape interface {
Area() float64
}
type Square struct {
Size float64
}
func (s Square) Area() float64 { return s.Size * s.Size }
func main() {
var shapes []Shape
for i := 0; i < 1000; i++ {
s := Square{Size: float64(i)}
shapes = append(shapes, s) // Boxing이 여기서생깁니다.
}
}
위의 boxing을 피하기 위해서 아래와 같이 수정할 수 있습니다.
shapes = append(shapes, &s) // 큰 struct 복사를 막고 포인터만 담습니다.
왜 중요하냐면
loop 혹은 고 성능을 내야하는 경로, 예를들면 unmarshalling JSON, template 렌더링, 많은 데이터 처리 등에서
boxing은 불필요한 메모리 할당과 GC를 늘립니다.
아래와 같은 예시에서 pointer를 넘겨주는것 보다 value를 넘겨주는게 19% 성능 향상을 보였습니다.
포인터를 넘겨주면 8byte만 복사하지만, value는 4096바이트 전체를 복사하거든요
type Worker interface {
Work()
}
type LargeJob struct {
payload [4096]byte // 4096 byte 구조체입니다.
}
func (LargeJob) Work() {}
// 아래는 실제 함수들
var sink []Worker
func BenchmarkBoxedLargeSlice(b *testing.B) {
jobs := make([]Worker, 0, 1000)
for i := 0; i < b.N; i++ {
jobs = jobs[:0]
for j := 0; j < 1000; j++ {
var job LargeJob
jobs = append(jobs, job) // 값 할당 방식
}
sink = jobs
}
}
func BenchmarkPointerLargeSlice(b *testing.B) {
jobs := make([]Worker, 0, 1000)
for i := 0; i < b.N; i++ {
jobs := jobs[:0]
for j := 0; j < 1000; j++ {
job := &LargeJob{} // 포인터 할당 방식
jobs = append(jobs, job)
}
sink = jobs
}
}
그렇다면 Interface Boxing은 항상 쓸모없을까요?
Interface Boxing이 쓸모 있는 경우가 있습니다.
1. abtraction이 성능 보다 중요한경우
2. value가 작은 경우
3. value가 수명이 짧은 경우
4. 동적인 동작이 필요한경우
for _, s := range []Shape{Circle{}, Square{}} {
fmt.Println(s.Area())
}
위와 같은 경우를 의미합니다.
5. Zero-copy 테크닉
zero copy는 많이 쓰는 테크닉입니다.
전통적으로 user-space 버퍼와 커널 버퍼 사이에서 데이터를 복사해서 읽거나 쓰는게 많았죠.
그런 동작은 CPU와 메모리를 소모시킵니다.
이를 그냥 복사하지 않고 전달하는게 zero-copy입니다.
Golang의 zero-copy 전략
1. io.Reader와 io.Writer interface 활용하기
io.Reader와 io.Writer를 사용하면 효과적으로 버퍼를 재사용하고 copy를 줄일 수 있습니다.
func StreamData(src io.Reader, dst io.Writer) error {
buf := make([]byte, 4096) // 재사용가능한 buffer입니다.
_, err := io.CopyBuffer(dst, src, buf)
return err
}
io.CopyBuffer가 반복된 allocation과 copy를 없앨 수 있습니다.
src -> dst로 복사할때 buffer를 하나만 할당하고 여러번 재활용하는거죠.
2. 데이터 access 효율화를 위한 slicing
Slicing은 효율적인 방법입니다.
func process(buffer []byte) []byte {
return buffer[128:256] // returns a slice reference without copying
}
3. Memory Mapping (mmap)
mmap을 사용하면 read operation 없이 파일 정보에 직접 접근이 가능합니다.
import "golang.org/x/exp/mmap"
func ReadFileZeroCopy(path string) ([]byte, error) {
r, err := mmap.Open(path)
if err != nil {
return nil, err
}
defer r.Close()
data := make([]byte, r.Len())
_, err = r.ReadAt(data, 0)
return data, err
}
결과
Benchmark | Time per op (ns) | Bytes per op | Allocs per op |
BenchmarkCopy | 4246 | 65536 | 1 |
BenchmarkSlice | 0.592 | 0 | 0 |
성능 비교를 해보자면 아래와 같습니다.
4MB 파일을 읽는 동작입니다.
func BenchmarkReadWithCopy(b *testing.B) {
f, err := os.Open("testdata/largefile.bin")
if err != nil {
b.Fatalf("failed to open file: %v", err)
}
defer f.Close()
buf := make([]byte, 4*1024*1024) // 4MB buffer
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := f.ReadAt(buf, 0)
if err != nil && err != io.EOF {
b.Fatal(err)
}
sink = buf
}
}
func BenchmarkReadWithMmap(b *testing.B) {
r, err := mmap.Open("testdata/largefile.bin")
if err != nil {
b.Fatalf("failed to mmap file: %v", err)
}
defer r.Close()
buf := make([]byte, r.Len())
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := r.ReadAt(buf, 0)
if err != nil && err != io.EOF {
b.Fatal(err)
}
sink = buf
}
}
Benchmark | Time per op (ns) | Bytes per op | Allocs per op |
ReadWithCopy | 94,650 | 0 | 0 |
ReadWithMmap | 50,082 | 0 | 0 |
6. 메모리 효율화 : Golang의 GC 마스터하기
A Guide to the Go Garbage Collector - The Go Programming Language
A Guide to the Go Garbage Collector - The Go Programming Language
Documentation A Guide to the Go Garbage Collector A Guide to the Go Garbage Collector Introduction This guide is intended to aid advanced Go users in better understanding their application costs by providing insights into the Go garbage collector. It also
go.dev
여기 페이지에 GC의 garbage collector 내용이 정리 되어있는데요.
일부를 정리해보겠습니다.
Golang은 non-generational, concurrent, tri-color mark-and-sweep garbage collector를 사용합니다.
1. Non-Generational
JVM이나 .NET 의 현대 GC 방식은 대부분의 Object가 일찍 사라지는것에서 착안해 메모리를 generation을 나누는것을 합니다.
Young and Old memory로요. 주로 Young Memory에 집중을 해서 짧은 Collection cycle을 하죠.
Golang은 다른 접근을 합니다.
모든 Object를 Generation 없이 동일하게 다룹니다. (non-generational segmentation)
이 선택은 승격 논리와 특수화된 메모리 영역의 복잡성을 방지합니다.
전체적으로 더 많은 스캔을 하는 것을 의미 할 수 있긴하지만, Concurrent + Efficient write barriers로 이를 상쇄합니다.
2. Concurrent
Go가 Concurrent하게 GC한다는 의미는 대부분의 경우 Stop the world 하지 않고 동작한다는것을 의미합니다.
대부분의 경우 Concurrent 하긴하나 아직도 Stop the world(STW)는 필요하긴합니다.
이런 stop은 굉장히 짧아서 큰 heap이나 수백개의 goroutine을 써도 100 microsecond 정도밖에 안걸리긴 합니다.
STW단계는 아래와 같습니다.
- STW Start Pahse : GC를 실행하기 위해 application이 멈춥니다. stack, global, root object들을 스캔합니다.
- Concurrent Mark Phase : GC는 heap graph를 탐색하고 프로그램에서 reachable object를 병렬적으로 표시합니다. 최소한의 간섭으로 동시에 실행됩니다.
- STW Mark Termination : 최종 marking과 consistency를 위해서 잠깐 멈춥니다.
- Concurrent Sweep Phase : 프로그램이 실행되는 동안 GC는 unreachable한 object로부터 memory를 reclaim 하고 heap으로 보냅니다.
write barrier는 concurrent marking 동안에 정확도를 보장합니다.
3. Tri-color Mark and Sweep
이 알고리즘은 세가지 heap object로 구분합니다.
- White : Unreachable objects (candidates for collection)
- Grey : Reachable but not fully scanned (discovered not processed)
- Black : Reachable and fully scanned (safe from collection)
GC는 root object를 grey로 표시하면서 시작하며 각 grey object를 처리하고 object의 각 field를 스캔합니다.
- marked 되지 않았으나 referenced object들은 grey set으로 보냅니다.
- 모든 reference 스캔이 끝나면 object는 black으로 됩니다.
mark 종료후에도 white인 object는 unreachable하고 sweep phase에서 sweep 됩니다.
최적화의 핵심은 incremental marking입니다.
GC 튜닝도 가능합니다.
1. 메모리 제한을 튜닝하기
시스템이나 컨테이너가 strict하게 메모리를 관리할 수 있게 해줍니다.
GOGC=100 GOMEMLIMIT=4GiB ./your-service
2. GOGC=off 튜닝하기
GOMEMLIMIT=2GiB GOGC=off ./my-app
이는 2GB메모리 사용시 GC를 합니다.
GOGC=off의 경우 default GC 알고리즘을 끄고 메모리 사용량이 GOMEMLIMIT에 다다른 경우 GC합니다.
7. Stack allocation and Escape Analysis
성능이 중요한 Go application에서 heap이 아닌 stack에 값들을 할당하는것은 중요합니다.
stack이 더 싸고 빠르고 garbage-free 하기 때문이죠
Go는 자동으로 stack에다가 항상 변수들을 배치하지는 않습니다.
이 결정은 Escape Analysis를 통해서 stack에 값을 할당할지말지를 결정합니다.
Escape Analysis란 무엇인가?
Go 컴파일러가 하는 정적인 분석이고 변수가 안전하게 stack 혹은 heap의 escape로 이동하는것을 돕습니다.
왜냐면
- Stack allocation이 더 싸기 때문이고
- Heap allocation이 더 비싸기 때문입니다. (GC 해야함)
e.g. Stack vs Heap
func allocate() *int {
x := 42
return &x // x escapes to the heap
}
func noEscape() int {
x := 42
return x // x stays on the stack
}
allocate()함수에서는 x가 pointer로 리턴됩니다.
pointer가 함수를 escape하기에 Go 컴파일러는 x를 heap에 둡니다.
어떤게 변수를 heap으로 escape 하게 만드는가?
1. 지역 변수를 pointer로 return
func escape() *int {
x := 10
return &x // escapes
}
2. Closures에서 variable을 capture 하는 경우
func closureEscape() func() int {
x := 5
return func() int { return x } // x escapes
}
3. Interface Conversions
func toInterface(i int) interface{} {
return i // escapes if type info needed at runtime
}
4. 전역 변수 혹은 struct field에 값을 할당하는 경우
var global *int
func assignGlobal() {
x := 7
global = &x // escapes
}
5. Large Composite Literal의 경우
func makeLargeSlice() []int {
s := make([]int, 10000) // may escape due to size
return s
}
'백엔드 > Golang' 카테고리의 다른 글
Golang 애플리케이션 성능 최적화 - 2. 동시성 및 동기화 최적화 (0) | 2025.04.13 |
---|---|
Golang Convention : Error wrapping vs Opaque error (0) | 2024.11.17 |
Golang Convention 중 논의를 해야 할 사항 정리 (0) | 2024.11.10 |
Golang 에러 처리 - (1) Google Guide/Best Practice 찾아보기 (2) | 2024.09.07 |
Golang zero-value 알아보기 (0) | 2024.07.09 |
개발 및 IT 관련 포스팅을 작성 하는 블로그입니다.
IT 기술 및 개인 개발에 대한 내용을 작성하는 블로그입니다. 많은 분들과 소통하며 의견을 나누고 싶습니다.