맵
이 챕터에서 다루는 것
맵(map)은 Go의 유일한 내장 해시 테이블이다. 쓰기는 쉽지만 제약이 셋 있고, 셋 다 입문자를 정확히 한 번씩 물어뜯는다. nil 맵에 쓰면 패닉, 순회 순서 무작위, 요소의 주소를 못 얻음. 이 챕터는 그 셋이 왜 그렇게 설계됐는지까지 같이 본다.
문제 — 제로값이 안전하지 않은 유일한 타입
2-1에서 "제로값이 정의돼 있다"와
"제로값이 안전하다"는 다른 말이라고 했다. 슬라이스는 nil이어도 append가 되고
range가 돈다. 맵은 다르다.
var m map[string]int
m["a"] = 1 // panic: assignment to entry in nil map
2-7의 패닉 예제에서 이미 실물을 봤다. 왜 슬라이스는 되고 맵은 안 되는지부터 정리하자.
append는 값을 반환하는 함수다. nil 슬라이스를 받으면 새 배열을 잡아서 새 헤더를
돌려주면 그만이다. 반면 m["a"] = 1은 대입문이다. 반환값이 없다. 여기서 런타임이
해시 테이블을 새로 만들어 봐야 그 결과를 m에 되돌려 줄 방법이 없다. 조용히 버려질
바에는 패닉이 낫다는 판단이다.
기본
package main
import "fmt"
func main() {
// 리터럴로 만들기
ages := map[string]int{
"영희": 30,
"철수": 25,
}
fmt.Println("len:", len(ages))
// make로 만들기. 두 번째 인자는 힌트일 뿐 길이가 아니다.
scores := make(map[string]int, 8)
fmt.Println("make 직후 len:", len(scores))
scores["국어"] = 90
scores["수학"] = 80
// 없는 키를 읽으면 에러가 아니라 값 타입의 제로값이 나온다.
fmt.Println("없는 키:", scores["과학"])
// comma-ok로 "없음"과 "0"을 구분한다.
if v, ok := scores["과학"]; ok {
fmt.Println("과학:", v)
} else {
fmt.Println("과학 점수 없음 (v =", v, ")")
}
scores["과학"] = 0
if v, ok := scores["과학"]; ok {
fmt.Println("이제 있음, 값 =", v)
}
// delete는 없는 키에도 안전하다.
delete(scores, "국어")
delete(scores, "없는키")
fmt.Println("delete 후 len:", len(scores))
// clear는 전부 지운다. 맵 자체는 그대로 쓸 수 있다.
clear(scores)
fmt.Println("clear 후 len:", len(scores))
// 맵은 참조처럼 동작한다. 복사해도 같은 해시 테이블을 가리킨다.
alias := ages
alias["영희"] = 31
fmt.Println("ages:", ages["영희"])
}
go run ./03-map-basics
len: 2
make 직후 len: 0
없는 키: 0
과학 점수 없음 (v = 0 )
이제 있음, 값 = 0
delete 후 len: 2
clear 후 len: 0
ages: 31
읽어야 할 것
make(map[string]int, 8)의 8은 길이가 아니라 힌트다. 슬라이스의 make([]T, n)과
헷갈리기 쉽다. 맵에는 "길이 n짜리 맵"이라는 개념이 없다. 8은 "대략 이만큼 들어갈
예정이니 미리 자리를 잡아 두라"는 성능 힌트일 뿐이고, len은 여전히 0이다.
없는 키를 읽는 것은 에러가 아니다. 값 타입의 제로값이 나온다. Python의 KeyError나
Java의 null과 다르다. 편하지만 위험하기도 하다 — scores["과학"]이 0을 줬을 때
"0점"인지 "안 봤음"인지 구분이 안 된다.
그래서 comma-ok가 있다. v, ok := m[k]에서 ok는 키의 존재 여부다. 위 출력의
4번째 줄과 5번째 줄이 그 차이다. 둘 다 값은 0인데 ok만 다르다.
if v, ok := m[k]; ok { ... } 형태로 쓰면 v의 스코프가 if 블록으로 좁혀진다.
2-4에서 본 초기화문의 대표적 쓰임이다.
delete는 없는 키에도 안전하다. 존재 확인을 먼저 할 필요가 없다.
clear(m)은 맵의 모든 항목을 지운다. 슬라이스의 clear(제로값 채우기)와 의미가
다르다는 점에 주의하자. 같은 내장 함수인데 대상 타입에 따라 동작이 갈린다.
맵 값을 복사해도 같은 테이블을 가리킨다. alias := ages 후 alias를 고치면 ages도
바뀐다. 슬라이스 헤더와 같은 이야기다 — 맵 값 자체는 런타임 해시 테이블을 가리키는
포인터 하나이고(3-4의 크기 측정에서 8바이트로 확인한다),
복사되는 것은 그 포인터다.
:::info 맵도 값으로 전달된다
"맵은 참조 타입"이라는 설명을 자주 보지만, Go에 참조 전달은 없다. 맵 값(포인터 하나)이
복사되는 것이고, 복사본이 같은 테이블을 가리킬 뿐이다. 그래서 함수 안에서 항목을 넣고
빼는 것은 호출자에게 보이지만, m = make(...)처럼 맵 자체를 갈아 끼우는 것은
보이지 않는다. 슬라이스의 append와 완전히 같은 구도다.
:::
키가 될 수 있는 타입
키는 ==로 비교 가능한 타입이어야 한다. 숫자, 문자열, bool, 포인터, 채널,
그리고 비교 가능한 필드만 가진 구조체와 배열이 된다. 슬라이스·맵·함수는 안 된다.
map[[2]int]string // 배열 키 — 된다
map[Point]string // 비교 가능한 구조체 키 — 된다 (3-4에서)
map[[]int]string // 컴파일 에러: invalid map key type []int
float64도 키가 되긴 하지만 쓰지 말자. NaN != NaN이라 m[math.NaN()] = 1을 여러 번
하면 항목이 계속 늘어나고, 넣은 값을 다시 찾을 수도 없다.
nil 맵
package main
import "fmt"
type Report struct {
Title string
Counts map[string]int // 초기화하지 않으면 nil이다
}
func main() {
var m map[string]int
fmt.Println("m == nil:", m == nil, "len:", len(m))
// 읽기는 안전하다. 제로값이 나온다.
fmt.Println("읽기:", m["없음"])
// comma-ok도 안전하다.
v, ok := m["없음"]
fmt.Println("comma-ok:", v, ok)
// range도 안전하다. 0번 돈다.
count := 0
for range m {
count++
}
fmt.Println("range 횟수:", count)
// delete도 안전하다.
delete(m, "없음")
fmt.Println("delete 통과")
// 쓰기만 패닉이다.
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("쓰기 패닉:", rec)
}
}()
m["있음"] = 1
}()
// 구조체 안의 맵 필드가 특히 자주 걸린다.
r := Report{Title: "월간"}
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("구조체 필드 패닉:", rec)
}
}()
r.Counts["방문"]++
}()
// 고치는 법: 만드는 시점에 함께 만든다.
ok2 := Report{Title: "월간", Counts: make(map[string]int)}
ok2.Counts["방문"]++
fmt.Println("초기화 후:", ok2.Counts)
}
go run ./03-nil-map
m == nil: true len: 0
읽기: 0
comma-ok: 0 false
range 횟수: 0
delete 통과
쓰기 패닉: assignment to entry in nil map
구조체 필드 패닉: assignment to entry in nil map
초기화 후: map[방문:1]
읽기·len·range·delete는 전부 안전하다. 오직 쓰기만 패닉이다. 이 비대칭이
버그를 늦게 드러나게 만든다. 조회만 하는 코드 경로에서는 몇 달이고 멀쩡히 돌다가,
어느 날 쓰기가 추가되는 순간 터진다.
가장 흔한 경로는 구조체 필드다. Report{Title: "월간"}처럼 맵 필드를 빼먹고
리터럴을 쓰면 그 필드는 nil이다. 컴파일러는 아무 말도 안 한다.
막는 법
-
생성자 함수를 둔다. 구조체에 맵 필드가 있으면
newReport()를 만들고 거기서make한다. 3-7의 재고 관리 실습이 이 형태다. -
함수가 맵을 반환할 때는
nil을 반환하지 않는다. 호출자가 거기에 쓸 수 있다. 빈 맵이라도make해서 돌려준다. -
nil을 받을 수도 있는 자리라면 쓰기 직전에 만든다.if r.Counts == nil {r.Counts = make(map[string]int)}r.Counts["방문"]++
순회 순서는 무작위다
2-4에서 여섯 번 실행 결과가 제각각인 것을 봤다. 여기서 정렬된 출력을 만드는 법까지 마무리한다.
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
stock := map[string]int{
"apple": 3,
"banana": 7,
"cherry": 1,
"durian": 9,
"elder": 5,
}
// 그냥 순회하면 순서가 실행마다 다르다.
fmt.Print("무작위: ")
for k, v := range stock {
fmt.Printf("%s=%d ", k, v)
}
fmt.Println()
// 키를 모아 정렬한 뒤 그 순서로 읽는다.
keys := make([]string, 0, len(stock))
for k := range stock {
keys = append(keys, k)
}
slices.Sort(keys)
fmt.Print("정렬: ")
for _, k := range keys {
fmt.Printf("%s=%d ", k, stock[k])
}
fmt.Println()
// 같은 일을 한 줄로 쓰는 방법
fmt.Print("한 줄: ")
for _, k := range slices.Sorted(maps.Keys(stock)) {
fmt.Printf("%s=%d ", k, stock[k])
}
fmt.Println()
// 값 기준 내림차순으로 정렬하고 싶다면 키 슬라이스를 그렇게 정렬한다.
byCount := slices.Sorted(maps.Keys(stock))
slices.SortFunc(byCount, func(a, b string) int {
return stock[b] - stock[a]
})
fmt.Print("값 내림: ")
for _, k := range byCount {
fmt.Printf("%s=%d ", k, stock[k])
}
fmt.Println()
// fmt는 맵을 출력할 때만은 키 순으로 정렬해 준다.
fmt.Println("fmt:", stock)
}
go run ./03-map-order
무작위: apple=3 banana=7 cherry=1 durian=9 elder=5
정렬: apple=3 banana=7 cherry=1 durian=9 elder=5
한 줄: apple=3 banana=7 cherry=1 durian=9 elder=5
값 내림: durian=9 banana=7 elder=5 apple=3 cherry=1
fmt: map[apple:3 banana:7 cherry:1 durian:9 elder:5]
:::warning 첫 줄은 이번 실행에서만 그렇다 같은 바이너리를 여섯 번 돌린 첫 줄의 결과는 이랬다.
무작위: apple=3 banana=7 cherry=1 durian=9 elder=5
무작위: apple=3 banana=7 cherry=1 durian=9 elder=5
무작위: durian=9 elder=5 apple=3 banana=7 cherry=1
무작위: apple=3 banana=7 cherry=1 durian=9 elder=5
무작위: apple=3 banana=7 cherry=1 durian=9 elder=5
무작위: apple=3 banana=7 cherry=1 durian=9 elder=5
정렬돼 보인다고 정렬된 것이 아니다. 항목 수가 적으면 우연히 삽입 순이나 사전순으로 나오는 일이 잦고, 그래서 개발 중에는 멀쩡하다가 데이터가 늘면 깨진다. 무작위화는 런타임이 순회 시작 지점을 매번 다르게 잡아서 생긴다. :::
왜 일부러 무작위로 만드는가
해시 테이블의 순회 순서는 원래 정의되지 않는다. 하지만 구현이 고정돼 있으면 사람들은 관찰된 순서에 의존하는 코드를 쓰게 된다. 그리고 나중에 런타임이 해시 알고리즘이나 버킷 구조를 바꾸는 순간 그 코드들이 전부 깨진다. Go 팀은 그런 의존이 애초에 생기지 않도록 처음부터 무작위화를 넣었다. 예방 접종에 가깝다.
정렬 출력의 세 형태
// 1. 직접 모아서 정렬 — 무슨 일이 일어나는지 가장 명확하다
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
// 2. 한 줄로
for _, k := range slices.Sorted(maps.Keys(m)) { ... }
// 3. 값 기준 정렬 — 키 슬라이스를 값으로 비교해 정렬한다
slices.SortFunc(keys, func(a, b string) int { return m[b] - m[a] })
2번의 maps.Keys(m)는 키를 하나씩 내주는 이터레이터를 돌려주고, slices.Sorted가 그걸
받아 정렬된 슬라이스로 만든다. 이터레이터 자체는 Part 5에서 다루지만, 이 조합은
그냥 관용구로 외워 써도 된다.
3번에서 stock[b] - stock[a]처럼 뺄셈으로 비교 함수를 쓰는 것은 값이 작을 때만
안전하다. 큰 정수라면 오버플로가 난다
(2-2의 순환 참고). 일반적으로는
cmp.Compare(stock[b], stock[a])가 안전하다.
fmt는 예외적으로 정렬해 준다. fmt.Println(m)은 키 순으로 출력한다. 디버깅
출력이 안정적이라 편하지만, 이걸 보고 "순회도 정렬돼 있겠지"라고 오해하면 안 된다.
맵 요소의 주소는 얻을 수 없다
m := map[string]int{"a": 1}
p := &m["a"]
// 컴파일 에러:
// invalid operation: cannot take address of m["a"] (map index expression of type int)
이유는 해시 테이블이 자란다는 것이다. 항목이 늘어나면 런타임은 더 큰 테이블을
할당하고 기존 항목을 옮긴다. 주소를 미리 내줬다면 그 주소는 이사 후에 엉뚱한 곳을
가리키게 된다. 슬라이스는 재할당 시점이 append 호출로 눈에 보이지만, 맵은 아무 대입에서나
일어날 수 있다. 그래서 언어 차원에서 주소 획득 자체를 막았다.
이 제약이 실제로 어떻게 나타나는지 보자.
package main
import "fmt"
type Counter struct {
Hits int
Label string
}
func main() {
// 값이 구조체인 맵: 필드를 직접 고칠 수 없다.
byValue := map[string]Counter{
"home": {Hits: 1, Label: "홈"},
}
// byValue["home"].Hits++ 는 컴파일 에러다.
// 꺼내서 → 고치고 → 다시 넣는다.
c := byValue["home"]
c.Hits++
byValue["home"] = c
fmt.Println("값 맵:", byValue)
// 값이 포인터면 바로 고칠 수 있다.
byPointer := map[string]*Counter{
"home": {Hits: 1, Label: "홈"},
}
byPointer["home"].Hits++
fmt.Println("포인터 맵:", *byPointer["home"])
// 단, 없는 키를 포인터 맵에서 읽으면 nil이 나온다. 역참조하면 패닉이다.
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Println("없는 키 역참조:", rec)
}
}()
byPointer["about"].Hits++
}()
// 값이 슬라이스면 append 결과를 되넣어야 한다.
tags := map[string][]string{}
tags["go"] = append(tags["go"], "fast")
tags["go"] = append(tags["go"], "boring")
fmt.Println("슬라이스 값:", tags)
// 값이 숫자나 문자열이면 복합 대입 연산자는 그냥 된다.
hits := map[string]int{}
hits["home"]++
hits["home"] += 10
fmt.Println("숫자 값:", hits)
}
go run ./03-map-struct
값 맵: map[home:{2 홈}]
포인터 맵: {2 홈}
없는 키 역참조: runtime error: invalid memory address or nil pointer dereference
슬라이스 값: map[go:[fast boring]]
숫자 값: map[home:11]
값이 구조체일 때
byValue["home"].Hits++는 컴파일 에러다
(cannot assign to struct field byValue["home"].Hits in map). 필드를 고치려면 주소가
필요한데 맵 요소의 주소를 못 얻으니 당연한 귀결이다. 답은 꺼내서 → 고치고 → 다시
넣기 세 줄이다.
hits["home"]++가 되는 것과 대조된다. 요소 전체를 읽고 쓰는 것은 주소 없이도
가능하다. 안 되는 것은 요소의 일부를 고치는 것뿐이다.
값이 포인터일 때
map[string]*Counter로 두면 byPointer["home"].Hits++가 된다. 맵이 돌려주는 것은
포인터 값(복사본)이고, 그 포인터가 가리키는 구조체는 맵 바깥의 힙에 있어서 이사와
무관하기 때문이다.
대신 없는 키는 nil 포인터를 준다. byPointer["about"].Hits++가 그대로 패닉으로
간다. 값 맵이었다면 제로값 구조체가 나와서 조용히 넘어갔을 자리다.
어느 쪽을 쓸 것인가.
값 맵 map[K]V | 포인터 맵 map[K]*V | |
|---|---|---|
| 필드 수정 | 꺼내서-고치고-넣기 | 바로 가능 |
| 없는 키 | 제로값 구조체 | nil — 역참조하면 패닉 |
| 복사 | 조회할 때마다 구조체 전체 | 포인터 8바이트 |
| 공유 | 없음 (꺼낸 값은 독립) | 있음 (여러 곳이 같은 것을 본다) |
구조체가 작고 수정이 드물면 값 맵, 구조체가 크거나 제자리 수정이 잦으면 포인터 맵이다.
포인터 맵을 쓸 때는 조회 결과가 nil인지 반드시 확인해야 한다. 판단 기준 전반은
3-5에서 정리한다.
값이 슬라이스일 때
tags["go"] = append(tags["go"], "fast")
tags["go"]가 없으면 nil 슬라이스가 나오고, append는 nil에 안전하다. 그래서
초기화 없이 바로 이 한 줄이 동작한다. 단 append 결과를 반드시 되넣어야 한다.
append(tags["go"], "fast")만 쓰면 컴파일 에러이거나(결과 미사용) 아무 일도 안 난다.
흔히 하는 실수
1. 구조체 필드의 맵을 초기화하지 않는다
이 챕터의 주인공이다. 맵 필드가 있는 구조체는 생성자 함수를 두자.
2. 없는 키의 제로값과 진짜 0을 구분하지 않는다
if scores[name] == 0 {
// "0점"인가 "명단에 없음"인가?
}
v, ok := scores[name]을 쓴다. 특히 map[string]bool이 위험하다 — false가 "없음"인지
"false로 넣어 둔 것"인지 알 수 없다.
3. 순회 순서에 의존한다
테스트에서 맵을 순회해 문자열을 만들고 기대값과 비교하는 코드는 CI에서 무작위로 실패한다. 키를 정렬하거나, 순서와 무관하게 비교한다.
4. 순회 중에 항목을 추가한다
순회 중 delete는 명세가 보장한다 — 아직 안 나온 키를 지우면 그 키는 안 나온다.
하지만 추가는 명세가 정의하지 않는다. 그 키가 나올 수도, 안 나올 수도 있다.
추가할 것을 따로 모았다가 순회가 끝난 뒤 넣는다.
5. 여러 고루틴에서 동시에 쓴다
Go의 맵은 동시 쓰기에 안전하지 않고, 런타임이 이를 감지해서 프로그램을 죽인다
(fatal error: concurrent map writes). 이건 recover로도 못 잡는다. Part 7에서
sync.Mutex와 sync.Map을 다룬다.
6. map[K]V의 V를 큰 구조체로 두고 자주 조회한다
조회할 때마다 구조체 전체가 복사된다. 필드가 수십 개라면 포인터 맵을 고려한다.
정리
- 맵은
make또는 리터럴로 만든다.make의 두 번째 인자는 길이가 아니라 힌트다. - 없는 키를 읽으면 제로값.
v, ok := m[k]로 존재 여부를 구분한다. delete는 없는 키에도 안전.clear(m)은 전부 삭제(슬라이스의clear와 다르다).nil맵은 읽기·len·range·delete가 전부 안전하고 쓰기만 패닉이다. 구조체 필드에서 특히 자주 터진다. 생성자 함수로 막는다.- 순회 순서는 의도적으로 무작위다. 정렬 출력은
slices.Sorted(maps.Keys(m)). - 맵 요소의 주소는 못 얻는다. 테이블이 자라면서 항목이 이사하기 때문이다.
- 그래서 값이 구조체면 필드를 직접 못 고친다. 꺼내서-고치고-넣거나 포인터 맵을 쓴다.
- 포인터 맵은 없는 키에
nil을 준다. 역참조 전에 확인한다.
연습문제
-
문자열 슬라이스를 받아 단어별 빈도를 세는
count(words []string) map[string]int를 쓰고, 결과를 빈도 내림차순, 같으면 사전순으로 출력해 보자. 힌트: 비교 함수 안에서 두 기준을 순서대로 본다.cmp.Compare가 도움이 된다. -
map[string]Counter(값 맵)를 쓰는 코드와map[string]*Counter(포인터 맵)를 쓰는 코드를 각각 작성하고, "없는 키를 증가시키려 할 때" 두 코드가 어떻게 다르게 실패하거나 성공하는지 비교해 보자. 어느 쪽 실패가 발견하기 쉬운가? -
맵 필드를 가진 구조체를 정의하고, 생성자 없이 쓰다가 패닉을 내 보자. 그다음 생성자를 도입해 같은 실수를 구조적으로 불가능하게 만들어 보자. 생성자만으로 충분한가? 아니라면 무엇이 더 필요한가?