본문으로 건너뛰기

Go append가 원본을 바꾸는 이유 — 슬라이스 · map · string 내부 구조

이 글의 순서8개 절

Go에서 자주 만나는 버그 목록을 적어 보니 셋 다 같은 모양이었다. 내가 들고 있다고 생각한 것과 실제로 들고 있는 것이 다르다. 슬라이스는 데이터가 아니라 창이고, map은 주소를 보장하지 않고, string은 문자 배열이 아니다. 세 자료구조의 레이아웃을 하나씩 열어 봤다.

배열과 슬라이스: 값이냐 창이냐

Go의 배열은 다른 언어의 배열과 다르다. 값 타입이고, 길이가 타입의 일부다. [5]int와 [4]int는 서로 다른 타입이라 같은 함수에 넘길 수도 없다. 대입하면 원소가 통째로 복사된다.

그래서 실무에서 배열을 직접 쓸 일은 거의 없다. 대신 슬라이스를 쓰는데, 슬라이스는 데이터를 가진 게 아니라 백킹 배열의 일부를 들여다보는 창이다. 창의 정체는 포인터 하나와 정수 두 개, 즉 3워드짜리 헤더가 전부다.

배열 [5]int · 값이라 대입하면 통째로 복사a12345b12345b := a → 다섯 칸 전부 복사b[0] = 99여도 a는 그대로길이가 타입의 일부다슬라이스 b := a[1:3] · 3워드 헤더 (24바이트)ptr주소len 2보이는 칸cap 4끝까지1[0]2[1]3[2]4[3]5[4]백킹 배열 alen 2 · 지금 보이는 구간cap 4 · 시작부터 배열 끝까지cap − len = 2 → append 두 번은 같은 배열에 쓴다
슬라이스는 백킹 배열의 일부를 가리키는 3워드 헤더다

여기서 막혔던 두 가지

그림을 그려 놓고도 한참을 더 헤맸다. 정리하고 보니 질문이 두 개였는데, 둘 다 “슬라이스는 배열의 업그레이드판”이라는 잘못된 그림에서 나온 것이었다.

① 배열 [4]int 뒤에 len과 cap을 붙이면 슬라이스인가? 아니다. 오히려 정반대에 가깝다. 배열과 슬라이스는 완전히 다른 타입이고, 슬라이스는 배열에 필드를 덧붙인 확장판이 아니라 배열을 가리키는 별도의 작은 구조체다. 배열은 그 자체가 데이터 덩어리이고, 슬라이스는 그 데이터를 어디서 찾을지 적어 둔 쪽지에 가깝다.

배열 변수와 슬라이스 변수
// 배열: 변수 자체가 데이터다. 가리키는 포인터 같은 건 없다
var arr [4]int = [4]int{1, 2, 3, 4} // arr = int 4개 크기의 메모리 그 자체
// 슬라이스 리터럴 한 줄이 실제로는 두 가지를 만든다
s := []int{1, 2, 3, 4}
// ① 어딘가에 {1,2,3,4}를 담을 밑바탕 배열을 만들고
// ② s에는 그 배열을 가리키는 {ptr, len, cap} 헤더를 담는다

슬라이스 뒤에는 항상 배열이 있다는 말은 맞다. make([]int, 4)처럼 배열 리터럴 없이 만들어도 안에서는 똑같이 배열이 생긴다. 그 배열이 눈에 안 보일 뿐이고, 슬라이스가 그 배열의 확장형인 건 아니다.

② 그럼 배열이 곧 ptr인가? 이것도 아니었다. 정확히는 배열이 ptr이 아니라 ptr이 가리키는 대상이다. 미묘해 보이지만 이 차이를 놓치면 뒤에 나올 메모리 누수가 이해되지 않는다.

함수에 넘기면 값 전달인가 참조 전달인가

