go to post list

상태관리 감 잡기

2024. 02. 29 • 글자를 이해하는 것과 체감하는 건 또 다른 문제라는 걸 여실히 깨닫습니다

0. 상태 관리 라이브러리의 필요성

웬만큼 복잡한 경우가 아니라면 Props Drilling으로도 해결할 수 있고, React Context useContext hook으로 잘 쓰이게 된 이후에 프론트엔드 개발 공부를 시작한 저로서는 굳이 라이브러리를 사용할 이유를 느끼고 있지 못했었습니다. 프로젝트 규모가 작았기 때문에 체감하지 못했던 것이 컸는데요, 그래서 Redux나 Recoil같은 상태 관리 라이브러리를 한 번 살펴보고 나서도 JWT 저장소처럼 꼭 필요한 곳을 제외하고는 Props Drilling을 이용해 해결하려고 했었습니다. 본격적으로 프로젝트가 복잡해지기 시작하자 내려줘야 하는 props의 종류가 많아지고 컴포넌트 트리가 깊어지면서 개발 피로도가 높아졌습니다.

여기에 급작스럽게 기능 추가가 이루어지니 이미 강하게 결합된 컴포넌트들이 문제가 되어 수정이 난해해졌습니다. API Call을 하거나 그 결과를 가공하는 부분을 그냥 묶어서 커스텀 훅으로 빼기만 하는 사실상의 안티 패턴을 많이 쓰게 되기도 했습니다. 무언가 잘못되었다고 느꼈을 때는 이미 상당 부분 개발이 진행되고 난 후였고, 모든 부분에서 숙련도가 확연히 떨어지는 상태였기 때문에 리팩토링은 커녕 구현에 급급해 유지보수가 거의 불가능한 코드를 짜게 되었습니다. 그게 제 첫 번째 프로젝트입니다. 그 코드는 좋은 반면교사로 남았고요... 그로부터 3개월 정도가 지나 마지막 프로젝트를 진행할 때에는 Zustand를 선택했었는데, 서로 다른 Next App간에 인증 상태 공유를 구현할 수 있다고 해서 도입한 것이었지만 결국 인증을 쿠키로 구현하게 되면서 그런 식으로 사용하지는 않게 됐습니다.

아무튼 저는 이때 Zustand와 함께 data fetching 라이브러리로 SWR을 함께 써 보고 나서야 상태 관리에 대한 전체적인 맥락을 체감할 수 있었는데요, 그때 불현듯 떠올라 정리하게 된 것들을 글로 남겨 보려고 합니다. 상태 관리 라이브러리를 사용한다는 전제하에 해당 프로젝트에서 어떤 기준으로 활용했는지 나름대로의 기준을 정리한 것입니다.

1. 상태의 분류

저는 상태의 종류가 크게 두 가지라고 봤는데요, 이를 편의상 서버 상태와 클라이언트 상태라고 이름지어 보겠습니다. 저는 상태의 생성 지점이 서버/클라이언트인 것 정도로 생각하고 있습니다.

서버 상태와 클라이언트 상태

서버 상태: API Call 등으로 React 외부에서 가져오는 데이터

React 외부의 데이터이기 때문에 CRUD에 대한 요청을 통해 데이터를 가져옵니다. 대표적으로 fetch API, Axios나 React Query (=Tanstack Query), SWR 등으로 외부에서 가져온 데이터는 서버 상태에 속합니다.

클라이언트 상태: 최초 생성 지점이 React인 데이터

클라이언트 상태는 최초 생성 지점이 React이기 때문에 클라이언트 단에서 편집할 수 있습니다. 구체적인 예시로는 input의 value, 컴포넌트끼리의 상호작용에 필요한 값(예: isOpen)들이 있습니다. 저는 클라이언트 상태의 케이스를 크게 아래처럼 나눌 수 있다고 봤습니다.

1. 클라이언트 상태의 분류

기준: 상태가 React App 바깥으로 전송되어야 하는가?

React 외부로 나가야 하는 상태

