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 개발자가 되기 위한 중요한 단계입니다.