고 기초 #5 컬렉션 — array, slice, map
#4 함수, 다중 반환, error 타입에서 함수와 에러를 봤습니다. 이번엔 — 데이터를 묶는 도구들인 Go의 세 컬렉션 타입을 정리합니다.
세 컬렉션 한눈에 #
| 길이 | 값 변경 | 일반 사용도 | |
|---|---|---|---|
| array | 고정 | OK | 거의 안 씀 |
| slice | 가변 | OK | 압도적으로 많이 씀 |
| map | 가변 | OK | 키-값에 매우 많이 씀 |
Array — 고정 길이 #
var nums [3]int // [0 0 0]
nums[0] = 10
nums[1] = 20
fmt.Println(nums) // [10 20 0]
primes := [5]int{2, 3, 5, 7, 11}
fmt.Println(len(primes)) // 5길이가 타입의 일부입니다. [3]int와 [5]int는 다른 타입입니다.
var a [3]int
var b [5]int
// a = b ✗ 다른 타입이 제약 때문에 array는 일반 자료구조로는 어색합니다. 거의 모든 경우에 슬라이스를 씁니다. array를 직접 만나는 건 보통 다음 경우입니다.
- 해시 결과 같은 고정 크기 (
[32]bytefor SHA-256) - 매우 좁은 성능 최적화
Slice — 가변 길이의 진짜 주인공 #
nums := []int{1, 2, 3, 4, 5}
fmt.Println(len(nums)) // 5
nums = append(nums, 6) // 끝에 추가
fmt.Println(nums) // [1 2 3 4 5 6][]int (대괄호 안에 길이가 없음)가 슬라이스의 타입. 길이는 런타임에 정해지고 자유롭게 바뀝니다.
만들기 — make
#
s1 := make([]int, 5) // 길이 5, 모두 zero — [0 0 0 0 0]
s2 := make([]int, 0, 10) // 길이 0, 용량 10세 번째 인자는 용량(capacity). 슬라이스의 동작 원리 핵심입니다 — 잠시 뒤에.
인덱스와 슬라이싱 #
s := []int{10, 20, 30, 40, 50}
s[0] // 10
s[1:3] // [20 30] — [start, end)
s[:3] // [10 20 30]
s[2:] // [30 40 50]
s[:] // 전체Python/JS와 비슷합니다. 시작 포함, 끝 제외.
Slice의 동작 원리 — 길이 vs 용량 #
이게 Go의 슬라이스를 이해하는 핵심입니다.
슬라이스는 내부적으로 세 부분으로 구성됩니다.
- 포인터 — 실제 데이터(배열)를 가리킴
- 길이 (len) — 현재 들어있는 원소 수
- 용량 (cap) — 데이터 공간의 크기
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s)) // 3 5
s = append(s, 100)
fmt.Println(len(s), cap(s)) // 4 5
s = append(s, 200)
fmt.Println(len(s), cap(s)) // 5 5
s = append(s, 300)
fmt.Println(len(s), cap(s)) // 6 10 ← cap 자동 증가 (보통 두 배)용량이 모자라면 — append가 새 배열을 할당하고 복사 합니다. 보통 두 배씩 커집니다. 자주 append 하는 경우엔 미리 cap을 잡아두면 효율적입니다.
result := make([]int, 0, 1000) // 1000 까지는 재할당 없음
for i := 0; i < 1000; i++ {
result = append(result, i*i)
}Slice 함정 — 같은 배열 공유 #
슬라이스는 포인터 + 길이 + 용량 이라, 두 슬라이스가 같은 배열을 가리킬 수 있습니다.
a := []int{1, 2, 3, 4, 5}
b := a[1:4] // [2 3 4]
b[0] = 999
fmt.Println(a) // [1 999 3 4 5] ← a도 바뀜
fmt.Println(b) // [999 3 4]b는 a의 일부에 대한 view입니다. 한쪽으로 변경한 게 다른 쪽에도 반영됩니다.
이게 자주 사고를 만듭니다. 진짜 복사가 필요하면 — copy 또는 append 트릭.
a := []int{1, 2, 3}
// copy
b := make([]int, len(a))
copy(b, a)
// append
c := append([]int{}, a...)
b[0] = 999
fmt.Println(a) // [1 2 3] (영향 없음)자주 쓰는 슬라이스 패턴 #
s := []int{1, 2, 3, 4, 5}
// 끝에 추가
s = append(s, 6)
s = append(s, 7, 8, 9) // 여러 개
// 다른 슬라이스 합치기
s2 := []int{10, 20}
s = append(s, s2...) // ... 펼치기
// 길이
n := len(s)
// 비어있는지
isEmpty := len(s) == 0
// 인덱스 i 원소 제거 (순서 유지)
i := 2
s = append(s[:i], s[i+1:]...)
// 순서 무시 제거 (마지막과 swap 후 자르기)
s[i] = s[len(s)-1]
s = s[:len(s)-1]nil 슬라이스 — 빈 슬라이스와 거의 동일
#
var s []int // nil
fmt.Println(len(s)) // 0
fmt.Println(s == nil) // true
s = append(s, 1) // OK — append가 nil을 받아도 됨선언만 하고 초기화 안 하면 nil. 하지만 — append와 len 등 거의 모든 동작이 nil 슬라이스에서도 안전합니다. 특별히 신경 안 써도 됩니다.
Map — 키-값 #
ages := map[string]int{
"커티스": 30,
"앨리스": 25,
}
ages["밥"] = 35 // 추가
fmt.Println(ages["커티스"]) // 30
delete(ages, "앨리스") // 삭제
fmt.Println(len(ages)) // 2map[키타입]값타입으로 타입을 적습니다.
만들기 — make
#
ages := make(map[string]int)
ages["커티스"] = 30
// 또는 리터럴
empty := map[string]int{}키 존재 확인 — comma-ok #
가장 자주 쓰는 패턴.
ages := map[string]int{"커티스": 30}
// 단순 접근 — 없으면 zero value
fmt.Println(ages["없는키"]) // 0 (혼란 가능)
// comma-ok 패턴 — 있는지 확인
if age, ok := ages["커티스"]; ok {
fmt.Println("있음:", age)
} else {
fmt.Println("없음")
}map[키]가 두 값을 반환할 수 있습니다 — 값과 존재 여부. 값 자체가 zero value이면 단순 접근으론 구분 못 함. comma-ok가 표준입니다.
순회 — 순서 없음 #
for name, age := range ages {
fmt.Println(name, age)
}Go의 맵 순회는 매번 다른 순서입니다. 의도적으로 randomize 됩니다(코드가 순서에 의존하지 않게 강제). 정렬된 순서가 필요하면 키를 슬라이스로 모아 정렬.
import "sort"
keys := make([]string, 0, len(ages))
for k := range ages {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, ages[k])
}nil map — 함정 주의
#
var m map[string]int만 선언하면 nil 맵이고 — 읽기는 OK 지만 쓰기는 panic 합니다.
var m map[string]int
fmt.Println(m["키"]) // 0 (읽기 OK)
fmt.Println(len(m)) // 0 (OK)
m["키"] = 1 // ✗ panic: assignment to entry in nil map쓰기 전에 반드시 make 또는 리터럴로 초기화. 슬라이스의 nil 동작과 다른 부분입니다.
자주 쓰는 map 패턴 #
counts := make(map[string]int)
words := []string{"a", "b", "a", "c", "b", "a"}
for _, w := range words {
counts[w]++ // 처음이면 zero (0)에서 +1
}
fmt.Println(counts) // map[a:3 b:2 c:1]키 없을 때 자동으로 zero value 인 점이 카운팅에 자연스럽습니다.
Set 만들기 — map[T]struct #
Go에는 set 타입이 없어 — 빈 struct를 값으로 한 map으로 만듭니다.
seen := make(map[string]struct{})
seen["a"] = struct{}{}
seen["b"] = struct{}{}
if _, ok := seen["a"]; ok {
fmt.Println("이미 있음")
}빈 struct는 메모리를 0 바이트 차지합니다(그래서 set 용도에 표준). bool을 값으로 써도 동작은 같지만 메모리 측면에서 빈 struct가 더 정확합니다.
컬렉션을 함수에 넘길 때 #
슬라이스는 참조 의미 #
func modify(s []int) {
s[0] = 999 // 호출자에게도 보임
}
nums := []int{1, 2, 3}
modify(nums)
fmt.Println(nums) // [999 2 3]슬라이스 자체(헤더)는 값으로 복사되지만, 안에 들어있는 포인터가 같은 배열을 가리킵니다. 데이터 변경은 호출자에게 보입니다.
다만 append 결과는 다를 수 있습니다 — append가 새 배열을 할당하면 원본 슬라이스 헤더는 바뀌지 않습니다.
func appendBad(s []int) {
s = append(s, 100) // 새 배열일 수 있음 — 호출자에게 안 보임
}
nums := []int{1, 2, 3}
appendBad(nums)
fmt.Println(nums) // [1 2 3] — 추가 안 됨
// 권장 — 결과를 반환
func appendGood(s []int) []int {
return append(s, 100)
}
nums = appendGood(nums)맵은 참조 의미 #
func modify(m map[string]int) {
m["new"] = 100 // 호출자에게도 보임
}맵은 슬라이스보다 더 단순하게 참조 의미입니다. 함수 안의 변경이 항상 보입니다.
마무리 #
이번 글에서 정리한 내용:
- array — 고정 길이, 거의 안 씀
- slice — 가변, 거의 모든 컬렉션 용도
- 슬라이스는 포인터+길이+용량. cap 모자라면 새 배열 할당
- 같은 배열 공유 함정 —
copy또는append([]T{}, ...)로 복사 - nil 슬라이스는 안전, nil map은 쓰기 시 panic
- map —
map[키]값, comma-ok로 존재 확인 - map 순회는 매번 다른 순서
- set은
map[T]struct{}로 만듦 - 슬라이스/맵은 참조 의미 — 함수 안 변경이 호출자에게 보임
다음 글(#6 구조체와 메서드)에서는 Go의 사용자 정의 타입 — struct와 그에 묶인 메서드, 그리고 포인터 리시버까지 정리합니다.