Go의 Map과 concurrent map writes 오류

1. 개요

Map은 slice 다음으로 Go에서 가장 많이 사용되는 자료구조입니다. Map은 여러 goroutine이 동시에 접근할 때 안전하지 않습니다. 여러 goroutine에서 map에 쓰기를 하면, 프로그램이 fatal error: concurrent map writes 메시지와 함께 완전히 중단될 수 있습니다.

이 글에서는 map의 기본 작업을 복습하고, 이 오류가 발생하는 이유, race detector로 감지하는 방법, 그리고 세 가지 해결책(sync.Mutex, sync.RWMutex, sync.Map)을 설명합니다. 예제는 Go 1.22 이상을 사용합니다.

2. Go의 Map: 꼭 알아야 할 것들

2.1. 기본 작업

package main

import "fmt"

func main() {
	stock := map[string]int{"apple": 10, "banana": 5}
	stock["orange"] = 7

	qty, ok := stock["grape"]
	fmt.Println(qty, ok)

	delete(stock, "banana")
	fmt.Println(len(stock))
}

설명:

  • map[string]int{...}는 키가 string 타입이고 값이 int 타입인 map을 생성합니다.
  • stock["grape"]는 키가 존재하지 않을 때 zero value(0)를 반환합니다. ok 변수는 키가 실제로 map에 존재하는지 알려줍니다.
  • delete는 키를 제거합니다. 존재하지 않는 키에 대해 delete를 호출해도 오류가 발생하지 않습니다.
0 false
2

2.2. Nil map

var m map[K]V로 선언하되 초기화하지 않으면 nil map을 얻습니다. nil map에서 읽는 것은 괜찮지만, 쓰면 panic이 발생합니다:

package main

func main() {
	var orders map[string]int
	_ = orders["A-001"] // OK, 0을 반환
	orders["A-001"] = 1 // panic
}
panic: assignment to entry in nil map

이를 고치는 방법은 항상 make(map[string]int) 또는 literal map[string]int{}로 map을 초기화하는 것입니다. 이 오류는 map이 struct의 필드인데 constructor에서 초기화를 잊었을 때 자주 발생합니다.

2.3. Map은 참조 타입이며, 순회 순서는 무작위입니다

map을 다른 변수에 할당하거나 함수로 전달하면, 둘 다 하나의 기본 데이터 구조를 가리킵니다. 따라서 두 goroutine이 “같은 map”을 받으면 실제로 같은 메모리 영역을 공유하게 됩니다. 이것이 다음 섹션의 문제의 근본입니다.

또한 for k, v := range m 순회 순서는 runtime에 의해 의도적으로 뒤섞입니다. 그 순서에 의존하는 코드나 테스트를 작성하면 안 됩니다. 고정된 순서가 필요하면 키를 추출하고, 정렬한 다음 순회하세요.

3. Concurrent map writes 오류

3.1. 오류 재현하기

사용자별 접근 횟수를 세는 경우를 가정해봅시다. 각 요청은 하나의 goroutine에서 실행됩니다:

package main

import (
	"fmt"
	"sync"
)

func main() {
	visits := make(map[string]int)
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				visits[fmt.Sprintf("user-%d", j%10)]++
			}
		}()
	}

	wg.Wait()
	fmt.Println(len(visits))
}

몇 번 실행하면 거의 확실하게 이렇게 볼 것입니다:

fatal error: concurrent map writes

goroutine 23 [running]:
main.main.func1()
	/tmp/visits/main.go:17 +0x...
...
exit status 2

visits[...]++ 라인은 하나의 읽기와 하나의 쓰기로 구성됩니다. 100개의 goroutine이 동시에 이 작업을 수행하면, 여러 goroutine이 map의 내부 구조(bucket, grow 과정)를 동시에 수정할 수 있습니다. Go의 runtime에는 간단한 검사 메커니즘이 있습니다: map은 “쓰기 중” 플래그를 유지합니다. 어떤 goroutine이 쓰기를 시작할 때 이 플래그가 이미 켜져 있는 것을 보면, runtime은 프로그램을 즉시 중단합니다.

한 goroutine이 읽는 동안 다른 goroutine이 쓸 때도 유사한 변형이 있습니다:

fatal error: concurrent map read and map write

3.2. 왜 recover할 수 없는가

많은 사람들이 recover로 코드를 감싸려고 시도합니다:

go func() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()
	visits["user-1"]++
}()

이 방법은 효과가 없습니다. concurrent map writes는 panic이 아닙니다. 이것은 runtime이 호출하는 fatal error이며, 전체 프로세스를 종료합니다. recover는 이를 잡을 수 없습니다. 이 선택은 의도적입니다: map이 손상되면 계속 실행하는 것이 완전히 중단하는 것보다 더 위험합니다.

참고: Runtime의 감지 메커니즘은 “best-effort”일 뿐입니다. 프로그램이 crash하지 않는다고 해서 코드가 안전하다는 뜻은 아닙니다. 두 goroutine이 정확히 충돌하는 순간을 만나지 못했을 수도 있으며, 데이터가 조용히 잘못되었을 수도 있습니다.

