Skip to content
ioob.dev
Go back

Go 입문 5편 — 배열, 슬라이스, 맵

· 4분 읽기

Table of contents

배열 — 고정 크기

Go의 배열은 크기가 타입의 일부다. [3]int[5]int는 완전히 다른 타입이다.

package main

import "fmt"

func main() {
    var a [3]int              // [0, 0, 0]
    b := [3]string{"Go", "is", "fun"}

    a[0] = 10
    fmt.Println(a)  // [10 0 0]
    fmt.Println(b)  // [Go is fun]
    fmt.Println(len(b))  // 3
}

크기를 컴파일 타임에 알아야 하고, 한번 정하면 바꿀 수 없다. 솔직히 말하면 Go에서 배열을 직접 쓰는 경우는 드물다. 대부분 슬라이스를 사용한다.

그런데 배열이 왜 존재하느냐고 묻는다면, 슬라이스의 기반이 되기 때문이다. 슬라이스를 이해하려면 배열이 밑에 깔려 있다는 사실을 알아야 한다.

슬라이스 — Go의 진짜 리스트

슬라이스는 Go에서 가장 많이 쓰는 컬렉션이다. 배열 위에 올라가는 가변 길이 뷰(view)라고 생각하면 된다.

package main

import "fmt"

func main() {
    // 슬라이스 리터럴
    fruits := []string{"사과", "바나나", "체리"}
    fmt.Println(fruits)       // [사과 바나나 체리]
    fmt.Println(len(fruits))  // 3

    // make로 생성
    nums := make([]int, 3, 5)  // 길이 3, 용량 5
    fmt.Println(nums)          // [0 0 0]
    fmt.Println(len(nums))     // 3
    fmt.Println(cap(nums))     // 5
}

배열은 [3]string처럼 크기가 있고, 슬라이스는 []string처럼 크기가 없다. 이 차이가 핵심이다.

len과 cap — 슬라이스의 내부 구조

슬라이스가 어떻게 동작하는지 이해하려면 내부 구조를 알아야 한다. 슬라이스는 세 가지 정보를 가진 작은 구조체다.

package main

import "fmt"

func main() {
    original := []int{1, 2, 3, 4, 5}

    // 슬라이싱 — 같은 배열을 공유한다
    sub := original[1:3]
    fmt.Println(sub)       // [2 3]
    fmt.Println(len(sub))  // 2
    fmt.Println(cap(sub))  // 4 — original[1]부터 끝까지의 용량

    // sub를 바꾸면 original도 바뀐다!
    sub[0] = 99
    fmt.Println(original)  // [1 99 3 4 5]
}

이게 슬라이스를 다룰 때 가장 주의해야 할 점이다. 슬라이싱으로 만든 슬라이스는 원본 배열을 공유한다. 한쪽을 바꾸면 다른 쪽도 바뀐다. Java의 List.subList()와 비슷한 함정이다.

독립적인 복사본이 필요하면 copy를 쓴다.

package main

import "fmt"

func main() {
    original := []int{1, 2, 3, 4, 5}
    clone := make([]int, len(original))
    copy(clone, original)

    clone[0] = 99
    fmt.Println(original)  // [1 2 3 4 5] — 원본은 그대로
    fmt.Println(clone)     // [99 2 3 4 5]
}

append — 슬라이스에 요소 추가

append는 슬라이스 끝에 요소를 추가하고 새 슬라이스를 반환한다.

package main

import "fmt"

func main() {
    var s []int
    fmt.Println(s, len(s), cap(s))  // [] 0 0

    s = append(s, 1)
    fmt.Println(s, len(s), cap(s))  // [1] 1 1

    s = append(s, 2, 3)
    fmt.Println(s, len(s), cap(s))  // [1 2 3] 3 4

    s = append(s, 4, 5, 6)
    fmt.Println(s, len(s), cap(s))  // [1 2 3 4 5 6] 6 8
}

용량이 부족하면 Go 런타임이 더 큰 배열을 새로 할당하고 기존 데이터를 복사한다. 용량이 대략 2배씩 늘어나는 것을 볼 수 있다. (정확한 증가 전략은 Go 버전에 따라 다르다.)

반드시 반환값을 받아야 한다. append는 원본을 수정하지 않고 새 슬라이스를 돌려주기 때문이다.

// 이렇게 하면 안 된다
append(s, 1)     // 반환값을 버리면 의미 없음

// 이렇게 해야 한다
s = append(s, 1)

슬라이스끼리 합칠 때는 ...을 쓴다.

package main

import "fmt"

