Go 슬라이스 완벽 가이드 | 내부 구조·메모리·성능 최적화 심화 분석
이 글의 핵심
Go 슬라이스의 내부 구조부터 메모리 할당, 성능 최적화까지 깊이 있게 다루는 완벽 가이드입니다. 실무에서 마주치는 함정과 해결책을 포함합니다.
실무 경험 공유: 슬라이스의 내부 동작을 이해하지 못해 메모리 누수와 성능 저하를 겪었던 경험과, 이를 해결하면서 배운 최적화 기법을 공유합니다.
들어가며: “슬라이스가 왜 이렇게 동작하죠?”
실무 문제 시나리오
시나리오 1: 예상치 못한 메모리 사용
슬라이스를 복사했는데 메모리가 두 배로 늘지 않습니다. 왜 그럴까요?
시나리오 2: append 후 이상한 동작
슬라이스에 append했는데 원본이 변경되었습니다. 어떻게 된 걸까요?
시나리오 3: 성능 저하
반복문에서 append하니 프로그램이 느립니다. 어떻게 최적화할까요?
목차
1. 슬라이스 내부 구조
슬라이스는 무엇인가?
슬라이스는 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
2. 메모리 할당 메커니즘
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, 메모리 할당 없음
- 빈 슬라이스: 포인터가 유효, 메모리 할당됨 (작은 크기)
실무 권장: JSON 인코딩 시 [] vs null 차이가 있으므로 주의
3. 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+):
- cap < 256: 2배 증가
- cap >= 256: 1.25배 증가 (점진적)
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))
}
결과:
append: 15.2ms
prealloc: 3.8ms
개선: 4.00x
결론: 사전 할당으로 4배 성능 향상
4. 슬라이스 복사와 참조
슬라이스는 참조 타입?
중요: 슬라이스는 값 타입이지만, 내부 포인터를 공유합니다.
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는 영향 없음
}
깊은 복사
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]
}
5. 성능 최적화
1) 사전 할당 (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))
}
결과:
사전 할당 없음: 18.5ms
사전 할당: 4.2ms
개선: 4.40x
2) 슬라이싱 최적화
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 가능
// ✅ Go 1.21+: 3-index slice
small3 := data[0:5:5] // [low:high:max]
// cap을 5로 제한 → append 시 새 배열
}
3) 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: 52.3ms
index: 38.1ms
개선: 1.37x
권장: 크기를 아는 경우 인덱스 할당이 빠름
6. 실전 패턴
패턴 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]
}
패턴 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]
7. 함정과 해결책
함정 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))
// ✅ 복사로 해결
small2 := make([]byte, 10)
copy(small2, data[0:10])
data = nil
small = nil
runtime.GC()
printMemory() // Alloc: ~0 MB
}
함정 2: 루프에서 슬라이스 포인터
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
// ❌ 잘못된 패턴
var ptrs []*int
for _, v := range s {
ptrs = append(ptrs, &v) // v의 주소는 항상 같음
}
for _, ptr := range ptrs {
fmt.Print(*ptr, " ") // 3 3 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()
}
함정 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는 원본에 영향 없음
}
해결책: 포인터로 전달
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)
}
8. 고급 기법
1) 슬라이스 풀링
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)
}
}
2) 제로 할당 슬라이싱
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])
}
3) 효율적인 삽입/삭제
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]
}
4) 병렬 처리
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)
}
메모리 레이아웃 상세 분석
배열 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 패키지는 매우 위험합니다. 특별한 이유가 없다면 사용하지 마세요.
벤치마크 및 최적화
벤치마크 작성
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
결과:
BenchmarkAppendWithoutPrealloc-8 50000 35421 ns/op 24576 B/op 10 allocs/op
BenchmarkAppendWithPrealloc-8 200000 8234 ns/op 8192 B/op 1 allocs/op
BenchmarkIndexAssignment-8 300000 5123 ns/op 8192 B/op 1 allocs/op
정리
핵심 요약
-
내부 구조
- 슬라이스는 (ptr, len, cap) 구조체
- 실제 데이터는 별도 배열에 저장
- 슬라이스 복사는 헤더만 복사 (배열 공유)
-
append 동작
- cap 충분: 기존 배열 사용
- cap 부족: 새 배열 할당 + 복사
- 증가 규칙: cap < 256 → 2배, cap >= 256 → 1.25배
-
성능 최적화
- 사전 할당:
make([]T, 0, n) - 인덱스 할당 > append (크기 고정 시)
- 슬라이스 풀링 (재사용)
- 사전 할당:
-
주의사항
- 슬라이싱 후 메모리 누수 주의
- 루프 변수 주소 참조 금지
- 함수 인자로 전달 시 append 주의
베스트 프랙티스
// ✅ 크기를 아는 경우 사전 할당
s := make([]int, 0, expectedSize)
// ✅ 크기가 고정이면 인덱스 할당
s := make([]int, n)
for i := 0; i < n; i++ {
s[i] = value
}
// ✅ 깊은 복사 필요 시
s2 := make([]T, len(s1))
copy(s2, s1)
// ✅ 메모리 해제 필요 시
s = s[:0] // len=0, cap 유지 (재사용)
s = nil // GC 가능
체크리스트
- 슬라이스 크기를 예측할 수 있으면 사전 할당
- 큰 슬라이스에서 작은 부분만 사용 시 복사
- 루프에서 요소 주소 참조 시 인덱스 사용
- 함수에서 슬라이스 수정 시 포인터 전달 고려
- 벤치마크로 성능 측정
참고 자료
한 줄 요약: Go 슬라이스는 (ptr, len, cap) 구조체로 배열을 참조하며, append 시 capacity 부족하면 재할당되므로 사전 할당으로 성능을 최적화하고 슬라이싱 시 메모리 누수를 주의해야 합니다.