이번 모락모락 프로젝트에서 사용했던 기술들을 각각 어떻게 사용했는지 되짚어 보고 보완할 점을 찾는 기술 회고를 진행하려고 한다. 한 번쯤은 사용했던 기술에 대해 정리하는 시간을 가지고 싶었다.
대망의 첫 번째 기술은 리액트의 데이터 fetching, caching 라이브러리인 SWR이다.
📝SWR 이란 무엇인가?
SWR의 공식문서를 살펴보면 아래와 같이 설명하고 있다.
"데이터 가져오기를 위한 React Hooks"
"SWR"이라는 이름은 HTTP RFC 5861 에 의해 알려진 HTTP 캐시 무효 전략인 stale-while-revalidate 에서 유래되었습니다. SWR은 먼저 캐시(스태일)로부터 데이터를 반환한 후, fetch 요청(재검증)을 하고, 최종적으로 최신화된 데이터를 가져오는 전략입니다.
stale-while-revalidate는 캐싱된 컨텐츠를 즉시 불러오는 즉시성과 재검증을 통해 데이터의 최신 상태를 업데이트하는 최신성을 동시에 취하기 위한 캐시 전략이다. 그래서 SWR 은 데이터를 재검증(요청) 해오는 동안 캐시에 저장된 데이터를 반환하여 화면에 출력하는 방식으로 동작한다.
간략하게 정리하면 react-query 처럼 서버 데이터를 캐싱하면서 요청하고 또 최신화 하도록 자동적으로 요청을 보내 갱신을 해주는 라이브러리이다. 사용 방법은 아래와 같다.
import useSWR from 'swr';
// 특정 url로 get 요청을 보내는 fetcher 함수
const fetcher = async (url: string) =>
axios.get(url).then((res) => res.data);
// useSWR 훅에 url과 fetcher를 보내면 해당 url 로 요청을 보내 data를 응답받아온다.
const { data, error, mutate } = useSWR(url, fetcher);
// 특정 행동 이후에 서버 상태와 동기화되도록 하고싶다면, mutate를 활용할 수 있다.
const onDelete = () => {
if (confirm('정말 코멘트를 삭제하시겠습니까..?')) {
client
.delete(url)
.then(() => {
alert('삭제가 완료되었습니다!');
// 코멘트 삭제 후에 해당 데이터 동기화하여 화면 업데이트
mutate(mutateUrl);
})
.catch((err) => {
console.error(err);
alert('삭제에 실패했습니다.');
});
}
};
❓왜 그리고 어떻게 사용했지?
서버상태 동기화 및 UI 업데이트
첫 번째 이유는 서버의 상태를 적절히 동기화 시켜서 UI를 "쉽게" 업데이트 하기 위함이었다. 지난 토이프로젝트 및 스택오버플로우 클론코딩 프로젝트에서 CRUD 를 구현할 때, 데이터에 어떤 변경을 일으킨 뒤 화면에 업데이트 하는 일이 생각보다 복잡한 일이라는 것을 깨달았다. 지금까지는 크게 두 가지 방법을 사용해왔다.
1. 서버에서 요청해온 데이터를 Recoil 혹은 useState로 로컬 상태로 등록한 뒤에 create, update, delete 등 업데이트가 있을 때 직접! 로컬 상태를 변경시켜 화면을 업데이트했다. 예를들자면 아래와 같이...
const onEditTodo = async (id, updatedTodo) => {
editTodo(id, updatedTodo).then(res => {
const newTodos = data.map(todo => (todo.id === id ? res.data : todo));
setData(newTodos);
});
};
위 코드는 이번에 프리온보딩 인턴쉽 사전과제였던 투두리스트를 구현할 때 작성한 코드이다. 투두 목록 중 하나를 수정한 뒤에 수정요청을 보내고 데이터를 직접 업데이트하는 방식으로 코드를 작성했다. 일단 이 코드의 문제점은 데이터 업데이트에 실패했을 경우를 가정하고 있지 않다는 것이다. try...catch 등으로 관리가 안되기 때문에 데이터 요청에 실패했더라도 화면에는 업데이트가 되는 것처럼 보일 것이다.
2. 데이터 요청을 보낸 뒤에 새로고침을 했다. ㅋㅋㅋㅋㅋㅋㅋ 예를 들면 아래와 같이... 이것은 내가 로컬 상태로 등록해서 동기화를 시켜줄 수도 있다는 생각을 하지 못했을 때라서 이렇게 썼는데, 새로고침을 해버리면 비동기적으로 통신하는 의미도 없고 React로 SPA 를 만드는 의미도 없는 것 같다.
const onAnswerSubmit = () => {
client
.post(`/api/${id}/answers`, {
content,
})
.then(() => {
// post 요청이 성공하면 새로고침
navigate(0);
}) ...
이 외에도 데이터를 가져올 때 각각 다른 컴포넌트에서 같은 데이터를 참조하게 된다면 이를 하나하나 다 전역 상태로 등록해서 관리해주어야 하는지? 아니면 각 컴포넌트에서 다 따로 요청을 보내서 사용해야하는지? 이런 고민이 있었는데, SWR 을 사용하면 이런 생각을 할 필요가 없이 너무나 간편하게 서버 데이터를 재사용하고 업데이트 할 수 있었다.
활용 방식
1. 데이터를 가져오는 함수도 훅으로 만들어서 쓸 수 있기 때문에 재사용성이 높다. 공식문서에서도 권장하는 방법이다.
// useFetchSWR.ts
import { client } from './client';
import useSWR from 'swr';
const fetcher = async (url: string) =>
await client.get(url).then((res) => res.data);
export const useFetch = (url: string) => {
const { data, error, mutate } = useSWR(url, fetcher);
// 에러 상태, 로딩상태 등을 함께 리턴한다.
return {
data,
isLoading: !error && !data,
isError: error,
mutate,
};
};
2. 데이터를 한 번 요청해오면 해당 데이터를 자동으로 캐싱한다. 데이터에 접근할 때는 url 을 통해 가져오면 되기 때문에 여러 컴포넌트에서 같은 상태를 참조할 수 있게 된다. 따라서 recoil 같은 전역상태로 서버 데이터를 관리할 필요가 없다.
// 아래와 같이 훅에 url 을 보내면 별도의 추가 요청 없이 캐싱된 데이터를 가져올 수 있다.
const {
data: answers,
isLoading,
isError,
} = useFetch(`/api/articles/${articleId}/answers?page=1&size=5`);
내 프로젝트에서는 Article, Answer, Comment 크게 세 가지 영역이 있었는데, 어떤 부분에서는 이 기능을 활용하기도 했지만 대부분 각각의 영역에서 props 로 데이터를 내려주는 방식을 사용하기도 해서 데이터를 참조하는 것 자체는 그렇게 많이 사용한 기능이 아닌 것 같다. 다만 캐싱이 되기 때문에 리스트 페이지에서 페이지네이션을 할 때는 이미 요청된 데이터에 대해서 캐시된 데이터를 렌더링하기 때문에 로딩 없이 부드러운 페이지 이동이 가능했다.
3. 전역 상태처럼 관리가 되기 때문에, 업데이트 이후에 상태를 서버와 동기화하고 싶으면 해당 url 과 바인딩된 mutate 함수를 호출하거나, mutate 함수에 url 을 전달하면 된다. 특정 상황에서 업데이트 할 수 있도록 trigger를 제공하거나, 데이터를 미리 제공하여 Optimistic 방식으로 UI 를 업데이트 할 수도 있지만 그런 기능은 사용하지 않았다. 다양한 기능은 공식문서를 참조하는 것이 좋겠다.
mutate를 유용하게 활용한 부분은 서로 같은 레벨에 위치한 컴포넌트에서 데이터 변경이 일어날 때였다. 예를들면 같은 레벨에 위치한 두 컴포넌트 AnswerContent와 AnswerEditor 가 있을 때, AnswerEditor 가 AnswerContent 에서 참조중인 데이터를 변경하는 경우 활용하기 좋았다.
// 답변글 등록 후 업데이트(mutate)를 위해 캐시 데이터 요청
const { data: currAnswers, mutate } = useFetch(
`/api/articles/${articleId}/answers?page=1&size=5`,
);
// 요청 후 UI 업데이트 함수 1
const postAnswer = async (data: FormValue) => {
// POST 요청을 하여 응답 데이터를 받아온다.
const response = await client.post(
`/api/articles/${articleId}/answers`,
payload,
);
setRenderingHeader((prev) => !prev);
// 응답의 data 부분을 추출한다.
const newAnswers = response.data;
// mutate 함수를 호출하여 화면을 업데이트한다.
mutate({ currAnswers, ...newAnswers }, { revalidate: false })
.then(() => {
alert('답변이 성공적으로 등록되었습니다!');
isAnswerPosted(true);
setValue('content', '');
trigger('content');
setFileIdList([]);
})
.catch((err) => {
alert('답변 등록에 실패했습니다...!');
console.log(err);
});
};
// 요청 후 UI 업데이트 함수 2
const patchComment = async (data: FormValue) => {
await client.patch(url, data);
setIsEdit(false);
// mutate 하여 화면 업데이트
mutate(mutateUrl);
};
보통 두 번째 함수처럼 사용하면 서버에 요청을 보내 revalidation 을 거쳐 UI 가 업데이트된다. mutate 함수는 (바인딩이 되지 않은 경우) 첫 번째 인자로 key 를 받고, data와 option 을 인자로 받을 수 있다. data를 보내는 경우 해당 데이터로 캐시를 업데이트 한다. 옵션에 revalidation : false 를 넣은 이유는 Optimistic 방식으로 UI를 업데이트 해서 get 요청이 한 번 더 가지 않도록 하기 위함이었다. 지금 생각해보면 어차피 주요 로직이라서 Optimistic 방식의 업데이트가 적합하지도 않았기 때문에 꼭 저런 방식으로 업데이트를 할 필요는 없었던 것 같다.
데이터 최신화
SWR 의 또 다른 장점으로는 실시간(은 아니지만) 업데이트가 가능하다는 것이다. 여러개의 탭을 띄워놓고 사용할 때 탭을 이동했다가 다시 돌아오면 자동으로 갱신 요청을 보내어 데이터가 최신 상태로 유지될 수 있도록 하는 기능이 있다.
이 기능이 필요하다고 생각했던 이유가, 우리 프로젝트에서는 답변글 그리고 댓글 기능이 있는데 실시간까지는 아니지만 여러 사람이 한 번에 작성을 하는 경우가 있을 수 있기 때문에 자주 최신화를 해주는게 필요하다고 생각했다.
약간의 단점으로는 여러개의 URL 을 통해 데이터를 가지고 오고 있다면, 한 페이지를 클릭했을 때 그에 대한 get 요청이 우다다 가기 때문에 서버쪽에서 당황스럽게 생각하시는 경우가 있었다. 우리의 작고 앙증맞은 서버...
왜 React-query 가 아닌 SWR을 사용했는지?
사실 이 부분에 대해서는 처음에 깊이 고민하지 않았던 것 같다. 세 명의 팀원 중 두 사람이 SWR을 사용해본 경험이 있었고, 이번에 Next.js + Typescript를 사용했는데 이 조합의 다른 프로젝트에서 SWR을 많이 사용하는 것을 보기도 했다. 또 팀원 중 한 분이 React-query 에 비해 SWR 이 더 가볍고 적은 기능이 있기 때문에 소규모 프로젝트에서 사용하기 좋다는 의견을 주셔서 선택하게 되었던 것 같다. React-query 를 사용해본적이 없기 때문에 지금이라도 간단하게 어떤 차이가 있는지 돌아보고자 한다.
잘 정리된 참고 블로그를 살펴보니 react-query 가 Offline mutation 기능을 지원하기도 하고 라이브러리 자체에서 isFetching, isLoading 같은 상태값을 제공하는 부분이 있는 것 같다. 그 외에도 여러 기능들이 더 추가적으로 제공되고, mutation의 경우 리액트 쿼리가 메서드를 가지고 서버 상태를 변경시키는 요청을 보내는 것인 반면 SWR 에서는 직접 요청을 보내고 난 뒤 클라이언트의 데이터를 변경한다는 차이가 있다. 채용 공고를 살펴봐도 리액트 쿼리를 훨씬 더 많이 사용하는 추세인 것 같아서 차후에 다른 프로젝트를 진행할때는 리액트 쿼리를 사용해봐야겠다.
🧐추가적으로 활용한 부분
SWR + SSR
이번에 Next.js 를 도입하면서 게시글 본문 부분에 SSR 방식을 적용하게 되었다. SSR 방식을 적용하면서 데이터 캐싱을 하기 위해서 아래와 같이 SWRConfig 에 fallback 옵션으로 키와 데이터를 제공해주었다. 공식문서 참조
아래와 같이 사용하면 SSR 처럼 pre-rendering 되는 데이터에 대해서 캐싱을 적용할 수 있어서 CSR 에서 한 번 더 요청을 보낼 필요가 없어진다.
const Page: NextPage<{
article: ArticleDetail;
id: string;
}> = ({ article, id }) => {
const keyArticle = `/articles/${id}`;
return (
// 질문 본문에 대한 캐시 초기값 설정
<SWRConfig
value={{
fallback: {
[keyArticle]: {
article,
},
},
}}
>
<QuestionDetail articleId={id} article={article} />
</SWRConfig>
);
};
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const id = ctx.params?.articleId;
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
// 질문 본문 요청
const resArticle = await axios.get(`${BASE_URL}/articles/${id}`);
const article = resArticle.data;
// article 데이터가 존재하지 않으면 일단 404 페이지로 이동
if (!article) {
return {
redirect: {
destination: '/404',
permanent: false,
},
};
}
return {
props: {
article,
id,
},
};
};
export default Page;
한 가지 아쉬운점은 Next.js 의 SSR을 활용하면서 액세스토큰과 리프레시토큰을 로컬 스토리지에 보관했다는 것이다. 로그인된 유저만 볼 수 있는 게시글 데이터의 특정 영역이 있는데 SSR 방식 즉 서버에서 요청을 보내는 경우 브라우저의 로컬 스토리지에 저장된 토큰을 참조해올 수 없기 때문에 정확하지 않은 데이터를 받아오는 문제가 있었다.
페이지네이션
공식 문서를 확인하면 SWR을 확인해 페이지네이션을 구현할 수 있는 내용이 나온다. 이 문서를 확인해서 Pagination 컴포넌트를 만들어서 활용했다. 아래는 공식 문서에 나와있는 활용 방법. 페이지의 인덱스를 상태로 관리하면 상태가 업데이트 될 때마다 요청을 보내게 되고, 이 데이터를 캐싱하면서 페이지네이션을 구현할 수 있다.
function App () {
const [pageIndex, setPageIndex] = useState(0);
// React state인 페이지 인덱스를 포함하는 API URL
const { data } = useSWR(`/api/data?page=${pageIndex}`, fetcher);
// ... 로딩 및 에러 상태를 처리
return <div>
{data.map(item => <div key={item.id}>{item.name}</div>)}
<button onClick={() => setPageIndex(pageIndex - 1)}>Previous</button>
<button onClick={() => setPageIndex(pageIndex + 1)}>Next</button>
</div>
}
🔥개선할 점
사실 프로젝트가 끝나고 차근차근 공부를 하면 내 코드의 부족한 부분이 조금은 보이지 않을까라는 생각에 회고 글 구성을 짜면서 "개선할 점" 이라는 파트를 넣었는데, 지금까지 글을 쓰면서 부족해보이긴 하지만 정확히 어디가 부족하고 더 좋은 방법이 뭐가 있는지 지금 시점에서 생각하기란 쉽지가 않은 것 같다. 왜냐면 저 때의 나는 저게 최선의 코드였고, 지금도 그리 다르지 않은 것 같기 때문에... 따라서 코드에 대한 개선점보다는 지금 글을 쓰면서 드는 아쉬운 점에 대해서 써보는게 좋을 것 같다.
1. 리액트 쿼리도 많이 사용하는데 제대로된 장점 혹은 차이점을 알아보지 않고 SWR을 사용했던 것.
처음에 SWR 에 mutation 이라는 기능이 있다는 것만 알고 제대로된 사용법에 대해서는 알지 못해서 자료를 찾아보며 고생을 했던 적이 있다. mutation 을 post, put, delete 처럼 요청을 보낼 수 있는 기능이라고 생각하고 접근했는데, 사실은 그런 업데이트 요청을 따로 보낸 뒤 데이터를 get 요청으로 업데이트 하는 용도였다.
만약 리액트 쿼리에 useMutation 이라는 훅이 있다는 것을 인지했고 내 프로젝트에 이 기능이 필요하다는 것을 더 빨리 알았다면 리액트 쿼리를 사용했을수도 있겠다는 생각이 들었다. 물론 사이즈가 작고 가볍기 때문에 SWR을 사용한 것도 나쁘지 않은 선택이었지만...
비단 SWR 뿐만 아니라 다른 라이브러리의 경우에도 마찬가지인 것 같다. 이 프로젝트를 하면서 여러개의 비슷한 기능을 하는 라이브러리중에 꼭 필요한 최선을 고르는 것도 프론트 개발자의 역량 중 하나라는 생각을 하게 되었다.
2. 데이터 처리 방식, 아키텍처에 대해 더 고민할 필요가 있다.
프로젝트를 시작할 때 구조, 아키텍처에 대한 고민이 거의 없었던게 좀 많이 아쉽다. 컴포넌트를 분리하는 것에만 몰두한 나머지 View 와 Business Logic 에 대한 구분없이 한 컴포넌트에 뷰에 관련 된 것, 비즈니스 로직에 관련된 코드가 한데 뒤섞여있다. 사실 비즈니스 로직이라는 것이 정확히 어떤 것인지 명확하게 정의할 수 없는데 지금 와서 생각해보면 api 를 요청하는 부분은 확실하게 로직을 분리하는게 더 생산성이 높았을 것이란 생각이 든다. 코드가 너무 한데 섞여있다보니 관리가 어렵고 특히 상호작용이 많이 일어나는 복잡한 컴포넌트의 경우 UI와 관련된 코드도 상당히 많기 때문에 분리가 되지 않아 작성할 때 헷갈리는 경우가 많았다.
또 SWR을 사용하면서 어떤 부분에서는 props로 전달하기도 하고 또 어떤 부분에서는 SWR의 키 값으로 데이터를 가져오는 등 일관성 없게 코드를 짰던 것 같다. 리팩토링을 위해서 코드를 들춰봤을 때 얼마 지나지 않았음에도 "대체 왜 코드를 이렇게 짰지.." 라는 생각이 드는 부분이 꽤 있었다.🥲
글을 작성하기 위해서 다른 선배 개발자들의 글들을 참고한 결과 프론트의 역할은 데이터를 보여주는 것이고, 숙제는 그 데이터를 어떻게 잘 보여주느냐인 것 같다. SWR 이나 react-query 같은 라이브러리도 이런 서버의 데이터를 쉽게 잘 관리하기 위해서 등장했다고 생각한다. 우리 프로젝트처럼 규모가 작음에도 컴포넌트가 많아지고 상호작용이 많아지면서 복잡성이 증가했기 때문에 이를 수정이 용이하고 재사용성이 높도록 관리하면서 기능에 충실하도록 코드를 작성할 수 있도록 이런 아키텍처에도 많은 신경을 쓸 필요가 있다.
👀참고자료
https://yozm.wishket.com/magazine/detail/1663/
https://min9nim.vercel.app/2020-10-05-swr-intro2/
'회고' 카테고리의 다른 글
[Project] 🪙데일리 옥션 최종 회고🪙 (2) | 2023.03.17 |
---|---|
[회고] 부트캠프 수료 후 1개월이 지났다. (1) | 2023.01.14 |
[Project] 🔥모락모락 프로젝트 최종 회고🔥 (2) | 2022.12.07 |
[회고] 코드스테이츠 FE 섹션 4 회고 (0) | 2022.10.19 |
[회고] 코드스테이츠 FE 섹션 3 회고 (0) | 2022.09.19 |