[Redux] Next.js(app router)에서 Redux Toolkit 사용하기

2024. 7. 13. 10:56상태관리

Next.js(app router)에서 Redux Toolkit 사용하기

 

Next.js는 React에서 널리 사용되는 서버 측 렌더링 프레임워크로, Redux를 올바르게 사용하기 위해서는 몇 가지 고려해야 할 점들이 있습니다. 아래와 같은 내용인데요. 하나씩 살펴보겠습니다.

요청별 안전한 Redux 스토어 생성: Next.js 서버는 동시에 여러 요청을 처리할 수 있습니다. 따라서 각 요청마다 Redux 스토어를 생성해야 하며, 요청 간에 스토어를 공유해서는 안됩니다.

SSR 친화적인 스토어 초기화: Next.js 애플리케이션은 서버에서 먼저 렌더링 되고, 이후 클라이언트에서 다시 렌더링됩니다. 클라이언트와 서버에서 동일한 페이지 내용을 렌더링하지 않으면 'hydration error'가 발생할 수 있습니다. 따라서 Redux 스토어는 서버에서 초기화된 후, 동일한 데이터를 사용하여 클라이언트에서 다시 초기화되어야 hydration 문제를 피할 수 있습니다.

SPA 라우팅 지원: Next.js는 클라이언트 측 라우팅을 위한 하이브리드 모델을 지원합니다. 사용자가 처음 페이지를 로드할 때는 서버에서 SSR 결과를 받게 되지만, 이후의 페이지 탐색은 클라이언트에서 처리됩니다. 따라서 레이아웃에 정의된 싱글톤 스토어를 사용할 경우, 라우트별 데이터는 선택적으로 리셋되어야 하며, 라우트에 의존하지 않는 데이터는 스토어에 유지되어야 합니다.

서버 캐싱 친화적: Next.js의 최근 버전(특히 App Router 아키텍처를 사용하는 애플리케이션)은 적극적인 서버 캐싱을 지원합니다. 이상적인 스토어 아키텍처는 이러한 캐싱과 호환될 수 있어야 합니다.

 

폴더 구조

Next 앱은 /app 폴더가 루트에 있거나 /src/app 아래에 중첩되도록 만들 수 있습니다. Redux 로직은 /app 폴더와 함께 별도의 폴더에 넣어야 합니다. 일반적으로 Redux 로직을 /lib 폴더에 넣는 것이 일반적이지만 필수는 아닙니다. 리덕스 자체는 애플리케이션의 폴더와 파일 구조가 어떻게 어떻게 구성되는지 신경 쓰지 않습니다. 그러나 특정 기능에 대한 로직을 한 곳에 배치하면 일반적으로 해당 코드를 유지 관리하기가 더 쉽습니다.

따라서 '기능 폴더 기반'으로 파일을 구조화할 것을 권장합니다. 특정 기능 폴더 내에서 해당 기능에 대한 Redux 로직은 가급적 Redux Toolkit의 createSlice API를 사용하여 단일 슬라이스 파일로 작성해야 합니다. (이를 'ducks 패턴'이라고도 합니다.) 이전 Redux 코드베이스에서는 '액션'과 '리듀서'를 위한 별도의 폴더를 사용하는 '폴더별' 접근 방식을 자주 사용했지만, 관련 로직을 함께 유지하면 해당 코드를 더 쉽게 찾고 업데이트할 수 있습니다. 

기능 폴더 기반 구조

요청별 리덕스 스토어 생성

스토어 파일을 생성하는 과정은 이전과 유사합니다. 다만 이전 방식에서는 스토어를 전역으로 정의했다면, 새로운 방법은 각 요청마다 새로운 스토어를 반환하는 makeStore 함수를 정의하는 것입니다.

//lib/store.ts

import { configureStore } from '@reduxjs/toolkit'
//import 리듀서

export const makeStore = () => {
  return configureStore({
    reducer: { 
    	//리듀서
    },
  })
}

