동기 취소, 비동기 취소, 우아한 종료의 차이를 설명하는 짧은 글입니다.
2026년 8월 31일
동기 취소, 비동기 취소, 우아한 종료의 차이를 설명하는 짧은 글입니다. 이 세 가지 구체적인 용어 자체에 크게 집착하는 것은 아니지만, 서로 혼동하지 않는 것이 중요한 그 뒤의 세 가지 _개념_에 주목해 주셨으면 합니다.
동기 취소는 종종 암묵적인 제어 흐름 구조입니다. 스택을 되감으며 다음과 같은 형태를 띱니다:
task.cancel();
// The task will have finished by this point.
동기 취소는 몰리에르의 산문과 조금 비슷합니다. 우리는 늘 그렇게 하지만, 반드시 완전히 의식하면서 하지는 않습니다. 동기 취소의 주된 원천은 오류 처리입니다. Exception이 던져지거나 error가 반환될 때마다, 코드는 즉시 모든 루프, 조건문, 블록에서 빠져나오며 RAII, finally, 리소스를 사용하는 with/try, 또는 defer를 통해 필요한 정리 작업을 호출합니다.
비동기 취소는 두 당사자 사이의 통신 프로토콜입니다. 한쪽 당사자가 취소를 요청하고(동기적으로), 다른 쪽 당사자가 이를 확인하고 정리될 때까지 기다려야 합니다. 다음과 같은 형태입니다:
task.request_cancelation();
// The task could still be running here.
task.join().await;
// After the requisite wait, the task is finished.
동기 취소와 마찬가지로, 충돌하거나 멈추지 않는 동시성 프로그램을 구현할 때 이는 비교적 저수준의 관심사입니다. 비동기 취소가 필요한 핵심적인 예는 두 가지가 있습니다.
첫째는 CPU 스레드 풀입니다. 사용자 요청을 처리하는 과정에서 버퍼를 암호화하는 작업을 별도 스레드로 넘겼다고 상상해 봅시다. 얼마 뒤 그 요청을 취소해야 한다는 사실을 알게 됩니다(가령 사용자가 떠났을 수 있습니다). 암호화 중인 스레드를 그냥 버릴 수는 없습니다. 우선 쓸모없는 작업에 CPU 사이클을 낭비하지 않는 것이 현명하겠지만, 더 중요하게는 기반 _버퍼_가 계속 점유된 상태로 유지되어야 합니다. 요청 취소의 결과로 버퍼가 해제되면, 다른 무언가가 그 메모리를 재사용하여 데이터 경합으로 이어질 수 있습니다.
하지만 그 스레드를 그냥 동기적으로 취소할 수도 없습니다! 그것은 고도로 최적화된 SIMD 루프 한가운데에 있고, 매 바이트를 읽기 전에 취소 플래그를 확인하게 하고 싶지는 않을 것입니다. 원하는 방식은 버퍼를 적절한 크기의 청크로 나누고 매 청크 뒤에 취소 상태를 확인하는 것입니다. 그러나 이는 취소를 요청한 당사자가 적어도 청크 하나를 처리하는 데 걸리는 시간만큼 기다려야 한다는 뜻입니다!
여기서 또 다른 예는 io_uring입니다. 정확히 같은 형태입니다. 버퍼와 함께 쓰기 작업을 커널에 제출했다면, 그 쓰기가 끝날 때까지 해당 버퍼는 점유된 상태여야 합니다(더 빨리 끝나게 하려고 쓰기 작업을 취소할 수 있습니다). io_uring은 여전히 적어도 어느 정도는 이국적인 기술이지만(논쟁의 여지 없이, 미로 같은 것은 오히려 이전에 우리가 사용하던 인터페이스들입니다), 스레드 풀 예시는 비동기 취소라는 현상 자체가 상당히 일상적임을 보여 줍니다.
동시성 소프트웨어를 작성할 때 비동기 취소는 늘 등장합니다. 이것이 코드 전체의 형태에 영향을 미치므로, 일찍 식별하는 것이 유용합니다. 반대로, 정말 비동기 취소가 필요한지, 동기 취소로 해결할 수는 없는지 자문하는 것도 유용합니다. 특히 동기 취소는 너무 쉽게 만들고 비동기 취소를 위한 훌륭한 메커니즘은 제공하지 않는 Rust에서는 더욱 중요합니다.
마지막으로, 우아한 종료는 연결을 처리하기 위한 애플리케이션 프로그래밍 패턴입니다. 두 종류의 취소보다 더 높은 추상화 수준에 존재합니다. 웹 서비스를 구현한다면, accept 루프를 멈춰 새 연결을 거부하되, 각 클라이언트가 연결을 끊을 때까지 기존의 모든 연결은 계속 처리하는 방식으로 종료를 구현할 수 있습니다. 로드 밸런서가 새 연결 요청을 서비스의 다른 인스턴스로 라우팅하도록 구성되어 있다면, 이 패턴을 통해 서비스 중단 없이 롤링 업그레이드를 수행할 수 있습니다.
보너스로, 관련된 개념으로 크래시 전용 소프트웨어가 있습니다. 취소도 좋지만, 전체 프로그램은 OOM 킬러에 의해 임의로 SIGKILL될 수 있고, 전원 손실 시 컴퓨터 전체가 재부팅될 수도 있습니다. 신뢰할 수 있는 소프트웨어는 데이터를 잃지 않고 우아하지 않은 종료를 처리해야 합니다. 하지만 전원 손실에서도 살아남을 수 있다면, 종료 버튼은 스스로에게 SIGKILL을 보내도록 구현하는 편이 낫습니다. 그러면 구현을 단순화하는 동시에 전원 손실 시나리오에 대한 테스트 범위도 넓어집니다.
TigerBeetle의 예를 몇 가지 들자면, Grid.cancel은 비동기 취소입니다. 취소가 완료되었을 때 호출자에게 알리기 위한 콜백을 받습니다. 이 API는 상태 동기화 중에 사용됩니다. 복제본이 클러스터가 너무 앞서 있어서 이벤트 기반 전송으로는 작동하지 않고 따라잡으려면 상태 전송이 필요하다고 판단하면, 진행 중인 모든 그리드 읽기 작업을 취소해야 합니다. 읽기는 복제본의 로컬 디스크가 뒷받침할 수도 있고, 이웃 복제본에서 데이터를 투명하게 가져오는 방식일 수도 있습니다. 첫 번째 경우에는 읽기가 끝날 때까지 기다려야 합니다. 두 번째 경우에는 읽기를 포기해야 합니다. 원격 읽기가 멈추는 일은 애초에 우리가 상태 동기화를 수행하는 바로 그 이유일 가능성이 높습니다.
StateMachine.reset는 동기 취소의 예입니다. 이는 Grid.cancel과 같은 흐름의 일부이며, 비동기 취소와 동기 취소를 명확히 생각하면 어떻게 코드를 단순화할 수 있는지를 보여 주는 예입니다. 궁극적으로 StateMachine은 Grid 위에 놓이지만, 그 사이에는 여러 중간 계층(Forest, Tree, Compaction, Scan 등)이 있습니다. 순진한 접근은 Grid에 비동기 취소가 필요하다는 사실을 발견하고 비동기성을 스택 전체로 전파하는 것입니다. 대신 우리가 하는 일은 오직 Grid만 직접 비동기적으로 취소하고, 그 밖의 모든 것은 동기적으로 reset하는 것입니다.
비동기 취소의 또 다른 예는 Client.shutdown입니다. TigerBeetle을 사용하는 애플리케이션이 Client 객체를 “드롭”할 때, 모든 OS 리소스를 해제해야 합니다. 우리 클라이언트도 io_uring을 사용하므로, 먼저 진행 중인 모든 시스템 호출이 완료될 때까지 기다려야 합니다. 주석에서는 이를 “우아한 종료”라고 부르지만, 이는 잘못되었다고 생각하며, 이 글을 쓰게 된 동기이기도 합니다. TigerBeetle에서는 우아한 종료를 하지 않습니다. 처음부터 끝까지 크래시 전용입니다. 꼬리 지연 시간 허용(여러 노드에 답을 요청하고 가장 빠른 것을 고르는 방식)은 크래시 장애뿐 아니라 회색 장애도 처리하므로 더 일반적인 해결책입니다. 분산 시스템에서 매우 느린 노드는 크래시된 노드와 정확히 똑같이 보입니다. 크래시는 그저 느림의 한 정도일 뿐입니다.
요점: