본문으로 건너뛰기

Redux 핵심, Part 4: Redux 데이터 사용하기

비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

학습 내용
  • 여러 React 컴포넌트에서 Redux 데이터 사용하기
  • 액션을 디스패치하는 로직 구성하기
  • 셀렉터로 상태 값 조회하기
  • 리듀서에서 복잡한 업데이트 로직 작성하기
  • Redux 액션에 대한 사고 방식
필수 사항

소개

Part 3: 기본 Redux 데이터 흐름에서는 빈 Redux+React 프로젝트 설정에서 시작해 새로운 상태 슬라이스를 추가하고, Redux 스토어에서 데이터를 읽고 해당 데이터를 업데이트하기 위해 액션을 디스패치할 수 있는 React 컴포넌트를 생성하는 방법을 살펴봤습니다. 또한 컴포넌트가 액션을 디스패치하고, 리듀서가 액션을 처리해 새로운 상태를 반환하며, 컴포넌트가 새로운 상태를 읽고 UI를 리렌더링하는 애플리케이션의 데이터 흐름도 확인했습니다. 또한 스토어 타입이 자동으로 적용된 "사전 타입화된" useSelectoruseDispatch 훅 버전을 생성하는 방법도 살펴봤습니다.

이제 Redux 로직 작성의 핵심 단계를 알았으므로, 소셜 미디어 피드에 유용한 새로운 기능을 추가하기 위해 동일한 단계를 사용할 것입니다: 단일 게시물 보기, 기존 게시물 수정, 게시물 작성자 세부 정보 표시, 게시물 타임스탬프, 반응 버튼 및 인증 기능입니다.

정보

상기하자면, 코드 예제는 각 섹션의 핵심 개념과 변경 사항에 초점을 맞춥니다. 애플리케이션의 전체 변경 사항은 CodeSandbox 프로젝트 및 프로젝트 저장소의 tutorial-steps-ts 브랜치에서 확인하세요.

단일 게시물 표시하기

Redux 스토어에 새 게시물을 추가할 수 있으므로, 게시물 데이터를 다양한 방식으로 사용하는 추가 기능을 구현할 수 있습니다.

현재 게시물 항목은 메인 피드 페이지에 표시되지만 텍스트가 너무 길 경우 내용의 일부만 보여줍니다. 단일 게시물 항목을 별도 페이지에서 볼 수 있는 기능이 유용할 것입니다.

단일 게시물 페이지 생성

먼저 posts 기능 폴더에 새로운 SinglePostPage 컴포넌트를 추가해야 합니다. 페이지 URL이 /posts/123 형태일 때 이 컴포넌트를 표시하도록 React Router를 사용할 것입니다. 여기서 123은 표시하려는 게시물의 ID여야 합니다.

features/posts/SinglePostPage.tsx
import { useParams } from 'react-router-dom'

import { useAppSelector } from '@/app/hooks'

export const SinglePostPage = () => {
const { postId } = useParams()

const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)

if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}

return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
</section>
)
}

이 컴포넌트를 렌더링하는 라우트를 설정할 때, URL의 두 번째 부분을 postId라는 변수로 파싱하도록 지정하고 useParams 훅에서 이 값을 읽을 수 있습니다.

postId 값을 얻은 후에는 셀렉터 함수 내부에서 이 값을 사용해 Redux 스토어에서 올바른 게시물 객체를 찾을 수 있습니다. state.posts가 모든 게시물 객체의 배열이므로 Array.find() 함수를 사용해 배열을 순회하면서 찾고 있는 ID와 일치하는 게시물 항목을 반환할 수 있습니다.

컴포넌트는 useAppSelector에서 반환된 값이 새 참조로 변경될 때마다 리렌더링된다는 점을 주목해야 합니다. 컴포넌트는 스토어에서 필요한 최소한의 데이터만 선택해 실제로 필요할 때만 렌더링되도록 해야 합니다.

스토어에 일치하는 게시물 항목이 없을 수도 있습니다. 사용자가 URL을 직접 입력했거나 올바른 데이터가 로드되지 않은 경우일 수 있습니다. 이런 경우 find() 함수는 실제 게시물 객체 대신 undefined를 반환합니다. 컴포넌트는 이를 확인하고 페이지에 "게시물을 찾을 수 없습니다!" 메시지를 표시해 처리해야 합니다.

스토어에 올바른 포스트 객체가 있다고 가정하면 useAppSelector가 해당 객체를 반환하므로, 이를 사용해 페이지에서 포스트의 제목과 내용을 렌더링할 수 있습니다.

이 로직이 메인 피드에서 전체 posts 배열을 순회하며 포스트 요약을 보여주는 <PostsList> 컴포넌트 본문의 로직과 상당히 유사해 보일 수 있습니다. 양쪽에서 사용할 수 있는 Post 컴포넌트를 추출해 볼 수도 있지만, 포스트 요약과 전체 포스트를 보여주는 방식에는 이미 차이가 있습니다. 중복이 있더라도 일단은 별도로 작성하는 것이 일반적으로 나으며, 코드 섹션이 충분히 유사해 재사용 가능한 컴포넌트로 추출할 수 있을 때 결정하는 것이 좋습니다.

단일 포스트 라우트 추가

이제 <SinglePostPage> 컴포넌트가 있으므로, 이를 표시할 라우트를 정의하고 프론트 페이지 피드의 각 포스트에 링크를 추가할 수 있습니다.

이와 함께 가독성을 위해 "메인 페이지" 콘텐츠를 별도의 <PostsMainPage> 컴포넌트로 추출하는 것도 가치 있을 것입니다.

PostsMainPageSinglePostPageApp.tsx에서 임포트하고 라우트를 추가하겠습니다:

App.tsx
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
</Routes>
</div>
</Router>
)
}

export default App

그런 다음 <PostsList>에서 해당 특정 포스트로 라우팅하는 <Link>를 포함하도록 목록 렌더링 로직을 업데이트합니다:

features/posts/PostsList.tsx
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'

export const PostsList = () => {
const posts = useAppSelector(state => state.posts)

const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
</article>
))

return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
)
}

이제 다른 페이지로 클릭하여 이동할 수 있으므로, <Navbar> 컴포넌트에 메인 포스트 페이지로 돌아가는 링크를 추가하는 것도 도움이 될 것입니다:

app/Navbar.tsx
import { Link } from 'react-router-dom'

export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>

<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
</div>
</div>
</section>
</nav>
)
}

포스트 수정하기

사용자로서 포스트 작성을 마치고 저장한 후 어딘가 실수를 깨닫는 것은 정말 성가신 일입니다. 생성한 포스트를 나중에 수정할 수 있는 기능이 유용할 것입니다.

기존 포스트 ID를 받아 스토어에서 해당 포스트를 읽고, 사용자가 제목과 내용을 편집한 다음 변경 사항을 저장해 스토어의 포스트를 업데이트할 수 있는 새로운 <EditPostForm> 컴포넌트를 추가해 보겠습니다.

포스트 항목 업데이트하기

