이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
Redux Toolkit과 Next.js 설정
- Next.js 프레임워크와 함께 Redux Toolkit을 설정하고 사용하는 방법
- ES2015 구문 및 기능에 대한 숙지
- React 용어에 대한 지식: JSX, 상태(State), 함수 컴포넌트, 속성(Props), 훅(Hooks)
- Redux 용어 및 개념에 대한 이해
- 빠른 시작 튜토리얼과 TypeScript 빠른 시작 튜토리얼 진행 권장, 가능하면 전체 Redux Essentials 튜토리얼까지 완료
소개
Next.js는 React를 위한 인기 있는 서버 사이드 렌더링 프레임워크로, Redux를 올바르게 사용하는 데 몇 가지 고유한 과제가 있습니다:
-
요청별 안전한 Redux 스토어 생성: Next.js 서버는 여러 요청을 동시에 처리할 수 있습니다. 이는 Redux 스토어가 요청별로 생성되어야 하며 여러 요청 간에 공유되어서는 안 된다는 의미입니다.
-
SSR 친화적인 스토어 하이드레이션: Next.js 애플리케이션은 서버에서 먼저 렌더링된 후 클라이언트에서 다시 렌더링됩니다. 클라이언트와 서버에서 동일한 페이지 콘텐츠를 렌더링하지 못하면 "하이드레이션 오류"가 발생합니다. 따라서 하이드레이션 문제를 방지하려면 Redux 스토어를 서버에서 초기화한 후 동일한 데이터로 클라이언트에서 재초기화해야 합니다.
-
SPA 라우팅 지원: Next.js는 클라이언트 측 라우팅을 위한 하이브리드 모델을 지원합니다. 사용자의 첫 페이지 로드는 서버의 SSR 결과를 받으며, 이후 페이지 탐색은 클라이언트에서 처리됩니다. 즉, 레이아웃에 정의된 싱글톤 스토어에서 경로별 데이터는 라우트 탐색 시 선택적으로 재설정해야 하지만, 경로와 무관한 데이터는 스토어에 유지되어야 합니다.
-
서버 캐싱 친화적: 최신 Next.js 버전(특히 App Router 아키텍처 사용 애플리케이션)은 적극적인 서버 캐싱을 지원합니다. 이상적인 스토어 아키텍처는 이 캐싱과 호환되어야 합니다.
Next.js 애플리케이션에는 두 가지 아키텍처가 있습니다: Pages Router와 App Router.
Pages Router는 Next.js의 원본 아키텍처입니다. Pages Router를 사용하는 경우, Redux 설정은 주로 next-redux-wrapper 라이브러리를 통해 처리되며, 이는 getServerSideProps 같은 Pages Router 데이터 가져오기 메서드와 Redux 스토어를 통합합니다.
이 가이드는 App Router 아키텍처에 중점을 둡니다. 이는 Next.js의 새로운 기본 아키텍처 옵션이기 때문입니다.
이 가이드 읽는 방법
이 페이지는 App Router 아키텍처를 기반으로 한 기존 Next.js 애플리케이션이 이미 있다고 가정합니다.
실습을 원한다면 npx create-next-app my-app 명령어로 새로운 빈 Next 프로젝트를 생성할 수 있습니다. 기본 설정은 App Router가 활성화된 새 프로젝트를 설정합니다. 그런 다음 @reduxjs/toolkit과 react-redux를 의존성으로 추가하세요.
npx create-next-app --example with-redux my-app 명령어로 새로운 Next+Redux 프로젝트를 생성할 수도 있으며, 이 프로젝트는 이 페이지에서 설명하는 초기 설정을 포함합니다.
App Router 아키텍처와 Redux
Next.js 앱 라우터의 주요 신규 기능은 React 서버 컴포넌트(RSCs) 지원 추가입니다. RSCs는 클라이언트와 서버 모두에서 렌더링되는 "클라이언트" 컴포넌트와 달리 서버에서만 렌더링되는 특별한 유형의 React 컴포넌트입니다. RSC는 async 함수로 정의할 수 있으며, 렌더링 시 데이터 요청을 위한 비동기 호출을 수행하면서 프로미스를 반환합니다.
RSC의 데이터 요청 블로킹 기능으로 인해 앱 라우터에서는 렌더링을 위한 데이터를 가져오기 위해 getServerSideProps를 더 이상 사용하지 않습니다. 트리 내의 모든 컴포넌트가 비동기 데이터 요청을 할 수 있습니다. 이는 매우 편리하지만 전역 변수(Redux 스토어 등)를 정의할 경우 요청 간에 공유된다는 문제가 있습니다. Redux 스토어가 다른 요청의 데이터로 오염될 수 있기 때문입니다.
앱 라우터 아키텍처 기반으로 Redux 적절한 사용을 위한 일반적인 권장사항은 다음과 같습니다:
-
전역 스토어 사용 금지 - Redux 스토어는 요청 간에 공유되므로 전역 변수로 정의되어서는 안 됩니다. 대신 요청별로 스토어를 생성해야 합니다.
-
RSC는 Redux 스토어를 읽거나 써서는 안 됨 - RSC는 훅이나 컨텍스트를 사용할 수 없습니다. 상태 저장을 목적으로 하지 않습니다. RSC가 전역 스토어에서 값을 읽거나 쓰는 것은 Next.js 앱 라우터 아키텍처를 위반합니다.
-
스토어는 변경 가능한 데이터만 포함해야 함 - 전역적이며 변경 가능한 데이터에는 Redux를 절제하여 사용할 것을 권장합니다.
이 권장사항들은 Next.js 앱 라우터로 작성된 애플리케이션에만 해당됩니다. 단일 페이지 애플리케이션(SPA)은 서버에서 실행되지 않으므로 스토어를 전역 변수로 정의할 수 있습니다. SPA에는 RSC가 존재하지 않으므로 RSC에 대해 걱정할 필요가 없습니다. 또한 싱글톤 스토어는 원하는 모든 데이터를 저장할 수 있습니다.
폴더 구조
Next 앱은 루트에 /app 폴더를 두거나 /src/app 아래에 중첩하여 생성할 수 있습니다. Redux 로직은 /app 폴더와 별도로 분리된 폴더에 위치해야 합니다. 일반적으로 /lib 폴더에 Redux 로직을 배치하지만 필수는 아닙니다.
/lib 폴더 내부의 파일 및 폴더 구조는 자유롭게 구성할 수 있으나, Redux 로직에는 일반적으로 단일 파일 로직을 가진 "기능 폴더" 기반 구조를 권장합니다.
일반적인 예시는 다음과 같습니다:
/app
layout.tsx
page.tsx
StoreProvider.tsx
/lib
store.ts
/features
/todos
todosSlice.ts
본 가이드에서는 이 방식을 사용하겠습니다.
초기 설정
RTK TypeScript 튜토리얼과 유사하게 Redux 스토어 파일과 추론된 RootState, AppDispatch 타입을 생성해야 합니다.
단, Next의 다중 페이지 아키텍처로 인해 단일 페이지 앱 설정과 몇 가지 차이점이 있습니다.
요청별 Redux 스토어 생성
첫 번째 변경사항은 store를 전역 또는 모듈 싱글톤 변수로 정의하는 대신, 요청별로 새 스토어를 반환하는 makeStore 함수를 정의하는 것입니다:
- TypeScript
- JavaScript
import { configureStore } from '@reduxjs/toolkit'
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']
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({
reducer: {}
})
}
이제 요청별로 스토어 인스턴스를 생성하면서도 Redux Toolkit이 제공하는 강력한 타입 안정성(타입스크립트 사용 시)을 유지할 수 있는 makeStore 함수가 생겼습니다.
store 변수를 내보내지는 않지만, makeStore의 반환 타입에서 RootState와 AppDispatch 타입을 추론할 수 있습니다.
추후 사용을 단순화하기 위해 사전 타입화된 React-Redux 훅 버전도 생성 및 내보내야 합니다:
- TypeScript
- JavaScript
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { AppDispatch, AppStore, RootState } 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>()
import { useDispatch, useSelector, useStore } from 'react-redux'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes()
export const useAppSelector = useSelector.withTypes()
export const useAppStore = useStore.withTypes()
스토어 제공하기
이 새로운 makeStore 함수를 사용하려면 스토어를 생성하고 React-Redux Provider 컴포넌트를 통해 공유할 새 "클라이언트" 컴포넌트를 생성해야 합니다.
- TypeScript
- JavaScript
'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 | null>(null)
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore } from '../lib/store'
export default function StoreProvider({ children }) {
const storeRef = useRef(null)
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}
이 예제 코드에서는 스토어가 한 번만 생성되도록 참조 값을 확인하여 클라이언트 컴포넌트의 재렌더링 안전성을 보장합니다. 이 컴포넌트는 서버에서 요청당 한 번만 렌더링되지만, 트리 상위에 상태 저장 클라이언트 컴포넌트가 있거나 이 컴포넌트 자체에 재렌더링을 유발하는 다른 변경 가능한 상태가 포함된 경우 클라이언트에서 여러 번 재렌더링될 수 있습니다.
Redux 스토어와 상호작용하는 컴포넌트(생성, 제공, 읽기, 쓰기)는 반드시 클라이언트 컴포넌트여야 합니다. 스토어 접근에는 React 컨텍스트가 필요하며, 컨텍스트는 클라이언트 컴포넌트에서만 사용 가능하기 때문입니다.
다음 단계는 스토어가 사용되는 영역 상위 트리 어디에서나 StoreProvider를 포함하는 것입니다. 특정 레이아웃을 사용하는 모든 경로에서 스토어가 필요한 경우 레이아웃 컴포넌트에 배치할 수 있습니다. 특정 경로에서만 스토어가 사용된다면 해당 경로 핸들러에서 스토어를 생성하고 제공할 수 있습니다. 트리 하위의 모든 클라이언트 컴포넌트에서는 react-redux가 제공하는 훅을 사용해 일반적인 방식으로 스토어를 활용할 수 있습니다.
초기 데이터 로딩
부모 컴포넌트의 데이터로 스토어를 초기화해야 한다면, 클라이언트 StoreProvider 컴포넌트에 해당 데이터를 prop으로 정의하고 아래 예시처럼 슬라이스의 Redux 액션을 사용해 스토어에 데이터를 설정하세요.
- TypeScript
- JavaScript
'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>
}
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore } from '../lib/store'
import { initializeCount } from '../lib/features/counter/counterSlice'
export default function StoreProvider({ count, children }) {
const storeRef = useRef(null)
if (!storeRef.current) {
storeRef.current = makeStore()
storeRef.current.dispatch(initializeCount(count))
}
return <Provider store={storeRef.current}>{children}</Provider>
}
추가 설정
경로별 상태 관리
next/navigation을 사용해 Next.js의 클라이언트 측 SPA 스타일 네비게이션을 활용하는 경우, 사용자가 페이지 간 이동할 때 경로 컴포넌트만 재렌더링됩니다. 즉, 레이아웃 컴포넌트에서 생성 및 제공된 Redux 스토어는 경로 변경 시에도 유지됩니다. 이는 스토어가 전역 변경 가능 데이터에만 사용될 때는 문제가 되지 않습니다. 그러나 경로별 데이터에 스토어를 사용하는 경우 경로가 변경될 때 스토어의 경로별 데이터를 재설정해야 합니다.
아래는 제품의 변경 가능한 이름을 관리하기 위해 Redux 스토어를 사용하는 ProductName 예제 컴포넌트입니다. ProductName 컴포넌트는 제품 상세 경로의 일부입니다. 스토어에 올바른 이름이 저장되도록 하려면 제품 상세 경로로 변경될 때마다 초기 렌더링되는 ProductName 컴포넌트에서 값을 설정해야 합니다.
- TypeScript
- JavaScript
'use client'
import { useRef } from 'react'
import { useAppSelector, useAppDispatch, useAppStore } from '../lib/hooks'
import {
initializeProduct,
setProductName,
Product
} from '../lib/features/product/productSlice'
export default function ProductName({ product }: { product: Product }) {
// Initialize the store with the product information
const store = useAppStore()
const initialized = useRef(false)
if (!initialized.current) {
store.dispatch(initializeProduct(product))
initialized.current = true
}
const name = useAppSelector(state => state.product.name)
const dispatch = useAppDispatch()
return (
<input
value={name}
onChange={e => dispatch(setProductName(e.target.value))}
/>
)
}
'use client'
import { useRef } from 'react'
import { useAppSelector, useAppDispatch, useAppStore } from '../lib/hooks'
import {
initializeProduct,
setProductName
} from '../lib/features/product/productSlice'
export default function ProductName({ product }) {
// Initialize the store with the product information
const store = useAppStore()
const initialized = useRef(false)
if (!initialized.current) {
store.dispatch(initializeProduct(product))
initialized.current = true
}
const name = useAppSelector(state => state.product.name)
const dispatch = useAppDispatch()
return (
<input
value={name}
onChange={e => dispatch(setProductName(e.target.value))}
/>
)
}
여기서는 이전과 동일한 초기화 패턴(스토어에 액션 디스패치)을 사용해 경로별 데이터를 설정합니다. initialized ref는 경로 변경당 한 번만 스토어가 초기화되도록 보장하는 데 사용됩니다.
useEffect로 스토어를 초기화하는 것은 useEffect가 클라이언트에서만 실행되므로 작동하지 않을 것임에 유의하세요. 이는 서버 측 렌더링 결과와 클라이언트 측 렌더링 결과가 일치하지 않아 하이드레이션 오류나 화면 깜빡임을 유발할 수 있습니다.
캐싱
App Router에는 fetch 요청 캐시와 경로 캐시를 포함해 네 가지 별도의 캐시가 있습니다. 가장 문제를 일으키기 쉬운 것은 경로 캐시입니다. 로그인 기능이 있는 애플리케이션에서 사용자별로 다른 데이터를 렌더링하는 경로(예: 홈 경로 /)가 있다면, 경로 핸들러의 dynamic 내보내기를 사용해 경로 캐시를 비활성화해야 합니다:
- TypeScript
- JavaScript
export const dynamic = 'force-dynamic'
export const dynamic = 'force-dynamic'
뮤테이션 후에는 적절한 revalidatePath 또는 revalidateTag 호출로 캐시를 무효화해야 합니다.
RTK Query
클라이언트 측에서만 데이터 페칭에 RTK Query 사용을 권장합니다. 서버 측 데이터 페칭은 async RSC의 fetch 요청을 사용해야 합니다.
RTK Query에 대한 자세한 내용은 Redux Toolkit Query 튜토리얼에서 확인할 수 있습니다.
향후 RTK Query는 React Server Components를 통해 서버에서 가져온 데이터를 받을 수 있게 될 수 있지만, 이는 React와 RTK Query 모두에 변경이 필요한 미래의 기능입니다.
작업 점검하기
Redux Toolkit을 올바르게 설정했는지 확인하기 위해 다음 세 가지 핵심 영역을 점검해야 합니다:
-
서버 사이드 렌더링 - 서버의 HTML 출력을 확인하여 Redux 스토어의 데이터가 서버 측 렌더링 결과물에 포함되어 있는지 검증하세요
-
경로 변경 - 동일한 경로 내 페이지 이동과 서로 다른 경로 간 이동을 수행하며 경로별 데이터가 올바르게 초기화되는지 확인하세요
-
데이터 변경 - 뮤테이션을 수행한 후 해당 경로를 벗어났다가 원래 경로로 돌아와 데이터가 업데이트되는지 확인하여 스토어가 Next.js App Router 캐시와 호환되는지 검증하세요
전체 권장사항
App Router는 Pages Router나 SPA 애플리케이션과 비교해 React 애플리케이션에 근본적으로 다른 아키텍처를 제시합니다. 이 새로운 아키텍처에 비추어 상태 관리 접근 방식을 재고할 것을 권장합니다. SPA 애플리케이션에서는 애플리케이션 구동에 필요한 모든 데이터(변경 가능 및 불가능)를 포함하는 대규모 스토어를 갖는 것이 일반적이나, App Router 애플리케이션에서는 다음을 권장합니다:
-
Redux는 전역적으로 공유되는 변경 가능한 데이터에만 사용하세요
-
다른 모든 상태 관리에는 Next.js 상태(검색 매개변수, 경로 파라미터, 폼 상태 등), React 컨텍스트 및 React 훅을 조합하여 활용하세요
학습 내용 요약
지금까지 App Router와 함께 Redux Toolkit을 설정하고 사용하는 방법에 대한 간략한 개요를 살펴보았습니다:
makeStore함수로 감싼configureStore를 사용해 요청별 Redux 스토어 생성- "클라이언트" 컴포넌트를 통해 React 애플리케이션 컴포넌트에 Redux 스토어 제공
- React 컨텍스트에 접근 가능한 클라이언트 컴포넌트에서만 Redux 스토어 상호작용
- React-Redux에서 제공하는 훅을 사용해 평소처럼 스토어 활용
- 레이아웃에 위치한 전역 스토어에서 경로별 상태 관리 시 이 점을 고려
다음 단계
Redux 핵심 문서의 "Redux Essentials" 및 "Redux Fundamentals" 튜토리얼을 진행해보세요. 이 튜토리얼들은 Redux 작동 방식, Redux Toolkit의 기능, 그리고 올바른 사용 방법에 대한 포괄적인 이해를 제공할 것입니다.