본문으로 건너뛰기

sync 패키지

이 챕터에서 다루는 것

7-2의 슬로건 — "통신해서 메모리를 공유하라" — 은 채널만 쓰라는 뜻이 아니다. 표준 라이브러리 자신이 sync를 널리 쓰고, Go 팀의 코드 리뷰 지침도 참조 카운트처럼 단순한 공유 상태에는 뮤텍스가 낫다고 말한다.

이 챕터는 언제 sync를 쓰는지, 그리고 각 도구의 함정을 다룬다.

채널이냐 뮤텍스냐

판단 기준은 하나로 요약된다.

값의 "소유권"이 옮겨 다니면 채널, 상태를 "제자리에서" 보호하면 뮤텍스.

상황도구
작업을 넘긴다, 파이프라인채널
여러 소스를 기다린다select
캐시, 카운터, 설정 — 제자리에서 읽고 쓴다Mutex / RWMutex
단일 정수/불린/포인터atomic
초기화 한 번만Once
"전부 끝날 때까지"WaitGroup

7-3tracker가 채널로 카운터를 만드는 예였다. 읽기 쉬웠지만 덧셈 하나에 고루틴 전환 두 번이 든다. 진짜 카운터라면 atomic.Int64 한 줄이 맞다.

Mutex — 임계 구역

var mu sync.Mutex
mu.Lock()
// 임계 구역
mu.Unlock()

sync.Mutex의 제로값은 바로 쓸 수 있는 잠금 해제 상태다. make도 생성자도 없다.

가장 중요한 관례는 뮤텍스와 그것이 지키는 데이터를 붙여 두는 것이다.

examples/07-concurrency/05-mutex/main.go
// Counter는 여러 고루틴이 동시에 건드리는 카운터다.
// 뮤텍스와 그것이 지키는 데이터를 같은 구조체에, 바로 위아래로 둔다.
type Counter struct {
mu sync.Mutex
n int
}

// 반드시 포인터 리시버여야 한다.
// 값 리시버로 쓰면 복사본을 잠그는 셈이고, go vet이 copylocks로 잡는다.
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}

func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}

mu가 무엇을 지키는지 코드 배치로 말한다. 필드가 많다면 주석을 단다.

type Server struct {
addr string // 불변 — 잠금 불필요

mu sync.Mutex // 아래 두 필드를 지킨다
conns map[string]*Conn
count int
}

:::tip defer mu.Unlock()을 기본으로 쓴다 Lock() 바로 다음 줄에 defer mu.Unlock()을 쓰면 어떤 경로로 반환하든, 패닉이 나든 잠금이 풀린다. 잠금을 안 푼 채 반환하면 그 뮤텍스를 기다리는 모든 고루틴이 영구히 멈춘다 — 데드락 감지에도 안 걸리는 종류의 정지다.

defer 비용이 문제가 되는 극단적인 핫 루프에서만 손으로 Unlock하고, 그때는 반환 경로를 하나로 유지한다. :::

RWMutex — 읽기가 압도적으로 많을 때

RWMutex는 잠금을 두 종류로 나눈다.

  • RLock() / RUnlock()읽기 잠금. 여러 개가 동시에 잡을 수 있다.
  • Lock() / Unlock() — 쓰기 잠금. 배타적이다.
examples/07-concurrency/05-mutex/main.go
// Registry는 읽기가 압도적으로 많은 맵이다. RWMutex가 맞는 자리다.
type Registry struct {
mu sync.RWMutex
m map[string]int
}

func NewRegistry() *Registry {
return &Registry{m: make(map[string]int)}
}

func (r *Registry) Set(k string, v int) {
r.mu.Lock()
defer r.mu.Unlock()
r.m[k] = v
}

func (r *Registry) Get(k string) (int, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
v, ok := r.m[k]
return v, ok
}

// Keys는 내부 맵을 밖으로 노출하지 않는다.
// 맵을 그대로 반환하면 잠금 밖에서 읽히므로 경합이 된다.
func (r *Registry) Keys() []string {
r.mu.RLock()
defer r.mu.RUnlock()
return slices.Sorted(maps.Keys(r.m))
}

Keys가 중요하다. 잠금 안에서 만든 새 슬라이스를 반환한다. 내부 맵을 그대로 반환하면 호출자가 잠금 없이 순회하게 되고, 그 사이에 Set이 일어나면 경합이다. 6-5의 "구조체 필드를 함부로 공개하지 않는다"가 동시성 맥락에서 강제 규칙이 된다.

