상태관리란? (라이브러리: Zustand + React Query)

이언덕·2025년 10월 2일
post-thumbnail

1. 상태란 무엇인가?

리액트에서 상태(state)화면(UI)을 변화시키는 데이터다.
버튼을 눌렀을 때 숫자가 늘어나거나, 로그인했을 때 화면 구성이 달라지는 것처럼 UI는 항상 어떤 데이터를 근거로 그려진다.
이 데이터를 상태라고 부른다.

1-1. 상태의 두 가지 범주

상태는 보통 로컬(Local)전역(Global)으로 나눌 수 있다.

  • 로컬 상태(Local State)
    특정 컴포넌트 안에서만 필요한 값이다.
    예를 들어 입력창에 적은 글자, 모달의 열림 여부, 체크박스의 선택 상태 같은 것들이 있다.
    이런 값은 useState, useReducer 같은 기본 훅으로 간단히 다룰 수 있다.

  • 전역 상태(Global State)
    여러 컴포넌트에서 동시에 참조하거나 수정해야 하는 값이다.
    로그인한 사용자 정보, 다크모드 여부, 장바구니 데이터 같은 게 대표적이다.
    이런 값은 단순히 props로 내려주기엔 불편하고, 규모가 커질수록 관리하기가 점점 힘들어진다.

즉, 상태는 “UI를 변화시키는 데이터”이며, 이 데이터가 어디까지 영향을 미치는지에 따라 관리 방식이 달라진다.



1-2. 상태의 생애주기

상태도 생명 주기가 있다. 말 그대로 상태가 언제 만들어지고, 언제 바뀌고, 언제 사라지는가를 말한다.


1. 초기화 (Initialization)
컴포넌트가 처음 렌더링될 때 상태에 기본값을 준다.

const [count, setCount] = useState(0); // count는 0에서 시작

.


2. 업데이트 (Update)
이벤트나 비동기 요청이 끝난 결과로 값이 바뀐다.

setCount(count + 1); // 버튼을 클릭하면 count가 바뀐다

.


3. 재렌더 (Re-render)
상태 값이 바뀌면, 그 값을 쓰는 컴포넌트가 다시 그려진다.
즉, 화면(UI)이 데이터(state)에 맞춰 새로 갱신된다.


4. 정리 (Cleanup)
컴포넌트가 화면에서 사라지면, 그 안에 있던 상태도 함께 없어진다.
예를 들어 모달이 닫히면서 해당 컴포넌트가 언마운트되면 상태도 초기화된다.


👉 이렇게 보면 “상태 = UI와 함께 태어나고, 업데이트되며, 사라지는 데이터”라고 이해하면 된다.



1-3. 예시 1: 카운터 (로컬 상태)

아주 단순한 카운터 앱을 만들어보자.

"use client";
import { useState } from "react";

export default function Counter() {
  // (1) 초기화: count는 0에서 시작
  const [count, setCount] = useState(0);

  return (
    <div className="flex flex-col items-center gap-2">
      {/* (2) 소비: 상태를 화면에 보여줌 */}
      <p>현재 카운트: {count}</p>

      {/* (3) 업데이트: 버튼 클릭 시 상태 변경 */}
      <button
        onClick={() => setCount((c) => c + 1)} // 이전 값 기반으로 안전하게 업데이트
        className="rounded bg-blue-500 px-4 py-2 text-white"

        +1 증가
      </button>
    </div>
  );
}
  • useState(0)초기화 단계다. 첫 렌더링 때 count = 0으로 시작한다.
  • 버튼을 누르면 setCount가 호출돼 업데이트가 일어난다.
  • 값이 바뀌면 해당 값을 쓰고 있는 <p> 부분만 다시 재렌더된다.


    즉, 코드 안에서 상태의 생애주기를 직접 확인할 수 있다.



1-4. 예시 2: 로그인 여부 (전역으로 확장 가능)

조금 더 현실적인 예를 들어보자.

"use client";
import { useState } from "react";

export default function LoginExample() {
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  return (
    <div>
      {isLoggedIn ? (
        <p>환영합니다, 사용자님!</p> // 로그인 후
      ) : (
        <button
          onClick={() => setIsLoggedIn(true)}
          className="rounded bg-green-600 px-4 py-2 text-white"

          로그인
        </button>
      )}
    </div>
  );
}

여기서는 로그인 여부(isLoggedIn)만 관리하면 된다.
하지만 실제 서비스에서는 로그인 여부를 헤더, 사이드바, 프로필, 심지어 API 요청까지 여러 군데에서 써야 한다.


그럴 땐 이 상태를 단순 로컬로만 관리하기 어렵다.
그래서 전역 상태로 끌어올리거나, 심지어 서버 데이터와 동기화해야 할 수도 있다.



1-5. 상태관리란?

앞에서 상태가 무엇인지 살펴봤다. 그렇다면 상태관리는 뭘까?
간단히 말하면, 상태를 잘 정리해서 필요한 곳에 가져다 쓰고, 꼬이지 않게 유지하는 일이다.


예를 들어보자.

  • 쇼핑몰에서 “장바구니”에 담긴 상품 개수는 헤더, 장바구니 페이지, 결제 페이지에서 모두 보여줘야 한다.
  • 만약 이 데이터를 각 컴포넌트에서 따로 관리한다면? 어떤 곳은 3개, 어떤 곳은 2개로 어긋나 버릴 수 있다.

👉 이런 문제를 막으려면 상태를 한 곳에 모아두고, 모든 컴포넌트가 같은 값을 바라보게 만들어야 한다. 이게 바로 상태관리다.

상태관리가 필요한 이유를 한 줄로 정리하면

“상태가 흩어져서 꼬이지 않도록 한 곳에서 규칙적으로 다루는 것”

즉, 상태관리는 어려운 개념이 아니다.
그냥 프로젝트가 커질수록 “이 데이터는 어디에 두고, 누가 쓸 수 있고, 어떻게 바뀌게 할까?”를 정리하는 과정이다.



2. 왜 상태관리가 중요한가?

처음엔 useState만 써도 충분하다. 버튼 클릭, 모달 열기, 입력 값 관리 정도는 간단히 해결된다.
그런데 프로젝트가 커질수록 문제가 생기기 시작한다.



2-1. Props Drilling 문제

상태를 자식 컴포넌트까지 내려주려면 props를 계속 전달해야 한다.
예를 들어 로그인한 유저 이름을 헤더 → 네비게이션 → 유저 메뉴까지 내려주려면, 중간 컴포넌트들은 실제로 쓰지도 않으면서 단순히 데이터를 “전달만” 하게 된다.

👉 컴포넌트가 많아질수록 구조가 복잡해지고, 유지보수가 힘들어진다.



2-2. 여러 곳에서 같은 데이터 필요

로그인 상태, 다크 모드 여부, 장바구니 데이터 같은 건 한 군데만 쓰이지 않는다.

  • 로그인 상태는 헤더, 마이페이지, 결제 페이지에서 모두 필요하다.
  • 다크 모드는 전체 화면에 영향을 준다.
  • 장바구니는 수량 뱃지, 장바구니 페이지, 결제 단계에서 함께 써야 한다.

👉 데이터를 여기저기 따로 관리하면 값이 서로 어긋나 버린다.



2-3. 서버 데이터와의 동기화

API로 받아오는 데이터는 더 까다롭다.

  • 새로고침할 때마다 같은 요청이 여러 번 나가기도 하고,
  • 오래된 데이터가 남아서 화면과 서버 값이 다르기도 한다.
  • 에러, 로딩 상태를 관리하는 것도 귀찮다.

👉 서버에서 가져오는 데이터까지 관리하려면, 단순 useState로는 금방 한계를 느낀다.



2-4. 성능 문제

상태가 바뀌면 그 상태를 쓰는 컴포넌트가 다시 렌더링된다.
만약 전역 상태를 무조건 모든 컴포넌트에 뿌려버리면, 작은 변화에도 앱 전체가 흔들릴 수 있다.

👉 효율적으로 “필요한 컴포넌트만” 업데이트할 수 있어야 한다.



2-5. 정리

