Go의 Slice: len, cap, 그리고 append의 함정
1. 개요
Slice는 Go에서 가장 많이 사용되는 데이터 타입이지만, 동시에 발견하기 가장 어려운 버그를 야기합니다. 이유는 여러 slice가 같은 underlying array를 공유할 수 있기 때문입니다. 이 경우 append가 다른 slice의 데이터를 예고 없이 덮어쓸 수 있습니다.
이 글에서는 len과 cap을 설명하고, append가 새로운 array를 할당할지 기존 array에 기록할지 결정하는 방식, 그리고 오류를 방지하는 기법들을 다룹니다: full slice expression, copy, slices.Clone. 이 글을 읽고 나면 append를 포함한 코드의 결과를 정확히 예측할 수 있고, aliasing 오류를 잡기 위한 test를 작성할 수 있을 것입니다.
예제들은 Go 1.21 이상을 사용합니다. 이 버전부터 slices 패키지가 포함되기 때문입니다.
2. Slice의 실제 정체
Slice는 세 개의 필드를 가진 작은 구조체입니다:
- pointer: underlying array의 첫 번째 요소를 가리킵니다.
- len: 접근 가능한 요소의 개수입니다.
- cap: pointer에서 underlying array의 끝까지 요소의 개수입니다.
Slice를 함수에 전달하거나 다른 변수에 할당할 때, Go는 이 세 필드만 복사합니다. Underlying array 자체는 복사되지 않습니다.
2.1. len과 cap 관찰하기
package main
import "fmt"
func main() {
orders := []string{"A01", "A02", "A03", "A04", "A05"}
recent := orders[1:3]
fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4
orders[1:3]은 인덱스 1부터 3 이전까지의 요소를 가져오므로 len은 2입니다. cap은 4입니다. 왜냐하면 orders[1] 위치에서 array의 끝까지 4개의 슬롯이 있기 때문입니다: A02, A03, A04, A05.
2.2. 두 개의 slice, 하나의 array
recent가 orders와 같은 array를 가리키므로, 이 slice의 요소를 수정하면 다른 slice에서도 즉시 반영됩니다:
recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]
이것은 의도된 동작입니다. Slice를 잘라내는 것을 비용 없이 할 수 있게 해줍니다. 하지만 이것이 또한 다음 섹션들의 모든 함정의 원인입니다.
3. append는 어떻게 작동하는가
append(s, x)는 다음 두 가지 중 하나를 수행합니다:
len(s) < cap(s)인 경우:x를 현재 array의s[len(s)]위치에 기록하고,len + 1을 가진 새로운 slice를 반환합니다.len(s) == cap(s)인 경우: 더 큰 새로운 array를 할당하고, 기존 데이터를 복사한 후,x를 기록하고, 새 array를 가리키는 slice를 반환합니다.
경우 2가 발생할 수 있으므로, 항상 결과를 다시 할당해야 합니다: s = append(s, x).
3.1. cap이 점진적으로 증가하는 모습 관찰하기
package main
import "fmt"
func main() {
var ids []int
for i := 0; i < 10; i++ {
ids = append(ids, i)
fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16
작은 slice의 경우 cap이 보통 매번 가득 찰 때마다 두 배가 됩니다. 큰 slice의 경우 runtime이 더 느리게 증가시키고, 메모리 할당자의 크기 클래스에 따라 결과가 반올림됩니다.
참고:
cap증가 공식은 runtime 구현의 세부사항이며 Go 버전에 따라 변해왔습니다.append이후 특정cap값에 의존하는 코드를 작성하지 마세요.
4. 함정 1: append가 원본 slice의 데이터를 덮어쓰기
이것이 가장 흔한 오류입니다:
package main
import "fmt"
func main() {
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2]
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
}
firstTwo: [10 20 99]
prices: [10 20 99 40 50]
firstTwo는 len = 2이지만 cap = 5입니다. Array에 공간이 있으므로 append는 새 array를 할당하지 않고 99를 공유 array의 세 번째 위치에 기록합니다. 결과적으로 prices[2]는 30에서 99로 변경됩니다.
이 버그는 찾기가 매우 어렵습니다. 코드는 panic을 발생시키지 않으며, 덮어쓴 값은 훨씬 나중에 다른 모듈에서만 드러날 수 있습니다.
5. 함정 2: 같은 slice에서 두 번의 append
package main
import "fmt"
func main() {
base := make([]string, 3, 4)
base[0], base[1], base[2] = "pen", "book", "bag"
cartA := append(base, "laptop")
cartB := append(base, "phone")
fmt.Println("cartA:", cartA)
fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]
base에는 정확히 하나의 빈 슬롯이 있습니다. 첫 번째 append는 "laptop"을 그 슬롯에 기록합니다. 두 번째 append는 "phone"을 같은 슬롯에 기록합니다. cartA와 cartB는 모두 같은 array를 가리키므로 "laptop"은 사라집니다.
만약 base에 cap = 3이었다면, 두 append는 각각 자신의 array를 할당했을 것이고 결과가 예상대로 나왔을 것입니다. 같은 코드가 cap에 따라 다르게 동작합니다. 이것이 이 버그가 간헐적으로만 나타나는 이유입니다.
6. 함정 3: 함수 내 append가 caller의 slice를 변경하지 않음
package main
import "fmt"
func addItem(items []string, item string) {
items = append(items, item)
}
func main() {
cart := make([]string, 0, 10)
addItem(cart, "pen")
fmt.Println(len(cart), cart)
fmt.Println(cart[:1])
}
0 []
[pen]
addItem 함수는 slice 구조체의 복사본을 받습니다. append는 충분한 cap이 있으므로 "pen"을 공유 array에 기록했습니다. 하지만 새로운 len은 지역 변수 items에만 할당됩니다. main의 cart 변수는 여전히 len = 0입니다. 데이터는 array에 있지만, cart[:1]로 일부러 재정렬해야만 볼 수 있습니다.
해결 방법은 새 slice를 반환하는 것입니다:
func addItem(items []string, item string) []string {
return append(items, item)
}
// 사용
cart = addItem(cart, "pen")
이것이 표준 라이브러리가 append를 값을 반환하는 함수로 설계한 이유입니다. Slice를 제자리에서 수정하지 않습니다.
7. 함정 4: make의 len과 make의 cap 혼동하기
package main
import "fmt"
func main() {
names := []string{"An", "Binh", "Chi"}
upper := make([]string, len(names)) // 잘못된 의도
for _, n := range names {
upper = append(upper, n+"!")
}
fmt.Println(len(upper), upper)
}
6 [ An! Binh! Chi!]
make([]string, 3)은 3개의 빈 요소를 가진 slice를 생성하며, 용량 3인 빈 slice가 아닙니다. append는 처음 3개의 빈 문자열 다음에 요소를 추가합니다.
올바른 방법은 두 가지 중 하나입니다. 하나를 선택하고, 혼합하지 마세요:
// 방법 1: len = 0, cap = n, 그다음 append
upper := make([]string, 0, len(names))
for _, n := range names {
upper = append(upper, n+"!")
}
// 방법 2: len = n, 인덱스로 할당
upper := make([]string, len(names))
for i, n := range names {
upper[i] = n + "!"
}
두 방법 모두 한 번에 정확하게 할당하므로, append가 cap을 여러 번 늘리는 것보다 빠릅니다.
8. 방지하는 방법
8.1. Full slice expression: cap 제한하기
s[low:high:max] 문법은 cap = max - low를 설정합니다. cap이 len과 같으면, 다음 append는 새 array를 할당해야 합니다:
prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2
firstTwo = append(firstTwo, 99)
fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices: ", prices)
firstTwo: [10 20 99]
prices: [10 20 30 40 50]
prices는 영향을 받지 않습니다. 이 기법은 내부 slice의 일부를 외부로 반환하고 caller가 자유롭게 append할 수 있게 하면서 데이터가 손상되지 않도록 할 때 유용합니다.
8.2. slices.Clone과 copy: array를 완전히 분리하기
완전히 독립적인 복사본이 필요할 때는 clone을 사용하세요:
import "slices"
orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)
snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X
Go 1.21 이전에는 copy를 사용하세요:
snapshot := make([]string, len(orders))
copy(snapshot, orders)
copy는 복사한 요소의 개수를 반환하며, 이는 len(dst)와 len(src) 중 작은 값입니다. 따라서 dst는 cap뿐 아니라 충분한 len을 가져야 합니다. 흔한 실수는 copy(make([]string, 0, n), src)를 쓰는 것인데, 결과는 빈 slice입니다.
8.3. 메모리 보관 함정
큰 array에서 작은 slice를 자르면 전체 큰 array를 메모리에 보관합니다:
func firstLine(data []byte) []byte {
for i, b := range data {
if b == '\n' {
return slices.Clone(data[:i]) // data 전체를 보관하지 않음
}
}
return slices.Clone(data)
}
data가 100MB 파일이고 data[:i]를 clone 없이 반환하면, garbage collector는 결과 slice가 참조되는 한 100MB를 해제할 수 없습니다.
9. Aliasing 오류를 잡기 위한 test 작성하기
Inventory 타입이 제품 목록을 반환한다고 가정합니다. 호출자가 결과에 append해도 내부 데이터를 손상시키지 않도록 해야 합니다:
// inventory.go
package inventory
type Inventory struct {
items []string
}
func New(items ...string) *Inventory {
return &Inventory{items: items}
}
func (inv *Inventory) Items() []string {
return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory
import (
"slices"
"testing"
)
func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
inv := New("pen", "book")
inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // 의도적으로 cap 초과
got := inv.Items()
got = append(got, "laptop")
want := []string{"pen", "book"}
if !slices.Equal(inv.Items(), want) {
t.Fatalf("internal items = %v, want %v", inv.Items(), want)
}
if inv.items[:3][2] != "bag" {
t.Fatalf("backing array가 덮어써짐: %v", inv.items[:3])
}
if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
t.Fatalf("got = %v", got)
}
}
Test는 내부 slice에 len < cap 조건을 만들고 이것이 버그가 드러날 환경입니다. Items()를 return inv.items로 변경하면, append(got, "laptop")이 "bag"을 덮어쓰고 두 번째 assertion에서 test가 실패합니다.
go test ./...
ok example.com/inventory 0.002s
참고: Aliasing test는 항상
len < cap상황을 만들어야 합니다. Test에서[]string{"pen", "book"}처럼 literal로 만든 slice는len == cap이므로append가 항상 새 array를 할당하고, 코드에 버그가 있어도 test는 통과합니다.
10. 걱정할 필요가 없는 경우
모든 slice를 clone할 필요는 없습니다. Clone은 메모리와 CPU를 낭비합니다. 다음 경우 안전하게 array를 공유할 수 있습니다:
- Slice는 읽기 전용이며, 누구도
append하거나 요소를 수정하지 않습니다. - Slice는 지역 변수이며 함수 밖으로 공유되지 않습니다.
- 의도적으로 제자리 수정을 원할 때, 예를 들어
s[:0]으로 필터링하여 array를 재사용합니다:
active := orders[:0]
for _, o := range orders {
if o != "CANCELLED" {
active = append(active, o)
}
}
이 기법은 active가 orders를 읽는 위치를 넘어서 절대 쓰지 않으므로 올바릅니다. 루프 후에는 active만 사용하고 orders는 버립니다.
11. 결론
Slice는 pointer, len, cap을 가진 구조체로, 공유될 수 있는 array를 가리킵니다. append는 len == cap일 때만 새 array를 할당합니다. 다른 모든 경우에는 공유 array에 직접 기록합니다. 이것이 네 가지 함정의 원인입니다: 원본 slice 덮어쓰기, 두 번의 append가 서로를 덮어쓰기, 함수 내 append가 caller를 변경하지 않기, make에서 len과 cap 혼동하기.
안전하려면 몇 가지 규칙을 기억하세요:
- 항상
s = append(s, ...)로 작성하세요. - 요소를 추가해야 하는 함수는 새 slice를 반환하세요.
- 내부 slice를 외부로 반환할 때
s[low:high:high]를 사용하세요. - 완전히 독립적인 복사본이 필요하거나 큰 array를 해제하려면
slices.Clone을 사용하세요.
Test를 작성할 때, len < cap 조건을 만들어 버그가 드러날 가능성을 높이세요.