cd examples/07-concurrency
go run ./05-mutex
카운터: 100000 (기대값 100000)
키: [key0 key1 key2 key3 key4]
리더 4명이 계산한 합: [30 30 30 30]

고루틴 100개가 각각 1,000번 Inc했고 정확히 100,000이 나왔다. 뮤텍스가 없으면 이 숫자는 매번 달라진다 — 7-6에서 실제로 확인한다.

:::warning RWMutex가 항상 빠른 것은 아니다 RWMutexMutex보다 무겁다. 읽기 잠금 자체에도 원자적 연산이 들어가고, 쓰기 대기 중에는 새 읽기를 막는 로직이 추가된다.

임계 구역이 짧으면(맵 조회 하나 정도) Mutex가 더 빠른 경우가 흔하다. 읽기가 쓰기보다 압도적으로 많고 + 임계 구역이 충분히 길 때만 이득이 난다. 확신이 없으면 Mutex로 시작하고, 파트 8에서 배울 벤치마크로 재고 나서 바꾼다. :::

잠금은 복사하면 안 된다

sync.Mutex는 내부에 상태를 가진 구조체다. 복사하면 잠금 상태까지 복사되고, 두 사본이 서로 다른 잠금이 된다. 한쪽을 잠가도 다른 쪽은 열려 있다.

go vetcopylocks 분석기가 이것을 잡는다. 아래는 일부러 틀리게 쓴 코드다 (예제 디렉터리에는 넣지 않았다 — go vet ./...이 깨지면 안 되므로).

type Counter struct {
mu sync.Mutex
n int
}

func (c Counter) Bad() int { // 값 리시버 — 복사본을 잠근다
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}

func show(c Counter) { fmt.Println(c.n) } // 인자로 복사

func main() {
var c Counter
show(c) // 복사가 일어나는 지점
fmt.Println(c.Bad())
}
main.go:13:9: Bad passes lock by value: vetchk.Counter contains sync.Mutex
main.go:19:13: show passes lock by value: vetchk.Counter contains sync.Mutex
main.go:23:7: call of show copies lock value: vetchk.Counter contains sync.Mutex

규칙: 뮤텍스를 담은 타입은 항상 포인터로 다룬다. 리시버도, 파라미터도, 반환값도 포인터다. WaitGroup, Once, RWMutex, atomic.* 전부 같다.

:::note go vet은 무료다 1-6에서 본 대로 go test는 자동으로 일부 vet 검사를 돌린다. 동시성 코드에서는 copylockswaitgroup 두 분석기가 특히 값을 한다. CI에서 go vet ./...을 돌리지 않을 이유가 없다. :::

WaitGroup — wg.Go를 기본으로

var wg sync.WaitGroup
wg.Go(task1)
wg.Go(task2)
wg.Wait()

wg.Go(f)고루틴을 띄우고 카운터 증감을 함께 처리한다. 이것이 현재의 기본형이다.

기존 코드에서 훨씬 자주 보게 될 형태는 이쪽이다.

var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
task1()
}()
wg.Wait()

읽을 줄은 알아야 하지만 새로 쓸 이유는 없다. go fixwaitgroup 모더나이저가 변환해 준다.

Add/Done을 손으로 쓸 때의 대표적 실수는 Add를 고루틴 안에서 부르는 것이다.

for range 3 {
go func() {
wg.Add(1) // 너무 늦다 — Wait가 먼저 통과할 수 있다
defer wg.Done()
}()
}
wg.Wait()

go vetwaitgroup 분석기가 정확히 이것을 잡는다.

main.go:29:10: WaitGroup.Add called from inside new goroutine

wg.Go를 쓰면 이 실수 자체가 불가능하다. 그것이 wg.Go가 추가된 이유다.

:::warning WaitGroup은 에러를 모으지 못한다 wg.Go가 받는 함수는 func()이다. 반환값이 없으니 에러를 밖으로 낼 수 없다. 슬라이스에 인덱스로 담거나(7-1의 기법), 채널로 보내거나, 아니면 errgroup을 쓴다 — 7-8에서 다룬다. :::

Once — 정확히 한 번

examples/07-concurrency/05-once/main.go
// 1. sync.Once — 가장 오래된 형태. 반환값이 없다.
var (
initOnce sync.Once
config map[string]string
)

func loadConfig() map[string]string {
initOnce.Do(func() {
onceCalls.Add(1)
config = map[string]string{"env": "prod"}
})
return config
}

