Promise는 왜 취소가 안 될까?

안녕하세요, 카카오 비즈 FE 파트에서 광고 SDK의 개발을 맡고 있는 Jake입니다.

Promise는 비동기 상태를 값으로 취급하여 다양한 연산을 할 수 있도록 도와주는 자바스크립트 객체입니다.

광고 SDK에서도 광고의 렌더링을 위해 Promise를 활용하고 있는데요, 사용자가 광고를 렌더링 중에 프로그램 혹은 스크립트로 제거한 경우와 같이, 렌더링 과정에서 이를 취소하는 기능을 구현해야 하면서 아래와 같은 의문을 떠올렸습니다.

왜 Promise에는 취소 기능이 없지?

그래서 Promise에 취소 기능이 없는 이유에 대해 심도 있게 살펴보았고, 이것이 굉장히 흥미로운 주제임을 알게 되었습니다. 이 글을 통해 Promise의 취소 기능에 대한 이야기와 함께, 렌더링의 취소 이슈를 광고 SDK에서 어떻게 해결하였는지 공유하겠습니다.

Promise의 취소 기능과 그 비운의 역사

Promise의 취소 기능은 오래전 Promise의 표준화를 논의할 때부터 제안되었던 기능입니다. 하지만 ECMAScript의 표준을 제정하는 TC39 위원회는 현재까지도 취소 기능의 표준화에 어려움을 겪고 있습니다.

TC39에서는 취소 기능이 Promise에만 국한된 것이 아니라 보다 범용적인 기능이 되기를 원했습니다. 예를 들어 dynamic import라든지, 아직 논의 단계인 Array.fromAsyncObservable과 같은 기능 등에서도 동기 및 비동기 작업을 취소하는 기능이 논의되었는데요, 이를 표준적인 취소 기능으로 통합하였으면 했습니다.

먼저, TC39가 최초에 제안하였던 취소 기능에 대해 소개해보겠습니다.

취소 상태와 취소 토큰