면접에서 자주 나오는 질문인데, 둘 중 하나로 답하면 반은 틀린다. 정확히는 헤더는 값으로 복사되고, 데이터는 공유된다. 그래서 이런 비대칭이 생긴다.

호출자의 sptr · len 3 · cap 4함수 안의 s (복사본)ptr · len 3 · cap 4별개의 변수 · 값만 같다백킹 배열 · 실체는 하나123원소 수정은 보인다s[0] = 99같은 배열을 가리키니호출자도 99를 본다append는 안 보인다s = append(s, 4)len 4는 복사본 헤더뿐호출자의 len은 여전히 3
헤더는 복사되고 백킹 배열은 공유된다
원소 수정은 보이고 append는 안 보인다
func modify(s []int) {
s[0] = 99 // 보인다: 같은 백킹 배열
s = append(s, 4) // 안 보인다: 복사본 헤더의 len만 바뀐다
}
s := make([]int, 3, 4)
modify(s)
fmt.Println(s, len(s)) // [99 0 0] 3
// 그래서 append하는 함수는 반드시 슬라이스를 반환해야 한다
func appendTo(s []int, v int) []int { return append(s, v) }
s = appendTo(s, 4) // 표준 라이브러리가 전부 이 모양인 이유

append가 결과를 반환하는 시그니처라 불편해 보였는데, 헤더가 값 타입이니 다른 선택지가 없었던 것이다. strings.Builder나 bytes.Buffer가 포인터 리시버 메서드로 설계된 것도 같은 이유다.

append의 두 갈래: 원본 훼손과 연결 끊김

append했는데 원본이 바뀌기도 하고 안 바뀌기도 한다는 증상의 정체가 여기 있다. append는 cap에 여유가 있느냐 없느냐에 따라 완전히 다른 두 가지 동작을 한다. 그리고 어느 쪽인지는 코드만 봐서는 알 수 없다.

① cap에 여유가 있을 때같은 배열에 쓴다 → 원본이 바뀐다b := a[1:3] // len 2, cap 4전12345b = append(b, 99)후123995a == [1 2 3 99 5] · a의 4가 덮였다② cap을 넘을 때새 배열로 복사 → 원본과 연결이 끊긴다c := a[1:3:3] // cap 2a12345c = append(c, 77)새 배열2377a는 그대로 · 이후 c와 a는 따로 논다
cap에 여유가 있으면 원본 배열에 쓰고, 넘치면 새 배열로 옮긴다
같은 append, 다른 결과
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // len 2, cap 4: cap은 배열 끝까지 물려받는다
b = append(b, 99)
fmt.Println(a) // [1 2 3 99 5] 원본 훼손
a = []int{1, 2, 3, 4, 5}
c := a[1:3:3] // full slice expression: cap을 3-1 = 2로 제한
c = append(c, 77) // cap 초과, 새 배열로 복사
fmt.Println(a) // [1 2 3 4 5] 원본 안전

막는 방법은 셋 중 하나다. 상황에 따라 고르면 된다.

방법 언제 내용
cap 자르기 a[l:h:h] 남에게 슬라이스를 넘길 때 다음 append가 반드시 새 배열을 만들게 강제한다
copy로 떼어 내기 오래 보관할 때 필요한 부분만 새 슬라이스로 떠서 원본 참조를 완전히 끊는다
미리 잡기 make([]T, 0, n) 크기를 알 때 재할당과 복사를 아예 없앤다

cap이 늘어나는 규칙도 두 배라고 외우고 있었는데, 지금은 아니다. Go 1.18부터 기준점이 1024에서 256으로 낮아졌고, 그 위로는 두 배에서 1.25배 쪽으로 완만하게 줄어든다. 큰 슬라이스에서 메모리를 두 배씩 낭비하지 않으려는 조정이다. 게다가 계산된 값은 할당자의 size class로 올림되므로 실제 cap은 예상보다 클 수 있다. cap을 정확한 숫자로 가정하는 코드를 쓰면 안 되는 이유다.

