본문 바로가기
STORAGE/React

2-2. 상태 관리 라이브러리(redux, tanstack-query)

by _wavy 2024. 4. 11.

(3) 상태 관리 도구

1) 클라이언트 측: UI 상태 관리

- 상태

  • UI 컨트롤 상태: 사용자의 입력, 버튼 활성 여부, 드롭다운 메뉴의 선택 등
  • UI 레이아웃 상태: 테마 설정, 사이드바 열림/닫힘, 모달 창의 보임/숨김 등
  • 클라이언트 측 캐싱: 사용자 인증 정보, API 호출 결과 등
  • 애플리케이션 로직 상태: 애플리케이션 내부에서 발생하는 이벤트나 사용자 작업에 따른 상태 변화 등

- 도구 종류

  • redux
    redux data flow(출처: future-architect.github.io)
    • 구조: 단일 스토어와 단방향 데이터 흐름
      • Actions: 상태 변경을 요청하기 위해 스토어에 전달하는 데이터 패킷. 이벤트 종류(type)와 추가 데이터(payload)로 구성
      • Reducers: 액션의 타입에 따라 이전 상태를 기반으로 한 상태 생성하는 순수 함수
      • Store: 전역 상태 보관소
    • 장점: 전역 상태 관리, 상태 변경 로직 분리, immer로 상태 불변성 유지, 미들웨어 지원
    • 단점: 보일러플레이트 코드가 많고 학습의 진입 장벽이 높음
      • createStore(): 스토어 생성
      • store.dispatch(): set state
      • store.getState(): get state
      • store.subscribe(): 리스너 등록
src/
|-- actions/ # 액션 타입, 액션 생성자 함수 정의
|   |-- userActions.js
|   |-- counterActions.js
|-- reducers/ # 초기 상태, 리듀서 함수 정의
|   |-- userReducer.js
|   |-- counterReducer.js
|   |-- index.js # 도메인별 리듀서 합쳐서 루트 리듀서 생성
|-- store/ # 스토어, 미들웨어
    |-- index.js
// actions/counterActions.js
// 액션 타입
const ACTION_TYPE = {
  INCREMENT: 'INCREMENT',
  DECREMENT: 'DECREMENT',
};

// 액션 생성자 함수
const increment = amount => ({
  type: ACTION_TYPE.INCREMENT,
  payload: amount,
});
const decrement = amount => ({
  type: ACTION_TYPE.DECREMENT,
  payload: amount,
});

// reducers/counterReducer.js
// 초기 상태
const initialState = {
  count: 0,
};

// 리듀서 함수
function counterReducer(state = initialState, action) {
  switch (action.type) {
    case ACTION_TYPE.INCREMENT:
      return { count: state.count + action.payload };
    case ACTION_TYPE.DECREMENT:
      return { count: state.count - action.payload };
    default:
      return state;
  }
}

// reducers/index.js
// 루트 리듀서 생성
const rootReducer = combineReducers({
  user: userReducer,
  counter: counterReducer,
});

// store/index.js
// 스토어 생성
const store = createStore(
  rootReducer, // 리듀서 등록
  applyMiddleware(thunk) // 미들웨어 등록
);

// component.js
// 상태 변경 리스너 등록
const listener = () => console.log('state changed!')
const unsubscribe = store.subscribe(listener)
);

// 액션 디스패치
store.getState(); // 현재 상태 조회,
/* { user: {}, counter: { count: 0 } } */
store.dispatch(increment(1));
store.getState();
/* { user: {}, counter: { count: 1 } } */

// 작업 후 구독 해제
unsubscribe();
  • @reduxjs/toolkit: redux 코드 작성을 간소화
    • configureStore({reducer, middleware, devTools}): createStore + combineReducer, 미들웨어 설정 등
    • createSlice(): 액션 + 리듀서 정의
      • slice: 도메인 객체 조각(상태+메서드)
src/
|-- features/ # 기능별 슬라이스 정의
|   |-- userSlice.js
|   |-- counterSlice.js
|-- app/
|   |-- store.js # 스토어 생성, 미들웨어 등록
|-- views/
    |-- HomePage.js