상태가 단순할 때는 문제가 드러나지 않는다.
하지만 프로젝트가 커질수록 데이터를 어디에 두고, 어떻게 공유할지가 점점 더 중요해진다.
그래서 개발자들은 상태관리(State Management)라는 개념을 도입하고, 이를 도와주는 상태관리 라이브러리를 사용한다.



3. 상태관리 라이브러리가 필요한 이유

리액트만으로도 상태를 다룰 수 있다. useState, useReducer, Context API 같은 기본 도구들이 있기 때문이다. 작은 규모의 프로젝트라면 이걸로도 충분하다.
하지만 프로젝트가 커질수록, 화면이 많아지고 서버 통신이 늘어날수록 상태를 다루는 일이 점점 버거워진다.



3-1. 상태 공유의 불편함

Context API를 쓰면 전역 상태를 만들 수 있다. 하지만 규모가 커지면 금세 불편해진다.

  • Provider가 트리의 최상단에 겹겹이 쌓인다.
  • 상태를 쓰지 않는 컴포넌트까지 불필요하게 리렌더링된다.
  • 어디에서 어떤 상태를 관리하는지 추적하기가 힘들어진다.

처음엔 간단해 보여도, 실제로 10개, 20개 상태가 쌓이면 금방 복잡해진다.
“이 값을 어디서 바꿨는지” 추적하는 것만으로도 시간이 꽤 든다.



3-2. 서버 상태 관리의 어려움

서버에서 데이터를 가져오는 순간, 단순한 useState 수준을 넘어선다.

  • 데이터를 캐싱해야 한다. (같은 요청을 매번 새로 보낼 필요는 없다)
  • 캐시를 언제 무효화할지도 정해야 한다. (예: 글 작성 후 목록 갱신)
  • 로딩/에러 상태도 같이 관리해야 한다.

만약 이걸 useEffect + fetch로만 구현한다면, 화면마다 비슷한 코드가 반복된다.
그리고 한 페이지에서 새로고침하면 또 요청을 보내는 식으로 비효율적인 흐름이 생기기 쉽다.



3-3. 생산성과 유지보수

상태관리 라이브러리는 단순히 “코드를 줄여준다”가 아니다.

  • 전역 상태를 한 곳에 모아 관리할 수 있다.
  • 서버 데이터는 자동으로 캐싱/동기화된다.
  • 관심 없는 컴포넌트는 리렌더를 피해서 성능이 좋아진다.
  • 로직이 정리돼 있으니, 협업하는 사람도 코드를 쉽게 이해한다.

결국 도구를 쓰면 코드가 더 예측 가능하고 유지보수가 쉬워진다.



3-4. 결론

리액트만으로도 상태를 관리할 수 있지만, 규모가 커지면 코드의 복잡도를 낮추고 데이터 흐름을 예측 가능하게 하기 위해 상태관리 라이브러리가 필요하다.



4. 상태 관리 방식의 분류

상태를 관리하는 방법은 크게 로컬 / 전역 / 서버 / 하이브리드 네 가지로 나눌 수 있다.
이건 서로 대체제가 아니라, 실제 프로젝트에서는 상황에 따라 함께 사용하게 된다.

4-1. 로컬 상태(Local State)

가장 단순한 상태다. 한 컴포넌트 안에서만 쓰이는 값을 말한다.

예를 들어, 일간 플래너 모듈에서 “오늘 메모 입력창이 열렸는가 닫혔는가” 같은 상태는 로컬이면 충분하다.
입력값이나 체크박스 같은 것도 마찬가지다. 컴포넌트가 사라지면 상태도 같이 초기화되니 관리하기 쉽다.

로컬 상태의 장점은 단순함이다. 의존 범위가 좁고, 해당 컴포넌트 안에서만 영향을 미치기 때문에 문제가 생겨도 원인을 찾기 쉽다.
하지만 이 값이 여러 컴포넌트에서 동시에 필요해지는 순간부터 props로 전달하기 번거롭고, 구조가 복잡해진다.


핵심: 한 컴포넌트 안에서만 쓰인다면 로컬 상태가 가장 직관적이고 빠른 선택이다.



4-2. 전역 상태(Global State)

상태가 여러 컴포넌트에 동시에 필요하다면 전역으로 끌어올려야 한다.

예를 들어, 대시보드 레이아웃 정보(일간, 주간, 월간, 투두, 습관, 메모 모듈이 어떤 위치에 배치되어 있는가)는 페이지 전반에서 필요하다.
헤더, 사이드바, 편집 모드, 심지어 모듈 자체도 모두 같은 값을 공유해야 한다.

이럴 때 전역 상태를 쓰면 모든 컴포넌트가 같은 “중앙 저장소”를 바라보기 때문에 값이 어긋날 걱정이 줄어든다.
다만 전역 상태는 잘못 설계하면 오히려 복잡도가 증가한다.
예를 들어 불필요하게 모든 컴포넌트가 전역을 구독하면, 작은 변화에도 앱 전체가 다시 렌더링될 수 있다.


핵심: 전역 상태는 공용 창고다. 필요한 데이터만 올려서 관리해야 유지보수가 쉽다.



4-3. 서버 상태(Server State)

서버에서 가져오는 데이터는 성격이 다르다.
단순히 저장하는 걸 넘어 캐싱, 무효화, 리패치 같은 규칙이 필요하기 때문이다.

예를 들어 일간/주간/월간 일정, 투두 리스트, 메모, 습관 기록, 대시보드 레이아웃 설정 등 모든 기능 데이터는 서버와 항상 동기화돼야 한다.
오늘 투두를 추가하거나 레이아웃을 바꾸면 서버에 반영되고, 다른 기기에서 접속해도 같은 상태가 보여야 한다.
만약 이를 단순 useState로만 관리하면, 새로고침하거나 다른 화면에서 접근할 때 데이터가 뒤엉키기 쉽다.

React Query 같은 도구를 쓰면 이 과정을 자동으로 관리해준다.
같은 요청은 중복 호출을 막고, 오래된 데이터를 자동으로 새로 가져오며, 로딩/에러 상태까지 일관성 있게 제공한다.


핵심: 서버 상태는 단순한 “값”이 아니라, “데이터 동기화 규칙”을 포함한다.



4-4. 하이브리드(혼합) 접근

실제로는 로컬, 전역, 서버 상태를 섞어서 사용한다.

예를 들어, 로그인한 사용자의 프로필 정보는 서버에서 가져와야 한다.
하지만 이 값을 앱 전반에서 쉽게 쓰려면 전역 상태에도 저장해두는 게 편하다.
즉, 서버 상태(React Query)로 가져오고 → 전역 상태(Zustand)에 일부를 저장하는 식으로 조합하는 것이다.

또 반대로, 모달 열림 여부나 일시적인 UI 값은 굳이 전역이나 서버로 올릴 필요가 없다. 그냥 로컬 상태로 두는 게 낫다.


핵심: 하나의 방식만 고집하지 말고, 데이터의 범위와 성격에 따라 섞어 쓰는 것이 현실적이다.



4-5. 정리

  • 한 컴포넌트 안에서만 쓰이면 → 로컬 상태
  • 여러 컴포넌트에서 동시에 쓰이면 → 전역 상태
  • 서버에서 불러오고 동기화가 필요하면 → 서버 상태
  • 실제 프로젝트는 대부분 → 하이브리드

결국 상태 관리란 “이 데이터는 어디에 두는 게 가장 자연스러운가?”를 판단하는 일이다.



5. 대표적인 상태관리 라이브러리 비교

상태관리 도구는 많다. 하지만 모든 도구가 같은 문제를 푸는 건 아니다.
어떤 건 “전역 상태 공유”에 강하고, 어떤 건 “서버 데이터 동기화”에 최적화되어 있다.
따라서 “이 도구가 최고”라는 답보다는 “이 도구가 어떤 상황에서 빛나는가”를 이해하는 게 중요하다.

5-1. Context API

리액트에 내장된 기능으로, 전역 값을 하위 컴포넌트에게 전달할 때 쓴다.
테마(light/dark), 언어 설정, 인증 토큰처럼 단순한 값 공유에는 충분하다.
하지만 값이 바뀌면 해당 컨텍스트를 구독하는 모든 하위 컴포넌트가 리렌더링되기 때문에, 규모가 커질수록 성능 문제가 생긴다.


핵심: 작고 단순한 전역 상태에는 적합하지만, 복잡한 전역 관리에는 한계가 있다.



5-2. Redux

가장 오래되고 대표적인 상태관리 라이브러리다. 액션 → 리듀서 → 스토어라는 명확한 규칙이 있고, 미들웨어와 개발자도구가 풍부하다.
상태 변화의 기록을 추적할 수 있어 팀 협업에서 “누가 언제 어떤 상태를 만들었는가”를 파악하기 좋다.

하지만 보일러플레이트 코드가 많고, 작은 프로젝트에서 쓰기엔 다소 무겁다.
Redux Toolkit이 나오면서 많이 간결해졌지만, 여전히 “규칙을 엄격히 지켜야 한다”는 부담이 있다.


핵심: 규칙성과 추적이 중요한 큰 규모 팀 프로젝트에 강하다.



5-3. MobX

상태를 “관찰 가능한 값”으로 만들고, 필요한 부분만 반응한다.
덕분에 코드가 간결하고, 선언적으로 짜기가 쉽다.
하지만 마법처럼 자동으로 반응하다 보니, 상태 변화의 흐름을 명시적으로 추적하기는 어렵다.
작은 프로젝트나 개인 취향에는 좋지만, 팀 협업에서는 일관성이 떨어질 수 있다.


핵심: 빠르게 개발하기 좋지만, 상태 흐름이 불투명해질 수 있다.



5-4. Recoil

페이스북에서 만든 라이브러리로, 리액트와 잘 어울리도록 설계됐다.
상태를 작은 단위(atom)로 쪼개 관리할 수 있어 유연하다.
다만 아직 커뮤니티와 생태계가 다른 라이브러리에 비해 크지 않고, 실무 적용 사례도 상대적으로 적다.


핵심: 리액트스러운 전역 상태 관리지만, 아직은 선택지가 제한적이다.



5-5. Zustand

가볍고 직관적인 전역 상태 라이브러리다.
보일러플레이트가 거의 없고, 단순한 함수 호출로 스토어를 만들 수 있다.
필요한 상태만 선택해서 구독할 수 있기 때문에 불필요한 렌더링도 최소화된다.
작은 프로젝트에도 가볍게 쓸 수 있고, 큰 프로젝트에도 확장성이 있다.


핵심: “간단하지만 강력한 전역 상태 관리”에 잘 맞는다.



5-6. React Query (TanStack Query)

서버 상태 관리에 특화된 라이브러리다.
API 요청을 자동으로 캐싱하고, 데이터가 오래되면 자동으로 새로 가져온다.
로딩, 에러, 리패치 로직까지 일관성 있게 제공하기 때문에 서버와 연동되는 화면 개발 속도가 크게 빨라진다.


핵심: “서버 데이터는 서버답게 관리한다”는 철학을 가진 도구다.



5-7. 정리

  • Context API → 단순 전역 값 전달
  • Redux → 큰 팀, 상태 추적/규칙이 중요한 프로젝트
  • MobX → 빠른 개발, 하지만 상태 흐름이 불투명할 수 있음
  • Recoil → 리액트 친화적, 하지만 생태계가 아직 작음
  • Zustand → 가볍고 직관적인 전역 상태 관리
  • React Query → 서버 상태 관리의 사실상 표준

즉, 이번 프로젝트에서는 Zustand(전역) + React Query(서버) 조합이 가장 자연스럽다.
각 도구의 장점을 살려 역할을 분리하는 게 목표다.



6. 내 프로젝트에서 선택한 조합: zustand + @tanstack/react-query

리액트에서 상태 관리는 단순히 값을 저장하는 것 이상의 의미를 가진다.
어디에 저장할지, 언제 갱신할지, 누구와 공유할지 같은 규칙을 정하지 않으면 금세 혼란스러워진다.


예를 들어,

  • UI 전용 값: “모듈 숨김 여부”, “다이얼로그 열림 상태” 같은 건 화면에서 즉각 반응해야 하고 서버와 상관없다.
  • 서버 연동 값: "일간, 주간, 월간 기록", “투두 리스트”, “습관 기록”등 기록을 저장할 수 있는 건 다른 기기에서도 동기화되어야 하므로 서버와의 규칙이 필요하다.

나는 이 두 가지 성격이 전혀 다르다고 판단했고,
그래서 전역·UI 상태는 zustand, 서버 상태는 react-query로 나누어 관리하기로 했다.



6-1) zustand: 전역 상태 관리의 개념들

zustand는 “앱 전체에서 공유되는 값”을 관리하는 데 특화된 라이브러리다.
그 핵심 개념은 다음과 같다.

  • Store (스토어)
    전역 상태를 보관하는 창고 같은 곳이다.
    useState가 컴포넌트 안의 개인 공간이라면, store는 앱 전체에서 함께 쓰는 공용 공간이다.

  • Selector (셀렉터)
    스토어에서 필요한 값만 골라서 구독하는 개념이다.
    예를 들어 count만 가져오면 다른 값이 바뀌어도 해당 컴포넌트는 리렌더되지 않는다.
    → 성능 최적화의 핵심.

  • set / get

    • set: 상태를 갱신하는 함수. 기존 객체를 펼쳐 불변성을 유지할 필요 없이 직관적으로 업데이트할 수 있다.
    • get: 현재 상태를 즉시 읽는 함수. 토글이나 조건 분기 로직에서 유용하다.

  • Middleware (미들웨어)
    zustand는 확장을 지원한다.

    • persist: 상태를 로컬스토리지에 저장해 새로고침 후에도 유지
    • devtools: Redux DevTools로 상태 변화 추적
    • immer: 불변성 신경 안 쓰고 마치 일반 객체처럼 다루기

  • 초기화/리셋 패턴
    store에 reset 함수를 넣어두면 상태를 초기값으로 되돌릴 수 있다.
    로그인/로그아웃처럼 전체 상태를 비워야 하는 경우 필수적이다.

  • shallow 비교자
    zustand/shallow를 쓰면 selector에서 여러 값을 동시에 구독할 때 불필요한 리렌더를 줄일 수 있다.

import { useUserStore } from "@/shared/stores/useUserStore";
import shallow from "zustand/shallow";

const { name, age } = useUserStore((s) => ({ name: s.name, age: s.age }), shallow);

.

  • Provider 필요 없음
    zustandContext API처럼 전역 Provider를 감쌀 필요가 없다.
    store를 정의하기만 하면 어디서든 useStore 훅으로 접근 가능하다.


    👉 정리: zustandUI 중심 전역 상태를 단순하고 빠르게 관리하기 위한 도구다.



💡 간단한 예시 코드

리액트에서 상태 관리는 보통 useState로 시작한다.
하지만 컴포넌트가 늘어나면 props로 상태를 주고받는 과정이 복잡해진다.
아래 예시는 useState 방식에서 출발해 zustand로 리팩터링하면서 사용자 액션 → 상태 변화 → UI 반영 흐름을 단계별로 보여준다.

(1) 기존 방식: useState + props

모든 상태를 page.tsx에 두고, 자식 컴포넌트는 props로만 접근한다.
간단하지만, 컴포넌트가 많아질수록 상태 전달이 번거롭다.

// src/types/todo.ts
export type FilterType = "all" | "active";

export interface Todo {
  id: string;
  text: string;
  done: boolean;
}
// src/app/page.tsx
"use client";

import { useState } from "react";
import TodoForm from "@/components/TodoForm";
import TodoFilter from "@/components/TodoFilter";
import TodoList from "@/components/TodoList";
import type { FilterType, Todo } from "@/types/todo";

export default function Page() {
  // [State] 전역(상위) 상태 보유
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<FilterType>("all");

  // [Flow: Add]
  // 1) 자식(TodoForm)에서 onAdd("운동하기") 호출
  // 2) 상위가 addTodo 실행 → todos에 새 항목 푸시
  // 3) setTodos로 상태 변경
  // 4) 파생값(visibleTodos) 재계산 → 자식에 내려감
  // 5) UI에 새로운 항목 반영
  const addTodo = (text: string) => {
    setTodos((prev) => [
      ...prev,
      { id: crypto.randomUUID(), text, done: false },
    ]);
  };

  // [Flow: Toggle]
  // 1) 자식(TodoList) 체크박스 onChange → onToggle(id) 호출
  // 2) 상위 toggleTodo 실행 → 해당 todo.done 반전
  // 3) setTodos로 상태 변경
  // 4) 필요 시 필터 반영(visibleTodos 재계산)
  // 5) 줄긋기/체크박스 상태/노출 여부 업데이트
  const toggleTodo = (id: string) => {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    );
  };

  // [Derived] 파생 상태: 필터 적용된 목록
  // - filter가 바뀌거나 todos가 바뀌면 재계산됨
  const visibleTodos = filter === "all" ? todos : todos.filter((t) => !t.done);

  return (
    <main style={{ maxWidth: 560, margin: "40px auto", padding: 16 }}>
      <h1 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16 }}>
        Todo (useState & props)
      </h1>

      {/* [Flow: Add] 진입점 — 입력 제출 → onAdd 호출 → 상위 addTodo */}
      <TodoForm onAdd={addTodo} />

      {/* [Flow: Filter] 진입점 — 라디오 변경 → onChange 호출 → 상위 setFilter */}
      <TodoFilter value={filter} onChange={setFilter} />

      {/* [Flow: Toggle] 진입점 — 체크박스 변경 → onToggle 호출 → 상위 toggleTodo */}
      {/* 렌더 소스: visibleTodos(파생값) */}
      <TodoList items={visibleTodos} onToggle={toggleTodo} />
    </main>
  );
}


TodoForm, TodoFilter, TodoList도 각각 props를 통해 동작한다.

// src/components/TodoForm.tsx
"use client";

import { useState } from "react";

interface Props {
  onAdd: (text: string) => void;
}

export default function TodoForm({ onAdd }: Props) {
  // [Local State] 입력값은 폼 내부에서만 사용
  const [text, setText] = useState("");

  // [Flow: Add]
  // 1) 사용자가 입력 후 제출
  // 2) onAdd(text)로 상위에 "추가" 요청 보냄
  // 3) 상위가 todos 갱신 → 화면 반영
  // 4) 폼은 입력창 초기화
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const value = text.trim();
    if (!value) return;
    onAdd(value);   // ← 상위로 이벤트 전달
    setText("");    // ← 로컬 입력 초기화
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: "flex", gap: 8 }}>
      <input
        placeholder="할 일을 입력..."
        value={text}
        // [Flow: Add] 0) 타이핑 중 — 로컬 상태만 갱신
        onChange={(e) => setText(e.target.value)}
        style={{ flex: 1, padding: 8, border: "1px solid #ddd", borderRadius: 8 }}
      />
      <button type="submit" style={{ padding: "8px 12px", borderRadius: 8 }}>
        추가
      </button>
    </form>
  );
}

상태 흐름 — 추가(Add) 예시

  1. 입력
    사용자가 TodoForm"운동하기"를 입력하고 제출한다.

  2. 이벤트 → 상위 호출
    TodoFormonAdd("운동하기")를 실행해 상위(page.tsx)의 addTodo를 호출한다.

  3. 상태 변경
    상위가 가진 todos
    [][ { id, text: "운동하기", done: false } ] 로 갱신된다.

  4. 파생값 재계산 & 렌더
    visibleTodos가 다시 계산되고, 새로운 항목이 포함된 상태로 TodoList에 전달된다.

  5. UI 결과
    목록에 "운동하기"가 즉시 나타난다. 입력창은 비워진다.



// src/components/TodoFilter.tsx
"use client";

import type { FilterType } from "@/types/todo";

interface Props {
  value: FilterType;
  onChange: (next: FilterType) => void;
}

export default function TodoFilter({ value, onChange }: Props) {
  // [Flow: Filter]
  // 1) 사용자가 라디오 클릭
  // 2) onChange("all"|"active") 호출 → 상위 setFilter 실행
  // 3) 상위 filter 변경 → visibleTodos 재계산 → 리스트 갱신
  return (
    <fieldset style={{ margin: "16px 0", display: "flex", gap: 16 }}>
      <legend style={{ fontWeight: 600, marginBottom: 8 }}>필터</legend>

      <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <input
          type="radio"
          name="filter"
          checked={value === "all"}
          onChange={() => onChange("all")}    // ← 상위로 이벤트 전달
        />
        전체
      </label>

      <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <input
          type="radio"
          name="filter"
          checked={value === "active"}
          onChange={() => onChange("active")} // ← 상위로 이벤트 전달
        />
        미완료
      </label>
    </fieldset>
  );
}

상태 흐름 — 필터(Filter) 예시

  1. 선택
    사용자가 TodoFilter에서 "미완료(active)"를 선택한다.

  2. 이벤트 → 상위 호출
    onChange("active")가 호출되어 상위의 setFilter("active")가 실행된다.

  3. 상태 변경
    filter"all""active"로 바뀐다.

  4. 파생값 재계산 & 렌더
    visibleTodos미완료만 남도록 재계산된다.

  5. UI 결과
    완료된 항목은 숨고, 미완료 항목만 남는다.



// src/components/TodoList.tsx
"use client";

import type { Todo } from "@/types/todo";

interface Props {
  items: Todo[];
  onToggle: (id: string) => void;
}

export default function TodoList({ items, onToggle }: Props) {
  // [Note] items는 상위가 필터 적용한 파생값(visibleTodos)
  if (items.length === 0) return <p style={{ opacity: 0.7 }}>항목 없음</p>;

  return (
    <ul style={{ marginTop: 8, display: "grid", gap: 8, padding: 0 }}>
      {items.map((t) => (
        <li
          key={t.id}
          style={{
            listStyle: "none",
            display: "flex",
            alignItems: "center",
            gap: 8,
            padding: 10,
            border: "1px solid #eee",
            borderRadius: 10,
          }}

          {/* [Flow: Toggle]
              1) 체크박스 변경 → onToggle(t.id)
              2) 상위 toggleTodo가 done 반전
              3) 상태 변경 후 렌더/스타일/노출 갱신 */}
          <input
            type="checkbox"
            checked={t.done}
            onChange={() => onToggle(t.id)} // ← 상위로 이벤트 전달
          />
          <span
            style={{
              textDecoration: t.done ? "line-through" : "none",
              opacity: t.done ? 0.6 : 1,
            }}

            {t.text}
          </span>
        </li>
      ))}
    </ul>
  );
}

상태 흐름 — 토글(Toggle) 예시 **

  1. 클릭
    사용자가 "운동하기"의 체크박스를 클릭한다.

  2. 이벤트 → 상위 호출
    TodoListonToggle(해당 id)를 호출해 상위의 toggleTodo를 트리거한다.

  3. 상태 변경
    대상 todo.donefalse ↔ true로 반전된다.

  4. 파생값/스타일 반영
    visibleTodos가 필요 시 변하고, 텍스트에 줄긋기/불투명도가 적용된다.
    필터가 "active"인 경우 done:true가 되면 목록에서 사라진다.

  5. UI 결과
    완료 상태가 즉시 반영된다(체크박스/스타일/노출).


    👉 이 useState 방식은 작을 때는 충분히 쓸 만하다.
    하지만 상위(page.tssx)가 모든 상태를 책임지고, 하위는 오직 props로만 움직이기 때문에, 컴포넌트가 늘어나면 관리가 급격히 어려워진다.
    특히 항목을 추가하거나(예: "운동하기"), 토글할 때마다 불필요한 리렌더가 발생하고, 이벤트와 데이터가 계속 위아래로만 오가서 코드 추적이 복잡해진다.

(2) 리팩터링: zustand Store 만들기

이제 같은 기능을 zustand로 리팩터링한다.
상태와 액션을 하나의 store에 모아두고, 컴포넌트는 필요한 값만 직접 구독한다.

// src/stores/useTodoStore.ts
import { create } from "zustand";
import type { FilterType, Todo } from "@/types/todo";

interface TodoState {
  todos: Todo[];
  filter: FilterType;
  // 액션
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  setFilter: (next: FilterType) => void;
  // 파생값(셀렉터 함수 형태)
  visibleTodos: () => Todo[];
}

