Go는 “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.“라는 철학을 가지고 있다.
“Don’t communicate by sharing memory, share memory by communicating”
— Rob Pike, Go Proverbs
이번 글에서는 메모리를 공유했을때 어떤 문제가 있는지를 CPU와 메모리 구조 수준에서 내려가 살펴보고, Java/Kotlin/Spring에서는 이 문제를 어떻게 풀어왔는지, 그리고 Go는 어떻게 해결하는지를 살펴볼 것이다.
모든 층위에서 반복되는 하나의 문제
미리 결론을 하나 말해두면, 이 글 전체를 관통하는 문제는 단 하나다.
공유된 상태에 대한 읽기-수정-쓰기(read-modify-write)는 원자적이지 않다.
CPU 명령어 수준에서는 count++ 가 세 개의 명령으로 쪼개지며 발생하고, 애플리케이션 수준에서는 싱글톤 빈의 필드를 여러 스레드가 동시에 덮어쓰며 발생하고, 데이터베이스 수준에서는 두 트랜잭션이 같은 재고 행을 읽고, 차감하고, 저장하며 발생하고, 분산 시스템 수준에서는 두 서버의 인스턴스가 같은 Redis 키를 갱신하며 발생한다.
이러한 문제를 해결하기 위한 방법은 크게 2가지가 존재한다.
- 접근을 직렬화: 한 번에 하나의 실행 흐름만 공유 자원에 접근하도록 하는 것이다. Mutex, synchronization, DB Lock, Distributed Lock 등을 사용하여 접근을 직렬화 할 수 있다.
- 소유권 설계: 공유 자원을 단 하나의 실행 흐름만 소유하게 만드는 것이다. Go 채널, Redis의 Single Thread EventLoop 등이 있다.
Go의 “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.” 철학은 소유권 설계를 기반으로 한다.
프로세스 메모리 구조
OS는 프로세스에게 크게 네 개의 메모리 영역을 준다.
┌──────────────┐
│ Stack │ 지역 변수, 매개변수 — 스레드마다 독립
├──────────────┤
│ Heap │ 동적 할당 객체 — 모든 스레드가 공유
├──────────────┤
│ Data │ 전역 변수, static 변수 — 모든 스레드가 공유
├──────────────┤
│ Text │ 프로그램 코드
└──────────────┘
Thread는 Stack만 각자 갖고, 나머지는 전부 공유한다. 따라서 싱글톤 빈을 설계할때 지역 변수는 동시성 이슈로 부터 안전하지만 전역 변수는 모든 스레드가 공유하기 때문에 동시성 이슈가 발생할 수 있다.
@Service
class OrderService {
// 싱글톤 빈의 필드 = 힙에 단 하나 = 모든 요청 스레드가 공유하는 상태
private var userId: Long = 0
fun order(id: Long) {
userId = id // 스레드 A가 100을 저장하고
Thread.sleep(100) // 잠시 다른 일을 하는 사이
pay(userId) // 스레드 B가 200으로 덮어쓰면, A는 200 번 유저의 결제를 하게 된다
}
}
동시성 이슈를 해결하기 위한 Spring 진영에서 지켜야할 중요한 원칙은 싱글톤 빈은 상태를 갖지 않도록 설계하는 것이다.
만약 요청 마다 독립적인 전역 변수가 필요하다면 스레드별 독립 저장소인 ThreadLocal을 사용해야한다.
참고로 Thread Pool은 Thread를 재사용하므로, 다 쓴 뒤 remove()를 호출하지 않으면 다음 요청이 이전 요청의 값을 바라보게 될 수 있다.
Data Race
경쟁 상태(Race Condition)는 결과가 실행 순서(interleaving)에 따라 달라지는 상황을 의미한다. 예를 들어 Thread가 여러개 동시에 접근할 때 누가 먼저 접근하느냐에 따라 결과가 달라질 수 있다.
Data Race란 Go Memory Model에서는 다음과 같이 정의한다. Data Race는 Race Condition의 한 종류이다.
Data Race - 둘 이상의 goroutine이 같은 메모리(공유 메모리)에 접근하고, 그 중 하나 이상이 쓰기(write)이며, 적절한 동기화가 없는 경우
Go도 고루틴을 사용하는 경우, 클로저가 캡처한 변수나 포인터로 건네받은 구조체는 힙 위에서 똑같이 공유된다. 이러한 상황에서 적절한 동기화가 없다면 Data Race가 발생한다.
아래 예시를 살펴보자.
package main
import (
"fmt"
"sync"
)
func main() {
var counter int // 모든 고루틴이 공유하는 변수
// WaitGroup은 "고루틴들이 전부 끝날 때까지 기다리는" 동기화 도구다.
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1) // 기다릴 고루틴이 하나 늘었다고 알린다
go func() { // go 키워드: 이 함수를 새 고루틴(경량 실행 흐름)에서 실행한다
defer wg.Done() // 고루틴이 끝나면 카운트를 하나 줄인다
counter++ // 위험 지점: 이 한 줄은 원자적이지 않다
}()
}
wg.Wait() // 1000 개가 모두 끝날 때까지 대기
fmt.Println(counter) // 1000이 아닐 수 있다. 실행할 때마다 다르다.
}
고루틴이 같은 힙 객체를 참조하면 스레드와 동일한 문제가 발생한다. 위 결과는 항상 1000이 아닐 수 있다.
counter++는 소스 코드에서는 한 줄이지만, CPU에게는 논리적으로 값을 읽고(read), 증가 시키고(modify), 다시 저장하는(write) 세 단계로 이뤄질 수 있다.
x86 계열에서는 개념적으로 다음과 같은 명령으로 생각할 수 있다.
MOVQ counter, AX ; (1) 메모리에서 값을 읽어 레지스터에 담는다
INCQ AX ; (2) 레지스터에서 1을 더한다
MOVQ AX, counter ; (3) 결과를 메모리에 되쓴다
Go는 Data Race 문제를 컴파일러 옵션 하나로 잡아낼 수 있게 해두었다.
$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c00001c090 by goroutine 8:
main.main.func1()
Previous write at 0x00c00001c090 by goroutine 7:
main.main.func1()
==================
가시성과 재배치 문제
공유 메모리에는 가시성(visibility)과 재배치(reordering)라는 두 가지 문제가 존재한다.
- 가시성(visibility): CPU는 메인 메모리가 너무 느려서 코어마다 캐시를 두고, 쓰기는 일단 스토어 버퍼(store buffer)에 모아뒀다가 나중에 반영한다. 그래서 코어 1이 쓴 값을 코어 2가 “언제 보게 될지, 심지어 보게 될지” 조차 보장이 없다.
- 재배치(reordering): 컴파일러와 CPU는 성능을 위해, 단일 스레드 관점에서 결과가 같다면 명령의 실행 순서를 바꾼다. 내 스레드에서는 티가 나지 않지만, 그 메모리를 훔쳐보는 다른 스레드에게는 순서가 뒤집혀 보인다.
package main
import "fmt"
var number int // 전달하려는 데이터
var ready bool // "데이터 준비 완료"를 알리는 깃발
func main() {
go func() {
number = 42 // (1) 데이터를 쓰고
ready = true // (2) 깃발을 올린다
}()
for !ready { // (3) 깃발이 올라올 때까지 반복문으로 기다린다 (동기화 없음!)
}
fmt.Println(number) // 42가 보장되지 않는다
}
직관적으로는 “깃발이 올라왔으면 데이터도 준비됐겠지"라고 기대하지만, 동기화가 없는 이상 아무것도 보장되지 않는다.
(1)과 (2)는 재배치될 수 있으므로 ready가 먼저 true가 되어 0이 출력될 수 있고,
ready = true라는 쓰기가 main 고루틴에게 영영 보이지 않아 반복문이 끝나지 않을 수도 있다.
volatile boolean ready = false;
자바는 volatile이라는 키워드를 두었다. volatile 필드에 대한 쓰기는 이후의 읽기보다
먼저 일어난다는(happens-before) 순서를 메모리 모델이 보장하고, 그 구현으로 컴파일러와 CPU의 재배치를 막는
**메모리 배리어(memory barrier)**가 삽입된다.
즉 volatile을 선언하면 CPU 캐시나 명령어 재배치(reordering)와 관련된 메모리 가시성(visibility)을 JVM이 보장해 준다.
다만 volatile은 가시성만 해결한다. counter++의 세 단계가 쪼개지는 원자성 문제는 그대로 남는다.
흥미로운 것은 Go의 선택이다. Go에는 volatile이 없다. 실수로 빠뜨린 게 아니라 일부러 뺐다.
Go 메모리 모델 문서는 동기화가 필요하면 채널이나 sync, sync/atomic을 쓰라고 말한다.
저수준 규칙에 기대려는 사람에게 이렇게 조언한다.
Don’t be clever.
지난 글에서 다룬 Clear is better than clever가 메모리 모델 문서에도 그대로 이어진다.
위 예제는 CPU의 명령어 재배치, 컴파일러 최적화, Happens-Before 관계를 머릿속에서 추론해야 한다. 즉, 코드를 이해하기 위해 메모리 모델을 해석(decode) 해야 한다. Go는 바로 이런 코드를 clever 하다고 본다.
반대로 Go가 권장하는 방식은 다음과 같다.
ready := make(chan struct{})
go func() {
number = 42
close(ready)
}()
<-ready
fmt.Println(number)
이 코드는 메모리 모델을 몰라도 읽을 수 있다. 메모리 가시성이 어떻게 보장되는지는 channel이 책임진다. 즉, 코드를 이해하기 위해 “이 코드는 happens-before 관계가 성립하니까 안전하다"와 같은 추론을 머릿속에서 할 필요가 없다.
따라서 Go가 volatile을 제공하지 않는 이유는
개발자가 메모리 모델의 세부 규칙을 이용해 영리한(clever) 코드를 작성하는 대신, 채널과 sync, sync/atomic 같은 명시적인 동기화 도구를 사용해 누구나 이해할 수 있는(clear) 코드를 작성하도록 유도하기 위해서라는
Clear is better than clever 철학을 반영하기 때문이다.
메모리 모델과 순차적 일관성에 대한 더 깊은 이야기는 MEMORY MODEL 글에서 다뤘다.
첫 번째 패러다임 - 접근을 직렬화 한다
공유 메모리 패러다임의 해법은 임계 영역(critical section)에 한 번에 하나만 들여보내는 것이다.
가장 원시적인 형태는 깃발 변수를 검사하며 도는 것이다.
acquire() {
/* busy waiting — 락이 풀릴 때까지 CPU를 태우며 돈다 */
while (!available);
available = false;
}
이러한 스핀락(spinlock)은 락 보유 시간이 아주 짧을 때만 효율적이다. 스레드를 재우는(blocking)방식을 쓰면 CPU 낭비는 없지만, 운영체제가 대기 중인 스레드를 잠재우고(lock), 이후 락이 해제되면 다시 깨워야(unpark) 한다. 이 과정에서는 커널 개입에 따른 User Mode ↔ Kernel Mode 전환, 스케줄링, 문맥 전환(Context Switch) 등의 비용이 발생한다.
동시성 도구들의 성능 문제는 대부분 “락이 풀릴 때까지 CPU를 계속 사용하며 기다릴 것인지(spinning), 아니면 운영체제에게 스레드를 잠시 재워 달라고 맡길 것인지(blocking)“의 트레이드오프다.
여러 실행 흐름이 동시에 **공유 자원(shared resources)**에 접근하지 못하도록 보호하기 위해서 다양한 프로그래밍 언어나 라이브러리에서 다양한 동기화 도구를 지원한다.
Java의 synchronized는 공유 상태를 안전하게 다루기 위해 가장 많이 사용되는 동기화 도구다. 모든 Java 객체에는 내부적으로 monitor가 존재하며, synchronized는 이 monitor를 획득한 스레드만 임계 영역에 접근할 수 있도록 만든다.
JVM에서는 이를 monitorenter와 monitorexit라는 바이트코드로 처리한다. 하나의 스레드는 이미 보유한 monitor에 다시 진입할 수 있기 때문에 재진입성(reentrancy)을 제공한다. 또한 monitor 획득과 해제 과정은 스레드 간 메모리 가시성을 보장하여, 한 스레드에서 변경한 값이 다른 스레드에서도 안전하게 관찰될 수 있도록 한다.
Go에서는 sync.Mutex 라이브러리를 통해 임계 영역을 보호한다.
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var mu sync.Mutex // counter를 보호하는 뮤텍스
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 임계 영역 진입 — 이미 누가 잠갔다면 여기서 대기한다
counter++ // 이 순간 counter를 만질 수 있는 고루틴은 하나뿐이다
mu.Unlock() // 잠금 해제 — 대기 중인 고루틴 하나가 진입한다
}()
}
wg.Wait()
fmt.Println(counter) // 항상 1000
}
Mutex는 Lock 획득 > 임계 영역 실행 > Unlock 흐름을 보장하는 매커니즘이다.
Go의 sync.Mutex는 재진입(reentrant)을 지원하지 않는다. 따라서 같은 고루틴이 이미 획득한 Mutex를 다시 Lock() 하려고 하면 deadlock이 발생한다.
아래 Deadlock 예제를 살펴보자.
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
fmt.Println("Increment: Lock 획득")
c.update()
fmt.Println("Increment: 종료")
}
// 내부 함수도 같은 Mutex를 사용한다고 가정
func (c *Counter) update() {
fmt.Println("update: Lock 획득 시도")
c.mu.Lock() // ❌ 여기서 deadlock 발생
defer c.mu.Unlock()
c.value++
}
func main() {
counter := &Counter{}
counter.Increment()
fmt.Println(counter.value)
}
위 코드를 실행하면 아래와 같은 결과를 얻는다.
Increment: Lock 획득
update: Lock 획득 시도
fatal error: all goroutines are asleep - deadlock!
문제의 핵심은 Mutex를 해제할 수 있는 유일한 주체가 현재 Mutex를 보유한 고루틴인데, 그 고루틴 스스로가 다시 같은 Mutex를 기다리면서 진행이 멈춘다는 점이다.
Go가 재진입 Mutex를 지원하지 않는 이유도 여기에 있다. 재진입을 허용하면 이러한 상태를 방지하기 위해 “현재 누가 몇 번 Lock을 획득했는지"를 추적해야 하고, 코드의 호출 관계가 복잡해질수록 락의 소유 관계를 이해하기 어려워진다.
이러한 점은 “Clear is better than clever” 와도 자연스럽게 연결된다. 핵심은 “재진입성이 없어서 불편하다"가 아니라, 숨겨진 락 상태를 줄여 사람이 코드를 해석하는 비용을 낮춘다는 점이다.
락 없이 직렬화 하기 - CAS와 원자 연산
락은 확실하지만, 대기와 문맥 교환이라는 비용이 있다. 카운터 하나 올리자고 고루틴을 재우는 것은 과하다. 그래서 CPU는 읽기-수정-쓰기를 한 번에 처리하는 원자 명령을 제공한다. 대표가 CAS(Compare-And-Swap)다. CAS는 **“내가 읽었던 값과 현재 값이 같다면 변경하라”**라는 의미를 갖는다.
CAS는 세 가지를 받는다. 메모리 위치 M, 기대값 A, 새값 B. 그리고 “M의 현재 값이 A와 같을 때만 B로 바꾼다"를 하드웨어가 하나의 명령으로 수행한다. 중간에 다른 코어가 끼어들 틈이 없다.
대표적으로 자바에서는 AtomicInteger가 있다.
AtomicInteger는 CAS(Compare-And-Swap)를 이용해 Lock 없이 값을 변경한다.
// AtomicInteger 내부 (단순화)
private volatile int value; // 가시성 보장을 위해 volatile
public final int incrementAndGet() {
int current;
do {
current = value; // (1) 현재 값을 읽고
} while (!compareAndSet(current, current + 1)); // (2) "아직 그 값이면 +1"을 시도
return current + 1; // 다른 스레드가 먼저 바꿨으면 (1)부터 재시도
}
위 코드는 실패하면 성공할 때까지 다시 도는 구조라서 락은 없지만(lock-free) 경합이 심할 수록 재시도가 늘어난다.
CAS에는 대표적으로 ABA 문제가 있다. 예를 들어 ThreadA가 현재 값(a)를 읽는다. 그 사이 ThreadB가 값을 변경(b)한다. 그리고 다시 원래 값으로 되돌린다(b->a). 이제 ThreadA가 다시 실행된다. 그러면 ThreadA는 내가 읽었던 값은 A이고 현재 값도 A라고 판단한다. CAS는 성공하지만 실제로는 값이 변하지 않은 것이 아니다.
즉, CAS는 “현재 값이 같은가"는 확인하지만, “그 사이에 변경이 있었는가"까지는 알지 못한다.
Go에서는 sync/atomic이 존재한다.
var counter atomic.Int64 // Go 1.19+ 타입 안전 원자 정수
counter.Add(1) // x86에서는 LOCK XADD 원자 명령 하나로 수행된다
v := counter.Load() // 원자적 읽기 — 가시성도 함께 보장된다
synchronized에서 분산락까지
Spring 환경에서는 이 문제가 애플리케이션 내부를 넘어 데이터베이스 수준으로 확장된다. 하지만 본질은 변하지 않는다. 하나의 변수에 여러 스레드가 접근하는 문제와, 하나의 재고 데이터를 여러 트랜잭션이 동시에 변경하는 문제는 같은 종류의 문제다.
재고 차감이라는 고전적인 예제를 통해 살펴보자.
@Service
class StockService(private val stockRepository: StockRepository) {
@Transactional
fun decrease(id: Long, quantity: Long) {
val stock = stockRepository.findById(id).orElseThrow() // (1) 읽고
stock.decrease(quantity) // (2) 고치고
// 트랜잭션 커밋 시점에 UPDATE (3) 쓴다
}
}
재고 10개에 100개의 스레드가 동시에 1개씩 차감을 요청하면 0이 남아야 하지만, 실제로는 9개 이상이 남는다. counter++와 완전히 같은 모양의 read-modify-write가, 이번에는 DB 행 위에서 벌어진 것이다.
synchronized를 사용하면 문제를 해결할 수 있을까?
@Transactional은 프록시 객체를 만들어 시작 → 대상 메서드 → 커밋 순서로 감싸는데, synchronized는 대상 메서드에만 걸린다. 스레드 A가 메서드를 끝내고(락 해제) 아직 커밋하기 전에, 스레드 B가 락을 잡고 커밋 전의 옛 값을 읽어버린다. 락과 트랜잭션의 경계가 어긋나면서 생기는 경쟁 상태다. 그리고 어렵게 고쳐도 synchronized는 JVM 하나 안에서만 유효하다. 서버가 두 대면 무용지물이다.
이러한 문제를 해결하기 위해서 락을 거는 위치가 점점 바깥으로 밀려난다. DB 수준에서는 비관락, 낙관락 등이 있고 Redis를 활용한 분산락도 존재한다.
두 번째 패러다임 - 소유권을 설계한다
Go의 “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.“라는 철학의 뿌리는 1978년 Tony Hoare의 논문 CSP(Communicating Sequential Processes)다. 공유 변수 대신, 독립적으로 실행되는 프로세스들이 메시지를 주고받으며 협력하는 모델이다. Go는 이것을 고루틴(값싼 실행 흐름)과 채널(타입이 있는 통신 통로)로 언어에 내장했다.
채널의 기본 동작부터 보자.
ch := make(chan int) // int를 실어 나르는 채널을 만든다 (버퍼 없음)
ch <- 42 // 송신: 받는 쪽이 나타날 때까지 여기서 기다린다
v := <-ch // 수신: 보내는 쪽이 나타날 때까지 여기서 기다린다
buf := make(chan int, 3) // 버퍼 3짜리 채널: 3개까지는 기다리지 않고 보낼 수 있다
버퍼 없는 채널의 송신과 수신은 서로를 기다린다. 즉 채널은 데이터 전달과 동기화가 한 동작에 합쳐진 도구다. 이 성질로 아까의 카운터 문제를 다시 풀어보자.
package main
import "fmt"
func main() {
results := make(chan int) // 각 고루틴이 결과를 보내는 통신 통로
for i := 0; i < 1000; i++ {
go func() {
// 공유 변수를 직접 고치는 대신, "1만큼 세어달라"는 값을 보낸다
results <- 1
}()
}
counter := 0 // counter는 main 고루틴의 지역 변수 — 소유자가 하나뿐이다
for i := 0; i < 1000; i++ {
counter += <-results // 채널에서 하나씩 받아 더한다
}
fmt.Println(counter) // 항상 1000. 락도, atomic도 없다.
}
Data Race가 해결된 이유는 counter에 접근하는 고루틴이 처음부터 하나뿐이기 때문이다. 1,000 개의 고루틴은 counter를 만지지 않는다. 값을 보낼 뿐이다. 경쟁할 대상 자체가 없다.
이것이 proverb의 뒷문장, “share memory by communicating"의 실체다.
즉 소유권(ownership)이 통신과 함께 이전되며 “이 데이터는 이제 네 것이다. 나는 더 이상 만지지 않겠다." 라는 의미를 갖는다. 한 시점에 데이터를 만질 수 있는 고루틴이 항상 하나라면, 그 사이의 모든 코드는 단일 스레드 코드처럼 읽고 쓸 수 있다. 락의 획득 순서를 고민할 일도, 해제를 잊을 일도 없다.
그리고 이 소유권 이전은 관례가 아니라 언어 명세가 보증하는 규칙이다. Go 메모리 모델은 “채널 송신은 대응하는 수신의 완료보다 먼저 일어난다(happens-before)“고 정의한다. 아까 volatile 없이는 깨졌던 깃발 예제가, 채널로는 정확해진다.
package main
import "fmt"
var number int
func main() {
done := make(chan struct{}) // 신호 전용 채널 (struct{} 는 크기 0 — 값이 아니라 신호가 목적)
go func() {
number = 42 // (1) 송신 이전의 모든 쓰기는
done <- struct{}{} // (2) 송신
}()
<-done // (3) 수신 — 메모리 모델이 (1) → (3) 이후 읽기의 순서를 보증한다
fmt.Println(number) // 반드시 42
}
채널을 사용하면 개발자는 CPU의 재배치나 스토어 버퍼 같은 하드웨어 수준의 세부 동작을 직접 관리하지 않아도 된다. Go 런타임이 채널 송수신 과정에서 필요한 동기화와 메모리 가시성을 책임진다. 덕분에 개발자가 고민해야 하는 것은 “이 메모리 접근이 happens-before 관계를 만족하는가?” 같은 저수준의 추론이 아니다. 대신 “어떤 고루틴이 데이터를 만들고, 어떤 고루틴이 그것을 소비하는가"라는 더 높은 수준의 프로그램 흐름에 집중할 수 있다.
재고 예제를 Go로 - 상태를 소유한 고루틴
Spring의 재고 차감 문제를 Go 방식으로 다시 풀어보자. 재고를 소유한 고루틴 하나를 세운다.
package main
import "fmt"
// order는 "재고를 qty만큼 차감해달라"는 요청 메시지다.
// 처리 결과를 돌려받을 reply 채널을 메시지에 함께 실어 보낸다.
type order struct {
qty int
reply chan bool
}
// stockKeeper는 재고를 소유한 유일한 고루틴이다.
// stock은 이 함수의 지역 변수(매개변수)라서, 다른 고루틴은 접근할 방법 자체가 없다.
func stockKeeper(stock int, orders <-chan order) {
for o := range orders { // 주문이 올 때마다 하나씩, 순서대로 처리한다
if stock >= o.qty {
stock -= o.qty // 이 순간 stock을 만지는 실행 흐름은 나 하나뿐이다
o.reply <- true // 성공을 알린다
} else {
o.reply <- false // 재고 부족 — 차감 없이 실패를 알린다
}
}
}
func main() {
orders := make(chan order)
go stockKeeper(10, orders) // 재고 10개를 들고 시작하는 관리자 고루틴
results := make(chan bool) // 각 손님의 주문 결과가 모이는 채널
// 100 명의 손님이 동시에 1개씩 주문한다
for i := 0; i < 100; i++ {
go func() {
reply := make(chan bool) // 내 주문의 결과만 받을 1회용 채널
orders <- order{qty: 1, reply: reply} // 주문을 보내고
results <- <-reply // 결과를 받아 집계 채널로 넘긴다
}()
}
success, fail := 0, 0
for i := 0; i < 100; i++ { // main 고루틴이 결과 100개를 회수한다
if <-results {
success++
} else {
fail++
}
}
fmt.Printf("성공 %d건, 실패 %d건\n", success, fail) // 항상: 성공 10건, 실패 90건
}
채널도 결국 공유 메모리 위에 있다.
Go 런타임 내부를 들여다보면 채널 역시 결국 공유 메모리와 동기화 메커니즘 위에 구현되어 있다.
Go 런타임의 채널 구현(runtime/chan.go)을 단순화하면 다음과 같은 구조다.
// hchan — 채널의 실제 구현체 (단순화)
type hchan struct {
qcount uint // 버퍼에 현재 들어있는 원소 수
dataqsiz uint // 버퍼 크기 — make(chan T, n)의 n
buf unsafe.Pointer // 원형 큐(ring buffer) — 힙 위의 공유 메모리다
sendx uint // 다음에 쓸 버퍼 위치
recvx uint // 다음에 읽을 버퍼 위치
recvq waitq // 수신하려고 잠들어 있는 고루틴들의 대기열
sendq waitq // 송신하려고 잠들어 있는 고루틴들의 대기열
lock mutex // 이 모든 필드를 보호하는 락
}
채널 역시 내부적으로는 공유 상태를 가지고 있다. 핵심은 개발자가 어떤 수준의 복잡성을 다뤄야 하는가이다. 직접 Mutex를 사용하면 개발자는 “누가 Lock을 획득하고”, “언제 Unlock"되고, “데이터 경쟁은 발생하지 않는지?” 등을 고민해야 한다. 반면 채널을 사용하면 고민의 중심이 “어떤 고루틴이 데이터를 만들고 어떤 고루틴이 데이터를 소비하는지"로 변경된다.
채널이 효율적인 이유
Go 런타임은 채널 사용 패턴에 맞춰 다양한 최적화를 수행한다.
대표적인 예가 버퍼 없는 채널(unbuffered channel)이다. 버퍼 없는 채널에서는 송신자의 스택에서 수신자의 스택으로 값을 직접 복사한다.
채널을 사용할 때 중요한 차이점은 대기 방식이다.
일반적인 OS 스레드 기반 모델에서는 스레드가 락이나 조건을 기다리게 되면 운영체제 수준에서 해당 스레드를 잠재운다. 이후 작업을 재개하려면 운영체제 스케줄러가 다시 스레드를 깨워야 하며, 이 과정에서 사용자 모드와 커널 모드 사이의 전환 비용이 발생한다.
하지만 Go의 고루틴은 다르다. 채널 송수신이나 동기화 지점에서 대기하는 고루틴은 OS 스레드를 그대로 점유한 채 멈춰 있지 않는다. Go 런타임 스케줄러가 해당 고루틴을 잠시 중단시키고, 같은 OS 스레드에서 실행 가능한 다른 고루틴을 실행한다.
즉, 하나의 고루틴이 대기한다고 해서 하나의 OS 스레드가 함께 멈추는 것이 아니다.
덕분에 Go는 수많은 고루틴이 동시에 대기하는 상황에서도 효율적으로 자원을 활용할 수 있다.
이 차이는 Go가 동시성을 다루는 방식의 핵심이다. 개발자는 “어떤 고루틴이 기다리고 있는가"에 집중하고, “어떤 스레드를 잠재우고 깨울 것인가"와 같은 저수준 스케줄링 문제는 Go 런타임이 담당한다.
결국 Go의 채널과 고루틴은 동시성의 복잡성을 없앤 것이 아니라, 개발자가 직접 추론해야 하는 영역을 줄이고 더 높은 수준의 데이터 흐름에 집중할 수 있도록 만든 설계다.
JDK 21의 가상 스레드(virtual threads)또한 뒤늦게 같은 구조를 채택하였다.
Kotlin의 Coroutine
Kotlin 코루틴을 써봤다면 Go의 Goroutine 낯설진 않을 것이다. Kotlin Coroutine은 Go의 CSP와 Erlang의 Actor 모델에서 많은 영향을 받았고, 이를 Kotlin 방식으로 재해석했다.
kotlinx.coroutines.Channel은 Go의 channel과 마찬가지로 CSP(Communicating Sequential Processes) 스타일의 메시지 전달 모델을 제공한다. 또한 actor 패턴은 상태를 하나의 coroutine이 소유하고, 다른 coroutine은 메시지를 통해서만 접근하는 구조로 Go에서 자주 사용하는 패턴과 같은 방향을 가진다.
분산 시스템에서도 반복되는 두 가지 선택
시야를 서버 한 대 안에서 여러 서버로 넓혀도 동시성 문제의 본질은 크게 달라지지 않는다. 여전히 다음과 같은 질문을 마주한다.
여러 실행 주체가 하나의 상태를 변경하려 할 때, 어떻게 안전하게 처리할 것인가?
선택지는 크게 두 가지가 있다. 하나는 공유 상태를 유지하고 락으로 보호하는 방식이고, 다른 하나는 상태의 소유자를 하나로 정하고 메시지로 요청을 전달하는 방식이다.
Redis가 원자적인 이유는 락이 아니라 소유권이다. Redis는 명령 처리를 싱글 스레드 이벤트 루프 하나가 전담한다. 수만 개의 클라이언트가 동시에 명령을 보내도, 결국 큐에 줄을 서고 루프가 한 번에 하나씩 실행한다. INCR이 원자적인 이유는 정교한 락이 아니라 데이터의 소유자가 하나뿐이기 때문이다. stockKeeper 고루틴과 정확히 같은 구조가 인프라 규모로 확장된 것이다. Lua 스크립트는 여기서 한 걸음 더 나가서, “읽고-판단하고-쓰는” 로직 전체를 메시지로 만들어 소유자에게 보내 통째로 실행시킨다. 통신으로 로직을 전달하는 셈이다.
반면 Redisson 분산락은 공유 메모리 패러다임의 분산 확장이다. 여러 서버가 하나의 락 키를 두고 경쟁하고, 락을 쥔 채 죽는 서버를 위해 leaseTime을 두고, 만료가 커밋보다 빨랐을 때를 위해 낙관적 락을 겹친다. 흥미로운 점은 Redisson 분산락 자체도 내부적으로는 Lua Script를 사용한다는 것이다. 락 획득과 해제 과정은 여러 Redis 명령을 조합해야 하는 작업이기 때문에, 이를 Redis 내부에서 원자적으로 실행하기 위해 Lua Script를 활용한다.
즉, 공유 자원을 보호하기 위한 도구조차 내부적으로는 단일 소유자에게 원자적인 작업을 전달하는 방식에 의존하고 있다.
메시징 시스템으로 시야를 넓혀도 같은 패턴이 반복된다. 재고 차감 문제를 Kafka로 해결한다고 생각해보자. 상품 ID를 파티션 키로 사용하면 같은 상품에 대한 주문 이벤트는 항상 같은 파티션으로 전달된다. 상품 ID를 파티션 키로 삼아 한 상품의 주문이 모두 같은 파티션에 쌓이게 하고, 그 파티션을 컨슈머 하나가 순서대로 처리하게 만든다. 채널 앞의 stockKeeper와 같은 그림이다.
언제 채널을 사용하고, 언제 뮤텍스를 사용해야 할까?
| 상황 | 도구 |
|---|---|
| 데이터의 소유권을 넘겨야 할 때 | channel |
| 작업 단위를 여러 고루틴에 분배할 때 | channel |
| 비동기 결과를 돌려받아야 할 때 | channel |
| 캐시, 상태, 카운터를 보호할 때 | mutex / atomic |
결국 판단 기준은 하나다.
보호하려는 대상이 하나의 데이터 자체라면 Mutex가 더 적합하다. 예를 들어 특정 맵이나 카운터 값을 여러 실행 흐름이 동시에 변경하지 못하도록 막는 것이 목적이라면, 단순히 접근을 직렬화하는 락이 가장 명확한 해결책이다.
반면 고민하는 대상이 데이터가 어떻게 생성되고 이동하는가라면 채널과 메시지 전달 방식이 더 적합하다. 상태를 여러 곳에서 직접 변경하는 대신, 하나의 소유자가 데이터를 관리하고 다른 실행 흐름은 메시지를 통해 요청하는 구조다.
하지만 모든 문제를 채널로 풀려고 하는 것은 좋은 설계가 아니다.
단순히 맵 하나를 보호하기 위해 여러 고루틴과 채널을 만들면, 오히려 문제의 본질보다 통신 구조를 이해하는 비용이 커질 수 있다. 이는 Go가 말하는 Clear is better than clever와 반대되는 방향이다.
Don’t communicate by sharing memory; share memory by communicating이라는 proverb는 Mutex 사용을 금지하는 규칙이 아니다. 모든 상황에서 채널을 사용하라는 의미도 아니다.
핵심은 동시성 설계를 시작할 때 “어디에 락을 걸 것인가?“보다 먼저 다음 질문을 던지는 것이다.
이 데이터의 소유자는 누구인가?
소유자가 명확하다면 메시지 전달을 통해 구조를 단순하게 만들 수 있고, 단순한 공유 데이터라면 Mutex가 더 명확한 선택일 수 있다.
좋은 동시성 설계는 특정 도구를 고집하는 것이 아니라, 데이터의 소유권과 변경 흐름을 가장 이해하기 쉬운 형태로 표현하는 것이다.
맺음말 — 접근 제어에서 소유권 설계로
다시 처음의 문장으로 돌아가 보자.
Don’t communicate by sharing memory; share memory by communicating.
이 문장의 핵심은 명확한 소유권 설계를 통해 경쟁이 발생하기 어려운 구조를 만들고 동시성의 복잡성을 사람이 이해할 수 있는 구조로 만드는 것이다.
References
- Rob Pike, “Go Proverbs” (Gopherfest 2015)
- C. A. R. Hoare, “Communicating Sequential Processes” (CACM, 1978)
- Effective Go — Share by communicating
- The Go Memory Model
- Andrew Gerrand, “Share Memory By Communicating” (The Go Blog, 2010)
- Brian Goetz, 『Java Concurrency in Practice』