// features/counterSlice.js
interface counterState {
  count: number;
}
const initialState = {
  count: 0,
};
const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state, action: PayloadAction<counterState>) => {
      state.count += action.payload.count;
    },
    decrement: (state, action: PayloadAction<counterState>) => {
      state.count -= action.payload.count;
    },
  },
});

// counterSlice = {actions, reducer}
export const { increment, decrement } = counterSlice.actions;

export default counterSlice.reducer;

// app/store.js
const store = configureStore({
  reducer: {
    counter: counterReducer,
    user: userReducer,
  },
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware().concat(counterSlice.middleware),
  devTools: () => {},
});

export default store;
// 이후 store 사용은 redux와 같음
  • @reduxjs/toolkit + react-redux: react 컴포넌트 내 state 구독으로 리렌더링 유발, 렌더링 최적화
    • useSelector(): store.getState() 대체
    • useDispatch(): store.dispatch(action) 대체
// store 설정까지 rtk로 진행
// app/store.js
export default store;

export type RootState = ReturnType<typeof store.getState>;

// App.tsx
const App: React.FC = () => {
  return (
    <Provider store={store}>
      <CounterComponent />
    </Provider>
  );
};

// CounterComponent.tsx
const CounterComponent: React.FC = () => {
  const { count } = useSelector((state: RootState) => state.counter);
  const dispatch = useDispatch();

  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={() => dispatch(increment(1))}>+</button>
      <button onClick={() => dispatch(decrement(1))}>-</button>
    </>
  );
};
  • MobX: 반응형 프로그래밍 패턴 사용
  • Recoil: 원자(Atoms)와 선택자(Selectors) 사용
  • Zustand: 가벼운 상태 관리 솔루션

2) 서버 측: 비동기적 상태 데이터 패칭, 캐싱, 동기화

- 상태

  • 영구 데이터: 장기 저장 데이터. 데이터베이스, 파일 시스템, 클라우드 스토리지 등에 보관됨. 사용자 정보, 게시글 등
  • 세션 데이터: 임시 저장 데이터. 로그인 세션 동안만 메모리에 유지됨. 로그인 여부, 장바구니 내용 등
  • 캐싱 데이터: 일시 저장 데이터. 서버의 응답 속도 향상을 위해 자주 사용하는 데이터를 메모리에 보관. 특정 시간 동안만 유효하고 주기적으로 갱신됨

