슬라이싱의 속임수: Go의 기본 배열 결합 해부
James Reed
Infrastructure Engineer · Leapcell

Go의 슬라이스는 배열 위에 구축된 강력하고 유연한 추상화입니다. 동적 크기 조정과 요소 시퀀스의 편리한 조작을 제공합니다. 그러나 슬라이스의 본질, 즉 기본 배열에 대한 가벼운 뷰라는 점이 미묘하고 좌절감을 주는 버그의 일반적인 원인이 될 수 있습니다. 이 글에서는 "슬라이싱의 속임수"라는 개념을 탐구합니다. 이는 겉보기에는 독립적인 슬라이스 작업이 공유 기본 배열 때문에 예상치 않게 상호 작용하여 사일런트 데이터 손상 및 예상치 못한 동작으로 이어지는 일반적인 함정입니다.
Go 슬라이스: 기본 사항 및 내부 살펴보기
함정에 빠지기 전에 Go 슬라이스가 어떻게 작동하는지 간략하게 요약해 보겠습니다. 슬라이스는 요소를 직접 보유하는 데이터 구조가 아닙니다. 대신, 기본 배열의 연속 세그먼트를 참조하는 설명자입니다. 이 설명자는 세 가지 구성 요소로 이루어집니다.
- 포인터(ptr): 기본 배열의 세그먼트 첫 번째 요소를 가리킵니다.
- 길이(len): 슬라이스 내에서 현재 액세스 가능한 요소 수입니다.
- 용량(cap): 포인터 위치에서 기본 배열 끝까지의 요소 수입니다.
다음 Go 스니펫을 고려하십시오.
package main import "fmt" func main() { originalArray := [5]int{10, 20, 30, 40, 50} fmt.Printf("Original Array: %v\n", originalArray) // 배열에서 슬라이스 생성 s1 := originalArray[1:4] // s1은 20, 30, 40 요소를 가리킵니다 fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1)) // 같은 배열에서 다른 슬라이스 생성 s2 := originalArray[0:3] // s2는 10, 20, 30 요소를 가리킵니다 fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2)) }
출력:
Original Array: [10 20 30 40 50]
s1: [20 30 40], len: 3, cap: 4
s2: [10 20 30], len: 3, cap: 5
여기서 s1
과 s2
는 별개의 슬라이스이지만 둘 다 originalArray
의 일부를 가리킵니다. s1
은 originalArray
의 인덱스 1에서 시작하며 originalArray
끝까지 확장되는 용량을 가집니다. s2
는 인덱스 0에서 시작하며 용량도 끝까지 확장됩니다. 이 공유 기본 메모리가 "슬라이싱의 속임수"의 근본 원인입니다.
슬라이싱의 속임수: 공유 메모리가 문제를 일으킬 때
문제는 한 슬라이스의 수정이 다른 슬라이스에 의도치 않게 영향을 미칠 때 발생합니다. 단지 기본 배열 메모리를 공유하기 때문입니다. 이는 예상치 못한 데이터 변경, 경쟁 조건(동시 시나리오에서), 디버깅하기 어려운 논리 오류로 이어질 수 있습니다.
예시를 통해 이를 설명해 보겠습니다.
package main import "fmt" func main() { scores := []int{100, 95, 80, 75, 90, 85} fmt.Printf("Original Scores: %v\n", scores) // 슬라이스 1: 통과한 학생 (점수 >= 80) passingScores := scores[2:6] // [80, 75, 90, 85] fmt.Printf("Passing Scores (initial): %v, len: %d, cap: %d\n", passingScores, len(passingScores), cap(passingScores)) // 슬라이스 2: 상위 3명의 학생 (최고 점수) top3Scores := scores[0:3] // [100, 95, 80] fmt.Printf("Top 3 Scores (initial): %v, len: %d, cap: %d\n", top3Scores, len(top3Scores), cap(top3Scores)) fmt.Println("\n--- Modifying top3Scores ---") // top3Scores의 학생 점수가 업데이트됩니다 top3Scores[2] = 88 // scores의 인덱스 2의 요소(80)를 수정합니다 fmt.Printf("Top 3 Scores (modified): %v\n", top3Scores) fmt.Printf("Original Scores (after top3Scores modification): %v\n", scores) // passingScores는 어떻게 보일까요? // it pointed to scores[2:6] i.e., [80, 75, 90, 85] // originally passingScores의 인덱스 0에 있던 요소(80)가 이제 88입니다 fmt.Printf("Passing Scores (after top3Scores modification): %v\n", passingScores) fmt.Println("\n--- Modifying passingScores ---") // passingScores의 점수가 업데이트됩니다 passingScores[0] = 70 // THIS IS THE SAME ELEMENT AS top3Scores[2]! fmt.Printf("Passing Scores (modified): %v\n", passingScores) fmt.Printf("Original Scores (after passingScores modification): %v\n", scores) fmt.Printf("Top 3 Scores (after passingScores modification): %v\n", top3Scores) }
출력:
Original Scores: [100 95 80 75 90 85]
Passing Scores (initial): [80 75 90 85], len: 4, cap: 4
Top 3 Scores (initial): [100 95 80], len: 3, cap: 6
--- Modifying top3Scores ---
Top 3 Scores (modified): [100 95 88]
Original Scores (after top3Scores modification): [100 95 88 75 90 85]
Passing Scores (after top3Scores modification): [88 75 90 85]
--- Modifying passingScores ---
Passing Scores (modified): [70 75 90 85]
Original Scores (after passingScores modification): [100 95 70 75 90 85]
Top 3 Scores (after passingScores modification): [100 95 70]
보시다시피 top3Scores[2]
를 수정하면 passingScores[0]
도 자동으로 변경됩니다. 이는 둘 다 scores
배열의 동일한 메모리 위치(특히 scores[2]
)를 참조하기 때문입니다. Go에서 슬라이스가 작동하는 방식의 근본적인 이 동작은 배열과 같은 구조에 대해 복사 의미론이 다른 언어에서 온 개발자에게 매우 직관적이지 않을 수 있습니다.
append
함수 및 용량 확장
append
함수는 복잡성의 또 다른 계층을 도입합니다. 슬라이스에 append
하고 기본 배열에 충분한 용량이 있으면 append
작업이 기존 기본 배열에서 직접 수행되어 해당 배열을 공유하는 다른 슬라이스에 영향을 미칠 수 있습니다.
package main import "fmt" func main() { data := []int{1, 2, 3, 4, 5} fmt.Printf("Original Data: %v, len: %d, cap: %d\n", data, len(data), cap(data)) sliceA := data[0:3] // [1, 2, 3] fmt.Printf("Slice A: %v, len: %d, cap: %d\n", sliceA, len(sliceA), cap(sliceA)) sliceB := data[2:5] // [3, 4, 5] fmt.Printf("Slice B: %v, len: %d, cap: %d\n", sliceB, len(sliceB), cap(sliceB)) fmt.Println("\n--- Appending to Slice A within capacity ---") sliceA = append(sliceA, 6) // 3 뒤의 위치에 있는 기본 배열에 6을 추가합니다 fmt.Printf("Slice A (after append): %v, len: %d, cap: %d\n", sliceA, len(sliceA), cap(sliceA)) fmt.Printf("Original Data (after Slice A append): %v\n", data) // data가 수정됩니다! fmt.Printf("Slice B (after Slice A append): %v\n", sliceB) // sliceB도 영향을 받습니다! }
출력:
Original Data: [1 2 3 4 5], len: 5, cap: 5
Slice A: [1 2 3], len: 3, cap: 5
Slice B: [3 4 5], len: 3, cap: 3
--- Appending to Slice A within capacity ---
Slice A (after append): [1 2 3 6], len: 4, cap: 5
Original Data (after Slice A append): [1 2 3 6 5]
Slice B (after Slice A append): [6 5] // 예상치 못한 변경! 원래 '3'이 이제 '6'이 되었습니다.
여기서 sliceA
에 6
이 추가되었습니다. 기본 배열 data
에 충분한 용량이 있었기 때문에 6
은 data[3]
에 직접 쓰여졌습니다. 이렇게 하면 data
가 [1 2 3 6 5]
로 변경됩니다. 결과적으로 data[2]
에서 시작하는 sliceB
는 이제 [3 6 5]
를 보게 됩니다. 이는 sliceA
가 독립적으로 증가하는 것처럼 보이지만 sliceB
가 보고 있는 데이터를 변경하기 때문에 상당한 혼란의 원인이 될 수 있습니다.
그러나 append
가 슬라이스가 현재 용량을 초과하게 하면 Go는 새롭고 더 큰 기본 배열을 할당하고 기존 요소를 새 배열로 복사한 다음 새 요소를 추가합니다. 이 시나리오에서는 새로 append
된 슬라이스가 더 이상 원래 기본 배열을 공유하지 않아 링크가 효과적으로 끊어지고 향후 수정이 원래 데이터 또는 파생된 다른 슬라이스에 영향을 미치는 것을 방지합니다.
package main import "fmt" func main() { initialData := []int{10, 20} fmt.Printf("Initial Data: %v, len: %d, cap: %d\n", initialData, len(initialData), cap(initialData)) // initialData에서 슬라이스 생성 s := initialData[0:1] // [10] fmt.Printf("s (initial): %v, len: %d, cap: %d\n", s, len(s), cap(s)) fmt.Println("\n--- Appending to 's' beyond capacity ---") s = append(s, 30, 40, 50) // 요소를 추가하고 새 기본 배열이 필요합니다 fmt.Printf("s (after append): %v, len: %d, cap: %d\n", s, len(s), cap(s)) fmt.Printf("Initial Data (after 's' append): %v\n", initialData) // InitialData는 수정되지 않습니다! }
출력:
Initial Data: [10 20], len: 2, cap: 2
s (initial): [10], len: 1, cap: 2
--- Appending to 's' beyond capacity ---
s (after append): [10 30 40 50], len: 4, cap: 4
Initial Data (after 's' append): [10 20]
이 경우 s
는 새 요소를 얻었지만 initialData
는 변경되지 않았습니다. 이는 append
가 s
에 대해 새롭고 더 큰 배열을 할당해야 했기 때문에 initialData
에서 효과적으로 "분리"되었기 때문입니다. 이 용량 유발 분리가 분리되는 것을 이해하는 것이 중요합니다.
슬라이싱의 속임수 완화 전략
공유 기본 배열이 Go 슬라이스의 기본적인 측면인 반면, 원치 않는 부작용을 피하기 위한 명확한 전략이 있습니다.
1. 필요할 때 명시적으로 복사 만들기
슬라이스 작업이 다른 슬라이스에 영향을 미치지 않도록 보장하는 가장 간단하고 강력한 방법은 데이터의 새롭고 독립적인 복사본을 만드는 것입니다. 이는 새 기본 배열을 할당하고 요소를 복사하는 것을 의미합니다.
이는 copy
내장 함수 또는 append
와 nil
또는 빈 슬라이스의 조합을 사용한 전체 슬라이스 식을 통해 수행할 수 있습니다.
copy()
사용:
package main import "fmt" func main() { original := []int{1, 2, 3, 4, 5} fmt.Printf("Original: %v\n", original) // 슬라이스 복사본 생성 safeSlice := make([]int, len(original)) copy(safeSlice, original) // original에서 safeSlice로 요소 복사 fmt.Printf("Safe Slice (copy): %v\n", safeSlice) fmt.Println("\n--- Modifying Safe Slice ---") safeSlice[0] = 99 fmt.Printf("Safe Slice (modified): %v\n", safeSlice) fmt.Printf("Original (after safeSlice modification): %v\n", original) // Original은 변경되지 않습니다! }
출력:
Original: [1 2 3 4 5]
Safe Slice (copy): [1 2 3 4 5]
--- Modifying Safe Slice ---
Safe Slice (modified): [99 2 3 4 5]
Original (after safeSlice modification): [1 2 3 4 5]
추가 트릭 사용(전체 복사용):
package main import "fmt" func main() { original := []int{1, 2, 3, 4, 5} fmt.Printf("Original: %v\n", original) // 새 기본 배열과 모든 요소의 복사본이 있는 새 슬라이스 생성 // 전체 복사를 원할 때 make와 copy의 바로 가기입니다. safeSliceAppend := append([]int(nil), original...) fmt.Printf("Safe Slice (append copy): %v\n", safeSliceAppend) fmt.Println("\n--- Modifying Safe Slice Append ---") safeSliceAppend[0] = 100 fmt.Printf("Safe Slice Append (modified): %v\n", safeSliceAppend) fmt.Printf("Original (after safeSliceAppend modification): %v\n", original) // Original은 변경되지 않습니다! }
이 append([]int(nil), original...)
패턴은 항상 새 할당 및 복사를 트리하여 완전하고 독립적인 복사본을 생성하기 때문에 Go에서 매우 일반적입니다.
2. 함수 매개변수 주의: 복사본 전달 또는 새 슬라이스 반환
슬라이스를 함수에 전달할 때 Go는 값을 전달한다는 것을 기억하십시오. 그러나 슬라이스의 값은 ptr
, len
, cap
설명자입니다. 기본 배열 자체를 복사하지는 않습니다. 이는 함수가 전달된 슬라이스의 요소를 수정하면 기본 배열을 수정하는 것이며, 이는 호출 범위의 원래 슬라이스에 영향을 미친다는 것을 의미합니다.
package main import "fmt" func processScores(s []int) { if len(s) > 0 { s[0] = 0 // 이것은 기본 배열의 요소를 수정합니다 } s = append(s, 100) // 용량이 사용 가능하면 기본 배열을 수정합니다. // 새 배열이 할당되면 's'는 새 배열을 가리키지만, // 호출자의 원래 슬라이스는 변경되지 않습니다(첫 번째 요소를 제외하고). fmt.Printf("Inside function (after modification): %v, len: %d, cap: %d\n", s, len(s), cap(s)) } func processScoresSafe(s []int) []int { // 원래 슬라이스에 대한 부작용을 피하기 위해 복사본 생성 safeCopy := make([]int, len(s)) copy(safeCopy, s) if len(safeCopy) > 0 { safeCopy[0] = 0 } safeCopy = append(safeCopy, 100) // 필요한 경우 새 기본 배열에 추가합니다. fmt.Printf("Inside function (safe copy, after modification): %v, len: %d, cap: %d\n", safeCopy, len(safeCopy), cap(safeCopy)) return safeCopy // 새롭고 수정된 슬라이스 반환 } func main() { data := []int{10, 20, 30} fmt.Printf("Before function call: %v\n", data) processScores(data) fmt.Printf("After processScores call: %v\n", data) // data가 수정됩니다! dataCopy := []int{10, 20, 30} // 다음 예시를 위해 리셋 fmt.Printf("\nBefore safe function call: %v\n", dataCopy) modifiedData := processScoresSafe(dataCopy) fmt.Printf("After processScoresSafe call: %v\n", dataCopy) // dataCopy는 수정되지 않습니다! fmt.Printf("Returned new slice: %v\n", modifiedData) }
출력:
Before function call: [10 20 30]
Inside function (after modification): [0 20 30 100], len: 4, cap: 6
After processScores call: [0 20 30 100]
Before safe function call: [10 20 30]
Inside function (safe copy, after modification): [0 20 30 100], len: 4, cap: 6
After processScoresSafe call: [10 20 30]
Returned new slice: [0 20 30 100]
processScores
함수는 data
와 같은 기본 배열을 참조하는 함수 내의 s
때문에 data
를 직접 수정합니다. 대조적으로, processScoresSafe
는 먼저 복사본을 만들고 해당 복사본을 수정한 다음 새 슬라이스를 반환하여 원래 dataCopy
를 그대로 둡니다. 이 구분은 데이터 무결성을 유지하는 데 가장 중요합니다.
3. 슬라이스 용량 및 append
동작 이해
항상 슬라이스의 용량과 append
가 사용되는 방식에 유의하십시오. 하위 슬라이스로 작업하고 원래 기본 배열(또는 이를 공유하는 다른 슬라이스)에 영향을 주지 않고 해당 슬라이스에 append
하려는 경우 append
작업이 새 할당을 강제로 실행하도록 해야 합니다. 이는 일반적으로 용량과 같거나 최대치인 슬라이스로 시작하거나 명시적으로 미리 복사하는 것을 의미합니다.
예를 들어, s := arr[low:high]
이고 arr
이나 arr
에서 파생된 다른 슬라이스를 위험하지 않고 s
에 append
하려는 경우 다음을 수행해야 합니다.
sCopy := make([]Type, len(s)) copy(sCopy, s) sCopy = append(sCopy, newElements...) // sCopy는 이제 자체 기본 배열을 가집니다
또는 하위 슬라이스에 직접 추가 트릭을 사용하면 다음과 같습니다.
sIndependent := append([]Type(nil), arr[low:high]...) // sIndependent는 이제 arr[low:high]의 완전하고 분리된 복사본입니다.
이러한 복사본을 만든 후에야 arr
또는 s
를 수정하는 것을 두려워하지 않고 sIndependent
를 자유롭게 수정하고 추가할 수 있습니다.
결론
Go의 슬라이스는 강력한 추상화이지만, 효율성은 기본 배열 공유라는 특성에서 비롯됩니다. 이러한 설계는 성능이 뛰어나지만, 겉보기에는 독립적인 슬라이스 작업이 공유 메모리로 인해 다른 슬라이스(또는 원래 배열)에 예상치 못한 부작용을 일으키는 "슬라이싱의 속임수"로 이어질 수 있습니다.
슬라이스의 ptr
, len
, cap
구성 요소를 이해하고 make
+ copy
또는 append([]T(nil), original...)
관용구와 같은 명시적인 복사 메커니즘을 사용하여 개발자는 이러한 문제를 효과적으로 완화할 수 있습니다. 함수에 전달된 슬라이스를 잠재적으로 수정 가능한 뷰로 취급하고 수정이 격리되어야 할 때 새 슬라이스를 반환하는 것은 강력한 Go 프로그래밍을 위한 모범 사례입니다. Go의 이 측면을 마스터하는 것은 신뢰할 수 있고 유지 관리 가능한 애플리케이션을 작성하는 데 중요합니다.