HTTP Request
그동안 살펴본 예제들은 모두 데이터들이 내부 State에 존재해서 UI 표현에 큰 어려움이 없었습니다.
실제 서비스에서는 대부분의 중요한 데이터가 서버에 있고
이 데이터를 가져와야만 화면에 유용한 데이터를 보여줄 수 있습니다.
이러한 외부 요청을 가능하게 해주는 것이 HTTP Request입니다.
HTTP Request는 간단하게 말해서 인터넷을 통해 서버에게 자료를 달라고 부탁하는 것입니다.
간단한 예시로, 웹 브라우저에서 주소창에 URL을 입력하는 것이 HTTP 요청의 일부입니다.
서버는 이에 응답으로 HTML, JSON, 이미지 등 다양한 데이터를 반환합니다.
우리는 Javascript에서 fetch API를 활용하여 코드로 요청을 보낼 수 있고
응답을 받아 화면에 반영할 수 있습니다.
👍 이제 이 요청을 보내는 타이밍이 중요합니다!
만약 컴포넌트가 랜더링 되는 도중에 요청을 보내려고 한다면, 문제가 생깁니다.
랜더링은 화면에 DOM을 만들고 그리는 과정인데, 이 때 네트워크 요청을 기다리다 보면
화면이 멈추고 반응하지 않게 됩니다.
이를 방지하기 위해 화면을 다 그린 후에 필요한 외부 작업은 별도의 흐름에서 처리해야 합니다.
여기서 useEffect를 활용할 수 있는데 이렇게 되면 DOM이 모두 준비된 뒤에 HTTP 요청을 수행할 수 있습니다.
응답이 도착하면 그 때 State를 업데이트하여 화면을 리 랜더링 해줍니다.
즉, 랜더링 단계와 데이터 불러오기 단계를 명확하게 분리합니다.
이로써 화면 전체가 아니라 컴포넌트 단위로 정교하게 제어가 가능합니다.
🌐 예시로 쇼핑몰의 상품 목록 요청 흐름을 살펴보겠습니다.
메인 컴포넌트가 처음 랜더링 될 때는 빈 화면 혹은 "로딩 중.."을 표시합니다.
useEffect에서 서버에 상품 목록을 요청하여 서버가 데이터를 응답으로 돌려주면 State에 저장하고
React가 해당 State를 기반으로 화면을 리 랜더링 합니다.
이로써 렌더링 -> 요청 -> 응답 -> 리 랜더링의 자연스러운 비동기 흐름이 완성됩니다.
🧭 이제 자바스크립트에서 비동기 요청을 다루는 fetch, axios에 대해 알아봅니다.
먼저 fetch는 브라우저에 내장되어 있어서 따로 설치할 필요가 없는 API 입니다.
fetch에 url를 넘겨주면 Promise를 반환하고, 그 결과를 then 체인이나 async - await 구문으로 처리하면 됩니다.
fetch("https://fakestoreapi.com/products")
.then(res => res.json())
.then(data => { console.log(data); })여기서 res.json()의 역할은 서버 응답을 JSON 형식으로 변환해주는 역할이며
최종적으로 data 변수에 실제 데이터를 저장합니다.
하지만 then 체인보다는 async - await 구문을 선호하는 편입니다.
async function fetchStore() {
const response = await fetch('https://fakestoreapi.com/products')
const data = await response.json()
}반대로 axios는 설치해야 하지만, 자동으로 JSON 파싱을 해주고 에러 처리도 더 깔끔하며
요청 취소나 헤더 설정같은 기능도 단순합니다.
npm(node package manager)를 통해 설치한 후 아래와 같이 사용합니다.
async function fetchStore() {
try {
const res = await axios.get("https://fakestoreapi.com/products");
console.log(res.data)
} catch(err) {
console.error(err)
}
}이제 우리가 신경써야 할 것은 React에서 이러한 비동기 요청을 언제 실행해야 하는지 입니다.
단순히 컴포넌트 함수 안에서 직접 요청 호출을 하면 안됩니다.
랜더링 중 네트워크 요청을 기다리면 화면이 늦게 뜨거나 멈춘 듯한 지연 현상이 발생하기 때문입니다.
React는 랜더링은 최대한 빨리 끝내야 한다라는 철칙이 있으므로, 외부 작업은 랜더링 중 수행 금지입니다.
반드시 랜더링이 끝난 뒤 별도의 흐름에서 실행해야 합니다. 이 때 사용해야 할 것이 useEffect 입니다.
하지만 다음과 같이 작성한다면 문제가 생깁니다.
useEffect(async () => {
const res = await axios.get("https://fakestoreapi.com/products");
console.log(res.data);
}, [])
// 잘못된 예시useEffect의 콜백 함수(실행 함수) 자체를 async로 처리하는 경우인데
React는 useEffect 안의 함수가 그냥 실행할 코드를 담고 있거나, cleanup 함수를 반환할 것이라고 기대하고 있는데,
위처럼 콜백 함수에 async 키워드를 붙이면 함수가 Promise 형태의 객체를 반환합니다.
이 때 React는 이 Promise 형태의 객체를 cleanup 함수로 오해하게 됩니다. 이 때 경고 혹은 의도한 대로 작동하지 않습니다.
간단하게 설명하면, useEffect는 끝날 때 청소 코드가 있으면 알려줘 하고 기다리고 있는데
async를 붙이게 되면 추후에 결과를 주는 것을 의미하는 Promise 객체만 반환하게 됩니다.
따라서, 콜백 함수 내에서 async 함수를 정의하여 실행하거나, 즉시 실행 함수(IIFE)를 활용하여 해결합니다.
이렇게 되면 useEffect는 여전히 동기적으로 cleanup 함수를 기다리고
콜백 함수 내에서는 비동기 요청을 안전하게 실행할 수 있습니다.
💥 실무에서 기억해야 할 것은 에러 처리입니다.
HTTP 요청은 네트워크 요청이므로 언제든지 실패할 수 있어 에러 처리를 해야합니다.
서버 응답 없음, 네트워크 끊김, 요청 주소 오류 등 언제든지 발생할 수 있습니다.
이 때 반드시 try - catch 패턴으로 에러를 catch하여 사용자에게 명확한 메시지를 제공해야 합니다.
🤬 또 중요한 것중 하나는 요청 취소입니다.
예를 들어 상품 목록 요청 중 사용자가 다른 페이지로 이동할 때 해당 요청은 취소해야 합니다.
요청이 계속 유지되면 네트워크 및 메모리 낭비가 발생하기 때문입니다.
이 때 AbortController 기능을 통해 요청을 취소할 수 있고
axios의 경우 취소 토큰(cancel token) 기능을 제공하기 때문에 반드시 고려해야 하는 부분입니다.
😏 이제 실제 fakestoreapi를 이용해서 HTTP 요청을 보내고 응답을 받아 리 랜더링까지 하는 과정을 살펴봅시다.
실습 코드는 다음과 같습니다.
import { useState, useEffect } from 'react';
import axios from 'axios';
export default function ProductList() {
// 1. State 준비
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchProducts() {
try {
const res = await axios.get("https://fakestoreapi.com/products");
setProducts(res.data);
setLoading(false);
} catch(err) {
setError("상품을 불러오는데 실패했습니다.");
setLoading(false);
}
}
fetchProducts();
}, [])
if (loading) return <p> 로딩 중.... </p>
if (error) return <p style={{ color: 'red'}}>{error}</p>
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<img src={p.image} alt={p.title} width="50" />
{p.title} - ${p.price}
</li>
)}
</ul>
)
}먼저, useState를 이용해 3가지 State 값을 준비합니다.
각각 상품 목록 상태, 로딩 여부 상태, 에러 메시지 상태를 의미합니다.
이렇게 상태를 나눠두면 화면에서 서로 다른 UI를 보여주고, 상태들이 바뀔 때마다 리 랜더링됩니다.
예를 들어, HTTP 요청을 보내고 응답을 받기 전까지는 loading 값이 true이므로, 로딩 중.. 텍스트를 랜더링합니다.
이후 서버에서 응답을 받으면, products와 loading의 상태가 변하여 리 랜더링되고, 이때 상품 목록을 보여줍니다.
반대로 요청이 실패하면 error와 loading의 상태가 변하여 역시 리 랜더링되고, 이 때는 에러 메시지를 보여줍니다.
그리고 반드시, 상태에 따라 조건부 랜더링을 하여 서로 다른 UI를 반환하여야 합니다.
여기서 좀 더 나아가, 실무에서 신경 써야 할 요청 취소 및 에러 처리 부분에서 안정성 문제를 다뤄보겠습니다.
먼저 요청 취소 상황에서는 예를 들어 사용자가 상품 목록 페이지를 열자마자 바로 다른 페이지로 이동했을 때
이미 언마운트된 컴포넌트에서 계속 요청이 진행중이라면, 불필요한 리 랜더링이 유발될 것입니다.
여기서 AbortController가 등장합니다. 이것은 브라우저의 내장 객체로 네트워크 요청을 강제로 취소합니다.
axios 같은 라이브러리에서도 이 컨트롤러를 연결해 사용하면 요청을 취소할 수 있습니다.
AbortController에는 controller.signal 이라는 속성이 있는데 이 signal을 axios 요청에 전달해두면
나중에 controller.abort()를 호출했을 때 요청이 즉시 중단됩니다.
다음과 같은 코드를 살펴봅시다.
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
const res = await axios.get("https://fakestoreapi.com/products", {
signal: controller.signal
})
setProducts(res.data);
setLoading(false);
} catch (err) {
if (axios.isCancel(err)) {
console.log("요청이 취소되었습니다.")
} else {
setError("상품을 불러오는데 실패했습니다.")
}
setLoading(false);
}
}
fetchProducts();
return () => {
controller.abort();
}
}, [])위 코드처럼 axios 요청에 signal 속성에 controller.signal 속성을 전달합니다.
이를 통해 나중에 controller.abort() 실행 시 요청이 자동으로 취소됩니다.
마지막으로, cleanup 함수에서 controller.abort()를 호출해 불필요한 요청을 정리합니다.
여기서 상태값은 변하지 않으므로 리 랜더링은 발생하지 않습니다.
이제 에러 처리를 좀 더 고도화해보겠습니다. 단순히 실패했습니다 라는 에러 메시지를 남겨주기 보다는
에러 코드별로 실패 이유를 분기하여 보여줄 수 있습니다.
try {
......
} catch (err) {
if (axios.isCancel(err)) {
setError("요청이 취소되었습니다.")
}
else if (err.code === "ECONNABORTED") {
setError("요청 시간이 초과되었습니다. 다시 시도해주세요.")
}
else if (err.response && err.response.status === 404) {
setError("상품을 찾을 수 없습니다.")
}
else if (err.response && err.response.status >= 500) {
setError("서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.")
}
else {
setError("알 수 없는 오류가 발생했습니다.")
}
}이를 통해 사용자의 상황을 구체적으로 안내하여 이해도를 상승시키고 서비스 신뢰도를 상승시킵니다.
😉 여기서 더 나아가 요청 취소 및 에러 처리를 좀 더 고도화하기 위한 패턴을 알아봅니다.
먼저, axios를 조금 더 실무적으로 쓰기 위해 axios 인스턴스를 하나 만들어 두겠습니다.
이렇게 하면 반복되는 요청을 줄이고, 모든 요청이 같은 규칙을 따르게 할 수 있습니다.
import axios from 'axios'
export const apiClient = axios.create({
baseURL: 'https://fakestoreapi.com', // 모든 요청이 이 주소를 기본으로 사용합니다.
timeout: 8000, // 8초가 지나면 자동으로 요청을 중단합니다.
headers: {
"Content-Type": "application/json" // JSON 형식으로 주고 받습니다.
}
})위 코드를 통해 AbortController 패턴에 적용한 최종 코드는 아래와 같습니다.
useEffect(() => {
const controller = new AbortController()
async function fetchProducts() {
try {
const res = await apiClient.get("/products", { signal: controller.signal });
setProducts(res.data)
setLoading(false)
} catch (err) {
if (err.code === "ERR_CANCELED") {
console.log("요청이 취소되었습니다.")
return
}
setError("데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.")
setLoading(false)
}
}
fetchProducts();
return () => {
controller.abort()
}
}, [])