고 고급 #2 메모리 모델과 sync 패키지

5 분 소요

#1 동시성 패턴에서 채널을 어떻게 조립하는지 봤다면 — 이번엔 반대편 도구. 채널 대신 공유 변수에 직접 접근할 때 필요한 도구들.

“Don’t communicate by sharing memory; share memory by communicating.”

격언은 맞지만 — 실전에선 공유 변수가 더 어울리는 경우도 있습니다. 카운터, 캐시, lazy 초기화 같은 경우가 그렇습니다.

메모리 모델 한 줄 요약 #

Go의 메모리 모델이 보장하는 건 — 같은 변수에 두 고루틴이 동시에 접근(읽기 + 쓰기 또는 쓰기 + 쓰기) 하는 건 데이터 레이스입니다. 같이 쓰려면 동기화가 필요합니다.

동기화 도구는 채널 외에 — 이번 글에서 보는 sync 패키지와 sync/atomic이 있습니다.

Mutex — 가장 단순한 잠금 #

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로 읽기 동시성을 늘릴 수 있습니다.

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에서 봤습니다. 핵심만 다시.

WaitGroup
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 초기화에 적합한 도구입니다.

Once
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 압박을 줄이고 싶을 때 사용합니다.

sync.Pool
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보다 가볍지만, 보호할 대상이 한 변수일 때만 어울립니다.

atomic 카운터
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 #

CAS
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는 표준 도구로 데이터 레이스를 잡아 줍니다.

race 검사
go run -race main.go
go test -race ./...
go build -race

런타임에 동시 접근을 감시 — 발견 시 즉시 보고. 오버헤드가 있어 프로덕션에는 안 켜지만, 테스트와 CI에서는 항상 켜두는 게 좋습니다.

race 보고 예
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.

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가 어떻게 동작하는지 정리합니다.

X