Go 슬라이스 심화: 메모리 할당 방식, append 성장, 복사와 참조, 성능 최적화와 함정
이 글의 핵심
슬라이스는 참조 타입처럼 보이지만 실제로는 포인터, 길이, 용량을 담은 값이라서 함수에 넘겼을 때 append 결과가 호출자에게 보이지 않는 경우가 생깁니다. 이 글은 nil 슬라이스와 빈 슬라이스의 차이, 깊은 복사가 필요한 시점, 루프에서 슬라이스 원소 포인터를 잡을 때의 함정, 슬라이스 풀링 같은 고급 기법까지 다룹니다.
들어가며
Go 슬라이스는 동적 배열처럼 쓰이지만, 실제로는 백킹 배열을 가리키는 (ptr, len, cap) 헤더라서 복사·append·부분 슬라이싱의 결과가 직관과 다르게 나올 때가 많습니다. 이 글은 슬라이스 헤더의 구조에서 출발해 append가 용량을 늘리는 규칙, 복사와 참조 공유, 사전 할당과 인덱스 대입을 이용한 최적화, 그리고 작은 슬라이스가 큰 배열을 붙잡는 메모리 누수 같은 함정을 코드와 함께 살펴봅니다.
실무에서 마주치는 문제들
예상치 못한 메모리 사용
슬라이스를 복사했는데 메모리가 두 배로 늘지 않습니다. 왜 그럴까요?
append 후 이상한 동작
슬라이스에 append했는데 원본이 변경되었습니다. 어떻게 된 걸까요?
성능 저하
반복문에서 append하니 프로그램이 느립니다. 어떻게 최적화할까요?
슬라이스 내부 구조
슬라이스는 무엇인가?
슬라이스는 Go의 동적 배열입니다. 내부적으로 3개의 필드를 가진 구조체입니다:
type slice struct {
ptr unsafe.Pointer // 배열 포인터
len int // 현재 길이
cap int // 용량
}
시각화
flowchart TB
subgraph Slice["슬라이스 구조체"]
PTR["ptr: 0x1000"]
LEN["len: 3"]
CAP["cap: 5"]
end
subgraph Array["실제 배열 (메모리)"]
A0["[0]: 10"]
A1["[1]: 20"]
A2["[2]: 30"]
A3["[3]: 0"]
A4["[4]: 0"]
end
PTR --> A0
실제 메모리 레이아웃
package main
import (
"fmt"
"unsafe"
)
func main() {
s := []int{10, 20, 30}
// 슬라이스 헤더 크기
fmt.Printf("슬라이스 헤더 크기: %d bytes\n", unsafe.Sizeof(s))
// 24 bytes (포인터 8 + len 8 + cap 8)
// 실제 데이터 크기
fmt.Printf("데이터 크기: %d bytes\n", len(s)*int(unsafe.Sizeof(s[0])))
// 24 bytes (int 8 * 3)
// 포인터 주소
fmt.Printf("포인터: %p\n", &s[0])
fmt.Printf("길이: %d\n", len(s))
fmt.Printf("용량: %d\n", cap(s))
}
출력:
슬라이스 헤더 크기: 24 bytes
데이터 크기: 24 bytes
포인터: 0xc000018030
길이: 3
용량: 3
메모리 할당 메커니즘
make로 슬라이스 생성
// 방법 1: len만 지정
s1 := make([]int, 5)
// len: 5, cap: 5
// [0, 0, 0, 0, 0]
// 방법 2: len과 cap 지정
s2 := make([]int, 3, 10)
// len: 3, cap: 10
// [0, 0, 0] + 7개 예약 공간
// 방법 3: 리터럴
s3 := []int{1, 2, 3}
// len: 3, cap: 3
nil 슬라이스 vs 빈 슬라이스
package main
import "fmt"
func main() {
// nil 슬라이스
var s1 []int
fmt.Printf("s1: %v, len: %d, cap: %d, nil: %v\n",
s1, len(s1), cap(s1), s1 == nil)
// s1: [], len: 0, cap: 0, nil: true
// 빈 슬라이스
s2 := []int{}
fmt.Printf("s2: %v, len: %d, cap: %d, nil: %v\n",
s2, len(s2), cap(s2), s2 == nil)
// s2: [], len: 0, cap: 0, nil: false
s3 := make([]int, 0)
fmt.Printf("s3: %v, len: %d, cap: %d, nil: %v\n",
s3, len(s3), cap(s3), s3 == nil)
// s3: [], len: 0, cap: 0, nil: false
}
차이점:
- nil 슬라이스: 포인터가 nil, 메모리 할당 없음
- 빈 슬라이스: 포인터가 nil이 아닌 값을 가리킴. 다만 크기 0 할당은 런타임이 공유하는
zerobase주소를 돌려주므로 실제 힙 할당은 일어나지 않음
실무 권장: JSON 인코딩 시 [] vs null 차이가 있으므로 주의
두 슬라이스는 len, range, append에서 완전히 똑같이 동작하므로, 코드 대부분에서는 구분할 필요가 없고 Go 코드 리뷰 관례도 var s []int(nil)로 선언하는 쪽을 권장합니다. 차이가 드러나는 곳은 경계 지점입니다. encoding/json은 nil 슬라이스를 null, 빈 슬라이스를 []로 직렬화하므로, API 응답에서 프런트엔드가 data.items.length를 읽다가 null 때문에 에러가 나는 일이 흔합니다. 응답 구조체를 만들 때는 items := make([]Item, 0)처럼 빈 슬라이스로 초기화해 두는 것이 안전합니다. reflect.DeepEqual(nilSlice, emptySlice)가 false인 것도 테스트에서 자주 걸리는 부분입니다.
append 동작 원리
기본 append
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
fmt.Printf("Before: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
s = append(s, 4)
fmt.Printf("After: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
}
출력:
Before: len=3, cap=3, ptr=0xc000018030
After: len=4, cap=6, ptr=0xc000018060
주목: 포인터가 변경됨 → 새 배열 할당
capacity 증가 알고리즘
Go는 capacity가 부족하면 새 배열을 할당하고 기존 데이터를 복사합니다.
증가 규칙 (Go 1.18+, runtime.growslice):
- 필요한 용량이 현재의 2배보다 크면 필요한 용량을 그대로 사용
- cap < 256: 2배 증가
- cap >= 256:
newcap += (newcap + 3*256) / 4를 반복, 즉 2배에서 시작해 크기가 커질수록 약 1.25배로 부드럽게 줄어듦 - 마지막으로 원소 크기 × 용량을 메모리 할당기의 크기 클래스(size class)에 맞춰 올림하므로, 실제 cap은 계산값보다 조금 클 수 있음
Go 1.17까지는 기준이 1024였고 1024를 넘는 순간 2배에서 1.25배로 급격히 바뀌었는데, 1.18에서 이 전환을 완만하게 바꿨습니다. 크기 클래스 올림 때문에 nil []byte에 1바이트를 append하면 cap이 1이 아니라 8이 되고, 원소 크기가 크기 클래스와 딱 맞지 않는 구조체 슬라이스에서는 cap이 2배 계산값보다 조금 크게 나오기도 합니다. 그래서 증가 규칙은 구현 세부사항이고 버전마다 바뀔 수 있으므로 코드가 특정 cap 값에 의존하면 안 됩니다. 중요한 성질은 하나뿐입니다. 용량이 기하급수적으로 늘기 때문에 append 한 번의 비용은 분할 상환 O(1)이라는 점입니다.
package main
import "fmt"
func main() {
s := make([]int, 0, 1)
for i := 0; i < 10; i++ {
oldCap := cap(s)
s = append(s, i)
newCap := cap(s)
if oldCap != newCap {
fmt.Printf("len=%d, cap: %d -> %d\n", len(s), oldCap, newCap)
}
}
}
출력:
len=2, cap: 1 -> 2
len=3, cap: 2 -> 4
len=5, cap: 4 -> 8
len=9, cap: 8 -> 16
append 성능 분석
package main
import (
"fmt"
"time"
)
func benchmarkAppend(n int) time.Duration {
start := time.Now()
s := []int{}
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func benchmarkPrealloc(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n) // 사전 할당
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func main() {
n := 1000000
t1 := benchmarkAppend(n)
t2 := benchmarkPrealloc(n)
fmt.Printf("append: %v\n", t1)
fmt.Printf("prealloc: %v\n", t2)
fmt.Printf("개선: %.2fx\n", float64(t1)/float64(t2))
}
결론: 직접 실행해 보면 사전 할당 쪽이 일관되게 빠릅니다. 사전 할당 없이 100만 개를 append하면 용량이 늘어날 때마다 새 배열을 할당하고 기존 원소를 복사하는 일이 수십 번 반복되고, 버려진 중간 배열들이 GC 부담이 됩니다. 정확한 배율은 머신, Go 버전, 원소 크기, GC 상태에 따라 크게 달라지므로 숫자 자체보다 “할당 횟수가 1번으로 줄어든다”는 점이 핵심입니다. time.Now()로 한 번 재는 방식은 워밍업과 GC 타이밍에 따라 결과가 흔들리므로, 비교할 때는 뒤의 testing.B 벤치마크와 -benchmem을 쓰는 것이 정확합니다.
슬라이스 복사와 참조
슬라이스는 참조 타입?
중요: 슬라이스는 값 타입이지만, 내부 포인터를 공유합니다.
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1 // 슬라이스 헤더 복사
fmt.Printf("s1: %p, s2: %p\n", &s1, &s2)
// 다른 주소 (헤더는 별도)
fmt.Printf("s1[0]: %p, s2[0]: %p\n", &s1[0], &s2[0])
// 같은 주소 (배열 공유)
s2[0] = 999
fmt.Println("s1:", s1) // [999, 2, 3]
fmt.Println("s2:", s2) // [999, 2, 3]
}
append와 참조
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1
// Case 1: capacity 충분 (재할당 없음)
s1 = append(s1[:2], 999) // len=3, cap=3 → 재할당 없음
fmt.Println("s1:", s1) // [1, 2, 999]
fmt.Println("s2:", s2) // [1, 2, 999] ← s2도 영향받음!
// Case 2: capacity 부족 (재할당)
s3 := []int{1, 2, 3}
s4 := s3
s3 = append(s3, 4) // cap 초과 → 새 배열
s3[0] = 999
fmt.Println("s3:", s3) // [999, 2, 3, 4]
fmt.Println("s4:", s4) // [1, 2, 3] ← s4는 영향 없음
}
Case 1이 이 글에서 가장 중요한 예제입니다. s1[:2]는 len 2, cap 3인 슬라이스라서 append는 새 배열을 만들지 않고 공유 중인 배열의 세 번째 칸에 그대로 씁니다. 그 결과 전혀 건드리지 않은 s2의 값이 바뀝니다. 반대로 Case 2는 cap이 부족해 새 배열이 생기므로 그 뒤의 수정은 원본과 무관합니다. 즉 같은 코드가 cap 여유에 따라 공유 배열을 수정하기도, 안 하기도 한다는 것이 슬라이스 버그의 본질입니다. 저는 이 문제를 함수가 입력 슬라이스를 잘라서 append한 결과를 반환하는 코드에서 처음 겪었는데, 테스트 데이터에서는 cap이 딱 맞아 문제가 없다가 실데이터에서 cap에 여유가 생기자 호출자의 원본이 조용히 바뀌었습니다. 다른 곳과 공유될 수 있는 슬라이스에 append할 때는 s[:len(s):len(s)]로 cap을 잘라 넘기거나, 명시적으로 복사하는 습관이 가장 확실한 예방책입니다.
깊은 복사
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3, 4, 5}
// 방법 1: copy 사용
s2 := make([]int, len(s1))
copy(s2, s1)
// 방법 2: append 사용
s3 := append([]int(nil), s1...)
// 방법 3: 수동 복사
s4 := make([]int, len(s1))
for i, v := range s1 {
s4[i] = v
}
s1[0] = 999
fmt.Println("s1:", s1) // [999, 2, 3, 4, 5]
fmt.Println("s2:", s2) // [1, 2, 3, 4, 5]
fmt.Println("s3:", s3) // [1, 2, 3, 4, 5]
fmt.Println("s4:", s4) // [1, 2, 3, 4, 5]
}
세 방법 모두 한 단계만 복사한다는 점에서 엄밀한 의미의 깊은 복사는 아닙니다. []int처럼 원소가 값이면 충분하지만, []*User나 [][]int, 슬라이스·맵을 필드로 가진 구조체의 슬라이스라면 포인터와 내부 슬라이스 헤더만 복사되어 여전히 같은 데이터를 공유합니다. Go 1.21 이상에서는 slices.Clone(s1)이 방법 2와 같은 동작을 이름으로 드러내 주며, 원소까지 깊게 복사하려면 원소 타입에 맞는 복사 함수를 직접 작성해야 합니다. 방법 2(append([]int(nil), s1...))는 s1이 빈 슬라이스일 때 결과가 nil이 된다는 미묘한 차이도 있습니다.
성능 최적화
사전 할당 (Pre-allocation)
package main
import (
"fmt"
"time"
)
func withoutPrealloc(n int) time.Duration {
start := time.Now()
var s []int
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func withPrealloc(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n)
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func main() {
n := 1000000
t1 := withoutPrealloc(n)
t2 := withPrealloc(n)
fmt.Printf("사전 할당 없음: %v\n", t1)
fmt.Printf("사전 할당: %v\n", t2)
fmt.Printf("개선: %.2fx\n", float64(t1)/float64(t2))
}
3장의 벤치마크와 같은 측정입니다. var s []int(nil)에서 시작하든 []int{}에서 시작하든 첫 append에서 할당이 일어나므로 결과는 사실상 같고, 차이를 만드는 것은 처음부터 충분한 cap을 잡았느냐입니다. 최종 크기를 정확히 모르더라도 상한이나 대략적인 크기(입력 슬라이스 길이 등)를 알면 그 값으로 cap을 잡는 편이 대부분 이득이고, 너무 크게 잡으면 쓰지 않는 메모리를 들고 있게 된다는 점만 주의하면 됩니다.
슬라이싱 최적화
package main
import "fmt"
func main() {
data := []byte("Hello, World! This is a long string.")
// ❌ 메모리 누수 위험
// 작은 부분만 필요한데 전체 배열 유지
small := data[0:5] // "Hello"
fmt.Println(string(small))
// small은 작지만 data 전체를 참조
// ✅ 복사로 메모리 해제 가능
small2 := make([]byte, 5)
copy(small2, data[0:5])
// 이제 data는 GC 가능
// ⚠️ 3-index slice(Go 1.2+)는 누수 해결책이 아님
small3 := data[0:5:5] // [low:high:max]
// cap을 5로 제한해 append가 data를 덮어쓰지 않게 할 뿐,
// small3가 살아 있는 동안 data의 백킹 배열 전체는 GC되지 않음
}
append vs 인덱스 할당
package main
import (
"fmt"
"time"
)
func useAppend(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n)
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func useIndex(n int) time.Duration {
start := time.Now()
s := make([]int, n)
for i := 0; i < n; i++ {
s[i] = i
}
return time.Since(start)
}
func main() {
n := 10000000
t1 := useAppend(n)
t2 := useIndex(n)
fmt.Printf("append: %v\n", t1)
fmt.Printf("index: %v\n", t2)
fmt.Printf("개선: %.2fx\n", float64(t1)/float64(t2))
}
권장: 크기를 아는 경우 인덱스 할당이 조금 더 빠름
차이가 나는 이유는 append가 매번 len과 cap을 비교하고 슬라이스 헤더를 갱신하는 반면, 인덱스 대입은 경계 검사 한 번(컴파일러가 루프에서 제거하기도 함)으로 끝나기 때문입니다. 다만 사전 할당이 이미 되어 있다면 차이는 크지 않고, make([]int, n)은 n개를 먼저 0으로 초기화한 뒤 다시 쓰는 비용이 있습니다. 코드가 중간에 원소를 건너뛸 수 있다면(필터링처럼) 인덱스 방식은 쓸 수 없고, 잘못 쓰면 끝에 0값이 남는 버그가 되므로 최종 길이가 정확히 정해진 변환에만 인덱스 대입을 쓰는 것이 좋습니다.
실전 패턴
패턴 1: 필터링
package main
import "fmt"
func filter(s []int, predicate func(int) bool) []int {
result := make([]int, 0, len(s)) // 사전 할당
for _, v := range s {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("짝수:", evens) // [2, 4, 6, 8, 10]
}
패턴 2: 맵 변환
package main
import "fmt"
func mapSlice[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
squared := mapSlice(numbers, func(n int) int {
return n * n
})
fmt.Println("제곱:", squared) // [1, 4, 9, 16, 25]
strings := mapSlice(numbers, func(n int) string {
return fmt.Sprintf("num-%d", n)
})
fmt.Println("문자열:", strings) // [num-1, num-2, ...]
}
패턴 3: 리듀스
package main
import "fmt"
func reduce[T, U any](s []T, initial U, f func(U, T) U) U {
result := initial
for _, v := range s {
result = f(result, v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
sum := reduce(numbers, 0, func(acc, n int) int {
return acc + n
})
fmt.Println("합계:", sum) // 15
product := reduce(numbers, 1, func(acc, n int) int {
return acc * n
})
fmt.Println("곱:", product) // 120
}
패턴 4: 중복 제거
package main
import "fmt"
func unique[T comparable](s []T) []T {
seen := make(map[T]bool, len(s))
result := make([]T, 0, len(s))
for _, v := range s {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
func main() {
numbers := []int{1, 2, 2, 3, 3, 3, 4, 5, 5}
uniq := unique(numbers)
fmt.Println("중복 제거:", uniq) // [1, 2, 3, 4, 5]
}
filter는 입력 길이만큼 cap을 잡으므로 결과가 작으면 메모리를 남깁니다. 입력이 크고 통과하는 원소가 적다면 사전 할당 없이 append하거나, 결과를 오래 보관할 경우 slices.Clip(result)으로 남는 용량을 잘라 두는 방법도 있습니다. 입력 슬라이스를 다시 쓸 필요가 없다면 result := s[:0]로 같은 배열에 덮어쓰는 제자리 필터가 할당을 아예 없애 주지만, 호출자의 원본이 바뀐다는 점을 문서화해야 합니다. Go 1.21 이상이라면 slices.DeleteFunc가 이 제자리 필터를 제공합니다.
패턴 5: 청크 분할
package main
import "fmt"
func chunk[T any](s []T, size int) [][]T {
var chunks [][]T
for i := 0; i < len(s); i += size {
end := i + size
if end > len(s) {
end = len(s)
}
chunks = append(chunks, s[i:end])
}
return chunks
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
chunks := chunk(numbers, 3)
for i, chunk := range chunks {
fmt.Printf("청크 %d: %v\n", i, chunk)
}
}
출력:
청크 0: [1 2 3]
청크 1: [4 5 6]
청크 2: [7 8 9]
청크 3: [10]
청크들은 모두 numbers의 백킹 배열을 공유하는 부분 슬라이스라 복사 비용이 없지만, 그 대가로 청크에 append하면 다음 청크를 덮어씁니다. chunks[0]은 len 3, cap 10이므로 append(chunks[0], 100)은 numbers[3], 즉 chunks[1][0]을 100으로 바꿉니다. 청크를 호출자가 수정할 수 있다면 s[i:end:end]로 cap을 잘라 두는 것이 안전합니다. Go 1.23부터는 표준 라이브러리 slices.Chunk가 이터레이터로 같은 일을 하며, 내부적으로도 cap을 잘라 이 문제를 피합니다.
함정과 해결책
함정 1: 슬라이싱 후 메모리 누수
package main
import (
"fmt"
"runtime"
)
func printMemory() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024)
}
func main() {
// 큰 슬라이스 생성
data := make([]byte, 100*1024*1024) // 100MB
for i := range data {
data[i] = byte(i)
}
printMemory() // Alloc: 100 MB
// ❌ 작은 부분만 사용하지만 전체 유지
small := data[0:10]
data = nil // data는 nil이지만
runtime.GC()
printMemory() // Alloc: 100 MB (여전히 높음)
// small이 전체 배열을 참조하고 있음
fmt.Println(len(small))
// ✅ 복사로 해결 (data는 이미 nil이므로 small에서 복사)
small2 := make([]byte, 10)
copy(small2, small)
small = nil
runtime.GC()
printMemory() // Alloc: ~0 MB
}
small이 10바이트만 쓰는데도 100MB가 해제되지 않는 이유는 GC가 배열 단위로 생존 여부를 판단하기 때문입니다. 슬라이스 헤더의 포인터가 배열 안 어딘가를 가리키고 있으면 그 배열 전체가 살아 있는 것으로 취급되고, “앞의 10바이트만 남기고 나머지를 해제”하는 일은 일어나지 않습니다. 실무에서 이 문제는 큰 파일이나 HTTP 응답 본문을 통째로 읽은 뒤 헤더 몇 바이트나 토큰 하나만 잘라 캐시나 맵에 저장하는 코드에서 주로 나타나며, 메모리 프로파일(go tool pprof -inuse_space)에서 할당 위치가 “읽기 함수”로 찍히기 때문에 원인을 찾기 까다롭습니다. string(data[a:b]) 변환도 새 메모리를 할당해 복사하므로 같은 해결책이 됩니다.
함정 2: 루프에서 슬라이스 포인터
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
// ❌ Go 1.21 이하에서 잘못된 패턴
var ptrs []*int
for _, v := range s {
ptrs = append(ptrs, &v) // 1.21 이하: v는 루프 전체에서 하나의 변수
}
for _, ptr := range ptrs {
fmt.Print(*ptr, " ") // 1.21 이하: 3 3 3 / 1.22 이상: 1 2 3
}
fmt.Println()
// ✅ 올바른 패턴
var ptrs2 []*int
for i := range s {
ptrs2 = append(ptrs2, &s[i])
}
for _, ptr := range ptrs2 {
fmt.Print(*ptr, " ") // 1 2 3
}
fmt.Println()
}
Go 1.22부터는 for 루프 변수가 반복마다 새로 만들어지도록 언어 의미가 바뀌어, 위 “잘못된 패턴”도 1 2 3을 출력합니다. 단, 이 동작은 go.mod의 go 지시어가 1.22 이상인 모듈에만 적용되므로, 오래된 go.mod를 가진 프로젝트에서는 최신 컴파일러로 빌드해도 여전히 3 3 3이 나옵니다. 또 1.22 이상에서도 &v는 원소의 복사본을 가리킬 뿐 슬라이스 원소 자체가 아니므로, 포인터로 원소를 수정하려면 여전히 &s[i]를 써야 합니다. &s[i] 역시 이후 append로 재할당이 일어나면 옛 배열을 가리키게 된다는 점은 같습니다.
함정 3: 함수 인자로 전달
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 999 // 원본 수정됨
s = append(s, 100) // 새 배열 할당 (원본과 분리)
}
func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // [999, 2, 3] ← s[0]만 변경
// append는 원본에 영향 없음
}
이 예제는 cap이 3으로 꽉 차 있어서 append가 새 배열을 만들었지만, 호출자가 넘긴 슬라이스에 cap 여유가 있었다면 결과가 달라집니다. 함수 안의 append는 호출자와 공유하는 배열의 뒤쪽 칸에 값을 쓰지만, 호출자의 슬라이스 헤더(len)는 바뀌지 않으므로 호출자는 새 원소를 볼 수 없습니다. 그러다 호출자가 나중에 자기 쪽에서 append하면 그 칸을 덮어씁니다. 이런 모호함 때문에 Go 표준 라이브러리는 거의 항상 “새 슬라이스를 반환하는” 형태(s = append(s, ...), s = slices.Insert(s, ...))를 쓰고, 포인터 전달보다 반환값 방식이 관용적입니다.
해결책: 포인터로 전달
func modifySlicePtr(s *[]int) {
(*s)[0] = 999
*s = append(*s, 100)
}
func main() {
s := []int{1, 2, 3}
modifySlicePtr(&s)
fmt.Println(s) // [999, 2, 3, 100]
}
함정 4: 슬라이스 비교
package main
import (
"fmt"
"slices" // Go 1.21+
)
func main() {
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
// ❌ 컴파일 에러
// if s1 == s2 { }
// ✅ 방법 1: 수동 비교
equal := len(s1) == len(s2)
if equal {
for i := range s1 {
if s1[i] != s2[i] {
equal = false
break
}
}
}
fmt.Println("Equal:", equal)
// ✅ 방법 2: slices.Equal (Go 1.21+)
equal2 := slices.Equal(s1, s2)
fmt.Println("Equal:", equal2)
}
고급 기법
슬라이스 풀링
package main
import (
"fmt"
"sync"
)
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
}
func processData(data []byte) {
// 버퍼 가져오기
buf := bufferPool.Get().([]byte)
buf = buf[:0] // 길이 리셋
// 사용
buf = append(buf, data...)
fmt.Printf("처리: %d bytes\n", len(buf))
// 반환
bufferPool.Put(buf)
}
func main() {
for i := 0; i < 5; i++ {
data := []byte(fmt.Sprintf("Data %d", i))
processData(data)
}
}
sync.Pool에 []byte를 그대로 넣는 이 코드는 흔하지만 두 가지 함정이 있습니다. 첫째, Put(buf)에 슬라이스 값을 넘기면 interface{}로 변환되면서 24바이트 헤더가 힙에 할당되어, 할당을 줄이려는 목적이 일부 무너집니다(staticcheck의 SA6002 경고). 풀에는 *[]byte를 넣는 것이 권장 패턴입니다. 둘째, 가끔 들어오는 아주 큰 입력 때문에 버퍼가 수 MB로 커진 채 풀로 돌아가면 그 메모리가 계속 재사용되며 남습니다. cap(buf)가 일정 크기를 넘으면 Put하지 않고 버리는 상한을 두는 것이 일반적입니다. 또 풀의 객체는 GC 때 비워질 수 있으므로 캐시처럼 “반드시 남아 있다”고 가정하면 안 됩니다.
제로 할당 슬라이싱
package main
import "fmt"
func main() {
data := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 슬라이싱은 할당 없음 (포인터만 조정)
chunk1 := data[0:3]
chunk2 := data[3:6]
chunk3 := data[6:10]
fmt.Println(chunk1) // [1 2 3]
fmt.Println(chunk2) // [4 5 6]
fmt.Println(chunk3) // [7 8 9 10]
// 모두 같은 배열 공유
fmt.Printf("%p\n", &data[0])
fmt.Printf("%p\n", &chunk1[0])
fmt.Printf("%p\n", &chunk2[0])
}
효율적인 삽입/삭제
package main
import "fmt"
// 중간 삽입
func insert[T any](s []T, index int, value T) []T {
s = append(s[:index], append([]T{value}, s[index:]...)...)
return s
}
// 중간 삭제
func remove[T any](s []T, index int) []T {
return append(s[:index], s[index+1:]...)
}
// 효율적인 삭제 (순서 무관)
func removeFast[T any](s []T, index int) []T {
s[index] = s[len(s)-1] // 마지막 요소로 덮어쓰기
return s[:len(s)-1]
}
func main() {
s := []int{1, 2, 3, 4, 5}
s = insert(s, 2, 99)
fmt.Println("삽입:", s) // [1, 2, 99, 3, 4, 5]
s = remove(s, 2)
fmt.Println("삭제:", s) // [1, 2, 3, 4, 5]
s = removeFast(s, 2)
fmt.Println("빠른 삭제:", s) // [1, 2, 5, 4]
}
이 함수들은 짧지만 원본 배열을 공유한다는 점을 조심해야 합니다. remove는 s[:index]의 뒤쪽에 이어 붙이므로 호출자의 원본 배열 자체가 한 칸씩 당겨지고, 마지막 칸에는 옛 값이 남습니다. 원소가 포인터라면 그 남은 참조 때문에 객체가 GC되지 않을 수 있습니다. insert는 임시 슬라이스([]T{value} + 뒤쪽 복사본)를 매번 할당합니다. Go 1.21의 slices.Insert와 slices.Delete가 이런 세부 처리를 해 주고, Go 1.22부터 slices.Delete는 줄어든 끝부분을 0값으로 지워 남은 참조 문제도 없앱니다. 참고로 main의 주석대로 결과가 나오는 것은 insert에서 cap 부족으로 새 배열이 만들어졌기 때문이며, cap에 여유가 있는 슬라이스라면 원본과 결과가 같은 배열을 공유하게 됩니다.
병렬 처리
package main
import (
"fmt"
"sync"
)
func parallelProcess(data []int, workers int) []int {
chunkSize := (len(data) + workers - 1) / workers
results := make([]int, len(data))
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
start := w * chunkSize
end := start + chunkSize
if end > len(data) {
end = len(data)
}
wg.Add(1)
go func(start, end int) {
defer wg.Done()
for i := start; i < end; i++ {
results[i] = data[i] * data[i]
}
}(start, end)
}
wg.Wait()
return results
}
func main() {
data := []int{1, 2, 3, 4, 5, 6, 7, 8}
results := parallelProcess(data, 4)
fmt.Println("결과:", results)
}
메모리 레이아웃 상세 분석
배열과 슬라이스의 차이 한눈에 보기
| 특징 | 배열 [N]T | 슬라이스 []T |
|---|---|---|
| 크기 | 고정, 길이가 타입의 일부([3]int와 [4]int는 다른 타입) | 가변, 헤더(포인터·길이·용량) + 백킹 배열 |
| 대입·함수 인자 | 요소 전체가 복사됨 | 24바이트 헤더만 복사, 백킹 배열은 공유 |
== 비교 | 요소 타입이 비교 가능하면 가능 | nil과의 비교만 가능 |
| 메모리 위치 | 탈출 분석 결과에 따라 스택 또는 힙 | 백킹 배열도 탈출 분석으로 결정(작고 함수 밖으로 안 나가면 스택에 둘 수 있음) |
“슬라이스는 항상 힙에 할당된다”는 설명을 자주 보는데, 정확하지 않습니다. 컴파일러가 make([]int, 8)처럼 크기가 상수이고 함수 밖으로 빠져나가지 않는 슬라이스를 스택에 두는 경우가 있습니다. go build -gcflags=-m으로 does not escape / escapes to heap 메시지를 보면 실제로 어느 쪽인지 확인할 수 있습니다.
Full Slice Expression으로 용량 제한하기
s[low:high:max] 형태의 3-index 슬라이싱은 길이 high-low, 용량 max-low인 슬라이스를 만듭니다. 용량을 길이와 같게 잘라 두면 이후 append가 반드시 새 배열을 할당하므로, 원본과 백킹 배열을 공유한 채 넘긴 슬라이스가 원본을 덮어쓰는 버그를 막을 수 있습니다.
s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
s1 := s[2:5:7] // [2 3 4], len=3, cap=5 (7-2)
s2 := s[2:5:5] // [2 3 4], len=3, cap=3
s2 = append(s2, 99) // cap 초과 → 새 배열, s[5]는 그대로 5
s1 = append(s1, 42) // cap 여유 → s[5]가 42로 바뀜
배열 vs 슬라이스
package main
import (
"fmt"
"unsafe"
)
func main() {
// 배열
arr := [5]int{1, 2, 3, 4, 5}
fmt.Printf("배열 크기: %d bytes\n", unsafe.Sizeof(arr))
// 40 bytes (int 8 * 5)
// 슬라이스
s := []int{1, 2, 3, 4, 5}
fmt.Printf("슬라이스 헤더: %d bytes\n", unsafe.Sizeof(s))
// 24 bytes (ptr 8 + len 8 + cap 8)
// 슬라이스 → 배열 변환
var arr2 [5]int
copy(arr2[:], s)
// 배열 → 슬라이스 변환
s2 := arr[:]
fmt.Println(s2)
}
슬라이스 헤더 직접 조작 (unsafe)
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
s := []int{1, 2, 3, 4, 5}
// 슬라이스 헤더 접근
header := (*reflect.SliceHeader)(unsafe.Pointer(&s))
fmt.Printf("Data: 0x%x\n", header.Data)
fmt.Printf("Len: %d\n", header.Len)
fmt.Printf("Cap: %d\n", header.Cap)
// ⚠️ 위험: 직접 수정
header.Len = 3
fmt.Println(s) // [1 2 3]
// ⚠️ 더 위험: 잘못된 포인터
// header.Data = 0x1234 // 크래시 가능
}
경고: unsafe 패키지는 매우 위험합니다. 특별한 이유가 없다면 사용하지 마세요.
reflect.SliceHeader는 Go 1.20부터 deprecated입니다. Data 필드가 uintptr이라 GC가 포인터로 인식하지 못하고, 헤더를 직접 만들어 쓰면 가리키는 배열이 수거되는 버그가 생기기 쉽기 때문입니다. 대신 Go 1.17의 unsafe.Slice(ptr, len)와 Go 1.20의 unsafe.SliceData(s), unsafe.String/unsafe.StringData를 쓰는 것이 공식 권장 방법입니다. 이 예제의 header.Len = 3은 cap 범위 안이라 우연히 안전할 뿐, 같은 효과는 s = s[:3]로 안전하게 얻을 수 있습니다.
벤치마크 및 최적화
벤치마크 작성
package main
import (
"testing"
)
func BenchmarkAppendWithoutPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
var s []int
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
func BenchmarkAppendWithPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
func BenchmarkIndexAssignment(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 1000)
for j := 0; j < 1000; j++ {
s[j] = j
}
}
}
실행:
go test -bench=. -benchmem
결과 읽는 법: -benchmem을 붙이면 ns/op 외에 B/op(반복당 할당 바이트)와 allocs/op(반복당 할당 횟수)가 함께 나옵니다. 사전 할당이 없는 벤치마크는 1000개를 채우는 동안 용량이 여러 번 늘어나므로 allocs/op가 두 자릿수에 가깝고, 버려진 중간 배열까지 합쳐 B/op도 최종 크기(8000바이트)보다 훨씬 큽니다. 사전 할당과 인덱스 대입은 allocs/op가 1이고 B/op는 약 8KB입니다. ns/op는 머신마다 다르므로 절대값보다 이 할당 지표를 먼저 비교하는 것이 좋습니다. 이 예제처럼 결과를 쓰지 않는 벤치마크는 컴파일러가 슬라이스를 스택에 두거나 일부 작업을 최적화할 수 있으므로, 결과를 패키지 변수에 대입해 두면 더 현실적인 측정이 됩니다. 두 버전을 비교할 때는 -count=10으로 여러 번 돌린 뒤 benchstat으로 통계적 차이를 확인하는 것이 정석입니다.
참고 자료
자주 묻는 질문 (FAQ)
Q. 두 슬라이스를 ==로 비교할 수 없는 이유와 대안은 무엇인가요?
A. 슬라이스는 배열을 가리키는 포인터·길이·용량을 담은 헤더라서 Go는 == 비교를 nil과의 비교로만 허용하고, s1 == s2는 컴파일 에러가 됩니다. 원소를 비교하려면 Go 1.21부터 표준 라이브러리에 들어온 slices.Equal을 쓰거나, 그 이전 버전이라면 길이를 먼저 비교한 뒤 원소를 순회하는 함수를 직접 작성합니다. 테스트 코드에서 reflect.DeepEqual을 쓰기도 하지만, nil 슬라이스와 빈 슬라이스를 다르게 취급하므로 주의해야 합니다.
같이 보면 좋은 글
- [Go 2주 완성 #02] 메모리와 자료구조
- C++ Small String Optimization (SSO) | string 성능 최적화 원리
- C++ 객체 슬라이싱 에러: vector
에 담을 때 잘리는 이유와 clone 패턴