3.3. Race detector로 조기에 감지하기

더 신뢰할 수 있는 도구는 race detector로, -race 플래그로 활성화됩니다:

go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000... by goroutine 8:
  runtime.mapaccess1_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...

Previous write at 0x00c000... by goroutine 7:
  runtime.mapassign_faststr()
  main.main.func1()
      /tmp/visits/main.go:17 +0x...
==================

Race detector는 정확히 어떤 라인에서 읽고 쓰기가 충돌하는지를 나타냅니다. CI에서 테스트를 실행할 때 -race를 활성화해야 합니다. -race로 프로그램을 실행하면 더 느리고 메모리를 더 많이 사용하므로, 보통 production 빌드에는 이 플래그를 사용하지 않습니다.

4. 첫 번째 해결책: sync.Mutex

가장 일반적이고 쉬운 방법은 map을 mutex가 있는 struct에 감싸고, method를 통해서만 map에 접근하도록 허용하는 것입니다:

package visits

import (
	"maps"
	"sync"
)

type VisitCounter struct {
	mu     sync.Mutex
	counts map[string]int
}

func NewVisitCounter() *VisitCounter {
	return &VisitCounter{counts: make(map[string]int)}
}

func (c *VisitCounter) Inc(user string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[user]++
}

func (c *VisitCounter) Get(user string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[user]
}

func (c *VisitCounter) Snapshot() map[string]int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return maps.Clone(c.counts)
}

설명:

  • 필드 counts는 소문자이므로 패키지 외부의 코드가 map에 직접 접근할 수 없습니다.
  • Inc와 Get은 모두 mutex를 잠그므로 한 번에 하나의 goroutine만 map에서 작업합니다.
  • Snapshot은 maps.Clone(Go 1.21부터 사용 가능)으로 복사본을 반환합니다. c.counts를 직접 반환하면 호출자가 잠금 없이 map을 순회하게 되어 원래 오류로 돌아갑니다.
  • Constructor NewVisitCounter는 map을 초기화하여 섹션 2.2의 nil map 오류를 피합니다.

4.1. -race로 테스트 작성하기

Go는 JUnit이나 AssertJ를 사용하지 않습니다. 표준 testing 패키지가 이 예제에는 충분합니다:

package visits

import (
	"sync"
	"testing"
)

func TestVisitCounter_ConcurrentInc(t *testing.T) {
	counter := NewVisitCounter()
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter.Inc("alice")
			}
		}()
	}
	wg.Wait()

	if got := counter.Get("alice"); got != 100_000 {
		t.Fatalf("Get(alice) = %d, want 100000", got)
	}
}
go test -race ./...
ok  	example.com/visits	1.214s

테스트는 두 가지를 확인합니다. 첫째, 결과가 정확히 100,000이어야 하므로 증가가 손실되지 않습니다. 둘째, race detector가 DATA RACE를 보고하지 않아야 합니다. Inc에서 c.mu.Lock()을 제거하고 다시 실행해보면 테스트가 즉시 실패할 것입니다.

4.2. Mutex에서 흔한 실수

Mutex를 포함한 struct를 복사합니다. value receiver를 사용하여 method를 선언하면(예: func (c VisitCounter) Inc(...)), 매번 method를 호출할 때마다 mutex의 복사본을 사용합니다. 복사본의 잠금은 아무것도 보호하지 않습니다. 항상 pointer receiver를 사용하세요. go vet는 copylocks 검사를 통해 이 경우를 경고합니다:

./visits.go:18:9: Inc passes lock by value: visits.VisitCounter contains sync.Mutex

잠금을 너무 오래 유지합니다. 잠금을 유지하는 동안 HTTP 호출, 데이터베이스 쿼리 또는 파일 쓰기를 하면 안 됩니다. 필요한 데이터를 가져와서 잠금을 해제한 다음 느린 작업을 수행하세요.

Check-then-act가 분리됩니다. 아래 코드는 두 개의 별도 잠금이 있으므로, 그 사이에 다른 goroutine이 개입할 수 있습니다:

if counter.Get("bob") == 0 {
	counter.Inc("bob") // 다른 goroutine이 먼저 증가했을 수 있습니다
}

“아직 없으면 만들기” 로직이 필요한 경우, 하나의 method로 작성하고 method 전체에서 잠금을 유지하세요.

5. 두 번째 해결책: sync.RWMutex

읽기가 매우 많지만 쓰기가 드문 데이터(예: 구성 캐시 또는 제품 가격표)의 경우, sync.RWMutex를 사용하면 여러 goroutine이 동시에 읽을 수 있습니다:

package pricing

import "sync"

type PriceTable struct {
	mu     sync.RWMutex
	prices map[string]int64
}

func NewPriceTable() *PriceTable {
	return &PriceTable{prices: make(map[string]int64)}
}

func (p *PriceTable) Price(sku string) (int64, bool) {
	p.mu.RLock()
	defer p.mu.RUnlock()
	v, ok := p.prices[sku]
	return v, ok
}

func (p *PriceTable) SetPrice(sku string, cents int64) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.prices[sku] = cents
}

