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)는 다음 두 가지 중 하나를 수행합니다:

  1. len(s) < cap(s)인 경우: x를 현재 array의 s[len(s)] 위치에 기록하고, len + 1을 가진 새로운 slice를 반환합니다.
  2. 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 조건을 만들어 버그가 드러날 가능성을 높이세요.

golangsliceappend메모리