먼저 postsSlice를 업데이트하여 스토어가 실제로 포스트를 업데이트하는 방법을 알 수 있도록 새로운 리듀서 함수와 액션을 생성해야 합니다.

createSlice() 호출 내부에서 reducers 객체에 새 함수를 추가해야 합니다. 이 리듀서의 이름은 액션이 디스패치될 때마다 Redux DevTools에서 액션 타입 문자열의 일부로 표시되므로, 발생하는 상황을 잘 설명하는 이름이어야 합니다. 첫 번째 리듀서는 postAdded였으므로, 이번에는 postUpdated로 부르겠습니다.

Redux 자체는 리듀서 함수에 어떤 이름을 사용하든 상관하지 않습니다. postAdded, addPost, POST_ADDED, someRandomName 등으로 명명해도 동일하게 실행됩니다.

하지만 애플리케이션에서 발생한 이벤트를 설명하기 위해 postAdded처럼 과거 시제의 "이런 일이 발생했다"는 이름으로 리듀서를 명명하는 것을 권장합니다.

포스트 객체를 업데이트하려면 다음 사항을 알아야 합니다:

  • 업데이트할 포스트의 ID: 상태에서 올바른 포스트 객체를 찾기 위함

  • 사용자가 입력한 새로운 titlecontent 필드

Redux 액션 객체는 일반적으로 설명적인 문자열인 type 필드를 반드시 가져야 하며, 발생한 사항에 대한 추가 정보를 담은 다른 필드도 포함할 수 있습니다. 관례적으로 추가 정보는 action.payload라는 필드에 넣지만, payload 필드에 무엇을 담을지는 우리가 결정할 수 있습니다. 문자열, 숫자, 객체, 배열 등이 될 수 있습니다. 이 경우 필요한 정보가 세 가지이므로 payload 필드를 해당 세 필드를 포함한 객체로 구성할 계획입니다. 즉 액션 객체는 {type: 'posts/postUpdated', payload: {id, title, content}} 형태가 됩니다.

기본적으로 createSlice가 생성한 액션 생성자는 하나의 인자를 전달받으며, 이 값은 액션 객체에서 action.payload로 설정됩니다. 따라서 해당 필드를 포함한 객체를 postUpdated 액션 생성자에 인자로 전달할 수 있습니다. postAdded와 마찬가지로 이는 전체 Post 객체이므로, 리듀서 인자를 action: PayloadAction<Post>로 선언합니다.

또한 리듀서는 액션이 디스패치될 때 상태를 실제로 어떻게 업데이트할지 결정하는 역할을 합니다. 따라서 리듀서가 ID를 기반으로 올바른 포스트 객체를 찾고, 해당 포스트의 titlecontent 필드를 명시적으로 업데이트해야 합니다.

마지막으로, 사용자가 포스트를 저장할 때 UI에서 새로운 postUpdated 액션을 디스패치할 수 있도록 createSlice가 생성한 액션 생성자 함수를 내보내야 합니다.

이러한 모든 요구사항을 고려하여 완료 후 postsSlice 정의는 다음과 같이 되어야 합니다:

features/posts/postsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

// omit state types

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
postUpdated(state, action: PayloadAction<Post>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})

export const { postAdded, postUpdated } = postsSlice.actions

export default postsSlice.reducer

포스트 수정 폼 생성하기

새로운 <EditPostForm> 컴포넌트는 <AddPostForm><SinglePostPage> 양쪽과 유사하게 보이겠지만, 로직은 약간 다를 필요가 있습니다. URL의 postId를 기반으로 스토어에서 올바른 post 객체를 검색한 다음, 사용자가 변경할 수 있도록 컴포넌트의 입력 필드를 초기화해야 합니다. 사용자가 폼을 제출하면 변경된 제목과 내용 값을 다시 스토어에 저장할 것입니다. 또한 React Router의 useNavigate 훅을 사용하여 변경 사항을 저장한 후 단일 포스트 페이지로 전환하고 해당 포스트를 표시할 것입니다.

features/posts/EditPostForm.tsx
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'

import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { postUpdated } from './postsSlice'

// omit form element types

export const EditPostForm = () => {
const { postId } = useParams()

const post = useAppSelector(state =>
state.posts.find(post => post.id === postId)
)

const dispatch = useAppDispatch()
const navigate = useNavigate()

if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}

const onSavePostClicked = (e: React.FormEvent<EditPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

if (title && content) {
dispatch(postUpdated({ id: post.id, title, content }))
navigate(`/posts/${postId}`)
}
}

return (
<section>
<h2>Edit Post</h2>
<form onSubmit={onSavePostClicked}>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
defaultValue={post.title}
required
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue={post.content}
required
/>

<button>Save Post</button>
</form>
</section>
)
}

여기서 Redux 관련 코드는 상대적으로 최소한입니다. 다시 한번 useAppSelector를 통해 Redux 스토어에서 값을 읽은 다음, 사용자가 UI와 상호작용할 때 useAppDispatch를 통해 액션을 디스패치합니다.

SinglePostPage와 마찬가지로, 이를 App.tsx로 가져오고 postId를 경로 매개변수로 사용하여 이 컴포넌트를 렌더링할 경로를 추가해야 합니다.

App.tsx
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</div>
</Router>
)
}

export default App

다음과 같이 SinglePostPageEditPostForm으로 라우팅될 새 링크도 추가해야 합니다:

features/post/SinglePostPage.tsx
import { Link, useParams } from 'react-router-dom'

export const SinglePostPage = () => {

// omit other contents

<p className="post-content">{post.content}</p>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>

액션 페이로드 준비하기

방금 살펴보았듯이, createSlice의 액션 생성자는 일반적으로 하나의 인자를 기대하며, 이 값이 action.payload가 됩니다. 이는 가장 일반적인 사용 패턴을 단순화하지만, 때로는 액션 객체의 내용을 준비하기 위해 추가 작업이 필요할 때가 있습니다. postAdded 액션의 경우, 새로운 포스트에 대한 고유 ID를 생성해야 하며, 페이로드가 {id, title, content} 형태의 객체인지 확인해야 합니다.

현재 우리는 React 컴포넌트에서 ID를 생성하고 페이로드 객체를 만들어 postAdded에 전달하고 있습니다. 하지만 다른 컴포넌트에서 동일한 액션을 디스패치해야 하거나 페이로드 준비 로직이 복잡해진다면 어떻게 될까요? 액션을 디스패치할 때마다 그 로직을 중복해서 작성해야 하며, 컴포넌트가 이 액션의 페이로드가 어떻게 생겼는지 정확히 알도록 강제하게 됩니다.

주의

액션에 고유 ID나 기타 무작위 값이 포함되어야 한다면, 항상 그 값을 먼저 생성하여 액션 객체에 넣어야 합니다. 리듀서는 절대 무작위 값을 계산해서는 안 됩니다. 그렇게 하면 결과를 예측할 수 없게 되기 때문입니다.

만약 postAdded 액션 생성자를 직접 작성한다면, 우리는 설정 로직을 그 안에 직접 넣을 수 있었을 것입니다:

// hand-written action creator
function postAdded(title: string, content: string) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}