export const useTodoStore = create<TodoState>((set, get) => ({
  // === 원본 상태 ===
  todos: [],                 // 전체 todo 상태 (예: 처음엔 [])
  filter: "all",             // "all" | "active"

  // [Flow: Add]
  // 1) 컴포넌트(TodoForm)에서 addTodo("운동하기") 호출
  // 2) store가 todos에 새 항목 추가
  // 3) set(...)으로 상태 변경
  // 4) visibleTodos() 재계산 → 구독 컴포넌트만 리렌더
  // 5) UI에 새 항목 반영
  addTodo: (text: string) =>
    set((s) => ({
      todos: [...s.todos, { id: crypto.randomUUID(), text, done: false }],
    })),

  // [Flow: Toggle]
  // 1) 컴포넌트(TodoList)에서 toggleTodo(id) 호출
  // 2) store가 해당 todo.done 반전
  // 3) set(...)으로 상태 변경
  // 4) visibleTodos() 재계산 → 구독 컴포넌트만 리렌더
  // 5) 줄긋기/체크박스/노출 상태 반영
  toggleTodo: (id: string) =>
    set((s) => ({
      todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
    })),

  // [Flow: Filter]
  // 1) 컴포넌트(TodoFilter)에서 setFilter("active") 호출
  // 2) store.filter 변경
  // 3) visibleTodos() 재계산 → 리스트 갱신
  setFilter: (next: FilterType) => set({ filter: next }),

  // 파생 상태(셀렉터 함수). 호출 시점의 최신 상태(get)로 계산
  visibleTodos: () => {
    const { todos, filter } = get();
    return filter === "all" ? todos : todos.filter((t) => !t.done);
  },
}));

상태 흐름 — Store 내부

  • set이 전역 상태(todos, filter) 갱신을 수행한다. → 불변성 로직은 set 안에서 알아서 처리.

  • get은 주로 액션 내부에서 현재 상태를 읽어올 때 사용한다. (예: 토글 시 특정 todo 찾기)

  • 파생 값(visibleTodos)은 스토어에 넣지 않고, 컴포넌트에서 todos + filter 조합으로 계산한다.

  • 따라서 누군가 set을 호출하면, 해당 조각(todos, filter)을 구독하는 컴포넌트만 리렌더된다. (셀렉터 단위 최적화)


    👉 store 안에는 원본 상태(todos, filter)와 이를 다루는 액션(addTodo, toggleTodo, setFilter)만 모여 있다.
    이제 컴포넌트는 props를 받을 필요 없이, useTodoStore 훅을 통해 필요한 값만 직접 구독한다.


(3) Store 사용하기: props 제거 후 전역 구독

컴포넌트는 필요한 상태와 액션을 직접 구독한다.
이제 더 이상 page.tsx가 상태를 들고 있을 필요가 없다.

// src/components-z/TodoForm.tsx
"use client";

import { useState } from "react";
import { useTodoStore } from "@/stores/useTodoStore";

export default function TodoForm() {
  const addTodo = useTodoStore((s) => s.addTodo); // 전역 store 액션
  const [text, setText] = useState("");

  // [Flow: Add]
  // 1) 사용자가 "운동하기" 입력 후 제출
  // 2) addTodo("운동하기") 호출 → store.todos에 새 항목 추가
  // 3) store가 set(...)으로 상태 변경
  // 4) visibleTodos()를 구독 중인 컴포넌트만 리렌더
  // 5) 리스트에 새 항목 표시, 입력 초기화
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const value = text.trim();
    if (!value) return;
    addTodo(value);
    setText("");
  };

  return (
    <form onSubmit={handleSubmit} style={{ display: "flex", gap: 8 }}>
      <input
        placeholder="할 일을 입력..."
        value={text}
        onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
          setText(e.target.value)
        }
        style={{ flex: 1, padding: 8, border: "1px solid #ddd", borderRadius: 8 }}
      />
      <button type="submit" style={{ padding: "8px 12px", borderRadius: 8 }}>
        추가
      </button>
    </form>
  );
}

상태 흐름 — 추가(Add) (예: "운동하기")

  1. 입력 & 제출
    • 사용자가 TodoForm 입력창에 "운동하기"를 적고 제출한다.
    • handleSubmit 실행 → addTodo("운동하기") 호출.
    • 이 시점까지는 전역 상태 변화 없음, 로컬 인풋(text)만 "운동하기" 상태.

  2. 스토어 액션 실행 (addTodo)
    • useTodoStore.addTodo 내부에서 set((s) => ({ todos: [...s.todos, 새 항목] })) 실행.
    • 이전: todos = []
    • 이후: todos = [{ id:uuid, text:"운동하기", done:false }]
    • filter 값은 그대로 유지(예: "all").

  3. 파생값 재계산 (컴포넌트 내부)
    • TodoList에서 다시 계산
      const visibleTodos = filter === "all" ? todos : todos.filter((t) => !t.done);
    • 이전: visibleTodos = []
    • 이후: visibleTodos = [ { "운동하기", done:false } ]

  4. 선택적 리렌더
    • todos를 구독하는 TodoList만 리렌더된다.
    • TodoForm은 전역 상태와 무관하고, 로컬 상태 setText("")로 입력창만 초기화.

  5. UI 반영
    • 화면 리스트에 "운동하기" 항목이 새로 표시된다.
    • 입력창은 비워져 다음 입력을 받을 준비가 된다.


      포인트
  • useState props 전달 대신, 스토어 액션 호출 → 전역 상태 변경 구조로 단순화됨.
  • 파생값 계산은 스토어 바깥(컴포넌트)에서 일관적으로 처리.
  • 덕분에 리렌더 범위는 구독 컴포넌트(TodoList)로 한정되어 성능에 유리.



// src/components/TodoFilter.tsx
"use client";

import { useTodoStore } from "@/stores/useTodoStore";
import type { FilterType } from "@/types/todo";

export default function TodoFilter() {
  const filter = useTodoStore((s) => s.filter);         // 전역 filter 상태
  const setFilter = useTodoStore((s) => s.setFilter);   // 전역 액션

  // [Flow: Filter]
  // 1) 라디오 클릭 → setFilter("all"|"active")
  // 2) store.filter 변경
  // 3) visibleTodos() 재계산 → 리스트 자동 갱신
  return (
    <fieldset style={{ margin: "16px 0", display: "flex", gap: 16 }}>
      <legend style={{ fontWeight: 600, marginBottom: 8 }}>필터</legend>

      <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <input
          type="radio"
          name="filter"
          checked={filter === "all"}
          onChange={() => setFilter("all")}
        />
        전체
      </label>

      <label style={{ display: "flex", alignItems: "center", gap: 6 }}>
        <input
          type="radio"
          name="filter"
          checked={filter === "active"}
          onChange={() => setFilter("active")}
        />
        미완료
      </label>
    </fieldset>
  );
}

상태 흐름 — 필터(Filter)

  1. 사용자 선택
    • TodoFilter에서 "미완료(active)" 라디오 클릭 → setFilter("active") 호출.

  2. 스토어 상태 변경
    • 이전: filter = "all"
    • 이후: filter = "active"
    • todos 배열은 변경 없음(데이터 자체는 그대로).

  3. 파생값 재계산(컴포넌트 내부)
    • TodoList 등에서 다음 공식을 이용해 파생값을 다시 계산:
      const visibleTodos = filter === "all" ? todos : todos.filter((t) => !t.done);
    • 이전: [ { "운동하기", done:false }, { ... } ]
    • 이후: 완료된 항목들은 제외되고 미완료만 남음.

  4. 선택적 리렌더
    • todos 또는 filter구독 중인 컴포넌트만 리렌더(주로 TodoList).
    • TodoFilter는 라디오 checked 값이 바뀌어 자기 자신도 자연스럽게 다시 그림.

  5. UI 반영
    • 화면에는 미완료 항목만 표시.
    • 만약 "운동하기"done:true였다면 즉시 숨김 처리되어 리스트에서 사라짐.


      포인트
  • 데이터는 그대로 두고, 관점(필터)만 바뀐 것.
  • 파생값은 스토어 밖(컴포넌트)에서 동일한 규칙으로 계산되어, 여러 화면에서도 일관된 결과를 얻는다.



// src/components/TodoList.tsx
"use client";

import { useTodoStore } from "@/stores/useTodoStore";

