State management와 TanStack Query - 1

JUNG MINU·2024년 3월 27일
0
post-thumbnail

TL;DR

서버에서 불러온 데이터(Server state)사용자의 데이터(Client state)와 별도로 관리하는 것이 세계적인 프론트엔드 추세이며, TanStack Query는 Server state management의 표준으로 자리잡은 라이브러리이다.

TanStack Query의 장점

  1. 공통화되고, 선언적인 API 요청 관련 코드를 간편하게 작성할 수 있다.

  2. QueryKey를 이용하여 서버 데이터를 전역적으로 캐싱해 불필요한 API 호출을 최소화한다.

  3. 예측 가능한 적절한 타이밍에 서버 데이터를 업데이트(refetch)할 수 있다.

작성 배경

State에 대한 기본 개념 정리부터, Client state, Server state를 구분하는 프론트엔드의 추세, Server state를 효과적으로 관리하는 라이브러리인 TanStack Query에 대해 알아봤습니다.

State에 대한 관리가 고도화되는 흐름에 맞춰 각 방식의 문제점과, 해결 방안, 그리고 서버 상태 관리 라이브러리의 필요성을 중점으로 정리했습니다.

Tanstack 공식문서의 Vue Query v4 문서를 기준으로 작성했습니다.

State management

State

React: 컴포넌트 안에서 관리되며 변경되면 컴포넌트를 리렌더링하는 JavaScript 객체

Vue: 앱 구동에 필요한 기본 데이터 소스.

특정 DOM만 사용자에게 다시 보여주는 SPA에서 화면을 업데이트 하는 조건은 “사용자에게 표시할 화면의 ‘데이터’가 변화되었는가?”입니다. 사용자에게 보여줄 화면의 데이터가 바뀌면 당연히 화면을 새로 그려야 하기 때문입니다. Single page 애플리케이션은 이 필요때문에 데이터, 즉 state가 변하면 화면을 새로 그릴 수 있도록(re-render) 설계되어 있고, 변화가 필요한 특정 DOM에 데이터를 Bind해 변화되는 데이터를 페이지 새로고침 없이 사용자에게 보여줍니다.

다루는 데이터가 얼마 되지 않는 애플리케이션의 state는 물론 신경쓰지 않고 편한대로 관리할 수 있겠지만, 화면에 보여줘야 하는 데이터의 종류가 매우 다양하고, 데이터의 라이프 사이클이 제각기 다르기 때문에 state의 철저한 관리는 필수적입니다.

상태 관리

State는 화면을 렌더링한다는 특징을 제외하면, 결국 JavaScript의 객체 변수이기 때문에 제대로된 관리가 없다면 손실되거나 사라져버릴 수 있고, 컴포넌트 간 공유도 복잡하고 어렵습니다. 특히, State의 변화는 화면의 렌더링으로 이어진다는 특성상, State의 무분별한 관리는 브라우저 성능에도 큰 영향을 미칩니다. 필요한 화면의 일부만 교체하며 화면을 표시해 성능을 개선하는 가상DOM의 이점을 전혀 활용하지 못할 것입니다.

State의 철저한 관리가 필요한 이유

  • 사용자에게 필요한 데이터를 적절하게 유지하고 관리해야 합니다.

  • State는 화면을 렌더링하는 성능에 영향을 미칩니다.

  • 민감한 데이터의 경우 보안 문제가 생길 수 있습니다.

  • 코드의 유지보수를 위해서는 데이터의 흐름을 파악할 수 있어야 합니다.

Vue에서의 상태 관리

Vue에서는 데이터의 변화를 감지하기 위해 상태를 ‘반응형’ 데이터로 관리합니다. 반응형 상태 선언을 위해 권장되는 형태는 ref() 함수를 사용하는 것입니다.

const age = ref('27')

2024년으로 해가 바뀌어서 나이를 먹었다면, 다음과 같이 상태를 변경하면 화면을 리렌더링 합니다.

age.value++

state는 Vue의 <script setup /> 태그 내에서 작성되어 화면을 리렌더링 하는 조건이 됩니다.

그런데 이 경우 state는 해당 state가 선언 된 특정 .vue파일 내부 스코프에서만 유효합니다. 다른 컴포넌트에서 데이터에 접근하려면 props를 명시적으로 선언해야 하고, 외부에서 컴포넌트에 props를 넘겨 데이터를 공유합니다.