React 외부로 나가는 상태는 서버로 직접 전달되어야 하는 값을 뜻합니다. 즉 API Call을 통해 서버 상태의 CRUD를 위해 직접적으로 필요한 값이며, 비즈니스 로직을 구성하게 됩니다. API Request 같은 값이 여기에 속합니다.

React 내부에서만 쓰이는 상태

React 내부에서만 쓰이는 상태는 모달 창의 열림/닫힘 상태나, 현재 적용된 사이트의 테마(React 외부에 저장하지 않을 때) 등 대체로 React 컴포넌트의 동작을 제어하기 위해 사용되는 값입니다. React 내부에서만 쓰이는 값은 아래 기준에 따라 또 두 가지로 분류할 수 있는데요,

React 내부의 상태가 도메인에 의존적인가?

  • 그렇다: 이 컴포넌트는 이 도메인 외에는 쓰일 일이 없다
  • 아니다: 이 컴포넌트는 여러 곳에서 재사용되어야 한다

이 기준에 따라 상태를 분류해보면 해당 상태를 어떻게 관리해야 할지 대략적으로 판단할 수 있습니다.

2. 상태 관리 도구를 선정하는 기준

기준 1: 도메인 진입점과 재사용성

상태 관리 라이브러리를 사용하려고 하는 프로젝트에서, 어떤 상태를 상태 관리 라이브러리에 저장하고 어떤 상태를 Props Drilling이나 Context API로 사용해야 할까요? 저는 ‘도메인 진입점’이 그 판단 기준이었습니다. 서버 상태와 React 외부로 나가는 상태는 비즈니스 로직에 묶여 있기 때문에 대부분 도메인과 연관되어 있습니다.

프로젝트가 진행되는 맥락에 따라 당연히 다른 선택을 할 수 있습니다만, 프로젝트 세 번을 진행하면서 저는 React 내부에서만 쓰이는 값에는 Props Drilling 또는 Context API를, React 외부로 나가는 값에는 상태 관리 라이브러리를 사용하는 것이 대체로 나은 선택이라고 봤습니다. 또 특정 도메인에 의존적이라면 상태 관리 라이브러리를, 그렇지 않다면 Props Drilling 또는 Context API를 사용하는 쪽이 조금 더 나은 선택이라고 생각했습니다. 도메인 진입점이라는 판단 기준은 다른 말로 컴포넌트의 재사용성과 같습니다.

기준 2: 컴포넌트의(혹은 상태의) 제어 위치

재사용성이 비교적 좋은 컴포넌트들은 제어 위치로 한 번 더 분류할 수 있는데요, Button같은 작은 단위의 컴포넌트의 제어는 제어 위치가 비교적 가깝고 특정하기 쉬우니 Props Drilling이, Modal이나 BottomSheet, Card같은 큰 단위의 컴포넌트는 제어 위치가 불분명하니 Context API가 좀더 적당합니다. 게시판 데이터를 받아 오는 컴포넌트는 해당 도메인에서만 사용되니 상태 관리 라이브러리와 연동하면 되고, 게시글 컴포넌트는 다른 곳에서 다시 쓰일 여지가 있다면 Props Drilling을 사용하는 게 자연스럽습니다. 이때 이 부분이 도메인 진입점이 됩니다. 다른 상태 관리 라이브러리를 사용하는 여러 프로젝트들이 하나의 어플리케이션을 구성하는 경우가 있는 경우도 보았고요, 도메인에 의존적이지 않은 컴포넌트들이 특정 의존성에 종속되면 재사용성이 떨어지기 때문에 이 문제를 최대한 배제하고 같은 UI 패키지를 최대한 사용할 수 있도록 하기 위함입니다.

예시로 Context API로 제어하는 모달의 onClick이 상태 관리 라이브러리에 있는 데이터를 가지고 API를 호출하거나, API 호출 결과가 상태 관리 라이브러리를 업데이트해야 하는 상황을 살펴봅시다. 여기서 모달은 도메인과는 상관없는 컴포넌트지만, 상태 관리 라이브러리는 도메인에 종속된 데이터를 가지고 있습니다.

Notion의 언어 설정을 바꾸는 모달 창