재슬라이싱이 만드는 메모리 누수

창이라는 성질이 가장 조용하게 사고를 내는 곳이다. 슬라이스 헤더는 백킹 배열 전체를 참조한다. 그러니 10MB짜리 응답에서 3바이트만 잘라 보관해도, GC 입장에서는 10MB가 여전히 살아 있는 객체다.

왜 하필 전체냐면, 앞에서 본 것처럼 배열이 통째로 하나의 할당 단위이기 때문이다. 힙에 잡힌 10MB 블록에는 앞의 3바이트만 남기고 뒤는 돌려준다는 개념 자체가 없다. len이 3이라는 건 데이터가 3바이트로 줄었다는 뜻이 아니라 이 블록의 앞 3바이트만 보겠다고 창을 좁혔다는 뜻일 뿐이다. 그 블록을 향하는 포인터가 하나라도 살아 있으면 GC는 블록 전체를 건드리지 못한다.

✕ 조각만 들고 있어도 배열 전체가 남는다headlen 3 · cap 10M3B나머지 10MB · GC가 못 가져간다head := body[:3] // 10MB 중 3바이트✓ copy로 원본 참조를 끊는다headlen 3 · cap 33B새로 만든 3바이트 배열이 전부head := make([]byte, 3); copy(head, body)
조각만 남겨도 ptr이 가리키는 배열 전체가 살아 있다

비슷한 결로 하나 더 있다. 포인터를 담은 슬라이스에서 원소를 잘라 내면, 잘려 나간 자리의 포인터가 len 밖에 그대로 남아 있다. 눈에는 안 보이는데 GC는 여전히 그 객체를 살려 둔다.

✕잘라 내기만 한다

func pop(s []*Item) []*Item {
// s[len(s)-1]이 여전히 Item을 붙잡고 있다
return s[:len(s)-1]
}

✓nil을 넣고 자른다

func pop(s []*Item) []*Item {
s[len(s)-1] = nil // 참조를 먼저 끊고
return s[:len(s)-1]
}

중간 삭제도 같은 원리다. Go 1.22부터 slices.Delete는 줄어든 뒤쪽 자리를 0으로 지워 주니, 직접 구현하지 말고 이걸 쓰면 된다.

🧪 직접 해보기

누수를 힙 통계로 확인하기

정말 안 줄어드는지 runtime.ReadMemStats로 재 봤다. 10MB 배열에서 3바이트만 남기는 두 가지 방식을 비교한다.

재슬라이싱과 copy 비교
func heap() uint64 {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.HeapAlloc
}
big := make([]byte, 10<<20) // 10MB
leak := big[:3] // 재슬라이싱
big = nil
fmt.Println("재슬라이싱:", heap()) // 10MB가 그대로 남는다
safe := make([]byte, 3) // copy로 떼어 내기
copy(safe, leak)
leak = nil
fmt.Println("copy:", heap()) // 10MB가 사라진다

처음엔 big = nil만 하면 될 줄 알았는데 힙이 꿈쩍도 안 했다. leak이 같은 블록을 붙잡고 있으니 당연했다. 그 블록을 향하는 포인터가 전부 사라져야 회수된다. copy로 떼어 낸 뒤 leak = nil까지 하고 나서야 10MB가 사라졌다.

숫자보다 중요한 건 이걸 프로파일러로 찾기가 얼마나 어려운가였다. go tool pprof -inuse_space로 보면 할당 지점은 io.ReadAll로 잡힌다. 정작 범인은 한참 떨어진 곳에서 조각을 캐시에 넣은 코드인데 말이다. 그래서 슬라이스를 오래 보관할 때는 copy한다는 걸 규칙으로 굳혔다.

nil 슬라이스 vs 빈 슬라이스

둘은 거의 모든 면에서 똑같이 동작한다. 딱 두 군데에서만 갈리는데, 그 두 군데가 하필 API 경계다.