하지만 Redux Toolkit의 createSlice가 이러한 액션 생성자를 우리 대신 생성해 줍니다. 이는 우리가 직접 작성하지 않아도 되므로 코드를 더 짧게 만들어 주지만, 여전히 action.payload의 내용을 커스터마이즈할 방법이 필요합니다.

다행히 createSlice를 사용하면 리듀서를 작성할 때 "prepare 콜백" 함수를 정의할 수 있습니다. "prepare 콜백" 함수는 여러 인자를 받을 수 있으며, 고유 ID 같은 임의의 값을 생성하거나 액션 객체에 어떤 값이 들어갈지 결정하기 위해 필요한 동기 로직을 실행할 수 있습니다. 그런 다음 payload 필드를 포함한 객체를 반환해야 합니다. (반환 객체는 meta 필드를 포함할 수도 있으며, 이는 액션에 추가적인 설명 값을 추가하는 데 사용될 수 있습니다. 또한 error 필드는 이 액션이 어떤 종류의 오류를 나타내는지 여부를 나타내는 불리언 값입니다.)

createSlicereducers 필드 내부에서 필드 중 하나를 {reducer, prepare} 형태의 객체로 정의할 수 있습니다:

features/posts/postsSlice.ts
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string) {
return {
payload: { id: nanoid(), title, content }
}
}
}
// other reducers here
}
})

이제 컴포넌트는 페이로드 객체가 어떻게 생겼는지 걱정할 필요가 없습니다. 액션 생성자가 올바른 방식으로 객체를 구성해 줄 것입니다. 따라서 컴포넌트가 postAdded를 디스패치할 때 titlecontent를 인자로 전달하도록 업데이트할 수 있습니다:

features/posts/AddPostForm.tsx
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

// Now we can pass these in as separate arguments,
// and the ID will be generated automatically
dispatch(postAdded(title, content))

e.currentTarget.reset()
}

셀렉터로 데이터 읽기

이제 ID로 게시물을 조회하는 여러 컴포넌트가 있고, 모두 state.posts.find() 호출을 반복하고 있습니다. 이는 중복 코드이며, 항상 중복 제거를 고려할 가치가 있습니다. 또한 취약합니다. 나중에 살펴보겠지만 결국 posts 슬라이스 상태 구조를 변경하게 될 것입니다. 그렇게 되면 state.posts를 참조하는 모든 위치를 찾아 로직을 업데이트해야 합니다. TypeScript는 컴파일 시 예상 상태 타입과 더 이상 일치하지 않는 깨진 코드를 잡아내기 위해 오류를 발생시켜 도움을 줄 것입니다. 하지만 리듀서에서 데이터 형식을 변경할 때마다 컴포넌트를 계속 다시 작성해야 하거나 컴포넌트에서 로직을 반복하지 않아도 된다면 더 좋을 것입니다.

이를 피하는 한 가지 방법은 슬라이스 파일에 재사용 가능한 셀렉터 함수를 정의하고, 각 컴포넌트에서 셀렉터 로직을 반복하는 대신 해당 셀렉터를 사용해 필요한 데이터를 추출하도록 하는 것입니다. 이렇게 하면 상태 구조를 다시 변경하더라도 슬라이스 파일의 코드만 업데이트하면 됩니다.

셀렉터 함수 정의하기

useAppSelector( state => state.posts )와 같이 useAppSelector를 호출할 때마다 이미 셀렉터 함수를 작성해 왔습니다. 이 경우 셀렉터가 인라인으로 정의되고 있습니다. 함수에 불과하므로 다음과 같이 작성할 수도 있습니다:

const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)

셀렉터는 일반적으로 슬라이스 파일에 독립적인 개별 함수로 작성됩니다. 일반적으로 첫 번째 인자로 전체 Redux RootState를 받으며, 다른 인자도 추가로 받을 수 있습니다.

Posts 셀렉터 추출하기

<PostsList> 컴포넌트는 모든 게시물 목록을 읽어야 하며, <SinglePostPage><EditPostForm> 컴포넌트는 ID로 단일 게시물을 조회해야 합니다. postsSlice.ts에서 이 경우를 처리하기 위해 두 개의 작은 셀렉터 함수를 내보내겠습니다:

features/posts/postsSlice.ts
import type { RootState } from '@/app/store'

const postsSlice = createSlice(/* omit slice code*/)

export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

export default postsSlice.reducer

export const selectAllPosts = (state: RootState) => state.posts

export const selectPostById = (state: RootState, postId: string) =>
state.posts.find(post => post.id === postId)

이 셀렉터 함수의 state 매개변수는 useAppSelector 내부에 직접 작성한 인라인 익명 셀렉터와 마찬가지로 루트 Redux 상태 객체입니다.

그런 다음 컴포넌트에서 이 셀렉터를 사용할 수 있습니다:

features/posts/PostsList.tsx
// omit imports
import { selectAllPosts } from './postsSlice'

export const PostsList = () => {
const posts = useAppSelector(selectAllPosts)
// omit component contents
}
features/posts/SinglePostPage.tsx
// omit imports
import { selectPostById } from './postsSlice'

export const SinglePostPage = () => {
const { postId } = useParams()

const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
features/posts/EditPostForm.tsx
// omit imports
import { postUpdated, selectPostById } from './postsSlice'

export const EditPostForm = () => {
const { postId } = useParams()

const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}

useParams()에서 얻은 postIdstring | undefined로 타입이 지정되지만, selectPostById는 유효한 string을 인자로 기대합니다. TS의 ! 연산자를 사용해 이 시점에서 이 값이 undefined가 아니라고 TS 컴파일러에 알릴 수 있습니다. (이는 위험할 수 있지만, URL에 게시물 ID가 있을 때만 <EditPostForm>을 표시하도록 라우팅이 설정되어 있다고 가정할 수 있습니다.)

계속 진행하면서 컴포넌트의 useAppSelector 내부에 셀렉터를 인라인으로 작성하는 대신 슬라이스에 셀렉터를 작성하는 이 패턴을 따를 것입니다. 필수는 아니지만 따르면 좋은 패턴입니다!

셀렉터 효과적으로 사용하기

재사용 가능한 셀렉터를 작성해 데이터 조회를 캡슐화하는 것이 종종 좋은 방법입니다. 이상적으로는 컴포넌트가 Redux state에서 값이 어디에 위치하는지조차 알 필요가 없습니다. 단순히 슬라이스의 셀렉터를 사용해 데이터에 접근하기만 하면 됩니다.

또한 메모이제이션된 셀렉터를 생성하여 리렌더링을 최적화하고 불필요한 재계산을 건너뛰어 성능을 개선할 수 있습니다. 이는 본 튜토리얼 후반부에서 다룰 예정입니다.

하지만 모든 추상화와 마찬가지로 항상 모든 곳에 적용해야 하는 것은 아닙니다. 셀렉터 작성을 위한 코드는 이해하고 유지보수해야 할 추가 코드를 의미합니다. 상태의 모든 필드에 대해 셀렉터를 작성해야 한다고 생각하지 마세요. 셀렉터 없이 시작한 후 애플리케이션 코드의 여러 부분에서 동일한 값을 조회하는 경우가 많아질 때 추가하는 방식을 시도해보세요.

선택 사항: createSlice 내부에 셀렉터 정의하기

지금까지 슬라이스 파일 내에서 독립 함수로 셀렉터를 작성하는 방법을 살펴보았습니다. 경우에 따라 createSlice 내부에 직접 셀렉터를 정의하여 코드를 약간 단축할 수 있습니다.

Defining Selectors inside createSlice

We've already seen that createSlice requires the name, initialState, and reducers fields, and also accepts an optional extraReducers field.

If you want to define selectors directly inside of createSlice, you can pass in an additional selectors field. The selectors field should be an object similar to reducers, where the keys will be the selector function names, and the values are the selector functions to be generated.

Note that unlike writing a standalone selector function, the state argument to these selectors will be just the slice state, and not the entire RootState!.

Here's what it might look like to convert the posts slice selectors to be defined inside of createSlice:

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
/* omit reducer logic */
},
selectors: {
// Note that these selectors are given just the `PostsState`
// as an argument, not the entire `RootState`
selectAllPosts: postsState => postsState,
selectPostById: (postsState, postId: string) => {
return postsState.find(post => post.id === postId)
}
}
})