<script setup>
const { age } = defineProps<{ age: number }>()

console.log(age) // 27
</script>

문제점

그런데 문제가 있습니다. props는 무조건 컴포넌트 내부에 선언된 컴포넌트에만 데이터를 넘겨줄 수 있다는 것입니다.


출처: Passing Data Deeply with Context

만약 상하 컴포넌트 구조가 매우 복잡한 프로젝트라면 데이터를 하위 컴포넌트에 직접 넘겨주는 props로는 데이터를 전달하는 데 무리가 있습니다.

여러 컴포넌트를 건너 데이터가 전달되는 구조에서

  • 데이터의 흐름이 어떻게 관리되고 있는지

  • State 변경으로 인한 컴포넌트 리렌더링이 어떻게 일어나는지

  • 데이터가 유실되거나, 함부로 변경되지 않고 있는지

개발자는 예측하기 매우 어렵습니다.

Local state와 Global state

Global state는 이런 props의 단점을 극복하기 위해 등장한 개념입니다. 기존의 state는 이제 local state(지역 상태)global state(전역 상태)로 구분됩니다.

  • Local state: 컴포넌트 내부에서, 해당 컴포넌트를 리렌더링하는 state

  • Global state: 여러 컴포넌트에서 전역적으로 호출되며, 부모-자식 관계와 상관 없이 컴포넌트를 리렌더링하는 state

그리고 일반적으로 global state는 관리의 편의를 위해 라이브러리를 이용합니다.

전역적인 상태 관리가 필요한 이유

  1. 데이터 지속: 새로고침되어 자바스크립트 변수가 새로 선언되지 않는 이상, 컴포넌트와 무관하게 데이터를 유지합니다.

  2. 컴포넌트 간 소통: 부모-자식 관계와 무관하게 전역적으로 컴포넌트 간 데이터 소통을 할 수 있습니다. 변경된 데이터는 의존관계를 갖지 않는 컴포넌트들을 리렌더링 하게 합니다.

  3. 예측 가능성: 위에서 설명한 데이터의 흐름과 컴포넌트의 리렌더링, 데이터의 손상을 예측 가능하도록 합니다.

문제점

State에는 완전히 다른 라이프사이클을 가지는 두 종류의 데이터가 있습니다.

  • 사용자가 브라우저에서 직접 만들어내거나 편집하는 데이터

  • 서버에서 fetch해 가져오는 데이터

이 두 가지의 데이터를 지금처럼 혼합해 관리한다면 문제가 발생합니다.

사용자의 데이터, 서버 데이터 둘 중 하나만 변경되어도 바인딩된 컴포넌트들이 전부 리렌더링 됩니다.

계속 말씀드렸듯이 불필요한 렌더링은 성능 문제를 일으키기 때문에 항상 경계해야 합니다.

서버 데이터는 비효율적으로 여러번 호출될 수 있습니다.

일반적으로 데이터가 필요해 data fetch 함수가 동작되는 상황은 컴포넌트가 마운트될 때입니다. 따라서 다음과 같은 코드를 작성할 수 있습니다.

onMounted(async () => {
	await axios.get(...); // 컴포넌트가 마운트될 때 서버 데이터를 요청합니다.
});

하지만, 다음과 같은 경우를 생각해볼 수 있습니다.

  • 데이터를 가져온지 몇 초만에, 또 다른 컴포넌트가 동일한 데이터를 요청한다면?

  • 여러 자식 컴포넌트에서 동일한 데이터를 요청한다면?

  • 별로 자주 변하지도 않는 데이터인데 컴포넌트 마운트가 수시로 발생한다면?

불필요한 API 요청이 자주 발생할 수 밖에 없습니다.

예를 들어 특정 Table 컴포넌트에서 페이지를 수시로 변경한다고 생각해보겠습니다.

const onChangePage = async () => {
	await axios.get(...); // Table의 page가 변경될 때 서버 데이터를 요청합니다.
};

Table의 페이지를 2페이지로 넘겼다가 1초만에 금방 다시 1페이지로 돌아왔을 때, 방금 막 불러왔던 1페이지를 또 불러와야 합니다.

