ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Go 언어 슬라이스 완벽 이해 - 구현과 활용
    Go 2024. 5. 19. 14:51

    슬라이스의 실체

    Go의 런타임 코드를 보면, Go의 슬라이스는 다음과 같이 정의되어 있습니다.

    type slice struct {
        array unsafe.Pointer
        len   int
        cap   int
    }

     

    reflect 패키지의 SliceHeader를 봐도 다음과 같이 정의되어 있습니다.

    type SliceHeader struct {
        Data uintptr
        Len  int
        Cap  int
    }

     

    즉, Go의 슬라이스는 다음 그림처럼 배열에 대한 포인터와 길이, 그리고 용량을 가진 값으로 표현됩니다.

    슬라이스 구조

    런타임과 reflect 패키지에서 포인터를 unsafe.Pointeruintptr로 표현하는 방법은 다르지만, 둘 다 포인터를 나타내는 값입니다.

     

    unsafe.Pointer는 임의의 타입 포인터와 상호 변환 가능한 타입입니다.

     

    반면, uintptr는 정수형으로 unsafe.Pointer로 변환이 가능한 타입입니다.

     

    uintptr는 정수형의 하나이므로, int형 등과 같이 사칙연산이 가능합니다.

     

    다음과 같이, []int 타입을 reflect.SliceHeader 타입으로 해석해 보겠습니다.

     

    먼저, []int 타입의 변수인 ns의 포인터를 가져와 unsafe.Pointer 타입으로 변환합니다.

     

    변환된 값은 ptr이라는 변수에 저장합니다.

     

    그 다음, ptr*reflect.SliceHeader로 캐스트하여, 그 포인터가 가리키는 값을 reflect.SliceHeader 타입의 변수 s에 대입합니다.

    package main
    
    import (
        "fmt"
        "reflect"
        "unsafe"
    )
    
    func main() {
        ns := []int{10, 20, 30}
        // ns를 unsafe.Pointer로 변환
        ptr := unsafe.Pointer(&ns)
        // ptr을 *reflect.SliceHeader로 캐스트하고, 그 값을 s에 대입
        s := *(*reflect.SliceHeader)(ptr)
        fmt.Printf("%#v\n", s)
    }

    요소에 접근하기

    reflect.SliceHeader를 사용하여 i번째 슬라이스 요소에 접근하는 방법을 생각해 보겠습니다.

     

    reflect.SliceHeader는 슬라이스가 참조하는 배열에 대한 포인터를 가지고 있습니다.

     

    그 포인터는 슬라이스의 0번째 요소를 가리키는 포인터입니다.

     

    따라서, 다음의 at 함수처럼, 포인터의 시작 위치에서 요소 i개의 포인터를 진행한 위치가 i번째 요소의 포인터가 됩니다.

     

    요소의 타입에 따라 1요소당 얼마나 포인터를 진행해야 하는지는 다르므로, 다음 예제에서는 int 타입의 경우로 한정합니다.

     

    임의의 타입 크기를 얻으려면 unsafe.Sizeof를 사용합니다.

    package main
    
    import (
        "fmt"
        "reflect"
        "unsafe"
    )
    
    func at(s reflect.SliceHeader, i int) unsafe.Pointer {
        // 시작 포인터 + 인덱스 * int 타입의 크기
        return unsafe.Pointer(s.Data + uintptr(i)*unsafe.Sizeof(int(0)))
    }
    
    func main() {
        a := [...]int{10, 20, 30}
        s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
        *(*int)(at(s, 0)) = 100 // unsafe.Pointer를 *int로 변환하여 대입
        fmt.Println(a)
    }

    요소 추가하기

    여기서 append 함수와 같은 기능을 구현해보겠습니다.

     

    append는 추가할 때, 슬라이스가 참조하는 배열의 용량이 충분한 경우와 그렇지 않은 경우의 동작이 다릅니다.

    용량이 충분한 경우

    용량이 충분한 경우에는 다음의 절차로 요소를 추가합니다.

    1. 새로운 요소를 복사합니다.
    2. 길이를 업데이트합니다.
    package main
    
    import (
        "fmt"
        "reflect"
        "unsafe"
    )
    
    func at(s reflect.SliceHeader, i int) unsafe.Pointer {
        // 시작 포인터 + 인덱스 * int 타입의 크기
        return unsafe.Pointer(s.Data + uintptr(i)*unsafe.Sizeof(int(0)))
    }
    
    func myAppend(s reflect.SliceHeader, vs ...int) reflect.SliceHeader {
        // 새로운 요소 추가
        for i := 0; i < len(vs); i++ {
            *((*int)(at(s, s.Len+i))) = vs[i]
        }
        return reflect.SliceHeader{Data: s.Data, Len: s.Len + len(vs), Cap: s.Cap}
    }
    
    func main() {
        a := [...]int{10, 20, 30}
        // s := a[0:2] -> [10 20]
        s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
        s = myAppend(s, 400)
    
        var ns []int
        *(*reflect.SliceHeader)(unsafe.Pointer(&ns)) = s
        fmt.Println(ns)
    }

    용량이 부족한 경우

    용량이 부족한 경우에는 다음과 같이 배열을 재할당해야 합니다.

    1. 원래 용량의 약 2배를 확보합니다.
    2. 배열에 대한 포인터를 다시 설정합니다.
    3. 원래 배열에서 요소를 복사합니다.
    4. 새로운 요소를 복사합니다.
    5. 길이와 용량을 업데이트합니다.

    이 절차로 슬라이스의 크기를 확장하는 함수 growslice를 구현하면 다음과 같습니다.

     

    그리고 myAppend 내에서 용량이 부족한 경우 growslice를 호출하여 슬라이스를 확장할 수 있습니다.

    package main
    
    import (
        "fmt"
        "reflect"
        "unsafe"
    )
    
    func at(s reflect.SliceHeader, i int) unsafe.Pointer {
        // 시작 포인터 + 인덱스 * int 타입의 크기
        return unsafe.Pointer(s.Data + uintptr(i)*unsafe.Sizeof(int(0)))
    }
    
    func myAppend(s reflect.SliceHeader, vs ...int) reflect.SliceHeader {
        // 용량이 부족한 경우
        if s.Len+len(vs) > s.Cap {
            s = growslice(s, s.Len+len(vs))
        }
    
        // 새로운 요소 추가
        for i := 0; i < len(vs); i++ {
            *((*int)(at(s, s.Len+i))) = vs[i]
        }
        return reflect.SliceHeader{Data: s.Data, Len: s.Len + len(vs), Cap: s.Cap}
    }
    
    func growslice(old reflect.SliceHeader, cap int) reflect.SliceHeader {
        newcap := cap
        doublecap := old.Cap + old.Cap
        if cap < doublecap {
            newcap = doublecap
        }
    
        s := make([]int, old.Len, newcap)
        newslice := *(*reflect.SliceHeader)(unsafe.Pointer(&s))
    
        // 기존 슬라이스에서 요소 복사
        for i := 0; i < old.Len; i++ {
            *((*int)(at(newslice, i))) = *((*int)(at(old, i)))
        }
    
        return newslice
    }
    
    func main() {
        a := [...]int{10, 20, 30}
        // s := a[0:2] -> [10 20]
        s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
        s = myAppend(s, 400, 500) // 용량 초과
    
        var ns []int
        *(*reflect.SliceHeader)(unsafe.Pointer(&ns)) = s
        fmt.Println(ns)
    }

     

    여기서 주의할 점은 myAppend의 반환값이 reflect.SliceHeader라는 점입니다.

     

    요소가 추가되면 길이가 업데이트되므로 반환값으로 돌려줘야 합니다.

     

    이는 reflect.SliceHeader가 구조체이기 때문에, 인수로 받은 값의 필드를 아무리 변경해도 호출한 쪽에 영향을 주지 않기 때문입니다.

     

    예를 들어, 다음 예제에서 s2.Len을 변경해도 s1.Len에는 영향을 미치지 않습니다.

    func main() {
        a := [...]int{10, 20, 30}
        s1 := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
        s2 := s1
        s2.Len = 3 // s1에는 영향 없음
    
        fmt.Println(s1) // {4310908 2 3}
        fmt.Println(s2) // {4310908 3 3}
    }

    마무리

    이 글에서는 슬라이스가 어떻게 표현되는지 조사하고, 슬라이스에 관련된 처리를 실제로 구현해보면서 슬라이스를 이해해보았습니다.

     

    여기서 다룬 unsafe.Pointer는 잘못 다루면 매우 위험할 수 있으니 주의하시기 바랍니다.

     

    예를 들어, 다음과 같이 잘못 사용하면 문제가 발생했습니다.

    func main() {
        v := struct {
            a [5]int
            b [2]int
        }{
            a: [...]int{10, 20, 30, 40, 50},
            b: [...]int{100, 200},
        }
    
        // cap이 len(a)보다 큽니다
        s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&v.a[1])), Len: 2, Cap: 6}
        var ns []int
        *(*reflect.SliceHeader)(unsafe.Pointer(&ns)) = s
    
        // a를 넘어 append
        ns = append(ns, 60, 70, 80)
        fmt.Println(ns, v.a, v.b)
    }

     

    이 코드는 ns 슬라이스가 v.a 배열의 경계를 넘어 v.b 배열의 값을 덮어쓰는 문제를 일으킬 수 있습니다.

     

    unsafe 패키지를 사용할 때는 이러한 점을 주의해야 합니다.

    마무리

    이 글에서는 Go 언어의 슬라이스가 어떻게 내부적으로 구현되는지 살펴보았습니다.

     

    슬라이스는 배열을 기반으로 하며, 포인터, 길이, 용량을 통해 관리됩니다.

     

    또한, reflect 패키지와 unsafe 패키지를 사용하여 슬라이스의 내부 구조를 직접 확인하고, 슬라이스의 요소를 추가하는 과정을 구현해보았습니다.

     

    슬라이스는 매우 강력한 기능을 제공하지만, unsafe 패키지를 사용할 때는 항상 신중해야 합니다.

     

    잘못된 포인터 연산이나 배열 경계를 넘는 접근은 예기치 않은 버그를 일으킬 수 있습니다.

     

    따라서, unsafe 패키지는 필요한 경우에만 사용하고, 사용할 때는 충분한 주의를 기울여야 합니다.

     

    위의 내용을 잘 이해하고 나면, Go 언어의 슬라이스를 더 효과적으로 사용할 수 있을 것입니다.

     

    슬라이스를 잘 활용하면 더 간결하고 효율적인 코드를 작성할 수 있습니다.

     

    이번 기회에 슬라이스의 내부 구조와 동작 원리를 깊이 있게 이해하고, 이를 바탕으로 Go 언어를 더 잘 활용해 보세요.

Designed by Tistory.