고 기초 #5 컬렉션 — array, slice, map

6 분 소요

#4 함수, 다중 반환, error 타입에서 함수와 에러를 봤습니다. 이번엔 — 데이터를 묶는 도구들인 Go의 세 컬렉션 타입을 정리합니다.

세 컬렉션 한눈에 #

길이값 변경일반 사용도
array고정OK거의 안 씀
slice가변OK압도적으로 많이 씀
map가변OK키-값에 매우 많이 씀

Array — 고정 길이 #

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]byte for 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 #

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의 슬라이스를 이해하는 핵심입니다.

슬라이스는 내부적으로 세 부분으로 구성됩니다.

  1. 포인터 — 실제 데이터(배열)를 가리킴
  2. 길이 (len) — 현재 들어있는 원소 수
  3. 용량 (cap) — 데이터 공간의 크기
len vs 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을 잡아두면 효율적입니다.

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]

ba의 일부에 대한 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 슬라이스 — 빈 슬라이스와 거의 동일 #

nil 슬라이스
var s []int     // nil
fmt.Println(len(s))    // 0
fmt.Println(s == nil)  // true

s = append(s, 1)        // OK — append가 nil을 받아도 됨

선언만 하고 초기화 안 하면 nil. 하지만 — appendlen 등 거의 모든 동작이 nil 슬라이스에서도 안전합니다. 특별히 신경 안 써도 됩니다.

Map — 키-값 #

map 기본
ages := map[string]int{
	"커티스": 30,
	"앨리스": 25,
}

ages["밥"] = 35           // 추가
fmt.Println(ages["커티스"])   // 30
delete(ages, "앨리스")     // 삭제
fmt.Println(len(ages))     // 2

map[키타입]값타입으로 타입을 적습니다.

만들기 — 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가 표준입니다.

순회 — 순서 없음 #

map 순회
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 합니다.

nil map 함정
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 패턴 #

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으로 만듭니다.

빈 struct로 set
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가 새 배열을 할당하면 원본 슬라이스 헤더는 바뀌지 않습니다.

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와 그에 묶인 메서드, 그리고 포인터 리시버까지 정리합니다.

X