Golang 슬라이스 데이터 구조

GolangBeginner
지금 연습하기

소개

이전 섹션에서는 Go 의 배열에 대해 알아보았습니다. 하지만 배열에는 한계가 있습니다. 한 번 선언하고 초기화하면 길이를 변경할 수 없다는 점입니다. 따라서 배열은 일상적인 프로그래밍에서 널리 사용되지 않습니다. 반면, 슬라이스는 훨씬 더 자주 사용되며 유연한 데이터 구조를 제공합니다.

학습 포인트:

  • 슬라이스 정의하기
  • 슬라이스 초기화하기
  • 슬라이스 연산: 추가, 삭제, 수정 및 검색
  • 슬라이스 확장
  • 슬라이스 절단 (Truncation)
  • 다차원 슬라이스
이 실습은 단계별 안내를 제공하는 가이드 랩입니다. 각 단계를 주의 깊게 따라하며 직접 경험을 쌓아보세요. 통계에 따르면 이 실습은 초급 수준이며, 93%의 완료율과 학습자들로부터 96%의 긍정적인 평가를 받았습니다.

슬라이스란 무엇인가

슬라이스는 배열과 유사하게 동일한 데이터 타입의 요소를 담는 컨테이너입니다. 그러나 배열은 선언 및 초기화 후 길이를 변경할 수 없다는 제약이 있습니다. 배열도 나름의 용도가 있지만 유연성이 떨어집니다. 따라서 실제 프로그래밍에서는 슬라이스가 더 보편적으로 사용됩니다.

Go 에서 슬라이스는 배열을 사용하여 구현됩니다. 슬라이스는 본질적으로 길이가 변할 수 있는 동적 배열입니다. 배열에서는 불가능한 요소의 추가, 삭제, 수정, 검색과 같은 작업을 슬라이스에서는 자유롭게 수행할 수 있습니다.

슬라이스 정의하기

슬라이스의 초기화 문법은 배열과 매우 유사합니다. 가장 큰 차이점은 요소의 길이를 지정할 필요가 없다는 것입니다. 다음 코드를 살펴보세요.

// 길이가 5 인 배열 선언
var a1 [5]byte
// 슬라이스 선언
var s1 []byte

슬라이스는 배열에 대한 참조입니다

int 타입의 배열을 선언하면 각 요소의 제로 값 (Zero value) 은 0 이 됩니다.

하지만 슬라이스를 선언하면 슬라이스의 제로 값은 nil이 됩니다. 이를 확인하기 위해 slice.go 파일을 생성해 보겠습니다.

touch ~/project/slice.go

다음 코드를 입력하세요.

package main

import "fmt"

func main() {
    var a [3]int
    var s []int

    fmt.Println(a[0] == 0) // true
    fmt.Println(s == nil)  // true
}
go run slice.go

코드를 실행하면 다음과 같은 결과가 출력됩니다.

true
true

배열 a와 슬라이스 s를 생성했습니다. 배열 a의 첫 번째 요소가 0 인지 비교하고, 슬라이스 snil인지 확인했습니다.

보시다시피 슬라이스를 선언만 했을 때의 제로 값은 nil입니다. 이는 슬라이스가 데이터를 직접 저장하지 않고 배열을 참조하기 때문입니다. 슬라이스는 내부적으로 기본이 되는 배열 구조를 가리킵니다.

슬라이스의 데이터 구조

슬라이스는 복합 데이터 타입으로, 구조체 (struct) 라고도 불립니다. 이는 서로 다른 타입의 필드들로 구성된 타입입니다. 슬라이스의 내부 구조는 포인터, 길이, 용량이라는 세 가지 요소로 이루어져 있습니다.

type slice struct {
    elem *type
    len  int
    cap  int
}

앞서 언급했듯이, 이 구조는 기본 배열을 참조합니다. elem 포인터는 배열의 첫 번째 요소를 가리키며, type은 참조되는 배열 요소의 타입입니다.