Notion의 언어 설정을 바꾸는 모달 창

자주 보이는 케이스이지만 데이터를 어디에 저장할지, 컴포넌트를 어디서 제어할지 정해지지 않았다면 십중팔구 설계가 꼬이게 됩니다. 모달 컴포넌트를 한 번 잘 설계해 놓으면 깔끔하게 사용할 수 있지만, 상태와 연관지어 잘 설계해놓지 않으면 끔찍한 상태 관리 지옥을 만나게 됩니다. (저도 알고 싶지 않았습니다) 이런 경우처럼, 상태 관리 라이브러리 데이터와 Context API 동작 제어 컴포넌트 두 개를 같이 사용해야 하는 상황이라면 도메인 관련된 데이터와 로직을 상태 관리 라이브러리에 작성하되, 최종적인 동작 제어를 Context API에 맡기는 방식으로 설계했을 때 깔끔하게 해결할 수 있었습니다.

또 다른 예시로 Input의 value같은 경우 React 외부로 나가게 될 데이터가 될 수도 있지만, 서버 상태의 분류나 필터를 트리거하는 UI 제어 데이터가 될 수도 있습니다. 다시 말해 상태 관리 도구는 관리하고자 하는 데이터의 특성과 프로젝트 상황에 따라 적절하게 사용하면 되는 것이고, 무조건 컴포넌트에 종속적인 것은 아닙니다.

3. 서버 상태와 클라이언트 상태의 격리

서버에서 가져온 데이터를 상태 관리 라이브러리에 저장해 관리한다고 할 때, 클라이언트 상태와 서버에서 가져온 상태를 따로 격리해(각각 다른 방식으로) 관리하는 방법이 있습니다. 서로 다른 두 상태 관리 라이브러리를 사용하거나 bottom-up 방식의 상태 관리 라이브러리를 사용하는 방법도 있지만 SWR이나 React Query 같은 data-fetching 라이브러리들의 특성에 따라 자동으로 격리되는 경우도 있습니다. 예를 들어 Redux + Axios 조합에서는 서버 상태를 (당연히) Redux에서 관리하게 되며, Zustand + SWR(또는 React Query) 조합에서는 SWR이 그 자체로 상태 관리자로 기능하기 때문에 Single Source of Truth의 원칙에 따라 서버 상태를 Zustand로 옮기지 않고 그대로 SWR이 관리하게 됩니다. 두 조합에는 어떤 차이가 있는 것일까요?

Axios와 SWR/React Query는 모두 data-fetching 라이브러리이지만, 가장 큰 차이는 SWR과 React Query는 데이터를 캐싱하고, 다른 컴포넌트에서 일어난 같은 호출에 대해서도 캐시된 값을 제공한다는 점입니다. 그래서 SWR이나 React-Query는 상태 관리자처럼 기능할 수 있게 됩니다. 그 상태를 변경하는 방법이 서버에서 다시 가져오는 것뿐이라는 중요한 차이가 있지만요. 이 특성 때문에 SWR을 서버 상태 관리자로 사용할 때 난처해지는 경우가 생깁니다. 서버에서 조회를 한 후 해당 서버 상태를 수정 가능한 클라이언트 상태로 전환시켜야 하는 경우에 그런데요, 수정이나 편집 기능이 대표적인 케이스입니다. 이 상황을 확장해서 말하면 클라이언트 상태의 출처가 서버여야 하는 경우라고 할 수 있겠습니다. 이 상황에 대한 해결책은 저는 두 가지라고 봤는데요, (1)클라이언트 상태관리 라이브러리에서 data fetching을 수행해 애초부터 클라이언트 상태로 간주하거나, 특수한 경우 (2)useEffect 훅으로 두 라이브러리를 연결하는 것입니다. (2)의 경우 서버 상태를 편집해 클라이언트 상태로 만들어 삽입하는 것이라고 할 수 있습니다.

이 개념을 이해하지 못했을 때 벌어지는 일을 살펴보겠습니다. 아래 글은 React QueryuseQuery API의 세 개의 콜백을 deprecated 처리하게 된 배경을 설명한 글입니다. (제 글을 이해하기 위해 읽을 필요는 없지만, 궁금하신 분들은 읽어보세요.)

