Go의 고정 길이 시퀀스: 배열 마스터링
Wenhao Wang
Dev Intern · Leapcell

컴퓨터 과학의 영역에서 데이터 구조는 기본적인 구성 요소입니다. 이 중 시퀀스(요소의 정렬된 컬렉션)라는 개념은 매우 중요합니다. 이 시퀀스의 요소 개수가 미리 결정되어 변경할 수 없을 때, 이를 고정 길이 시퀀스라고 합니다. Go에서 이 특정 유형의 시퀀스는 배열로 구현됩니다.
Go의 slice
타입이 동적인 특성과 일반적인 사용으로 인해 자주 주목받지만, array
를 이해하는 것은 중요합니다. 배열은 슬라이스가 구축되는 기반 구조이며, 특정 시나리오에 적합한 고유한 특성을 제공합니다. 이 글에서는 Go 배열의 특성을 자세히 살펴보고, 정의, 동작 방식, 실제 적용 사례를 탐구하며, 더 유연한 대응 요소인 슬라이스와 명확히 구별할 것입니다.
Go에서 배열이란?
Go에서 배열은 동일한 타입의 0개 이상의 요소를 메모리에 연속적으로 저장하는 고정 길이 시퀀스입니다. "고정 길이"라는 측면이 정의하는 특징입니다. 배열이 특정 크기로 선언된 후에는 해당 크기를 변경할 수 없습니다.
이 정의를 자세히 살펴보겠습니다.
- 고정 길이: 요소 개수는 배열 타입의 일부입니다. 예를 들어,
[5]int
는[10]int
와 다른 타입입니다. 이 크기의 불변성은 슬라이스와의 주요 차이점입니다. - 시퀀스: 요소는 정렬되어 있으며, 이는 정의된 위치(인덱스)를 갖는다는 것을 의미합니다. 첫 번째 요소는 인덱스 0, 두 번째는 인덱스 1에 있으며,
length - 1
까지 계속됩니다. - 동일한 타입: 배열 내의 모든 요소는 동일한 데이터 타입이어야 합니다(예: 모두
int
, 모두string
, 모두float64
, 또는 사용자 정의 구조체의 모든 인스턴스). - 메모리 연속성: 배열 요소는 메모리에 순차적으로 저장됩니다. 이 연속 할당을 통해 인덱스를 통해 요소에 효율적으로 액세스할 수 있으며, 메모리 주소를 직접 계산할 수 있습니다.
배열 선언 및 초기화
배열은 길이와 요소 타입을 지정하여 선언합니다. 몇 가지 예시를 살펴보겠습니다.
package main import "fmt" func main() { // 5개의 정수 배열, 0 값으로 초기화 (int의 경우 0) var a [5]int fmt.Println("Declared array 'a':", a) // 출력: [0 0 0 0 0] // 3개의 문자열 배열, 초기값 포함 var b [3]string = [3]string{"apple", "banana", "cherry"} fmt.Println("Declared array 'b':", b) // 출력: [apple banana cherry] // 배열에 대한 축약 선언 및 초기화 c := [4]float64{1.1, 2.2, 3.3, 4.4} fmt.Println("Declared array 'c':", c) // 출력: [1.1 2.2 3.3 4.4] // "..."를 사용하여 컴파일러가 요소 개수를 세도록 함 d := [...]bool{true, false, true} // 길이 3으로 추론됨 fmt.Println("Declared array 'd':", d) // 출력: [true false true] fmt.Printf("Type of 'd': %T\n", d) // 출력: Type of 'd': [3]bool // 요소 액세스 fmt.Println("First element of 'b':", b[0]) // 출력: apple fmt.Println("Last element of 'c':", c[len(c)-1]) // 출력: 4.4 // 요소 수정 a[0] = 10 a[4] = 50 fmt.Println("Modified array 'a':", a) // 출력: [10 0 0 0 50] }
명시적으로 배열을 초기화하지 않으면, 해당 요소들은 각자의 0 값(예: 숫자 타입의 경우 0
, 문자열의 경우 ""
, 불리언의 경우 false
, 포인터의 경우 nil
)으로 설정됩니다.
배열 대 슬라이스: 결정적인 차이
이것이 Go를 처음 접하는 사람들이 많이 혼란스러워하는 부분입니다. 배열과 슬라이스 모두 요소의 시퀀스를 나타내지만, 근본적인 차이는 길이와 내부 메커니즘에 있습니다.
특징 | 배열 ([N]T ) | 슬라이스 ([]T ) |
---|---|---|
길이 | 선언 시 고정 (타입의 일부) | 동적, 성장 또는 축소 가능 ( append 사용) |
타입 | [N]T (예: [5]int ) | []T (예: []int ) |
값 의미론 | 값 타입: 할당 시 전체 배열 복사가 일어남. | 참조 타입: 할당 시 슬라이스 헤더 복사 (동일한 하위 배열 세그먼트 가리킴). |
함수에 전달 | 값으로 (배열 복사본 생성). | |
참조로 (슬라이스 헤더 복사, 동일한 하위 데이터 가리킴). | ||
하위 구조 | N 개 요소의 연속적인 메모리 블록. | |
하위 배열에 대한 포인터, 길이, 용량을 포함하는 헤더. |
배열의 값 의미론으로 인한 함의를 생각해 봅시다.
package main import "fmt" func modifyArray(arr [3]int) { arr[0] = 99 // *복사본* 배열 수정 fmt.Println("Inside function (copied array):", arr) } func main() { originalArray := [3]int{1, 2, 3} fmt.Println("Original array before function call:", originalArray) modifyArray(originalArray) fmt.Println("Original array after function call:", originalArray) // 여전히 [1 2 3] // 비교를 위한 슬라이스 originalSlice := []int{1, 2, 3} fmt.Println("Original slice before function call:", originalSlice) // 배열 슬라이싱 시 슬라이스 생성 mySlice := originalArray[:] // mySlice는 originalArray를 참조하는 슬라이스 mySlice[0] = 100 // 슬라이스를 통해 originalArray의 *하위 데이터*를 수정함 fmt.Println("Original array after slice modification:", originalArray) // [100 2 3]이 됨 }
originalArray
가 modifyArray
에 전달될 때, 3개 정수 배열의 완전한 복사본이 생성됩니다. modifyArray
내부의 변경 사항은 이 로컬 복사본에만 영향을 미칩니다. 대규모 배열의 경우 메모리 부담이 클 수 있습니다.
반대로, mySlice
가 originalArray
에서 생성될 때, mySlice
는 originalArray
의 데이터에 다시 연결되는 슬라이스 헤더입니다. mySlice
를 통해 요소를 수정하면 originalArray
의 요소를 직접 변경합니다. 이것이 슬라이스가 동적 데이터 처리에 자주 선호되는 이유인데, 전달할 때 값 비싼 전체 데이터 복사를 피하기 때문입니다.
배열은 언제 사용할까?
슬라이스의 보편성과 유연성을 고려할 때, 배열이 올바른 선택인 경우는 언제일까요? 배열은 고정 길이와 값 의미론이 유리하거나 심지어 필요한 특정 시나리오에서 빛을 발합니다.
-
고정 크기 버퍼/데이터 구조: 최대 컬렉션 크기를 미리 정확히 알고 변경되지 않을 때.
- 예시: RGB 색상 값(
[3]uint8
), IPv4 주소([4]byte
), 또는 GPS 좌표([2]float64
) 저장. 이들은 본질적으로 고정적입니다.
type RGB struct { R uint8 G uint8 B uint8 } func main() { var redColor RGB = RGB{255, 0, 0} fmt.Printf("Red Color: R=%d, G=%d, B=%d\n", redColor.R, redColor.G, redColor.B) // 고정 크기인 2D 그리드/매트릭스에 배열 사용 var matrix [2][3]int // 2x3 매트릭스 matrix[0] = [3]int{1, 2, 3} matrix[1] = [3]int{4, 5, 6} fmt.Println("Matrix:", matrix) }
- 예시: RGB 색상 값(
-
성능 최적화 (미세 최적화): 극도로 성능에 민감한 코드에서 슬라이스 오버헤드(헤더, 용량 검사, 잠재적 재할당)를 피하면 때때로 사소한 이점을 제공할 수 있습니다. 그러나 Go 컴파일러와 런타임은 슬라이스에 대해 매우 최적화되어 있으므로, 이는 거의 주된 이유가 아닙니다.
-
C 상호 운용성: cgo를 통해 C 라이브러리와 상호 작용할 때, 배열은 C 스타일의 고정 크기 배열과 직접적으로 일치하는 경우가 많습니다.
-
맵의 키 (드묾!): 배열은 값 타입이며 요소가 비교 가능하면 비교 가능하므로, 때때로 맵의 키로 사용할 수 있습니다. 참조 타입인 슬라이스는 맵의 키가 될 수 없습니다.
package main import "fmt" func main() { // 맵 키로서의 배열 counts := make(map[[3]int]int) point1 := [3]int{1, 2, 3} point2 := [3]int{1, 2, 3} // point1과 동일한 값 point3 := [3]int{4, 5, 6} counts[point1] = 1 counts[point3] = 10 fmt.Println("Count for point1:", counts[point1]) fmt.Println("Count for point2 (same value):", counts[point2]) // 1 출력 fmt.Println("Count for point3:", counts[point3]) }
이 예시는
[3]int{1, 2, 3}
과[3]int{1, 2, 3}
이 배열은 값 타입이고 내용이 비교되기 때문에 맵에 대해 동일한 키로 간주됨을 보여줍니다. -
슬라이스의 하위 데이터: 언급했듯이, 모든 슬라이스는 내부적으로 하위 배열을 참조합니다. 슬라이스를 만들 때, 새로운 하위 배열을 만들거나 기존 배열의 일부를 참조하는 것입니다. 예를 들어,
arr[:]
는arr
전체를 참조하는 슬라이스를 만듭니다.
함수 서명에서의 배열
값 의미론 때문에 함수 매개변수에서 배열이 어떻게 처리되는지 이해하는 것이 중요합니다.
package main import "fmt" // 이 함수는 정확히 5개의 정수 배열을 수락합니다. // 이 함수를 호출할 때 배열의 복사본이 만들어집니다. func processFixedArray(data [5]int) { fmt.Println("Inside processFixedArray (before modification):", data) data[0] = 999 // 로컬 복사본 수정 fmt.Println("Inside processFixedArray (after modification):", data) } // 이 함수는 정수 슬라이스를 수락합니다. // 슬라이스 헤더는 복사되지만, 동일한 하위 데이터 포인터를 가리킵니다. func processSlice(data []int) { fmt.Println("Inside processSlice (before modification):", data) if len(data) > 0 { data[0] = 999 // 실제 하위 데이터 수정 } fmt.Println("Inside processSlice (after modification):", data) } func main() { myArray := [5]int{10, 20, 30, 40, 50} fmt.Println("Original array before processFixedArray:", myArray) processFixedArray(myArray) fmt.Println("Original array after processFixedArray:", myArray) // 변경되지 않음: [10 20 30 40 50] // 슬라이스 기반 함수로 배열 내용을 처리하려면, // 일반적으로 배열에서 파생된 슬라이스를 전달합니다. mySlice := myArray[:] // 배열에서 슬라이스 생성 fmt.Println("\nOriginal array before processSlice:", myArray) processSlice(mySlice) fmt.Println("Original array after processSlice:", myArray) // 변경됨: [999 20 30 40 50] // 다른 크기의 배열을 전달하려고 하면 어떻게 될까요? // var smallArray [3]int = {1,2,3} // processFixedArray(smallArray) // 컴파일 시간 오류: type [3]int인 smallArray(변수)를 type [5]int인 것으로 사용할 수 없습니다. // 또는 슬라이스를 배열에 전달하려고 하면 어떻게 될까요? // var dynamicSlice []int = []int{1,2,3,4,5} // processFixedArray(dynamicSlice) // 컴파일 시간 오류: type []int인 dynamicSlice(변수)를 type [5]int인 것으로 사용할 수 없습니다. }
예시는 processFixedArray
가 복사본으로 작동하는 반면, processSlice
는 슬라이스 헤더를 통해 하위 데이터로 작동함을 명확히 보여줍니다. 배열의 강력한 타입 지정 때문에 크기 M
을 기대하는 함수에 크기 N
인 배열을 전달할 수 없습니다 (N != M
인 경우). 이러한 엄격함은 배열 타입의 일부로서 "고정 길이"를 강조합니다.
결론
Go의 배열은 일반적인 데이터 컬렉션에 슬라이스보다 덜 자주 사용되지만, 근본적인 것입니다. 이는 동일한 타입의 요소를 가진 고정 길이, 연속적인 시퀀스를 나타냅니다. 주요 특성인 고정 크기, 값 의미론, 저장 연속성은 컬렉션의 정확한 경계를 알고 변경할 수 없을 때, 또는 직접적인 메모리 레이아웃과 타입 수준의 크기 보장이 유익할 때 이상적입니다.
Go 배열의 고유한 특성, 특히 슬라이스와 대비되는 점을 이해하는 것은 효율적이고 정확하며 관용적인 Go 코드를 작성하는 데 필수적입니다. 슬라이스가 동적인 유연성을 제공하는 동안, 배열은 특정 프로그래밍 문제를 해결하는 데 적합한 컴파일 시간 보증 및 직접성을 제공합니다. 둘 다를 효과적으로 활용함으로써 Go 개발자는 데이터의 고유한 속성에 맞게 조정된 강력하고 성능이 뛰어난 애플리케이션을 만들 수 있습니다.