lencap은 각각 슬라이스의 길이와 용량을 나타냅니다. len()cap() 함수를 사용하여 슬라이스의 길이와 용량을 확인할 수 있습니다.

아래 이미지는 슬라이스가 int 타입의 기본 배열을 참조하며, 길이가 5 이고 용량이 5 인 상태를 보여줍니다.

slice referencing int array

새 슬라이스를 정의할 때 elem 포인터는 제로 값 (즉, nil) 으로 초기화됩니다. 포인터 개념은 이후 실습에서 자세히 다루겠지만, 지금은 포인터가 값의 메모리 주소를 가리킨다는 점만 기억하세요. 위 이미지에서 elem 포인터는 기본 배열의 첫 번째 요소 주소를 가리키고 있습니다.

슬라이스 연산: 추가, 삭제, 수정 및 검색

배열 또는 슬라이스 절단 (Truncating)

슬라이스의 내부 구조가 배열이므로, 배열의 특정 길이를 추출하여 슬라이스의 참조로 사용할 수 있습니다. 다음 코드 세그먼트가 이를 보여줍니다.

package main

import "fmt"

func main() {
    // 길이가 10 인 정수 배열 정의
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // 빈 슬라이스 선언
    var s1 []int

    fmt.Println("Slice s1 is empty:", s1 == nil)

    // 배열 절단을 사용하여 슬라이스 획득
    s1 = a[1:5]
    s2 := a[2:5]
    s3 := a[:]

    fmt.Println("Slice s1, s2, and s3 are empty:", s1 == nil, s2 == nil, s3 == nil)
    fmt.Println("Elements in array a:", a)
    fmt.Println("Elements in slice s1:", s1)
    fmt.Println("Elements in slice s2:", s2)
    fmt.Println("Elements in slice s3:", s3)
}

출력 결과는 다음과 같습니다.

Slice s1 is empty: true
Slice s1, s2, and s3 are empty: false false false
Elements in array a: [0 1 2 3 4 5 6 7 8 9]
Elements in slice s1: [1 2 3 4]
Elements in slice s2: [2 3 4]
Elements in slice s3: [0 1 2 3 4 5 6 7 8 9]

이 프로그램에서는 먼저 배열 a를 선언하고 초기화한 다음, 배열 절단을 사용하여 배열의 일부를 빈 슬라이스 s1에 할당했습니다. 이를 통해 새로운 슬라이스가 생성됩니다.

s1[1:5]는 배열로부터 슬라이스를 생성함을 의미합니다. 슬라이스의 범위는 배열 a의 인덱스 1 부터 인덱스 5 까지이며, 다섯 번째 요소 (인덱스 5) 는 포함되지 않습니다.

참고: 프로그래밍 언어에서 첫 번째 요소의 인덱스는 1 이 아니라 0 입니다. 따라서 배열의 두 번째 요소의 인덱스가 1 이 됩니다.

:= 연산자를 사용하여 절단된 배열을 슬라이스 s2에 직접 할당했습니다. s3도 마찬가지지만 범위를 지정하지 않았으므로 배열의 모든 요소를 가져옵니다.

아래 이미지는 이 절단 작업을 시각화한 것입니다. 슬라이스의 초록색 부분은 파란색 배열에 대한 참조를 나타냅니다. 즉, 두 구조 모두 동일한 기본 배열 a를 공유합니다.

slice truncation visualization

슬라이스 절단 문법은 다음과 같습니다.

[start:end]

startend는 모두 선택적 인자입니다. 배열의 모든 요소를 가져오려면 두 인자를 모두 생략할 수 있습니다. 앞선 프로그램의 s3 := a[:]가 그 예입니다.

특정 인덱스 이후의 모든 요소를 가져오려면 end 파라미터를 생략할 수 있습니다. 예를 들어 a1[3:]은 인덱스 3 부터 끝까지의 모든 요소를 가져옵니다.

