Go 파헤치기 - Arrays, Slices, Maps

 

1. 들어가며

 

Go에는 크게 세 가지 데이터 구조체가 있습니다. array, slice, map이 그것입니다. Java에 익숙하다면 Array와 Map은 많이 보았겠지만 slice는 낯설 것이라 생각합니다 :) 또한 이러한 자료형들은 구체적으로 어떻게 구현되어 있는지 살펴볼 일이 많지 않습니다. 오늘은 구체적인 구현과 주의해야할 사항까지 자세히 정리해보도록 하겠습니다 :)

 

2. Arrays (배열)

 

배열: 동일한 유형의 요소의 연속 블록을 포함하는 고정 길이 데이터 유형입니다. 이는 정수, 문자열, 구조체 모두가 가능합니다.

 

 

배열 메모리 구조

 

Go의 배열은 여타 언어처럼 메모리가 순차적으로 할당되기 때문에 캐시에 유리하며 빠른 반복을 수행할 수 있습니다. 또한 각 요소는 동일한 유형으로 순차적으로 따라가기 때문에 일관되고 빠르게 이동할 수 있습니다. 또한 여타 언어처럼 배열의 크기는 선언 때 고정됩니다. 또한 각 값은 따로 초기화하거나 할당하지 않는다면 0 값으로 초기화됩니다.

 

1) 다양한 선언 방식

// 5개 요소의 정수 배열을 선언합니다. 
// 각 요소를 특정 값으로 초기화합니다. 
array := [5]int{10, 20, 30, 40, 50}

// 정수 배열을 선언합니다. 
// 각 요소를 특정 값으로 초기화합니다. 
// 용량은 초기화된 값의 개수에 따라 결정됩니다. 
array := [...]int{10, 20, 30, 40, 50}

// 5개 요소의 정수 배열을 선언합니다. 
// 인덱스 1과 2를 특정 값으로 초기화합니다. 
// 나머지 요소에는 0 값이 포함됩니다. 
array := [5]int{1: 10, 2: 20}

// c처럼 포인터 배열을 가질 수 있습니다.
// 5개 요소의 정수 포인터 배열을 선언합니다. 
// 정수 포인터로 배열의 인덱스 0과 1을 초기화합니다. 
array := [5]*int{0: new(int), 1: new(int)} 

// 인덱스 0과 1에 값을 할당합니다. 
*array[0] = 10 
*array[1] = 20

 

위와 같은 방식으로 배열을 선언할 수 있습니다. 자바는 아래와 같은 방식으로 선언한다는 것을 알고 계실 것입니다.

int[] intArray = new int[5];

 

순서 정도가 차이가 있을 것 같습니다.

하지만 배열을 복사하는 방식에는 중요한 차이점이 있습니다.

 

2) 배열 복사

// 같은 자료형의 배열이라면 
// 5개 요소의 문자열 배열을 선언합니다. 
var array1 [5]string 

// 5개 요소의 두 번째 문자열 배열을 선언합니다. 
// 색상으로 배열을 초기화합니다. 
array2 := [5]string{"Red", "Blue", "Green", "Yellow", "Pink"} 

// array2의 값을 array1로 복사합니다. 
array1 = array2

array2[1] = "Black"
fmt.Println(array1[1]) // Blue
fmt.Println(array2[1]) // Black

 

Java에서 위와 같은 행동을 한다면 array1과 array2는 같은 배열을 바라보게 됩니다. 즉 shallow copy가 일어납니다.

 

하지만 golang에서는 이렇게 값 배열을 다른 배열에 할당한다면 deep copy, 즉 배열 전체 메모리를 복사해서 array2에 할당합니다.

 

위 코드는 다음 그림과 같이 서로 다른 메모리 공간을 가지게 됩니다. 이렇듯 배열 전체 메모리를 복사해서 넣는 방식이므로 복사를 위해선 두 배열의 자료형과 크기가 모두 동일해야 합니다.

 

반대로 포인터 배열을 복사한다면

 

 

다음 그림과 같이 각 포인터값들만 복사가 되고 실제로 바라보고 있는 값은 원래 값을 바라보게 됩니다. (포인터가 가리키는 지점이 동일하므로)

 

함수 간에 배열을 전달하는 것은 메모리나 성능 측면에서 비용이 많이 듭니다. 왜냐하면 함수 간에 변수를 전달할 때는 항상 값으로 전달되고, 변수가 배열일 경우 전체 배열이 복사되어 함수에 전달되기 때문입니다.

 

예를 들어 다음 코드는

 

// 8메가바이트 배열을 선언합니다. 
var array [1e6]int 

// 배열을 foo 함수에 전달합니다. 
foo(array) 

// foo 함수는 100만 개의 정수 배열을 허용합니다. 
func foo(array [1e6]int) { 
    ... 
}

 

8메가바이트의 메모리가 foo 함수가 호출될 때마다 스택에 할당되고, 그 메모리 전체를 해당 할당에 복사되어야 합니다.

따라서 이런 배열은 포인터 전달을 통한다면 전체 배열 복사가 아닌 포인터값만 복사하여 전달할 수 있습니다.

 

3. Slice (슬라이스)

슬라이스는 필요에 따라 확장, 축소할 수 있는 동적 배열입니다.
슬라이스는 기본 메모리가 연속된 블록에 할당되기 때문에 인덱싱, 반복 및 GC 최적화에 이점을 제공합니다.
즉 슬라이스는 기본 배열을 추상화한 작은 객체입니다.

 

슬라이스 구성

 

 

슬라이스는 내부적으로 배열을 가지고 있습니다. 다만 동적으로 확장, 축소하도록 추상화되어 있는 객체라고 보면 됩니다.

그림에서 위의 세 개의 필드는 기본 배열에 대한 포인터, 슬라이스가 액세할 수 있는 길이, 또 슬라이스가 크기를 늘릴 때 사용할 요소의 용량 또는 수입니다.

 

- 선언 방법

 

Go에서 슬라이스를 만드는 방법은 내장함수 make를 사용하는 것입니다.

// 문자열 슬라이스를 생성합니다. 
// 5개의 요소의 길이와 용량을 포함합니다. 
slice := make([]string, 5)

// 정수 슬라이스를 생성합니다. 
// 길이는 3이고 용량은 5개입니다. 
slice := make([]int, 3, 5)

// 문자열 슬라이스를 생성합니다. 
// 5개 요소의 길이와 용량을 포함합니다. 
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"}

// 다음과 같이 빈 슬라이스를 선언할 수 있습니다.
// make를 사용하여 빈 정수 슬라이스를 생성합니다. 
slice := make([]int, 0)

// 슬라이스 리터럴을 사용하여 빈 정수 슬라이스를 생성합니다. 
slice := []int{}

 

마지막 코드처럼 빈 정수 슬라이스를 만들 수도 있습니다. 

빈 슬라이스

빈 슬라이스는 위처럼 구성됩니다. 빈 슬라이스도 슬라이스의 기본 메소드인 append, len, cap을 모두 사용 가능합니다. 

 

배열과 슬라이스를 선언하는 것은 다음과 같이 다릅니다. []연산자 안에 값을 지정하면 배열이고,값을 지정하지 않으면 슬라이스를 만드는 것입니다.

// 3개의 정수 배열을 생성합니다. 
array := [3]int{10, 20, 30} 

// 길이와 용량이 3인 정수 슬라이스를 생성합니다. 
slice := []int{10, 20, 30}

 

슬라이스는 배열처럼 사용할 수 있습니다. 즉 slice[1]과 같이 값을 조회해올 수 있습니다.

 

- 슬라이스 복사

 

그럼 배열처럼 슬라이스도 복사할 수 있을까요? 다음 코드에서 살펴보겠습니다.

// 정수 슬라이스를 생성합니다. 
// 길이와 용량은 5개 요소를 포함합니다. 
slice := []int{10, 20, 30, 40, 50} 


// 새 슬라이스를 생성합니다. 
// 길이 2와 용량은 4개 요소를 포함합니다. 
newSlice := slice[1:3]

 

위처럼 기존 슬라이스를 잘라서 새로운 슬라이스를 생성한다면 메모리 구조는 다음과 같습니다.

슬라이스 복사

 

즉 포인터는 기존 슬라이스의 배열을 가리키게 됩니다.

 

용량 k의 기본 배열을 갖는 slice[i:j]의 경우

  • 길이: j - i
  • 용량: k – i

를 가지게 됩니다.

 

슬라이스는 길이까지만 인덱스에 엑세스할 수 있습니다.

슬라이스의 장점은 필요에 따라 슬라이스의 용량을 늘릴 수 있다는 점입니다. 이는 append 함수를 통해 이루어집니다.

// 정수 슬라이스를 생성합니다. 
// 길이와 용량은 5개 요소를 포함합니다. 
slice := []int{10, 20, 30, 40, 50} 

// 새 슬라이스를 생성합니다. 
// 길이는 2이고 용량은 4개 요소를 포함합니다. 
newSlice := slice[1:3] 

// 용량에서 새 요소를 할당합니다. 
// 새 요소에 60의 값을 할당합니다. 
newSlice = append(newSlice, 60)

 

 

이처럼 새 슬라이스에서 append한 것도 기존 슬라이스에 영향을 주는 것을 알 수 있습니다.

이러한 문제를 겪지 않으려면 다음과 같은 방식으로 할 수 있습니다.

// 문자열 슬라이스를 생성합니다. 
// 5개 요소의 길이와 용량을 포함합니다. 
source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"} 

// 세 번째 요소를 슬라이스하고 용량을 제한합니다. 
// 1개 요소의 길이와 용량을 포함합니다. 
slice := source[2:3:3] 

// 슬라이스에 새 문자열을 추가합니다. 
slice = append(slice, "Kiwi")

 