// 2. sync.OnceValue — 결과가 하나면 이쪽이 훨씬 짧다.
var expensive = sync.OnceValue(func() int {
valueCalls.Add(1)
sum := 0
for i := range 1000 {
sum += i
}
return sum
})

// 3. sync.OnceValues — 에러까지 캐시한다.
//
// 에러도 캐시된다는 점에 주의한다. 첫 호출이 실패하면 이후 모든 호출이
// 같은 에러를 받는다. 재시도가 필요한 초기화에는 맞지 않는다.
var connect = sync.OnceValues(func() (string, error) {
pairCalls.Add(1)
return "", errors.New("연결 실패")
})
go run ./05-once
config: map[env:prod]
expensive: 499500
connect 에러: 연결 실패
초기화 함수 실행 횟수 (Once): 1
초기화 함수 실행 횟수 (OnceValue): 1
초기화 함수 실행 횟수 (OnceValues): 1

고루틴 50개가 동시에 불렀는데 초기화 함수는 각각 한 번씩만 실행됐다. Do는 초기화가 끝날 때까지 다른 호출자를 대기시킨다 — "한 번만 실행"뿐 아니라 "끝난 뒤에 반환"까지 보장한다.

세 가지 시그니처는 이렇다.

func OnceFunc(f func()) func()
func OnceValue[T any](f func() T) func() T
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)

Once 구조체를 따로 선언하고 전역 변수에 결과를 담는 옛 방식보다, OnceValue로 함수 값 하나를 만드는 쪽이 상태를 숨겨서 낫다.

:::danger 에러 캐싱은 함정이다 OnceValues로 DB 연결을 감쌌는데 첫 시도가 네트워크 문제로 실패하면, 프로세스가 살아 있는 내내 같은 에러를 반환한다. 재시도 가능한 초기화에는 Once를 쓰면 안 된다. 뮤텍스로 감싸고 실패 시 상태를 되돌리거나, 아예 명시적인 초기화 함수를 main에서 부른다 — 6-1에서 정한 "설정과 DB 연결은 init이 아니라 main이 부르는 함수에서"와 같은 이야기다. :::

atomic — 잠금 없는 단일 값

sync/atomic타입들(atomic.Int64, atomic.Bool, atomic.Pointer[T], atomic.Value)을 쓴다. 예전의 atomic.AddInt64(&n, 1) 같은 함수 형태는 정렬 문제와 잘못된 주소 전달을 막아 주지 못한다.

examples/07-concurrency/05-atomic/main.go
// 1. atomic.Int64 — 잠금 없는 카운터.
var hits atomic.Int64
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
for range 1000 {
hits.Add(1)
}
})
}
wg.Wait()
fmt.Println("hits:", hits.Load())

// 2. CompareAndSwap — "최대값 갱신"처럼 읽고-비교하고-쓰는 연산.
// 실패하면 다시 읽고 재시도한다. 이 루프가 CAS의 기본형이다.
var maxSeen atomic.Int64
var wg2 sync.WaitGroup
for i := range 100 {
wg2.Go(func() {
v := int64(i * 7 % 100)
for {
cur := maxSeen.Load()
if v <= cur {
return
}
if maxSeen.CompareAndSwap(cur, v) {
return
}
// 다른 고루틴이 먼저 바꿨다. 다시 읽고 재시도한다.
}
})
}
wg2.Wait()
fmt.Println("최대값:", maxSeen.Load())

CompareAndSwap의 재시도 루프가 atomic의 핵심 관용구다. "읽고 → 계산하고 → 쓰기"를 원자적으로 하고 싶은데 atomic이 제공하는 것은 한 연산뿐이므로, 쓰기 직전에 "내가 읽었을 때 그대로인가"를 확인하고 아니면 다시 한다.

atomic.Pointer[T]구조체 전체를 통째로 갈아 끼우는 데 쓴다.

examples/07-concurrency/05-atomic/main.go
// 3. atomic.Pointer — 설정 전체를 원자적으로 교체한다.
var cfg atomic.Pointer[Settings]
cfg.Store(&Settings{Timeout: 30, Retries: 3})

var wg3 sync.WaitGroup
reads := make([]Settings, 10)
for i := range reads {
wg3.Go(func() {
// 읽는 쪽은 스냅숏을 얻는다. 읽는 도중에 필드가 섞이는 일이 없다.
reads[i] = *cfg.Load()
})
}
wg3.Wait()
go run ./05-atomic
hits: 100000
최대값: 99
교체 전 읽기(전부 동일): {30 3} true
교체 후: {5 1}
첫 CompareAndSwap: true
두 번째 CompareAndSwap: false
closed: true

