🔥 학습목표
- Custom Hook을 사용하여 코드를 리펙토링 한다.
- React.lazy() 및 suspense가 어디서 어떻게 사용되는지 파악한다.
이번 실습을 통해 새로 알게 된 유용한 라이브러리이다. 솔로 프로젝트 등을 할 때 DB나 서버를 구축하지 않은 상태에서 프론트 화면을 만들기란 여간 쉬운 일이 아니었는데...
단지 json 파일만을 사용하여 REST API 서버를 구축해주는 아주 고마운 라이브러리다.
아래 명령어를 입력하여 설치한다.
npm i -g json-server
그 다음 앱 내에 존재하는 data 폴더로 이동한 뒤
다음 명령어를 입력한다.
cd data
json-server --watch data.json --port 3001
port 3001
옵션을 넣지 않으면 json-sever
가 자동으로 3000번 포트를 사용하려 하기 때문에, 꼭 다른 포트를 설정해야 한다. (3000번은 클라이언트 화면으로 쓸 거니까)
Postman에서 REST API를 사용하여 요청하면 아래와 같이 잘 작동하는 걸 볼 수 있다.
더 자세한 설명은
🎁 json-server 사용하기
가장 기본적인 홈 화면 모습이다.
BlogList 컴포넌트와 Loading 컴포넌트를 불러와야 한다.
return (
<div className="home">
{isPending && <Loading />}
{blogs && <BlogList blogs={blogs} />}
</div>
)
홈 화면에 데이터를 불러오는 동안(isPending
)
로딩 화면을 보여주고,
데이터(blogs
)가 불러와지면 BlogList
를 보여준다.
일단 App.js 에서 불러와야 하는 기본적인 컴포넌트 목록은 다음과 같다.
Components
Pages
평소대로 코드를 작성한다면 아래와 같이 한 줄 씩 import를 할 것이다.
import Navbar from './component/Navbar';
import Footer from './component/Footer';
그런데 이번 실습의 주제는 코드를 최적화 하여 리펙토링 하는 것이다.
특히 최근에 배운 React.lazy()와 Suspense 를 사용하는 게 이번 실습의 목표이기도 하다.
React.lazy()가 무엇인지 다시 복습해보자면, React에서의 코드 분할(Code Spliting)을 떠올려야 한다.
대부분 React 앱은 Webpack, Rollup 같은 툴을 사용해 번들링(Bundling)을 한다.
번들링이란, 사용자에게 웹 앱을 제공하기 위해 여러 코드 및 프로그램을 묶는 행위를 뜻한다.
이렇게 하면 HTML 웹 페이지에 JS를 쉽게 추가할 수 있다고 한다.
그러나 점점 DOM을 다루는 정도가 정교해지면서 JS 코드가 방대해지고, 그로 인해 번들링 후 코드를 해석하고 실행하는 정도가 느려지게 되었다.
그래서 등장한 것이 코드 분할 아이디어다.
"어떤 페이지에서 코드를 해석하고 실행하는 정도가 느려졌는지 파악한 뒤, 번들을 나누어 당장 필요한 코드만 불러올 수 없을까?"
번들이 거대해지는 것을 방지하기 위해 번들을 물리적으로 나누려 한다. 런타임 시 여러 번들을 동적으로 만들고 불러온다. 이를 Webpack
, Rollup
과 같은 번들러가 지원해준다.
즉, 당장 필요한 코드가 아니면 따로 분리시키고 나중에 필요할 때 불러와서 사용하는 것이다.
React에서의 코드 분할
React는 SPA(Single-Page-Application)이기 때문에 모든 컴포넌트까지 한 번에 불러온다.
사용하지 않는 컴포넌트는 나중에 불러오기 위해 코드 분할 개념을 도입한다.
React에서 코드 분할 방법은 Danamic import(동적 불러오기)를 사용하는 것이다.
지금껏 코드의 최상위에서 import
를 통해 라이브러리 및 파일을 불러온 것을 static import(정적 불러오기) 라고 한다.
└▷ 번들링 시 코드 구조를 분석해 모듈을 한 곳에 모은 다음, 사용하지 않는 건 제거하는 작업을 하기 때문에 그렇다.
아래 예시를 보면 조금 더 이해가 간다.
form.addEventListener("submit", e => {
e.preventDefault();
/* 코드의 중간에 불러올 수 있다. */
import('library.moduleA')
.then(module => module.default)
.then(someFunction())
.catch(handleError());
});
const someFunction = () => {
/* moduleA를 여기서 사용. */
}
dynamic import 를 사용하면 moduleA
는 사용자가 form을 통해 양식을 제출한 경우에만 가져오게 된다.
이제 진짜로 React.lazy()를 써서 코드 동적 불러오기를 실현해보자!
Navbar, Footer 같은 경우 모든 페이지에서 불러오는 컴포넌트이기 때문에 굳이 동적으로 불러올 필요가 없다.
그러니 다른 컴포넌트에 대해서 React.lazy()
를 사용해보자.
const Home = React.lazy(() => import("./Home"));
const CreateBlog = React.lazy(() => import('./blogComponent/CreateBlog'));
const BlogDetails = React.lazy(() => import('./blogComponent/BlogDetail'));
const NotFound = React.lazy(() => import('./component/NotFound'));
const Loading = React.lazy(() => import('./component/Loading'));
참고로 React.lazy()
는 단독으로 쓰일 수 없다. React.suspense
컴포넌트의 하위에서 렌더링을 해야한다.
import { Suspense, lazy } from 'react';
Router로 분기가 나누어진 컴포넌트들을 lazy
를 통해 import 하면 해당 경로로 이동할 때 컴포넌트를 불러오게 된다.
이 과정에서 로딩하는 시간이 생기기 때문에 Suspense
를 사용하여 아직 렌더링 준비가 되지 않은 컴포넌트가 있으면 로딩 화면을 보여주고, 로딩이 끝나면 준비된 컴포넌트를 보여준다.
<BrowserRouter>
<div className="app">
<Navbar />
<Suspense fallback={<Loading/>}>
<Routes>
<Route exact path="/" element={<Home blogs={blogs} isPending={isPending} />} />
...
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
<Footer/>
</div>
</BrowserRouter>
fallback
: 컴포넌트가 로드될 때까지 로딩 화면으로 보여줄 React 엘리먼트를 받는다.
Suspense
컴포넌트 하나로 여러 개의 lazy
컴포넌트를 보여줄 수 있다.
BlogDetail
은 아래 캡처와 같이 게시물을 클릭했을 때 볼 수 있는 상세보기 화면이다.
BlogDetail
페이지로 이동하는 url은 /blogs/:id
와 같다.
url
파라미터를 통해 id 값을 받으면, GET 요청을 통해 해당 아이디에 대한 정보를 가져온다.
const { id } = useParams();
const [blog, isPending, error] = useFetch(`http://localhost:3001/blogs/${id}`)
여기서 바로 처음 보는 듯 처음 보지 않는 useFetch
hook이 사용되었다.
fetch
와 같이 자주 쓰이는 메서드는 여러 파일에서 비슷한 코드를 반복 작성하게 된다. 이러한 코드 중복을 막기 위해 Hook을 사용할 수 있다.
그렇다면 useFetch
를 빼내어 작성해보자.
일단 Custom Hook에서 관리 할 상태를 정리해보자.
const [data, setData] = useState(null);
const [isPending, setIsPending] = useState(true);
const [error, setError] = useState(null);
이제 useEffect()
를 사용하여 매개변수로 넘어오는 url
이 바뀔 때마다 fetch
함수를 수행하도록 한다.
useEffect(() => {
setTimeout(() => {
fetch(url, {
headers: {
"Content-Type" : "application/json",
Accept: "application/json"
}
})
.then(res => {
if (!res.ok) {
throw Error('could not fetch the data for that resource');
}
return res.json();
})
.then(data => {
// 데이터를 얻으면 isPending을 false로 바꾼다.
setIsPending(false);
setData(data);
setError(null);
})
.catch(err => {
setIsPending(false);
setError(err.message);
})
}, 1000);
}, [url])
특정 게시글을 삭제하는 것 또한 REST API의 DELETE
메소드를 통해 해결한다.
이 또한 fetch
함수를 사용할 테고, 어디서든 중복 사용 될 가능성이 충분하기 때문에 utils 폴더 같은 곳에 함수를 따로 빼둔다.
특별히 관리해야 하는 상태가 없기 때문에 훅으로 관리하지는 않는다.
const BASE_URL = 'http://localhost:3000/';
const BLOG_URL = 'http://localhost:3000/blogs/';
export const fetchDelete = (url, id) => {
fetch(`${url}${id}`, {
method: "DELETE",
})
.then(() => {
window.location.href = BASE_URL;
})
.catch((error) => {
console.error('Error', error);
})
}
삭제가 완료되면 자동으로 홈 화면(BASE_URL
)으로 이동한다.
window.location.href
는 페이지를 리렌더링 시키고, 그로 인해 App.js 에서
const [blogs, isPending, error] = useFetch("http://localhost:3001/blogs/");
훅을 다시 발동시켜 삭제가 된 후 업데이트 된 blogs
목록을 가져온다.
여기선 사용되지 않지만 만약 리렌더링 하지 않고 페이지 이동을 하고싶다면 pushState를 쓰면 된다고 한다.
🎁 pushState 참고 블로그
그 다음 삭제 버튼이 클릭되었을 때 다음과 같이 호출하면 된다.
const handleDeleteClick = () => {
fetchDelete('http://localhost:3001/blogs/', id);
}
좋아요 기능은 [isLike, setIsLike]
같은 상태를 사용하여 관리하면 된다.
주의할 점은 좋아요 수가 증가/감소할 때마다 해당 게시물 데이터에 Like 값을 업데이트 해줘야 한다.
REST API의 PATCH
메서드를 사용할 건데, 이것 또한 여러 번 중복 호출 될 수 있는 함수이므로 따로 빼둔다.
export const fetchPatch = (url, id, data) => {
fetch(`${url}${id}`, {
method : "PATCH",
headers: {
"Content-Type" : "application/json",
Accept: "application/json"
},
body: JSON.stringify(data)
})
.then(() => {
window.location.href = `${BLOG_URL}${id}`;
})
.catch((error) => {
console.error('Error', error);
})
}
여기서 반성할 점이, 처음에 아무것도 모르고 PUT
메서드 요청을 했다가 뒤늦게 '근데 PUT
과 PATCH
의 차이가 뭐더라?' 하고 검색해봤다.
잊어선 안 될 걸 잊고 있었다... PUT
은 모든 데이터를 업데이트 하고, PATCH
는 특정 데이터만 업데이트 하는 것이다.
나는 PUT
을 쓴 다음 특정 블로그 데이터를 ...blog
로 구조분해할당 한 뒤 likes 를 따로 넣어줬었다... 그런데 PATCH
는 likes 값을 body에 보내면 해당 키 값만 수정해준다.
이제 마우스가 클릭 되었을 때 변한 likes 값만 data로써 보내주면 된다.
const handleLikeClick = () => {
setIsLike(!isLike);
let patchData = {"likes" : blog.likes + 1};
fetchPatch('http://localhost:3001/blogs/', id, patchData);
}
CreateBlog
화면은 다음과 같이 생겼다.
제목, 내용, 작성자를 입력하고 '등록' 버튼을 누르면 Home 페이지로 이동한다.
게시글을 작성하는 페이지에도 중복 되는 코드가 있다.
바로 제목, 내용, 작성자를 입력하는 Input 입력란에 대한 onChange() 이벤트 리스너가 동일하다.
제목을 입력하면 input 안에 제목이 입력되고, 내용을 입력하면 input 안에 내용이 입력되야 한다.
이를 useInput
이라는 훅으로 분리하며 관리하면 된다.
const titleBind = useInput('');
const bodyBind = useInput('');
const authorBind = useInput('김코딩');
useInput 훅은 다음과 같이 작동한다.
const useInput = () => {
const [value, setValue] = useState('');
const bind = {
value,
onChange : useCallback((e) => {
const { value } = e.target;
setValue(value)
}, [])
}
return bind;
}
입력 받는 value
를 상태로 관리한 뒤, value
와 onChange
함수를 가진 bind
객체를 반환한다.
const handleSubmit = (e) => {
e.preventDefault();
const data = {
"title": titleBind.value,
"body": bodyBind.value,
"author": authorBind.value,
"likes": 0
}
fetchCreate("http://localhost:3001/blogs/", data)
}
그런 뒤 form 태그에 submit 이벤트가 발생하면 얻은 값들을 하나의 data 객체로 정리하여 CREATE 메서드를 요청한다.
CREATE 또한 자주 사용될 수 있는 요청이기 때문에 별도의 함수로 빼두면 좋다.
🎁 fetch 함수 사용법