Frontend
서버 상태를 별도로 관리하는 이유
개요
프로그래머스 데브코스 프론트엔드 교육을 받으면서 Tanstack Query (당시에는 리액트 쿼리) 라이브러리는 모든 팀에서 사용하던 국룰과도 같은 라이브러리였다. 당시 함께 교육 받던 동료들의 수준은 항상 나보다 높았다고 느꼈기 때문에 팀 프로젝트에서 민폐를 끼치지 않으려면 어떻게든 사용법을 익혀 코드를 작성해야만 했다.
사실 개발을 학습하면서 라이브러리를 사용할 때 별 생각 없이 갖다 쓰는 경우가 많다. “도구들을 잘 사용해서 결과물을 잘 만들면 된다.”는 주의였기 때문에 언제나 나는 겉으로 보이는 결과물에만 집착했던 것 같다. 내가 프론트엔드 개발자를 희망하고, 이 분야에서 전문가가 되기 위해서는 특정 라이브러리가 영향력을 가지게 된 시대적 배경과 그 인기의 이유 정도는 알아두어야 할 것이다.
지금까지 리액트 쿼리를 바라본 시점
- 서버 데이터에 대한 상태 관리를 하는 라이브러리
- 함수를 넘겨주면 상태를 알아서 업데이트해줌
- 처음부터 사용해와서 이 기술에 대한 편리함 혹은 감사함을 모름
리액트 쿼리에 대해 알아볼 것들
- 탄생) react-query가 해결하고자 하는 문제는 무엇이었는가?
- 구조) react-query는 어떻게 사용하는가?
해부) 작동 구조를 담은 내부 코드는 어떻게 작성되어 있는가? (간략히)(다음 시간에)
서버 상태(Server State)
리액트 쿼리가 편하고 좋은 것은 알겠는데, 서버 상태를 왜 따로 관리해야 할까?에 대한 궁금증이 생긴다. 서버 상태 혹은 서버 데이터 상태는 무엇이고 왜 따로 관리해야 하는지 알아보자.
리액트 쿼리에서 얘기하는 서버 상태
Tanstack Query는 웹 애플리케이션에서 서버 상태를 가져오고, 캐싱하고, 동기화하고, 관리할 때 개발자가 직면하는 일반적인 문제를 해결하기 위해 만들어졌다. 서버 상태는 API 응답이나 데이터베이스 항목과 같이 서버에 존재하고 클라이언트와 가져와 동기화해야 하는 모든 상태를 의미한다.
처음에는 구체적으로 React 애플리케이션을 대상으로 개발되었기 때문에 라이브러리의 이름이React Query
였다. 하지만 인기를 얻고 유용성이 입증되면서Tanstack Query
로 이름을 바꾸고 여러 프레임워크를 지원하면서 많은 사용자가 이용할 수 있게 되었다.
Tanstack query는 ‘서버 상태 관리 기능을 제공하는 라이브러리’라고 할 수 있겠다. 여기서 서버 상태란 프론트엔드 개발자가 클라이언트 측에서 제공하는 데이터가 아닌 서버에서 받아와 사용하는 데이터를 포괄적으로 칭하는 말로, 주로 사용자 데이터, 게시물, 상품 정보 등과 같이 영속적으로 저장되는 보안과 데이터 무결성이 중요한 데이터를 말한다.
클라이언트 상태와 서버 상태
서버 상태를 따로 관리해야 하는 이유는 클라이언트 애플리케이션에서 서버 데이터와 관련된 복잡성을 효과적으로 다루기 위해서이다. 클라이언트 상태와 서버 상태는 무엇이 다른걸까?
클라이언트 상태 vs. 서버 상태
“Apollo는 단순히 여러분이 원하는 데이터를 설명하고 불러오는 일만 하지 않습니다. Apollo는 서버 데이터에 대한 캐시를 제공합니다. 즉, 여러 개의 컴포넌트에서useQuery
hook을 사용해도, 오직 한 번만 불러온 후 캐시에서 제공합니다.이는 서버로부터 데이터를 불러온 후 어디서든 사용할 수 있게 한다는 점에서 우리와 아마 많은 팀들이redux
를 사용해온 방식과 매우 유사합니다.우리는 이미 여느 클라이언트 상태처럼 서버 상태를 다루고 있었던 것처럼 보입니다. 서버 상태는 (서버로부터 불러온 뉴스 기사 목록, 사용자의 상세 정보 등등) 여러분의 어플리케이션이 소유하고 있지는 않다는 점을 제외하면요. 우리는 단지 이 정보의 가장 최신 버전을 화면에 보여주기 위해 빌려왔을 뿐입니다. 데이터 자체는 서버가 보유하고 있죠.저에게는, 데이터를 생각하는 방식에 대한 발상의 전환으로 다가왔습니다. 캐시를 통해 우리가 보유하고있지 않은 데이터를 보여줄 수 있다면, 실제로 전체 앱에서 사용 가능한 클라이언트 상태는 그리 많지 않다는 것을 의미합니다. 이렇게 보면 왜 많은 사람들이 Apollo가 Redux를 대체할 수 있다고 생각하는지 이해가 되네요.” - from Practical React Query by TkDodo (리액트 쿼리 메인테이너)
클라이언트 상태와 서버 상태는 웹 애플리케이션에서 서로 다른 역할을 담당한다. 클라이언트 상태는 주로 UI와 관련된 임시 데이터를 관리하며, 서버 상태는 데이터베이스와 API를 통해 영구적으로 저장되고 여러 클라이언트와 공유되는 데이터를 관리한다. 서버 상태를 따로 관리하면 아래와 같은 이점들을 얻을 수 있다.
위 인용글은 리액트 쿼리의 주요 메인테이너인 TkDodo의 포스팅 실용적인 리액트 쿼리에 있는 문구이다. “실제로 전체 앱에서 사용 가능한 클라이언트 상태는 그리 많지 않다는 것을 의미합니다.”라는 문구가 공감되는데, 처음 팀 프로젝트를 했던 때가 생각났기 때문이다.
공부할 때 워낙 상태 관리가 중요하다고 해서 상태 관리와 라이브러리들을 공부한 뒤 막상 팀 프로젝트를 진행했는데, 막상 서버 상태를 관리하는 라이브러리로 따로 관리하니 ‘클라이언트 상태는 라이브러리로 관리해야 할 만큼 많지 않은 것 같은데?’ 라는 생각이 들었었다. 그래서 라이브러리를 쓰지 않았고, useState로만 관리했다.
서버 상태를 따로 관리하는 이유
데이터 동기화 및 일관성 유지
- 서버에 있는 데이터는 클라이언트 애플리케이션과 항상 동기화되어야 한다. 서버 상태를 따로 관리하지 않으면 클라이언트에서 서버 데이터를 직접 접근하거나 사용할 때 일관성이 깨질 수 있다.
- 서버 데이터가 변경되면 클라이언트는 이를 즉시 반영해야 사용자는 최신 정보를 볼 수 있다. 이를 관리하지 않으면 사용자는 오래된 정보(stale data)를 보게 되어 혼란을 겪을 수 있다.
효율적인 네트워크 요청 관리
- 서버 데이터를 가져올 때 중복된 요청을 방지하고, 불필요한 네트워크 요청을 줄여 성능을 최적화할 수 있다.
- 동일한 데이터를 여러 컴포넌트에서 사용하거나 여러 번 요청할 때, 서버 상태를 관리하면 한 번의 요청으로 캐싱된 데이터를 재사용할 수 있다.
로딩 상태 및 에러 처리
- 네트워크 요청이 지연되거나 실패할 경우, 이를 사용자에게 명확히 전달하고 적절한 조치를 취해,야 한다. 이 과정은 복잡하고 많은 보일러 플레이트 코드를 요구한다.
- 서버 상태 관리 라이브러리를 사용하면 데이터 요청 중 로딩 상태와 에러 상태를 일관되게 관리하여 개발자의 부담을 줄여준다.
데이터 업데이트 및 무효화
- 사용자가 데이터를 생성, 수정, 삭제할 때마다 클라이언트에서 이를 반영하고, 관련 데이터를 무효화하거나 갱신하는 작업은 자칫 복잡해질 가능성이 높다.
- 서버 상태를 별도로 관리하면 데이터의 일관성을 유지하고 최신 상태를 보장할 수 있다.
백그라운드 데이터 갱신
- 서버 데이터가 자주 변경된다면 이를 주기적으로 갱신해주어야 한다. 그러나 이를 직접 구현하면 애플리케이션의 복잡성이 증가한다.
- 서버 상태 관리 라이브러리들은 백그라운드에서 주기적으로 데이터를 갱신하여 최신 상태를 유지하는 기능을 제공한다. 이를 통해 사용자는 항상 최신 정보를 볼 수 있으며, 데이터의 일관성이 보장된다.
서버 상태 관리를 효율적으로 하기 위한 노력
서버 상태를 대부분 React Query(현재는 Tanstack Query)나 SWR을 사용하는 등 여러가지 라이브러리를 통해 관리한다. 현재 사용되고 있는 라이브러리 이전에는 어떤 것들로 서버 상태를 관리했는지 그 배경을 알아보자.
jQuery (2006 -)
- jQuery의 AJAX 기능을 사용하여 서버와 비동기적으로 통신할 수 있었고, 페이지 리로드 없이 서버 데이터를 받아와 클라이언트에 뿌려줄 수 있게 되었다.
- 그러나 상태 관리를 위한 기능이 없어 데이터와 UI에 대한 상태를 관리하는 데 어려움이 있었다. 앱이 복잡해질수록 코드의 가독성과 유지보수가 어려워졌다.
Backbone.js와 AngularJS (2010-)
- MVC 패턴으로 데이터와 UI 상태를 체계적으로 관리하고 데이터 바인딩과 의존성 주입을 통해 클라이언트 사이드 앱 개발을 단순화했다고 평가받는다.
- Backbone.js의 경우 모델과 콜렉션을 사용해 데이터를 서버와 동기화했고, AngularJS는 내장 http 패키지를 통해 AJAX 요청을 관리하며 데이터 바인딩과 의존성 주입을 통해 데이터를 관리했다.
- 그러나 양방향 데이터 바인딩은 대규모 앱에서 성능 문제와 코드의 복잡성으로 인해 개발자가 데이터 흐름을 파악하기 어려웠다.
Redux (2015-)
- 상태 관리를 단순화하고 예측 가능하게 만들기 위해 만들어졌다. 단일 스토어와 불변성 원칙을 도입해서 상태 변화를 추적하고 디버깅을 용이하게 했다.
- 그러나 비동기 작업을 위해 Redux Thunk, Redux Saga 등의 추가 미들웨어가 필요하고, 복잡하고 긴 보일러 플레이트 코드로 인해 초기 설정과 유지보수가 어려웠다.
Apollo Client (2016-)
- GraphQL API와 클라이언트 사이드 앱을 쉽게 통합하기 위해 만들어졌다. 데이터 캐싱과 자동 갱신, 로딩 및 에러 상태 관리 등의 기능을 제공한다.
- 그러나 GraphQL API에 특화되어 있어 RESTful API를 사용하는 경우 다른 라이브러리나 커스텀 코드가 필요했다.
React Query (2019-)
- GrpahQL이 아닌 REST API에서도 Apollo와 같이 데이터 페칭이 단순해졌으면 좋겠다는 마음에서 개발되었다. 데이터 페칭, 캐싱, 동기화, 백그라운드 업데이트를 자동으로 처리하고 로딩 상태, 에러 상태를 간편하게 관리할 수 있다.
- 보일러 플레이트 코드가 적고 설정이 간단하지만 GraphQL과 같은 특정 기술에 비해 범용성이 떨어질 수 있다.
기존에 서버 상태를 관리하던 방법
리액트 쿼리가 나오기 전의 서버 데이터는 어떻게 관리되었을까? 여러 방법이 있겠지만 대표적으로 단순 fetch API, 상태 관리 라이브러리(redux) 사용, Apollo Client와 같은 GraphQL 클라이언트 사용 총 세 가지 방법을 알아보자.
단순 fetch API로 관리하기
가장 기본적인 방법으로,
fetch
API 또는 axios
와 같은 라이브러리로 HTTP 클라이언트를 직접 사용하여 데이터를 가져오고, 이를 로컬 상태로 관리했다.import React, { useEffect, useState } from 'react'; import axios from 'axios'; function DataFetchingComponent() { const [data, setData] = useState(null); // 페칭한 데이터를 담을 상태 const [isLoading, setIsLoading] = useState(true); // 로딩 상태 const [error, setError] = useState(null); // 에러 상태 useEffect(() => { // 데이터 페칭 axios.get('https://api.example.com/data') .then(response => { // 컴포넌트 생명주기를 이용해 데이터 페칭하여 상태 저장 setData(response.data); setIsLoading(false); }) .catch(err => { // 에러 발생 시 에러 상태 설정 setError(err); setIsLoading(false); }); }, []); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> {data.map(item => ( <div key={item.id}>{item.name}</div> ))} </div> ); } export default DataFetchingComponent;
위 코드는 간단하지만 상태 관리, 에러 처리, 데이터 캐싱 등의 문제를 해결하기 위해 굉장히 많은 코드를 작성해야 하며, 수동으로 처리해주어야 한다.
상태 관리 라이브러리 redux로 관리하기
redux와 같은 상태 관리 라이브러리를 사용하여 서버 데이터를 전역 상태로 관리하는 방식이다. 상태의 일관성을 유지하는 데는 용이하지만, 설정과 보일러 플레이트 코드가 많이 필요해진다.
// actions.js export const fetchData = () => async dispatch => { dispatch({ type: 'FETCH_DATA_REQUEST' }); try { const response = await axios.get('https://api.example.com/data'); dispatch({ type: 'FETCH_DATA_SUCCESS', payload: response.data }); } catch (error) { dispatch({ type: 'FETCH_DATA_FAILURE', payload: error }); } }; // reducer.js const initialState = { data: null, isLoading: false, error: null, }; const dataReducer = (state = initialState, action) => { switch (action.type) { case 'FETCH_DATA_REQUEST': return { ...state, isLoading: true, error: null }; case 'FETCH_DATA_SUCCESS': return { ...state, isLoading: false, data: action.payload }; case 'FETCH_DATA_FAILURE': return { ...state, isLoading: false, error: action.payload }; default: return state; } }; export default dataReducer;
// DataFetchingComponent.js import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchData } from './actions'; function DataFetchingComponent() { const dispatch = useDispatch(); const data = useSelector(state => state.data); const isLoading = useSelector(state => state.isLoading); const error = useSelector(state => state.error); useEffect(() => { dispatch(fetchData()); }, [dispatch]); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> {data.map(item => ( <div key={item.id}>{item.name}</div> ))} </div> ); } export default DataFetchingComponent;
일단 코드가 길다. 게다가 여기서 끝이 아니다. 비동기 작업을 처리하기 위해서는
thunk
나 saga
등의 미들웨어 라이브러리를 추가해야 한다. 또한 캐싱 및 재검증(revalidation)이 어려워 수동으로 코드를 작성해 처리해야 했으며, redux는 하나의 큰 중앙 집중식 스토어를 통해 모든 상태를 관리하기 때문에, 서버 상태와 클라이언트 상태를 단일 스토어에서 관리하게 되는 것이 복잡성을 증가시킬 수 있다.
Apollo Client와 같은 GraphQL 클라이언트로 관리하기
Apollo Client란 JavaScript 애플리케이션에서 GraphQL API와 상호 작용할 수 있게 해주는 종합적인 클라이언트 라이브러리다. GraphQL 쿼리 및 뮤테이션, 데이터 캐싱, 반응형 리렌더링, SSR 지원, 개발 도구 지원 등으로 2018년 즈음 크게 조명됐다.
Apollo Client는 GraphQL 기반의 라이브러리로, 클라이언트 애플리케이션의 GraphQL과 데이터 교환을 돕는다. GraphQL을 사용하면 under-fetching(데이터를 가져오기 위해 여러 API를 호출하게 되는 것) 및 over-fetching(불필요한 데이터가 API에 포함되는 것)과 같은 문제를 해결할 수 있다.
import React from 'react'; import { useQuery, gql } from '@apollo/client'; const GET_DATA = gql` query GetData { items { id name } } `; function DataFetchingComponent() { const { loading, error, data } = useQuery(GET_DATA); if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> {data.items.map(item => ( <div key={item.id}>{item.name}</div> ))} </div> ); } export default DataFetchingComponent;
다른 코드들보다 매우 깔끔하고 직관적이면서 사용하기 좋으나, GraphQL API가 아니라면 적용하기 어렵다.
결론
서버 상태는 클라이언트가 서버로부터 받아오는 데이터를 의미한다. 이는 특정 시점에서 받아온 데이터이므로 이 데이터가 실제 최신 데이터와 같은지 우리는 확신할 수 없다. 즉, 서버 상태와 클라이언트 상태가 항상 일치한다는 보장이 없으며 클라이언트에서는 사용자에게 유효하지 않은 데이터를 보여줄 가능성이 생긴다. 이를 해결하고자 렌더링과 상호작용이 일어날 때마다 API 요청과 응답을 주고받는 것은 대단한 비용이 들어가는 일이다.
비용을 줄이기 위해 서버에서 받은 데이터를 캐싱하고 여러 요청을 한 번의 요청으로 바꾸고, 오래된 데이터를 백그라운드에서 자동으로 업데이트하고, 데이터가 오래되었는지 확인하는 등 해야 할 작업이 너무나 많다.
기존의 방법들은 여전히 보일러 플레이트 코드가 많고 복잡했으며 직접 작성해야 하는 코드가 너무나 많았다. GraphQL API를 사용한다면 Apollo Client를 사용할 수 있겠지만, 아직 대부분은 REST API를 사용한다. REST API에서, 리액트 환경에서 사용할 수 있는 서버 상태 관리를 위해 React Query라는 라이브러리가 탄생했다. 그리고 v5가 출시된 지금은 그 이름을 Tanstack Query로 바꾸면서 리액트 뿐 아닌 여러 프레임워크와 환경들을 지원하게 되었다.