위 코드 같은 경우 새 슬라이스에 자체 기본 배열이 있으므로 다른 슬라이스를 변경하는 문제를 겪지 않고 변경할 수 있습니다.

 

- 슬라이스 순회

// 정수 슬라이스를 생성합니다. 
// 4개 요소의 길이와 용량을 포함합니다. 
slice := []int{10, 20, 30, 40} 

// 각 요소를 반복하고 각 값을 표시합니다. 
for index, value := range slice { 
  fmt.Printf("Index: %d Value: %d\n", index, value) 
}

 

위처럼 range를 통해 순회한다면 각 요소의 복사본이 생성됩니다. 즉 참조를 반환하는 것이 아닌 값의 사본을 만들게 됩니다. 각 값에 직접 접근하고 싶다면 index를 통해 직접 접근하면 됩니다.

 

// 정수 슬라이스를 생성합니다. 
// 4개 요소의 길이와 용량을 포함합니다. 
slice := []int{10, 20, 30, 40} 

// 3번째 요소부터 각 요소를 반복합니다. 
for index := 2; index < len(slice); index++ { 
    fmt.Printf("인덱스: %d 값: %d\n", index, slice[index]) 
} 

// 출력: 
// 인덱스: 2 값: 30 
// 인덱스: 3 값: 40

 

- 함수 간의 slice 전달

// 100만 개의 정수 슬라이스를 할당합니다. 
slice := make([]int, 1e6) 

// 슬라이스를 foo 함수에 전달합니다. 
slice = foo(slice) 

// foo 함수는 정수 슬라이스를 받아서 슬라이스를 반환합니다. 
func foo(slice []int) []int { 
    ... 
    return slice 
}

 

배열에서는 기억하시겠지만 slice 값을 그대로 복사해서 전달하기 때문에 매우 큰 값의 메모리 복사가 일어나게 됩니다. 하지만 슬라이스에서는 배열처럼 값을 복사해서 전달하는 것이 아닌 포인터 레퍼런스 값이 복사되어 넘어가기 때문에 두 함수는 같은 슬라이스의 배열을 바라보게 됩니다즉 포인터를 전달하거나 복잡한 구문을 다룰 필요가 없어집니다.

슬라이스 함수 전달

 

 

4. Map

맵은 키/값 쌍의 정렬되지 않은 컬렉션을 제공합니다.

맵의 축약 구조

 

즉 키/값 쌍이 반환되는 순서를 예측할 수 있는 방법이 없습니다. 내부적으로 해시 테이블을 사용해서 구현되기 때문입니다.

실제 맵 구조

 

맵의 해시 테이블에는 bucket 컬렉션이 들어 있습니다. /값 쌍을 저장, 제거, 조회할 때 모두 bucket 선택으로 시작됩니다. 해시 함수는 모든 버킷에 키/값 쌍을 균등하게 분배하는 인덱스를 생성합니다. Go 맵의 경우 생성된 해시 키의 일부, 특히 하위 비트를 사용하여 버킷을 선택합니다

 

 

버킷을 선택하는데 사용된 동일한 해시 키에서 상위 8개의 비트를 사용하여 해당 버킷에 저장된 각 개별 키/값 쌍을 구별합니다. 두 번째로 키/값을 저장하는 바이트 배열에서 모든 키를 패킹합니다.

 

- map 선언 및 API 정리

map의 경우 다른 언어와 크게 차이가 없으므로 간단하게 코드로 정리하고 넘어가겠습니다 :)

// 문자열 형식의 키와 int 형식의 값을 갖는 맵을 생성합니다. 
dict := make(map[string]int) 

// 문자열 형식의 키와 값을 갖는 맵을 생성합니다. 
// 2개의 키/값 쌍으로 맵을 초기화합니다. 
dict := map[string]string{"Red": "#da1337", "Orange": "#e95a22"}

// 색상과 색상 코드를 저장할 빈 맵을 생성합니다. 
colors := map[string]string{} 

// 맵에 빨간색 색상 코드를 추가합니다. 
colors["Red"] = "#da1337"

// 키 "Blue"에 대한 값을 검색합니다. 
value, exists := colors["Blue"] 

// 이 키가 존재했습니까? 
if exists { 
    fmt.Println(value) 
}

// 색상과 색상 16진수 코드의 맵을 만듭니다. 
colors := map[string]string{ 
    "AliceBlue": "#f0f8ff", 
    "Coral": "#ff7F50", 
    "DarkGray": "#a9a9a9", 
    "ForestGreen": "#228b22", 
} 

// 맵에 있는 모든 색상을 표시합니다. 
for key, value := range colors { 
    fmt.Printf("키: %s 값: %s\n", 키, 값) 
}

// 키 "Coral"에 대한 키/값 쌍을 제거합니다. 
delete(colors, "Coral") 

// 맵에 있는 모든 색상을 표시합니다. 

for key, value := range colors { 
    fmt.Printf("키: %s 값: %s\n", key, value) 
}