export const { selectAllPosts, selectPostById } = postsSlice.selectors

export default postsSlice.reducer

// We've replaced these standalone selectors:
// export const selectAllPosts = (state: RootState) => state.posts

// export const selectPostById = (state: RootState, postId: string) =>
// state.posts.find(post => post.id === postId)

There are still times you'll need to write selectors as standalone functions outside of createSlice. This is especially true if you're calling other selectors that need the entire RootState as their argument, in order to make sure the types match up correctly.

사용자와 게시물

지금까지 우리는 단일 상태 슬라이스만 다루었습니다. 로직은 postsSlice.ts에 정의되었고 데이터는 state.posts에 저장되었으며 모든 컴포넌트는 게시물 기능과 관련이 있었습니다. 실제 애플리케이션에는 여러 개의 서로 다른 상태 슬라이스와 Redux 로직 및 React 컴포넌트를 위한 여러 "기능 폴더"가 존재할 것입니다.

다른 사람이 관여하지 않는다면 "소셜 미디어" 앱이 될 수 없습니다! 앱에서 사용자 목록을 추적하는 기능을 추가하고 해당 데이터를 활용하도록 게시물 관련 기능을 업데이트해 보겠습니다.

사용자 슬라이스 추가

"사용자" 개념은 "게시물" 개념과 다르므로 사용자 관련 코드와 데이터는 게시물과 분리하여 유지해야 합니다. 새로운 features/users 폴더를 추가하고 usersSlice 파일을 생성합니다. 게시물 슬라이스와 마찬가지로 작업할 데이터를 확보하기 위해 초기 항목을 추가합니다.

features/users/usersSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

import type { RootState } from '@/app/store'

interface User {
id: string
name: string
}

const initialState: User[] = [
{ id: '0', name: 'Tianna Jenkins' },
{ id: '1', name: 'Kevin Grant' },
{ id: '2', name: 'Madison Price' }
]

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {}
})

export default usersSlice.reducer

export const selectAllUsers = (state: RootState) => state.users

export const selectUserById = (state: RootState, userId: string | null) =>
state.users.find(user => user.id === userId)

현재는 실제로 데이터를 업데이트할 필요가 없으므로 reducers 필드를 빈 객체로 남겨둡니다(이는 후속 섹션에서 다시 다룰 예정입니다).

이전과 마찬가지로 usersReducer를 스토어 파일로 가져와 스토어 설정에 추가합니다:

app/store.ts
import { configureStore } from '@reduxjs/toolkit'

import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'

export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})

이제 루트 상태는 {posts, users} 형태가 되어 reducer 인수로 전달된 객체와 일치합니다.

게시물 작성자 추가

앱의 모든 게시물은 사용자 중 한 명이 작성했으며, 새 게시물을 추가할 때마다 해당 게시물을 작성한 사용자를 추적해야 합니다. 이는 Redux 상태와 <AddPostForm> 컴포넌트 모두에 변경이 필요함을 의미합니다.

먼저 기존 Post 데이터 타입을 업데이트하여 작성한 사용자의 ID를 포함하는 user: string 필드를 추가해야 합니다. 또한 initialState의 기존 게시물 항목이 예시 사용자 ID 중 하나를 가진 post.user 필드를 갖도록 업데이트합니다.

그런 다음 기존 리듀서를 이에 맞게 업데이트합니다. postAdded prepare 콜백은 사용자 ID를 인수로 수락하고 이를 액션에 포함해야 합니다. 또한 게시물을 업데이트할 때 user 필드를 포함해서는 안 됩니다. 필요한 것은 변경된 게시물의 id와 업데이트된 텍스트를 위한 새 titlecontent 필드뿐입니다. Post에서 이 세 필드만 포함하는 PostUpdate 타입을 정의하고, 이를 postUpdated의 페이로드로 사용합니다.

features/posts/postsSlice.ts
export interface Post {
id: string
title: string
content: string
user: string
}

type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>

const initialState: Post[] = [
{ id: '1', title: 'First Post!', content: 'Hello!', user: '0' },
{ id: '2', title: 'Second Post', content: 'More text', user: '2' }
]

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})

이제 <AddPostForm>에서 useSelector를 사용해 스토어에서 사용자 목록을 읽고 드롭다운으로 표시할 수 있습니다. 그런 다음 선택한 사용자의 ID를 가져와 postAdded 액션 생성자에 전달합니다. 동시에 제목과 내용 입력란에 실제 텍스트가 있을 때만 "Save Post" 버튼을 클릭할 수 있도록 폼에 검증 로직을 추가합니다:

features/posts/AddPostForm.tsx
import { selectAllUsers } from '@/features/users/usersSlice'

// omit other imports and form types

const AddPostForm = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)

const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const userId = elements.postAuthor.value

dispatch(postAdded(title, content, userId))

e.currentTarget.reset()
}

const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))

return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" name="postAuthor" required>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}

이제 포스트 목록 항목과 <SinglePostPage> 내부에 포스트 작성자 이름을 표시할 방법이 필요합니다. 동일한 정보를 여러 곳에서 표시해야 하므로, 사용자 ID를 prop으로 받아 해당 사용자 객체를 조회하고 이름을 형식화하는 PostAuthor 컴포넌트를 만들 수 있습니다:

features/posts/PostAuthor.tsx
import { useAppSelector } from '@/app/hooks'

import { selectUserById } from '@/features/users/usersSlice'

interface PostAuthorProps {
userId: string
}

export const PostAuthor = ({ userId }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))

return <span>by {author?.name ?? 'Unknown author'}</span>
}

