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으로 기다린다
이런 식으로 도구를 섞는 게 일반적이다. 오히려 한 가지로 다 해결하려 들면 코드가 어색해진다.
의사 결정 흐름
- 공유 자체를 줄일 수 있는가? — 24.3~24.7
- 데이터의 흐름이 보이는가? — 채널
- 그저 자리를 지키는 일인가? — 락
- 단일 값 카운터/플래그인가? — atomic
이 순서로 자문해 보면 대부분의 경우 자연스럽게 답이 나온다.
24.9 정리
이 장에서 살펴본 내용:
- Go 의 격언: 메모리 공유로 통신하지 말고 통신으로 메모리를 공유하라
- 락 범위가 커지거나 락이 얽히면 설계를 의심해 볼 신호다
- 소유권 이전 패턴: 한 순간엔 한 고루틴만 데이터를 만진다
- 단일 작성자 패턴: 쓰기는 한 고루틴에 위임하고 나머지는 채널로 부탁한다
- 영역 나누기(샤딩): 키별로 워커를 분배해 충돌을 설계 단계에서 없앤다
- 역할 나누기: 생산자와 소비자를 분리하면 공유가 자연스럽게 줄어든다
- 불변 데이터는 락 없이 공유할 수 있다
- 락 vs 채널은 대립 관계가 아니라 도구 차이다
- 자리 지키기 → 락
- 흐름 / 소유권 이전 → 채널
도구함이 한층 풍성해졌다. 이제 우리에겐 락, 채널, 그리고 그 둘을 묶는 설계 감각이 있다.
다음 장은 이 도구들을 본격적인 실전 패턴으로 묶는다. 파이프라인, 워커 풀, 취소, 그리고 동시성 코드를 디버깅하는 법까지 한꺼번에 정리한다.