Go

Go의 Slice는 어떻게 동작하는가

Hwisaek 2025. 6. 14. 01:49
반응형

Slice는 Go에서 배열을 유연하게 사용할 수 있게 도와주는 자료구조이다. Slice 사용 중 마주하게 되는 문제들을 해결하기 위해 Slice의 동작 원리를 정리하고자 글을 작성한다.

 


 

Slice의 동작 원리 ⚙️

 

Slice 의 내부 구조

GOROOT의 아래 파일을 보면 slice의 내부 구조가 어떻게 되어있는지 짐작할 수 있다. Array를 가리키는 포인터와 길이, 용량을 나타내는 필드가 각각 존재하는 것을 알 수 있다. 이를 토대로 len, cap 이 동적으로 변함에 따라 원본 배열을 복사해 가면서 slice 가 동작한다는 것을 유추할 수 있다.

 

// src/runtime/slice.go
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

 

슬라이싱과 메모리 공유

슬라이싱(Slicing) 시 데이터가 새로 복사된다고 오해하기 쉽다. 그러나 슬라이싱은 새로운 데이터 복사본을 만드는 것이 아니라, 기존의 기반 배열을 가리키는 새로운 슬라이스 헤더를 생성할 뿐이다. 즉, 원본 슬라이스와 슬라이싱된 슬라이스는 같은 메모리 공간을 공유한다.

 

이것을 '뷰(View)'로 생각하면 이해가 쉽다.

original := []int{10, 20, 30, 40, 50}
sliceA := original[1:4] // [20, 30, 40]

// sliceA의 값을 변경하면
sliceA[0] = 99

// original에도 영향이 간다.
fmt.Println(original) // 출력: [10 99 30 40 50]

 

 

이러한 메모리 공유는 성능상 이점을 주지만, 의도치 않은 데이터 변경을 유발할 수 있으므로 항상 주의해야 한다.

 

 

append와 용량(Capacity) 확장

슬라이스의 동적인 크기 조절은 append 함수가 담당한다. append의 동작은 슬라이스의 용량(Capacity) 상태에 따라 두 가지 시나리오로 나뉜다.

  1. 용량이 충분할 때: 슬라이스의 용량이 남아있다면, append는 새로운 메모리를 할당하지 않는다. 단순히 배열의 빈 공간에 값을 추가하고, 슬라이스 길이만 1 증가시킨다. 이 경우 슬라이스가 가리키는 메모리 주소는 변하지 않는다.
  2. 용량이 부족할 때: 용량을 초과하여 요소를 추가하면, 새로운 배열을 할당하며 아래와 같은 과정을 거친다.
    • 기존보다 더 큰 새로운 배열 할당 (보통 2배)
    • 기존 배열의 모든 요소를 새 배열로 복사
    • 새로운 요소를 맨 뒤에 추가
    • 슬라이스 헤더의 포인터가 새로운 배열을 가리키도록 변경

용량이 부족한 경우 메모리를 2배 사용하려는 부분 때문에, 잦은 재할당은 성능 저하를 유발할 수 있다.

 


 

사용 시 주의사항⚠️

 

함수의 파라미터로 사용 시 주의할 점

Go의 함수는 모든 인자를 값에 의한 전달(Pass-by-value) 방식으로 넘긴다. 이는 슬라이스도 예외가 아니며, 슬라이스 헤더의 복사본이 함수에 전달된다.

이로 인해 혼란스러운 상황이 발생한다.

  • 배열 내 요소의 수정은 반영됨: 복사된 헤더는 원본과 같은 배열을 가리키므로, 함수 내에서 요소를 바꾸면 원본에도 반영된다.
  • append는 반영되지 않음: 함수 내에서 append로 인해 재할당이 발생하는 경우, 함수 내의 복사된 헤더만 새로운 배열을 가리키게 된다. 함수 외부의 원본 슬라이스는 여전히 이전 배열을 가리키고 있으므로 아무런 변화가 없다.
func failedAppend(s []int) {
    // s는 함수 내의 '복사본'이므로, 이 변경은 외부에 영향을 주지 못한다.
    s = append(s, 100) 
}

func main() {
    original := []int{1, 2, 3}
    failedAppend(original)
    fmt.Println(original) // 출력: [1 2 3]
}

 

해결책은 간단하다. 함수가 변경된 슬라이스를 반환하고, 호출한 쪽에서 그 결과를 다시 원본 변수에 할당하면 된다.

func correctAppend(s []int) []int {
    s = append(s, 100)
    return s
}

func main() {
    original := []int{1, 2, 3}
    original = correctAppend(original) // 반환된 슬라이스를 다시 할당
    fmt.Println(original) // 출력: [1 2 3 100]
}

 

메모리 누수 주의

 

아주 큰 배열의 일부만 슬라이싱하여 사용할 때 메모리 누수가 발생할 수 있다. 작은 슬라이스가 살아있는 동안, 거대한 원본 배열은 가비지 컬렉터(GC)에 의해 수거되지 않기 때문이다.

예를 들어 1GB 크기의 슬라이스에서 1KB만 잘라내 사용한다면, 이 1KB 때문에 1GB 가 메모리에 남아있게 된다.

이런 경우에는 copy 함수를 사용해 필요한 부분만 새로운 슬라이스로 복사하여, 기존의 큰 배열에 대한 참조를 끊어주는 것이 좋다.

// 큰 슬라이스에서 필요한 부분만 복사하여 참조를 끊는다.
func getNecessaryPart(bigSlice []byte) []byte {
    necessaryPart := make([]byte, 1024) // 필요한 만큼의 새 슬라이스 생성
    copy(necessaryPart, bigSlice)       // 내용 복사
    return necessaryPart
}

 

nil 슬라이스와 빈(Empty) 슬라이스

  • nil 슬라이스: var s []int 와 같이 선언만 된 슬라이스. 헤더의 포인터가 nil이며 길이와 용량 모두 0이다.
  • 빈 슬라이스: s := []int{} 또는 s := make([]int, 0)와 같이 초기화된 슬라이스. 길이와 용량은 0이지만, 포인터는 nil이 아닐 수 있다.

len(), cap(), append, for range 등 대부분의 연산에서 둘은 동일하게 동작한다.

 


 

slice 사용 모범 사례 ✅

 

위에서 다룬 동작 원리와 주의점을 바탕으로, 슬라이스를 사용 시 주의해야하는 부분은 아래 세 가지로 정리할 수 있다.

  1. append의 결과는 반드시 원본 변수에 다시 할당한다. slice = append(slice, ...) 재할당이 일어나 새로운 배열을 가리키게 될 가능성을 항상 염두에 두어야 한다.
  2. 성능을 위해 make로 용량을 미리 지정한다. 슬라이스에 담길 요소의 개수를 미리 안다면 make([]T, length, capacity)를 사용해 한 번에 메모리를 할당하는 것이 좋다. 불필요한 재할당과 데이터 복사를 막아 성능을 향상시킬 수 있다.
  3. 독립적인 슬라이스가 필요할 땐 copy를 사용한다. 메모리 공유로 인한 데이터 변경을 막거나, 메모리 누수 가능성을 차단하고 싶을 때 copy는 가장 확실한 해결책이다.

 


 

마무리

 

슬라이스는 Go의 강력한 기능이지만, 내부 동작을 정확히 이해하지 않으면 예상치 못한 버그를 마주하기 쉽다. 슬라이스는 데이터 자체가 아닌, 배열을 가리키는 헤더라는 점을 기억하는 것이 중요하다. 이 글에서 정리한 동작 원리와 모범 사례가 더 안정적이고 효율적인 Go 코드를 작성하는 데 도움이 되길 바란다.

 


 

참고

  • $GOROOT/src/runtime/slice.go
반응형

'Go' 카테고리의 다른 글

Go 의 map 은 어떻게 동작하는가  (0) 2025.06.21
Go 프로젝트 라이브러리 받기  (0) 2022.04.19
알고리즘 용 콘솔 입/출력 방법  (0) 2022.04.15