Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

24장. 락 없이 동시성 설계하기

23장에서 우리는 뮤텍스로 데이터를 보호하는 법을 배웠다. 잘 동작한다. 하지만 코드가 커질수록 락도 같이 커진다는 점을 슬쩍 느꼈을 것이다.

이 장의 목표는 시야를 한 단계 넓히는 것이다. “공유 데이터를 락으로 지킨다” 대신 “애초에 공유하지 않게 설계한다” 는 발상이다.

다룰 내용:

  • Go 의 유명한 한 줄, “통신으로 메모리를 공유하라”
  • 락이 답이 아닌 상황들
  • 소유권 이전, 단일 작성자, 영역 나누기 같은 설계 패턴
  • 락과 채널 중 어느 쪽을 언제 쓸지

새 도구를 배운다기보다는 이미 알고 있는 도구를 다르게 조합하는 법을 배운다.


24.1 Go 의 철학

Go 의 동시성 격언으로 가장 유명한 한 줄.

Do not communicate by sharing memory; instead, share memory by communicating.

번역하면,

메모리를 공유함으로써 통신하지 말고, 통신함으로써 메모리를 공유하라.

겉으론 멋부린 말 같지만 안에는 분명한 처방이 들어 있다.

두 가지 그림

같은 일을 두 방식으로 한다고 해 보자. “카운터에 100 까지 누적해서 더해.”

(A) 메모리 공유로 통신

[고루틴 A]              [고루틴 B]
  |                      |
  +-- 공유 변수 counter --+
       ↑ Lock/Unlock 으로 보호
  • 공유 변수가 중심에 있다
  • 모두가 같은 자리를 동시에 만지므로 락이 필요하다

(B) 통신으로 공유

[고루틴 A] --값--> [집계 고루틴] <--값-- [고루틴 B]
                       |
                       └── 자기만의 변수에 누적
  • 누적 변수는 집계 고루틴 혼자만 만진다
  • 다른 고루틴은 채널로 “이만큼 더해 줘” 라고 부탁
  • 공유 변수 자체가 없으니 락이 필요 없다

(A) 와 (B) 는 같은 결과를 낸다. 하지만 (B) 의 코드는 더 읽기 쉽고 디버깅도 편하다.

락은 “공유 자원을 다 같이 쓰되 부딪치지 말자” 는 약속이다. 채널은 “그 자원을 한 사람에게 맡기고 부탁하자” 는 위임이다.

도구를 부정하는 건 아니다

오해를 막아 두자.

  • Go 가 락을 쓰지 말라고 하는 건 아니다
  • 표준 라이브러리 곳곳에 뮤텍스가 들어 있다
  • 단일 카운터 보호엔 그냥 Mutex 가 가장 깔끔하다

격언의 진짜 뜻은,

“공유 데이터를 지키는 데에만 집중하면 설계가 빨리 더러워진다. 한 발 떨어져서 ‘공유를 줄이는 설계’ 를 먼저 고려하라.”

이번 장은 그 “한 발 떨어진 시선” 의 카탈로그다.


24.2 락이 정답이 아닌 경우들

락이 슬슬 무거워지는 신호가 몇 가지 있다. 이런 게 보이면 설계를 다시 의심해 보자.

락 범위가 점점 커진다

처음엔 짧은 임계 영역으로 시작했는데 기능이 늘면서 점점 길어진다.

mu.Lock()
defer mu.Unlock()

if cond1 { ... }
data := fetch()       // 누군가가 슬쩍 추가
m[key] = transform(data)
publish(m[key])       // 또 누군가가 추가

이 정도면 사실상 싱글 스레드 프로그램이다. 모두가 락 앞에서 줄을 서느라 고루틴을 띄운 보람이 사라진다.

락이 여러 개로 늘어난다

기능이 커지면 락도 늘어난다.

  • userMu — 사용자 정보 보호
  • cacheMu — 캐시 보호
  • metricsMu — 지표 보호

