Go 슬라이스 이해하기: 동적 배열 활용
James Reed
Infrastructure Engineer · Leapcell

Go의 슬라이스 타입은 많은 Go 프로그램의 핵심에 있는 강력하고 유연한 구성 요소입니다. 종종 동적 배열과 개념적으로 비교되지만, 슬라이스 자체가 배열이 아니라 기본 배열에 대한 뷰 또는 참조라는 점을 이해하는 것이 중요합니다. 이 구별은 동작, 성능 특성 및 효과적인 사용 방법을 파악하는 데 중요합니다.
슬라이스란 무엇인가? 배열에 대한 뷰
핵심적으로 Go 슬라이스는 세 가지 구성 요소로 이루어진 데이터 구조입니다.
- 포인터 (Pointer): 슬라이스가 참조하는 기본 배열의 첫 번째 요소를 가리킵니다. 반드시 기본 배열 자체의 시작일 필요는 없지만, 슬라이스 뷰의 시작점입니다.
- 길이 (Length, len): 슬라이스를 통해 현재 접근 가능한 요소의 수입니다. 이는 뷰의 길이를 나타냅니다.
- 용량 (Capacity, cap): 슬라이스의 포인터부터 시작하여 슬라이스가 재할당 없이 사용할 수 있는 기본 배열의 요소 수입니다. 이는 잠재적 뷰의 최대 범위를 나타냅니다.
시각적으로 메모리 내의 기본 배열을 상상해 보세요. 슬라이스는 단순히 해당 배열의 연속된 부분에 대한 창을 정의합니다.
// 기본 배열 var underlyingArray [10]int // underlyingArray의 일부를 보는 슬라이스 's' // s는 underlyingArray의 인덱스 2를 가리킴 // s는 길이 3 (인덱스 2, 3, 4의 요소)을 가짐 // s는 용량 8 (인덱스 2부터 9까지의 요소)을 가짐 s := underlyingArray[2:5]
이러한 설계는 엄청난 유연성을 제공합니다. 여러 슬라이스가 동일한 기본 배열을 참조할 수 있으며, 잠재적으로 겹치거나 다른 세그먼트를 볼 수 있습니다. 이러한 동작은 copy 연산을 이해하고 한 슬라이스의 수정이 다른 슬라이스를 통해 보일 수 있는 방법을 이해할 때 중요합니다.
슬라이스 생성
Go에서 슬라이스를 생성하는 방법은 여러 가지입니다.
1. 기존 배열 또는 슬라이스 슬라이싱
이것은 기존 배열이나 슬라이스를 활용하여 슬라이스를 만드는 가장 일반적인 방법입니다. a[low:high] 구문은 low부터 high (포함하지 않음)까지의 요소를 포함하는 슬라이스를 만듭니다.
arr := [5]int{10, 20, 30, 40, 50} // 인덱스 1 (포함)부터 인덱스 4 (제외)까지 슬라이싱 s1 := arr[1:4] // s1 == {20, 30, 40} fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1)) // s1: [20 30 40], len: 3, cap: 4 (20부터 50까지의 요소 사용 가능) // 슬라이스는 low 및 high 경계를 생략할 수 있습니다: s2 := arr[2:] // s2 == {30, 40, 50}, len: 3, cap: 3 s3 := arr[:3] // s3 == {10, 20, 30}, len: 3, cap: 5 s4 := arr[:] // s4 == {10, 20, 30, 40, 50}, len: 5, cap: 5 // 다른 슬라이스 슬라이싱: s5 := s1[1:] // s5 == {30, 40}, len: 2, cap: 3 (s1의 30부터 50까지의 기본 요소 사용 가능)
슬라이싱으로 생성된 새 슬라이스의 용량은 원래 슬라이스/배열의 용량에서 low 인덱스를 뺀 값으로 결정됩니다. 이는 새 슬라이스가 원래 슬라이스 또는 배열의 경계 외부의 요소를 접근할 수 없도록 합니다.
2. make() 사용
make() 함수는 지정된 길이와 선택적 용량으로 슬라이스를 만드는 데 사용됩니다. make가 슬라이스를 만들 때 메모리에 새로운 기본 배열을 할당합니다.
// 길이 5, 용량 5인 정수 슬라이스 생성 // 모든 요소는 해당 제로 값(int의 경우 0)으로 초기화됩니다. s := make([]int, 5) // s == {0, 0, 0, 0, 0}, len: 5, cap: 5 fmt.Printf("s: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // 길이 3, 용량 10인 문자열 슬라이스 생성 // 추가 용량은 재할당 없이 append 작업에서 사용할 수 있습니다. s2 := make([]string, 3, 10) // s2 == {"", "", ""}, len: 3, cap: 10 fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))
3. 복합 리터럴 사용
배열과 유사하게, 크기를 지정하지 않고 복합 리터럴을 사용하여 슬라이스를 직접 초기화할 수 있습니다. Go는 제공된 요소를 기반으로 길이를 추론합니다. 새 기본 배열이 할당됩니다.
scores := []int{100, 95, 80, 75} // scores == {100, 95, 80, 75}, len: 4, cap: 4 fmt.Printf("scores: %v, len: %d, cap: %d\n", scores, len(scores), cap(scores)) names := []string{"Alice", "Bob", "Charlie"} // names == {"Alice", "Bob", "Charlie"}
필수 슬라이스 연산
1. len(s): 현재 길이
len() 내장 함수는 슬라이스에 현재 있는 요소의 수를 반환합니다. 이것이 슬라이스의 "보이는" 크기입니다.
mySlice := []int{1, 2, 3, 4, 5} fmt.Println(len(mySlice)) // 출력: 5 subSlice := mySlice[1:3] // {2, 3} fmt.Println(len(subSlice)) // 출력: 2
2. cap(s): 기본 용량
cap() 내장 함수는 슬라이스의 용량을 반환하는데, 이는 슬라이스의 포인터부터 시작하여 슬라이스가 사용할 수 있는 기본 배열의 요소 수입니다. 이는 기본 배열이 언제 재할당될지를 이해하는 데 중요합니다.
mySlice := []int{1, 2, 3, 4, 5} fmt.Println(cap(mySlice)) // 출력: 5 (초기에는 리터럴의 경우 len == cap) subSlice := mySlice[1:3] // {2, 3} fmt.Println(cap(subSlice)) // 출력: 4 (mySlice의 배열 인덱스 1부터 4까지의 요소) anotherSlice := make([]int, 2, 10) // len:2, cap:10 fmt.Println(len(anotherSlice), cap(anotherSlice)) // 출력: 2 10
3. append(s, elems...): 요소 추가
append() 내장 함수는 슬라이스에 새 요소를 추가하는 기본 방법입니다. 개념적으로 원본 요소들과 새 요소들로 구성된 새 슬라이스를 반환합니다. 두 가지 시나리오가 있습니다.
- 충분한 용량: 슬라이스의 용량이 새 요소를 수용하기에 충분하면 append는 슬라이스의 길이를 확장하고 기존 기본 배열을 수정합니다. 반환된 슬라이스는 업데이트된 길이를 가진 동일한 기본 배열을 가리킬 가능성이 높습니다.
- 부족한 용량: 충분한 용량이 없으면 append는 새롭고 더 큰 기본 배열을 할당합니다. 기존 배열의 모든 요소를 새 배열로 복사하고 새 요소를 추가한 다음, 이 새 배열을 가리키는 슬라이스를 반환합니다. 이전 기본 배열(및 해당 배열을 가리키는 슬라이스)은 다른 참조가 없는 경우 가비지 수집 대상이 됩니다.
Go의 기본 배열 성장 전략은 일반적으로 재할당이 필요할 때 용량을 두 배로 늘리는 것입니다. 최대치에 도달하면 매우 큰 슬라이스의 경우 더 작은 계수(예: 1.25배)가 적용됩니다. 이는 재할당 비용을 상각합니다.
var numbers []int // nil 슬라이스, len:0, cap:0 numbers = append(numbers, 10) // numbers: [10], len:1, cap:1 (새 기본 배열) fmt.Printf("After 10: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers)) numbers = append(numbers, 20) // numbers: [10 20], len:2, cap:2 (새 기본 배열, 일반적으로 두 배) fmt.Printf("After 20: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers)) numbers = append(numbers, 30, 40) // numbers: [10 20 30 40], len:4, cap:4 (다시 새 기본 배열) fmt.Printf("After 30, 40: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers)) // 다른 슬라이스를 append하려면 '...'가 필요합니다. moreNumbers := []int{50, 60} numbers = append(numbers, moreNumbers...) // numbers: [10 20 30 40 50 60] fmt.Printf("After appending slice: %v, len: %d, cap: %d\n", numbers, len(numbers), cap(numbers))
append에 대한 중요 참고 사항: append는 새 슬라이스(다른 기본 배열을 가리킬 수 있음)를 반환할 수 있으므로, 결과를 원본 슬라이스 변수에 다시 할당하는 것이 중요합니다. 이것을 하지 않으면 원본 슬라이스는 변경되지 않은 상태로 남거나(또는 이전의, 잠재적으로 꽉 찬 기본 배열을 가리키게 됩니다).
s := []int{0, 1, 2} fmt.Printf("s before append: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // s before append: [0 1 2], len: 3, cap: 3 // 이 append는 재할당하고 새 슬라이스를 반환하지만, `s`는 여전히 이전 슬라이스를 가리킴. append(s, 3, 4) fmt.Printf("s after UNASSIGNED append: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // s after UNASSIGNED append: [0 1 2], len: 3, cap: 3 // 올바른 방법: 결과 다시 할당 s = append(s, 3, 4) fmt.Printf("s after ASSIGNED append: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // s after ASSIGNED append: [0 1 2 3 4], len: 5, cap: 6 (또는 Go 버전/아키텍처에 따라 8)
4. copy(dst, src): 요소 복사
copy() 내장 함수는 원본 슬라이스(src)의 요소를 대상 슬라이스(dst)로 복사합니다. min(len(src), len(dst))개의 요소를 복사합니다. copy는 새 기본 배열을 할당하지 않습니다. 기존 배열에서 작동합니다.
source := []int{10, 20, 30, 40, 50} destination := make([]int, 3) // destination: {0, 0, 0} n := copy(destination, source) // source에서 destination으로 3개 요소(10, 20, 30) 복사 fmt.Printf("Copied %d elements\n", n) // 출력: Copied 3 elements fmt.Printf("source: %v\n", source) // source: [10 20 30 40 50] fmt.Printf("destination: %v\n", destination) // destination: [10 20 30] // source에서 사용 가능한 것보다 더 많이 복사하거나, destination이 담을 수 있는 것보다 적게 복사하는 경우 destination2 := make([]int, 10) copy(destination2, source) // source의 모든 5개 요소를 destination2로 복사 fmt.Printf("destination2: %v\n", destination2) // destination2: [10 20 30 40 50 0 0 0 0 0] // 자체 복사 (이동과 같은 인플레이스 수정에 사용 가능) s := []int{1, 2, 3, 4, 5} copy(s[1:], s[0:]) // 요소 이동: s[1]은 s[0]을 받음, s[2]는 s[1]을 받음 등 fmt.Printf("Shifted slice: %v\n", s) // Shifted slice: [1 1 2 3 4]
copy는 일반적으로 다음을 위해 사용됩니다.
- 슬라이스 데이터의 완전한 독립 복사본 생성.
- 사용자 정의 슬라이스 조작(예: 삽입, 삭제, 필터링) 구현.
슬라이스 함정 및 모범 사례
1. 기본 배열 수정
슬라이스는 뷰이므로, 한 슬라이스를 통해 요소를 수정하면 동일한 기본 배열을 공유하는 다른 슬라이스에도 영향을 미칩니다. 단, 수정이 각각의 뷰 내부에 있을 경우에만 해당됩니다.
arr := [5]int{1, 2, 3, 4, 5} s1 := arr[1:4] // s1 == {2, 3, 4} s2 := arr[2:5] // s2 == {3, 4, 5} fmt.Printf("Initial: s1=%v, s2=%v, arr=%v\n", s1, s2, arr) s1[1] = 99 // arr[2]를 수정함 fmt.Printf("After s1[1]=99: s1=%v, s2=%v, arr=%v\n", s1, s2, arr) // 출력: // Initial: s1=[2 3 4], s2=[3 4 5], arr=[1 2 3 4 5] // After s1[1]=99: s1=[2 99 4], s2=[99 4 5], arr=[1 2 99 4 5]
이 동작은 일반적으로 효율성을 위해 바람직하지만, 의도하지 않은 부작용을 피하기 위해 신중한 고려가 필요합니다. 완전하게 독립적인 복사본이 필요한 경우 append 또는 copy를 사용하십시오.
original := []int{1, 2, 3} // 진정한 독립 복사본 생성 independentCopy := make([]int, len(original), cap(original)) copy(independentCopy, original) independentCopy[0] = 99 fmt.Printf("Original: %v, Independent Copy: %v\n", original, independentCopy) // 출력: Original: [1 2 3], Independent Copy: [99 2 3]
2. 서브 슬라이스로 인한 "메모리 누수"
커다란 기본 배열에서 작은 서브 슬라이스를 가져왔는데 서브 슬라이스만 유지하는 경우, 서브 슬라이스가 여전히 기본 배열에 대한 참조를 보유하고 있기 때문에 원래의 큰 배열이 가비지 수집되지 않을 수 있다는 점이 일반적인 우려입니다. 이는 필요 이상으로 많은 메모리를 유지하는 결과를 초래할 수 있습니다.
func createBigSlice() []byte { bigData := make([]byte, 1<<20) // 1MB 슬라이스 // ... bigData 채우기 ... return bigData[500:510] // 중간에서 작은 슬라이스 반환 } // createBigSlice()에서 반환된 10바이트 슬라이스가 도달 가능한 한 1MB 기본 배열은 메모리에 유지됩니다.
이를 방지하려면, 큰 슬라이스의 일부만 필요하고 나머지는 가비지 수집되도록 하려면 copy를 사용하여 작은 슬라이스에 대한 새로운 기본 배열을 생성하십시오.
func createSmallIndependentSlice(bigData []byte) []byte { smallSlice := bigData[500:510] // 자체 기본 배열을 가진 새 슬라이스 생성 independentSmallSlice := make([]byte, len(smallSlice)) copy(independentSmallSlice, smallSlice) return independentSmallSlice } // 이제 'bigData' 슬라이스는 다른 참조가 없는 경우 가비지 수집될 수 있습니다.
3. Nil 슬라이스 대 빈 슬라이스
- Nil 슬라이스: var s []int또는s := []int(nil).len == 0및cap == 0을 가집니다. nil 슬라이스에len,cap,append,range를 호출하는 것은 안전합니다.
- 빈 슬라이스: s := []int{}또는s := make([]int, 0).len == 0및cap == 0([]int{}의 경우) 또는 지정된 용량 (make의 경우)을 가집니다.
많은 문맥에서 기능적으로 유사하지만, nil 슬라이스는 "기본 배열 없음"을 진정으로 나타내는 반면, 빈 슬라이스는 0 길이 배열을 가리킬 수 있습니다. append가 nil 슬라이스를 올바르게 처리하므로 "제로 값"에 대해 nil 슬라이스를 사용하는 것이 일반적으로 좋은 방법입니다.
var nilSlice []int emptySlice := []int{} madeEmptySlice := make([]int, 0) fmt.Printf("nilSlice: %v, len: %d, cap: %d, is nil: %t\n", nilSlice, len(nilSlice), cap(nilSlice), nilSlice == nil) fmt.Printf("emptySlice: %v, len: %d, cap: %d, is nil: %t\n", emptySlice, len(emptySlice), cap(emptySlice), emptySlice == nil) fmt.Printf("madeEmptySlice: %v, len: %d, cap: %d, is nil: %t\n", madeEmptySlice, len(madeEmptySlice), cap(madeEmptySlice), madeEmptySlice == nil) // 모두 append하기 안전함 nilSlice = append(nilSlice, 1) fmt.Printf("nilSlice after append: %v\n", nilSlice) // 출력: [1]
결론
Go의 슬라이스는 요소 시퀀스를 다루는 기본적이고 고도로 최적화된 데이터 타입입니다. 기본 배열 모델과 len, cap, append, copy의 의미론을 이해함으로써 효율적이고 간결한 Go 프로그램을 구축하기 위한 강력한 도구를 얻게 됩니다. 슬라이스가 뷰라는 사실을 항상 명심하세요. 이 핵심 원칙은 동작을 예측하고 일반적인 함정을 피하는 데 도움이 될 것입니다. 슬라이스를 마스터하는 것은 숙련된 Go 개발자가 되기 위한 중요한 단계입니다.