export default function TodoList() {
 // [State 구독]
 // todos: 전체 목록
 // filter: 현재 필터 상태("all" | "active")
 // toggleTodo: 항목 완료 여부 토글 액션
 const todos = useTodoStore((s) => s.todos);
 const filter = useTodoStore((s) => s.filter);
 const toggleTodo = useTodoStore((s) => s.toggleTodo);

 // [Derived] 파생 상태 계산
 // - filter가 바뀌거나 todos가 바뀔 때만 다시 계산됨
 // - store 안에서 함수를 실행하지 않고, 컴포넌트에서 직접 계산해야 무한 루프 방지됨
 const visibleTodos =
   filter === "all" ? todos : todos.filter((t) => !t.done);

 // 렌더링: visibleTodos가 비어 있으면 안내 문구
 if (visibleTodos.length === 0) return <p style={{ opacity: 0.7 }}>항목 없음</p>;

 return (
   <ul style={{ marginTop: 8, display: "grid", gap: 8, padding: 0 }}>
     {visibleTodos.map((t) => (
       <li
         key={t.id}
         style={{
           listStyle: "none",
           display: "flex",
           alignItems: "center",
           gap: 8,
           padding: 10,
           border: "1px solid #eee",
           borderRadius: 10,
         }}
       >
         {/* [Flow: Toggle]
             1) 사용자가 체크박스를 클릭
             2) toggleTodo(t.id) 호출 → store.todos의 해당 항목 done 반전
             3) set(...)으로 상태 변경
             4) visibleTodos 재계산
             5) 줄긋기/체크박스 상태/노출 여부가 즉시 업데이트 */}
         <input
           type="checkbox"
           checked={t.done}
           onChange={() => toggleTodo(t.id)}
         />
         <span
           style={{
             textDecoration: t.done ? "line-through" : "none",
             opacity: t.done ? 0.6 : 1,
           }}
         >
           {t.text}
         </span>
       </li>
     ))}
   </ul>
 );
}

상태 흐름 — 토글(Toggle)

  1. 사용자 클릭
    • 사용자가 TodoList에서 "운동하기" 체크박스를 클릭한다.
    • 이벤트 핸들러에서 toggleTodo(id)(스토어 액션)가 호출된다.

  2. 스토어 상태 변경
    • 액션 내부에서 해당 idtodo.done 값만 반전된다.
    • 이전: { id, text:"운동하기", done:false }
    • 이후: { id, text:"운동하기", done:true }

  3. 파생값 재계산 (컴포넌트 내부)
    • TodoList에서 visibleTodos = filter === "all" ? todos : todos.filter(...) 로 다시 계산된다.
    • filter가 "all""운동하기"는 여전히 목록에 남지만 done:true라 스타일만 달라짐(줄긋기/투명도).
    • filter가 "active""운동하기"가 완료 처리되었으므로 목록에서 제외됨.

  4. 선택적 리렌더
    • todos, filter를 구독하던 TodoList만 다시 렌더된다.
    • 다른 컴포넌트는 영향 없음.

  5. UI 반영
    • 체크박스 상태가 반영되고, 텍스트에 줄긋기/투명도 적용.
    • 만약 현재 필터가 "active"라면 "운동하기" 항목이 즉시 사라진다.


      포인트
  • 토글은 데이터 자체를 수정하는 동작.
  • 파생값(visibleTodos)은 스토어 바깥에서 계산되므로,
    filter 상태에 따라 화면에 남거나 사라짐이 결정된다.



정리

  • useState 방식: 단순하지만 모든 변화가 상위 → props → 하위로만 흐르며, 규모가 커지면 props 드릴링/리렌더 이슈가 커진다.

  • zustand 방식: store에 상태·액션을 집중하고, 컴포넌트는 필요 조각만 구독해 리렌더를 국소화한다. 흐름이 명확하고 확장성/성능 관리가 쉽다.



6-2) react-query: 서버 상태 관리의 개념들

반대로, 서버에서 가져온 데이터는 단순히 값 하나가 아니다.
언제 최신화할지, 캐싱은 어떻게 할지, 에러 났을 때 어떻게 복구할지 같은 규칙이 따라붙는다.
이런 규칙을 자동으로 관리해주는 것이 react-query다.

핵심 개념은 다음과 같다.

  • Query Key (쿼리 키)
    모든 요청은 queryKey로 구분된다.
    같은 키는 같은 캐시를 재사용하고, 키가 바뀌면 새 데이터를 가져온다.
    → 캐싱과 중복 요청 방지의 기준점.

  • QueryClient & QueryClientProvider
    • QueryClient: 캐시와 규칙(staleTime, retry, gcTime 등)을 관리하는 중앙 엔진
    • QueryClientProvider: 리액트 컨텍스트로 앱 전역에 QueryClient를 주입해, 하위 컴포넌트에서 훅(useQuery, useMutation)을 사용할 수 있게 한다.
      zustand와 달리, react-query는 Provider가 반드시 필요하다.

  • defaultOptions
    QueryClient를 만들 때 defaultOptions를 지정하면, 전역에서 재시도 횟수·refetch 정책·캐시 수명 같은 규칙을 통일할 수 있다.
const client = new QueryClient({
  defaultOptions: {
    queries: { retry: 2, refetchOnWindowFocus: false, staleTime: 30_000 },
  },
});

.

  • useQuery
    데이터를 조회할 때 쓰는 훅.

    • queryKey: 캐시 주소
    • queryFn: 실제 fetch 함수
    • staleTime: 얼마 동안 데이터를 신선하게 취급할지
    • placeholderData: 이전 데이터를 잠깐 보여주어 깜빡임 방지

  • useMutation
    데이터를 변경할 때 쓰는 훅.

    • onMutate: 요청 전 낙관적 업데이트 (UI 먼저 반영)
    • onError: 실패 시 롤백
    • onSettled: 성공/실패 후 캐시 무효화 → 서버와 동기화

  • Cache & Invalidation
    react-query의 핵심은 캐시다.

    • 캐시를 통해 중복 요청을 막고
    • 오래된 데이터는 자동 갱신하며
    • 특정 이벤트 이후엔 invalidate로 관련 데이터를 새로 가져온다.

  • Devtools
    @tanstack/react-query-devtools를 설치하면 캐시/쿼리 상태를 시각적으로 확인할 수 있다. 개발 환경에서 디버깅에 유용하다.


    👉 정리: react-query서버와 클라이언트의 데이터 일관성을 관리하는 도구다.



💡 간단 예시 코드 (react-query)

⚠️ 현재 실제 API는 연결되어 있지 않으므로 직접 실행해볼 수는 없음.
이 코드는 React Query의 사용 흐름(조회/추가/토글)을 이해하기 위한 예시이며,
API 연동 없이 “코드 구조와 데이터 흐름”을 파악하는 데 집중하면 된다.

1) 전역 설정 — app/providers.tsx

// src/app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";

export function Providers({ children }: { children: ReactNode }) {
 // 한 번만 생성되도록 useState 패턴 사용
 const [client] = useState(
   () =>
     new QueryClient({
       defaultOptions: {
         queries: {
           refetchOnWindowFocus: false, // 탭 전환 시 자동 리패치 비활성
           retry: 1,                    // 실패 재시도 횟수
           staleTime: 1000 * 30,        // 30초 동안 신선한 데이터로 간주
         },
       },
     }),
 );

 return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

상태 흐름 — 전역 설정(Provider)

  1. 레이아웃에서 Providers로 앱을 감싼다 → 내부적으로 QueryClient 1회 생성
  2. 자식 컴포넌트에서 useQuery/useMutation 사용 가능
  3. 모든 쿼리/뮤테이션은 동일한 클라이언트 & 캐시를 공유
  4. 전역 옵션(staleTime, retry, refetchOnWindowFocus)이 일괄 적용
  5. 캐시/무효화/재패치 동작이 일관되게 관리됨



📌 레이아웃 연결 예시

React Query 훅(useQuery, useMutation)은 반드시 QueryClientProvider로 감싸야 동작한다.
Next.js App Router에서는 앱 전체를 감싸는 루트 컴포넌트가 app/layout.tsx 이다.

따라서 layout.tsx에서 Providers를 불러와 <body> 안에 두면,
모든 하위 페이지와 컴포넌트가 동일한 React Query 클라이언트와 캐시를 공유하게 된다.


  • Providers = 전역 React Query 컨테이너
  • layout.tsx = 앱의 공통 뼈대 (여기서 한 번 감싸주면 끝)
  • 그 안에 있는 TodoList, TodoForm 같은 개별 컴포넌트는 props 전달 없이 바로 useQuery / useMutation 사용 가능
// src/app/layout.tsx
import "./globals.css";
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
 return (
   <html lang="ko">
     <body>
       {/*
         Providers로 감싸는 이유:
         - React Query의 QueryClientProvider를 전역 적용
         - 모든 하위 컴포넌트에서 동일한 client & cache를 공유
         - useQuery, useMutation 훅이 정상적으로 동작할 수 있는 환경 보장
       */}
       <Providers>{children}</Providers>
     </body>
   </html>
 );
}