func main() {
    a := []int{1, 2, 3}
    b := []int{4, 5, 6}
    a = append(a, b...)
    fmt.Println(a)  // [1 2 3 4 5 6]
}

nil 슬라이스와 빈 슬라이스

Go에서는 nil 슬라이스와 빈 슬라이스가 다르다. 하지만 실용적으로는 거의 같게 동작한다.

package main

import "fmt"

func main() {
    var nilSlice []int           // nil 슬라이스
    emptySlice := []int{}        // 빈 슬라이스
    madeSlice := make([]int, 0)  // 빈 슬라이스

    fmt.Println(nilSlice == nil)    // true
    fmt.Println(emptySlice == nil)  // false
    fmt.Println(madeSlice == nil)   // false

    // 하지만 len, cap, append 모두 동일하게 동작
    fmt.Println(len(nilSlice), len(emptySlice))  // 0 0
    nilSlice = append(nilSlice, 1)
    emptySlice = append(emptySlice, 1)
    fmt.Println(nilSlice, emptySlice)  // [1] [1]
}

nil 슬라이스에도 len, cap, append가 정상 동작한다. 그래서 초기화할 필요 없이 var s []int로 선언하고 바로 append해도 된다. 이것이 Go의 zero value 철학이 빛나는 지점이다.

다만 JSON으로 직렬화할 때는 차이가 있다. nil 슬라이스는 null이 되고, 빈 슬라이스는 []가 된다. API 응답을 만들 때 이 차이를 인지하고 있어야 한다.

맵 — 키-값 저장소

맵은 키와 값을 연결하는 자료구조다. Python의 딕셔너리, Java의 HashMap과 같은 역할이다.

package main

import "fmt"

func main() {
    // 리터럴로 생성
    scores := map[string]int{
        "Alice": 90,
        "Bob":   85,
        "Carol": 92,
    }
    fmt.Println(scores)           // map[Alice:90 Bob:85 Carol:92]
    fmt.Println(scores["Alice"])  // 90

    // make로 생성
    ages := make(map[string]int)
    ages["Tom"] = 25
    ages["Jane"] = 30
    fmt.Println(ages)  // map[Jane:30 Tom:25]
}

맵에서 존재하지 않는 키를 조회하면 zero value가 반환된다. 키가 실제로 있는지 확인하려면 두 번째 반환값을 쓴다.

package main

import "fmt"

func main() {
    scores := map[string]int{
        "Alice": 90,
    }

    score := scores["Bob"]
    fmt.Println(score)  // 0 — 없는 키지만 에러가 아니다

    // 키 존재 여부 확인
    score, ok := scores["Bob"]
    if !ok {
        fmt.Println("Bob의 점수가 없다")
    }

    // 이런 패턴이 자주 쓰인다
    if score, ok := scores["Alice"]; ok {
        fmt.Println("Alice:", score)
    }
}

value, ok := m[key] — 이 패턴을 “comma ok” 패턴이라고 부른다. Go 곳곳에서 등장하니 눈에 익혀두자.

맵 순회와 삭제

맵을 순회할 때는 range를 쓴다. 한 가지 알아둘 점은, 순회 순서가 보장되지 않는다는 것이다.

package main

import "fmt"

func main() {
    colors := map[string]string{
        "red":   "#FF0000",
        "green": "#00FF00",
        "blue":  "#0000FF",
    }

    // 순회 — 실행할 때마다 순서가 다를 수 있다
    for key, value := range colors {
        fmt.Printf("%s: %s\n", key, value)
    }

    // 삭제
    delete(colors, "red")
    fmt.Println(colors)  // map[blue:#0000FF green:#00FF00]

    // 전체 삭제 (Go 1.21+)
    clear(colors)
    fmt.Println(colors)  // map[]
}

Go는 맵 순회 순서를 의도적으로 랜덤화한다. 순서에 의존하는 코드를 미리 방지하기 위한 설계 결정이다. 정렬된 순서가 필요하면 키를 슬라이스에 담아 정렬한 뒤 순회해야 한다.

delete로 키를 삭제할 수 있고, Go 1.21부터는 clear로 모든 항목을 한번에 지울 수 있다. 존재하지 않는 키를 delete해도 에러가 나지 않으니 안심하고 써도 된다.


다음 편에서는 Go의 구조체와 메서드를 다룬다. 포인터 리시버, 임베딩, 생성자 패턴까지 Go가 객체지향을 어떻게 풀어내는지 살펴보자.

-> 6편: 구조체와 메서드


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 6편 — 구조체와 메서드
Next Post
Go 입문 4편 — 에러 처리