Fetch API의 abort signal, 네트워크 요청을 똑똑하게 관리하는 방법
Fetch API와 AbortController, AbortSignal을 활용하여 네트워크 요청을 효과적으로 취소하고 관리하는 패턴을 알아봅니다. 컴포넌트 생명주기 연동, 사용자 액션 기반 취소, 타임아웃 구현까지 실전 예제와 함께 설명합니다.
사용자의 액션으로 네트워크 요청을 취소해야 할 때
웹 애플리케이션을 개발하다 보면 사용자가 특정 작업을 수행하는 도중에 이전 작업을 취소하거나, 여러 요청이 동시에 발생했을 때 불필요한 응답을 막아야 하는 상황에 직면하곤 합니다. 예를 들어, 사용자가 검색창에 텍스트를 입력할 때마다 서버에 검색어 추천 요청을 보내는 경우를 생각해 봅시다. 사용자가 빠르게 타이핑하는 동안 이전 요청이 완료되기도 전에 새로운 요청이 계속해서 발생할 수 있습니다. 이때, 이미 입력이 완료되어 이전 추천 결과가 필요 없게 되었음에도 불구하고 백그라운드에서 이전 요청이 서버와 통신하고, 서버는 응답을 생성하여 클라이언트로 보냅니다. 이는 불필요한 네트워크 트래픽을 유발하고 서버 부하를 증가시킬 뿐만 아니라, 클라이언트 입장에서도 이미 사용하지 않는 데이터를 처리하느라 리소스를 낭비하게 됩니다. 혹은 사용자가 페이지를 이동하는 도중에 진행 중이던 네트워크 요청을 취소해야 할 수도 있습니다. 이러한 상황에서 우리는 어떻게 효과적으로 네트워크 요청을 관리하고 취소할 수 있을까요?
프론트엔드 개발에서 비동기 작업을 처리하는 데 있어 Fetch API는 필수적인 도구입니다. Fetch API는 XMLHttpRequest보다 간결하고 강력한 기능을 제공하며, 특히 AbortSignal을 통해 네트워크 요청을 취소할 수 있는 기능을 내장하고 있습니다. 이 기능은 사용자의 액션이나 애플리케이션의 상태 변화에 따라 진행 중이던 네트워크 요청을 명시적으로 중단시켜, 불필요한 리소스 낭비를 막고 사용자 경험을 향상시키는 데 핵심적인 역할을 합니다.
이번 글에서는 Fetch API와 AbortController를 활용하여 네트워크 요청을 효과적으로 취소하는 패턴에 대해 알아보고, 실제 코드 예제를 통해 그 활용법을 살펴보겠습니다.
AbortController와 AbortSignal: 요청 취소의 핵심
Fetch API에서 요청 취소를 가능하게 하는 주역은 AbortController와 AbortSignal입니다. 이 둘은 마치 리모컨과 신호처럼 작동합니다.
AbortController:abort()메서드를 호출하여 신호를 보낼 수 있는 객체입니다. 이 객체를 생성하면, 연관된AbortSignal객체를 얻을 수 있습니다.AbortSignal:AbortController로부터 받은 신호 객체입니다. 이 신호는 fetch 요청의options객체 안에signal속성으로 전달됩니다.AbortController의abort()메서드가 호출되면, 이AbortSignal은aborted이벤트가 발생하고reason속성에 취소 이유가 담기게 됩니다.
이 메커니즘을 이용하면, 네트워크 요청을 보내는 코드와 요청을 취소하는 코드를 분리할 수 있습니다. 즉, 컴포넌트 내부에서 fetch 요청을 보내고, 해당 컴포넌트가 언마운트되거나 다른 이유로 요청이 더 이상 필요 없을 때, 컴포넌트 외부나 내부에서 AbortController의 abort() 메서드를 호출하여 진행 중이던 요청을 중단시킬 수 있습니다.
실전 패턴: AbortSignal 활용하기
AbortSignal을 활용하는 가장 일반적인 패턴은 컴포넌트의 생명주기(Lifecycle)와 연동하는 것입니다. React와 같은 UI 라이브러리에서는 컴포넌트가 마운트될 때 AbortController를 생성하고, 언마운트될 때 abort() 메서드를 호출하는 방식으로 구현할 수 있습니다.
1. 컴포넌트 마운트 시 요청 보내고, 언마운트 시 취소하기
이 패턴은 컴포넌트가 화면에 보이는 동안에만 필요한 네트워크 요청을 처리할 때 유용합니다. 예를 들어, 특정 게시글의 상세 정보를 불러오는 컴포넌트가 있다고 가정해 봅시다.
1// React 예제 (Create React App 환경 권장) 2import React, { useState, useEffect } from 'react'; 3 4function PostDetail({ postId }) { 5 const [post, setPost] = useState(null); 6 const [loading, setLoading] = useState(true); 7 const [error, setError] = useState(null); 8 9 useEffect(() => { 10 // 1. AbortController 생성 11 const controller = new AbortController(); 12 const signal = controller.signal; 13 14 const fetchPost = async () => { 15 setLoading(true); 16 setError(null); 17 try { 18 const response = await fetch(`/api/posts/${postId}`, { 19 signal: signal // 2. fetch 요청에 signal 전달 20 }); 21 22 if (!response.ok) { 23 throw new Error('Network response was not ok'); 24 } 25 26 const data = await response.json(); 27 setPost(data); 28 } catch (err) { 29 // 3. AbortError 처리 30 if (err.name === 'AbortError') { 31 console.log('Fetch aborted'); // 요청이 취소되었음을 로그로 남김 32 } else { 33 setError(err.message); 34 } 35 } finally { 36 setLoading(false); 37 } 38 }; 39 40 fetchPost(); 41 42 // 4. 컴포넌트 언마운트 시 abort() 호출 43 return () => { 44 controller.abort(); 45 }; 46 }, [postId]); // postId가 변경될 때마다 재요청 47 48 if (loading) return <p>Loading...</p>; 49 if (error) return <p>Error: {error}</p>; 50 if (!post) return null; 51 52 return ( 53 <div> 54 <h2>{post.title}</h2> 55 <p>{post.body}</p> 56 </div> 57 ); 58} 59 60export default PostDetail;
코드 설명:
useEffect훅 안에서AbortController인스턴스를 생성합니다. 이 컨트롤러는 컴포넌트의 생명주기 동안 유효합니다.fetch요청 시,options객체의signal속성에controller.signal을 전달합니다. 이렇게 하면 fetch 요청이 이 신호를 감시하게 됩니다.try...catch블록에서 fetch 요청을 처리합니다. 만약controller.abort()가 호출되어 요청이 취소되면,fetch는DOMException을 발생시키는데, 이때error.name은'AbortError'가 됩니다. 이 오류는 사용자 취소로 인한 것이므로 별도의 오류 처리 대신 로그를 남기거나 무시할 수 있습니다. 일반적인 네트워크 오류나 서버 오류는 그대로catch블록에서 처리합니다.useEffect훅의 클린업 함수(return () => { ... };)에서controller.abort()를 호출합니다. 컴포넌트가 언마운트되거나postId가 변경되어useEffect가 재실행되기 전에, 이전에 실행 중이던 fetch 요청을 취소합니다. 이는 불필요한 백그라운드 요청을 방지합니다.
2. 사용자 액션에 따른 요청 취소 (예: 검색어 추천)
사용자가 입력 필드에 텍스트를 입력할 때마다 API 요청을 보내는 경우, 사용자가 너무 빠르게 입력하면 이전 요청이 완료되지 않은 상태에서 새로운 요청이 계속 발생합니다. 이럴 때 AbortSignal을 활용하여 이전 요청을 취소할 수 있습니다.
1// React 예제 (Create React App 환경 권장) 2import React, { useState, useEffect, useRef } from 'react'; 3 4function SearchComponent() { 5 const [query, setQuery] = useState(''); 6 const [suggestions, setSuggestions] = useState([]); 7 // AbortController를 ref로 관리하여 컴포넌트 생명주기와 독립적으로 유지 8 const controllerRef = useRef(null); 9 10 useEffect(() => { 11 if (!query) { 12 setSuggestions([]); 13 return; 14 } 15 16 // 이전 요청이 있다면 취소 17 if (controllerRef.current) { 18 controllerRef.current.abort(); 19 } 20 21 // 새 AbortController 생성 22 controllerRef.current = new AbortController(); 23 const signal = controllerRef.current.signal; 24 25 const fetchSuggestions = async () => { 26 try { 27 const response = await fetch(`/api/suggestions?q=${query}`, { 28 signal: signal 29 }); 30 31 if (!response.ok) { 32 throw new Error('Network response was not ok'); 33 } 34 35 const data = await response.json(); 36 // 현재 활성화된 요청의 결과만 반영 37 if (!signal.aborted) { 38 setSuggestions(data); 39 } 40 } catch (err) { 41 if (err.name === 'AbortError') { 42 console.log('Search suggestion fetch aborted'); 43 } else { 44 console.error('Error fetching suggestions:', err); 45 } 46 } 47 }; 48 49 fetchSuggestions(); 50 51 // 컴포넌트 언마운트 시에도 취소 (필수는 아니지만 좋은 습관) 52 return () => { 53 if (controllerRef.current) { 54 controllerRef.current.abort(); 55 } 56 }; 57 }, [query]); // query가 변경될 때마다 실행 58 59 const handleInputChange = (event) => { 60 setQuery(event.target.value); 61 }; 62 63 return ( 64 <div> 65 <input 66 type="text" 67 value={query} 68 onChange={handleInputChange} 69 placeholder="검색어를 입력하세요" 70 /> 71 <ul> 72 {suggestions.map((suggestion, index) => ( 73 <li key={index}>{suggestion}</li> 74 ))} 75 </ul> 76 </div> 77 ); 78} 79 80export default SearchComponent;
코드 설명:
useRef를 사용하여AbortController인스턴스를 관리합니다.useRef는 컴포넌트의 리렌더링 간에 값을 유지하면서도, 컴포넌트의 생명주기와 직접적으로 연결되지 않아useEffect의 클린업 함수 없이도 이전 컨트롤러를 참조하고 취소할 수 있게 합니다.query값이 변경될 때마다useEffect가 실행됩니다. 실행될 때마다 이전에 생성된controllerRef.current가 있다면abort()를 호출하여 이전 검색 요청을 취소합니다.- 새로운
AbortController를 생성하고controllerRef.current에 저장하여 다음 요청에 사용할 준비를 합니다. - fetch 요청을 보내고, 응답을 받은 후
signal.aborted상태를 확인하여 현재 요청이 취소된 상태가 아닌 경우에만suggestions상태를 업데이트합니다. 이는abort()가 호출된 직후에 응답이 도착하는 경우를 대비한 안전 장치입니다. useEffect의 클린업 함수에서는 컴포넌트가 언마운트될 때 혹시 모를 진행 중인 요청을 취소하기 위해controllerRef.current.abort()를 호출합니다. 이는 앞서query변경 시 취소하는 로직과는 별개로, 컴포넌트 자체의 생명주기에 따른 취소입니다.
이 패턴은 사용자의 입력 속도보다 API 응답 속도가 느릴 때 발생하는 문제를 효과적으로 해결하며, 실시간 검색이나 자동 완성 기능 구현에 매우 유용합니다.
추가 고려사항 및 팁
- 에러 처리의 중요성:
AbortError는 정상적인 취소 흐름이므로, 일반적인 네트워크 오류와 구분하여 처리하는 것이 좋습니다.catch블록에서err.name === 'AbortError'인지 확인하여 로그를 남기거나 무시하도록 합니다. - Cancelling Multiple Requests: 하나의
AbortController인스턴스는 여러 개의 fetch 요청에signal로 전달될 수 있습니다. 따라서, 특정 상황에서 여러 요청을 한 번에 취소해야 할 때 유용하게 사용할 수 있습니다. fetch외의 라이브러리: Axios와 같은 다른 HTTP 클라이언트 라이브러리도AbortSignal을 지원하므로, 동일한 패턴으로 요청 취소를 구현할 수 있습니다.- 타임아웃 설정:
AbortSignal을 사용하여 요청 타임아웃을 구현할 수도 있습니다.setTimeout과 함께AbortController를 사용하여 일정 시간 후 자동으로abort()를 호출하도록 만들 수 있습니다.
1function fetchWithTimeout(url, options = {}, timeout = 5000) { 2 const controller = new AbortController(); 3 const signal = controller.signal; 4 5 const timeoutId = setTimeout(() => { 6 controller.abort(); 7 }, timeout); 8 9 return fetch(url, { ...options, signal }) 10 .then(response => { 11 clearTimeout(timeoutId); 12 if (!response.ok) { 13 throw new Error(`HTTP error! status: ${response.status}`); 14 } 15 return response.json(); 16 }) 17 .catch(error => { 18 if (error.name === 'AbortError') { 19 throw new Error(`Request timed out after ${timeout}ms`); 20 } 21 throw error; 22 }); 23} 24 25// 사용 예시 26// fetchWithTimeout('/api/data') 27// .then(data => console.log(data)) 28// .catch(error => console.error(error));
결론
AbortController와 AbortSignal은 Fetch API를 통해 네트워크 요청을 더욱 정교하고 효율적으로 관리할 수 있게 해주는 강력한 도구입니다. 컴포넌트의 생명주기 관리, 사용자 인터랙션 기반의 요청 취소, 타임아웃 설정 등 다양한 시나리오에서 불필요한 네트워크 트래픽을 줄이고 애플리케이션의 응답성을 높이는 데 기여합니다. 복잡한 비동기 로직을 다루는 현대적인 프론트엔드 애플리케이션 개발에서 이 패턴을 숙지하고 활용하는 것은 필수적입니다.
참고 자료: