−비동기 작업의 취소(cancellation)는 호출자가 더 이상 필요하지 않은 작업에 중단 의사를 전달하고, 그 작업이 종료되거나 결과가 더 사용되지 않도록 수명을 조정하는 일이다. 화면을 닫았는데도 이미지 다운로드가 계속되거나, 새 검색을 시작했는데 옛 검색 결과가 나중에 화면을 덮는 상황에서 필요해진다.
이때 중단하려는 대상에는 서로 다른 층이 있다. 결과를 기다리는 코드를 끝내는 일, 계산이나 통신을 실제로 멈추는 일, 이미 끝난 작업의 결과를 적용하지 않는 일은 각각 다른 동작이다. 어느 층을 취소하는지 API의 계약을 읽어야 한다.
작업을 시작한 뒤에는 호출자가 그 내부의 모든 실행을 직접 통제하기 어렵다. 네트워크 요청은 원격 서버로 넘어가고, 파일 읽기는 운영체제의 입출력과 연결되며, 계산은 이벤트 루프를 점유할 수 있다. 그래서 많은 API는 취소 신호를 전달하고 작업이 이를 관찰해 정리하도록 하는 협력적 취소를 사용한다.
취소는 작업 수명과 동시성 제어가 만나는 지점이기도 하다. 새 검색이 시작됐다는 사건과 옛 검색의 완료가 어떤 순서로 처리되는지에 따라 화면이 달라질 수 있다. 작업을 멈추려는 시도와 화면을 바꿀 권한을 관리하는 처리를 함께 이해해야 이 문제의 구조가 보인다.
1. 결과를 표현하는 Promise
JavaScript의 ECMAScript 2015 표준은 Promise를 언어의 구성 요소로 정리했다. Promise는 아직 정해지지 않은 결과와, 그 결과가 성공 또는 실패로 정해졌을 때 이어서 실행할 동작을 표현한다. pending에서 fulfilled나 rejected로 이동하는 결과의 모델이다.[각주 1]
Promise 객체가 결과를 나타낸다는 것과 실제 작업의 중단 권한을 가진다는 것은 다른 문제다. 하나의 결과를 여러 코드가 기다릴 수도 있고, 그 결과를 만드는 동작이 통신인지 계산인지도 Promise만으로는 알 수 없다. await하던 코드를 그만 기다리게 했다고 해서 결과의 생산자가 자동으로 멈추는 관계는 성립하지 않는다.
이 구분은 타임아웃에서도 드러난다. 타이머와 요청 중 먼저 완료한 Promise를 선택하면 대기 시간을 제한할 수 있지만, 뒤에 남은 요청의 통신이나 서버 작업은 별도로 수명을 관리해야 한다.
2. 웹 플랫폼에서 AbortController로
Jake Archibald의 2017년 「Abortable fetch」는 2015년에 시작된 fetch 취소 논의와 브라우저 도입 과정을 설명한다. 문서의 정정에는 Edge 16의 지원이 언급된다. 이 기록은 웹의 fetch에 취소를 연결한 과정이며, 비동기 취소 자체가 그때 처음 생겼다는 연혁은 아니다.[각주 2]
DOM 표준의 AbortController와 AbortSignal은 요청하는 쪽과 관찰하는 쪽을 분리한다. controller를 소유한 코드는 중단을 요청하고, 작업에 전달한 signal은 중단 여부와 이유를 나타낸다. 이 구조를 사용하면 하나의 중단 요청을 여러 협력 작업에 전달할 수도 있다.[각주 3]
fetch는 이 신호를 받는 API 중 하나다. 같은 signal을 받는 다른 API도 있을 수 있으나, 동작의 취소 방식과 정리는 각각의 API가 정의한다. 신호를 전달할 수 있다는 사실과 해당 작업이 실제로 그 신호에 반응한다는 사실은 구분된다.
3. 협력적 취소의 실행 과정
협력적 취소는 보통 요청, 관찰, 정리, 완료의 순서로 이해할 수 있다. 호출자가 중단을 요청하면 작업은 적절한 시점에 신호를 확인하고, 더 진행할 일을 멈춘 뒤 자신이 가진 자원을 정리한다. 오래 걸리는 동기 계산이라면 신호를 확인할 지점과 다른 코드가 실행될 기회도 마련해야 한다.
취소 요청과 정상 완료가 비슷한 시점에 일어나면 작업의 종료 규칙이 중요해진다. 이미 완료한 결과를 취소 신호가 뒤늦게 되돌리는지, 완료 이후 신호를 무시하는지에 따라 계약이 달라진다. 이벤트 리스너와 타이머도 작업 수명에 맞춰 정리해야 다음 실행에 남지 않는다.
4. 가장 작은 신호 예제
다음 코드는 작업을 아직 시작하지 않은 상태에서 중단 신호가 이미 전달돼 있을 때를 보여준다. 통신이나 타이머를 추가하지 않고, controller와 signal의 역할부터 확인하려는 예제다.
+비동기 작업의 취소(cancellation)는 호출자가 더 이상 필요하지 않은 작업에 중단 의사를 전달하고, 그 작업이 종료되거나 결과가 더 사용되지 않도록 수명을 조정하는 일이다. 화면을 닫았는데도 이미지 다운로드가 이어지거나, 새 검색을 시작했는데 옛 검색 결과가 나중에 화면을 덮을 때 취소가 필요해진다.
중단하려는 대상에는 층이 여럿이다. 결과를 기다리던 코드를 끝내는 일, 계산이나 통신을 실제로 멈추는 일, 이미 끝난 작업의 결과를 적용하지 않는 일은 서로 다른 동작이고, 어느 층을 취소하는지는 API의 계약을 읽어 확인해야 한다.
작업을 시작한 뒤에는 호출자가 그 내부의 모든 실행을 직접 통제하기 어렵다. 네트워크 요청은 원격 서버로 넘어가고, 파일 읽기는 운영체제의 입출력과 연결되며, 계산은 이벤트 루프를 점유할 수 있다. 그래서 많은 API가 협력적 취소를 쓴다. 호출자는 취소 신호를 전달하고, 작업은 그 신호를 관찰해 스스로 정리한다.
취소는 작업 수명뿐 아니라 동시성 제어와도 연결된다. 새 검색의 시작과 옛 검색의 완료 순서에 따라 화면이 달라질 수 있으니, 작업을 멈추려는 시도와 화면을 바꿀 권한을 함께 살펴봐야 한다.
연혁
2009년 5월: Mike Liddell이 Microsoft .NET Blog의 「.NET 4 Cancellation Framework」에서 중단을 요청하는 CancellationTokenSource와 작업에 전달하는 CancellationToken을 나눈 .NET 4의 취소 설계를 소개했다. Parallel Extensions의 타입 전반에 이 방식을 적용했다고 밝힌 당시 설계 소개 글이다.[각주 1]
2010년 4월: CancellationTokenSource와 CancellationToken이 .NET Framework 4에 들어갔다.[각주 2][각주 3]
2015년 3월: WHATWG fetch 저장소에 「Aborting a fetch」 이슈 #27이 개설돼 취소 인터페이스를 논의했다.[각주 4][각주 5]
2015년 6월: ECMAScript 2015(ECMA-262 제6판)가 Promise를 JavaScript 표준에 넣었다.[각주 6]
2017년: 브라우저가 fetch 취소를 지원하기 시작했다. Jake Archibald의 「Abortable fetch」는 2015년 논의부터 이 과정을 기록하며, 글에 붙은 정정에 따르면 Edge 16이 먼저 지원했다.[각주 4]
강제 중단과 협력적 취소
Mike Liddell은 .NET 4 취소 프레임워크를 소개하는 글에서 실행 중인 코드를 강제로 끊는 접근과 중단을 요청하는 접근을 비교했다. 강제로 끊으면 공유 데이터나 자원이 정리되지 않은 지점에서 코드가 멈출 수 있다. 협력적 취소에서는 작업이 중단 요청을 관찰하고 자신이 맡은 정리를 직접 수행한다.[각주 1]
같은 글에 따르면 기존 API는 중단 방법이 객체마다 달랐다. 작업 객체의 취소 메서드나 플래그에 기대면 요청 하나를 여러 작업에 전달하기 어렵고, 여러 작업 가운데 특정 실행만 구별하기도 어렵다. 그래서 작업을 수행하는 객체와 실행 한 번의 수명을 분리할 필요가 생긴다.
.NET은 이 분리를 두 타입으로 나타낸다. 호출자는 CancellationTokenSource로 중단 의사를 전달하고, 작업은 전달받은 CancellationToken을 확인하거나 거기에 콜백을 등록한다. 같은 요청을 여러 작업이 관찰할 수도 있다.[각주 1] 이렇게 나누면 취소 권한을 가진 쪽과 그 요청에 반응하는 쪽이 코드에서 구별된다. 현재 .NET 문서도 요청, 관찰, 작업별 정리를 협력적 취소의 절차로 설명하며, 요청을 보낸 시각과 작업이 실제로 끝난 시각 사이에는 간격이 있다.[각주 7]
결과를 기다리는 일과 실행을 멈추는 일
Promise는 아직 정해지지 않은 결과와 그 뒤의 성공·실패 반응을 표현하며, pending에서 fulfilled 또는 rejected로 정해진다.[각주 6] 하나의 결과를 여러 코드가 기다릴 수 있고, 결과의 생산자가 통신을 하는지 계산을 하는지는 Promise만으로 정해지지 않는다. 그래서 결과를 기다리는 인터페이스와 생산자의 실행 수명을 제어하는 인터페이스가 따로 필요하다.
타이머와 요청 가운데 먼저 정해진 Promise를 고르면 기다림에 기한을 둘 수 있지만, 남은 요청의 자원 사용이나 원격 서버의 처리까지 끝내려면 그 작업이 지원하는 중단 수단을 함께 연결해야 한다.
취소 신호와 결과 적용 권한
웹 플랫폼에서는 DOM의 AbortController와 AbortSignal이 요청자와 관찰자를 나눈다. controller가 중단을 요청하면 signal에 중단 상태와 이유가 남고, signal을 받은 API가 자신의 실행을 어떻게 중단할지 정한다. 두 타입이 맡은 역할은 앞서 본 .NET의 요청자·관찰자 구분과 비교해 볼 수 있다.[각주 8]
현재 DOM 표준은 진행 중인 활동의 중단을 위한 공통 신호를 정의하고, Fetch 표준은 이 신호를 요청과 응답 본문에 연결한다. 응답 객체를 받은 뒤에도 본문 읽기는 계속될 수 있으므로, 응답 도착과 본문 처리 완료는 서로 다른 시점이다.[각주 8][각주 9]
협력적 취소는 요청, 관찰, 정리, 완료 순으로 진행된다. 긴 동기 계산은 신호 객체를 넘겨받는 것만으로는 멈추지 않는다. 계산 도중에 신호를 관찰할 지점이 있어야 하고, 다른 코드가 실행될 기회도 있어야 한다.
외부 쓰기는 수명이 따로 있다. 클라이언트가 통신을 중단했을 때 서버가 이미 커밋했다면, 그 변경이 어떻게 처리됐는지는 서버 조회나 별도의 취소 명령 같은 애플리케이션 계약으로 확인한다.
검색 A 다음에 B를 시작해도 A가 더 늦게 끝날 수 있고, 완료 순서대로 화면에 적용하면 오래된 A의 결과가 최종 화면에 남는다. React 공식 문서의 Effect 정리 예제는 오래된 응답을 무시하는 방식으로 이 경쟁을 설명한다.[각주 10]
여기서는 작업을 줄이는 중단 요청과 현재 화면을 바꿀 권한을 함께 관리한다. 요청 번호나 세대로 최신 요청을 구별해 두면 성공 결과는 물론 오류 표시와 로딩 해제에도 같은 규칙을 적용할 수 있다. 부모 기능이 끝날 때 하위 작업을 어디까지 끝낼지도 수명 계약에 들어간다.
취소 상태를 읽는 예제
다음 코드는 통신이나 비동기 계산 없이 중단 요청 전후의 signal 상태만 읽어서, controller와 signal의 역할을 확인한다.