이 셋이 서로 얽히기 시작하면

  • “어느 락이 먼저인가?” 같은 순서 규칙이 필요해진다 (23.5)
  • 잘못 잡으면 데드락
  • 새 코드 추가할 때 매번 락 순서를 검증해야 한다

라이브러리/API 경계에서 락이 새 나간다

func (s *Service) Get(key string) Item {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.items[key]
}

Get 이 돌려준 Item 을 호출자가 수정하면? 호출자가 그걸 또 다른 고루틴에 넘기면? 락의 보호 범위가 코드 밖으로 슬슬 새 나간다.

신호 체크리스트

신호의심해 볼 것
임계 영역이 한 화면을 넘어간다락이 데이터 흐름까지 짊어졌다
락이 3개 이상 얽힌다영역 분리가 잘못됐다
락 안에 채널 송수신이 있다안티패턴 (23.7)
반환값이 락 밖에서도 안전한지 헷갈린다소유권이 모호하다

이런 신호가 보이면 락을 더 정교하게 짜는 대신 설계 자체를 바꾸는 게 정답일 가능성이 높다.


24.3 소유권 이전 패턴

가장 강력한 발상은 이것이다.

어떤 데이터든 한 순간엔 한 고루틴만 만지게 한다.

이렇게만 보장되면 락이 아예 필요 없다. 경쟁할 수 있는 상대가 없기 때문이다.

채널은 이 “소유권“을 깔끔하게 옮기는 도구다. 값을 보내고 나면 그 값은 받는 쪽 것이고, 보낸 쪽은 만지지 않기로 약속하면 된다.

예제: 단계 사이로 데이터 넘기기

package main

import "fmt"

type Order struct {
	ID    int
	Items []string
	Total int
}

func main() {
	stage1 := make(chan *Order)
	stage2 := make(chan *Order)

	// 1단계: 주문 생성
	go func() {
		defer close(stage1)
		for i := 0; i < 3; i++ {
			o := &Order{ID: i, Items: []string{"apple", "bread"}}
			stage1 <- o // 여기서 소유권 이전
		}
	}()

	// 2단계: 합계 계산
	go func() {
		defer close(stage2)
		for o := range stage1 {
			o.Total = len(o.Items) * 1000
			stage2 <- o
		}
	}()

	// 3단계: 출력
	for o := range stage2 {
		fmt.Printf("order %d total=%d\n", o.ID, o.Total)
	}
}

Order 포인터는,

  • 1단계가 만들어서 보내고
  • 2단계가 받아서 수정하고
  • 3단계가 받아서 읽는다

세 단계 모두 같은 메모리를 만지지만 시간상으로는 한 번에 한 곳에서만 만진다. 락이 한 줄도 없는데 데이터 레이스가 없다.

약속이 핵심이다

  • “보낸 쪽은 보낸 뒤에 안 만진다”
  • “받은 쪽이 잠시 동안의 유일한 주인이다”

이 약속이 깨지면 데이터 레이스가 살아 돌아온다.

// 잘못된 예
stage1 <- o
o.Total = 999    // 보낸 후에도 만진다 — 위험

-race 로 돌리면 즉시 잡힌다.

채널은 마법이 아니다. “보낸 후엔 안 만진다” 는 규율이 진짜 보호의 근거다.


24.4 단일 작성자 패턴

읽기는 많고 쓰기는 한 곳에서만 일어나는 상황은 의외로 자주 나타난다.

이 패턴의 발상은,

  • 쓰기를 단 하나의 고루틴에만 맡긴다
  • 다른 모든 고루틴은 채널로 쓰기를 부탁한다
  • 그 한 고루틴이 자기 데이터를 자기만 수정하므로 락이 없다

예제: 안전한 카운터 서비스

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	incCh chan int
	qCh   chan chan int
	stop  chan struct{}
}