특정 인덱스 이전의 모든 요소를 가져오려면 start 파라미터를 생략할 수 있습니다. 예를 들어 a1[:4]는 인덱스 0 부터 인덱스 4 이전까지의 모든 요소를 추출합니다.

배열뿐만 아니라 기존 슬라이스에서 새로운 슬라이스를 추출할 수도 있습니다. 작업 방식은 배열과 동일합니다. 다음은 간단한 예시입니다.

package main

import "fmt"

func main() {
    // 길이가 10 인 정수 배열 정의
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // 초기 슬라이스 s1 생성
    var s1 []int
    s1 = a[1:7]
    fmt.Printf("Slice s1: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    // 초기 슬라이스 s1 에서 새로운 슬라이스 s2 추출
    s2 := s1[2:4]
    fmt.Printf("Slice s1: %d\tLength: %d\tCapacity: %d\n", s2, len(s2), cap(s2))
}

출력 결과는 다음과 같습니다.

Slice s1: [1 2 3 4 5 6] Length: 6 Capacity: 9
Slice s1: [3 4] Length: 2 Capacity: 7

이 프로그램에서 배열 a를 절단하여 슬라이스 s1을 얻었습니다. s1의 범위는 인덱스 1 부터 7 까지입니다. 그리고 :=를 사용하여 s1에서 새로운 슬라이스 s2를 추출했습니다. s1의 절단 부위가 연속적이므로 새 슬라이스 s2도 연속적인 데이터를 갖게 됩니다.

슬라이스를 절단할 때 용량 (capacity) 이 변하는 것을 알 수 있습니다. 규칙은 다음과 같습니다.

용량이 c인 슬라이스를 절단할 때, s[i:j]의 길이는 j-i가 되고 용량은 c-i가 됩니다.

s1의 경우 기본 배열은 a이며, a[1:7]의 용량은 9(즉, 10-1) 입니다.

s2의 경우 기본 배열은 s1과 동일합니다. 이전 단계에서 절단된 부분의 용량이 9 였으므로, s1[2:4]의 용량은 7(즉, 9-2) 이 됩니다.

슬라이스 값 수정은 기본 배열 요소의 값에 동시에 영향을 미칩니다

슬라이스는 데이터를 직접 저장하지 않고 배열을 참조만 하기 때문에, 슬라이스의 값을 수정하면 참조하고 있는 기본 배열의 값도 함께 변경됩니다. 이를 확인해 보겠습니다.

package main

import "fmt"

func main() {
    // 길이가 10 인 정수 배열 정의
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[2:5]
    s2 := a[:]

    fmt.Println("Before Modification: ")
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)

    // 슬라이스 s1 의 인덱스 2 값을 23 으로 수정
    s1[2] = 23

    fmt.Println("After Modification: ")
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)
}

출력 결과는 다음과 같습니다.

Before Modification:
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
Elements in array a:  [2 3 4]
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
After Modification:
Elements in array a:  [0 1 2 3 23 5 6 7 8 9]
Elements in array a:  [2 3 23]
Elements in array a:  [0 1 2 3 23 5 6 7 8 9]

이 프로그램에서 슬라이스 s1s2는 모두 배열 a를 참조합니다. 슬라이스 s1이 자신의 인덱스 2 값을 23 으로 수정하면, 배열 a와 슬라이스 s2의 값도 업데이트됩니다.

배열의 인덱스 4 값이 23 으로 수정된 것을 볼 수 있습니다 (왜냐하면 s1 := a[2:5]에서 s1[0]a[2], s1[1]a[3], s1[2]a[4]를 가리키기 때문입니다). 결과적으로 배열 a와 슬라이스 s2 모두 인덱스 4 의 값이 변경되었습니다.

이러한 특성은 프로그램 개발 시 디버깅하기 어려운 버그를 유발할 수 있습니다. 따라서 일상적인 프로그래밍에서는 가급적 여러 슬라이스가 동일한 기본 배열을 참조하지 않도록 주의해야 합니다.