Promise에 취소 기능을 넣는다면 아래처럼 Promise에 cancel() 메소드를 추가하는 단순한 방식을 생각해볼 수 있습니다.

				
					const fetchPromise = fetch(“https://test.kakao.com/request”);
 
fetchPromise
  .then((response) => response.json())
  .then((jsonData) => updateUI(jsonData));
 
document.getElementById(“cancelButton”).addEventListener(“click”, () => {
  fetchPromise.cancel();
});

				
			

하지만 문제를 깊이 살펴보면 전혀 단순하지 않습니다. Promise는 기본적으로 다음과 같이 총 3가지의 상태를 가집니다.

  • 대기 상태(pending)
  • 이행 상태(fullfilled)
  • 거부 상태(rejected)

그렇다면 취소 기능은 이행 상태에 해당할까요? 아니면 거부 상태에 해당할까요?

TC39가 처음 취소 기능을 제안할 때는 위의 3가지의 상태에 더해 새로운 취소 상태(canceled)가 필요하다고 주장하였습니다. 하지만 취소 기능을 구현하기 위해 정말 새로운 상태가 필요한 것인지, 취소는 어떻게 발생시킬 것인지, 취소를 위한 문법이 필요할지 등에 대해 결론이 없이 끝없는 논쟁을 이어가게 되었죠.

이 과정에서는 취소 상태와 함께 취소 토큰(Cancel Token)을 도입하자는 제안도 있었습니다. 취소 토큰이란 취소 요청을 동기/비동기적으로 전달할 수 있는 객체입니다. 취소 토큰은 취소 여부를 알 방법을 제공한다면 다양한 방법으로 구현해 볼 수 있습니다. 가장 간단하게는 다음과 같이 Promise를 사용해 취소 토큰을 만들 수 있죠.

				
					# 코드예제 A
const cancelToken = new Promise((resolve) => {
  document.getElementById(“cancelButton”).onclick = () => resolve();
});

				
			

위의 예제는 취소 버튼이 눌리면 Promise가 이행되어 취소 “신호”가 전달되는 방식으로 취소 토큰을 생성한 것입니다. 이 취소 토큰을 받는 쪽에서는 취소 토큰의 Promise 이행을 대기하다가, 이행되면 취소된 것으로 판단하여 취소 처리를 진행할 수 있습니다.

다음과 같이 특정 밀리 초 동안 대기하는 timeout 함수를 통해 이 취소 토큰을 사용하는 예를 들어 보겠습니다.

				
					# 코드예제 B
function timeout(ms, cancelToken) {
  return new Promise((resolve, reject) => {
    const timeoutHandle = setTimeout(() => resolve(), ms);
    cancelToken?.then(() => clearTimeout(timeoutHandle));
  });
}

				
			

이때, timeout 함수의 두 번째 인자로 취소 토큰을 전달할 수 있는데요, ‘코드 예제 A’에서 만든 취소 토큰을 함수에 전달해 보겠습니다.

				
					# 코드예제 C
timeout(10000, cancelToken).then(() => alert(“timeout!”));
				
			

이렇게 구현하면 사용자가 취소 버튼을 10초 안에 눌렀을 때, “timeout!” 메시지 창이 나타나지 않을 것입니다. 하지만 이렇게 Promise를 사용하여 취소 토큰을 구현하면 취소 여부를 동기적으로 알 수 없다는 단점이 있습니다.

따라서 동기 및 비동기 방식에 사용할 수 있을 뿐 아니라, 취소 기능과 관련된 다양한 편의 기능을 제공하면서 범용성이 있는 인터페이스를 가지는 취소 토큰 표준이 최종적으로 제안되었습니다.

이러한 취소 토큰은 Promise와 취소 기능을 개념적으로 분리할 수 있다는 장점을 가지고 있습니다. 이른바 “관심사의 분리”라고 할 수 있습니다. 따라서 Promise 외에 다양한 곳에서도 취소 기능을 활용할 수 있게 되고, 생성한 취소 토큰을 원하는 곳에 전달할 수도 있습니다.

이때 제안된 취소 토큰은 비록 표준으로 채택되지 못했지만, Axios라는 라이브러리에서 요청을 취소하기 위한 기능에서 구현되어 그 흔적을 찾아볼 수 있습니다.

AbortController와 AbortSignal의 등장

취소 기능의 표준화를 위해 TC39는 굉장히 오랜 기간 논의를 이어갔습니다. 취소 기능을 구현하는 데는 복잡하고 다양한 이슈들이 포함되어 있었고, 특히나 TC39는 위원 모두가 동의해야 비로소 다음 단계로 진행하는 절차를 가지고 있어 진행이 더뎠기 때문이었죠.

그러던 와중에 웹 표준을 담당하는 위원회인 WHATWG에서 AbortControllerAbortSignal을 표준으로 내세우는 일이 벌어졌습니다.

AbortController는 웹 요청을 취소하는 기능이었기 때문에, 취소 기능의 표준화를 위해 애쓰던 TC39 입장에서는 날벼락과 같은 일이었을 겁니다. 당시 회의록에는 “절차의 실패(process failure)”라는 표현까지 있을 정도였으니까요.

AbortControllerAbortSignal은 앞서 설명한 취소 토큰과 유사하게 동작합니다. AbortController를 사용하여 fetch 함수의 호출을 어떻게 취소하는지 아래 예제 코드를 통해 알아보겠습니다.

				
					# 코드예제 D
const controller = new AbortController();

fetch(“https://test.kakao.com/request”, {signal: controller.signal})
  .then((response) => response.json())
  .then((jsonData) => updateUI(jsonData))
  .catch((e) => alert(e.message));

document
  .getElementById(“cancelButton”)
  .addEventListener(“click”, () => controller.abort());
				
			

fetch 함수는 네트워크 요청을 비동기로 간편하게 처리해 주는 Web API입니다. 요청에 대한 응답이 오면 이행되는 Promise 객체를 반환합니다.

위 예제 코드에서 fetch 함수의 두 번째 인자에 signal이라는 옵션으로 controller.signal을 지정했습니다. controller.signal은 AbortSignal 객체의 인스턴스입니다. 이 객체를 통해 fetch 함수 내부에서 수행되고 있는 ‘네트워크 요청’을 “취소(중단)” 하겠다는 신호를 받아 취소 처리를 진행합니다. 취소하겠다는 신호는 사용자가 취소 버튼을 누르면 객체에 전달되도록 버튼 요소의 click 이벤트에 controller.abort()를 호출하도록 하였습니다.

AbortSignal을 직접 사용해 취소 처리하는 기능을 만들 수도 있습니다. 아래는 ‘코드 예제 B’에서 취소 토큰을 사용했던 timeout 함수를 AbortSignal 인터페이스를 사용하도록 재구현한 예제 코드입니다.

				
					# 코드예제 E
function timeout(ms, {signal}) {
  return new Promise((resolve) => {
    signal?.throwIfAborted(); // 동기적으로 취소되었는지 알아내어 취소되었다면 예외를 던짐
    const timeoutHandle = setTimeout(() => resolve(), ms);
    signal?.addEventListener(“abort”, () => clearTimeout(timeoutHandle));
  });
}

				
			

요원한 취소 기능의 표준화

AbortController와 AbortSignal이 Web API의 표준이 됨에 따라 TC39에서 논의되던 취소 토큰 제안은 결국 중단되었습니다. 그렇다면 반대로 AbortController와 AbortSignal을 ECMAScript 언어의 표준으로 가져올 수도 있지 않을까요?

그러나 AbortController와 AbortSignal에는 ECMAScript 언어 표준이 되지 못하는 중대한 결점이 있습니다. 바로 Web 플랫폼에 종속적인 EventTarget과 DOMException 객체 등에 의존한다는 점입니다.

예를 들어 Node.js 환경의 플랫폼에는 EventTarget 대신에 EventEmitter를 사용합니다. EventTarget과 EventEmitter 모두 이벤트 등록과 발생을 책임지지만, EventTarget은 Web 플랫폼에서만 쓰이는 DOM 트리를 위한 기능 등이 추가로 구현되어 있습니다.

현재는 Node.js 환경에서도 버전 14 이상부터 EventTarget을 사용할 수 있게 되었지만, AbortController와 AbortSignal이 의존하는 EventTarget은 여전히 특정 플랫폼에 종속적인 기능이라는 점에는 변함이 없습니다.

TC39는 플랫폼에 종속되지 않은 취소 기능의 표준을 만들기 위해 현재도 고군분투 중입니다. 하지만 너무나 다양한 아이디어가 논의되고 있어서 취소 기능의 표준화가 언제 이루어질지는 요원해 보입니다.

Promise 취소 기능의 표준화가 그토록 어려운 이유

Promise의 등장부터 논의되었던 취소 기능의 표준화가 어떠한 이유로 난항을 겪고 있는지 이해하기 위해 대표적인 몇 가지의 문제점을 살펴보겠습니다.

취소는 예외인가?

취소를 다루는 방법 중에는 일반적인 예외(Exception)처럼 취급하는 방법도 있습니다. 실제로 비동기 작업의 취소 기능을 지원하는 C#에서 취소는 일반적인 예외와 동일합니다. 그러다 보니 아래와 같이 발생한 예외가 취소인지 아닌지 확인하기 위한 조건을 매번 추가해야 하는 어려움이 있다고 합니다.

				
					try {
  …
} catch (Exception e) {
  if (e is not TaskCancelledException) {
    …
  }
}

				
			

만약 catch 문 안에서 예외가 취소인지 아닌지 확인하는 것을 잊는다면 에러가 아님에도 에러로 처리되어 불필요한 정보를 에러 메시지로 보여주거나 서버 에러 로그에 남게 되겠죠.

반면 취소를 일반적인 예외와 다르게 취급하고 try – catch cancel 등의 새로운 문법 요소를 도입하자는 제안도 있었습니다.

				
					try {
  …
} catch cancel (reason) {
  …
} catch {
  …
}

				
			

위 예제 코드에서 수행 중인 작업이 취소되면, catch 문이 아니라 catch cancel 문으로 점프합니다. 만약 취소된 경우의 처리가 필요없다면 catch cancel 문은 생략할 수 있습니다. 이 제안의 장점은 C#에서의 사례와 같이 발생한 예외가 취소인지 아닌지 catch 문 안에서 매번 확인하지 않아도 된다는 점입니다.

하지만 취소를 일반적인 예외와 다르게 취급할 경우, Promise에도 기존 then, catch, finally 등의 메소드와 더불어 취소를 위한 cancel, cancelCatch 등의 메소드를 추가해야 하고 결과적으로 논의 중인 스펙의 요구사항이 점점 복잡해지게 되었습니다.

이렇듯 취소를 어떻게 다루고 구현할지 모두가 확실히 동의할 만한 정답이 없기 때문에 굉장한 논쟁거리가 될 수밖에 없습니다.

취소의 기대 동작

취소라는 동작을 모든 케이스에 적용할 수 있도록 일관되고 범용적으로 정의하기는 어려운 문제입니다.

예를 들어 Promise를 취소하면 취소 에러가 발생하는 것으로 정의할 수도 있지만, Pending 상태에 머물고 다음 상태로 진행하지 않는 것으로 정의할 수도 있습니다. 이 때문에 취소(Cancel)라는 단어 자체가 모호한 뉘앙스를 주는 것이 아닌지 고민해 보자는 의견도 심심찮게 나오는 것 같습니다.

Promise를 사용하는 기존 코드에 대한 영향

만약 Promise에 취소 기능이 생긴다면, Promise에 의존성이 있는 기존 코드에도 심각한 영향을 끼칠 수 있습니다. 아래는 파일, 데이터베이스와 같은 비동기 저장소를 사용하여 캐시를 구현할 수 있는 예시 코드입니다.

				
					class PromiseCache {
  #cache = new Map();

  get(key) {
    if (!this.#cache.has(key)) {
      this.#cache.set(key, this.#compute(key));
    }
    return this.#cache.get(key);
  }

  async #compute(key) {
    // key로부터 값을 가져오기 위한 어떤 비동기 작업
  }
}

				
			

만약 Promise에 취소 기능이 추가되면, get 메소드로 받아온 Promise도 취소할 수 있습니다. 하지만 get 메소드는 코드의 다양한 곳에서 호출되므로 해당 키로 값을 가져오는 모든 코드에 의도치 않게 동작을 취소하게 되는 영향을 끼칠 수 있습니다.

이외에도 다양한 논의들이 있지만 현 단계에서 가장 큰 문제는 다름 아닌 WHATWG에서 표준화한 AbortController 및 AbortSignal 과의 호환성을 어떻게 풀어갈 것인지로 보입니다. 언어 차원에서 표준화된 취소 기능이 없기 때문에, 계속해서 더 많은 플랫폼에서 Web 환경에 종속적인 AbortController를 지원해 나가고 있기 때문입니다.

광고 SDK에서 렌더링 취소 문제를 해결한 방법

광고 SDK에서는 광고를 렌더링 하기 위해 서버에 광고를 요청하고, DOM 요소를 생성하고, 광고 렌더링이 정상적인지 체크하는 등 다양한 동기, 비동기 작업이 필요합니다. 따라서 광고 렌더링 개발 과정에 Promise를 적극 활용하고 있습니다.

광고 렌더링의 취소

광고 렌더링을 아래처럼 의사 코드로 단순하게 표현해 보겠습니다.

				
					async function render(ad) {
  // 광고 요청
  const response = await fetch(ad.requestUrl);
  // 광고 응답에 따라 DOM 요소 생성
  const rollbackRender = await renderAdElement(ad, response);
  // 렌더링이 정상적인지 체크
  const rollbackCheck = await checkRender(ad);
  // 롤백 함수 정의
  const rollbackAll = () => {
    rollbackCheck();
    rollbackRender();
  };
  // 롤백 함수 저장
  setRollback(ad, rollbackAll);
}

				
			

render 함수를 통한 광고 렌더링의 전체 프로세스가 완료되면, 진행된 렌더링을 원래 상태로 되돌릴 수 있는 롤백 함수를 저장합니다. 이것은 광고가 제거될 때 렌더링에 사용된 리소스를 정상적으로 반환하여 메모리 누수(Memory leak) 등을 방지하기 위해서입니다.

이때, 롤백 함수는 render 함수의 실행이 완료된 마지막에 저장이 됩니다. 문제는, render 함수의 실행 중에 광고가 제거될 경우 롤백 함수가 저장되지 않기 때문에 롤백 함수를 호출할 방법이 없었던 것이죠.

render 함수는 async 함수로 정의되었는데요, async 함수는 항상 Promise를 반환한다는 특징이 있습니다. 여기서 async 함수는 await 문법을 통해 Promise를 마치 동기적인 코드처럼 다룰 수 있게 해줍니다. fetch, renderAdElement, checkRender 등의 함수는 모두 await 구문이 있어 Promise를 반환하므로 동기적인 코드를 작성하는 것처럼 간편하게 render 함수의 로직을 구현할 수 있습니다. 그렇기 때문에 롤백 함수를 중간에 호출할 방법으로 render 함수가 반환한 Promise를 취소할 수 있다면 적절할 것으로 생각하여 Promise의 취소에 대해 조사해보았던 것입니다.

먼저, Promise의 취소의 여러 대안 중에 하나인 취소 토큰을 직접 구현해 다음과 같이 사용하는 것을 시도해 보았습니다.

				
					async function render(ad, cancelToken) {
  // 광고 요청
  const response = await fetch(ad.requestUrl);
  // 광고 요청 사이에 렌더링 취소 요청이 발생했는지 확인
  cancelToken.throwIfRequested();
  // 광고 응답에 따라 DOM 요소 생성
  const rollbackRender = await renderAdElement(ad, response, cancelToken);
  cancelToken.throwIfRequested();
  // 렌더링이 정상적인지 체크
  const rollbackCheck = await checkRender(ad, cancelToken);
  cancelToken.throwIfRequested();
  // 롤백 함수 정의
  const rollbackAll = () => {
    rollbackCheck();
    rollbackRender();
  };
  // 롤백 함수 저장
  setRollback(ad, rollbackAll);
}

				
			

renderAdElement, checkRender 함수와 같이, 비동기로 작업 후 롤백 함수를 반환하는 함수에도 취소 토큰을 인자로 전달하여 취소 이벤트를 등록하였고, 취소가 필요한 경우 내부에서 롤백 처리할 수 있도록 구현하였습니다. 이렇게 하면 취소가 요청되는 즉시 롤백 함수가 호출될 것입니다.

그리고 비동기 코드 다음에는 cancelToken.throwIfRequested()를 호출하여 비동기 작업 중에 렌더링 취소가 요청되었는지 확인하고, 취소 요청이 발생했다면 예외를 발생시켜 render 함수의 렌더링 과정이 중단되도록 했습니다.

취소를 예측하기

하지만 렌더링 취소가 요청되는 시점이 꼭 비동기 코드가 수행될 때만 일어나는 것이 아니라는 사실을 구현 중에 알게 되었습니다.

예를 들어, checkRender 함수는 렌더링이 정상적인지 체크한 후 render 이벤트를 발생시키도록 합니다.

				
					async function checkRender(ad, cancelToken) {
  … // 렌더링 체크
  // 렌더링이 정상이면 “render” 이벤트 발생
  ad.emit(“render”);
  cancelToken.throwIfRequested();
  … // 마무리 작업
  // 롤백 함수 반환
  return () => {...};
}

				
			

이 이벤트는 광고 SDK를 사용하는 사용자에게도 노출되어 있기 때문에, 사용자가 광고를 제거하는 로직을 render 이벤트에 등록했을지도 모르는 일입니다. 따라서 render 이벤트를 수행하는 코드 다음에도 cancelToken.throwIfRequested()를 호출해야 합니다.

결국 이런 식으로 취소가 발생할 만한 곳을 모두 예측하여 cancelToken.throwIfRequested()를 호출하는 것은 구현이 불가능한 일이라고 판단했습니다.

비동기적으로 해결하기

취소 토큰을 사용한 방법이 적절하지 않았던 이유는 취소가 동기적으로 처리되길 원했기 때문입니다. 즉, 광고가 제거되기 직전에 곧바로 렌더링이 중단되길 원했습니다. 하지만 실행 중인 코드에서 취소 요청을 일일이 확인하고 중단하는 것은 불가능함을 깨닫고, 더 근본적인 방향으로 문제를 다시 바라보았습니다.

문제가 있었던 근본적인 원인은 렌더링 도중에 광고가 제거되면 롤백 함수가 저장되어 있지 않아 제거가 제대로 되지 않는다는 것이었죠. 그러면 롤백 함수가 저장될 시점까지 기다렸다가 롤백 함수를 호출하면 되는 것 아닐까요?

이 해법은 렌더링을 중간에 취소하는 것보다 훨씬 간단하게 구현이 가능했습니다. 광고가 제거되면 제거된 광고를 별도의 버퍼에 저장하였다가, 렌더링이 완료되면 롤백하도록 하는 것만으로 문제가 해결되었던 것이죠.

				
					async function render(ad) {
  // 광고 요청
  const response = await fetch(ad.requestUrl);
  // 광고 응답에 따라 DOM 요소 생성
  const rollbackRender = await renderAdElement(ad, response);
  // 렌더링이 정상적인지 체크
  const rollbackCheck = await checkRender(ad);
  // 롤백 함수 정의
  const rollbackAll = () => {
    rollbackCheck();
    rollbackRender();
  };
  if (isRemovedAd(ad)) {
    // 광고가 제거되었다면 즉시 롤백
    rollbackAll();
  } else {
    // 광고가 제거되지 않았다면 롤백 함수 저장
    setRollback(ad, rollbackAll);
  }
}

				
			

이렇게 로직을 구현하면 렌더링 중간에 광고가 제거되었더라도 렌더링은 무조건 완료해야 한다는 단점이 있지만, 광고의 렌더링은 빠른 속도로 이루어지기 때문에 사용자에게 문제가 되지 않을 것으로 판단했습니다. 이 방법은 취소 토큰을 도입하고 취소 로직을 중간중간 추가해야 하는 방식의 복잡함에 대비하여 훌륭한 해결책이라고 생각합니다.

문제를 해결하며 배운 것

광고 렌더링의 취소 문제 해결 과정에서 분명하게 배운 것이 하나 있습니다. 그것은 취소가 생각보다 명확하지 않은 개념이라는 것입니다. 코드의 어느 지점에서 어떻게 중단할 것인지 등 명확한 요구사항이 없으면 취소의 동작은 구현하는 사람마다 달라질 수 있습니다. 아니면 명확한 요구사항을 그대로 구현하였더라도, 예측하지 못한 사이드이펙트(Side effect)가 생길 수도 있죠.

그렇기에 취소를 도입하기 위해서는 무엇을 어떻게 어느 시점에 취소할 것인지, 보다 명확하고 상세한 설계가 필요한 것 같습니다.

마치며

광고 렌더링의 취소 문제는 결과적으로 단순하게 해결되었지만, 그 과정에서 Promise와 취소 기능에 대해 조사하며 흥미로운 지식을 배우고 깊은 통찰을 얻을 수 있었습니다.

플랫폼 독립적이고 범용적인 형태의 취소 기능을 표준화하기 위해 TC39에서는 현재도 열심히 논의 중인데요, 가까운 미래에 그 결실이 세상에 나올 수 있기를 기대해 봅니다.

카카오톡 공유 보내기 버튼

Latest Posts

대학생 멘토링: 우물 밖 내다보기

https://www.youtube.com/watch?v=LGdb4Q5t-gw 안녕하세요. 저는 카카오톡 톡플랫폼개발팀에서 백엔드 서버 개발자로 일하고 있는 nano.son(손은호)입니다. 2023년 1월 16일, 제 모교인 경북대학교의 학생들과 판교 아지트에서 멘토링을 진행했습니다.