2) 조회 훅 — useTodos.ts

여기서 부터는 API 레이어 세팅이기 때문에 아래 예시도 좋지만 아래 글을 참고하는게 좋다! (사실 아래 글이 API 레이어 세팅임)
API 레이어 세팅

// src/features/todos/api/useTodos.ts
import { useQuery } from "@tanstack/react-query";

export interface Todo {
 id: string;
 title: string;
 done: boolean;
}

export function useTodos(userId: string | null) {
 return useQuery<Todo[]>({
   queryKey: ["todos", userId], // 캐시 키
   queryFn: async () => {
     const res = await fetch(`/api/todos?user=${userId}`);
     if (!res.ok) throw new Error("Failed to fetch todos");
     return res.json();
   },
   enabled: !!userId,          // userId가 있을 때만 실행
   staleTime: 1000 * 30,
 });
}

상태 흐름 — 조회(Query)

  1. 컴포넌트에서 useTodos(userId) 호출 → queryKey=["todos", userId]로 캐시 조회
  2. 캐시에 신선한 데이터가 있으면 즉시 반환, 없거나 오래됐으면 queryFn 실행
  3. 요청 중 status="loading"/isFetching=true → 로딩 UI 표시
  4. 응답이 오면 캐시 업데이트 & status="success"
  5. 해당 키를 구독 중인 컴포넌트만 리렌더 (중복 요청 방지)



3) 추가 뮤테이션 — useAddTodo.ts

// src/features/todos/api/useAddTodo.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Todo } from "./useTodos";

export function useAddTodo() {
 const qc = useQueryClient();

 return useMutation({
   mutationFn: async (title: string): Promise<Todo> => {
     const res = await fetch("/api/todos", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ title }),
     });
     if (!res.ok) throw new Error("Failed to add todo");
     return res.json();
   },
   onSettled: () => {
     qc.invalidateQueries({ queryKey: ["todos"] }); // 목록 무효화 → 최신화
   },
 });
}

상태 흐름 — 추가(Add)

  1. 사용자가 "운동하기" 입력 후 제출 → mutate("운동하기") 호출
  2. POST /api/todos 요청 진행 → 서버에 새 Todo 추가
  3. 요청 중 isPending=true → 버튼 비활성화/로딩 표시
  4. 응답 완료 시 invalidateQueries(["todos"]) 실행 → 목록 재패치
  5. 최신 목록이 캐시에 반영 → 구독 중인 리스트만 리렌더



4) 토글 뮤테이션 — useToggleTodo.ts

// src/features/todos/api/useToggleTodo.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Todo } from "./useTodos";

export function useToggleTodo() {
 const qc = useQueryClient();

 return useMutation({
   mutationFn: async (payload: { id: string; done: boolean }): Promise<Todo> => {
     const res = await fetch(`/api/todos/${payload.id}`, {
       method: "PATCH",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ done: payload.done }),
     });
     if (!res.ok) throw new Error("Failed to toggle todo");
     return res.json();
   },
   onSettled: () => {
     qc.invalidateQueries({ queryKey: ["todos"] }); // 목록 최신화
   },
 });
}

상태 흐름 — 토글(Toggle)

  1. "운동하기" 체크박스 클릭 → mutate({ id, done: !done }) 호출
  2. PATCH /api/todos/:id 요청으로 완료 여부 반전
  3. isPending=true 동안 체크박스 비활성화 처리 가능
  4. 응답 후 invalidateQueries(["todos"]) 실행 → 재패치로 최신화
  5. 완료 항목은 체크 표시 + 줄긋기, 필터 "active"였다면 즉시 숨김







6-3) 두 도구의 역할 분리

앞에서 살펴봤듯이,

  • zustandUI 전역 상태 (다이얼로그 열림, 모듈 숨김, 현재 탭 등)에 강하다.
  • react-query서버와 동기화되는 데이터 (투두, 습관, 메모 등)에 최적화되어 있다.

둘을 혼용하다 보면 “이 값은 어디에 둬야 하지?”라는 혼란이 생길 수 있는데,
경계를 명확히 나누면 훨씬 깔끔하다.

  • 서버에서 해결할 것: 정렬, 필터링 같은 건 API 요청 시 파라미터로 넘겨 서버에서 처리.
  • 클라이언트에서만 필요한 것: 검색 키워드, 강조 상태 같은 건 zustand로만 관리.

👉 이렇게 구분하면 상태가 뒤섞이지 않고, 각 도구가 잘하는 역할만 맡게 된다.



6-4) 정리

  • zustand: 스토어/셀렉터/set·get/미들웨어로 UI 전역 상태를 빠르고 단순하게 관리
  • react-query: 쿼리 키/useQuery/useMutation/캐시 규칙으로 서버 상태를 일관되게 관리
  • 두 도구를 명확히 분리할수록 앱 구조가 단순해지고, 사용자 경험도 매끄러워진다.


7. Zustand + React Query 함께 쓰기 & 프로젝트 세팅

앞서 살펴본 것처럼,

  • Zustand: 전역·UI 상태 관리 (모달/탭/선택/임시 입력/뷰 전환 등)
  • React Query: 서버 동기화 상태 관리 (투두·습관·메모·일/주/월 기록·프로필 등)


    두 라이브러리는 성격이 다르지만 함께 써야 앱이 자연스럽게 돌고,
    예를 들어 ZustanduserId, selectedDate 같은 값을 React QueryqueryKey/params로 넘기면
    사용자·기간별로 정교한 캐시를 유지할 수 있다.

7-1) 통합 설계 — 역할 분리 · 데이터 흐름 · Provider

1) 역할 분리

🟩 UI/전역 상태 → Zustand

새로고침하면 사라져도 큰 문제 없는 보기 상태(UX 전용).

  • 캘린더 컨텍스트: 현재 뷰(day/week/month), 선택된 날짜
  • UI 전용 필터/검색/강조: 클라이언트 전용 하이라이트(하이라이트, 로컬 검색)
  • 정렬/그룹핑 UI: 리스트/보드/캘린더 전환, 칼럼 정렬
  • 모달/패널/드로어: 상세보기 열림/닫힘, 편집모드
  • 폼 임시 값: 인풋 초안, 드래그 상태
  • 레이아웃/테마: 테마·컴팩트 모드·사이드바
  • 선택/포커스: 멀티선택 id 배열, 포커스 셀


    원칙
  • 서버와 일치해야 하는 값(제목/완료)은 Zustand에 복제 금지
  • 클라이언트 전용 값만 Zustnad에 두고, 필요할 때만 React Query 파라미터로 전달



🟦 서버 동기화 상태 → React Query

시간이 지나도 남아야 하고, 여러 기기에서 동일해야 하는 데이터

  • 투두(Todos): 항목/완료/마감/태그, 서버 정렬·필터
  • 습관(Habits): 습관 정의(주기/목표), 체크인 로그(일/주/월)
  • 메모(Memos): 본문/태그/핀/아카이브/첨부
  • 플래너(Planner)
    • Daily: 날짜별 일정/할 일/회고/요약
    • Weekly: 주간 목표/스프린트/리뷰
    • Monthly: 월간 목표/성과 지표/달력/회고
  • 리마인더(Reminders): 예약 알림/반복/채널
  • 사용자(User): 프로필/서버에 저장되는 설정


    원칙
  • queryKeyuserId + 기간 + 조건(filter/sort/tag)을 명시해 캐시 분리
  • 정렬/필터/검색은 가능하면 서버 계산 (일관성·페이지네이션 유리)
  • 변경 후 정확한 범위invalidateQueries로 최신화
  • UX가 중요하면 optimistic update(낙관 갱신) 설계



