여러 goroutine이 같은 메모리를 동시에 읽고 쓰면 실행 순서에 따라 결과가 달라질 수 있습니다. 한 goroutine이 값을 쓰는 동안 다른 goroutine이 같은 값을 읽거나 쓰고, 그 접근 사이에 적절한 동기화가 없다면 data race가 발생합니다.
Go에서는 공유 상태의 성격에 따라 sync/atomic, sync.Mutex, channel을 사용하는 owner goroutine 방식을 선택할 수 있습니다.
| 방법 | 적합한 경우 | 핵심 특징 |
|---|---|---|
sync/atomic |
독립적인 숫자, 상태 flag, 단순 counter | 하나의 값을 원자적으로 읽고 변경 |
sync.Mutex |
여러 field를 하나의 일관된 상태로 관리 | 보호할 코드 영역을 한 번에 하나의 goroutine만 실행 |
| owner goroutine | 상태 변경을 message로 전달 | 한 goroutine만 상태를 직접 소유하고 변경 |
atomic이 항상Mutex보다 좋은 것은 아닙니다. 여러 field 사이의 관계를 함께 유지해야 한다면 각 field를 따로 atomic 처리하기보다 하나의Mutex로 전체 상태를 보호하는 편이 명확합니다.
공유 상태와 data race
아래 코드는 10개의 goroutine이 같은 counter를 각각 1,000번 증가시킵니다.
package main
import (
"fmt"
"sync"
)
func main() {
var counter int64
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
for range 1_000 {
counter++
}
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}
for range 10은 반복문을 10번 실행한다는 의미입니다. for range 1_000은 반복문을 1,000번 실행합니다.
for range 1_000 {
counter++
}
숫자의 _는 가독성을 위한 구분자입니다. 따라서 1_000은 1000과 같은 값입니다.
1_000 // 1000
10_000 // 10000
1_000_000 // 1000000
Go 1.22부터 정수에 대해 range를 사용할 수 있습니다. 반복 횟수뿐 아니라 인덱스가 필요하면 다음처럼 작성합니다.
package main
import "fmt"
func main() {
for i := range 5 {
fmt.Println(i)
}
}
출력:
0
1
2
3
4
기존 반복문으로 작성하면 다음 코드와 같습니다.
package main
import "fmt"
func main() {
for i := 0; i < 5; i++ {
fmt.Println(i)
}
}
처음 예제의 counter++는 코드 한 줄이지만 하나의 원자적 연산이라고 보장되지 않습니다. 개념적으로는 다음 과정으로 이루어집니다.
현재 값을 읽음
1을 더함
계산된 값을 다시 저장
두 goroutine이 동시에 같은 값을 읽으면 각각 1을 더한 뒤 같은 결과를 저장할 수 있습니다. 이 경우 증가 작업 일부가 사라져 기대값인 10,000보다 작은 값이 나올 수 있습니다.
sync.WaitGroup은 goroutine의 종료를 기다릴 뿐 counter 접근을 보호하지 않습니다. 따라서 WaitGroup을 사용했다고 해서 data race가 해결되지는 않습니다.
다음 명령으로 race detector를 실행할 수 있습니다.
go run -race .
WARNING: DATA RACE가 출력되면 동기화되지 않은 메모리 접근이 실제 실행 중 발견된 것입니다.
sync/atomic으로 단순 counter 관리
하나의 숫자를 여러 goroutine이 증가시키는 경우에는 sync/atomic을 사용할 수 있습니다.
아래 코드는 그대로 main.go에 저장해 실행할 수 있습니다.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
for range 1_000 {
counter.Add(1)
}
}()
}
wg.Wait()
fmt.Println("atomic counter:", counter.Load())
}
실행:
go run .
go run -race .
출력:
atomic counter: 10000
counter.Add(1)은 현재 값에 1을 원자적으로 더합니다. counter.Load()는 현재 값을 원자적으로 읽습니다.
atomic.Int64의 주요 메서드는 다음과 같습니다.
| 메서드 | 의미 |
|---|---|
Load() |
현재 값을 원자적으로 읽음 |
Store(v) |
값을 원자적으로 저장 |
Add(delta) |
값을 원자적으로 더하고 변경된 값을 반환 |
Swap(v) |
새 값을 저장하고 이전 값을 반환 |
CompareAndSwap(old, new) |
현재 값이 old일 때만 new로 변경 |
atomic은 다음처럼 하나의 값이 독립적인 의미를 가질 때 적합합니다.
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var requestCount atomic.Int64
var shuttingDown atomic.Bool
requestCount.Add(1)
requestCount.Add(1)
shuttingDown.Store(true)
fmt.Println("request count:", requestCount.Load())
fmt.Println("shutting down:", shuttingDown.Load())
}
하지만 여러 field를 읽고 검사한 뒤 함께 변경해야 한다면 각 field를 atomic으로 선언하는 것만으로는 전체 작업이 하나의 원자적 연산이 되지 않습니다. 이런 경우에는 Mutex가 더 적합합니다.
sync.Mutex로 여러 field 보호
sync.Mutex는 한 번에 하나의 goroutine만 잠금으로 보호된 코드 영역에 들어가도록 합니다.
아래 예제는 작업의 전체 개수인 total과 완료 개수인 completed를 하나의 Mutex로 보호합니다.
package main
import (
"fmt"
"sync"
)
type Progress struct {
mu sync.Mutex
total int
completed int
}
func (p *Progress) AddTask() {
p.mu.Lock()
defer p.mu.Unlock()
p.total++
}
func (p *Progress) CompleteTask() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.completed >= p.total {
return false
}
p.completed++
return true
}
func (p *Progress) Snapshot() (total int, completed int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.total, p.completed
}
func main() {
progress := &Progress{}
var wg sync.WaitGroup
for range 10 {
progress.AddTask()
}
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
progress.CompleteTask()
}()
}
wg.Wait()
total, completed := progress.Snapshot()
fmt.Printf("total=%d, completed=%d\n", total, completed)
}
실행 결과:
total=10, completed=10
이 구조에서는 다음 규칙을 하나의 잠금 범위 안에서 확인하고 변경합니다.
completed는 total보다 클 수 없다.
여러 field 사이에서 항상 유지되어야 하는 관계를 invariant라고 합니다. 여러 field의 invariant를 보호해야 한다면 관련 값을 읽고 검사하고 변경하는 전체 작업을 같은 Mutex로 묶어야 합니다.
Lock()과 Unlock()은 일반적으로 다음 형태로 사용합니다.
p.mu.Lock()
defer p.mu.Unlock()
// 보호 대상 확인 및 변경
함수 중간에 return이 있어도 defer p.mu.Unlock()이 실행되므로 잠금 해제를 빠뜨릴 가능성을 줄일 수 있습니다.
잠금 범위 안에는 보호 대상의 확인과 변경에 필요한 코드만 두는 것이 좋습니다. 네트워크 요청이나 오래 걸리는 파일 작업까지 잠금 안에서 실행하면 다른 goroutine이 장시간 대기할 수 있습니다.
Mutex가 포함된 struct를 복사하면 안 되는 이유
Mutex가 어떤 데이터를 보호하는지 명확히 알 수 있도록 보호 대상과 Mutex를 같은 struct에 두는 것이 좋습니다.
package main
import (
"fmt"
"sync"
)
type Store struct {
mu sync.Mutex
items map[string]int
}
func NewStore() *Store {
return &Store{
items: make(map[string]int),
}
}
func (s *Store) Add(name string, quantity int) {
s.mu.Lock()
defer s.mu.Unlock()
s.items[name] += quantity
}
func (s *Store) Quantity(name string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.items[name]
}
func update(store *Store) {
store.Add("book", 1)
}
func main() {
store := NewStore()
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
update(store)
}()
}
wg.Wait()
fmt.Println("book quantity:", store.Quantity("book"))
}
sync.Mutex는 처음 사용한 뒤 복사하면 안 됩니다. 따라서 Mutex가 포함된 struct는 일반적으로 pointer로 생성하고 pointer receiver 메서드로 사용합니다.
store := NewStore()
update(store)
다음처럼 값을 인자로 전달하면 Store와 그 안의 Mutex가 복사되므로 피하는 것이 좋습니다.
func update(store Store) {
store.Add("book", 1)
}
go vet의 copylocks 검사도 Mutex처럼 복사하면 안 되는 값을 복사하는 코드를 찾는 데 도움을 줍니다.
go vet ./...
owner goroutine으로 상태 소유권 집중
공유 메모리를 여러 goroutine이 직접 수정하지 않고 하나의 goroutine만 상태를 소유하도록 설계할 수 있습니다.
다른 goroutine은 channel로 명령이나 조회 요청을 보내고, owner goroutine만 실제 상태를 읽고 변경합니다.
package main
import (
"fmt"
"sync"
)
type addCommand struct {
amount int
}
type getCommand struct {
response chan int
}
func ownCounter(commands <-chan any, done chan<- struct{}) {
defer close(done)
counter := 0
for command := range commands {
switch command := command.(type) {
case addCommand:
counter += command.amount
case getCommand:
command.response <- counter
}
}
}
func main() {
commands := make(chan any)
done := make(chan struct{})
go ownCounter(commands, done)
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() {
defer wg.Done()
for range 1_000 {
commands <- addCommand{amount: 1}
}
}()
}
wg.Wait()
response := make(chan int)
commands <- getCommand{response: response}
result := <-response
close(commands)
<-done
fmt.Println("owner counter:", result)
}
실행 결과:
owner counter: 10000
이 예제에서 counter 변수는 ownCounter를 실행하는 goroutine 안에만 존재합니다. 다른 goroutine은 counter를 직접 읽거나 변경하지 않고 commands channel을 통해 작업을 요청합니다.
channel을 통해 전달된 명령은 owner goroutine이 하나씩 수신하여 처리합니다. 따라서 실제 상태 변경은 한 goroutine 안에서 순서대로 실행됩니다.
owner goroutine은 다음과 같은 경우에 적합합니다.
- 상태 변경을 command나 event로 표현하기 자연스러운 경우
- 상태 변경 순서를 명확하게 유지해야 하는 경우
- 특정 자원에 대한 접근을 한 goroutine으로 제한하고 싶은 경우
- 잠금보다 message 흐름이 프로그램 구조에 더 잘 맞는 경우
단순 counter 하나만 관리한다면 owner goroutine은 atomic보다 코드가 복잡하고 channel 통신 비용도 추가됩니다. 상태의 구조와 변경 흐름에 맞춰 선택해야 합니다.
세 가지 방식을 함께 실행하는 전체 예제
아래 예제는 atomic, Mutex, owner goroutine을 한 프로그램에서 모두 실행합니다. -unsafe 옵션을 사용하면 의도적으로 data race가 있는 코드도 실행할 수 있습니다.
프로젝트 구조:
sources/
└── shared-state/
└── main.go
sources/shared-state/main.go:
package main
import (
"flag"
"fmt"
"sync"
"sync/atomic"
)
const (
workerCount = 10
incrementSize = 1_000
)
func main() {
unsafeMode := flag.Bool("unsafe", false, "data race가 있는 counter 실행")
flag.Parse()
if *unsafeMode {
fmt.Printf("unsafe counter: %d\n", runUnsafeCounter())
return
}
fmt.Printf("atomic counter: %d\n", runAtomicCounter())
progress := runMutexProgress()
total, completed := progress.Snapshot()
fmt.Printf("mutex progress: total=%d, completed=%d\n", total, completed)
fmt.Printf("owner counter: %d\n", runOwnerCounter())
}
func runUnsafeCounter() int64 {
var counter int64
var wg sync.WaitGroup
for range workerCount {
wg.Add(1)
go func() {
defer wg.Done()
for range incrementSize {
counter++
}
}()
}
wg.Wait()
return counter
}
func runAtomicCounter() int64 {
var counter atomic.Int64
var wg sync.WaitGroup
for range workerCount {
wg.Add(1)
go func() {
defer wg.Done()
for range incrementSize {
counter.Add(1)
}
}()
}
wg.Wait()
return counter.Load()
}
type Progress struct {
mu sync.Mutex
total int
completed int
}
func (p *Progress) AddTask() {
p.mu.Lock()
defer p.mu.Unlock()
p.total++
}
func (p *Progress) CompleteTask() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.completed >= p.total {
return false
}
p.completed++
return true
}
func (p *Progress) Snapshot() (total int, completed int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.total, p.completed
}
func runMutexProgress() *Progress {
progress := &Progress{}
var wg sync.WaitGroup
for range workerCount {
progress.AddTask()
}
for range workerCount {
wg.Add(1)
go func() {
defer wg.Done()
progress.CompleteTask()
}()
}
wg.Wait()
return progress
}
type addCommand struct {
amount int
}
type getCommand struct {
response chan int
}
func ownCounter(commands <-chan any, done chan<- struct{}) {
defer close(done)
counter := 0
for command := range commands {
switch command := command.(type) {
case addCommand:
counter += command.amount
case getCommand:
command.response <- counter
}
}
}
func runOwnerCounter() int {
commands := make(chan any)
done := make(chan struct{})
go ownCounter(commands, done)
var wg sync.WaitGroup
for range workerCount {
wg.Add(1)
go func() {
defer wg.Done()
for range incrementSize {
commands <- addCommand{amount: 1}
}
}()
}
wg.Wait()
response := make(chan int)
commands <- getCommand{response: response}
result := <-response
close(commands)
<-done
return result
}
실행:
cd sources
go run ./shared-state
go run -race ./shared-state
정상 출력:
atomic counter: 10000
mutex progress: total=10, completed=10
owner counter: 10000
의도적으로 data race가 있는 코드를 실행하려면 다음 명령을 사용합니다.
go run -race ./shared-state -unsafe
WARNING: DATA RACE가 출력되면 race detector가 counter++에서 충돌하는 메모리 접근을 찾은 것입니다. 출력된 counter가 우연히 10000이더라도 data race가 없다는 의미는 아닙니다.
선택 기준과 race detector
| 상황 | 일반적인 선택 |
|---|---|
| 요청 수처럼 독립적인 숫자를 증가 | atomic.Int64 |
| 실행 여부 같은 하나의 boolean 상태 | atomic.Bool |
| map을 여러 goroutine이 함께 읽고 수정 | sync.Mutex 또는 sync.RWMutex |
| 여러 field를 함께 검사하고 변경 | sync.Mutex |
| 명령 순서에 따라 상태가 변함 | owner goroutine과 channel |
| 하나의 goroutine만 자원을 직접 다뤄야 함 | owner goroutine과 channel |
-race는 프로그램이 실행되는 동안 발생한 메모리 접근을 관찰해 data race를 찾습니다.
go run -race ./shared-state
go test -race ./...
go build -race ./...
race detector는 실행되지 않은 코드 경로의 문제를 찾을 수 없습니다. 특정 분기, 오류 처리, 종료 경로 또는 동시 요청 상황이 실행되지 않았다면 그 경로의 race도 발견되지 않습니다.
-race에서 경고가 나오지 않았다는 것은 실행한 경로에서 race가 발견되지 않았다는 의미입니다. 프로그램의 모든 실행 경로에 data race가 없다는 증명은 아닙니다.
race가 발견되면 일반적으로 충돌한 read 또는 write 위치, 접근을 수행한 goroutine의 stack trace, goroutine이 생성된 위치가 함께 출력됩니다.
정리
여러 goroutine이 같은 메모리를 읽고 쓰면 명시적인 동기화가 필요합니다.
단순 counter나 독립된 flag는 sync/atomic으로 관리할 수 있습니다. 여러 field의 값을 함께 검사하고 변경하면서 일관성을 유지해야 한다면 sync.Mutex가 적합합니다. 상태 변경을 command나 event로 표현하기 자연스럽다면 하나의 owner goroutine이 상태를 소유하고 channel로 요청을 받는 구조를 고려할 수 있습니다.
Mutex가 포함된 struct는 사용 후 복사하지 않아야 합니다. 보호 대상과 Mutex를 같은 struct에 두고 pointer receiver와 pointer 전달을 사용하면 잠금과 보호 대상의 관계를 분명하게 유지할 수 있습니다.
for range 1_000은 반복문을 1,000번 실행하는 Go 문법이며, 1_000은 숫자 1000과 같습니다.
마지막으로 -race는 실제 실행된 코드 경로에서 발생한 data race를 찾는 도구입니다. 동시 요청, 오류 처리, 종료 경로까지 충분히 실행해야 검사 효과를 높일 수 있습니다.
참고 자료
- Go 메모리 모델: https://go.dev/ref/mem
- Go 언어 명세의 for 문과 정수 range: https://go.dev/ref/spec#For_statements
- sync 패키지: https://pkg.go.dev/sync
- sync/atomic 패키지: https://pkg.go.dev/sync/atomic
- Go Data Race Detector: https://go.dev/doc/articles/race_detector
- Go Wiki - Mutex or Channel: https://go.dev/wiki/MutexOrChannel
- Go vet: https://pkg.go.dev/cmd/vet
'프로그래밍 > Go' 카테고리의 다른 글
| Go 채널과 context 취소 신호 이해하기 (0) | 2026.07.30 |
|---|---|
| Go JSON과 XML 처리하기 (0) | 2026.07.28 |
| Go Worker Pool, WaitGroup, Rate Limiting (0) | 2026.07.27 |
| Go Timer와 Ticker (0) | 2026.07.24 |
| Go panic과 recover (0) | 2026.07.23 |
댓글