Go 언어에서 슬라이스를 생성할 때 make([]T, 0, cap) 형태와 make([]T, len) 형태는 внешне 유사해 보이지만, 런타임의 메모리 관리 방식과 데이터 조작 전략에서 명확한 차이를 보입니다. 이 차이는 주로 초기 길이 설정, 제로값 할당 여부, 그리고 append 호출 시의 재할당 로직에서 발생합니다.
1. 길이(Length)와 용량(Capacity)의 초기 상태
슬라이스 초기화 시 전달하는 인자에 따라 런타임이 관리하는 내부 메타데이터가 다르게 구성됩니다.
- 용량만 미리 확보하는 경우 (
make([]string, 0, size)): 길이는 0으로 설정되며, 지정된 크기만큼의 백킹 배열(backing array)이 미리 할당됩니다. 요소가 비어 있는 상태이므로 인덱스를 통한 직접 접근은 런타임 패닉을 유발합니다. - 길이와 용량을 동일하게 설정하는 경우 (
make([]string, size)): 길이와 용량이 모두size로 고정됩니다. 런타임은 해당 영역을 타입의 제로값(문자열의 경우"")으로 미리 채워놓습니다. 따라서 인덱스를 이용한 즉시 덮어쓰기가 가능합니다.
2. 요소 추가 시 메모리 재할당 전략
두 방식의 가장 큰 차이는 append 내장 함수를 사용할 때 드러납니다. Go의 슬라이스는 현재 길이(len)가 용량(cap)을 초과하려는 순간 새로운 메모리 블록을 할당하고 기존 데이터를 복사합니다.
make([]string, 0, N)으로 생성된 슬라이스는append를 통해 요소를 추가할 때, 누적 개수가N을 넘지 않는 한 추가적인 메모리 할당이 발생하지 않습니다. 이는 동적으로 데이터를 수집할 때 매우 효율적입니다.- 반면,
make([]string, N)은 생성 시점부터 길이가 용량과 동일합니다. 이 상태에서append를 단 한 번만 호출해도 즉시 용량 초과로 간주되어, 런타임은 기존 배열보다 큰 새로운 공간을 할당하고 데이터를 이동시킵니다. 이는 불필요한 GC 부하와 CPU 사이클을 낭비할 수 있습니다.
3. 실제 코드 패턴 비교
다음 예제는 동일한 소스 데이터를 처리할 때 초기화 방식에 따라 어떻게 다른 결과가 나오는지 보여줍니다.
패턴 A: 동적 수집에 최적화된 방식
sourceItems := []string{"alpha", "beta", "gamma"}
buffer := make([]string, 0, len(sourceItems))
for _, val := range sourceItems {
buffer = append(buffer, val) // len이 0에서 시작하므로 cap 범위 내에서는 재할당 없음
}
// 결과: len=3, cap=3, 요소=[alpha beta gamma]
패턴 B: 인덱스 기반 직접 할당 방식
sourceItems := []string{"alpha", "beta", "gamma"}
targetList := make([]string, len(sourceItems))
for idx, val := range sourceItems {
targetList[idx] = val // 제로값이 채워진 공간을 인덱스로 직접 덮어씀
}
// 결과: len=3, cap=3, 요소=[alpha beta gamma]
4. 흔한 실수: 길이 설정 후 append 혼용
맵(Map)의 키를 슬라이스로 추출하는 상황에서 초기화 방식을 잘못 선택하면 의도치 않은 길이 증가와 메모리 복사가 발생합니다.
configMap := map[string]int{"port": 8080, "timeout": 30}
// 잘못된 접근: 길이를 미리 채운 상태에서 append 사용
faultySlice := make([]string, len(configMap)) // len=2, cap=2, 요소=["", ""]
for key := range configMap {
faultySlice = append(faultySlice, key) // len이 이미 2이므로 즉시扩容 발생
}
// 최종 상태: len=4, cap=4, 요소=["", "", "port", "timeout"] (순서는 랜덤)
위 코드에서 faultySlice는 초기 제로값 2개를 보유한 상태에서 append가 호출됩니다. 런타임은 기존 2개의 빈 문자열 뒤에 새로운 키를 추가하려 시도하지만, 용량이 부족하다고 판단하여 배열 크기를 두 배로 확장합니다. 결과적으로 원하지 않는 빈 문자열이 앞에 남고 전체 길이가 예상보다 커집니다.
이를 방지하려면 데이터 수집 목적일 때는 항상 길이를 0으로 설정하고 용량만 미리 지정해야 합니다.
// 올바른 접근: 용량만 예약하고 append로 채우기
validSlice := make([]string, 0, len(configMap)) // len=0, cap=2
for key := range configMap {
validSlice = append(validSlice, key)
}
// 최종 상태: len=2, cap=2, 요소=["port", "timeout"]
5. 성능 및 메모리 관점에서의 선택 기준
- 메모리 초기화 오버헤드:
make([]T, N)은 할당 직후 제로값으로 메모리를 초기화하는 비용이 발생합니다. 반면make([]T, 0, N)은 백킹 배열 공간만 확보할 뿐 초기화 루프를 생략하므로, 대규모 슬라이스를 다룰 때 미세한 성능 이점을 가질 수 있습니다. - JSON 직렬화 동작: 길이가 0인 슬라이스는 일반적으로
[]로 인코딩되는 반면, 제로값으로 채워진 슬라이스는["", "", ...]형태로 출력됩니다. API 응답 스펙에 따라 적절한 초기화 방식을 선택해야 합니다. - 인덱스 접근 안정성: 병렬 처리나 특정 알고리즘에서 임의의 인덱스에 값을 배치해야 한다면
make([]T, N)이 필수적입니다. 길이가 0인 슬라이스에 인덱스로 접근하면 즉시panic: runtime error: index out of range가 발생합니다.