[Go 언어 탐구] 슬라이스 용량(Capacity)은 어떻게 늘어날까? append의 비밀 파헤치기 (Go 1.23 기준)
안녕하세요!
Go 언어의 슬라이스(slice)는 정말 유연하고 편리한 데이터 구조인데요.
특히 append
함수를 사용하면 슬라이스 길이에 신경 쓰지 않고도 간편하게 요소를 추가할 수 있습니다.
슬라이스의 용량(capacity)이 부족해지면 append
함수가 알아서 더 큰 메모리 공간을 확보하고 기존 요소들을 복사해주기 때문이죠.
그런데 문득 이런 궁금증이 생기지 않으십니까?
"대체 append
는 어떤 규칙으로 슬라이스의 용량을 늘리는 걸까?" 단순히 2배씩 늘어난다고 알고 계신 분들도 많을 텐데요. 과연 항상 그럴까요?
오늘은 이 궁금증을 해결하기 위해 직접 실험을 통해 Go 1.23 버전에서 append
함수가 슬라이스 용량을 어떻게 증가시키는지 관찰하고, 그 내부 동작 원리까지 함께 탐구해보는 시간을 갖겠습니다.
실험: append
반복하며 용량 변화 추적하기
실험 목표
Go의 append
함수를 반복적으로 호출하여 슬라이스에 요소를 추가할 때, 슬라이스 용량(capacity)이 어떤 패턴으로 변화하는지 직접 관찰하고 규칙성을 찾아봅니다.
실험 코드
아래 코드는 초기 용량이 0인 슬라이스에 float64
타입의 요소를 계속해서 추가하면서, 용량이 변경되는 시점마다 슬라이스의 길이(len), 용량(cap), 그리고 이전 대비 용량 증가율(growth)을 출력합니다.
package main
import "fmt"
func main() {
// Define a slice with no initial capacity
var numbers []float64
lastCap := cap(numbers)
// Append 'counts' times and check capacity changes
counts := 10000 // Let's append 10,000 times
fmt.Printf("len, cap, growth\n") // Print header
for i := 0; i < counts; i++ { // Correct loop condition
numbers = append(numbers, float64(i))
currentCap := cap(numbers)
// Print only when capacity changes
if currentCap != lastCap {
fmt.Printf("%d, %d, %.2f\n", len(numbers), currentCap, calculateGrowth(lastCap, currentCap))
lastCap = currentCap
}
}
}
// calculateGrowth calculates the capacity growth rate
func calculateGrowth(lastCap, currentCap int) float64 { // Return float64 for better precision
if lastCap == 0 {
// Handle the initial case where lastCap is 0
// We can return 0 or perhaps indicate it's the first allocation
return 0.0 // Or return float64(currentCap) if preferred
}
return float64(currentCap) / float64(lastCap)
}
(참고: 위 코드는 Go 1.23 환경에서 실행하는 것을 기준으로 합니다. Go 버전에 따라 내부 구현이 조금씩 달라질 수 있습니다.)
실험 결과 및 관찰 내용
위 코드를 실행하면 슬라이스의 용량이 변경될 때마다 길이, 새로운 용량, 증가율이 출력되는데요. 결과를 자세히 살펴보면 흥미로운 패턴을 발견할 수 있습니다.
(실제 코드 출력 예시는 환경에 따라 약간 다를 수 있으므로, 직접 실행해보시는 것을 권장합니다. 여기서는 관찰되는 일반적인 경향을 설명합니다.)
- 초기 단계: 슬라이스 용량이 매우 작을 때는(예: 0에서 시작하여 1, 2, 4, 8, 16...) 대체로 용량이 정확히 2배씩 증가하는 경향을 보입니다.
- 성장률 변화: 하지만 슬라이스 용량이 특정 크기(과거에는 1024였으나, 최근 버전(예: Go 1.18 이후)에서는 256)를 넘어서면, 용량 증가율이 2배에서 약 1.25배 ~ 1.5배 사이로 줄어드는 것을 관찰할 수 있습니다. 정확히 1.25배가 아니라 약간씩 차이가 나는 경우도 종종 발견됩니다.
- 일정하지 않은 증가량: 특히 용량이 커질수록 증가율이 1.25배에 가깝게 수렴하는 것처럼 보이기도 하지만, 항상 정확히 1.25배는 아닙니다. 때로는 조금 더 크거나 작은 비율로 증가하기도 합니다.
어째서 이런 패턴이 나타나는 걸까요? 왜 항상 2배씩 늘리지 않고, 특정 크기 이후에는 증가율을 낮추는 걸까요?
그리고 왜 증가율이 항상 일정하지는 않을까요? 이 비밀을 풀기 위해 Go의 내부 동작 방식을 조금 더 깊이 들여다볼 필요가 있습니다.
왜 이런 패턴이? Go 내부 동작 들여다보기
Go 공식 문서나 소스 코드를 분석해보면, append
시 용량 증가는 대략 다음과 같은 과정을 거칩니다.
(세부 내용은 Go 버전에 따라 다를 수 있습니다.)
- 새로운 용량 계산 (1단계: 목표 용량 결정):
- 현재 용량이 256보다 작으면, 현재 용량의 2배를 새로운 목표 용량으로 설정합니다. (과거 버전에서는 이 기준이 1024였습니다.)
- 현재 용량이 256 이상이면, 현재 용량의 약 1.25배 (정확히는
newCap += (newCap + 3*threshold) / 4
와 유사한 계산)를 새로운 목표 용량으로 설정합니다. 이는 큰 슬라이스의 경우 메모리를 너무 공격적으로 늘리지 않기 위함입니다.
- 메모리 할당 크기 조정 (2단계: 메모리 효율화):
- 1단계에서 계산된 '목표 용량'만큼의 메모리를 바로 할당하는 것이 아닙니다! Go의 메모리 할당자(Allocator)는 미리 정해진 다양한 크기의 메모리 클래스(size class) 를 가지고 있습니다.
- Go 런타임은 계산된 목표 용량을 담을 수 있는 가장 작은 메모리 클래스 크기로 실제 할당할 메모리 크기를 결정(반올림과 유사)합니다. 예를 들어 목표 용량이 300인데 메모리 클래스가 256 다음이 320이라면, 320만큼의 공간을 할당받는 식입니다. (TCMalloc 기반의 Go 자체 메모리 할당자 특성)
- 이 과정 때문에 실제 용량 증가율이 정확히 2배나 1.25배가 아닐 수 있습니다! 메모리 할당 효율성을 위해 약간 더 큰 공간이 할당되는 경우가 많습니다.
- 새로운 배열 할당 및 복사:
- 2단계에서 결정된 크기로 새로운 기본 배열(underlying array)을 메모리에 할당합니다.
- 기존 배열의 모든 요소를 새로 할당된 배열로 복사합니다.
- 새로운 배열을 기반으로 하는, 용량이 늘어난 슬라이스를 반환합니다.
이러한 과정을 통해 Go는 슬라이스의 용량을 필요에 따라 자동으로 늘리면서도, 메모리 할당 및 관리의 효율성까지 고려하고 있음을 알 수 있습니다.
특히 메모리 할당 크기 조정 단계는 우리가 관찰한 '일정하지 않은 증가율'의 핵심적인 이유가 됩니다.
정리 및 핵심 요약
오늘 실험과 내부 동작 탐구를 통해 append
시 슬라이스 용량 증가에 대해 다음과 같은 점들을 알게 되었습니다.
- 용량 증가 패턴: 슬라이스 용량이 작을 때는(256 미만) 약 2배로 증가하지만, 커지면(256 이상) 증가율이 약 1.25배 수준으로 감소합니다. (Go 1.18 이후 기준)
- 메모리 할당자 최적화: 실제 할당되는 메모리 크기는 Go 내부 메모리 할당자의 메모리 클래스에 맞춰 조정되므로, 증가율이 정확히 2배나 1.25배가 아닐 수 있습니다. 이는 메모리 단편화를 줄이고 효율성을 높이기 위함입니다.
- 복사 비용 고려:
append
로 인해 용량이 늘어날 때는 새로운 메모리 할당과 기존 요소 전체 복사라는 비용이 발생합니다.
성능을 위한 팁: 용량 미리 지정하기
append
는 매우 편리하지만, 용량 증가 시 발생하는 재할당과 복사 비용은 성능에 영향을 줄 수 있습니다.
만약 슬라이스에 담길 요소의 개수를 미리 예측할 수 있다면, 슬라이스를 생성할 때 make
함수의 세 번째 인자로 초기 용량(capacity)을 지정해주는 것이 좋습니다.
// 예를 들어, 최소 1000개의 요소가 담길 것을 안다면...
estimatedSize := 1000
numbers := make([]float64, 0, estimatedSize) // 길이 0, 용량 1000인 슬라이스 생성
// 이제 최소 1000번의 append 동안은 재할당이 발생하지 않습니다!
for i := 0; i < estimatedSize; i++ {
numbers = append(numbers, float64(i)) // No reallocation cost for the first 1000 appends
}
이렇게 하면 불필요한 메모리 재할당과 요소 복사 과정을 최소화하여 성능을 개선할 수 있습니다.
오늘은 Go 슬라이스의 append
동작과 용량 증가의 비밀을 함께 파헤쳐 보았습니다.
내부 동작 원리를 이해하면 단순히 기능을 사용하는 것을 넘어, 더 효율적인 코드를 작성하는 데 큰 도움이 될 것입니다.
'Go' 카테고리의 다른 글
고랭(Golang) 리플렉션, 정말 느린가요? 알아볼까요? (0) | 2025.04.28 |
---|---|
우버(Uber)가 만든 고성능 Go 로깅! Zap(자프) 사용법 완벽 정리 (설치부터 파일 분리, 색상 출력까지) (0) | 2025.04.26 |
Go 1.22 버전, `http.ServeMux` 하나면 충분할까요? (0) | 2025.04.25 |
Golang 웹 프레임워크 7종 비교분석 (Gin, Echo, Beego, Revel, Fiber, Gorilla Mux, go-zero/rest) (0) | 2025.03.29 |
Go 언어의 난수, 왜 예측 가능할까요? (math/rand vs crypto/rand 깊이 파헤치기) (0) | 2025.03.24 |