스레드와 비동기 환경은 처음 접하면 상당히 까다롭게 느껴집니다. 상호작용을 정리할 좋은 멘탈 모델이 없으면 쉽게 문제에 부딪히고 예상치 못한 결과를 마주하게 됩니다. 게다가 적절한 도구나 테스트 패턴 없이는 비동기 코드를 테스트하는 일 자체가 어려운 작업이 됩니다.
스레드를 '사람'으로, 공유 객체를 한 번에 한 사람만 소유할 수 있는 '사물'로 생각하면 멀티스레드 시스템의 동작 방식을 훨씬 체계적으로 이해할 수 있습니다. 이번 글에서는 하나의 예제를 통해 Ruby 비동기 코드를 테스트하는 모든 과정을 살펴보겠습니다.
Rails, Rack 또는 웹 브라우저를 프런트엔드로 사용하는 어떤 애플리케이션이든 이미 비동기 환경 안에 있습니다. Rack의 #call은 항상 비동기적으로 호출되기 때문입니다. 의식하지 못했더라도 여러분은 이미 멀티스레드 컴포넌트를 사용하고 있을 가능성이 큽니다.
테스트: 트리거(Trigger), 수집(Collect), 검증(Check)
비동기 콜백 API의 테스트는 세 단계 패턴, 즉 트리거, 수집, 검증을 따르면 동기 방식처럼 만들 수 있습니다. 각 스레드를 독립된 개인으로, 객체는 한 번에 한 사람만 소유할 수 있는 사물로 상상해 보세요.
여기서는 배트맨과 그의 7벌의 수트를 예제로 사용합니다. 도시를 구하러 나가야 하는 순간에 모든 수트가 알프레드의 세탁물 속에 있는지 아는 것이 얼마나 중요한지 누구나 공감할 수 있는, 실질적인 예제이기 때문입니다.
예제: 배트케이브의 빨래하는 날
예제의 시나리오는 알프레드가 배트맨의 수트를 세탁하는 상황입니다. SuitWashScheduler는 각 세탁 이벤트마다 콜백을 호출하는 스케줄러입니다. 시작한 지 1초 후부터 1초 간격으로 총 7번의 콜백을 발생시키며, 이때 트리거(trigger)는 SuitWashScheduler의 생성입니다.
class SuitWashScheduler
def initialize(cnt)
Thread.new {
cnt.times {
sleep(1.0)
yield
}
}
end
end결과 수집하기
결과를 수집하는 과정은 경쟁 상태(race condition)를 피하기 위해 반드시 스레드 세이프(thread-safe)해야 합니다. 둘 이상의 스레드가 공유하는 객체는 반드시 보호되어야 하며, 이 보호란 곧 객체의 소유자(owner)를 추적하는 방법입니다. 오직 소유자만 객체를 변경하거나 조회할 수 있습니다. 마치 한 벌의 수트가 전투 중인 배트맨에게 있거나, 세탁 중인 알프레드에게 있어야 하는 것과 같습니다.
원활한 협업을 위해 스레드(비유하자면 배트맨이나 알프레드)는 짧은 시간 동안만 소유권을 가진 뒤 이를 반납합니다. 이때 소유자를 추적하는 데 일반적으로 Mutex가 사용됩니다. SuitWashScheduler의 콜백은 카운터를 증가시키는 동안 그 결과 카운터의 소유권을 가지며, 스케줄러 스레드에서 실행되는 콜백은 카운터가 목표 값에 도달하면 모든 결과가 수신되었음을 알립니다.
예제 작성은 몇 가지 전역 변수 설정부터 시작합니다. 실제 애플리케이션에서는 이 전역 변수들이 클래스나 객체의 속성으로 대체될 것입니다.
$main_thread = Thread.current
$mu = Mutex.new
$count = 0
$target = 7관리와 소유권
$main_thread와 $mu는 스레드를 관리하고 테스트 완료를 기다리는 데 사용되며, $target과 $count는 테스트 결과를 추적합니다. 지금은 아주 단순한 테스트이므로 결과의 수집과 검증도 최대한 간단하게 유지합니다.
테스트는 SuitWashScheduler의 새 인스턴스를 생성하면서 시작되며, 초기화 메서드에는 반복 횟수인 $target을 전달합니다. 여기서는 세탁이 필요한 7벌의 수트가 해당됩니다. 이때 전달된 블록은 SuitWashScheduler 스레드에서 실행되며, 매 반복마다 $count가 증가하고 출력됩니다.
조금 더 들여다보면, 메인 테스트 스레드 역시 $count를 검사해야 한다는 것을 알 수 있습니다. 즉 메인 스레드도 $count의 소유권이 필요하므로, 소유권을 확보할 수단이 필요합니다. 바로 $mu라는 Mutex 인스턴스가 소유권 토큰 역할을 합니다. SuitWashScheduler.new 호출에 전달되는 블록 안에서 $mu.synchronize 블록이 $count를 설정하고 결과를 확인하는 데 필요한 짧은 시간 동안 소유권을 가져옵니다. 결과 검증에 대해서는 잠시 후 자세히 다루겠습니다.
SuitWashScheduler.new($target) {
$mu.synchronize {
$count += 1
puts $count
$main_thread.wakeup if $target <= $count
}
}검증: 모든 수트가 끝났는가?
메인 스레드로 돌아오면 이제 테스트가 끝나기를 기다려야 합니다. 배트맨이 7벌의 수트가 모두 완성되기를 기다려야 하는 것처럼요. 여기서 확인해야 할 조건은 두 가지입니다. 테스트가 예상대로 $count를 갱신하는 경우, 혹은 배트맨이 지루함을 참지 못하고 타임아웃되는 경우입니다. $count가 $target에 도달했는지 검사하기 전에 먼저 $count의 소유권을 확보해야 합니다. SuitWashScheduler의 블록에서 그랬던 것처럼 $mu.synchronize를 호출하면 됩니다.
'그런데 잠깐, 메인 스레드를 잠가 버리면 SuitWashScheduler 스레드는 어떻게 $count를 변경할 수 있을까?' 하는 의문이 드는 것이 당연합니다. 다행히 이 문제를 해결하는 깔끔한 트릭이 있습니다. Mutex 클래스에는 #sleep 메서드가 있어, 소유권을 반납한 채 타임아웃이 되거나 깨워질 때까지 대기할 수 있습니다. 타임아웃 또는 메인 스레드에 대한 #wakeup 호출로 깨어난 후에는 $mu가 다시 소유권 확보를 시도한 뒤 계속 진행합니다. 소유권을 확보한 이후에야 결과를 검사하고 테스트의 통과 여부를 판정할 수 있습니다.
$mu.synchronize {
$mu.sleep($target + 1)
if $target != $count
puts 'FAILED'
else
puts 'Passed! All suits are washed and clean'
end
}더 깊이 파고들고 싶다면 예제를 조금 더 흥미롭게 확장해 볼 수 있습니다. 여러 개의 스케줄러를 생성해서 Mutex가 $count 변경 충돌을 어떻게 막아 주는지 직접 확인해 보세요. 배트맨이 수트 일부는 알프레드에게, 나머지는 드라이클리너에게 맡기는 상황을 시뮬레이션하는 것입니다. 이때 $target 검사 로직이 모든 예상 yield의 합계를 기준으로 하도록 수정하는 것을 잊지 마세요.
마무리
적절한 멘탈 모델만 있다면 스레드와 비동기 환경에서의 작업은 훨씬 수월해집니다. 이 글에서는 사람을 스레드의 은유로, 한 번에 한 스레드(또는 한 사람)만 소유할 수 있는 물리적 객체(수트)를 공유 객체의 은유로 사용했습니다. 이러한 추상화 방식으로 사고하면 이해하고 기억하기가 훨씬 쉬워집니다.
이 예제들이 비동기의 동작 원리를 기억하는 데 도움이 되기를 바랍니다. 다만 모든 수트가 세탁 중인 상태에서 배트맨이 출동해야 하는 그 곤란한 장면은 너무 오랫동안 기억 속에 남지 않기를 바랄 뿐입니다.
P.S. 블로그의 배트맨 비유가 이제 지겨우시다면 언제든 알려주세요.