Table of contents
- 배열 — 고정 크기
- 슬라이스 — Go의 진짜 리스트
- len과 cap — 슬라이스의 내부 구조
- append — 슬라이스에 요소 추가
- nil 슬라이스와 빈 슬라이스
- 맵 — 키-값 저장소
- 맵 순회와 삭제
배열 — 고정 크기
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 — 슬라이스의 내부 구조
슬라이스가 어떻게 동작하는지 이해하려면 내부 구조를 알아야 한다. 슬라이스는 세 가지 정보를 가진 작은 구조체다.
- 포인터: 실제 데이터가 있는 배열의 시작 위치
- 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편: 구조체와 메서드
Loading comments...