각 컴포넌트에서 동일한 패턴을 따르고 있음에 주목하세요. Redux 저장소에서 데이터를 읽어야 하는 모든 컴포넌트는 useAppSelector 훅을 사용하여 필요한 특정 데이터 조각을 추출할 수 있습니다. 또한 여러 컴포넌트가 동시에 Redux 저장소의 동일한 데이터에 접근할 수 있습니다.

이제 PostAuthor 컴포넌트를 PostsList.tsxSinglePostPage.tsx에 임포트하고 <PostAuthor userId={post.user} />로 렌더링할 수 있습니다. 포스트 항목을 추가할 때마다 선택된 사용자의 이름이 렌더링된 포스트 내부에 표시됩니다.

추가 포스트 기능

이제 포스트를 생성하고 편집할 수 있습니다. 포스트 피드를 더 유용하게 만들기 위해 추가 로직을 구현해 보겠습니다.

포스트 작성 날짜 저장

소셜 미디어 피드는 일반적으로 포스트 생성 시간 기준으로 정렬되며 "5시간 전"과 같은 상대적 설명으로 생성 시간을 표시합니다. 이를 위해 포스트 항목에 대한 date 필드 추적을 시작해야 합니다.

post.user 필드와 마찬가지로 액션이 디스패치될 때 항상 post.date가 포함되도록 postAdded prepare 콜백을 업데이트합니다. 하지만 이는 전달되는 또 다른 매개변수가 아닙니다. 액션이 디스패치된 정확한 타임스탬프를 항상 사용하고자 하므로 prepare 콜백 자체에서 이를 처리하도록 합니다.

주의

Redux 액션과 상태는 객체, 배열, 기본형과 같은 일반 JS 값만 포함해야 합니다. 클래스 인스턴스, 함수, Date/Map/Set 인스턴스 또는 기타 직렬화 불가능한 값을 Redux에 넣지 마세요!.

Date 클래스 인스턴스를 Redux 저장소에 직접 넣을 수 없으므로 post.date 값을 타임스탬프 문자열로 추적합니다. 이를 초기 상태 값에 추가하고(현재 날짜/시간에서 몇 분을 빼기 위해 date-fns 사용) prepare 콜백의 각 새 포스트에도 추가합니다.

features/posts/postsSlice.ts
import { createSlice, nanoid } from '@reduxjs/toolkit'
import { sub } from 'date-fns'

const initialState: Post[] = [
{
// omitted fields
content: 'Hello!',
date: sub(new Date(), { minutes: 10 }).toISOString()
},
{
// omitted fields
content: 'More text',
date: sub(new Date(), { minutes: 5 }).toISOString()
}
]

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId
}
}
}
}
// omit `postUpdated
}
})

포스트 작성자와 마찬가지로 <PostsList><SinglePostPage> 컴포넌트 모두에 상대적 타임스탬프 설명을 표시해야 합니다. 타임스탬프 문자열을 상대적 설명으로 형식화하는 <TimeAgo> 컴포넌트를 추가합니다. date-fns와 같은 라이브러리는 날짜 파싱 및 형식화에 유용한 유틸리티 함수를 제공합니다:

components/TimeAgo.tsx
import { parseISO, formatDistanceToNow } from 'date-fns'

interface TimeAgoProps {
timestamp: string
}

export const TimeAgo = ({ timestamp }: TimeAgoProps) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}

return (
<time dateTime={timestamp} title={timestamp}>
&nbsp; <i>{timeAgo}</i>
</time>
)
}

포스트 목록 정렬

현재 <PostsList>는 Redux 저장소에 보관된 순서대로 모든 포스트를 표시합니다. 예시에서는 가장 오래된 포스트가 먼저 표시되며 새 포스트를 추가할 때마다 포스트 배열 끝에 추가됩니다. 즉, 가장 최신 포스트는 항상 페이지 하단에 위치합니다.

일반적으로 소셜 미디어 피드는 최신 포스트를 먼저 표시하며 오래된 포스트를 보려면 아래로 스크롤합니다. 저장소에서 데이터가 오래된 순서대로 보관되더라도 최신 포스트가 먼저 오도록 <PostsList> 컴포넌트에서 데이터 순서를 재정렬할 수 있습니다. 이론적으로 state.posts 배열이 이미 정렬되었다는 것을 알고 있으므로 목록을 역순으로 정렬할 수 있지만, 확실히 하기 위해 직접 정렬하는 것이 좋습니다.

array.sort()는 기존 배열을 변형하므로 state.posts의 복사본을 만들어 정렬해야 합니다. post.date 필드가 날짜 타임스탬프 문자열로 보관되며 이를 직접 비교하여 올바른 순서로 포스트를 정렬할 수 있습니다:

features/posts/PostsList.tsx
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))

const renderedPosts = orderedPosts.map(post => {
return (
// omit rendering logic
)
})

포스트 반응 버튼

현재 포스트는 다소 지루합니다. 포스트를 더 흥미롭게 만들 필요가 있으며, 친구들이 포스트에 반응 이모지를 추가할 수 있도록 하는 것보다 더 좋은 방법이 있을까요? 🎉

이제 <PostsList><SinglePostPage>에 있는 각 게시물 하단에 이모지 반응 버튼 행을 추가하겠습니다. 사용자가 반응 버튼 중 하나를 클릭할 때마다 Redux 저장소에서 해당 게시물의 일치하는 카운터 필드를 업데이트해야 합니다. 반응 카운터 데이터가 Redux 저장소에 있기 때문에 앱의 다른 부분으로 전환해도 해당 데이터를 사용하는 모든 컴포넌트에서 일관된 값을 표시할 수 있습니다.

게시물에서 반응 데이터 추적하기

아직 데이터에 post.reactions 필드가 없으므로, 모든 게시물이 reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0} 같은 데이터를 포함하도록 initialState 게시물 객체와 postAdded prepare 콜백 함수를 업데이트해야 합니다.

그런 다음 사용자가 반응 버튼을 클릭할 때 게시물의 반응 횟수를 업데이트하는 새로운 리듀서를 정의할 수 있습니다.

게시물 편집과 마찬가지로 게시물 ID와 사용자가 클릭한 반응 버튼을 알아야 합니다. action.payload{id, reaction} 형태의 객체로 설정합니다. 그러면 리듀서가 올바른 게시물 객체를 찾아 해당 반응 필드를 업데이트할 수 있습니다.

features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'

export interface Reactions {
thumbsUp: number
tada: number
heart: number
rocket: number
eyes: number
}

export type ReactionName = keyof Reactions

export interface Post {
id: string
title: string
content: string
user: string
date: string
reactions: Reactions
}

type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>

const initialReactions: Reactions = {
thumbsUp: 0,
tada: 0,
heart: 0,
rocket: 0,
eyes: 0
}

const initialState: Post[] = [
// omit initial state
]

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
// omit other reducers
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
})

export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