RLock은 여러 goroutine이 동시에 읽을 수 있습니다. Lock은 배타적 잠금입니다: 모든 reader가 완료될 때까지 기다리고 쓰기가 완료될 때까지 새로운 reader를 차단합니다.

참고: RWMutex가 항상 Mutex보다 빠른 것은 아닙니다. 관리 오버헤드가 더 높습니다. 쓰기 비율이 상당하거나 잠금 내 코드가 매우 짧으면 Mutex이 보통 더 빠르거나 같습니다. 변경하기 전에 benchmark로 측정하세요.

흔한 실수: p.mu.RLock()을 호출한 다음 내부에서 RLock도 호출하는 다른 method를 호출합니다. 두 번 사이에 writer가 대기 중이면 프로그램이 deadlock될 수 있습니다. 이유는 RWMutex이 writer가 대기 중일 때 새로운 reader를 차단하기 때문입니다.

6. 세 번째 해결책: sync.Map

sync 패키지에는 sync.Map이 있는데, 이는 직접 잠금을 관리할 필요 없이 concurrency-safe한 map입니다:

package main

import (
	"fmt"
	"sync"
)

type Session struct {
	UserID string
}

func main() {
	var sessions sync.Map

	sessions.Store("token-abc", Session{UserID: "alice"})

	actual, loaded := sessions.LoadOrStore("token-abc", Session{UserID: "bob"})
	fmt.Println(actual.(Session).UserID, loaded)

	if v, ok := sessions.Load("token-abc"); ok {
		fmt.Println(v.(Session).UserID)
	}

	sessions.Range(func(key, value any) bool {
		fmt.Println(key, value.(Session).UserID)
		return true
	})
}
alice true
alice
token-abc alice

설명:

  • Store는 key/value 쌍을 씁니다.
  • LoadOrStore는 원자적 작업입니다. 키가 이미 존재하면 method는 이전 값을 반환하고 loaded = true를 반환하며 덮어쓰지 않습니다. 이것이 sync.Map으로 check-then-act 상황을 처리하는 올바른 방법입니다.
  • Range는 map을 순회합니다. 콜백 함수가 false를 반환하면 조기에 중지합니다.

sync.Map에는 알아야 할 제한 사항이 있습니다:

  • 키와 값의 타입은 any이므로 compile 시 타입 검사를 잃고 곳곳에서 type assertion을 해야 합니다.
  • Len()이 없습니다. 요소 수를 세려면 Range로 순회해야 합니다.
  • sync.Map은 두 가지 특정 경우에 최적화되어 있습니다. 하나는 각 키가 한 번 쓰고 매우 많이 읽는 경우(예: 추가 전용 캐시)입니다. 다른 하나는 goroutine이 겹치지 않는 키 세트에서 읽고 쓸 때입니다.

참고: 위의 두 경우를 제외하고는, 일반적으로 map에 Mutex 또는 RWMutex를 함께 사용하는 것이 더 빠르고, 읽기 쉽고, 타입 안전합니다. 모든 map을 기본적으로 sync.Map으로 바꾸지 마세요.

7. 어떤 해결책을 선택해야 할까요?

상황 권장 선택
읽기/쓰기 혼합, 조건부 로직 map + sync.Mutex
대부분 읽기, 쓰기 드뭄 map + sync.RWMutex (벤치마크 먼저)
추가 전용 캐시, 또는 각 goroutine이 별도의 키 세트 사용 sync.Map
Map이 하나의 goroutine에서만 사용됨 일반 map, 잠금 필요 없음

또 다른 방법이 있습니다: 하나의 goroutine만 map을 소유하고, 다른 goroutine은 channel을 통해 요청을 보냅니다. 이 방법은 map 업데이트가 기존 처리 루프에 연결되어 있을 때 적합합니다. 간단한 카운터의 경우 이 방법은 mutex보다 장황하지만 명확한 이점이 없습니다.

읽기 전용 map에 대한 추가 참고: map이 goroutine이 시작되기 전에 완전히 초기화되고 그 후 누구도 쓰기를 하지 않는다면, 여러 goroutine이 동시에 읽는 것은 안전하며 잠금이 필요하지 않습니다.

8. 결론

Go의 map은 빠르고 편리하지만 concurrency에 안전하지 않습니다. 여러 goroutine이 동시에 쓰거나 읽고 쓸 때, runtime이 fatal error: concurrent map writes로 프로그램을 중단할 수 있습니다. 이 오류는 recover할 수 없습니다. 프로그램이 crash하지 않는다고 해서 코드가 올바르다는 것은 아니므로, go test -race로 테스트를 실행하여 data race를 조기에 감지하세요.

대부분의 경우, map을 sync.Mutex가 있는 struct에 감싸고, pointer receiver를 사용한 method로 접근하며, 원본 map 대신 복사본을 반환하기만 하면 됩니다. 읽기 부하가 정말 많을 때만 sync.RWMutex를 사용하고, 설계된 특정 시나리오에서만 sync.Map을 사용하세요.

golangmapconcurrencygoroutinesync