슬라이스에 요소 추가하기

이번 섹션에서는 슬라이스에 요소를 추가하는 데 사용되는 append 함수를 소개합니다. 문법은 다음과 같습니다.

func append(slice []Type, elems ...Type) []Type

첫 번째 인자는 대상 슬라이스 slice이고, 나머지 인자들은 슬라이스에 추가할 요소들입니다. 마지막의 []Typeappend 함수가 slice와 동일한 데이터 타입의 새로운 슬라이스를 반환함을 의미합니다.

... 뒤의 elems는 가변 파라미터 (variadic parameter) 를 나타내며, 하나 이상의 인자를 입력할 수 있음을 의미합니다.

다음은 append 함수 사용 예시입니다.

package main

import "fmt"

func main() {
    // 길이가 10 인 정수 배열 정의
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[1:7]
    fmt.Printf("Initial s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 12)
    fmt.Printf("Modified s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 14, 14)
    fmt.Printf("Modified s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))
}

출력 결과는 다음과 같습니다.

Initial s1 value: [1 2 3 4 5 6] Length: 6 Capacity: 9
Modified s1 value: [1 2 3 4 5 6 12] Length: 7 Capacity: 9
Modified s1 value: [1 2 3 4 5 6 12 14 14] Length: 9 Capacity: 9

이 프로그램에서는 먼저 배열 a를 절단하여 슬라이스 s1을 생성했습니다. s1의 범위는 인덱스 1 부터 7 까지입니다. append의 반환 값을 s1에 다시 할당하기 위해 := 연산자를 사용했습니다. append를 사용하여 요소를 추가할 때 요소의 수가 용량을 초과하면 슬라이스의 용량이 늘어납니다.

슬라이스 확장 규칙은 다음과 같습니다.

  • 슬라이스의 기본 배열이 새 요소를 수용할 수 있으면 슬라이스의 용량은 변하지 않습니다.
  • 기본 배열이 새 요소를 수용할 수 없으면 Go 는 더 큰 배열을 새로 만들고, 기존 슬라이스의 값을 새 배열로 복사한 뒤 추가된 값을 새 배열에 넣습니다.

참고: 슬라이스 확장이 항상 두 배로 늘어나는 것은 아닙니다. 요소의 크기, 확장되는 요소의 수, 컴퓨터 하드웨어 등 여러 요인에 따라 달라집니다. 자세한 내용은 슬라이스 심화 섹션을 참조하세요.

슬라이스에서 요소 삭제하기

Go 는 슬라이스에서 요소를 삭제하기 위한 별도의 키워드나 함수를 제공하지 않지만, 배열 절단을 사용하여 동일한 기능이나 더 강력한 기능을 구현할 수 있습니다.

다음 코드는 슬라이스 s에서 인덱스 5 의 요소를 삭제하고 이를 새 슬라이스 s1에 할당합니다.

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    // 삭제 전 슬라이스 s 출력
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // 인덱스 5 의 요소를 삭제한 후 슬라이스 s 와 s1 출력
    fmt.Printf("%d\n%d\n", s, s1)
}

출력 결과는 다음과 같습니다.

[0 1 2 3 4 5 6 7 8 9]
[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

특정 인덱스를 삭제하는 핵심은 append 문에 있습니다. 인덱스 6 이후의 요소들을 인덱스 5 이전의 요소들에 덧붙이는 방식입니다.

이 작업은 앞당겨 덮어쓰는 것과 같습니다. 예를 들어, 요소 5 는 요소 6 으로 덮어씌워지고, 요소 6 은 요소 7 로 덮어씌워지는 식으로 마지막 요소 9 까지 진행되어 새 슬라이스에 추가됩니다. 따라서 이 시점에서 기본 배열의 값은 [0 1 2 3 4 6 7 8 9 9]가 됩니다.

이때 s1의 길이와 용량을 확인하면 길이는 9 이지만 용량은 10 인 것을 볼 수 있습니다. 이는 s1s를 절단한 것에 대한 참조이기 때문입니다. 두 슬라이스는 동일한 기본 배열을 공유합니다.

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // s1 의 길이와 용량 확인
    fmt.Println(len(s1), cap(s1))
    fmt.Printf("\n%d\n%d\n\n", s, s1)

    // 슬라이스 s 수정
    s[3] = 22
    fmt.Printf("%d\n%d\n", s, s1)
}

출력 결과는 다음과 같습니다.

[0 1 2 3 4 5 6 7 8 9]
9 10

[0 1 2 3 4 6 7 8 9 9]
[0 1 2 3 4 6 7 8 9]

[0 1 2 22 4 6 7 8 9 9]
[0 1 2 22 4 6 7 8 9]

연습 문제

특정 위치의 요소를 삭제하는 것 외에도, 절단을 사용하여 슬라이스에서 특정 범위의 요소를 삭제할 수 있습니다. 작업 방식은 특정 인덱스를 삭제하는 것과 동일합니다. 간단한 연습 문제를 풀어봅시다.

slice1.go 파일을 생성하세요. 슬라이스 a를 생성하고 다음과 같이 초기화합니다. 그런 다음 절단을 사용하여 3 보다 크거나 7 보다 작은 요소를 포함하지 않는 또 다른 슬라이스 s를 만드세요. 마지막으로 새 슬라이스를 출력합니다.

a := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}

출력 예시:

[9 8 7 3 2 1 0]
  • 힌트: 슬라이스의 시작 인덱스에 주의하세요 (인덱스는 0 부터 시작함을 기억하세요).
  • 요구 사항: slice1.go 파일은 ~/project 디렉토리에 위치해야 합니다.
✨ 솔루션 확인 및 연습

슬라이스 확장

슬라이스에는 lencap이라는 두 가지 속성이 있습니다. len은 현재 슬라이스에 들어있는 요소의 수를 나타내고, cap은 슬라이스가 담을 수 있는 최대 요소 수를 나타냅니다.

슬라이스에 추가된 요소의 수가 용량을 초과하면 어떻게 될까요? 함께 알아봅시다.

package main

import "fmt"

func main() {
    s1 := make([]int, 3)
    s2 := make([]int, 3, 5)
    fmt.Println("Before Append in s1:", s1, "Length:", len(s1), "Capacity:", cap(s1))
    fmt.Println("Before Append in s2:", s2, "Length:", len(s2), "Capacity:", cap(s2))

    s1 = append(s1, 12)
    s2 = append(s2, 22)
    fmt.Println("After Append in s1:", s1, "Length:", len(s1), "Capacity:", cap(s1))
    fmt.Println("After Append in s2:", s2, "Length:", len(s2), "Capacity:", cap(s2))
}

프로그램을 실행하려면 다음 명령을 실행하세요.

go run slice.go

출력 결과는 다음과 같습니다.

Before Append in s1: [0 0 0] Length: 3 Capacity: 3
Before Append in s2: [0 0 0] Length: 3 Capacity: 5
After Append in s1: [0 0 0 12] Length: 4 Capacity: 6
After Append in s2: [0 0 0 22] Length: 4 Capacity: 5

이 프로그램에서 볼 수 있듯이, 슬라이스에 요소를 추가할 때 요소의 수가 원래 용량을 초과하면 슬라이스의 용량이 자동으로 증가합니다.

슬라이스 확장 규칙은 다음과 같습니다.

  • 슬라이스의 기본 배열이 새 요소를 수용할 수 있으면 용량은 변하지 않습니다.
  • 기본 배열이 새 요소를 수용할 수 없으면 Go 는 더 큰 배열을 새로 생성하고, 기존 슬라이스의 값을 새 배열로 복사한 뒤 추가된 값을 새 배열에 넣습니다.

참고: 슬라이스 용량이 항상 두 배로 늘어나는 것은 아닙니다. 요소의 크기, 요소의 개수, 컴퓨터 하드웨어 사양 등에 따라 달라집니다. 자세한 내용은 슬라이스 심화 섹션을 참조하세요.

슬라이스 복사하기

copy 함수를 사용하여 한 슬라이스를 다른 슬라이스로 복제할 수 있습니다. 문법은 다음과 같습니다.

func copy(dst, src []Type) int

dst는 대상 슬라이스, src는 소스 슬라이스이며, 반환되는 int 값은 복사된 요소의 개수를 나타냅니다. 이 값은 len(dst)len(src) 중 작은 값입니다.

참고: copy 함수는 요소를 추가 (append) 하지 않습니다.

다음은 예시입니다.

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3}
    s2 := []int{8, 9}

    s3 := []int{0, 1, 2, 3}
    s4 := []int{8, 9}

    // s1 을 s2 로 복사
    n1 := copy(s2, s1)
    // s4 를 s3 으로 복사
    n2 := copy(s3, s4)

    fmt.Println(n1, s1, s2)
    fmt.Println(n2, s3, s4)
}