func NewCounter() *Counter {
	c := &Counter{
		incCh: make(chan int),
		qCh:   make(chan chan int),
		stop:  make(chan struct{}),
	}
	go c.run()
	return c
}

// 단일 작성자 고루틴
func (c *Counter) run() {
	var value int
	for {
		select {
		case n := <-c.incCh:
			value += n
		case reply := <-c.qCh:
			reply <- value
		case <-c.stop:
			return
		}
	}
}

func (c *Counter) Inc(n int) { c.incCh <- n }
func (c *Counter) Value() int {
	reply := make(chan int)
	c.qCh <- reply
	return <-reply
}
func (c *Counter) Stop() { close(c.stop) }

func main() {
	c := NewCounter()
	defer c.Stop()

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc(1)
		}()
	}
	wg.Wait()

	fmt.Println(c.Value())
}

run 안의 value 변수는 그 고루틴 혼자만 만진다. 외부에서는 채널을 통해서만 부탁할 수 있다.

장단점

  • 장점
    • 락이 없어 데드락 위험이 없다
    • 인터페이스(Inc, Value) 가 깔끔하다
    • 한 자리에서 모든 변경이 일어나니 로그/검증을 박기 좋다
  • 단점
    • 짧은 작업엔 채널 오버헤드가 락보다 크다
    • 단일 작성자 고루틴이 병목이 될 수 있다
    • 코드 양은 뮤텍스 버전보다 많다

그래서 모든 카운터를 이 패턴으로 짤 필요는 없다. “카운터” 가 아니라 “한 곳에서 일관성을 보장해야 하는 상태” 가 복잡할 때 빛난다.


24.5 영역 나누기 (Sharding)

또 다른 강력한 발상은 충돌 자체를 발생시키지 않게 데이터를 쪼개는 것이다.

영어로는 샤딩(sharding) 또는 파티셔닝(partitioning) 이라고 한다.

발상

큰 맵 하나를 보호하는 대신, 작은 맵 N개로 나누고 각 맵을 각 고루틴이 맡는다.

  • k 가 들어오면 hash(k) % N 으로 워커를 정한다
  • 한 워커는 자기 영역만 본다
  • 워커끼리는 데이터를 공유하지 않는다

예제: 카운트 by 키

package main

import (
	"fmt"
	"hash/fnv"
	"sync"
)

const N = 4

type Shard struct {
	counts map[string]int
	ch     chan string
}

func newShard() *Shard {
	return &Shard{
		counts: make(map[string]int),
		ch:     make(chan string, 16),
	}
}

func (s *Shard) run(wg *sync.WaitGroup) {
	defer wg.Done()
	for k := range s.ch {
		s.counts[k]++ // 이 맵은 나만 만진다
	}
}

func hashKey(k string) uint32 {
	h := fnv.New32a()
	h.Write([]byte(k))
	return h.Sum32()
}

func main() {
	shards := make([]*Shard, N)
	var wg sync.WaitGroup
	for i := 0; i < N; i++ {
		shards[i] = newShard()
		wg.Add(1)
		go shards[i].run(&wg)
	}

	// 입력 분배
	inputs := []string{"a", "b", "a", "c", "b", "a", "d"}
	for _, k := range inputs {
		idx := hashKey(k) % N
		shards[idx].ch <- k
	}

	// 입력 끝났음을 알리고 결과 수집
	for _, s := range shards {
		close(s.ch)
	}
	wg.Wait()

	for i, s := range shards {
		fmt.Printf("shard %d: %v\n", i, s.counts)
	}
}

핵심:

  • 같은 키는 항상 같은 워커로 간다
  • 워커끼리 같은 맵을 만지지 않는다
  • 락이 없고 채널은 분배 통로일 뿐이다

언제 빛나는가

  • 키별 독립 카운트, 집계
  • 키별 캐시
  • 사용자 ID 별 작업 처리 (같은 사용자는 같은 워커)