이미 살펴본 것처럼 createSlice를 사용하면 리듀서에서 "변경" 로직을 작성할 수 있습니다. createSlice와 Immer 라이브러리를 사용하지 않았다면 existingPost.reactions[reaction]++ 라인이 실제로 post.reactions 객체를 변경하고, 이는 리듀서 규칙을 따르지 않아 앱의 다른 부분에서 버그를 일으킬 수 있습니다. 하지만 createSlice를 사용 중이므로 이 복잡한 업데이트 로직을 더 간단하게 작성할 수 있으며, Immer가 이 코드를 안전한 불변 업데이트로 변환하는 작업을 처리합니다.

액션 객체에는 발생한 상황을 설명하는 데 필요한 최소한의 정보만 포함되어 있음에 유의하세요. 어떤 게시물을 업데이트해야 하는지, 어떤 반응 이름이 클릭되었는지만 알면 됩니다. 새로운 반응 카운터 값을 계산해 액션에 포함시킬 수도 있었지만, 액션 객체는 가능한 작게 유지하고 상태 업데이트 계산은 리듀서에서 수행하는 것이 항상 더 좋습니다. 이는 리듀서가 새로운 상태를 계산하는 데 필요한 만큼의 로직을 포함할 수 있음을 의미하기도 합니다. 실제로 상태 업데이트 로직은 리듀서에 있어야 합니다! 이렇게 하면 다른 컴포넌트에서 로직이 중복되거나 UI 계층이 작업할 최신 데이터를 갖지 못하는 문제를 피할 수 있습니다.

정보

Immer를 사용할 때는 기존 상태 객체를 "변경"하거나 새로운 상태 값을 직접 반환할 수 있지만, 동시에 둘 다 수행할 수는 없습니다. 자세한 내용은 Immer 문서의 주의 사항새 데이터 반환하기 가이드를 참조하세요.

반응 버튼 표시하기

게시물 작성자와 타임스탬프와 마찬가지로 게시물을 표시하는 모든 곳에서 이를 사용하려면 post를 prop으로 받는 <ReactionButtons> 컴포넌트를 생성합니다. 사용자가 버튼을 클릭하면 해당 반응 이모지의 이름과 함께 reactionAdded 액션을 디스패치합니다.

features/posts/ReactionButtons.tsx
import { useAppDispatch } from '@/app/hooks'

import type { Post, ReactionName } from './postsSlice'
import { reactionAdded } from './postsSlice'

const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}

interface ReactionButtonsProps {
post: Post
}

export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const dispatch = useAppDispatch()

const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => dispatch(reactionAdded({ postId: post.id, reaction }))}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)

return <div>{reactionButtons}</div>
}

이제 반응 버튼을 클릭할 때마다 해당 반응의 카운터가 증가해야 합니다. 앱의 다른 부분을 탐색할 때 이 게시물을 볼 때마다 올바른 카운터 값이 표시되며, <PostsList>에서 반응 버튼을 클릭한 후 <SinglePostPage>에서 게시물을 단독으로 보더라도 마찬가지입니다. 이는 각 컴포넌트가 동일한 게시물 데이터를 Redux 저장소에서 읽기 때문입니다.

사용자 로그인 추가하기

이번 섹션에서 추가할 기능이 하나 더 있습니다.

현재 <AddPostForm>에서 각 게시물 작성자를 선택하고 있습니다. 좀 더 현실감을 주기 위해 사용자가 애플리케이션에 로그인하여 누가 게시물을 작성하는지 미리 알 수 있어야 합니다(향후 다른 기능에도 유용합니다).

이 예제 앱은 소규모이므로, 실제 인증 확인을 구현하지는 않을 것입니다(여기서 핵심은 실제 인증 구현 방법이 아닌 Redux 기능 사용법을 배우는 것입니다). 대신 사용자 이름 목록을 표시하고 실제 사용자가 그 중 하나를 선택하도록 할 것입니다.

이 예제에서는 state.auth.username을 추적하는 auth 슬라이스를 추가하여 사용자가 누구인지 파악할 수 있게 합니다. 그러면 사용자가 게시물을 추가할 때마다 이 정보를 활용해 올바른 사용자 ID를 게시물에 자동으로 추가할 수 있습니다.

인증 슬라이스 추가하기

첫 번째 단계는 authSlice를 생성하고 스토어에 추가하는 것입니다. 이는 이미 익숙한 패턴입니다: 초기 상태 정의, 로그인/로그아웃 업데이트를 처리하는 리듀서로 슬라이스 작성, 스토어에 슬라이스 리듀서 추가.

여기서 인증 상태는 단순히 현재 로그인한 사용자 이름이며, 로그아웃 시 null로 재설정됩니다.

features/auth/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface AuthState {
username: string | null
}

const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}

const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
userLoggedIn(state, action: PayloadAction<string>) {
state.username = action.payload
},
userLoggedOut(state) {
state.username = null
}
}
})

export const { userLoggedIn, userLoggedOut } = authSlice.actions

export const selectCurrentUsername = (state: RootState) => state.auth.username

export default authSlice.reducer
app/store.ts
import { configureStore } from '@reduxjs/toolkit'

import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'

export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer
}
})

로그인 페이지 추가하기

현재 앱 메인 화면은 게시물 목록과 추가 양식이 있는 <Posts> 컴포넌트입니다. 이 동작을 변경해 사용자가 먼저 로그인 화면을 보게 하고, 로그인 후에만 게시물 페이지를 볼 수 있도록 합니다.

먼저 <LoginPage> 컴포넌트를 생성합니다. 이 컴포넌트는 스토어에서 사용자 목록을 읽어 드롭다운으로 표시하고, 양식 제출 시 userLoggedIn 액션을 디스패치합니다. 로그인 후 <PostsMainPage>를 보여주기 위해 /posts 경로로 이동합니다:

features/auth/LoginPage.tsx
import React from 'react'
import { useNavigate } from 'react-router-dom'

import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'

import { userLoggedIn } from './authSlice'

interface LoginPageFormFields extends HTMLFormControlsCollection {
username: HTMLSelectElement
}
interface LoginPageFormElements extends HTMLFormElement {
readonly elements: LoginPageFormFields
}

export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()

const handleSubmit = (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()

const username = e.currentTarget.elements.username.value
dispatch(userLoggedIn(username))
navigate('/posts')
}

const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))

return (
<section>
<h2>Welcome to Tweeter!</h2>
<h3>Please log in:</h3>
<form onSubmit={handleSubmit}>
<label htmlFor="username">User:</label>
<select id="username" name="username" required>
<option value=""></option>
{usersOptions}
</select>
<button>Log In</button>
</form>
</section>
)
}

다음으로 <App> 컴포넌트의 라우팅을 업데이트합니다. 루트(/) 경로에는 <LoginPage>를 표시하고, 인증되지 않은 접근은 다른 페이지에서 로그인 화면으로 리디렉션해야 합니다.

일반적인 방법은 React 컴포넌트를 children으로 받아 인증 확인 후 인증된 사용자에게만 자식 컴포넌트를 표시하는 "protected route" 컴포넌트를 추가하는 것입니다. state.auth.username 값을 읽어 인증 확인에 사용하는 <ProtectedRoute>를 추가하고, 게시물 관련 라우팅 설정 전체를 해당 <ProtectedRoute>로 래핑합니다:

App.tsx
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'

