12장. 맵
11장의 슬라이스는 “인덱스 번호로 값을 찾는” 자료구조였다. 하지만 실제 프로그램에서는 인덱스가 아니라 “이름” 이나 “ID” 같은 키로 값을 찾고 싶을 때가 훨씬 많다.
- 사용자 이름 → 사용자 정보
- 상품 코드 → 가격
- 단어 → 등장 횟수
이런 경우에 쓰는 자료구조가 맵 (map) 이다. 다른 언어에서는 사전 (dictionary), 해시맵 (HashMap), 연관 배열 (associative array) 같은 이름으로도 불린다.
목표:
- 맵을 선언하고 값을 넣고 꺼내기
- 키가 있는지 안전하게 확인하기
- 맵을 순회하기
- nil 맵의 함정 피하기
12.1 맵이란
맵은 키-값 쌍을 모아 두는 자료구조다.
"alice" ──▶ 30
"bob" ──▶ 25
"carol" ──▶ 42
키를 던지면 해당하는 값이 빠르게 돌아온다. 값을 찾는 데 드는 시간은 맵의 크기와 거의 무관하다. 원소가 백 개든 백만 개든 비슷한 속도라는 뜻이다.
키와 값의 타입
타입은 두 개를 지정한다.
- 키 타입 — 무엇으로 찾을지
- 값 타입 — 무엇이 저장되는지
map[string]int
// └ 키 └ 값
위 타입은 “문자열 키로 정수 값을 찾는 맵” 이다.
키 타입의 조건
키는 아무 타입이나 될 수 없다. 비교 가능한 타입 만 키가 될 수 있다.
가능한 키 타입의 예:
stringint,float64같은 숫자 타입bool- 비교 가능한 필드들로만 이뤄진 구조체
불가능한 키 타입의 예:
- 슬라이스
[]int - 맵 자체
- 함수
“비교 가능” 이란
==연산자를 쓸 수 있다는 뜻이다. 슬라이스는==로 비교할 수 없기 때문에 키가 될 수 없다. 구조체 비교에 대해선 13장에서 자세히 다룬다.
12.2 선언과 초기화
세 가지 방법을 차례로 본다.
1. var 로 선언만 — nil 맵
var m map[string]int
fmt.Println(m == nil) // true
이 상태의 맵은 nil 이다.
읽기는 가능하지만 쓰기는 패닉을 일으킨다.
fmt.Println(m["a"]) // 0 (제로값, 안전)
m["a"] = 1 // panic: assignment to entry in nil map
초보가 가장 흔히 만나는 함정이라 12.6 절에서 따로 강조한다.
2. make 로 만들기
비어 있는 사용 가능한 맵을 만들 땐 make 를 쓴다.
m := make(map[string]int)
m["alice"] = 30
m["bob"] = 25
fmt.Println(m) // map[alice:30 bob:25]
이 시점부터 m 은 쓸 준비가 끝난 상태다.
nil 이 아니다.
3. 맵 리터럴
처음부터 값을 채워서 만들 수도 있다.
ages := map[string]int{
"alice": 30,
"bob": 25,
"carol": 42,
}
각 줄 끝에 쉼표가 필요하다. 마지막 항목 뒤에도 쉼표를 붙여야 한다 (gofmt 가 강제한다).
빈 맵도 리터럴로 만들 수 있다.
m := map[string]int{}
make(map[string]int) 과 사실상 같은 효과다.
| 방법 | 결과 |
|---|---|
var m map[string]int | nil 맵 (쓰기 불가) |
m := make(map[string]int) | 사용 가능한 빈 맵 |
m := map[string]int{} | 사용 가능한 빈 맵 |
m := map[string]int{"a":1} | 초기 데이터 있는 맵 |
12.3 값 추가, 조회, 삭제
기본 세 가지 동작은 모두 한 줄로 끝난다.
추가와 수정
m := make(map[string]int)
m["alice"] = 30 // 추가
m["alice"] = 31 // 수정
키가 없으면 새로 만들고, 있으면 덮어쓴다. “있는지 확인 후 추가” 같은 분기를 따로 할 필요가 없다.
조회
age := m["alice"]
fmt.Println(age) // 31
여기에 한 가지 의외의 동작이 있다.
없는 키를 조회하면?
에러가 나지 않는다. 대신 값 타입의 제로값 이 돌아온다.
m := map[string]int{"alice": 30}
fmt.Println(m["unknown"]) // 0
값 타입이 int 라서 0 이 돌아왔다.
값 타입이 string 이면 빈 문자열 "" 이 돌아온다.
이건 편리하지만 위험할 수도 있다. “진짜로 0 이 저장돼 있는 키” 와 “키 자체가 없는 경우” 를 구별하지 못하기 때문이다.
그래서 다음 절의 v, ok 형태가 필요해진다.
삭제
내장 함수 delete 를 쓴다.
delete(m, "alice")
키가 없어도 에러가 나지 않는다. “있으면 지우고, 없으면 그냥 두라” 는 안전한 동작이다.
12.4 키 존재 여부 확인
값 조회에 두 번째 반환값을 받으면 키가 실제로 있었는지 확인할 수 있다.
v, ok := m["alice"]
v— 값 (없으면 제로값)ok— 키가 실제로 있었는지 (true/false)
흔한 패턴
if v, ok := m["alice"]; ok {
fmt.Println("alice 의 나이는", v)
} else {
fmt.Println("alice 정보가 없습니다")
}
8장에서 본 if 의 짧은 선언 형식과 결합되어
한 줄에 “조회 + 존재 확인 + 분기” 가 모두 들어간다.
Go 코드에서 매우 자주 보게 되는 모양이다.
v 와 ok 의 조합 정리
| 상황 | v | ok |
|---|---|---|
| 키가 있고 값이 30 | 30 | true |
| 키가 있고 값이 0 | 0 | true |
| 키가 없음 | 0 (제로값) | false |
값으로 0 이 들어 있는 경우와 키가 없는 경우를 구별할 수 있다는 게 핵심이다.
12.5 맵 순회 (for range)
11장의 for range 가 여기서 또 등장한다.
맵에서는 인덱스 대신 키가 나온다.
ages := map[string]int{
"alice": 30,
"bob": 25,
"carol": 42,
}
for k, v := range ages {
fmt.Println(k, v)
}
k 가 키, v 가 값이다.
키만, 값만
키만 필요하면 두 번째 변수를 빼면 된다.
for k := range ages {
fmt.Println(k)
}
값만 필요하면 _ 를 쓴다.
for _, v := range ages {
fmt.Println(v)
}
순회 순서는 매번 다르다
여기서 처음 보면 당황하는 특성이 있다.
for k, v := range ages {
fmt.Println(k, v)
}
이 코드를 같은 프로그램에서 두 번 실행해도 출력 순서가 다를 수 있다. 실행할 때마다 일부러 섞기 때문이다.
// 1회차
bob 25
alice 30
carol 42
// 2회차
carol 42
bob 25
alice 30
이는 버그가 아니라 의도된 동작이다. 개발자가 “맵의 순서에 의존하지 않게” 만들기 위한 장치다.
왜 일부러 섞는지, 내부 구조와 함께 자세히 보려면 19장에서 다룬다. 지금은 “맵은 순서가 없다” 정도로 받아들이면 충분하다.
정렬된 순서로 순회하고 싶다면
키를 슬라이스로 모아 정렬한 뒤 순회하면 된다.
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])
}
11장의 make([]T, 0, cap) 패턴이 자연스럽게 쓰인다.
미리 용량을 잡아 두면 append 가 재할당하지 않는다.
12.6 맵의 함정
세 가지만 기억하면 큰 사고는 막을 수 있다.
1. nil 맵에 쓰기 → 패닉
12.2 절에서 본 그 이야기다. 선언만 한 맵은 쓸 수 없다.
var m map[string]int
m["a"] = 1 // panic: assignment to entry in nil map
해결책은 단순하다.
m := make(map[string]int)
m["a"] = 1 // OK
슬라이스의 nil 은
append가 가능했지만, 맵의 nil 은 쓰기가 불가능하다. 이 비대칭은 외워 두는 편이 빠르다.
2. 동시 접근은 안전하지 않다
여러 고루틴이 같은 맵을 동시에 읽고 쓰면 프로그램이 갑자기 죽거나 데이터가 깨질 수 있다.
// 위험: 여러 고루틴이 동시에 m 에 쓰기
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
고루틴, 동시성, 그리고 안전하게 공유하는 방법은 22장부터 자세히 다룬다. 19장에서도 맵 내부 구조와 함께 다시 짚는다. 지금은 “혼자 쓰는 맵은 안심해도 되고, 여럿이 동시에 건드릴 거면 보호 장치가 필요하다” 정도로 기억해 둔다.
3. 맵 안의 값은 직접 수정 못 한다
이건 좀 미묘한 함정이다. 맵의 값이 구조체일 때 그 안의 필드를 바로 못 바꾼다.
type Point struct {
X, Y int
}
m := map[string]Point{
"a": {1, 2},
}
m["a"].X = 99 // 컴파일 에러
이유는 14~15장의 포인터와 메서드를 배운 뒤에야 완전히 와닿는다. 지금은 우회 방법만 알아 두면 된다.
p := m["a"]
p.X = 99
m["a"] = p
값을 통째로 꺼내 수정하고 다시 넣는 패턴이다.
12.7 정리
이 장에서 살펴본 내용:
- 맵은 키-값을 모아 두는 자료구조다
- 키는 비교 가능한 타입이어야 한다
var m ...으로 만든 맵은 nil 이라 쓰기 불가make또는 리터럴로 만들어야 안전
- 없는 키 조회는 에러 대신 제로값을 반환한다
v, ok := m[key]로 키 존재 여부를 확인한다delete로 키를 지울 수 있다 (없어도 안전)for range로 순회할 때 순서는 매번 달라진다- 동시 접근은 따로 보호해야 한다
슬라이스와 맵. 이 두 자료구조만 익숙해져도 대부분의 일상 코드는 작성할 수 있다.
다음 장에서는 여러 값을 하나의 묶음으로 다루는 구조체 (struct) 를 본다. “사용자 한 명” 같은 단위 데이터를 표현하는 핵심 도구다.