세줄 요약
- Pyroscope에서
gzip.Writer반복 생성과 캐시 데이터 복사를 allocation 병목으로 찾았다. sync.Pool을 이용한 writer 재사용과 한 줄짜리 복사 제거를 각각 PR로 배포했다.- 카나리 기준 요청당 할당량은 37%, CPU는 11% 줄었다. 더 공격적인 세 번째 최적화는 GC 비용을 키워 롤백했다.
운영 중인 Go 서버의 Pyroscope를 살펴보다가 상대적으로 넓은 allocation 경로 두 개를 발견했다. 기능상 문제나 CPU가 부족한 상황도 아니었지만 요청마다 새로 만들고 버리는 메모리가 많았다.
첫 번째는 gzip writer를 매번 새로 만드는 부분이었고, 두 번째는 로컬 캐시에 이미 있는 JSON 바이트를 조회할 때마다 통째로 복사하는 코드였다.
쉽게 말하면 이미 만든 것을 다시 만들고, 읽기 전용 데이터를 매번 복사하는 것이 문제였다.
Pyroscope에서 찾은 두 개의 할당 경로
운영서버의 프로파일에서 CPU와 메모리 할당을 함께 확인했다.
| 경로 | 관찰 결과 |
|---|---|
compress/flate.NewWriter 계열 | 전체 누적 할당의 40.67% |
캐시 hit의 []byte 복사 | 30분 누적 1.11TB, 전체 할당의 약 3.1% |
RedisLineitemIndex.RetrieveLineitems | 전체 CPU의 22.37% |
여기서 1.11TB는 서버가 한 번에 점유한 메모리는 아니고, alloc_space는 조회 구간에 할당된 메모리를 모두 더한 값으로 생성 직후 GC로 회수된 바이트도 포함한다.
현재 살아 있는 메모리를 보려면 inuse_space를 봐야 한다. 두 값을 구분하지 않으면 “메모리를 1TB 썼다”는 잘못된 결론에 도달하기 쉽다.
gzip writer 재사용
기존 코드는 gzip 요청을 보낼 때마다 새 writer를 만들었다.
이미지가 조금 구리지만 왼쪽은 요청마다 gzip 압축기를 새로 만들고 버리는, 오른쪽은 필요한 동안 빌려 쓴 뒤 되돌려놓는 모습이다.
![]()
as-is
gob := &bytes.Buffer{} gzWriter := gzip.NewWriter(b) if _, err := gzWriter.Write(postBody); err != nil { return nil, err } _ = gzWriter.Close()
gzip.NewWriter 내부의 flate.Writer는 압축에 필요한 큰 버퍼와 자료구조를 만들고 시작하는데 요청이 많아질수록 동일한 준비를 반복하고, 사용이 끝난 객체는 다시 GC의 회수 대상이 된다.
sync.Pool이란?
sync.Pool은 여러 goroutine이 잠깐 쓸 객체를 모아두는 공용 대여함이다. 요청이 오면 Get으로 하나를 빌리고, 다 쓴 뒤에는 Put으로 돌려놓는다.
text요청 → Get → writer 사용 → Reset → Put ↓ 비어 있으면 New
pool에 writer가 있으면 새로 만들지 않고 재사용한다. 비어 있으면 New가 하나를 만든다. sync.Pool 자체는 여러 goroutine이 동시에 접근해도 안전하며, 각 요청은 Get으로 꺼낸 writer 하나를 혼자 사용한다.
일반 캐시와 다른 점은 pool 안의 객체가 계속 남는다는 보장이 없다는 점이다.
Go 런타임은 GC 과정에서 pool을 비울 수 있다. 그래도 다음 Get에서 다시 만들기에 기능 상 영향을 주지 않는다.
따라서 사용자 데이터처럼 반드시 보존해야 하는 값은 넣으면 안 되지만, 생성 비용은 크지만 사라져도 다시 만들 수 있는 임시 객체가 적합하고 gzip.Writer가 여기에 해당한다.
to-be
표준 라이브러리의 sync.Pool과 gzip.Writer.Reset을 사용해 writer를 재사용했다.
govar gzipWriterPool = sync.Pool{ New: func() any { return gzip.NewWriter(io.Discard) }, } func writeGzip(dst io.Writer, src []byte) error { w := gzipWriterPool.Get().(*gzip.Writer) w.Reset(dst) defer func() { w.Reset(io.Discard) gzipWriterPool.Put(w) }() if _, err := w.Write(src); err != nil { return err } return w.Close() }
writer를 빌린 뒤 Reset(dst)을 호출하면 “지난 작업은 끝났으니, 이제 현재 요청의 dst에 새 압축 결과를 써라”라는 상태가 된다. 압축은 처음부터 시작하지만 만들기 비싼 내부 작업 공간은 그대로 사용한다.
압축을 마친 writer는 여전히 직전 요청의 출력 buffer를 가리킬 수 있다. 그대로 pool에 넣으면 이미 사용이 끝난 buffer까지 메모리에 남는다.
그래서 반납 직전에 출력 대상을 io.Discard로 바꿔 직전 요청과의 연결을 끊는다. io.Discard는 전달받은 데이터를 저장하지 않는 io.Writer다. 실제 압축 결과를 버리는 것이 아니라, 이전 요청의 buffer를 계속 참조하지 않도록 임시 목적지로 연결한 것이다.
sync.Pool로 write를 재사용하면서 걱정한 지점은 서로 다른 요청이 섞이거나, 두 요청이 같은 writer를 동시에 사용하는 케이스였다. 서로 다른 payload를 연속으로 압축한 뒤 각각 원문으로 복원되는지 테스트했고 race detector로 동시성 이슈도 검증했다.
캐시 hit의 바이트 복사 제거
두 번째 병목은 캐시에서 데이터를 꺼낼 때마다 같은 바이트를 새 배열에 복사하는 코드였다.
왼쪽은 읽는 사람마다 복사본을 만드는 모습, 오른쪽은 원본을 바꾸지 않고 함께 들여다보는 모습이다.
as-is: 새 배열에 복사
goif cached, found := r.dataCache.Get(id); found { cachedBytes := cached.([]byte) result[id] = append([]byte(nil), cachedBytes...) }
append는 새 배열을 만들고 캐시의 바이트를 전부 복사한다. 같은 내용이 메모리에 하나 더 생기는 셈이다.
이 방식은 반환된 바이트가 수정돼도 캐시 원본을 보호한다. 하지만 이 함수의 유일한 소비자인 json.Unmarshal은 입력을 읽기만 하므로 복사본이 필요하지 않았다.
to-be: 캐시 배열을 그대로 공유
goif cached, found := r.dataCache.Get(id); found { // Cached bytes are read-only. result[id] = cached.([]byte) }
새 배열도, 바이트 복사도 만들지 않는다. 반환된 slice가 캐시 데이터가 들어 있는 같은 backing array를 바라보게 했다. backing array는 slice의 실제 데이터가 저장되는 내부 배열이다.
대신 반환된 바이트를 수정하면 캐시 원본도 함께 바뀐다. 그래서 주석으로 read-only 조건을 남겼다.
호출 경로를 확인해 json.Unmarshal이 입력을 변경하지 않는지 검증했다. 테스트에서는 반환값이 캐시와 같은 backing array를 가리키는지도 확인했다.
공개 API였다면 이 최적화를 선택하지 않았을 것이다. 그러나 private 함수이고 소비자가 하나뿐이라 읽기 전용 조건을 좁게 유지할 수 있었다.
카나리에서 실제 효과 확인하기
| 지표 | 변화 |
|---|---|
sendOtherRequests 할당량 | 약 94% 감소 |
loadLineitemData 할당량 | 약 90% 감소 |
| 요청당 전체 할당량 | 약 37% 감소 |
| 요청당 전체 CPU | 약 11% 감소 |
앞서 서두에서 확인한 병목 지점인 compress/flate.NewWriter는 주요 allocation 경로에서 사라졌다. loadLineitemData의 누적 할당 비중도 3.55%에서 0.57%로 줄었다.
이번에 개선한 것은 수많은 작은 객체보다 압축 버퍼와 JSON backing array처럼 비교적 큰 객체였기 때문에, 초당 생성되는 heap object 수는 눈에 띄게 줄지 않았다. 객체 1,000개 중 큰 객체 10개만 없애면 개수는 1% 줄지만 바이트는 크게 줄일 수 있다. (Heap Memory Allocated가 내려가고 Heap Objects Allocated는 대체로 유지됨)
writer를 pool에 보관하는 비용도 있었다. gzip 관련 in-use 비중은 2.24%에서 6.15%로 늘었다. 배포 후 전체 live heap은 약 85.3MiB였고, 당시 2GiB 제한의 약 4.2% 수준이었다.
GC pause p95도 2.15ms로 안정적이었다. 할당 감소와 상주 메모리 증가를 함께 보고, 현재 운영 여유 안에서 받아들일 수 있는 교환이라고 판단했다.
마무리
![]()
Go 서버의 메모리 성능을 볼 때는 할당 바이트 뿐만 아니라 살아 있는 바이트와 객체 수, GC CPU, 요청당 CPU, 지연시간까지 같이 봐야 최적화 지점을 파악할 수 있다. 이번 작업은 이미 만든 writer를 재사용하고 읽기 전용 데이터를 다시 복사하지 않는게 전부였다. 전체적인 응답속도 latency 개선이나 pod instances 개선은 없었지만, 선제적으로 불필요한 메모리 할당 제거로 동일한 요청을 더 적은 CPU와 GC 에서 처리하도록 런타임 효율을 개선할 수 있었다.