표준 JavaScript API인 AbortController로 이벤트 리스너, fetch 요청, 스트림 등을 중단(취소)하는 방법과, 어떤 로직이든 중단 가능하게 만드는 패턴을 살펴봅니다.
오늘은 여러분이 아마도 놓치고 있을 표준 JavaScript API 하나를 이야기해 보려 합니다. 바로 AbortController입니다.
AbortController란?AbortController는 JavaScript의 전역 클래스이며, 말 그대로 무엇이든 중단(abort)하는 데 사용할 수 있습니다!
사용법은 다음과 같습니다:
jsconst controller = new AbortController() controller.signal controller.abort()
컨트롤러 인스턴스를 만들면 두 가지를 얻게 됩니다:
signal 프로퍼티: AbortSignal의 인스턴스입니다. 이건 중단 이벤트에 반응하도록 어떤 API에든 꽂아 넣을 수 있는(pluggable) 부품이며, 그에 맞게 구현할 수 있습니다. 예를 들어 fetch() 요청에 이를 전달하면 요청을 중단합니다..abort() 메서드: 호출하면 signal에 중단 이벤트를 트리거합니다. 또한 해당 시그널을 “중단됨(aborted)”으로 표시되도록 업데이트합니다.여기까지는 좋습니다. 그런데 실제 중단 로직은 어디에 있을까요? 그게 바로 이 API의 매력입니다—로직은 소비자(consumer)가 정의합니다. 중단 처리란 결국 abort 이벤트를 리스닝하고, 해당 로직에 적합한 방식으로 중단을 구현하는 것으로 귀결됩니다:
jscontroller.signal.addEventListener('abort', () => { })
이제 AbortSignal을 기본으로(out of the box) 지원하는 표준 JavaScript API들을 살펴봅시다.
이벤트 리스너를 추가할 때 abort signal을 제공하면, 중단이 발생하는 순간 해당 리스너가 자동으로 제거되게 할 수 있습니다.
jsconst controller = new AbortController() window.addEventListener('resize', listener, { signal: controller.signal }) controller.abort()
controller.abort()를 호출하면 윈도우에서 resize 리스너가 제거됩니다. 이는 이벤트 리스너를 다루는 매우 우아한 방식인데, 더 이상 .removeEventListener()에 넘기기 위해 리스너 함수를 따로 추상화할 필요가 없기 때문입니다.
jsconst controller = new AbortController() window.addEventListener('resize', () => {}, { signal: controller.signal }) controller.abort()
또한 애플리케이션의 다른 부분이 리스너 제거를 담당하는 경우에도 AbortController 인스턴스를 넘겨주는 방식이 훨씬 깔끔합니다.
제가 깨달음을 얻었던 순간은, *하나의 signal*로 여러 개의 이벤트 리스너를 제거할 수 있다는 걸 알았을 때였습니다!
jsuseEffect(() => { const controller = new AbortController() window.addEventListener('resize', handleResize, { signal: controller.signal, }) window.addEventListener('hashchange', handleHashChange, { signal: controller.signal, }) window.addEventListener('storage', handleStorageChange, { signal: controller.signal, }) return () => { controller.abort() } }, [])
위 예시에서는 React의 useEffect() 훅에서 서로 다른 목적과 로직을 가진 여러 이벤트 리스너를 추가하고 있습니다. 정리(clean up) 함수에서 controller.abort()를 한 번만 호출해도 추가된 모든 리스너를 제거할 수 있다는 점에 주목하세요. 깔끔하죠!
fetch() 함수도 AbortSignal을 지원합니다! 시그널에서 abort 이벤트가 발생하면, fetch()가 반환하는 요청 프라미스가 reject되면서 대기 중인 요청이 중단됩니다.
tsfunction uploadFile(file: File) { const controller = new AbortController() const response = fetch('/upload', { method: 'POST', body: file, signal: controller.signal, }) return { response, controller } }
여기서 uploadFile() 함수는 POST /upload 요청을 시작합니다. 그리고 관련된 response 프라미스뿐 아니라, 언제든 그 요청을 중단할 수 있도록 controller 참조도 함께 반환합니다. 예를 들어 사용자가 “Cancel” 버튼을 눌렀을 때 진행 중인 업로드를 취소해야 하는 경우에 유용합니다.
Node.js의
http모듈로 발행한 요청도signal프로퍼티를 지원합니다!
AbortSignal 클래스에는 JavaScript에서 요청 처리를 단순화해 주는 몇 가지 정적 메서드도 있습니다.
AbortSignal.timeoutAbortSignal.timeout() 정적 메서드를 사용하면, 특정 타임아웃 시간이 지나면 abort 이벤트를 디스패치하는 시그널을 손쉽게 만들 수 있습니다. 요청이 타임아웃을 초과하면 취소하는 것이 전부라면 AbortController를 굳이 만들 필요가 없습니다.
jsfetch(url, { signal: AbortSignal.timeout(3000), })
AbortSignal.anyPromise.race()로 여러 프라미스를 “먼저 끝나는 것 우선”으로 처리하듯이, AbortSignal.any() 정적 메서드를 사용해 여러 abort 시그널을 하나로 묶을 수 있습니다.
jsconst publicController = new AbortController() const internalController = new AbortController() channel.addEventListener('message', handleMessage, { signal: AbortSignal.any([publicController.signal, internalController.signal]), })
위 예시에서는 두 개의 abort 컨트롤러를 도입하고 있습니다. public 컨트롤러는 코드 소비자에게 노출되어, 그들이 abort를 트리거할 수 있도록 하고, 그 결과 message 이벤트 리스너가 제거됩니다. 반면 internal 컨트롤러는 저 자신도 public abort 컨트롤러에 영향을 주지 않으면서 해당 리스너를 제거할 수 있게 해 줍니다.
AbortSignal.any()에 전달된 시그널 중 하나라도 abort 이벤트를 디스패치하면, 그 부모 시그널도 abort 이벤트를 디스패치합니다. 그 이후에 들어오는 다른 abort 이벤트들은 무시됩니다.
AbortController와 AbortSignal로 스트림도 취소할 수 있습니다.
jsconst stream = new WritableStream({ write(chunk, controller) { controller.signal.addEventListener('abort', () => { }) }, }) const writer = stream.getWriter() await writer.abort()
WritableStream 컨트롤러는 동일한 AbortSignal인 signal 프로퍼티를 노출합니다. 그래서 writer.abort()를 호출하면, 스트림의 write() 메서드 안에서 controller.signal의 abort 이벤트로 전파(bubble up)됩니다.
AbortController API에서 제가 가장 좋아하는 부분은 활용도가 엄청나게 높다는 점입니다. настолько(정말) 범용적이라서, 어떤 로직이든 중단 가능하게 가르칠 수 있습니다!
이런 초능력을 손에 쥐면, 직접 더 나은 경험을 제공할 수 있을 뿐 아니라, 기본적으로 abort/취소를 지원하지 않는 서드파티 라이브러리를 사용하는 방식도 개선할 수 있습니다. 실제로 그렇게 해 봅시다.
Drizzle ORM 트랜잭션에 AbortController를 추가해, 여러 트랜잭션을 한 번에 취소할 수 있도록 만들어 보겠습니다.
jsimport { TransactionRollbackError } from 'drizzle-orm' function makeCancelableTransaction(db) { return (callback, options = {}) => { return db.transaction((tx) => { return new Promise((resolve, reject) => { options.signal?.addEventListener('abort', async () => { reject(new TransactionRollbackError()) }) return Promise.resolve(callback.call(this, tx)).then(resolve, reject) }) }) } }
makeCancelableTransaction() 함수는 데이터베이스 인스턴스를 받아서, 이제 abort signal을 인자로 받을 수 있는 고차(higher-order) 트랜잭션 함수를 반환합니다.
중단이 언제 발생했는지 알기 위해 signal 인스턴스에 “abort” 이벤트 리스너를 추가하고 있습니다. 이 이벤트 리스너는 abort 이벤트가 디스패치될 때마다, 즉 controller.abort()가 호출될 때마다 실행됩니다. 따라서 그 순간 트랜잭션 프라미스를 TransactionRollbackError로 reject하여 트랜잭션 전체를 롤백할 수 있습니다(이는 동일한 에러를 던지는 tx.rollback()을 호출하는 것과 동치입니다).
이제 Drizzle에서 사용해 봅시다.
jsconst db = drizzle(options) const controller = new AbortController() const transaction = makeCancelableTransaction(db) await transaction( async (tx) => { await tx .update(accounts) .set({ balance: sql`${accounts.balance} - 100.00` }) .where(eq(users.name, 'Dan')) await tx .update(accounts) .set({ balance: sql`${accounts.balance} + 100.00` }) .where(eq(users.name, 'Andrew')) }, { signal: controller.signal } )
db 인스턴스로 makeCancelableTransaction() 유틸리티 함수를 호출해, 중단 가능한 커스텀 transaction을 만들고 있습니다. 이 시점부터는 Drizzle에서 하던 대로 커스텀 transaction을 사용해 여러 데이터베이스 작업을 수행할 수 있고, 또한 abort signal을 제공해 그것들을 한 번에 취소할 수도 있습니다.
모든 abort 이벤트에는 그 중단에 대한 reason(사유)이 함께합니다. 이는 훨씬 더 많은 커스터마이징을 가능하게 해 주며, 서로 다른 중단 사유에 대해 서로 다르게 반응할 수 있습니다.
abort reason은 controller.abort() 메서드의 선택적 인자입니다. 그리고 어떤 AbortSignal 인스턴스에서든 reason 프로퍼티로 그 값을 확인할 수 있습니다.
jsconst controller = new AbortController() controller.signal.addEventListener('abort', () => { console.log(controller.signal.reason) }) controller.abort('user cancellation')
reason인자는 어떤 JavaScript 값이든 될 수 있으므로 문자열, 에러, 심지어 객체도 전달할 수 있습니다.
JavaScript에서 중단하거나 취소하는 것이 자연스러운 라이브러리를 만든다면, AbortController API를 적극적으로 고려해 보길 권합니다. 정말 놀랍습니다! 그리고 애플리케이션을 만든다면, 요청 취소, 이벤트 리스너 제거, 스트림 중단, 혹은 어떤 로직이든 중단 가능하게 만들 때 abort 컨트롤러를 매우 효과적으로 활용할 수 있습니다.
이 글을 교정해 준 Oleg Isonen에게 특별한 감사를 전합니다!