프로그램을 실행하려면 다음 명령을 실행하세요.

go run slice.go

출력 결과는 다음과 같습니다.

2 [0 1 2 3] [0 1]
2 [8 9 2 3] [8 9]

이 프로그램에서는 슬라이스 s1의 값을 s2로, s4의 값을 s3으로 복사했습니다. copy 함수는 복사된 요소의 개수를 반환합니다.

s1s2, 그리고 s3s4의 값을 확인해 보세요. 첫 번째 copy 함수는 s1[0, 1, 2, 3]s2[8, 9]에 복사합니다. s1s2 중 최소 길이가 2 이므로 2 개의 값만 복사됩니다. 따라서 대상 슬라이스 s2[0, 1]로 수정됩니다.

두 번째 copy 함수는 s4[8, 9]s3[0, 1, 2, 3]에 복사합니다. 최소 길이가 2 이므로 2 개의 값이 복사됩니다. 결과적으로 s3[8, 9, 2, 3]으로 수정됩니다.

슬라이스 순회하기

슬라이스를 순회하는 방법은 배열을 순회하는 방법과 유사합니다. 배열에서 사용하는 모든 순회 방식을 슬라이스에도 적용할 수 있습니다.

연습 문제

이 연습 문제에서는 슬라이스와 배열의 순회에 대한 이해도를 테스트하고 강화합니다.