import { useAppSelector } from './app/hooks'
import { Navbar } from './components/Navbar'
import { LoginPage } from './features/auth/LoginPage'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'

import { selectCurrentUsername } from './features/auth/authSlice'

const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const username = useAppSelector(selectCurrentUsername)

if (!username) {
return <Navigate to="/" replace />
}

return children
}

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
)
}

export default App

이제 인증 동작이 양방향으로 작동하는지 확인할 수 있습니다:

  • 사용자가 로그인 없이 /posts에 접근하면 <ProtectedRoute>/로 리디렉션하며 <LoginPage> 표시

  • 사용자 로그인 시 userLoggedIn() 디스패치로 Redux 상태 업데이트 후 /posts로 강제 이동. 이때 <ProtectedRoute>가 게시물 페이지 표시

현재 사용자로 UI 업데이트하기

앱 사용 중 로그인한 사용자를 파악했으므로 네비게이션 바에 실제 사용자 이름을 표시할 수 있습니다. "로그아웃" 버튼 추가로 로그아웃 기능도 제공해야 합니다.

표시할 user.name을 읽으려면 스토어에서 현재 사용자 객체를 가져와야 합니다. 인증 슬라이스에서 현재 사용자 이름을 가져온 후 해당 사용자 객체를 조회하면 됩니다. 이 기능을 여러 곳에서 사용할 수 있으므로 재사용 가능한 selectCurrentUser 셀렉터를 작성하기 적절한 시기입니다. usersSlice.ts에 배치하되 authSlice.tsselectCurrentUsername을 임포트해 의존하게 합니다:

features/users/usersSlice.ts
import { selectCurrentUsername } from '@/features/auth/authSlice'

// omit the rest of the slice and selectors

export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
return selectUserById(state, currentUsername)
}

셀렉터를 조합하고 다른 셀렉터 내부에서 사용하는 것은 종종 유용합니다. 이 경우 selectCurrentUsernameselectUserById를 함께 사용할 수 있습니다.

구현한 다른 기능들과 마찬가지로 스토어에서 관련 상태(현재 사용자 객체)를 선택하고 값을 표시하며, "로그아웃" 버튼 클릭 시 userLoggedOut() 액션을 디스패치합니다:

components/Navbar.tsx
import { Link } from 'react-router-dom'

import { useAppDispatch, useAppSelector } from '@/app/hooks'

import { userLoggedOut } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'

import { UserIcon } from './UserIcon'

export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)

const isLoggedIn = !!user

let navContent: React.ReactNode = null

if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(userLoggedOut())
}

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
}

return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
{navContent}
</section>
</nav>
)
}

이와 관련하여 <AddPostForm>도 사용자 선택 드롭다운을 표시하는 대신 상태에서 로그인된 사용자 이름을 사용하도록 변경해야 합니다. postAuthor 입력 필드에 대한 모든 참조를 제거하고, authSlice에서 사용자 ID를 읽기 위해 useAppSelector를 추가하면 됩니다.

features/posts/AddPostForm.tsx
export const AddPostForm = () => {
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!

const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Removed the `postAuthor` field everywhere in the component

dispatch(postAdded(title, content, userId))

e.currentTarget.reset()
}

마지막으로 현재 사용자가 다른 사용자가 작성한 게시물을 편집할 수 있도록 허용하는 것은 이치에 맞지 않습니다. 게시물 작성자 ID가 현재 사용자 ID와 일치하는 경우에만 "게시물 편집" 버튼을 표시하도록 <SinglePostPage>를 업데이트할 수 있습니다.

features/posts/SinglePostPage.tsx
import { selectCurrentUsername } from '@/features/auth/authSlice'

export const SinglePostPage = () => {
const { postId } = useParams()

const post = useAppSelector(state => selectPostById(state, postId!))
const currentUsername = useAppSelector(selectCurrentUsername)!

if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}

const canEdit = currentUsername === post.user

return (
<section>
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
{canEdit && (
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
)}
</article>
</section>
)
}

로그아웃 시 다른 상태 정리하기

인증 처리에서 살펴봐야 할 부분이 하나 더 있습니다. 현재 사용자 A로 로그인하여 새 게시물을 생성한 후 로그아웃하고, 사용자 B로 다시 로그인하면 초기 예제 게시물과 새 게시물이 모두 표시됩니다.

이는 우리가 지금까지 작성한 코드에 대해 Redux가 의도한 대로 작동하고 있으므로 "기술적으로는" 정확합니다. Redux 저장소에서 게시물 목록 상태를 업데이트했고 페이지를 새로 고치지 않았기 때문에 동일한 JS 데이터가 여전히 메모리에 남아 있습니다. 하지만 앱 동작 측면에서는 다소 혼란스럽고 개인정보 보호 측면에서도 문제가 될 수 있습니다. 사용자 B와 사용자 A가 서로 연결되지 않은 경우는 어떨까요? 여러 사람이 동일한 컴퓨터를 공유한다면 어떨까요? 로그인 시 서로의 데이터를 볼 수 없어야 합니다.

따라서 현재 사용자가 로그아웃할 때 기존 게시물 상태를 정리할 수 있다면 좋을 것입니다.

여러 슬라이스에서 액션 처리하기

지금까지 다른 상태 업데이트를 할 때마다 새로운 Redux 케이스 리듀서를 정의하고, 생성된 액션 생성자를 내보낸 다음, 컴포넌트에서 해당 액션을 디스패치했습니다. 여기서도 그렇게 할 수 있습니다. 하지만 결국 다음과 같이 두 개의 별도 Redux 액션을 연속으로 디스패치하게 됩니다.

dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())

액션을 디스패치할 때마다 전체 Redux 저장소 업데이트 프로세스(리듀서 실행, 구독된 UI 컴포넌트에 알림, 업데이트된 컴포넌트 다시 렌더링)가 발생해야 합니다. 이는 Redux와 React의 작동 방식이므로 문제없지만, 연속으로 두 개의 액션을 디스패치하는 것은 일반적으로 로직 정의 방식을 재고해야 한다는 신호입니다.

이미 userLoggedOut() 액션이 디스패치되고 있지만, 이는 auth 슬라이스에서 내보낸 액션입니다. posts 슬라이스에서도 이 액션을 수신할 수 있다면 좋을 것입니다.

앞서 액션을 "값을 설정하라는 명령" 이라기보다 "앱에서 발생한 이벤트" 로 생각하는 것이 도움이 된다고 언급했습니다. 이는 실제로 좋은 사례입니다. 발생한 이벤트는 "사용자가 로그아웃했습니다" 하나뿐이므로 clearUserData를 위한 별도 액션이 필요하지 않습니다. 하나의 userLoggedOut 액션을 여러 곳에서 처리하여 관련된 모든 상태 업데이트를 동시에 적용할 수 있는 방법만 있으면 됩니다.

extraReducers를 사용하여 다른 액션 처리하기