2) 데이터 흐름

  1. UI → Zustand
    사용자 입력/클릭/날짜 선택 → Zustnad 전역 상태 업데이트
    (예: selectedDate = "2025-10-02")

  2. Zustand → React Query
    훅 내부에서 Zustand 값을 읽어 queryKey/params로 전달
    (예: useTodos({ userId, date: selectedDate, filter, sort }))

  3. React Query → UI
    서버 데이터 응답 → 캐시 반영 → 해당 키를 구독하는 컴포넌트만 리렌더



3) Provider 여부

  • Zustand: Provider 불필요 (훅으로 전역 store 직접 접근)
  • React Query: 필수QueryClientProvider로 앱을 최상위에서 한 번 감싸기



7-2) 실제 세팅 절차

① 패키지 설치

pnpm add zustand @tanstack/react-query

📌 나중에 추가할 것 (선택)

  • @tanstack/react-query-devtools도 같이 설치하면 쿼리 캐시/요청 상태를 실시간으로 확인할 수 있다.



② Zustand store 생성 (예: useUserStore.ts) ✅ (지금 해도 됨)

// src/shared/stores/useUserStore.ts
import { create } from "zustand";

type UserState = {
  userId: string | null;
  setUserId: (id: string | null) => void;
  reset: () => void;
  // 앞으로 확장될 수 있는 전역 UI 상태 예시
  theme: "light" | "dark";
  toggleTheme: () => void;
};

export const useUserStore = create<UserState>((set) => ({
  userId: null,
  setUserId: (id) => set({ userId: id }),
  reset: () => set({ userId: null }),
  theme: "light",
  toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}));

코드 설명
👉 단순히 userId만 전역 관리하는 간단한 예제다.

  • 로그인하면 setUserId로 ID를 넣고,
  • 로그아웃 시 reset으로 초기화할 수 있다.


    📌 나중에 바꿀 것
  • 실제 로그인 API를 붙이면, userId만 저장하지 않고
    username, role, accessToken 같은 값도 함께 관리해야 한다.
  • 로그아웃 시 reset()을 자동 호출하도록 Auth 로직과 연결해야 한다.
  • 지금은 userIdtheme만 있지만, 실제 프로젝트에선 sidebar 열림 여부, 선택된 날짜(selectedDate) 등 UI 전역 상태도 함께 관리할 수 있다.



③ React Query 전역 Provider 설정 (app/providers.tsx) ✅ (지금 해도 됨)

// src/app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";

export function Providers({ children }: { children: ReactNode }) {
 const [client] = useState(
   () =>
     new QueryClient({
       defaultOptions: {
         queries: { retry: 2, refetchOnWindowFocus: false, staleTime: 30_000, gcTime: 5 * 60_000 },
       },
     })
 );

 return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

코드설명
👉 모든 useQuery, useMutation 훅이 정상 동작하려면 최상위에서 QueryClientProvider로 한 번 감싸야 한다.

  • useStateQueryClient를 한 번만 생성해 재사용한다.
  • queries 옵션으로 전역 정책(retry, staleTime, refetchOnWindowFocus)을 설정할 수 있다.


    📌 나중에 바꿀 것
  • retry, staleTime, refetchOnWindowFocus 같은 옵션은
    실제 API 상황에 맞춰 조정 필요 (예: 데이터가 자주 변하면 staleTime 짧게).
  • API 호출은 단순 fetch 대신 axios wrapper를 사용하면 토큰/에러 처리 일관성이 좋아진다.
  • 개발 환경에서는 ReactQueryDevtools를 추가해 쿼리 상태를 확인하는 게 편리하다.



④ 레이아웃에 Provider 연결 (필수)

// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";

export const metadata: Metadata = {
  title: "Custom Daily Planner",
  description: "나만의 맞춤형 플래너를 손쉽게 디자인하고 사용할 수 있는 웹 앱",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        {/*
          Providers로 감싸는 이유:
          - React Query의 QueryClientProvider를 전역 적용
          - 모든 하위 컴포넌트가 동일한 client & cache 공유
          - useQuery, useMutation 훅이 어디서든 정상 동작
        */}
        {/* 
          ✅ 추후 확장 예시:
          <AuthProvider>
            <ThemeProvider>
              <Providers>{children}</Providers>
            </ThemeProvider>
          </AuthProvider>
        */}
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

코드 설명
👉 이렇게 해두면 전체 앱에서 같은 QueryClient 인스턴스를 공유한다.

  • 모든 하위 컴포넌트가 같은 캐시와 설정을 공유하므로, 어디서든 useQuery, useMutation이 정상 동작한다.
  • 루트에서 한 번만 감싸면 전역 적용이 된다.


    📌 나중에 바꿀 것
  • 프로젝트가 커지면 AuthProvider, ThemeProvider, i18nProvider 같은
    다른 글로벌 Provider도 함께 감싸야 한다.



⑤ React Query 훅과 함께 사용하기 (예: 유저 데이터) ❌ (나중에 API 생기면 구현)

// src/api/http.ts
import axios from "axios";

export const http = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL || "", // 환경 변수로 관리 권장
  withCredentials: true, // 쿠키 기반 인증이면 true
  headers: {
    "Content-Type": "application/json",
  },
});

코드 설명
👉 공통 axios 인스턴스를 생성해 모든 API 요청에서 재사용한다.

  • baseURL: 환경별로 바뀌는 API 주소를 환경 변수(.env) 로 관리해 배포/개발 전환이 쉽다.
  • withCredentials: 쿠키 기반 인증(세션/CSRF)일 때 쿠키를 자동 포함해 세션을 유지한다.
  • headers: 기본 Content-Type을 지정해 JSON 요청을 일관되게 보낸다.

// src/features/user/api/useUser.ts
import { useQuery } from "@tanstack/react-query";
import { useUserStore } from "@/shared/stores/useUserStore";
import { http } from "@/lib/http";

async function fetchUser(userId: string) {
  const res = await http.get(`/users/${userId}`);
  return res.data; // axios는 data 필드에 응답이 들어감
}

export function useUser() {
  const userId = useUserStore((s) => s.userId);

  return useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId!),
    enabled: !!userId, // userId 없으면 실행 안 함
    select: (data) => ({
        ...data,
        displayName: `${data.firstName} ${data.lastName}`, // 응답 후처리 예시
    }),
  });
}

코드 설명
👉 axios 인스턴스(http)를 만들어두고, 모든 API 요청에서 재사용한다.

  • baseURL.env로 관리해 환경마다 다르게 적용 가능.
  • withCredentials를 켜두면 쿠키 기반 인증에서 세션 유지 가능.
  • axios는 응답을 res.data로 바로 가져오므로 더 간단하다.
  • select 옵션으로 응답을 가공해 캐시에 넣을 수 있다.
    예: 이름 필드를 합쳐서 displayName을 만들거나, 불필요한 필드를 제거할 수 있음.


    📌 나중에 바꿀 것
  • 토큰 기반 인증이라면 인스턴스에 request interceptor를 붙여 자동으로 헤더에 Authorization 추가.
  • 공통 response interceptor에서 에러 처리/리다이렉트(예: 401 → 로그인 페이지) 로직 넣기.
  • zod 같은 스키마 검증 라이브러리로 res.data를 검증해 타입 안정성을 확보.



7-3) 구조 확인

src/
├─ app/
│  ├─ layout.tsx            # Root Layout (전역 Providers 연결, 확장 주석 추가됨)
│  └─ providers.tsx         # QueryClientProvider 전역 설정
├─ shared/
│  ├─ queryKeys.ts          # 공통 queryKey 생성기 (샘플 추가됨)
│  └─ stores/
│     └─ useUserStore.ts    # Zustand 전역 store (UI/로컬 상태 확장됨)
├─ features/
│  └─ user/
│     └─ api/
│        └─ useUser.ts      # React Query 훅 (select 예시 보강)
└─ lib/
   └─ http.ts               # axios 인스턴스

👆 위 구조 아니니 API 레이어 세팅 글 참고바람,,,

0개의 댓글