var s []int (nil) s := []int{} (빈)
len / cap 0 / 0 0 / 0
append, range 똑같이 동작 똑같이 동작
힙 할당 없음 없음 (ptr만 nil이 아닌 주소)
s == nil true false
JSON 직렬화 null []

Go 코드 리뷰 가이드(Go Code Review Comments)는 var s []T 쪽을 권한다. 다만 JSON 응답을 만들 때는 예외라고 짚어 둔다. 결과가 없을 때 null이 나가면 프런트엔드에서 .map()이 터지기 때문이다.

map: Swiss Table, 그리고 의도된 불친절 두 가지

map은 Go 1.24에서 구현이 통째로 바뀌었다. 오래된 자료가 설명하는 버킷 8개와 오버플로 버킷 체인은 이제 옛날 이야기고, 지금은 Swiss Table 기반이다. 함정을 이해하려면 이 구조를 한 번은 보고 가는 게 좋다.

hash(key)를 두 조각으로h1 · 상위 57비트 · 어느 그룹으로 갈지h2하위 7비트그룹 = 슬롯 8개 + 컨트롤 워드 8바이트컨트롤238950—————슬롯563221키 32 찾기 (h2 = 89)컨트롤 8바이트를 한 번에 비교해 후보를 고른다Go가 더한 계층 · 테이블 분할디렉터리해시 상위 비트로table 0≤ 1024슬롯table 1≤ 1024슬롯table 2≤ 1024슬롯커질 때 테이블 하나씩만 키운다 → 지연이 튀지 않는다
Go 1.24부터 맵은 슬롯 8개짜리 그룹과 테이블 분할 구조다

성능보다 중요한 건, 이 구조가 map이 왜 그렇게 불친절한지를 설명해 준다는 점이다. 자주 욕먹는 두 가지 성질은 둘 다 의도된 설계다.

① 순회 순서가 매번 다르다

런타임이 순회 시작 지점을 일부러 무작위로 잡는다. 해시 테이블이라 원래도 순서가 정해져 있지 않지만, 그냥 두면 어쩌다 항상 같은 순서가 나오는 상황이 생기고 개발자는 거기에 기대게 된다. 그러면 데이터가 조금 달라진 어느 날 운영에서 터진다. 무작위화는 그 버그를 개발 단계에서 억지로 깨뜨리는 장치다. 순서가 필요하면 키를 뽑아 정렬한다.

② 동시 쓰기를 감지하면 즉시 죽는다

그리고 이건 panic이 아니라 fatal error다. 카드로 외웠을 때 놓쳤던 부분인데, 차이가 크다. recover로 잡을 수 없고, defer도 실행되지 않고, 프로세스가 그 자리에서 끝난다.

recover로 잡히지 않는다
m := map[int]int{}
for i := 0; i < 100; i++ {
go func(i int) {
defer func() {
if r := recover(); r != nil { // 이 recover는 절대 실행되지 않는다
fmt.Println("복구:", r)
}
}()
m[i] = i
}(i)
}
실행 결과
fatal error: concurrent map writes
# 읽기와 쓰기가 겹치면
fatal error: concurrent map read and map write

이렇게까지 하는 이유는, 테이블을 키우는 도중에 구조가 깨지면 추적할 수 없는 메모리 손상이 되기 때문이다. 잘못된 값을 조용히 돌려주거나 남의 메모리를 밟는 것보다, 그 자리에서 스택 트레이스를 남기고 죽는 편이 낫다는 판단이다.

다만 이 감지는 보장이 아니라 최선의 노력이다. 타이밍이 어긋나면 잡히지 않고 조용히 지나갈 수도 있다. 그래서 -race가 여전히 필요하다.

그래서 실무에서는 무엇을 쓰나