// Infer the type of makeStore
export type AppStore = ReturnType<typeof makeStore>
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']

타입스크립트를 사용하면 Redux Toolkit이 제공하는 강력한 타입 안정성을 유지하면서 요청마다 스토어 인스턴스를 생성할 수 있는 makeStore 함수를 사용할 수 있습니다.

나중에 더 편하게 사용하기 위해 미리 타입이 지정된 React-Redux 훅을 생성하고 내보내는 것도 좋습니다.

//lib/hooks.ts

import { useDispatch, useSelector, useStore } from 'react-redux'
import type { RootState, AppDispatch, AppStore } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()

 

스토어 제공하기(Providing the store)

새로운 makeStore 함수를 사용하려면 스토어를 생성할 새로운 '클라이언트 컴포넌트'를 생성하고 React-Redux Provider 컴포넌트를 사용하여 공유해야 합니다. 스토어에 접근하려면 React 컨텍스트가 필요하며, 컨텍스트는 클라이언트 컴포넌트에서만 사용할 수 있기 때문입니다. Provider 컴포넌트는 리액트 컨텍스트를 사용하여 Redux 스토어를 제공하는 역할을 합니다. 이 컨텍스트를 통해 컴포넌트 트리의 하위 컴포넌트 어디서든 스토어에 접근할 수 있게 됩니다. 예를 들어, 하위 컴포넌트에서 useSelector와 useDispatch 훅을 사용하여 Redux 스토어의 상태를 읽거나 액션을 디스패치할 수 있습니다. 

//app/StoreProvider.tsx

'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from '../lib/store'

export default function StoreProvider({
  children,
}: {
  children: React.ReactNode
}) {
  const storeRef = useRef<AppStore>()
  if (!storeRef.current) {
    // Create the store instance the first time this renders
    storeRef.current = makeStore()
  }

  return <Provider store={storeRef.current}>{children}</Provider>
}

위 예제 코드에서는 클라이언트 컴포넌트가 다시 렌더링될 때 레퍼런스 값을 확인하여 스토어가 한 번만 생성되는 것을 보장합니다. 이 컴포넌트는 서버에서 요청당 한 번만 렌더링되지만, 트리 구조 상위에 상태가 있는 클라이언트 컴포넌트가 있거나 이 컴포넌트 자체에 상태 변화가 있으면 클라이언트에서 여러 번 다시 렌더링 될 수 있습니다.

Redux 상태 슬라이스 생성

//lib/features/counter/counterSlice.tsx

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface CounterState {
  value: number
}

const initialState: CounterState = {
  value: 0,
}

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
    },
    decrement: (state) => {
      state.value -= 1
    },
    initializeCount: (state, action: PayloadAction<number>) => {
      state.value = action.payload
    },
  },
})

export const { increment, decrement, initializeCount } = counterSlice.actions

export default counterSlice.reducer

 

초기 데이터 로딩

//src/app/StoreProvider.tsx

'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from '../lib/store'
import { initializeCount } from '../lib/features/counter/counterSlice'

export default function StoreProvider({
  count,
  children,
}: {
  count: number
  children: React.ReactNode
}) {
  const storeRef = useRef<AppStore | null>(null)
  if (!storeRef.current) {
    storeRef.current = makeStore()
    storeRef.current.dispatch(initializeCount(count))
  }

  return <Provider store={storeRef.current}>{children}</Provider>
}

부모 컴포넌트에서 데이터를 초기화하여 스토어를 설정해야 할 경우, 위 예시처럼 StoreProvider 컴포넌트의 prop으로 해당 데이터를 정의하고, 슬라이스의 Redux 액션을 사용하여 스토어에 데이터를 설정하면 됩니다.

 
이상으로 Next.js에서 Redux Toolkit 세팅하는 법에 대해 알아보았습니다.
 

참고

Redux Toolkit Setup with Next.js

Structure Files as Feature Folders with Single-File Logic

'상태관리' 카테고리의 다른 글

[Redux] Redux Overview and Concepts  (0) 2024.07.10