불필요한 API 요청 자체도 문제이지만, 데이터를 자바스크립트에서 효율적으로 캐싱할 수 있다면 데이터를 매우 빠르게 화면에 표시할 수 있으므로 사용자 경험에 긍정적인 영향을 미칠 것입니다.

Client state와 Server state

위 같은 문제를 해결하기 위해 지금까지 local state와 global state라고만 구분되었던, 사용자가 브라우저에서 만들어낸 데이터들은 client state(클라이언트 상태)로, 서버에서 불러온 데이터는 server state(서버 상태)로 구분합니다.

  • Client state: 사용자가 생성, 편집한 데이터

  • Server state: 서버에 요청해 가져온 데이터

그렇다면 Global state에 서버 데이터를 분리해 저장하며, 데이터를 캐싱한다면 문제가 해결될까요?

export const useCacheStore = defineStore('cache', () => {
  // state: 필요한 데이터
	const data = ref()

	// getter: 캐시된 데이터를 가져오는 함수
	async function getCachedData() {
		// 데이터가 저장되어있지 않는 경우에만 서버에 데이터를 요청합니다.
		if (!data.value) {
			await fetchData()
		}
		return data.value
	}
	
	// action: API data fetch 함수
  async function fetchData() {
    data.value = await axios.get(...); // 일단 error는 고려하지 않겠습니다.
  }

  return { data, getData }
})

이렇게 되면 캐싱되어있는 데이터를 잘 불러올 수 있을 것입니다.

하지만 state에 캐싱된 data와 현재 서버의 data가 항상 일치한다는 보장이 없으므로 위 코드는 문제가 됩니다.

캐싱을 한다 하더라도, 캐시가 최신의 서버 데이터와 일치하는지 구분하기 어렵습니다. 캐시는 언제든 유효하지 않은(invalidate), 이전 버전의 데이터일 수 있습니다.

그렇다면 특정 상황에 데이터를 다시 불러오는(refetch) 상황을 구분해줘야 합니다. 하지만 다양한 API와, 복잡한 컴포넌트를 가지고 있다면 아래와 같은 상황들을 구분하며 refetch하는 로직을 개발하기엔 각각의 함수 하나 하나를 개발하는데 매우 많은 노력이 들어갑니다.

다음과 같은 상황들을 구분하며 데이터 refetch 주기를 정하기 쉽지 않습니다.

  • 컴포넌트가 다시 마운트되면 데이터를 다시 불러와야 할까요?

  • input이나 button 태그에 focus될 때 데이터를 다시 불러와야 할까요?

  • 네트워크가 장애로 인해 끊겼다가 다시 연결되었을 때 데이터를 다시 불러와야 할까요?

  • 혹은 5분마다? 10분마다? 데이터가 만료되어 다시 불러와야 하는 주기를 어느정도로 해야 할까요?

아무튼, 실력 좋은 개발자들이 API 요청에 대해 위에서 제시된 내용을 모두 만족하는 코드를 짠다고 가정해보겠습니다. 개발자들은 저마다의 방식으로 복잡한 구현방식을 만족하는 store를 생성할 것입니다.

Loading 또는 API 요청 성공, 실패를 구분하는 status 값이 필요하다면?

isLoading()? 또는 isError()? boolean값으로 status를 반환하는 코드를 만드는 개발자도 있을 것이고, status: ‘loading’ | ‘success’ | ‘error’ 상태값에 대한 string을 반환하는 코드를 만드는 개발자도 있을 것입니다. 모든 코드를 다 만드는 개발자도 물론 있을 것입니다.

interface apiStatus {
	isLoading: boolean;
}
// or
interface apiStatus {
	status: ‘loading’ | ‘success’ | ‘error’;
}

또 error에 대한 상태값 처리를 할 때도 try-catch문을 이용하는 개발자도 있을 것이고, then-catch문을 이용하는 경우가 있을 것이고, finally문을 사용할지, 예외처리 바깥에 특정 코드를 실행할지…

구현 방법이 매우 복잡하고, 수많은 API를 위한 Pinia store들을 통일된 문법으로 관리하는 것은 너무 어렵습니다.

Server state의 관리를 간편하게 하기 위해 개발된 라이브러리가 바로 Tanstack에서 개발한 Vue Query입니다.

계속...

profile
감각있는 프론트엔드 개발자 정민우입니다.

0개의 댓글