Go, Java, Erlang이 동시성에서 ‘메시지 전달’이 실제로는 어떤 의미인지 드러내는 방식과, 그것이 공유 가변 상태의 고질적 문제를 어떻게 다른 이름으로 되풀이하는지 살펴본다.
CausalitySeriesAboutSubscribeRSS
February 2026
Go, Java, Erlang이 동시성의 상태에 대해 무엇을 드러내는가
동시성 프로그램을 작성하는 방식에는 언제나 어딘가 잘못됐다는 느낌이 들었다. 새 언어를 집어 들고 그 동시성 모델을 보면 늘 같은 불편한 감정이 올라온다. API는 바뀌고, 용어도 바뀌지만, 밑바닥의 패턴은 이상할 정도로 익숙해 보인다.
당신도 이런 느낌을 받은 적이 있을지 모른다. 도구는 좋아지고, 추상화는 더 세련돼지는데, 핵심 문제는 좀처럼 사라지지 않는다.
진지한 프로젝트에서 동시성을 다뤄본 소프트웨어 개발자라면 멀티스레드 및 동시성 프로그램의 함정과 싸우며 생긴 상처가 있다. 예민하고 종종 투박한 API와 동기화 메커니즘, 데이터 레이스와 데드락을 디버깅해야 한다는 공포, 그리고 모든 것이 비국소적이라 머리를 비틀어야 하는 그 감각 말이다.
이것들이 왜 이렇게 어긋나 보이는지 말로 설명할 수 있을 때까지는 시간이 좀 걸렸지만, 이제는 준비가 된 것 같다. 비교적 최근의 언어인 Go에서 시작해보자.
2006년, Edward Lee는 _The Problem with Threads_를 발표했다. 그의 주장은 단호했다. 스레드는 “극도로 비결정적”이며, 그 결과 프로그래머의 일이 계산을 표현하는 것이 아니라 그 비결정성을 가지치기(pruning)하는 일이 된다는 것이다.
하지만 Lee는 스레드를 비판하는 데서 더 나아갔다. 그는 공유 메모리 vs. 메시지 전달 논쟁이 거짓 이분법이라고 주장했다. 두 접근 모두 동시성을 조율되어야 하는 실행 스레드들의 집합으로 모델링한다. 조율 메커니즘을 락에서 메시지로 바꾼다고 해서 근본 모델이 바뀌는 것이 아니라, 실패의 문법이 바뀔 뿐이다.
당시 이는 반주류적(contrarian) 입장이었고, 주류 언어들은 오히려 그 반대 방향으로 움직이고 있었다.
3년 뒤 Go는 정반대의 배팅 위에 세워진 동시성 철학과 함께 등장했다. Go 문서는 “메모리를 공유해서 통신하지 말라”고 권했다. “대신 통신해서 메모리를 공유하라.” 채널(타입이 있고, 일급인 메시지 전달 프리미티브)은 동시성 난맥상에 대한 Go의 해답이었다.
Go는 장난감이 아니었다. Google의 지원을 등에 업고, 현대 인터넷을 굴리는 인프라에 채택됐다. Docker, Kubernetes, etcd, gRPC, CockroachDB 같은 것들 말이다. 이 시스템들은 현존하는 Go 코드베이스 중에서도 가장 많이 사용되는 축에 속하며, 폭넓은 코드 리뷰와 테스트 관행을 갖춘 숙련된 팀들에 의해 유지된다. 수만 명의 개발자가 Go의 지침을 따라 동시성 코드를 작성했고, 뮤텍스나 락 대신 채널을 사용하며, 통신으로 메모리를 공유했다.
이는 업계가 수행한 메시지 전달 가설의 시험 중 가장 눈에 띄고, 자원이 풍부하며, 현실 세계에서의 테스트였다.
2019년 Tengfei Tu와 동료들은 이런 대표적 Go 프로젝트들에서 실제 동시성 버그 171개를 연구해 _Understanding Real-World Concurrency Bugs in Go_를 발표했다. 결과는 인상적이었다. 메시지 전달 버그는 공유 메모리 버그만큼, 혹은 그보다 더 흔했다.
블로킹 버그(즉 goroutine이 멈춰 더 이상 진행하지 못하는 경우)의 약 58%는 공유 메모리가 아니라 메시지 전달 때문에 발생했다. 치료제가 될 것이라던 것이 질병과 같은 문제를 만들어낸 셈이다.
분명히 하자면, 메시지 전달은 동시성 버그의 중요한 한 부류, 즉 동기화되지 않은 메모리 접근을 제거한다. 두 goroutine이 채널을 통해서만 통신한다면 같은 변수를 동시에 변경할 수 없다. 하지만 데이터 레이스를 없앤다고 해서 조율 실패가 사라지는 것은 아니다. 데드락, 누수, 프로토콜 위반, 비결정적 스케줄링은 여전히 남는다.
Go에는 내장 데드락 탐지기가 있지만, 연구진이 테스트한 21개의 블로킹 버그 중 단 2개만 잡아냈다. 둘. 레이스 탐지기는 논블로킹 버그에서 성능이 더 나아 대략 절반을 잡았지만, 이는 곧 프로덕션 Go 코드의 동시성 버그 절반이 그 버그를 찾도록 설계된 도구들에 보이지 않는다는 뜻이기도 하다.
대부분의 버그는 수명이 길었다. 커밋되고, 배포되고, 프로덕션에서 돌아갔으며, 누군가 우연히 ‘딱 맞는’ 인터리빙을 밟을 때까지 발견되지 않았다. 테스트로도 잡히지 않았고, 코드 리뷰로도 잡히지 않았다. 대신 가장 면밀히 검토되는 Go 코드베이스들 속에 숨어 있었다.
조율 메커니즘을 바꾼다고 해서 근본 원인이 해결되지 않는다는 Lee의 예측은 확인되었다.
다음은 논문에 나온 Kubernetes의 단순화된 버그다. 어떤 함수가 타임아웃을 둔 요청 처리를 위해 goroutine을 스폰한다:
func finishReq(timeout time.Duration) ob {
ch := make(chan ob)
go func() {
result := fn()
ch <- result // blocks forever if timeout wins
}()
select {
case result = <-ch:
return result
case <-time.After(timeout):
return nil
}
}
fn()이 타임아웃보다 오래 걸리면 부모는 nil을 반환하고, 그 뒤로는 아무도 ch에서 읽지 않는다. 자식 goroutine은 ch <- result에서 블로킹되고 영원히 정리되지 않는다. Go는 객체는 가비지 컬렉션하지만, 영원히 읽히지 않을 채널에서 블로킹된 goroutine까지 가비지 컬렉션하지는 않는다.
Kubernetes(프로덕션 컨테이너 인프라를 관리하는 그 시스템)에서 이렇게 누수된 goroutine 하나하나가 참조를 붙잡고 메모리를 놓지 않는다. 부하가 걸리면 이들이 누적되고, 프로세스는 서서히 성능이 악화되다가 결국 크래시가 나거나 OOM-kill된다. 이는 채널의 버퍼 하나가 빠졌다는 단 한 가지 이유로, 다른 소프트웨어가 계속 돌아가도록 책임지는 소프트웨어에서 발생한 신뢰성 실패다.
수정은 문자 하나다: make(chan ob)를 make(chan ob, 1)로 바꾸면 된다.
이제 Java에서 같은 로직을 보자:
BlockingQueue<Result> queue = new ArrayBlockingQueue<>(1);
new Thread(() -> {
Result result = computeResult();
try { queue.put(result); } // blocks if queue is full
catch (InterruptedException e) { }
}).start();
try {
Result result = queue.poll(timeout, TimeUnit.SECONDS);
if (result != null) {
return result;
} else {
return null;
// thread still running, still blocked on put()
// queue object still holds a reference
// nothing will ever clean this up
}
} catch (InterruptedException e) { return null; }
어떤 Java 개발자도 이것을 보고 “나는 메시지 전달을 하고 있다”고 말하지 않을 것이다. 대신 BlockingQueue가 java.util.concurrent에 있고 Mutex와 Semaphore 옆에 있으니 “공유되는 동시성 큐를 쓰고 있다”고 말할 것이다. 그리고 그것이 공유 가변 상태가 지닌 모든 위험을 그대로 갖고 있다는 것도 알고 있을 것이다.
하지만 이것이 바로 Go 채널 코드다. 같은 공유 가변 데이터 구조, 같은 블로킹 의미론, 같은 버그. 타임아웃이 발화하면 아무도 큐에서 소비하지 않으니 생산자는 영원히 블로킹된다. 스레드는 누수된다. 구조는 동일하고, 바뀐 것은 어휘뿐이다.
Java에서는 이를 공유 동시성 큐라고 부르며 위험을 이해한다. Go에서는 이를 채널이라고 부르고, 마치 다른 무언가인 척한다.
메시지 전달은 종종 공유 가변 상태의 대안으로 제시되지만, 실제로는 다른 이름으로 공유 조율 구조를 자주 다시 들여온다.
Arthur O’Dwyer는 이 논문을 다루며, Go 채널의 “원죄(original sin)”를 지적했다. 그것은 채널이 사실 채널이 아니라는 것이다. 채널에는 서로 다른 타입과 능력을 가진 두 개의 분리된 엔드포인트가 있다. 생산자 쪽과 소비자 쪽이다. 마지막 소비자가 사라지면 런타임은 이를 감지해 생산자의 블로킹을 풀고 정리할 수 있다.
Go 채널에는 이런 것이 없다. 그것은 단일 객체, 즉 동시성 큐 하나이며, 참조를 들고 있는 goroutine이 몇 개든 그 사이에 공유된다. 어떤 goroutine이든 보낼 수 있고, 어떤 goroutine이든 받을 수 있다. 분리된 엔드포인트도 없고, 방향성을 가진 타이핑도 없으며, 런타임이 한쪽이 사라졌음을 감지할 방법도 없다. 이는 여러 스레드 사이에 공유되는 가변 데이터 구조로, 어떤 스레드든 push/pop으로 공유 상태를 변경할 수 있다.
이를 한 번 보고 나면, 연구에서 나타난 버그 범주가 놀랍기보다는 예측 가능해진다. 공유 가변 상태의 모든 고전적 실패 양상에는 채널 버전이 있다:
Deadlock. goroutine A가 채널로 보내고 다른 채널에서 응답을 기다린다. goroutine B는 반대로 한다. 둘 다 블로킹된다. 이는 공유 상태에 대한 순환 의존성으로, 락 대신 큐로 표현됐을 뿐 뮤텍스 데드락과 같은 구조다. 이런 문제는 Docker, Kubernetes, gRPC에서 발견됐다.
Leak. 아무도 채널에서 읽지 않으면 송신자는 영원히 블로킹된다. 공유 큐가 goroutine에 대한 참조를 유지해 정리를 막는다. 위 Kubernetes 버그가 바로 이 패턴이다. 공유 상태에 대한 매달린 참조(dangling reference) 때문에 발생한 리소스 누수다.
Race. 여러 goroutine이 같은 채널에서 읽으면, 어느 쪽이 각 메시지를 받는가? 답은 비결정적이다. 런타임 스케줄러가 하나를 고른다. 이는 공유 자원에 대한 동시 접근이며, 비결정성이 명시적 락이 아니라 스케줄러를 통해 매개될 뿐이다. 논문은 etcd와 CockroachDB에서 이런 사례를 문서화한다.
Protocol violation. 어떤 goroutine이 수신자가 기대하지 않는 메시지를 보내거나, 닫힌 채널로 send해서(Go에서는 패닉이 난다), 혹은 이미 닫힌 채널을 다시 닫는다. 공유 객체의 암묵적 계약이 위반된 것으로, 공유 가변 상태가 늘 만들어내던 것과 같은 범주의 버그다.
이 모든 것은 메시지 전달 의상을 걸친 고전적 공유 가변 상태 버그다.
그리고 이것은 Go만의 문제가 아니다. 동시성 모델로서의 메시지 전달은 공유 상태를 제거하지 않고, 그것을 _이전_시킨다. 전달되는 데이터는 송신자에서 수신자로 깔끔하게 이동할지 모르지만, 통신 메커니즘 자체(채널, 메일박스, 메시지 큐)는 공유 가변 자원이다. 그리고 그 자원은 공유 가변 상태가 언제나 갖고 있던 모든 문제를 그대로 상속한다.
Erlang도 이를 보여준다. Erlang 프로세스는 진짜로 격리되어 각자 별도의 힙을 가지며, 공유 참조가 없고, 메시지는 프로세스 사이에서 복사된다. 이는 어디에서도 찾아보기 힘든 가장 강한 형태의 메시지 전달 보장인데도, 연구자들은 Erlang 자체의 철저히 테스트된 표준 라이브러리에서 이전에 알려지지 않았던 레이스 조건을 찾아냈다.
그 레이스들은 ETS 테이블 주변에 몰려 있었다. ETS는 순수한 actor 격리에서 빠져나오는 Erlang의 탈출구로, 순수 actor 모델이 성능 요구를 충족하지 못했기 때문에 존재하는 공유 가변 저장소다. 안전 모델은 격리를 약속했지만, 현실은 공유 가변 탈출구를 요구했다. 그리고 그 탈출구는 모델이 막으려 했던 바로 그 버그들을 정확히 다시 들여왔다.
메시지 전달이 동시성 버그를 해결하는 방식은, 한 방의 어질러진 것을 다른 방으로 옮기는 것이 잡동사니를 해결하는 방식과 같다.
Go 프로그래머가 채널 데드락을 맞닥뜨리고 뮤텍스를 집어 들까 고민할 때, 그들은 같은 구조적 이유로 실패하는 두 접근 사이에서 선택하고 있는 것이다. “Go 채널은 올바르게 사용하면 괜찮다”는 말은 참이다. “뮤텍스는 올바르게 사용하면 괜찮다”도 참이다. 둘은 같은 문장이다.
Lee는 2006년에 이를 보았다. 공유 메모리 vs. 메시지 전달 논쟁은 어떤 조율 메커니즘을 사용할지에 대한 논쟁이다. 그것은 우리가 애초에 올바른 질문을 하고 있는지조차 묻지 않았다.
이분법의 양쪽이 모두 실패한다면, 이분법 자체가 틀렸을지도 모른다. 문제는 동시 실행을 조율하기 위해 _어떤 도구_를 쓰느냐가 아닐지도 모른다. 어쩌면 두 접근이 공유하는 기반에 더 깊은 무언가가 있고, 우리가 아직 그것을 질문하지 않았을지도 모른다.
나는 그렇다고 생각한다. 어떤 언어들은 다른 기반을 시도했고 문제의 측면들을 실질적인 통찰로 공격했지만, 그 어느 것도 주류로 완전히 돌파하지는 못했다. 왜 그런지 탐구해볼 가치가 있고, 이제 우리가 향할 곳이 바로 거기다.
Discuss:Hacker NewsLobstersReddit
새 글이 게시되면 알림을 받으세요. 스팸도 없고, 추적도 없고, 아이디어만 있습니다.
Subscribe
Josh Segall · Bluesky