고 고급 #2 메모리 모델과 sync 패키지
#1 동시성 패턴에서 채널을 어떻게 조립하는지 봤다면 — 이번엔 반대편 도구. 채널 대신 공유 변수에 직접 접근할 때 필요한 도구들.
“Don’t communicate by sharing memory; share memory by communicating.”
격언은 맞지만 — 실전에선 공유 변수가 더 어울리는 경우도 있습니다. 카운터, 캐시, lazy 초기화 같은 경우가 그렇습니다.
메모리 모델 한 줄 요약 #
Go의 메모리 모델이 보장하는 건 — 같은 변수에 두 고루틴이 동시에 접근(읽기 + 쓰기 또는 쓰기 + 쓰기) 하는 건 데이터 레이스입니다. 같이 쓰려면 동기화가 필요합니다.
동기화 도구는 채널 외에 — 이번 글에서 보는 sync 패키지와 sync/atomic이 있습니다.
Mutex — 가장 단순한 잠금 #
import "sync"
type Counter struct {
mu sync.Mutex
n int
}
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
}Lock() / Unlock() 사이의 코드가 — 임계 영역. 한 번에 한 고루틴만 들어갈 수 있습니다.
패턴 — defer Unlock이 표준
#
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // 함수 끝에 자동
c.n++
}panic이나 early return에도 안전. 거의 항상 이 패턴을 따릅니다.
Mutex는 zero value가 사용 가능 #
var mu sync.Mutex // 바로 사용 가능
mu.Lock()sync.Mutex{} 같은 초기화 불필요. 구조체 안에 넣으면 — 자동으로 동작 가능 상태.
함정 — Mutex를 복사하지 마라 #
type Counter struct {
mu sync.Mutex
n int
}
func use(c Counter) { // ✗ 값 복사 — Mutex도 복사됨
c.mu.Lock()
// ...
}값으로 받으면 — Mutex가 통째로 복사돼서 다른 잠금이 됩니다. go vet이 잡아 줍니다. 항상 포인터 receiver.
RWMutex — 읽기는 동시에 #
읽기가 많고 쓰기가 적을 때 — sync.RWMutex로 읽기 동시성을 늘릴 수 있습니다.
type Cache struct {
mu sync.RWMutex
m map[string]string
}
func (c *Cache) Get(k string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.m[k]
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}RLock/RUnlock— 여러 reader가 동시에 들어갈 수 있음Lock/Unlock— writer는 혼자
벤치마크 해 보면 — 읽기 비율이 90% 넘게 압도적일 때만 의미가 있습니다. 그 이하면 일반 Mutex와 큰 차이 없거나 더 느립니다(RWMutex 자체 오버헤드).
WaitGroup — 여러 고루틴 끝나기 기다리기 #
중급 #3에서 봤습니다. 핵심만 다시.
var wg sync.WaitGroup
for _, x := range items {
wg.Add(1)
go func(x Item) {
defer wg.Done()
process(x)
}(x)
}
wg.Wait()세 메서드: Add(n)으로 등록, Done()으로 완료 신호, Wait()으로 모두 끝나길 기다립니다.
함정 — Add 위치 #
Add(1)은 고루틴 시작 전, 메인 고루틴에서. 고루틴 안에서 Add 하면 — Wait가 먼저 도착해 0 인 상태로 빠질 수 있습니다.
Once — 한 번만 실행 #
lazy 초기화에 적합한 도구입니다.
var (
once sync.Once
instance *DB
)
func GetDB() *DB {
once.Do(func() {
instance = connect()
})
return instance
}once.Do(f)가 — 여러 고루틴이 동시에 호출해도 f를 정확히 한 번만 실행. 두 번째 호출부터는 즉시 return.
singleton, lazy init, 1 회 cleanup 같은 경우에 어울립니다.
Pool — 임시 객체 재사용 #
GC 압박을 줄이고 싶을 때 사용합니다.
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handler() {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
// buf 사용
}특징:
Get이 풀에서 꺼내거나, 비어 있으면New호출Put으로 반납- 풀의 객체는 GC가 임의로 회수할 수 있음 — 반드시 다시 받을 보장 없음
핫 패스의 bytes.Buffer, byte slice 같은 경우에 효과 큼. fmt 패키지 내부에서도 활용합니다.
atomic — 락 없이 단일 변수 동기화 #
sync/atomic 패키지는 — 단일 변수의 읽기/쓰기를 원자적으로 처리합니다. Mutex보다 가볍지만, 보호할 대상이 한 변수일 때만 어울립니다.
import "sync/atomic"
type Counter struct {
n atomic.Int64
}
func (c *Counter) Inc() {
c.n.Add(1)
}
func (c *Counter) Value() int64 {
return c.n.Load()
}Go 1.19+ 에서 추가된 atomic.Int64 같은 타입은 — 원자성을 타입으로 강제해서 안전. 이전 버전의 atomic.AddInt64(&n, 1)보다 깔끔합니다.
compare-and-swap #
var flag atomic.Bool
if flag.CompareAndSwap(false, true) {
// 처음 진입한 고루틴만 여기로
}CompareAndSwap(old, new) — 현재 값이 old 면 new로 바꾸고 true, 아니면 그대로 두고 false. 락프리 알고리즘의 기본 도구.
어느 도구를 언제 쓰나? #
| 상황 | 도구 |
|---|---|
| 단일 카운터/플래그 | atomic |
| 구조체나 여러 변수 동시 보호 | Mutex |
| 읽기 압도적, 쓰기 드뭄 | RWMutex |
| 여러 고루틴 종료 대기 | WaitGroup |
| 1 회 초기화 | Once |
| 임시 객체 재사용 | Pool |
| 단계별 데이터 흐름, 신호 | 채널 |
기본은 채널 우선. 채널이 어색해지는 경우(공유 카운터, 캐시, 1 회 초기화)에서 sync 도구로 갈아탑니다.
데이터 레이스 — race detector #
Go는 표준 도구로 데이터 레이스를 잡아 줍니다.
go run -race main.go
go test -race ./...
go build -race런타임에 동시 접근을 감시 — 발견 시 즉시 보고. 오버헤드가 있어 프로덕션에는 안 켜지만, 테스트와 CI에서는 항상 켜두는 게 좋습니다.
WARNING: DATA RACE
Read at 0x00c000118018 by goroutine 7:
main.read()
/tmp/race.go:14 +0x3a
Previous write at 0x00c000118018 by goroutine 6:
main.write()
/tmp/race.go:9 +0x47읽기/쓰기 위치를 모두 알려 줍니다. 핵심 디버깅 도구 중 하나입니다.
채널 vs Mutex — 어느 쪽? #
| 상황 | 어느 쪽? |
|---|---|
| 데이터의 소유권을 옮김 (producer → consumer) | 채널 |
| 단계별 처리 흐름 (pipeline) | 채널 |
| 공유 상태에 여러 고루틴이 읽고 씀 | Mutex |
| 단순 카운터/플래그 | atomic |
| 1 회 초기화 | Once |
Go 커뮤니티의 합의는 — 둘 다 도구. 격언은 “채널 우선” 이지만, 자료구조의 안쪽처럼 채널이 어색한 경우는 Mutex가 자연스럽습니다. 표준 라이브러리 자체가 두 가지를 모두 자유롭게 씁니다.
함정 — 락의 범위 #
func (c *Cache) Get(k string) string {
c.mu.Lock()
v := c.m[k]
c.mu.Unlock()
return v
}
func main() {
v := c.Get("k")
process(v) // 락 밖이라 OK
}락은 — 꼭 필요한 만큼만 잡아야 합니다. 락 안에서 무거운 일을 하면(예: I/O, 외부 호출) 다른 고루틴이 다 기다립니다.
c.mu.Lock()
defer c.mu.Unlock()
v := c.m[k]
log.Print(v) // ✗ 락 안에서 I/O
http.Get(someURL) // ✗ 락 안에서 네트워크I/O는 항상 락 밖에서.
함정 — 두 락의 순서 #
여러 락을 잡을 일이 있다면 — 항상 같은 순서로. 안 그러면 deadlock.
// 고루틴 A
a.Lock(); b.Lock() // a → b 순서
// 고루틴 B (반대)
b.Lock(); a.Lock() // b → a 순서 — A와 만나면 deadlock가능하면 — 락이 여러 개 필요한 설계 자체를 피하는 게 좋습니다.
마무리 #
이번 글에서 정리한 내용:
- Mutex — 가장 단순한 임계 영역 보호,
defer Unlock표준 - RWMutex — 읽기 압도적일 때만 의미 있음
- WaitGroup — 여러 고루틴 종료 대기,
Add는 고루틴 밖에서 - Once — 1 회 실행 보장, lazy init
- Pool — 임시 객체 재사용, GC 압박 완화
- atomic — 단일 변수 락프리 동기화
- race detector — 테스트/CI에 항상 켜기
- 락 안에 I/O 금지, 락 순서 통일
다음 글(#3 제네릭)에서는 — Go 1.18에 도입된 type parameter. 언제 쓰고 언제 안 쓰는지, constraint가 어떻게 동작하는지 정리합니다.