공유 가변 상태를 뮤텍스로 동기화하는 접근의 한계를 은행 계좌 예제로 짚고, 데이터 레이스, 원자성, 합성, 데드락 문제를 설명한 뒤, 액터/CSP 같은 패턴과 Software Transactional Memory(STM)를 대안으로 제시합니다.
여러 개의 병렬 CPU 코어를 쓰는 일은 전혀 새삼스러운 일이 아닙니다. 사람들은 이미 반세기 동안 병렬 프로그래밍을 해 왔죠. 하지만 최근엔 분기점에 와 있습니다. 무어의 법칙은 사그라들고, 강력한 단일 코어만으로는 더 이상 따라잡지 못합니다. 현대 컴퓨터는 여러 CPU 코어를 기본으로 제공하니 병렬 계산을 활용하는 게 어느 때보다 중요해졌습니다. 이 분야의 역사가 오래되었으니, 자연스레 효과적인 도구가 자리 잡았고 이제 스레드 동기화는 사소한 문제가 되었겠죠...?
안타깝게도 제 경험은 그렇지 않았고, 아마 여러분도 마찬가지일 겁니다. 스레드 간 공유 상태를 다루는 일은 어렵고, 가장 흔히 쓰는 도구인 뮤텍스와 세마포어는 탄생 이후 별로 진화하지 못했습니다.
이 글은 뮤텍스와 공유 가변 상태 동기화에 내재한 문제를 파고들고, 이후 더 도움이 될 만한 다른 길을 살펴봅니다.
먼저 왜 동기화가 필요한지 보여 주는 간단한 소프트웨어 시스템을 만들어 봅시다.
자주 쓰이는 예를 들어 보죠. 병렬 이체 요청이 들어와도 은행 계좌 잔액을 올바르게 관리하는 작업입니다.
물론 실제 은행은 모든 계좌 잔액을 RAM에 담아 두지 않습니다. 그러니 이 교육용 예제의 개념을 각자의 도메인에 맞게 적용해 보시길 바랍니다. 이 예제는 여러 스레드 사이에서 임의 데이터의 임시 동기화가 필요한 충분히 복잡한 어떤 시스템이든 대체할 수 있는 비유입니다.
여기 간단한 은행 계좌와 그 연산을 위한 고(Go) 언어풍 의사 코드가 있습니다(제발 실제로 컴파일하지 마세요). 여기서는 동기화 문제에 집중하니, 복식부기, 입력 검증, 기타 현실 세계의 복잡성은 생략합니다.
struct Account {
balance int,
}
// 계좌에 입금
func (a *Account) deposit(amount int) {
a.balance += amount
}
// 출금. 잔액이 부족하면 false 반환
func (a *Account) withdraw(amount int) bool {
if (a.balance < amount) {
return false
} else {
balance -= amount
return true
}
}
좋습니다! Account 타입과 입금/출금 메서드를 정의했습니다. 이제 계좌 간 이체 함수를 추가해 봅시다.
func transfer(from *Account, to *Account, amount int) bool {
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}
그럴듯해 보이죠. 그런데 동시에 여러 요청을 처리하기 시작하면 어떻게 될까요?
struct TransferRequest {
from *Account,
to *Account,
amount int,
}
func main() {
// 무한 루프: 이체 요청을 받고 고루틴으로 처리
for {
req := acceptTransferRequest()
go transfer(req.from, req.to, req.amount)
}
}
운이 좋다면(혹은 나쁘다면) 테스트에서는 잘 돌아갈 수 있고, 프로덕션에서도 한동안은 잘 동작할 수 있습니다. 하지만 머지않아 돈이 사라지고, 고객들이 혼란스럽고 화난 상태로 찾아올 겁니다.
왜일까요? 여기서 우리가 해결해야 할 첫 번째 동기화 문제, 데이터 레이스로 이어집니다.
대부분의 프로그래밍 언어는 가변 자료구조를 가진 명령형 언어입니다 [출처 필요]. 따라서 포인터를 여러 스레드에 넘기면 공유 가변 데이터가 생기고, 공유 가변 데이터는 필연적으로 데이터 레이스를 야기합니다.
데이터 레이스는 둘 이상의 스레드가 동시에 같은 메모리 위치에 접근하고, 그중 적어도 하나가 쓰기를 수행하며, 그 접근 순서가 비결정적일 때 발생합니다. 데이터 레이스가 있으면 동일한 코드와 동일한 상태로 두 번 실행하더라도 결과가 비결정적으로 달라질 수 있습니다.
여기서는 계좌를 참조로 전달하므로, 여러 스레드가 같은 계좌를 수정할 수 있습니다. 동일 계좌에 대한 여러 이체 고루틴이 동시에 수행되는 동안, 스케줄러는 거의 임의의 지점에서 각 고루틴을 중단시킬 수 있습니다. 즉, 이 단순한 예제 안에서도 이미 데이터 레이스가 도입되었습니다. withdraw 함수를 다시 보죠. 문제가 되는 부분을 표시해 보겠습니다.
// 출금. 잔액이 부족하면 false 반환
func (a *Account) withdraw(amount int) bool {
hasFunds := a.balance >= amount
// 여기! 스케줄러가 여기서 실행을 멈추고 다른 스레드로 전환할 수 있습니다
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}
앨리스 계좌에 $150만 있는데 두 스레드가 각각 $100을 출금하려 한다고 합시다. 스레드 1이 잔액을 확인해 충분하다고 판단한 직후 스케줄러에 의해 중단될 수 있습니다. 그 사이 스레드 2가 실행되어 역시 잔액이 충분하다고 보고 $100을 출금합니다. 이후 스레드 1이 중단 지점(검사 이후)에서 다시 실행되면 역시 $100을 출금해 버립니다. 결과적으로 앨리스 계좌는 -$50의 음수 잔액이 되어 버립니다. 검증을 했는데도 유효하지 않은 상태가 된 것이죠!
이런 종류의 동시성 오류는 특히 교묘합니다. 원래의 withdraw 메서드는 단일 스레드 프로그램에서는 지극히 합리적이고 관용적이며 옳습니다. 하지만 시스템의 전혀 다른 곳에서 병렬성을 도입하는 순간, 기존의 올바르던 코드 깊숙한 곳에 버그가 생깁니다. 단일 스레드에서 다중 스레드로의 지극히 정상적인 진화가, 경고 한 마디 없이 전혀 관련 없어 보이는 코드에 치명적인 시스템 붕괴급 버그를 일으킬 수 있다는 건, 대놓고 말해 완전히 용납할 수 없습니다. 장인으로서 저는 제 도구에게 더 나은 것을 기대합니다.
좋아요, 수천, 수백만 달러를 날려 먹었는데, 이걸 어떻게 고치죠?
전통적인 지식은 우리를 뮤텍스로 이끕니다.
공유 가변 상태에서 발생한 문제를 보았습니다. 전통적 해결책은 이른바 "임계 구역"에서의 배타적 접근을 강제하는 것입니다. 뮤텍스라는 이름은 mutual exclusion의 줄임말로, 특정 가상 리소스에 한 번에 하나의 스레드만 접근할 수 있게 합니다.
다음처럼 프로그램을 수정해 뮤텍스로 데이터 레이스 문제를 고칠 수 있습니다.
struct Account {
mutex Mutex,
balance int,
}
func (a *Account) deposit(amount int) {
a.mutex.lock()
defer a.mutex.unlock()
a.balance += amount
}
func (a *Account) withdraw(amount int) bool {
a.mutex.lock()
defer a.mutex.unlock()
hasFunds := a.balance >= amount
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}
이제 각 Account에는 배타적 잠금 역할을 하는 뮤텍스가 달렸습니다.
분주한 식당의 화장실 열쇠를 떠올려 보세요. 화장실을 쓰려면 열쇠를 가져갑니다. 화장실마다 열쇠는 하나뿐이니, 열쇠를 가지고 있는 동안에는 아무도 그 화장실을 쓸 수 없습니다. 볼일을 본 다음 벽의 걸이에 열쇠를 돌려놓죠.
하지만 화장실 열쇠와 달리 뮤텍스는 개념적 잠금일 뿐, 실제 잠금이 아닙니다. 그래서 사실상 명예 시스템으로 동작합니다.
프로그래머가 뮤텍스 잠금을 잊어버리더라도 시스템이 그를 막아 주지 않습니다. 심지어 잠그는 데이터와 잠금 자체 사이에 실제 연결도 없습니다. 프로그래머가 그 합의를 이해하고 지켜줄 것이라 믿어야 합니다. 양쪽 모두 위험한 가정입니다.
이 경우 withdraw와 deposit 내부의 데이터 레이스는 뮤텍스로 해결했지만, 여전히 transfer 함수에는 문제가 남아 있습니다.
transfer 함수가 withdraw와 deposit 호출 사이에서 선점된다면 무슨 일이 벌어질까요? 어떤 계좌에서는 돈이 빠져나갔지만, 다른 계좌에는 아직 입금되지 않았을 수 있습니다. 이는 시스템의 불일치 상태입니다. 돈이 잠시 사라진 채 스레드의 운영 메모리에만 존재하고, 외부에서 관찰 가능한 상태에는 보이지 않습니다. 이렇게 되면 정말 이상한 동작이 발생합니다.
구체적으로 이상함을 관찰하기 위해 모든 계좌의 잔액을 출력하는 report 함수를 작성해 봅시다.
func report() {
for _, account := range accounts {
account.mutex.lock()
fmt.Println(account.balance)
account.mutex.unlock()
}
}
이체가 진행되는 동안 report를 실행하면, 시스템 내 총액이 올바르지 않거나 보고 때마다 바뀌는 것을 보게 될 가능성이 큽니다. 폐쇄계에서는 일어날 수 없는 일이어야 하죠! 이 불일치는 각 계좌의 잔액을 확인하기 전에 해당 계좌의 락을 획득하더라도 발생할 수 있습니다.
큰 시스템에서는 이런 불일치가 단순한 로직에도 결함을 일으킬 수 있습니다. 불일치한 시스템 상태에 근거해 의사결정을 하기 때문입니다. 문제의 근원은 transfer 함수가 독립적인 여러 락을 잡아야 하지만, 그것들이 원자적 작업으로 묶여 있지 않다는 데 있습니다.
최소한 뮤텍스를 존중하는 다른 스레드의 관점에서는 전체 이체 작업이 원자적이도록 만들어야 합니다.
좋습니다. 두 계좌를 모두 잠그면 되겠네요?
func transfer(from *Account, to *Account, amount int) bool {
from.mutex.lock()
to.mutex.lock()
defer from.mutex.unlock()
defer to.mutex.unlock()
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}
눈치 빠른 독자는 이미 여기서 문제가 보일 겁니다. 하지만 두 가지나 보셨나요?
첫 번째는 지적하면 자명합니다. 기억하세요. withdraw와 deposit도 해당 계좌의 뮤텍스를 잠급니다. 즉, 같은 스레드에서 같은 락을 두 번 얻으려 하고 있습니다.
transfer는 이런 상태에서는 시작조차 못 합니다. withdraw 내부에서 from.mutex를 두 번째로 잠그려는 순간 영원히 블록됩니다.
일부 시스템(재진입 가능 락이나 Java의 synchronized 키워드 등)은 같은 스레드가 같은 뮤텍스를 여러 번 잠글 수 있도록 부가적인 기록을 해 줍니다. 재진입 가능 락을 쓰면 이 특정 문제는 해결됩니다. 하지만 Go 같은 다른 시스템은 원칙적으로 재진입 가능 락을 제공하지 않습니다(https://groups.google.com/g/golang-nuts/c/XqW1qcuZgKg/m/Ui3nQkeLV80J 참고).
그럼 어떻게 하죠? withdraw와 deposit에서 락을 제거하고, 대신 transfer에서 잠그도록 해야겠습니다.
func (a *Account) deposit(amount int) {
a.balance += amount
}
func (a *Account) withdraw(amount int) bool {
hasFunds := a.balance >= amount
if (hasFunds) {
balance -= amount
return true
} else {
return false
}
}
func transfer(from *Account, to *Account, amount int) bool {
from.mutex.lock()
to.mutex.lock()
defer from.mutex.unlock()
defer to.mutex.unlock()
if (from.withdraw(amount)) {
to.deposit(amount)
return true
} else {
return false
}
}
읔, 올바른 transfer 함수는 개념적으로 캡슐화가 잘 된 withdraw와 deposit의 합성이어야 합니다. 그런데 올바르게 정의하려다 보니 두 함수에서 락을 제거해야 했고, 둘 다 덜 안전해졌습니다. 잠금의 부담을 호출자에게 떠넘긴 셈인데(시스템 차원의 보장이 전혀 없음), 더 나쁜 점은 이제 코드베이스 전체의 모든 withdraw와 deposit 호출마다 잠금을 추가해야 한다는 겁니다. 모든 것을 모듈 안에 캡슐화하고 안전한 연산만 export하려 해도, 이제 동기화된 버전과 비동기화된 버전의 withdraw와 deposit을 중복해서 제공해야 합니다. 그리고 Account가 아닌 다른 데이터와 함께 연산을 동기화하려면 뮤텍스도 외부로 노출해야 하죠.
결국 뮤텍스는 합성되지 않는다는 얘기입니다! 여러 임계 구역을 하나의 원자적 단위로 잇는 방법을 제공하지 못합니다. 오히려 캡슐화를 깨고, 깊은 구현 내부에서 어떤 불변식이 유지되어야 하는지에 대한 세부사항을 호출자에게 떠넘깁니다. 동기화된 변수 접근을 연산에 추가하거나 제거할 때마다 모든 호출 지점에서 잠금을 추가/제거해야 하며, 그 호출 지점은 완전히 다른 애플리케이션이나 라이브러리일 수도 있습니다. 완전한 혼돈이죠.
그렇게 나쁘게만 들리는데, 믿기 어렵겠지만 이게 전부가 아닙니다. 합성만 망가진 게 아닙니다. transfer를 원자적 연산으로 고치는 과정에서, 새롭고 더 찾기 어려운 데드락 버그까지 심었습니다.
기억하세요. 메인 루프에서는 임의의 이체 요청을 받아 고루틴으로 실행하고 있습니다. 두 건의 이체 요청이 있다고 합시다. 앨리스는 방금 밥에게서 산 빈백 의자 값 $25를 베넘으로 보내려 하고, 한편 밥은 앨리스에게 빚진 기괴 앨(Weird Al) 공연 티켓 값 $130를 베넘으로 보내려 합니다.
공교롭게도 두 요청이 동시에 제출되면, transfer가 이렇게 두 번 호출됩니다.
각 호출은 먼저 from 계좌를 잠그고, 그 다음 to 계좌를 잠급니다. 앨리스와 밥이 아주 운이 없다면, 시스템은 첫 번째 transfer를 시작해 앨리스 계좌를 잠근 뒤 스케줄러에 의해 중단됩니다. 두 번째 transfer 호출이 들어오면 먼저 밥 계좌를 잠근 뒤 앨리스 계좌를 잠그려 합니다. 하지만 이미 첫 번째 transfer가 잠근 상태라 잠글 수 없습니다.
고전적인 데드락 상황입니다. 두 스레드는 영원히 멈춰 있고, 더 나쁜 건 시스템이 재시작될 때까지 앨리스와 밥의 계좌가 모두 잠긴 상태라는 점입니다.
이건 이토록 사소한 예제에서도 발견하기 어려운 문제치고는 결과가 매우 참담합니다. 실제 시스템에서는 수십, 수백 개의 메서드가 조합 폭발적으로 병렬화되니 이런 문제는 매우 다루기 어렵고, 락을 안전하고 일관된 순서로 획득하도록 보장하는 데 많은 노력이 듭니다.
Go는 데드락과 데이터 레이스를 탐지하는 런타임 도구를 제공한다는 점에서 어느 정도 공을 세웁니다. 훌륭하죠. 하지만 이런 탐지는 테스트가 문제를 맞닥뜨렸을 때만 도움이 됩니다. 애초에 문제가 발생하지 않도록 막아주지는 못합니다. 대부분의 언어는 이런 도움조차 제공하지 않으니, 프로덕션에서 이런 이슈를 추적하기가 매우 어렵습니다.
우리가 스스로 만든 쓰레기 더미가 참으로 장관이군요...
제가 예제를 일부러 최악의 버그를 한꺼번에 건드리도록 설계했을 수는 있지만, 제 경험상 충분한 시간과 복잡성이 주어지면 이런 문제들은 결국 어떤 시스템에서든 나타납니다. 이를 뮤텍스로 해결하는 것은 특히 위험합니다. 처음엔 효과적인 해결책처럼 보이기 때문이죠. 뮤텍스는 작고 국소적인 경우에는 잘 동작합니다. 그래서 우리를 유혹하죠. 하지만 시스템이 유기적으로 커지면 뮤텍스를 무리하게 확장하다가 대규모로 참사가 일어나고, 온갖 해키한 우회책이 생깁니다. 저는 두 손 모으고 잘 되기만을 비는 태도는 적절한 소프트웨어 공학 전략이 아니라고 생각합니다.
요컨대 뮤텍스로 올바른 소프트웨어 시스템을 설계하는 건 가능하지만, 매우 어렵습니다. 하나를 고치려 할 때마다 둘씩 문제를 낳습니다.
지금까지 겪은 문제를 요약해 봅시다.
제 생각에, 우리는 이 글에서나 업계 전반에서나 뮤텍스를 한계를 넘어 남용해 왔습니다. 뮤텍스는 하나의 리소스만 잠그고, 그 리소스가 같은 모듈의 몇몇 함수에서만 접근되는 작고 잘 정의된 범위에서는 훌륭합니다. 하지만 수많은 상호 작용 구성 요소를 포함하고 수십, 수백 명의 개발자가 유지보수하는 크고 복잡한 시스템에는 통하지 않습니다. 우리는 도구를 발전시켜 더 신뢰할 수 있는 해법을 찾아야 합니다.
다행히도, 뮤텍스에 과의존해 왔음에도 불구하고 업계는 1960년대 이후로 몇 가지를 배웠습니다. 특히 기본값으로 불변성을 강제하는 것이 여기서 아주 큰 효과를 냅니다. 많은 프로그래머에게 이는 익숙한 것과는 다른 패러다임 전환이라 보통 약간의 불편함을 유발합니다. 안전벨트도 초창기에는 답답하다며 종종 외면받았지만, 시간이 지나면서 약간의 불편함이 제공하는 안전을 충분히 상쇄한다는 인식이 자리 잡았습니다.
더 많은 언어(Haskell, Clojure, Erlang, Gleam, Elixir, Roc, Elm, Unison, ...)가 이를 깨닫고 핵심 설계 원칙으로 채택하고 있습니다. 물론 모든 프로그래머가 하룻밤 사이에 불변성 우선 언어로 옮길 수는 없습니다. 하지만 병렬성이 프로젝트의 큰 축을 이룬다면 불변 언어를 진지하게 고려하는 것이 좋습니다.
불변 자료구조를 사용하면 데이터 레이스는 그 즉시 방지됩니다. 끝. 그러니 가능한 한 불변 데이터를 유지하세요. 하지만 불변의 세계에서도 병렬 프로세스를 동기화할 방법은 필요하고, 이를 위해 대부분의 언어는 어떤 형태로든 가변 참조를 제공합니다. 결코 기본값은 아니고, 보통 타입 시스템에 추가 의식이나 추적이 들어가 공유 가변 상태가 개입되었음을 즉각 알리는 이정표가 있습니다. 여기 용(드래곤)이 산다, 같은 경고죠.
더 나아가 수십 년간의 학술 및 산업 연구는 뮤텍스나 가변 참조 같은 저수준 동기화 원시 위에 구축된, 전장 검증을 거친 고수준 동시성 패턴들을 풍부하게 제공해 왔고, 대개 프로그래머에게 훨씬 더 안전한 인터페이스를 노출합니다.
액터 시스템과 CSP(Communicating Sequential Processes)는 가장 흔한 동시성 오케스트레이션 패턴입니다. 이들은 각기 독립적인 하위 프로그램을 정의하고, 각 하위 프로그램은 자신만 접근할 수 있는 고립된 상태를 갖습니다. 각 액터/프로세스는 다른 단위로부터 메시지를 받아 처리하고 응답합니다. 각각 따로 글이나 발표 하나씩은 할 수 있는 주제이니 여기서는 깊이 파고들지는 않겠습니다. 처음 들어보셨다면 꼭 더 찾아보세요.
이 접근법은 작업 병렬성(task parallelism)에 훌륭합니다. 독립적으로 실행할 프로세스가 있고, 병렬성의 필요가 실행할 작업 수로 경계 지어질 때 특히 좋습니다. 예시로, 저는 Unison의 코드 동기화 프로토콜을 만들 때 액터 기반 시스템을 사용했습니다. 코드 로드/요청 송신을 담당하는 액터 하나, 코드 수신/압축 해제 액터 하나, 수신한 코드의 해시를 검증하는 액터 하나. 동기화하는 항목이 얼마나 많든 정확히 3명의 작업자만 협력하면 됐습니다. 액터/CSP 시스템은 조정해야 할 작업자/작업 수가 정적으로 알려져 있을 때(고정된 수의 작업자, 미리 정의된 맵리듀스 파이프라인 등) 훌륭한 선택입니다. 각 액터/프로세스는 공유 가변 상태 접근을 동기화할 걱정 없이 자신만의 코어에서 독립적으로 실행되므로, 다수의 코어는 물론 여러 대의 머신으로도 잘 확장됩니다.
하지만 병렬성이 동적이거나 임시(ad-hoc)인 문제도 있습니다. 즉, 런타임에 임의로 스폰된 다수의 동시 액터가 서로 잘 조정되어야 하는 경우죠. 이런 경우에는 시스템이 흔들리기 시작합니다. 저는 컨설턴트들이 동적으로 액터를 도입하는 복잡한 패턴, 리소스당 하나의 액터, 트리 기반 액터 리소스 계층 등 복잡한 아이디어를 설명하는 것을 보았지만, 제 생각엔 이런 시스템은 곧 어떤 개발자도 완전히 이해하고 디버깅할 수 없는 지경에 이릅니다.
그렇다면 은행 계좌 같은 시스템은 어떻게 모델링해야 할까요? 이체 작업자를 고정된 수로 제한하더라도 그들은 여전히 같은 데이터(은행 계좌)에 동시 접근하고, 계좌 간 원자적 이체를 표현해야 합니다. 이는 액터나 CSP만으로는 쉽게 달성할 수 없습니다.
어쩌란 말이냐?
대부분의 경우에는 스트리밍 시스템, 액터, CSP가 가장 효과적이고 이해하기 쉽습니다. 하지만 다수의 작업자에 걸쳐 개별 데이터 조각을 동기화해야 하고, 여러 데이터 조각에 걸친 연산을 원자적으로 수행해야 하는 경우에는, 일을 제대로 해내는 단 하나의 이름이 있습니다.
STM(Software Transactional Memory)은 지금까지 마주한 모든 문제를 해결하면서 더 많은 안전성, 더 나은 합성 가능성, 더 깔끔한 추상화를 제공하는, 터무니없이 저평가된 동기화 도구입니다. 대부분의 데드락과 라이브락도 방지한다는 말 했던가요?
STM이 어떻게 동작하는지 이해하려면 데이터베이스 트랜잭션을 떠올리면 됩니다. 데이터베이스 트랜잭션에서 격리는 동시 접근에도 일관된 데이터 뷰를 제공합니다. 각 트랜잭션은 다른 읽기/쓰기에 의해 흐트러지지 않은 고립된 데이터 뷰를 봅니다. 모든 읽기/쓰기를 마친 후 커밋합니다. 커밋 시 트랜잭션은 완전히 성공해 데이터 스냅샷에 모든 변경사항을 한꺼번에 적용하거나, 충돌이 발생할 수 있습니다. 충돌이 생기면 트랜잭션은 실패하고 모든 변경을 롤백해 아무 일도 없었던 것처럼 만든 뒤, 새로운 데이터 스냅샷으로 재시도합니다.
STM도 거의 같은 방식으로 동작하지만, 데이터베이스의 행과 열 대신 일반적인 메모리 내 자료구조와 변수를 대상으로 합니다.
이 기법을 살피기 위해 예제를 Haskell로 바꿔 뮤텍스 대신 STM을 사용해 봅시다.
data Account = Account {
-- 동기화가 필요한 데이터는 트랜잭셔널 변수(TVar)에 담습니다
balanceVar :: TVar Int
}
-- 계좌에 입금
deposit :: Account -> Int -> STM ()
deposit Account{balanceVar} amount = do
-- TVar 연산으로 데이터를 다루며 STM 트랜잭션을 구성합니다
modifyTVar balanceVar (\existing -> existing + amount)
-- 출금
-- `do` 블록 안의 모든 것은 같은 트랜잭션의 일부입니다.
-- 우리가 접근/변경하는 TVar에 대한 일관된 뷰를 보장합니다.
withdraw :: Account -> Int -> STM Bool
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing < amount
then (return False)
else do
writeTVar balanceVar (existing - amount)
return True
-- 두 계좌 간 이체를 원자적으로 수행
transfer :: Account -> Account -> Int -> STM Bool
transfer from to amount = do
-- 두 개의 개별 트랜잭션이 자연스럽게
-- 하나의 더 큰 트랜잭션으로 합성되어,
-- 개별 연산을 바꿀 필요 없이 일관성을 보장합니다.
withdrawalSuccessful <- withdraw from amount
if successful
then do
deposit to amount
return True
else
return False
이제 뮤텍스로 겪은 문제들을 하나씩 다시 훑으며, 새 접근법이 어떤지 보겠습니다.
데이터 레이스는 언어 수준에서 해결하는 게 최선이라고 봅니다. 앞서 말했듯이 기본적으로 불변 데이터를 쓰면 데이터 레이스는 아예 존재하지 않습니다. Haskell의 데이터는 기본이 전부 불변이므로, 일반 코드 어디에서 선점이 발생하더라도 데이터 레이스가 없다는 걸 알고 있습니다.
가변 데이터가 필요할 땐 그 데이터를 TVar로 감싸 명시합니다. 언어는 더 나아가 이 변수들을 트랜잭션 안에서만 변경할 수 있게 보호하고, 우리는 이들을 결합해 데이터의 일관되고 손상되지 않은 뷰가 보장되는 연산을 구성합니다.
withdraw를 STM과 balanceVar TVar를 사용하도록 바꿔 봅시다.
-- 출금
withdraw :: Account -> Int -> STM Bool
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing < amount
then (return False)
else do
-- 여기에는 데이터 레이스가 없습니다!
writeTVar balanceVar (existing - amount)
return True
우리가 작성한 코드는 동기화되지 않은 Go 버전과 매우 비슷해 보이지만, STM을 사용하니 데이터 레이스로부터 완벽히 안전합니다! 연산 도중 스레드가 선점되더라도 트랜잭션 상태는 트랜잭션이 커밋될 때까지 다른 스레드에 보이지 않습니다.
STM은 낙관적 동시성(optimistic concurrency) 시스템입니다. 즉, 스레드는 락을 기다리며 블록되지 않습니다. 대신 각 동시 연산은 자기만의 독립적인 트랜잭션 로그 위에서(가능하다면 병렬로) 진행합니다. 각 트랜잭션은 자신이 접근/변경한 데이터 조각을 추적하고, 커밋 시점에 누군가 다른 트랜잭션이 커밋되어 이 트랜잭션이 접근한 데이터도 변경되었음이 감지되면, 후자의 트랜잭션이 롤백되고 단순히 재시도됩니다.
이 구조는 락 기반 배타적 접근과는 근본적으로 다릅니다. STM에서는 락을 전혀 다루지 않습니다. 필요한 데이터를 트랜잭션 안에서 읽고 쓰면 됩니다. 우리의 transfer 함수는 두 개의 서로 다른 TVar를 읽고 쓰지만, 이 변수들에 대한 배타적 락을 얻는 게 아니니 데드락 걱정을 전혀 할 필요가 없습니다. 만약 두 스레드가 같은 TVar들에 대해 동시에 transfer를 수행하려 든다면, 먼저 커밋한 쪽이 두 계좌에 대한 갱신을 원자적으로 적용하고, 다른 트랜잭션은 커밋 시점에 이 갱신을 감지해 새로운 잔액을 기준으로 재시도하게 됩니다.
이로 인해 충돌과, 같은 데이터를 동시에 갱신하려는 스레드가 아주 많을 때 특정 트랜잭션의 기아(starvation)가 생길 수는 있습니다. 하지만 충돌이 일어난다는 것은 누군가 다른 트랜잭션이 커밋되었다는 뜻이므로, 시스템이 최소한 일부 작업에서는 전진한다는 보장은 있습니다. Haskell에서 STM 트랜잭션은 순수(pure)해야 하며 IO를 할 수 없기에, 대부분의 트랜잭션은 비교적 짧게 실행되고 결국에는 진행됩니다. 단점처럼 보일 수 있지만, 실제로는 드물게 성가신 정도이고 보통 큰 어려움 없이 우회할 수 있습니다.
Haskell에 익숙하지 않으면 타입만 보고는 바로 와닿지 않을 수 있지만, withdraw, deposit, transfer는 모두 결과를 STM 모나드로 감싼 함수를 반환합니다. 이는 atomically 함수로 트랜잭션에서 실행하도록 요청할 수 있는 연산들의 시퀀스입니다.
STM으로 감싼 어떤 임의의 메서드라도 호출하면 현재 트랜잭션의 일부로 자동으로 합류합니다.
뮤텍스 설정과 달리, 호출자는 withdraw나 deposit을 호출할 때 직접 락을 처리할 필요가 없습니다. 또한 안전을 위해 이러한 메서드의 "동기화된" 특수 버전을 따로 노출할 필요도 없습니다. 우리는 한 번만 정의하고, 그 하나의 정의를 단독으로 쓰든 transfer 같은 더 복잡한 연산 안에서 쓰든 추가 작업 없이 그대로 사용할 수 있습니다. 추상화는 새지 않습니다. 호출자가 어떤 동기화된 데이터에 접근하는지, 어떤 뮤텍스를 잠그고 풀어야 하는지 알 필요가 없습니다. 트랜잭션을 실행하면 STM 시스템이 나머지를 기쁘게 처리해 줍니다.
실제로 STM 트랜잭션을 실행하는 모습은 다음과 같습니다. atomically 함수를 사용합니다.
main :: IO ()
main = do
forever $ do
req <- acceptTransferRequest
-- 각 이체를 그린스레드에서, 원자적 트랜잭션으로 실행
forkIO (atomically (transfer req.from req.to req.amount)
앞서처럼 모든 계좌 잔액을 집계하는 리포트를 만들고 싶다면, 그것도 할 수 있습니다. 이번에는 시스템의 일관성 없는 스냅샷을 우연히 얻는 일은 없습니다. 대신 타입 시스템이 어떤 동작을 원하는지 명시적으로 선택하도록 강제합니다.
선택지는 두 가지입니다.
이는 시스템 개발자가 반드시 고민해야 할 정당한 트레이드오프입니다.
두 가지 구현은 다음과 같습니다.
-- 일관성 없는 리포트: 돈이 나타났다/사라졌다 할 수 있음
reportInconsistent :: [Account] -> IO ()
reportInconsistent accounts = do
for_ accounts $ \Account{balanceVar} -> do
balance <- atomically (readTVar balanceVar)
print balance
-- 일관성 있는 리포트: 이체가 너무 빈번하면 무기한 재시도될 수 있음
reportConsistent :: [Account] -> IO ()
reportConsistent accounts = do
balances <- atomically do
for accounts $ \Account{balanceVar} -> do
readTVar balanceVar
-- 스냅샷을 얻었으니 이제 출력
for_ balances print
STM의 마지막 장점 하나를 더 이야기하겠습니다. 동기화된 데이터의 조건에 기반한 "똑똑한 트랜잭션 재시도"를 지원한다는 점입니다. 예컨대 앨리스 계좌에서 $100을 출금해야 하지만 현재 $50만 있다면, 뮤텍스 기반 시스템은 출금을 실패로 처리하고 실패를 호출 스택 위로 반환하는 수밖에 없습니다. 나중에 다시 시도하도록 그 호출을 감쌀 수는 있지만, 언제 다시 시도하는 것이 합리적일까요? 이것도 다시 호출자가 구현 세부사항과 어떤 락을 접근하는지 이해해야 합니다.
STM은 실패와 재시도를 일급 개념으로 지원합니다. STM 트랜잭션 안에서 언제든 retry를 호출할 수 있습니다. 그러면 그 시점까지 트랜잭션이 접근한 모든 TVar를 기록하고, 현재 트랜잭션을 중단한 뒤, 그 TVar 중 하나라도 다른 트랜잭션의 성공으로 변경될 때까지 잠들어 있습니다. 바쁜 대기(busy-waiting)를 피하고, 매우 간단하고 우아한 코드를 작성할 수 있게 해 줍니다.
예를 들어, 다음은 우리의 withdraw 함수를 새로 쓴 버전입니다. 실패를 반환하는 대신, 충분한 잔액이 확보될 때까지 현재 스레드를 블록하고, 해당 계좌의 잔액이 다른 트랜잭션의 성공으로 변경될 때만 재시도합니다.
-- 출금: 충분한 잔액이 있을 때까지 블록
withdraw :: Account -> Int -> STM ()
withdraw Account{balanceVar} amount = do
existing <- readTVar balanceVar
if existing < amount
then retry
else do
writeTVar balanceVar (existing - amount)
이 예제처럼 며칠, 몇 주가 걸릴 수도 있는 이벤트를 기다리는 데 쓰지는 않겠지만, 채널 대기, 미래 값(future)의 결과 대기, 단기 조건 충족 대기 등의 문제에는 매우 우아하고 효율적인 해법입니다.
다음은 두 개의 STM 큐를 zip하는 유틸리티 예제입니다. 두 큐 모두에 값이 있어야 트랜잭션이 성공해 결과를 내고, 그렇지 않으면 큐 중 하나가 변경될 때만 재시도합니다. readTQueue는 큐가 비어 있으면 내부적으로 retry를 호출하기 때문입니다.
zipQueues :: TQueue a -> TQueue b -> STM (a, b)
zipQueues q1 q2 = do
val1 <- readTQueue q1
val2 <- readTQueue q2
return (val1, val2)
멋지죠!
이 글에서 정말 많은 내용을 다뤘습니다. 단 하나만 가져가시길 바란다면, 공유 가변 상태에 뮤텍스를 쓰는 접근이 그 고유의 비용과 복잡성을 상쇄할 만큼의 유틸리티를 정말 제공하고 있는지 생각해 보셨으면 합니다. 최고 성능이 꼭 필요한 게 아니라면, 이렇게 위험한 도구를 쓰기 전에 두 번 생각해 보세요. 대신, 사용 사례에 맞다면 액터, CSP, 스트리밍, 맵리듀스 같은 동시성 패턴을 고려해 보세요.
더 유연하거나 더 저수준의 제어가 필요하다면, 사용 중인 언어에서 제대로 지원한다는 전제하에 STM(Software Transactional Memory)은 훌륭한 선택입니다. 다만 모든 언어가 이를 지원하는 것은 아니며, 지원하더라도 변수와 자료구조가 가변적이라면 충분한 안전 보장을 제공하지 못할 수도 있습니다.
동시성이나 병렬성이 일급 관심사인 신규 프로젝트를 시작한다면, STM을 제대로 지원하는 언어를 시도해 보세요. Unison이나 Haskell을 강력히 추천합니다.
뭔가 배우셨길 바랍니다 🤞! 그러셨다면 제 Patreon(https://www.patreon.com/join/ChrisPenner)에 가입해 제 프로젝트를 팔로우하시거나, 제 책도 확인해 보세요. 이 책은 Haskell과 다른 함수형 언어에서 옵틱스를 사용하는 원리를 가르치고, 초급자에서 시작해 온갖 종류의 옵틱스에 통달한 마법사(!)로 이끌어 드립니다. 여기에서 구할 수 있습니다: https://leanpub.com/optics-by-example/. 한 권 한 권의 판매가 이런 블로그 글을 더 많이 쓰도록 저를 독려하고, 교육적인 함수형 프로그래밍 콘텐츠를 계속 만드는 데 큰 도움이 됩니다. 감사합니다!