왜 Go는 동시성을 언어 차원에서 다루는가
스레드 하나에 8MB, 그래서 다들 콜백으로 도망쳤다
전통적인 OS 스레드는 비싸다. 리눅스 기본 스택 크기가 8MB인데, 요청마다 스레드 하나씩
띄우는 서버를 만들면 동시 접속 1만 명에 80GB의 스택 메모리가 필요하다는 계산이 나온다.
실제로는 그전에 컨텍스트 스위칭 비용이 먼저 발목을 잡는다. 그래서 Node.js는 이벤트 루프와
콜백으로, JavaScript와 Python은 뒤이어 async/await로 도망쳤다. 스레드를 아예 안 쓰고
싱글 스레드에서 논블로킹 I/O로 버티는 전략이다.
이 전략은 I/O 바운드 작업에서는 잘 먹힌다. 문제는 CPU 바운드 작업이거나, 여러 작업의 결과를
합쳐야 하는 순간이다. async/await는 결국 "언젠가 값이 온다"는 약속(Promise/Future)을
함수 시그니처 전체에 전염시킨다. 동기 함수 하나를 비동기로 바꾸면 그 함수를 호출하는 모든
곳이 async가 되어야 하는 경험을 해본 적이 있다면 무슨 말인지 알 것이다. Go는 이 문제를
아예 다른 방향에서 풀었다 — 스레드를 없애는 대신 스레드를 싸게 만들었다.
고루틴은 스레드가 아니다
고루틴의 초기 스택 크기는 2KB다(버전에 따라 다르지만 KB 단위인 건 변함없다). 필요하면
런타임이 알아서 스택을 늘리고 줄인다. OS 스레드처럼 커널이 관리하는 게 아니라 Go 런타임이
자체 스케줄러로 수만~수십만 개를 동시에 굴린다. go func() 한 줄이면 고루틴이 뜨는데, 이게
async 함수를 정의하는 것과 다른 점은 호출하는 쪽 코드가 전혀 오염되지 않는다는 것이다.
평범한 함수를 그냥 go로 감싸 호출하면 된다.
func fetchUser(id int) User {
// 평범한 동기 함수. async 키워드도, Promise 반환도 없다.
return db.QueryUser(id)
}
func main() {
go fetchUser(1) // 그냥 앞에 go를 붙였을 뿐
// ...
}물론 이렇게 결과를 버리는 코드는 거의 의미가 없다(실제로 이 챕터 뒤에서 왜 위험한지도
다룬다). 요점은 문법적으로 "비동기 색"이 함수 타입에 스며들지 않는다는 것이다. Rust의
async fn이나 JavaScript의 async function처럼 별도의 타입 세계가 생기지 않는다. 이건
설계 철학의 차이이지 어느 쪽이 절대적으로 우월하다는 뜻은 아니다 — Go는 그 대가로 컴파일
타임에 "이 함수가 어디서 실행되는가"를 추적할 수 없다. 실수는 런타임에 터진다.
CSP: 메모리를 공유하지 말고, 통신으로 공유하라
Go 동시성 모델의 뿌리는 Tony Hoare가 1978년에 제안한 CSP(Communicating Sequential Processes)다. 핵심 아이디어는 단순하다 — 여러 실행 흐름이 같은 메모리를 락으로 지키며 공유하는 대신, 값을 채널에 실어서 주고받으라는 것. Go 공식 슬로건이 정확히 이 문장이다.
Do not communicate by sharing memory; instead, share memory by communicating.
이게 왜 중요한지는 뮤텍스 기반 코드를 유지보수해 본 사람이라면 안다. 락을 어디서 걸고 어디서 풀어야 하는지, 어떤 순서로 락을 잡아야 데드락이 안 나는지는 코드베이스가 커질수록 암묵적 지식이 되어 버린다. 채널은 그 암묵적 규칙을 타입 시스템과 문법으로 드러낸다 — "이 고루틴은 이 채널로만 값을 받는다"는 게 코드에 그대로 적힌다.
flowchart LR A["고루틴 A<br/>작업 생성"] -->|"채널로 전송"| C["channel"] C -->|"채널에서 수신"| B["고루틴 B<br/>작업 처리"]
다만 오해하지 말아야 할 게 있다. Go가 뮤텍스를 없앤 게 아니다. sync.Mutex는 표준
라이브러리에 멀쩡히 있고, 실무에서도 채널만큼 자주 쓴다. Go 팀 스스로도 "항상 채널을
써야 한다"고 말한 적이 없다 — 카운터 하나 증가시키는 데 채널과 고루틴을 새로 만드는 건
과잉 설계다. 이 책 5장에서 언제 채널 대신 뮤텍스를 쓰는 게 맞는지 구체적으로 다룬다.
지금 단계에서는 "Go는 두 가지 도구를 다 주고, 상황에 맞게 고르라고 한다" 정도로
이해하면 충분하다.
이 책에서 다루는 범위
이 책은 Go 문법 전체를 훑지 않는다. 이미 Go 문법을 어느 정도 아는 상태에서, 동시성이라는
한 축만 깊게 판다. 고루틴이 뜨고 죽는 원리, 채널의 정확한 블로킹 규칙, select로 여러
채널을 다루는 법, context로 취소를 전파하는 관용구, 그리고 실무에서 제일 많이 밟는
지뢰 — 고루틴 누수와 데이터 레이스 — 를 실제 코드와 함께 짚는다. 마지막 장에서는 워커
풀, 파이프라인 같은 패턴을 직접 만들어 보면서 "이 패턴이 정말 필요한 상황인가"까지
같이 판단해 본다.