- 도구 종류

  • @tanstack/react-query
    • 기능: 데이터 패칭, 캐싱, 동기화
      • 데이터 패칭: HTTP 엔드포인트로 쿼리를 보내 데이터 패칭하고, 패칭 과정의 상태(로딩, 성공, 에러) 처리 간편화
        쿼리(query): 데이터 요청, 요청과 조건을 URL이나 HTTP 요청의 본문으로 전송하면 서버는 해당하는 데이터로 응답함. 조건으로 데이터의 종류, 양, 형식 등 설정이 가능
      • 캐싱: 패칭된 데이터를 자동으로 캐싱하고 백그라운드 리프레시로 주기적인 동기화. 동일한 요청을 줄여 재요청이 없으므로 앱 사용이 빠르고 효율적임
      • 동기화: 스테일 데이터를 주기적으로 업데이트하여 서버와 동기화. 쿼리 키로 데이터를 관리하여 여러 컴포넌트를 효율적으로 관리함
        • 데이터 상태: fresh🥩 & stale🥔
      • 무한 스크롤, 페이지네이션 같은 고급 기능들도 지원
    • queryClient: 비동기 상태 스토어
    • useQuery(queryKey, queryFn, staleTime, gcTime, enabled): 비동기적인 데이터 페칭(read)
      • isPending: 초기 데이터 요청이 시작되기 전(쿼리 비활성화), 진행 중(활성화) 데이터 대기 상태
      • isFetching: 초기, 추가 데이터 대기 상태
      • isLoading: (isPending && isFetching)초기 데이터 로딩(활성화) 상태, 쿼리 비활성화시엔 로딩 상태가 아님(false). 검색창에 검색시 트리거 되는 등, 특정 작업을 통한 쿼리 활성화시 사용
      • isError: 데이터 요청 실패
    // app.tsx
    export const queryClient = new QueryClient();
    
    function App() {
    return (
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router} />
      </QueryClientProvider>
    );
    }
    
    // src/components/Component.tsx
    const { data, error, isPending, isLoading, isError } = useQuery({
    queryKey: ['events', { searchTerm }], // 배열키 사용
    queryFn: ({ signal, queryKey }) => fetchEvent({ signal, ...queryKey[1] }), // HTTP 요청 함수, signal로 요청 취소
    staleTime: 1000, // 새 요청을 전송하는 시기
    gcTime: 5 * 60 * 1000, // 데이터를 보관하는 기간
    enabled: searchTerm !== undefined,
    });
    
    // apis.ts
    interface Event {
    id: number;
    name: string;
    }
    export const fetchEvent = async ({ signal, searchTerm }): Promise<Event> => {
    const response = await fetch(`/events/${searchTerm}`, { signal }); // 쿼리 중단시 요청 취소 signal 전달
    // ...
    return response.json();
    };
    • useMutation(mutationFn, onSuccess, onMutate, onError, onSuccess, onSettled): 비동기적인 데이터 전송(write)
    const { mutate, error, isPending, isError } = useMutation({
    mutationFn: createNewEvent,
    onSuccess: () => {
      // 뮤테이션 성공시 실행
      queryClient.invalidateQueries({
        // 쿼리 무효화(=> 리패치)
        queryKey: ['events'], // 해당 쿼리를 포함한 쿼리를 대상
        // exact: false, // 정확히 일치하는 쿼리로 제한하는 옵션
        refetchType: 'none', // 리패치 비활성화
      });
      navigate('/events');
    },
    
    // 낙관적 업데이트: 데이터를 서버에 업데이트하기 전에 클라이언트 상태를 미리 업데이트함. 업데이트 실패시 클라이언트는 이전 상태로 롤백. 서버 데이터 업데이트 전에 사용자가 기다림 없이 즉각적인 피드백을 받을 수 있다는 장점
    onMutate: async ({ id, event }) => {
      // mutate 호출 후 서버 응답 필요 없이 즉시 실행, 낙관적 업데이트시 비동기적으로 작동해야 함
      const queryKey = ['events', { id }];
      await queryClient.cancelQueries({ queryKey });
    
      const previousEvent = queryClient.getQueryData(queryKey);
    
      queryClient.setQueryData(queryKey, event);
    
      return { previousEvent }; // 롤백을 위한 이전 데이터(context)
    },
    onError: (error, data, context) => {
      // 뮤테이션 실패시 롤백
      queryClient.setQueryData(['events', { id }], context.previousEvent);
    },
    onSuccess: () => {}, // 뮤테이션 성공시 작업
    onSettled: () => {
      // 뮤테이션 완료시(성패 무관 결과 확정시)
      queryClient.invalidateQueries(['events', { id }]); // 쿼리 무효화-데이터 리패칭을 통해 서버 데이터와 동기화
    },
    });
    
    function handleSubmit(formData) {
    mutate({ event: formData }); // 함수를 실행해 요청 보냄, 이 인자는 mutationFn: createNewEvent로 전달됨
    }
    • react-router-dom + tanstack-query: 라우트시 초기 데이터(loader) + 데이터 업데이트(caching)
      • useQuery ➜ loader + useQuery
      • useMutation ➜ action
      • 중복 HTTP 요청 방지: staleTime: 10000으로 loader에서 가져온 데이터 감지하도록 함
  • SWR: 캐시된 데이터를 먼저 반환하고 데이터를 패칭하여 새로고침하는 방법 사용
  • Apollo Client: GraphQL 사용 애플리케이션 맞춤 도구
  • Relay: React와 GraphQL 사용 애플리케이션을 위한 프레임워크

'STORAGE > React' 카테고리의 다른 글

3. 내장 함수를 통한 성능 최적화  (0) 2024.04.13
2-1. 상태 관리 개괄  (0) 2024.04.11
1. React와 컴포넌트  (0) 2024.04.03

댓글