설정 핫 리로드의 표준 패턴이다. 읽는 쪽은 잠금 없이 Load()만 하고, 바꾸는 쪽은 새 구조체를 통째로 만들어 Store()한다. 이전 값을 읽고 있던 고루틴은 그 값을 끝까지 일관되게 본다.

:::warning atomic으로 "여러 개"를 지킬 수는 없다

total.Add(1)
sum.Add(v) // 이 두 줄 사이에 다른 고루틴이 total만 본다

각각의 연산은 원자적이지만 둘이 함께 원자적이지는 않다. 불변식이 여러 변수에 걸쳐 있으면 뮤텍스다. atomic은 값 하나를 위한 도구다. :::

sync.Map — 대부분의 경우 쓰지 않는다

sync.Map은 "동시성 맵"이지만 범용 대체품이 아니다. 문서가 명시하는 적합한 경우는 둘뿐이다.

  1. 키가 한 번 쓰이고 여러 번 읽힌다 (append-only 캐시)
  2. 여러 고루틴이 서로 겹치지 않는 키 집합을 다룬다

그 밖에는 map + RWMutex가 더 빠르고 더 명확하다.

examples/07-concurrency/05-syncmap/main.go
var m sync.Map

// LoadOrStore는 "없으면 넣고, 있으면 기존 값을 준다"를 원자적으로 한다.
// 여러 고루틴이 같은 키를 동시에 초기화해도 승자는 하나뿐이다.
var wg sync.WaitGroup
winners := make([]bool, 20)
for i := range winners {
wg.Go(func() {
// 20개 고루틴이 5개 키를 나눠 가진다. 키마다 4번씩 경쟁한다.
key := fmt.Sprintf("key%d", i%5)
_, loaded := m.LoadOrStore(key, i)
winners[i] = !loaded // 처음 넣은 고루틴만 true
})
}
wg.Wait()
go run ./05-syncmap
LoadOrStore 승자 수: 5
키: [key0 key1 key2 key3 key4]
key0 값은 int인가: true — 어떤 고루틴이 이겼는지는 매번 다르다: true
삭제 후 key0 존재: false
CompareAndSwap(1→2): true
CompareAndSwap(1→3): false
counter: 2

승자가 정확히 5명인 것은 보장된다 (키가 5개이므로). 어느 고루틴이 이겼는지는 매번 다르다. 그래서 값 자체가 아니라 "int인가"만 출력했다.

sync.Map의 비용을 정리하면.

map + RWMutexsync.Map
타입 안전성map[string]intany — 꺼낼 때마다 타입 단언
len()있다없다. Range로 세야 한다
복합 연산잠금 안에서 뭐든LoadOrStore 등 정해진 것만
쓰기가 많을 때낫다느리다
겹치지 않는 키잠금 경쟁 발생빠르다

:::tip 제네릭을 쓰면 타입 안전성 문제는 내가 풀 수 있다 5-2에서 만든 것처럼 map[K]V + RWMutex를 감싼 제네릭 타입을 직접 만들면, sync.Mapany 문제 없이 타입 안전한 동시성 맵을 얻는다. 실무에서 이쪽이 훨씬 흔하다. :::

sync.Pool — 짧게

sync.Pool할당을 줄이기 위한 임시 객체 재사용소다.

var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}

b := bufPool.Get().(*bytes.Buffer)
b.Reset() // 반드시 초기화한다
defer bufPool.Put(b)

주의할 점이 셋이다.

  1. GC가 언제든 풀을 비운다. 캐시가 아니다. 커넥션 풀로 쓰면 안 된다.
  2. 꺼낸 객체의 내용은 이전 사용자의 것이다. 반드시 리셋한다.
  3. 측정 없이 쓰면 손해다. Get/Put도 공짜가 아니다.

프로파일링으로 할당이 병목이라는 증거가 나온 다음에 도입한다. 벤치마크와 프로파일링은 파트 8과 파트 12의 주제다.

흔히 하는 실수

1. 잠금 안에서 채널 연산을 한다

mu.Lock()
ch <- v // 수신자가 없으면 잠금을 쥔 채 영원히 멈춘다
mu.Unlock()