주의점

  • 분포가 균등해야 한다 (한 키만 폭주하면 그 워커만 바쁘다)
  • 워커 간 통신이 필요하면 다시 복잡해진다
  • 결과를 하나로 합쳐야 한다면 최종 단계에서 한 번은 모아야 한다

그래도 가장 큰 장점은 모든 충돌이 설계 단계에서 사라진다는 점이다. 런타임에 락을 잡을 일도, 데드락을 의심할 일도 없다.


24.6 역할 나누기 (생산자/소비자)

또 하나의 흔한 분리는 역할에 따른 분리다.

  • 어떤 고루틴은 데이터를 만들기만 한다 (생산자)
  • 어떤 고루틴은 데이터를 처리만 한다 (소비자)
  • 둘 사이를 채널이 잇는다

생산자는 자기 상태만, 소비자는 자기 상태만 만지므로 공유 데이터가 자연스럽게 줄어든다.

예제: 로그 처리

package main

import (
	"fmt"
	"sync"
)

func producer(out chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		out <- fmt.Sprintf("log-%d", i)
	}
}

func consumer(in <-chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	for line := range in {
		fmt.Println("처리:", line)
	}
}

func main() {
	ch := make(chan string, 4)

	var prodWg sync.WaitGroup
	prodWg.Add(2)
	go producer(ch, &prodWg)
	go producer(ch, &prodWg)

	var consWg sync.WaitGroup
	consWg.Add(1)
	go consumer(ch, &consWg)

	prodWg.Wait()
	close(ch)
	consWg.Wait()
}
  • 생산자 2명이 채널에 로그를 만들어 넣는다
  • 소비자 1명이 채널에서 꺼내 처리한다
  • 채널은 둘 사이의 큐 역할

워커 풀의 직관

생산자가 한 명, 소비자가 N명이면 그게 바로 워커 풀이다.

[producer] --→ chan task --→ [worker 1]
                       \--→ [worker 2]
                       \--→ [worker 3]
  • 일감 채널 하나
  • 같은 일감을 가져가려고 워커 N개가 경합
  • 채널이 알아서 한 명에게만 전달
  • 락 없이 자연스럽게 부하 분산

워커 풀의 정식 구현은 25장에서 본다. 여기선 “역할을 분리하니까 락이 사라진다” 는 직관만 잡아 두자.


24.7 불변 데이터로 공유

가장 단순하면서도 강력한 카드.

읽기만 가능한 데이터는 자유롭게 공유해도 된다.

데이터 레이스는 “한 쪽이라도 쓰기“가 있을 때 발생한다 (23.1). 아무도 쓰지 않으면 동시에 100개 고루틴이 봐도 안전하다.

활용 패턴

  • 설정값을 시작 시 한 번 만들고 그 뒤로는 절대 수정하지 않는다
  • “조회 전용 사본” 을 만들어 공유한다
  • 변경이 필요하면 새 객체를 만들어 포인터만 교체한다 (copy-on-write)

예제: 설정 공유

type Config struct {
	Timeout int
	Retries int
	Hosts   []string
}

var current = &Config{
	Timeout: 3,
	Retries: 2,
	Hosts:   []string{"a.example", "b.example"},
}

func GetConfig() *Config {
	return current // 모두 같은 포인터를 본다 — 안전
}

설정을 만든 뒤 절대 수정하지 않는다면 여러 고루틴이 동시에 읽어도 락이 필요 없다.

업데이트가 필요할 땐?

func UpdateConfig(newCfg *Config) {
	current = newCfg // 포인터만 교체
}

이 자체는 안전하지 않다. 포인터 교체는 단일 워드 쓰기지만, 일관성 보장과 가시성을 위해 보통은 다음 중 하나를 쓴다.

  • atomic.Pointer[Config] 로 교체
  • 작은 락으로 보호
  • 24.4 의 단일 작성자 패턴