방식 언제 대가
sync.RWMutex로 감싸기 기본값. 고민되면 여기서 시작 쓰기가 잦으면 락 경합
샤딩 경합이 실제로 프로파일에 잡힐 때 전체 순회와 크기 계산이 번거롭다
sync.Map 읽기가 압도적이고 키 집합이 안정적일 때 (캐시류) 타입 안전성이 없다. 일반적인 읽기·쓰기 혼합에서는 오히려 느린 경우가 많다
RWMutex로 감싼 맵
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(k string) (int, bool) {
s.mu.RLock() // 읽기는 여럿이 동시에
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *SafeMap) Set(k string, v int) {
s.mu.Lock() // 쓰기는 혼자
defer s.mu.Unlock()
s.m[k] = v
}

마지막으로 잘 알려지지 않은 함정 두 개. 맵 요소의 주소는 얻을 수 없고(&m[k]는 컴파일 에러다. 지난 글에서 다뤘다), delete를 해도 메모리는 줄지 않는다. 슬롯이 삭제됨으로 표시될 뿐 테이블은 그대로다. 한 번 크게 부풀었다가 비워지는 맵이 있다면, 새 맵을 만들어 살아 있는 항목만 옮기는 수밖에 없다.

string: 문자 배열이 아니다

Go의 string은 읽기 전용 UTF-8 바이트 뭉치다. 헤더는 데이터 포인터와 길이, 2워드뿐이고 불변이다. 문자들의 배열이라는 직관으로 다가가면 세 군데에서 어긋난다.

s := “한글Go” 의 실제 메모리인덱스0ED19529C3EA4B858064776F‘한’ 3바이트‘글’ 3바이트‘G’‘o’직관과 어긋나는 것len(s) == 8글자 수가 아니라 바이트 수s[0] == 0xED’한’이 아니라 바이트 하나range는 rune 단위0 ‘한’ 3 ‘글’6 ‘G’ 7 ‘o’i는 여전히 바이트 위치rune도 사람이 보는 글자는 아니다가족 이모지(사람 셋 + ZWJ 둘) = 5 rune, 18바이트조합형(NFD) ‘한’ = 자모 셋 = 3 rune, 9바이트글자 수는 grapheme 단위로 센다
바이트, rune, 사람이 보는 글자(grapheme)는 서로 다른 단위다
len, 인덱싱, range
s := "한글Go"
fmt.Println(len(s)) // 8: 바이트 수
fmt.Println(utf8.RuneCountInString(s)) // 4: 코드 포인트 수
fmt.Println(s[0]) // 237 (0xED): byte 하나
for i, r := range s { // range만 rune 단위로 돈다
fmt.Println(i, string(r)) // 0 한 / 3 글 / 6 G / 7 o
} // i는 바이트 위치라 1, 2는 건너뛴다
// 잘못된 UTF-8이 섞여 있으면 range는 U+FFFD(�)를 1바이트씩 내놓는다. 무한 루프는 생기지 않는다

불변이라서 생기는 비용: +=는 O(N²)이다

string이 불변이라는 건 고치는 일이 곧 새 문자열을 만드는 일이라는 뜻이다. 루프에서 +=로 이어 붙이면 반복마다 지금까지 만든 전체를 새 버퍼에 복사한다. N번 반복하면 총 복사량이 N²에 비례한다.

✕+=로 이어 붙이기

// 반복마다 지금까지 전체를 복사
var s string
for _, w := range words {
s += w
}

✓strings.Builder

// 버퍼 하나에 이어 쓴다
var b strings.Builder
b.Grow(totalLen) // 크기를 알면 재할당도 없다
for _, w := range words {
b.WriteString(w)
}
s := b.String() // 복사 없이 넘긴다

단순히 이어 붙이는 거라면 strings.Join(words, "")가 더 읽기 쉽다. 안에서 한 번에 할당한다.

string과 []byte 변환은 복사다, 단 예외가 있다