다행히도 가능합니다! createSliceextraReducers 라는 옵션을 지원하며, 이를 사용하면 슬라이스가 앱 내 다른 곳에서 정의된 액션을 수신하도록 할 수 있습니다. 다른 액션이 디스패치될 때마다 이 슬라이스도 자체 상태를 업데이트할 수 있습니다. 즉, 여러 다른 슬라이스 리듀서가 모두 동일한 디스패치된 액션에 응답할 수 있으며, 필요에 따라 각 슬라이스가 자체 상태를 업데이트할 수 있습니다!

extraReducers 필드는 builder라는 매개변수를 받는 함수입니다. builder 객체에는 세 가지 메서드가 있으며, 각 메서드는 슬라이스가 다른 액션을 수신하고 자체 상태 업데이트를 수행할 수 있게 합니다.

  • builder.addCase(actionCreator, caseReducer): 특정 액션 유형 하나를 수신합니다.

  • builder.addMatcher(matcherFunction, caseReducer): Redux Toolkit "매처" 함수를 사용하여 여러 액션 유형 중 하나를 수신합니다(액션 객체 비교용).

  • builder.addDefaultCase(caseReducer): 이 슬라이스에서 다른 어떤 것도 액션과 일치하지 않을 때 실행되는 케이스 리듀서를 추가합니다(switch 문 내부의 default 케이스와 동일합니다).

builder.addCase().addCase().addMatcher().addDefaultCase()처럼 이러한 메서드를 체인으로 연결할 수 있습니다. 여러 매처가 액션과 일치하면 정의된 순서대로 실행됩니다.

따라서 authSlice.ts에서 userLoggedOut 액션을 가져와 postsSlice.tspostsSlice.extraReducers 내부에서 해당 액션을 수신하고, 로그아웃 시 게시물 목록을 재설정하기 위해 빈 게시물 배열을 반환할 수 있습니다:

features/posts/postsSlice.ts
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit'
import { sub } from 'date-fns'

import { userLoggedOut } from '@/features/auth/authSlice'

// omit initial state and types

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
// omit postAdded and other case reducers
},
extraReducers: (builder) => {
// Pass the action creator to `builder.addCase()`
builder.addCase(userLoggedOut, (state) => {
// Clear out the list of posts whenever the user logs out
return []
})
},
})

builder.addCase(userLoggedOut, caseReducer)를 호출합니다. 해당 리듀서 내부에서는 createSlice 호출 내부의 다른 케이스 리듀서와 마찬가지로 "변경(mutating)" 상태 업데이트를 작성할 수 있습니다. 하지만 기존 상태를 완전히 교체하려는 경우 가장 간단한 방법은 새로운 게시물 상태로 빈 배열을 반환하는 것입니다.

이제 "Log Out" 버튼을 클릭한 후 다른 사용자로 로그인하면 "Posts" 페이지가 비어 있어야 합니다. 훌륭합니다! 로그아웃 시 게시물 상태를 성공적으로 비웠습니다.

reducersextraReducers의 차이점은 무엇인가요?

createSlice 내부의 reducersextraReducers 필드는 서로 다른 목적을 가지고 있습니다:

  • reducers 필드는 일반적으로 객체입니다. reducers 객체에 정의된 모든 케이스 리듀서에 대해 createSlice는 자동으로 동일한 이름의 액션 생성자와 Redux DevTools에 표시할 액션 타입 문자열을 생성합니다. 슬라이스의 일부로 새로운 액션을 정의하려면 reducers를 사용하세요.
  • extraReducersbuilder 매개변수가 있는 함수를 받습니다. builder.addCase()builder.addMatcher() 메서드는 새로운 액션을 정의하지 않고도 다른 액션 타입을 처리하는 데 사용됩니다. 슬라이스 외부에서 정의된 액션을 처리하려면 extraReducers를 사용하세요.

학습 내용 요약

이번 섹션은 여기까지입니다! 많은 작업을 수행했습니다. 이제 개별 게시물을 보고 편집할 수 있으며, 각 게시물의 작성자를 확인하고, 이모지 반응을 추가하고, 현재 사용자가 로그인 및 로그아웃하는 것을 추적할 수 있습니다.

모든 변경 사항을 적용한 후의 앱 모습입니다:

실제로 더 유용하고 흥미로워지기 시작했습니다!

이번 섹션에서는 많은 정보와 개념을 다뤘습니다. 기억해야 할 중요한 사항을 다시 살펴보겠습니다:

요약
  • React 컴포넌트는 필요에 따라 Redux 스토어의 데이터를 사용할 수 있습니다
    • 모든 컴포넌트는 Redux 스토어에 있는 데이터를 읽을 수 있음
    • 여러 컴포넌트가 동시에 동일한 데이터를 읽을 수 있음
    • 컴포넌트는 렌더링에 필요한 최소한의 데이터만 추출해야 함
    • 컴포넌트는 props, state, Redux 스토어의 값을 조합하여 필요한 UI를 결정할 수 있음. 스토어에서 여러 데이터 조각을 읽고 표시를 위해 필요에 따라 데이터를 재구성할 수 있음
    • 모든 컴포넌트는 상태 업데이트를 트리거하기 위해 액션을 디스패치할 수 있음
  • Redux 액션 생성자는 적절한 내용으로 액션 객체를 준비할 수 있음
    • createSlicecreateAction은 액션 페이로드를 반환하는 "prepare 콜백"을 허용함
    • 고유 ID 및 기타 랜덤 값은 리듀서에서 계산하지 않고 액션에 포함되어야 함
  • 리듀서에는 실제 상태 업데이트 로직이 포함되어야 함
    • 리듀서는 다음 상태를 계산하는 데 필요한 모든 로직을 포함할 수 있음
    • 액션 객체는 발생한 사건을 설명하기에 충분한 정보만 포함해야 함
  • 재사용 가능한 "셀렉터" 함수를 작성해 Redux 상태에서 값 읽기를 캡슐화할 수 있음
    • 셀렉터는 Redux state를 인수로 받아 일부 데이터를 반환하는 함수임
  • 액션은 "발생한 이벤트"를 설명하는 것으로 간주해야 하며, 여러 리듀서가 동일한 디스패치된 액션에 응답할 수 있음
    • 앱은 일반적으로 한 번에 하나의 액션만 디스패치해야 함
    • 케이스 리듀서 이름(및 액션)은 일반적으로 postAdded처럼 과거 시제로 명명해야 함
    • 여러 슬라이스 리듀서가 각각 동일한 액션에 대한 자체 상태 업데이트를 수행할 수 있음
    • createSlice.extraReducers를 사용하면 슬라이스 외부에서 정의된 액션을 수신할 수 있음
    • 상태 값은 기존 상태를 변형하는 대신 케이스 리듀서에서 대체할 새 값을 반환하여 재설정할 수 있음

다음 단계

지금까지 Redux 스토어와 React 컴포넌트에서 데이터를 다루는 방법에 익숙해졌을 것입니다. 지금까지는 초기 상태에 있거나 사용자가 추가한 데이터만 사용했습니다. 5부: 비동기 로직 및 데이터 불러오기에서는 서버 API에서 가져온 데이터를 다루는 방법을 살펴보겠습니다.