useQuery API는 기본적으로 데이터를 가져오고 캐시하는 기능을 제공합니다. 위에서 설명한 것처럼 전역 캐시 덕분에 상태 관리자로도 기능합니다. 제공하는 여러 옵션들 중에는 세 개의 콜백이 있는데요, 그 중 onSuccess라는 콜백은 데이터를 가져오는 데 성공했을 때 그 결과를 콜백의 인자로 제공합니다.

Javascript

export function useTodos() {
  const { dispatch } = useDispatch()

  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    onSuccess: (data) => {
      dispatch(setTodos(data))
    },
  })
}
//출처: Breaking React Query’s API on purpose

위 코드처럼 사람들은 이 onSuccess 콜백을 이용해 가져온 데이터를 다른 상태관리 라이브러리로 결과값을 넣는 로직을 아주 많이 사용하고 있었다고 합니다. 글에서는 Redux와 React Query를 함께 사용하고 있는 경우를 상정해 이런 식의 패턴을 사용했을 때 어떤 문제가 생기는지 자세히 설명하고 있는데요, 여러 문제가 있지만 저희가 살펴볼 부분은 React Query의 캐시와 상태관리 라이브러리의 데이터가 동기화되지 않을 수 있는 경우입니다. React Query가 데이터를 가져오지 않고 캐시된 값을 제공할 때에는 HTTP 요청을 하지 않기 때문에 onSuccess 콜백이 실행되지 않습니다. 이때 useQuery로 가져온 데이터를 사용하는 컴포넌트와 Redux에 있는 데이터를 사용하는 컴포넌트가 동시에 다른 값을 보여줄 수 있습니다. Redux의 값이 업데이트되지 않기 때문이에요. 인용한 글에서 이야기한 맥락과는 다르지만, React Query상태를 관리한다는 점을 인지하지 못해 하나의 서버 상태 출처를 만드려다 두 개의 서버 상태 출처를 만들게 되는 경우에 위 코드와 같은 방식으로 구현해 같은 버그가 발생할 수 있습니다. (사실 제가 이렇게 구현했었습니다)

이 패턴은 지금까지 나온 단 하나의 케이스를 제외하고는 좋은 용례가 없었다고 하는데, 너무 많은 사람들이 이 패턴을 사용하고 있어 안티패턴으로 간주했다고 합니다. 글에서는 API 자체가 너무 직관적(intuitive)인 것을 원인으로 지목했는데요, 요컨대 너무 좋은 직관성을 가진 API가 안티패턴을 사용하도록 유도한 케이스라고 판단한 것입니다. React Query의 새 버전에서는 세 개의 콜백이 deprecated 처리되었습니다.

4. 마무리

React에서, 혹은 비슷한 결의 SPA 프레임워크에서 다루는 ‘상태’에 대한 기초적인 내용의 글이었습니다. 상태에 관련된 여러 라이브러리를 사용해 보고, 상태에 대한 논의들을 따라가다 보면 직관적으로 알게 되는 영역인데요, 사실 부끄럽게도 저는 프로젝트 세 개를 할 때까지 상태에 대해 글로 쓰거나 설명을 할 만큼의 정확한 기준이나 지식이 없었기 때문에 이번 기회에 정리해 보았습니다. 프로젝트를 진행하면서 여러 케이스를 만나고 문제를 해결하기 위해 동료와 열심히 의견을 나누었기에 어느 정도 정리를 할 수 있었습니다. 역시 개발자라는 직업의 매력은 (동반) 성장이 아닌가 합니다.

사실 이건 기술적인 내용이라기보다는 감상이나 느낌에 가까운 것 같습니다. 어떤 체계나 공식을 만드려고 한 것은 아니고, 상태를 분류해봄으로써 상태 관리라는 큰 맥락의 일부를 이해해보려는 시도를 했다는 정도로 받아들여주시면 감사하겠습니다. 틀린 내용이나 피드백, 의견이 있으시다면 댓글을 달아주세요. 감사합니다.

다른 글