append와 백킹 배열
이 챕터에서 다루는 것
Go에서 가장 자주, 가장 조용히 터지는 버그가 여기 모여 있다. append가 백킹 배열을
재사용할 때와 새로 잡을 때의 차이, 그로 인한 원본 오염, full slice expression으로
차단하는 법, 그리고 copy·clear·삭제 관용구를 다룬다.
문제 — 같은 코드가 어떤 때는 되고 어떤 때는 안 된다
a := []int{1, 2, 3}
b := append(a[:1], 99)
// a는 [1 99 3]이 됐다
a := []int{1, 2, 3}
c := append(a, 99)
c[0] = -1
// a는 [1 2 3] 그대로다
둘 다 append인데 하나는 원본을 오염시키고 하나는 안 한다. 차이는 그 순간 용량이
남아 있었는가 하나다. 이 규칙만 정확히 알면 이 챕터의 모든 함정이 예측 가능해진다.
append의 규칙
append(s, v)가 하는 일은 정확히 이렇다.
len(s) < cap(s)면 — 백킹 배열의 남은 자리에 그대로 쓴다. 새 배열을 잡지 않는다. 반환되는 헤더는 포인터와 cap이 같고 len만 1 늘어난 것이다.len(s) == cap(s)면 — 더 큰 배열을 새로 할당하고, 기존 요소를 복사하고, 거기에 쓴다. 반환되는 헤더는 완전히 다른 배열을 가리킨다.
1번일 때 원본이 오염되고, 2번일 때 원본과 연결이 끊긴다. 그래서 append의 반환값을
원래 변수에 다시 대입하는 관용구가 중요하다. s = append(s, v)라고 쓰면 두 경우
모두 올바르게 동작한다.
용량은 어떻게 자라는가
package main
import "fmt"
func main() {
var s []int
prev := cap(s)
fmt.Printf("시작 len=%d cap=%d\n", len(s), cap(s))
for i := range 2000 {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("len=%-5d cap %-5d -> %d\n", len(s), prev, cap(s))
prev = cap(s)
}
}
// 최종 크기를 알면 미리 잡는다. 재할당도, 복사도 없다.
pre := make([]int, 0, 2000)
preCap := cap(pre)
regrow := 0
for i := range 2000 {
pre = append(pre, i)
if cap(pre) != preCap {
regrow++
preCap = cap(pre)
}
}
fmt.Println("미리 잡았을 때 재할당 횟수:", regrow)
// append는 새 슬라이스를 반환한다. 반환값을 버리면 아무 일도 없다.
base := []int{1, 2, 3}
appended := append(base, 4)
fmt.Println("base:", base, "appended:", appended)
// 여러 개 붙이기와 슬라이스 전개
joined := append([]int{0}, appended...)
fmt.Println("joined:", joined)
}
go run ./02-append-growth
시작 len=0 cap=0
len=1 cap 0 -> 4
len=5 cap 4 -> 8
len=9 cap 8 -> 16
len=17 cap 16 -> 32
len=33 cap 32 -> 64
len=65 cap 64 -> 128
len=129 cap 128 -> 256
len=257 cap 256 -> 512
len=513 cap 512 -> 848
len=849 cap 848 -> 1280
len=1281 cap 1280 -> 1792
len=1793 cap 1792 -> 2560
:::warning 이 숫자를 외우지 말 것
증가 규칙은 명세에 없다. 런타임 구현 사항이고, 요소 크기와 메모리 할당기의 크기
클래스에 따라 달라지며, 버전마다 바뀐다. 위 출력은 go1.26.5 / darwin-arm64 / []int
기준이다. 작을 때는 대략 두 배씩, 커지면 증가율이 완만해진다는 경향만 기억하면 된다.
확실한 것은 하나다 — cap이 바뀌면 그 순간 백킹 배열이 갈아 끼워졌다.
:::
나머지 출력.
미리 잡았을 때 재할당 횟수: 0
base: [1 2 3] appended: [1 2 3 4]
joined: [0 1 2 3 4]
make([]int, 0, 2000)으로 시작하면 재할당이 0번이다. 최종 개수를 알거나 어림잡을 수
있으면 cap을 미리 주는 것이 가장 값싼 최적화다. 3-6에서 실제 할당량 차이를 재 본다.
base가 [1 2 3] 그대로인 것도 눈여겨보자. base는 len == cap == 3이었으므로
append가 새 배열을 잡았다. 여기서 base의 cap이 4 이상이었다면 이야기가 달라진다.
백킹 배열 공유 — 실제 사고
package main
import "fmt"
func main() {
// 1. 여유 용량이 있는 슬라이스에 append하면 백킹 배열을 덮어쓴다.
base := []int{1, 2, 3, 4, 5}
head := base[:2]
fmt.Printf("head len=%d cap=%d\n", len(head), cap(head))
head = append(head, 999)
fmt.Println("base:", base)
fmt.Println("head:", head)
// 2. full slice expression으로 cap을 잘라 두면 append가 반드시 새 배열을 만든다.
base2 := []int{1, 2, 3, 4, 5}
safe := base2[:2:2]
fmt.Printf("safe len=%d cap=%d\n", len(safe), cap(safe))
safe = append(safe, 999)
fmt.Println("base2:", base2)
fmt.Println("safe: ", safe)
// 3. 한 배열을 나눠 가진 두 슬라이스는 서로를 침범한다.
all := []int{0, 1, 2, 3, 4, 5}
left, right := all[:3], all[3:]
left = append(left, 100)
fmt.Println("all: ", all)
fmt.Println("left: ", left)
fmt.Println("right:", right)
}
go run ./02-aliasing
head len=2 cap=5
base: [1 2 999 4 5]
head: [1 2 999]
safe len=2 cap=2
base2: [1 2 3 4 5]
safe: [1 2 999]
all: [0 1 2 100 4 5]
left: [0 1 2 100]
right: [100 4 5]
1번이 전형적인 사고다. head는 len=2 cap=5다. 용량이 남았으니 append는 백킹
배열의 3번째 칸(원래 3이 있던 자리)에 999를 쓴다. base가 조용히 망가진다.
head만 만졌는데 base가 바뀐 것이다.
3번이 더 고약하다. left에 append 한 번으로 right의 첫 요소가 바뀌었다.
"슬라이스를 앞뒤로 나눠서 각각 처리한다"는 흔한 패턴이 이렇게 무너진다.
full slice expression a[low:high:max]
2번이 해법이다. 세 번째 인덱스는 cap을 정한다.
s := a[low:high:max]
// len(s) == high - low
// cap(s) == max - low
base2[:2:2]는 len=2 cap=2다. 용량이 꽉 찼으니 append는 반드시 새 배열을 잡고,
base2는 안전하다.
:::tip 슬라이스를 밖으로 내보낼 때는 cap을 잘라라
함수가 내부 버퍼의 일부를 반환한다면 return buf[:n:n]을 쓴다. 호출자가 거기에
append해도 내 버퍼를 덮어쓰지 못한다. 표준 라이브러리가 곳곳에서 쓰는 방어 기법이다.
반대로 입력으로 받은 슬라이스에 append하는 함수는 호출자의 배열을 덮어쓸 수 있다는
사실을 문서에 적어야 한다.
:::
작은 슬라이스가 큰 배열을 붙잡는다
2-3에서 부분 문자열이 원본 바이트 배열을 그대로 가리킨다고 했다. 슬라이스도 똑같고, 이쪽이 영향이 더 크다. GC는 백킹 배열 전체를 하나의 객체로 보기 때문에, 4바이트짜리 슬라이스 하나가 64MiB 배열을 살려 둘 수 있다.
package main
import (
"fmt"
"runtime"
"slices"
)
// heapMiB는 GC를 한 번 돌린 뒤 살아 있는 힙 크기를 MiB로 돌려준다.
func heapMiB() float64 {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.HeapAlloc) / (1 << 20)
}
func main() {
fmt.Printf("시작: %6.1f MiB\n", heapMiB())
big := make([]byte, 64<<20)
for i := range big {
big[i] = byte(i)
}
fmt.Printf("64MiB 할당 후: %6.1f MiB\n", heapMiB())
// 뒤쪽 4바이트만 남기고 원본 참조를 버린다.
tail := big[len(big)-4:]
big = nil
fmt.Printf("tail만 남김: %6.1f MiB (len=%d)\n", heapMiB(), len(tail))
runtime.KeepAlive(tail)
// Clone은 딱 필요한 만큼만 새로 할당한다. 원래 배열은 이제 회수된다.
free := slices.Clone(tail)
tail = nil
fmt.Printf("Clone 후: %6.1f MiB (len=%d)\n", heapMiB(), len(free))
runtime.KeepAlive(free)
}
go run ./02-pinning
시작: 0.2 MiB
64MiB 할당 후: 64.2 MiB
tail만 남김: 64.2 MiB (len=4)
Clone 후: 0.2 MiB (len=4)
(0.2 MiB 기저값은 런타임 상태에 따라 조금씩 다르다. 봐야 할 것은 64MiB가 유지되다가
Clone 후에 사라지는 지점이다.)
big = nil을 해도 힙은 64MiB 그대로다. tail이 그 배열을 가리키고 있기 때문이다.
slices.Clone으로 4바이트를 새로 떠 내고 나서야 회수된다.
언제 신경 써야 하나. 큰 입력(파일 전체, 응답 본문)에서 작은 조각만 뽑아 오래 보관할 때다. 캐시, 맵의 값, 구조체 필드로 넣을 때가 대표적이다. 지역 변수로 잠깐 쓰고 버리는 것이라면 신경 쓸 필요 없다.
- 슬라이스는
slices.Clone(s)또는append([]T(nil), s...) - 문자열은
strings.Clone(s) []byte에서string으로의 변환은 어차피 복사가 일어난다
copy, clear, 그리고 삭제
package main
import (
"fmt"
"slices"
)
func main() {
src := []int{1, 2, 3, 4, 5}
// copy는 두 슬라이스 중 짧은 쪽 길이만큼만 옮긴다. 목적지를 늘려 주지 않는다.
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println("copy n =", n, "dst =", dst)
// 완전한 복제: 길이를 맞춰 놓고 copy
clone := make([]int, len(src))
copy(clone, src)
clone[0] = 100
fmt.Println("src:", src, "clone:", clone)
// slices.Clone이 같은 일을 한 줄로 한다.
fmt.Println("slices.Clone:", slices.Clone(src))
// clear는 요소를 제로값으로 만든다. 길이는 그대로다.
zeroed := []int{1, 2, 3}
clear(zeroed)
fmt.Println("clear 후:", zeroed, "len =", len(zeroed))
// 구간 삭제. 뒤를 앞으로 당기고 길이를 줄인다.
nums := []int{10, 20, 30, 40, 50}
nums = slices.Delete(nums, 1, 3)
fmt.Println("Delete(1,3):", nums, "len =", len(nums), "cap =", cap(nums))
// 조건 삭제. 원본을 건드리기 싫으면 Clone을 먼저 한다.
mixed := []int{1, -2, 3, -4, 5}
positive := slices.DeleteFunc(slices.Clone(mixed), func(v int) bool { return v < 0 })
fmt.Println("원본:", mixed, "음수 제거:", positive)
// 순서를 지킬 필요가 없다면 마지막 요소를 끌어오는 O(1) 삭제도 있다.
fast := []string{"a", "b", "c", "d"}
i := 1
fast[i] = fast[len(fast)-1]
fast = fast[:len(fast)-1]
fmt.Println("swap 삭제:", fast)
}
go run ./02-copy-clear-delete
copy n = 3 dst = [1 2 3]
src: [1 2 3 4 5] clone: [100 2 3 4 5]
slices.Clone: [1 2 3 4 5]
clear 후: [0 0 0] len = 3
Delete(1,3): [10 40 50] len = 3 cap = 5
원본: [1 -2 3 -4 5] 음수 제거: [1 3 5]
swap 삭제: [a d c]
copy
copy(dst, src)는 min(len(dst), len(src))개를 옮기고 그 수를 반환한다.
가장 흔한 실수는 목적지를 make([]int, 0, n)으로 만들어 놓고 copy를 부르는 것이다.
len(dst)가 0이라 한 개도 복사되지 않는다. 컴파일러도 vet도 잡아 주지 않는다.
dst := make([]int, 0, len(src))
copy(dst, src) // n == 0. 아무 일도 안 일어난다
make([]int, len(src))이 맞다. 아니면 그냥 slices.Clone(src)을 쓴다.
겹치는 구간에도 안전하고(memmove 의미론), copy(dst []byte, s string) 형태로 문자열도
받는다.
clear
clear(s)는 모든 요소를 제로값으로 만든다. 길이는 그대로다. 슬라이스에 대해서는
"비우기"가 아니다. 비우려면 s = s[:0](백킹 배열 유지)이나 s = nil(버리기)을 쓴다.
요소가 포인터나 포인터를 품은 구조체라면 s = s[:0]만으로는 GC가 그 객체들을 회수하지
못한다. 잘라 낸 뒤쪽 자리에 여전히 포인터가 남아 있기 때문이다. 버퍼를 재사용하는
코드라면 clear(s) 후에 s = s[:0]을 한다.
삭제
slices.Delete(s, i, j)가 표준 답이다. s[i:j]를 지우고 뒤를 앞으로 당긴다.
뒤에 남는 자리는 제로값으로 채워진다. 그래서 요소가 포인터여도 참조가 남지 않는다.
직접 손으로 쓴 append(s[:i], s[j:]...)에는 이 정리가 없어서, 그 관용구가 남긴
"유령 포인터"가 실제 누수 원인이 되곤 했다.
cap이 5로 유지된 것에 주목하자. Delete는 새 배열을 잡지 않는다. 입력 슬라이스의
백킹 배열을 고친다. 원본을 지키려면 slices.Delete(slices.Clone(s), i, j).
순서가 상관없으면 마지막 요소를 끌어오는 방법이 O(1)이다. [a d c]처럼 순서가 흐트러지는
것이 대가다.
흔히 하는 실수
1. append 결과를 다른 변수에 받는다
head := data[:2]
tail := append(head, x) // data를 덮어쓸 수도, 안 쓸 수도 있다
"쓸 수도, 안 쓸 수도"가 최악이다. 테스트에서는 통과하고 프로덕션 데이터에서 터진다.
필요하면 slices.Clone을 먼저 하거나 data[:2:2]로 cap을 자른다.
2. 순회하면서 원소를 지운다
for i, v := range items {
if bad(v) {
items = slices.Delete(items, i, i+1) // 인덱스가 어긋난다
}
}
range가 순회 대상 헤더를 처음에 확정해 두는 데다 인덱스까지 밀린다.
slices.DeleteFunc을 쓰거나, 새 슬라이스에 담아 나가거나, 뒤에서부터 순회한다.
3. copy의 목적지 길이가 0이다
앞서 본 그대로다. copy가 반환하는 개수를 확인하는 습관을 들이면 바로 잡힌다.
4. s = s[:0]로 비웠다고 메모리가 돌아온 줄 안다
백킹 배열은 그대로 살아 있다. 그것이 재사용 목적이면 옳고, 메모리를 돌려주고 싶은
거라면 s = nil이다.
5. 커다란 응답에서 조각만 떼어 오래 보관한다
앞의 pinning 예제다. 맵의 값이나 구조체 필드로 넣기 전에 slices.Clone /
strings.Clone을 거친다.
정리
len < cap이면append는 백킹 배열을 재사용한다. 아니면 새 배열을 잡고 복사한다.- 그래서
s = append(s, v)로 같은 변수에 되받는 것이 관용구다. - 증가 규칙은 명세에 없다. 최종 크기를 알면
make([]T, 0, n)으로 미리 잡는다. - 백킹 배열을 나눠 가진 슬라이스들은 서로를 침범한다.
a[low:high:max]로 cap을 잘라 두면append가 반드시 새 배열을 만든다.- 작은 조각이 큰 배열을 붙잡는다. 오래 보관할 조각은
slices.Clone/strings.Clone. copy는 짧은 쪽 길이만큼. 목적지의len이 기준이다.clear(s)는 제로값 채우기이지 비우기가 아니다.- 삭제는
slices.Delete/slices.DeleteFunc. 뒤쪽을 제로값으로 정리해 준다.
연습문제
-
func split(s []int, i int) ([]int, []int)가s[:i]와s[i:]를 반환한다고 하자. 호출자가 양쪽에 각각append했을 때 벌어지는 일을 예측하고 실제로 확인해 보자. 그다음 두 결과가 절대 서로를 침범하지 못하게 고쳐 보자. -
정수 슬라이스에서 중복을 제거하는
dedup(s []int) []int를 두 가지로 써 보자. (a) 새 슬라이스에 담는 방식, (b) 입력 슬라이스를 제자리에서 압축하는 방식. (b)에서 남는 뒤쪽 자리는 어떻게 처리해야 하는가? -
cap이 어떻게 자라는지[]byte,[]int64,[]struct{A,B,C int64}에 대해 각각 찍어 보고 결과를 비교해 보자. 왜 타입마다 다른가? 힌트: 메모리 할당기가 쓰는 크기 클래스와 관련이 있다.