Slice는 무엇인가

slice는 연속된 메모리 공간에 동일한 타입의 데이터를 순차적으로 저장할 때 사용하는 Go의 타입입니다. 이런 점에선 배열과 동일하지만 길이가 고정적인 배열과는 다르게 길이를 유동적으로 다룰 수 있습니다.

Slice의 내부 구조

slice는 내부적으로 배열의 첫 번째 요소를 가리키는 포인터와 slice의 길이와 용량을 나타내는 값을 가지고 있습니다.

슬라이싱1.png

길이는 익숙하지만 용량은 생소하게 느껴질 수 있습니다. 그렇다면 slice에서 용량은 무엇이고 왜 있는걸까요?

배열은 길이의 변경이 필요할 때마다 새로운 길이를 가진 배열을 다시 할당하는 비효율적인 작업을 해야합니다.

slice는 길이의 변경에 대비하여 미리 특정 용량을 가진 배열을 할당해두고, 정해진 길이 만큼만 사용할 수 있도록 하여, 길이의 수정 만으로 배열을 재할당할 필요 없이 유동적으로 다룰 수 있도록 하는 것입니다.

Slice의 생성

그럼 실제 코드를 보며 slice가 어떻게 동작하는지 살펴보겠습니다.

a := make([]int, 5, 10) // 길이가 5이고 용량이 10인 int형 slice

make 함수를 통해 길이가 5이고 용량이 10인 int형 slice를 만들었습니다.

make 함수에는 타입, 길이, 용량을 차례로 입력하여 slice를 생성할 수 있고, 만약 용량을 생략하면 길이와 용량이 같은 slice를 생성합니다.

b := []int{1, 20, 300, 4000}

make 함수를 사용하지 않고 slice 리터럴을 사용하여 slice를 생성할 수 있습니다. b는 자동으로 길이와 용량이 4인 slice가 됩니다.

Nil Slice

var a []int
var b []int = make([]int, 0)
var c []int = []int{}

fmt.Println(a, b, c) // [] [] []

전에 말씀드렸던 것처럼, 변수를 생성하고 할당하지 않은 slice는 len과 cap인 0인 nil slice가 됩니다. 위의 a, b, c 세 변수 모두 길이와 용량이 0인 slice이고 출력 결과도 같습니다. 그렇다면 세 변수 모두 nil slice일까요?

결론부터 말씀드리자면, 세 변수 모두 길이와 용량이 0인 slice는 맞지만 nil slice는 아닙니다. nil slice는 변수 a 단 한 개입니다.

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false

위와 같은 nil slice는 단순히 길이와 용량이 0인 empty slice와 구분하기 힘들고 에러를 일으키기 쉬우므로, 사용시 주의하셔야 합니다.

Slicing

그렇다면 slicing은 무엇일까요? Go에서는 배열과 slice의 특정 영역을 slice 형태로 추출할 수 있도록 slicing이라는 기능을 제공합니다. 사용법은 다음과 같습니다.

a := []int{1, 200, 3000, 40000}
b := a[0:3] // [1 200 3000] : 0번 인덱스부터 3번 인덱스 전까지 Slicing 합니다.
c := b[1:3] // [200 3000]
d := a[:2] //  [1 200] : a[0:2]와 동일
e := a[2:] // [3000 40000] : a[2:4]와 동일

변수명[시작인덱스(포함):끝인덱스(불포함)]만 기억하면 어렵지 않게 사용하실 수 있을 것입니다.

Slicing의 내부 동작

위의 slicing 코드가 내부적으로 어떻게 동작하는지 살펴보겠습니다.

슬라이싱1.png

변수 a가 생성된 모습입니다. make 함수를 사용하지 않고 리터럴을 통하여 slice를 생성하였기 때문에, slice 내부의 요소의 수에 따라 자동으로 길이와 용량이 4인 슬라이스가 생성되었습니다.

슬라이싱2.png