slice2.go 파일을 생성하세요. 배열 a1과 슬라이스 s1을 선언하고 다음과 같이 초기화합니다. 그런 다음 배열 a1과 슬라이스 s1의 요소를 반복하며 인덱스와 값을 출력하세요.

a1 := [5]int{1, 2, 3, 9, 7}
s1 := []int{1, 8, 12, 1, 3}

출력 예시:

Element a1 at index 0 is 1
Element a1 at index 1 is 2
Element a1 at index 2 is 3
Element a1 at index 3 is 9
Element a1 at index 4 is 7
Element s1 at index 0 is 1
Element s1 at index 1 is 8
Element s1 at index 2 is 12
Element s1 at index 3 is 1
Element s1 at index 4 is 3
  • 요구 사항: slice2.go 파일은 ~/project 디렉토리에 위치해야 합니다.
  • 힌트: 요소를 반복할 때 range 형식이나 인덱스 형식을 사용할 수 있습니다.
✨ 솔루션 확인 및 연습

요약

이 섹션에서는 슬라이스와 그 사용법에 대해 배웠습니다. 배열과 비교했을 때 슬라이스는 훨씬 더 유연하고 다재다능합니다. 여러 슬라이스를 다룰 때는 예상치 못한 연산 결과가 발생하지 않도록 주의가 필요합니다.