string은 불변이고 []byte는 가변이다. 그래서 서로 바꿀 때 기본적으로 복사가 일어난다. 그러지 않으면 []byte를 고쳐서 string을 바꿀 수 있게 되니까. 핫 패스에서 이 변환을 반복하면 눈에 안 보이는 할당이 쌓인다.

다만 컴파일러가 변환 결과가 살아남지 않는다고 증명할 수 있는 몇 가지 자리에서는 복사를 생략한다.

복사가 생략되는 자리와 아닌 자리
b := []byte("key")
v := m[string(b)] // 맵 조회: 복사 생략
if string(b) == "key" {} // 비교: 복사 생략
s := string(b) // 변수에 담으면 복사 (살아남으니 당연하다)
// 정말 복사를 없애야 한다면 (Go 1.20+), 확신이 있을 때만
s = unsafe.String(unsafe.SliceData(b), len(b))
// 이후 b를 고치면 s도 바뀐다 = 불변성 계약이 깨진다

어느 자리가 생략되는지는 컴파일러 버전마다 조금씩 넓어진다. 핫 패스라면 짐작하지 말고 go test -bench . -benchmem의 allocs/op로 확인한다.

🧪 직접 해보기

글자 수에 답이 세 개 있다

문자열 길이를 재는 방법이 왜 하나가 아닌지, 세 층위를 한 번에 찍어 봤다.

바이트 수와 rune 수
for _, s := range []string{"한글Go", "👨‍👩‍👧", "é"} {
fmt.Printf("%-10q len=%2d runes=%d\n", s, len(s), utf8.RuneCountInString(s))
}
실행 결과
"한글Go" len= 8 runes=4
"👨‍👩‍👧" len=18 runes=5
"é" len= 3 runes=2

사람이 보는 글자 수는 차례로 4, 1, 1이다. 마지막 é는 e 뒤에 결합 악센트(U+0301)가 붙은 조합형이다. 세 줄이 전부 다른 답을 준다. DB 컬럼 길이 제한, 입력 글자 수 카운터, 문자열 자르기가 실무에서 자꾸 깨지는 이유가 이것이었다. len으로 자르면 글자가 깨지고, rune으로 잘라도 이모지는 깨진다. 사람이 인식하는 글자 단위가 필요하면 grapheme cluster를 다루는 라이브러리를 써야 한다.

정리

  • 슬라이스는 창이다. 전달되는 건 3워드 헤더뿐이고 데이터는 공유된다. 원소 수정은 보이는데 append는 안 보이는 비대칭, cap 여유에 따라 달라지는 append, 조각이 배열 전체를 붙잡는 누수가 전부 여기서 나온다.
  • 남에게 넘길 때는 a[l:h:h], 오래 보관할 때는 copy. 이 두 규칙만 지켜도 슬라이스 사고 대부분이 사라진다.
  • map의 불친절은 전부 의도된 설계다. 순회 무작위화는 순서에 기대는 코드를 일찍 깨뜨리려고, 동시 쓰기 fatal error는 조용한 메모리 손상을 막으려고 있다. 후자는 recover로 못 잡고 감지도 보장이 아니라서 -race가 필요하다.
  • string은 불변 UTF-8 바이트다. 바이트, rune, grapheme 세 층위가 전부 다르고, 불변이라 이어 붙이기는 strings.Builder로, 변환은 복사가 일어난다는 전제로 다룬다.

세 자료구조를 나란히 놓고 보니 공통점이 하나 보였다. Go는 편의를 위해 실체를 숨기지 않는다. 슬라이스는 창이라는 걸 숨기지 않고, map은 요소 주소를 보장하는 척하지 않고, string은 문자 배열인 척하지 않는다. 처음엔 불친절해 보였는데, 숨겼다면 그 대가를 훨씬 나쁜 때에 치렀을 것 같다.

GitHub에서 고치기
esc

최근 글

↑↓ 이동↵ 열기검색 페이지에서 보기 ›