b := a[0:3]을 통하여 변수 b를 생성하였습니다. ba를 slicing한 slice이기 때문에 내부 배열을 공유합니다. 하지만, 길이가 3인 slice로 [1 20 300]을 값으로 갖습니다.

그렇다면 b를 slicing한 c는 어떨까요?

슬라이싱3.png

c 역시 a를 slicing한 b를 slicing 하였기 때문에 a와 내부 배열을 공유합니다. 하지만 포인터가 두 번째 인덱스를 가리키게 되므로, 용량은 3이 됩니다.

여기서 주의할 점은 slicing은 내부 배열을 공유한다는 것입니다. 이러한 상황에서 slice 내부의 값을 수정하게 되면, 내부 배열을 공유하고 있는 모든 slice의 값 역시 변경됩니다. 만약, 내부 배열을 공유하지 않고 싶다면 copy 함수를 사용해 복사된 slice를 생성해야 합니다.

Append Slice

그렇다면 slice에 값을 추가하기 위해선 어떻게 해야할까요? 답은 append 함수입니다. append 함수는 기존 slice와 추가할 요소들을 차례로 입력받아, 요소가 추가된 slice를 반환합니다. 사용법은 다음과 같습니다.

a := []int{1, 200, 3000, 40000}
b := append(a, 500000, 6000000) // a slice에 500000, 6000000을 추가합니다.

c := append(a, b...) // '...' 연산자를 통해 slice끼리 이어 붙일 수 있습니다.

fmt.Println(a) // [1 200 3000 40000]
fmt.Println(b) // [1 200 3000 40000 500000 6000000]
fmt.Println(c) // [1 200 3000 40000 1 200 3000 40000 500000 6000000]

정말 편리하죠? 하지만 append 함수를 사용할 때 주의해야 할 것이 있습니다. 위에서 말씀드린 것처럼 slice는 내부적으로 길이와 용량 값을 갖고 있는데, append 함수는 용량 값에 따라 다르게 동작합니다.

만약 slice의 용량이 새로운 요소들을 추가하기 충분하다면 append 함수는 배열을 재할당할 필요 없이 내부배열을 공유하고 길이가 다른 slice를 생성할 것입니다. 하지만 용량이 충분하지 않다면, append 함수는 상황에 따라 최적화 된 용량을 가진 slice를 생성합니다. 이렇게 생성된 slice는 기존 slice와 내부 배열을 공유하지 않습니다.

    a := make([]int, 3, 4) // len: 3 cap: 4인 slice 생성
    a[0] = 10
    a[1] = 20
    a[2] = 30

    b := append(a, 40) // a에 여분의 용량이 남으므로 내부배열 공유
    c := append(a, 50) // a에 여분의 용량이 남으므로 내부배열 공유
    d := append(c, 60) // c에 여분의 용량이 남지 않으므로 새로운 내부배열 할당

    fmt.Println(a, len(a), cap(a)) // [10 20 30] 3 4 
    fmt.Println(b, len(b), cap(b)) // [10 20 30 50] 4 4
    fmt.Println(c, len(c), cap(c)) // [10 20 30 50] 4 4
    fmt.Println(d, len(d), cap(d)) // [10 20 30 50 60] 5 8

위의 코드를 보면 쉽게 이해가 가지 않는 내용이 있을 것입니다. 바로 b의 값입니다. 예상대로라면 [10 20 30 40]이어야 합니다. 하지만 c 역시 a의 내부배열을 공유하므로, c가 생성될 때 값이 덮어씌워져 예상치 못한 결과를 만들게 된 것입니다.

마치며

slice의 기본적인 사용법부터 slice의 내부구조까지 집중 탐구라 하긴 부끄럽지만 살짝이나마 살펴 보았습니다. slice는 정말 편리한 자료구조지만 slice를 정확하게 이해하지 못하고 사용하면 예상치 못한 결과를 만들어 낼 수 있으므로 주의해야 할 것 같네요.😄