핵심은,

  • 객체 내부는 절대 수정하지 않는다
  • 포인터 교체만 동기화한다

이러면 읽기 쪽은 락 없이 마음 편하게 데이터를 본다.

함정

  • 슬라이스/맵은 “값을 안 바꾸기” 가 약속이지 강제가 아니다
    • 호출자가 슬그머니 append 할 수 있다
    • 외부에 노출할 땐 의식적으로 복사하거나 수정 불가 형태로 감싼다
  • Go 에는 const struct 같은 게 없다
    • 불변성은 규율로 지키는 약속이다

24.8 락 vs 채널 — 언제 무엇을

흔한 질문.

“이 상황에 락을 써야 할까, 채널을 써야 할까?”

Go 팀의 공식 FAQ 에 깔끔한 가이드가 있다. 요지를 정리하면 이렇다.

락이 어울리는 경우

  • 작은 상태의 보호
    • 카운터, 캐시 한 줄, 플래그
  • 임계 영역이 매우 짧다
    • 채널 송수신보다 락 한 쌍이 가볍다
  • 읽기/쓰기 패턴이 단순하다
  • 소유권의 흐름이 없다
    • 그냥 같은 자리를 여러 명이 본다

sync.Mutex / sync.RWMutex / sync/atomic 의 자리.

채널이 어울리는 경우

  • 데이터의 흐름이 있다
    • 단계 1 → 단계 2 → 단계 3
  • 소유권을 옮긴다
    • “이건 이제 너 거” 라고 말하고 싶다
  • 여러 비동기 이벤트를 다룬다
    • 들어오는 작업, 취소 신호, 타임아웃 등 (select)
  • 결과를 모아야 한다
    • 여러 고루틴의 출력을 하나로

chan T + select 의 자리.

양쪽 다 쓰기

둘 중 하나만 골라야 하는 건 아니다.

  • 채널로 작업을 분배하고
  • 각 워커 안에서는 작은 뮤텍스를 쓰고
  • 종료는 WaitGroup 으로 기다린다

이런 식으로 도구를 섞는 게 일반적이다. 오히려 한 가지로 다 해결하려 들면 코드가 어색해진다.

의사 결정 흐름

  1. 공유 자체를 줄일 수 있는가? — 24.3~24.7
  2. 데이터의 흐름이 보이는가? — 채널
  3. 그저 자리를 지키는 일인가? — 락
  4. 단일 값 카운터/플래그인가? — atomic

이 순서로 자문해 보면 대부분의 경우 자연스럽게 답이 나온다.


24.9 정리

이 장에서 살펴본 내용:

  • Go 의 격언: 메모리 공유로 통신하지 말고 통신으로 메모리를 공유하라
  • 락 범위가 커지거나 락이 얽히면 설계를 의심해 볼 신호다
  • 소유권 이전 패턴: 한 순간엔 한 고루틴만 데이터를 만진다
  • 단일 작성자 패턴: 쓰기는 한 고루틴에 위임하고 나머지는 채널로 부탁한다
  • 영역 나누기(샤딩): 키별로 워커를 분배해 충돌을 설계 단계에서 없앤다
  • 역할 나누기: 생산자와 소비자를 분리하면 공유가 자연스럽게 줄어든다
  • 불변 데이터는 락 없이 공유할 수 있다
  • 락 vs 채널은 대립 관계가 아니라 도구 차이다
    • 자리 지키기 → 락
    • 흐름 / 소유권 이전 → 채널

도구함이 한층 풍성해졌다. 이제 우리에겐 락, 채널, 그리고 그 둘을 묶는 설계 감각이 있다.

다음 장은 이 도구들을 본격적인 실전 패턴으로 묶는다. 파이프라인, 워커 풀, 취소, 그리고 동시성 코드를 디버깅하는 법까지 한꺼번에 정리한다.