임계 구역 안에서 블록될 수 있는 일을 하면 안 된다. 채널 송수신, 네트워크 호출, 다른 잠금 획득, Wait — 전부 위험하다.

2. 잠금 순서를 뒤섞는다

// 고루틴 A // 고루틴 B
a.mu.Lock() b.mu.Lock()
b.mu.Lock() a.mu.Lock() // 교착

여러 잠금을 잡아야 하면 프로그램 전체에서 순서를 하나로 고정한다. 가장 좋은 답은 잠금을 하나로 줄이는 것이다.

3. RUnlock 대신 Unlock을 부른다

RWMutex에서 RLock 뒤에 Unlock을 부르면 fatal error: sync: Unlock of unlocked RWMutex다. defer로 짝을 붙여 쓰면 눈에 띈다.

4. 재귀적으로 잠근다

Go의 Mutex재진입 불가다. 같은 고루틴이 두 번 Lock하면 자기 자신을 기다린다. 공개 메서드가 잠그고 비공개 메서드는 잠그지 않는 규칙(doThing / doThingLocked)이 표준적인 해법이다.

5. 뮤텍스를 값으로 반환한다

func (s *Server) Snapshot() State { return s.state } // state에 Mutex가 있으면 복사

copylocks가 잡는다. 스냅숏이 필요하면 잠금이 없는 별도 타입으로 옮겨 담는다.

6. atomic으로 map/slice를 지키려 한다

atomic.Pointer[[]int]슬라이스 헤더 교체만 원자적으로 한다. 그 슬라이스의 원소를 고치는 것은 여전히 경합이다. 불변 값을 통째로 교체하는 용도로만 쓴다.

7. sync.Map을 "동시성 map"이라 생각하고 기본으로 쓴다

위 표대로다. 기본은 map + RWMutex다.

정리

  • 소유권이 옮겨 다니면 채널, 제자리 상태를 지키면 sync. 슬로건이 뮤텍스를 금지하지 않는다.
  • 뮤텍스는 지키는 데이터 바로 옆에 둔다. 무엇을 지키는지 코드 배치로 말한다.
  • Lock() 다음 줄에 defer Unlock(). 임계 구역 안에서는 블록될 수 있는 일을 하지 않는다.
  • RWMutex는 읽기가 압도적이고 임계 구역이 길 때만 이득이다. 기본은 Mutex.
  • 잠금을 담은 타입은 항상 포인터로 다룬다. go vetcopylocks가 감시한다.
  • wg.Go(f)가 기본형이다. Add/defer Done은 기존 코드에서 읽을 것. waitgroup 분석기가 Add 위치 실수를 잡는다.
  • OnceValue/OnceValuesOnce 구조체보다 낫다. 다만 에러도 캐시된다.
  • atomic은 값 하나짜리 도구다. 여러 변수의 불변식은 뮤텍스로. CompareAndSwap 재시도 루프가 기본 관용구이고, atomic.Pointer는 설정 핫 리로드의 정석이다.
  • sync.Map은 특수 목적이다. 기본은 map + RWMutex, 또는 그것을 감싼 제네릭 타입.
  • sync.Pool은 프로파일링 이후에. 캐시가 아니고, GC가 비운다.

연습문제

  1. 05-mutexCounter.Inc를 값 리시버 func (c Counter) Inc()로 바꾸고 go vet ./05-mutex를 돌려 보자. 어떤 메시지가 나오는가? go vet을 무시하고 실행하면 카운터 값은 얼마가 되는가, 그리고 왜 그런가?

  2. 05-mutexRegistry.Keysmap[string]int를 그대로 반환하도록 바꾸고, 반환된 맵을 순회하는 동시에 Set을 호출하는 고루틴을 띄워 보자. go run -race로 실행하면 무엇이 나오는가? (7-6을 읽은 뒤에 다시 해 봐도 좋다.)

  3. 05-atomicmaxSeen 갱신을 CompareAndSwap 없이 if v > maxSeen.Load() { maxSeen.Store(v) }로 바꿔 보자. 결과가 99가 아닌 적이 있는가? 여러 번 돌려 보고, 왜 이 코드가 틀렸는지 "읽고-계산하고-쓰기"로 설명해 보자.

  4. sync.Map 대신 map[string]int + RWMutex를 감싼 제네릭 타입 SafeMap[K comparable, V any]를 만들어 05-syncmapLoadOrStore 실험을 다시 구현해 보자. 어느 쪽 코드가 읽기 쉬운가? 어느 쪽이 타입 실수를 더 잘 막는가?