본문으로 건너뛰기

Redux Essentials, Part 6: 성능 최적화, 데이터 정규화 및 반응형 로직

비공식 베타 번역

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

학습 내용
  • createSelector로 메모이제이션 셀렉터 함수 생성 방법
  • 컴포넌트 렌더링 성능 최적화 패턴
  • createEntityAdapter로 정규화된 데이터 저장 및 업데이트 방법
  • createListenerMiddleware를 활용한 반응형 로직 구현 방법
필수 사항
  • 데이터 패칭 흐름 이해를 위한 Part 5 완료

소개

Part 5: 비동기 로직 및 데이터 패칭에서는 서버 API에서 데이터를 가져오기 위한 비동기 썽크 작성법과 비동기 요청 로딩 상태 처리 패턴을 살펴보았습니다.

이번 섹션에서는 애플리케이션 성능 보장을 위한 최적화 패턴과 스토어 내 데이터 업데이트 자동화 기법을 살펴보겠습니다. 또한 디스패치된 액션에 반응하는 로직 작성법도 다룰 것입니다.

지금까지 대부분의 기능은 posts 기능을 중심으로 구현되었습니다. 이제 앱에 몇 가지 새로운 섹션을 추가할 것입니다. 추가 완료 후에는 구현 방식의 세부 사항을 살펴보고, 현재 구현의 약점과 개선 방법에 대해 논의하겠습니다.

사용자 기능 확장

사용자 페이지 추가

가짜 API에서 사용자 목록을 가져오고 있으며, 새 게시물 추가 시 작성자로 사용자를 선택할 수 있습니다. 하지만 소셜 미디어 앱은 특정 사용자의 페이지를 방문하여 해당 사용자가 작성한 모든 게시물을 확인할 수 있어야 합니다. 전체 사용자 목록을 표시하는 페이지와 특정 사용자의 게시물을 보여주는 페이지를 추가해 보겠습니다.

새로운 <UsersList> 컴포넌트를 추가하는 것부터 시작하겠습니다. 이 컴포넌트는 useSelector로 스토어에서 데이터를 읽고, 배열을 매핑하여 개별 페이지로 연결되는 사용자 목록을 표시하는 일반적인 패턴을 따릅니다:

features/users/UsersList.tsx
import { Link } from 'react-router-dom'

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

import { selectAllUsers } from './usersSlice'

export const UsersList = () => {
const users = useAppSelector(selectAllUsers)

const renderedUsers = users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))

return (
<section>
<h2>Users</h2>

<ul>{renderedUsers}</ul>
</section>
)
}

다음으로 <UserPage>를 추가하겠습니다. 이 컴포넌트는 라우터에서 userId 매개변수를 받는다는 점에서 <SinglePostPage>와 유사합니다. 특정 사용자의 모든 게시물 목록을 렌더링하며, 일반적인 패턴에 따라 먼저 postsSlice.tsselectPostsByUser 셀렉터를 추가하겠습니다:

features/posts/postsSlice.ts
// omit rest of the file
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)

export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ This seems suspicious! See more details below
return allPosts.filter(post => post.user === userId)
}

export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
features/users/UserPage.tsx
import { Link, useParams } from 'react-router-dom'

import { useAppSelector } from '@/app/hooks'
import { selectPostsByUser } from '@/features/posts/postsSlice'

import { selectUserById } from './usersSlice'

export const UserPage = () => {
const { userId } = useParams()

const user = useAppSelector(state => selectUserById(state, userId!))

const postsForUser = useAppSelector(state =>
selectPostsByUser(state, userId!)
)

if (!user) {
return (
<section>
<h2>User not found!</h2>
</section>
)
}

const postTitles = postsForUser.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))

return (
<section>
<h2>{user.name}</h2>

<ul>{postTitles}</ul>
</section>
)
}
주의

참고: selectPostsByUser 내부에서 allPosts.filter()를 사용하고 있습니다. 이는 실제로 문제가 있는 패턴입니다! 이유는 곧 설명하겠습니다.

이미 usersSlice에서 selectAllUsersselectUserById 셀렉터를 사용할 수 있으므로 컴포넌트에서 해당 셀렉터를 가져와 사용하면 됩니다.

이전에 살펴본 것처럼, 한 useSelector 호출이나 props에서 가져온 데이터를 활용하여 다른 useSelector 호출에서 읽을 내용을 결정할 수 있습니다.

항상 그렇듯이 <App>에서 이러한 컴포넌트에 대한 라우트를 추가하겠습니다:

App.tsx
          <Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />

<Navbar>/users로 연결되는 새 탭을 추가하여 클릭 시 <UsersList>로 이동할 수 있게 합니다:

app/Navbar.tsx
export const Navbar = () => {
// omit other logic

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

// omit other rendering
}

이제 실제로 각 사용자의 페이지를 방문하여 해당 사용자의 게시물 목록만 확인할 수 있습니다.

서버에 로그인 요청 전송

현재 <LoginPage>authSlice는 클라이언트 측 Redux 액션을 디스패치하여 현재 사용자명만 추적하고 있습니다. 실제로는 서버에 로그인 요청을 전송해야 합니다. 게시물과 사용자 처리와 마찬가지로 로그인 및 로그아웃 처리를 비동기 썽크로 전환하겠습니다.

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

import { client } from '@/api/client'

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

import { createAppAsyncThunk } from '@/app/withTypes'

interface AuthState {
username: string | null
}

export const login = createAppAsyncThunk(
'auth/login',
async (username: string) => {
await client.post('/fakeApi/login', { username })
return username
}
)

export const logout = createAppAsyncThunk('auth/logout', async () => {
await client.post('/fakeApi/logout', {})
})

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,
// Remove the reducer definitions
reducers: {},
extraReducers: builder => {
// and handle the thunk actions instead
builder
.addCase(login.fulfilled, (state, action) => {
state.username = action.payload
})
.addCase(logout.fulfilled, state => {
state.username = null
})
}
})

// Removed the exported actions

export default authSlice.reducer

이와 함께 <Navbar><LoginPage>를 업데이트하여 기존 액션 생성자 대신 새 썽크를 가져와 디스패치하도록 합니다:

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

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

import { logout } 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(logout())
}
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 { login } from './authSlice'

// omit types

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

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

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

postsSlice에서 userLoggedOut 액션 생성자를 사용하고 있었으므로, 대신 logout.fulfilled를 수신하도록 업데이트할 수 있습니다.

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

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

// Import this thunk instead
import { logout } from '@/features/auth/authSlice'

// omit types and setup

const postsSlice = createSlice({
name,
initialState,
reducers: {
/* omitted */
},
extraReducers: builder => {
builder
// switch to handle the thunk fulfilled action
.addCase(logout.fulfilled, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// omit other cases
}
})

알림 추가하기

누군가 메시지를 보내거나 댓글을 남기거나 우리 게시물에 반응했을 때 알림이 팝업되는 기능 없이는 소셜 미디어 앱이 완성되었다고 할 수 없습니다.

실제 애플리케이션에서는 앱 클라이언트가 백엔드 서버와 지속적으로 통신하며, 서버는 무언가 발생할 때마다 클라이언트에 업데이트를 푸시합니다. 이는 작은 예시 앱이므로, 가짜 API에서 실제로 알림 항목을 가져오는 버튼을 추가하여 그 과정을 모방해 보겠습니다. 또한 메시지를 보내거나 게시물에 반응하는 다른 실제 사용자가 없으므로, 가짜 API는 요청할 때마다 무작위 알림 항목을 생성할 것입니다. (여기서 목표는 Redux 자체를 사용하는 방법을 보는 것임을 기억하세요.)

알림 슬라이스

이는 앱의 새로운 부분이므로, 첫 번째 단계는 알림을 위한 새 슬라이스를 생성하고 API에서 알림 항목을 가져오는 비동기 Thunk를 만드는 것입니다. 실제와 같은 알림을 생성하기 위해, 우리는 상태에 있는 최신 알림의 타임스탬프를 포함할 것입니다. 이를 통해 모의 서버가 해당 타임스탬프 이후의 새 알림을 생성할 수 있습니다.

features/notifications/notificationsSlice.ts
import { createSlice } from '@reduxjs/toolkit'

import { client } from '@/api/client'

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

export interface ServerNotification {
id: string
date: string
message: string
user: string
}

export const fetchNotifications = createAppAsyncThunk(
'notifications/fetchNotifications',
async (_unused, thunkApi) => {
const allNotifications = selectAllNotifications(thunkApi.getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ''
const response = await client.get<ServerNotification[]>(
`/fakeApi/notifications?since=${latestTimestamp}`
)
return response.data
}
)

const initialState: ServerNotification[] = []

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export default notificationsSlice.reducer

export const selectAllNotifications = (state: RootState) => state.notifications

다른 슬라이스와 마찬가지로, notificationsReducerstore.ts로 가져와 configureStore() 호출에 추가합니다.

우리는 서버에서 새 알림 목록을 검색할 fetchNotifications라는 비동기 Thunk를 작성했습니다. 이를 위해 최신 알림의 생성 타임스탬프를 요청의 일부로 사용하여, 서버가 실제로 새로운 알림만 반환하도록 할 것입니다.

알림 배열을 반환받을 것이므로, 이를 state.push()에 개별 인수로 전달하면 배열에 각 항목이 추가됩니다. 또한 서버가 순서를 어겨서 보낼 경우를 대비해 가장 최근 알림이 배열의 첫 번째에 오도록 정렬해야 합니다. (상기하자면, array.sort()는 항상 기존 배열을 변형합니다. 이는 createSlice와 Immer를 내부에서 사용하기 때문에 안전합니다.)

Thunk 인수

fetchNotifications Thunk를 보면, 이전에 보지 못한 새로운 내용이 있습니다. 잠시 Thunk 인수에 대해 이야기해 보겠습니다.

이미 dispatch(addPost(newPost))와 같이 Thunk 액션 생성자를 디스패치할 때 인수를 전달할 수 있음을 보았습니다. 특히 createAsyncThunk의 경우 하나의 인수만 전달할 수 있으며, 전달한 내용은 페이로드 생성 콜백의 첫 번째 인수가 됩니다. 아무것도 전달하지 않으면 해당 인수는 undefined가 됩니다.

페이로드 생성기의 두 번째 인수는 몇 가지 유용한 함수와 정보를 포함하는 thunkAPI 객체입니다:

  • dispatchgetState: Redux 스토어의 실제 dispatchgetState 메서드입니다. Thunk 내부에서 추가 액션을 디스패치하거나, 다른 액션이 디스패치된 후 업데이트된 값을 읽는 등 최신 Redux 스토어 상태를 가져오는 데 사용할 수 있습니다.

  • extra: 스토어 생성 시 Thunk 미들웨어에 전달할 수 있는 "추가 인수"입니다. 일반적으로 애플리케이션 서버에 API 호출을 수행하고 데이터를 반환하는 방법을 아는 함수 세트와 같은 API 래퍼로, Thunk 내부에 모든 URL과 쿼리 로직을 직접 포함할 필요가 없도록 합니다.

  • requestId: 이 Thunk 호출을 위한 고유한 무작위 ID 값입니다. 개별 요청의 상태를 추적하는 데 유용합니다.

  • signal: 진행 중인 요청을 취소하는 데 사용할 수 있는 AbortController.signal 함수입니다.

  • rejectWithValue: Thunk가 오류를 수신할 때 rejected 액션의 내용을 사용자 정의하는 데 도움이 되는 유틸리티입니다.

(createAsyncThunk 대신 수동으로 Thunk를 작성하는 경우, Thunk 함수는 (dispatch, getState)를 하나의 객체로 묶지 않고 별도의 인수로 받습니다.)

정보

이러한 인자와 thunk 및 요청 취소 처리 방법에 대한 자세한 내용은 createAsyncThunk API 레퍼런스 페이지를 참조하세요.

이 경우에는 항상 두 번째 인자인 thunkApi 인자에 접근해야 합니다. 즉, thunk를 디스패치할 때 아무것도 전달하지 않으며 페이로드 콜백 내부에서 사용하지 않더라도 첫 번째 인자에 대한 변수 이름을 반드시 제공해야 합니다. 따라서 사용하지 않음을 의미하는 _unused라는 이름을 지정하겠습니다.

여기서 알림 목록은 Redux 스토어 상태에 있으며 최신 알림은 배열의 맨 앞에 있어야 합니다. 상태 값을 읽기 위해 thunkApi.getState()를 호출하고, 알림 배열만 제공하는 selectAllNotifications 셀렉터를 사용할 수 있습니다. 알림 배열은 최신 순으로 정렬되어 있으므로 배열 비구조화를 사용하여 가장 최근 알림을 가져올 수 있습니다.

알림 목록 추가하기

이제 notificationsSlice가 생성되었으므로 <NotificationsList> 컴포넌트를 추가할 수 있습니다. 이 컴포넌트는 스토어에서 알림 목록을 읽고 각 알림이 얼마나 최근인지, 누가 보냈는지 등의 정보를 포함해 포맷팅해야 합니다. 이미 이러한 포맷팅을 처리할 수 있는 <PostAuthor><TimeAgo> 컴포넌트가 있으므로 여기서 재사용할 수 있습니다. 다만 <PostAuthor>에는 "by " 접두사가 포함되어 있어 여기서는 적절하지 않습니다. 기본값이 trueshowPrefix prop을 추가하고 여기서는 특별히 접두사를 표시하지 않도록 수정하겠습니다.

features/posts/PostAuthor.tsx
interface PostAuthorProps {
userId: string
showPrefix?: boolean
}

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

return (
<span>
{showPrefix ? 'by ' : null}
{author?.name ?? 'Unknown author'}
</span>
)
}
features/notifications/NotificationsList.tsx
import { useAppSelector } from '@/app/hooks'

import { TimeAgo } from '@/components/TimeAgo'

import { PostAuthor } from '@/features/posts/PostAuthor'

import { selectAllNotifications } from './notificationsSlice'

export const NotificationsList = () => {
const notifications = useAppSelector(selectAllNotifications)

const renderedNotifications = notifications.map(notification => {
return (
<div key={notification.id} className="notification">
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})

return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}

또한 <Navbar>를 업데이트하여 "알림" 탭과 새 알림을 가져오는 버튼을 추가해야 합니다:

app/Navbar.tsx
// omit several imports

import { logout } from '@/features/auth/authSlice'
import { fetchNotifications } from '@/features/notifications/notificationsSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'

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

const isLoggedIn = !!user

let navContent: React.ReactNode = null

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

const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit user details */}
</div>
)
}

// omit other rendering
}

마지막으로, 'Notifications' 경로를 추가하여 해당 페이지로 이동할 수 있도록 App.tsx를 업데이트해야 합니다:

App.tsx
// omit imports
import { NotificationsList } from './features/notifications/NotificationsList'

function App() {
return (
// omit all the outer router setup
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
<Route path="/notifications" element={<NotificationsList />} />
</Routes>
)
}

현재까지 구현된 "알림" 탭의 모습은 다음과 같습니다:

초기 알림 탭

새 알림 표시하기

"새 알림 가져오기" 버튼을 클릭할 때마다 몇 개의 새 알림 항목이 목록에 추가됩니다. 실제 앱에서는 UI의 다른 부분을 보고 있는 동안 서버에서 이러한 알림이 전송될 수 있습니다. <PostsList><UserPage>를 보고 있는 동안 "새 알림 가져오기"를 클릭하면 비슷한 효과를 얻을 수 있습니다.

하지만 현재는 몇 개의 새 알림이 도착했는지 전혀 알 수 없으며, 버튼을 계속 클릭하면 읽지 않은 많은 알림이 쌓일 수 있습니다. 어떤 알림이 읽었는지, 어떤 알림이 "새 것"인지 추적하는 로직을 추가해 보겠습니다. 이를 통해 내비게이션 바의 "알림" 탭에 "읽지 않음" 알림 수를 배지로 표시하고 새 알림을 다른 색상으로 표시할 수 있습니다.

알림 상태 추적

가짜 API가 전송하는 Notification 객체는 {id, date, message, user} 형태입니다. "새 것" 또는 "읽지 않음" 상태는 클라이언트에서만 존재합니다. 이를 고려하여 notificationsSlice를 수정해 보겠습니다.

먼저 ServerNotification을 확장하여 이 두 필드를 추가한 ClientNotification 타입을 생성합니다. 그런 다음 서버에서 새 알림 배치를 받을 때마다 항상 기본값으로 이러한 필드를 추가합니다.

다음으로 모든 알림을 읽음으로 표시하는 리듀서와 기존 알림을 "새 아님"으로 표시하는 로직을 추가합니다.

마지막으로 스토어에 읽지 않은 알림이 몇 개 있는지 세는 셀렉터도 추가할 수 있습니다:

features/notifications/notificationsSlice.ts
// omit imports

export interface ServerNotification {
id: string
date: string
message: string
user: string
}

export interface ClientNotification extends ServerNotification {
read: boolean
isNew: boolean
}

// omit thunk

const initialState: ClientNotification[] = []

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))

state.forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})

state.push(...notificationsWithMetadata)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}

알림 읽음 처리

<NotificationsList> 컴포넌트가 렌더링될 때마다(알림 탭을 클릭했거나 열린 상태에서 새 알림을 받은 경우) 알림을 읽음으로 표시하고 싶습니다. 컴포넌트가 다시 렌더링될 때마다 allNotificationsRead를 디스패치하여 구현할 수 있습니다. 업데이트 시 오래된 데이터가 깜빡이는 것을 방지하기 위해 useLayoutEffect 훅에서 액션을 디스패치합니다. 또한 페이지 내 알림 목록 항목에 추가 클래스명을 지정하여 강조 표시합니다:

features/notifications/NotificationsList.tsx
import { useLayoutEffect } from 'react'
import classnames from 'classnames'
import { useAppSelector, useAppDispatch } from '@/app/hooks'

import { TimeAgo } from '@/components/TimeAgo'

import { PostAuthor } from '@/features/posts/PostAuthor'

import {
allNotificationsRead,
selectAllNotifications
} from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useAppDispatch()
const notifications = useAppSelector(selectAllNotifications)

useLayoutEffect(() => {
dispatch(allNotificationsRead())
})

const renderedNotifications = notifications.map(notification => {
const notificationClassname = classnames('notification', {
new: notification.isNew
})

return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})

return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}

이 방법은 동작하지만 약간 놀라운 동작을 보입니다. 새 알림이 있을 때마다(이 탭으로 전환했거나 API에서 새 알림을 가져온 경우) 실제로 두 개의 "notifications/allNotificationsRead" 액션이 디스패치됩니다. 왜 그럴까요?

<PostsList>를 보고 있을 때 알림을 가져온 후 "Notifications" 탭을 클릭한다고 가정해 보겠습니다. <NotificationsList> 컴포넌트가 마운트되고 첫 렌더링 후 useLayoutEffect 콜백이 실행되어 allNotificationsRead를 디스패치합니다. notificationsSlice는 스토어의 알림 항목을 업데이트하여 처리합니다. 이로 인해 불변적으로 업데이트된 항목을 포함하는 새 state.notifications 배열이 생성되고, useSelector에서 반환된 새 배열을 인식한 컴포넌트가 다시 렌더링됩니다.

컴포넌트가 두 번째 렌더링될 때 useLayoutEffect 훅이 다시 실행되어 allNotificationsRead를 다시 디스패치합니다. 리듀서도 다시 실행되지만 이번에는 데이터 변경이 없으므로 슬라이스 상태와 루트 상태가 유지되고 컴포넌트는 다시 렌더링되지 않습니다.

컴포넌트 마운트 시 한 번만 디스패치하거나 알림 배열 크기가 변경될 때만 다시 디스패치하는 등 두 번째 디스패치를 피하는 몇 가지 방법이 있습니다. 하지만 실제로 해를 끼치지 않으므로 그대로 둘 수 있습니다.

이것은 액션을 디스패치했음에도 상태 변경이 전혀 발생하지 않을 수 있음을 보여줍니다. 기억하세요, 상태 업데이트가 필요한지 여부는 항상 리듀서가 결정하며, '아무 일도 일어나지 않음'은 리듀서가 내릴 수 있는 유효한 결정입니다.

"새 알림/읽음" 동작이 구현된 후 알림 탭의 모습입니다:

새 알림

읽지 않은 알림 표시

다음으로 진행하기 전에 마지막으로 해야 할 일은 내비게이션 바의 "Notifications" 탭에 배지를 추가하는 것입니다. 이 배지는 다른 탭에 있을 때 "읽지 않은" 알림의 수를 표시합니다:

app/Navbar.tsx
// omit other imports

import {
fetchNotifications,
selectUnreadNotificationsCount
} from '@/features/notifications/notificationsSlice'

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

const numUnreadNotifications = useAppSelector(selectUnreadNotificationsCount)


const isLoggedIn = !!user

let navContent: React.ReactNode = null

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

const fetchNewNotifications = () => {
dispatch(fetchNotifications())
}

let unreadNotificationsBadge: React.ReactNode | undefined

if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit button */}
</div>
)
}

// omit other rendering
}

렌더링 성능 개선

애플리케이션은 유용해 보이지만, 컴포넌트가 다시 렌더링되는 시기와 방식에 몇 가지 결함이 있습니다. 이 문제점들을 살펴보고 성능을 개선하는 방법에 대해 논의해 보겠습니다.

렌더링 동작 분석

상태 업데이트 시 어떤 컴포넌트가 다시 렌더링되는지 확인하기 위해 React DevTools Profiler를 사용할 수 있습니다. 특정 사용자의 <UserPage>로 이동해 보세요. 브라우저 개발자 도구를 열고 React "Profiler" 탭에서 상단 왼쪽의 원형 "Record" 버튼을 클릭합니다. 그런 다음 앱에서 "Refresh Notifications" 버튼을 클릭하고 React DevTools Profiler에서 기록을 중지합니다. 다음과 같은 차트가 표시됩니다:

React DevTools Profiler 렌더링 캡처 - &lt;UserPage&gt;

<Navbar>가 다시 렌더링된 것은 탭에서 업데이트된 "읽지 않은 알림" 배지를 표시해야 하기 때문에 이해할 수 있습니다. 하지만 <UserPage>는 왜 다시 렌더링되었을까요?

Redux DevTools에서 마지막으로 디스패치된 액션들을 살펴보면 알림 상태만 업데이트되었음을 확인할 수 있습니다. <UserPage>는 알림을 읽지 않으므로 다시 렌더링되지 않아야 합니다. 컴포넌트나 사용 중인 셀렉터에 문제가 있는 것 같습니다.

<UserPage>selectPostsByUser를 통해 스토어에서 게시물 목록을 읽습니다. selectPostsByUser를 자세히 살펴보면 특정 문제가 있습니다:

features/posts/postsSlice.ts
export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ WRONG - this _always_ creates a new array reference!
return allPosts.filter(post => post.user === userId)
}

액션이 디스패치될 때마다 useSelector가 다시 실행되며, 새로운 참조 값을 반환하면 컴포넌트가 다시 렌더링된다는 것을 알고 있습니다.

이 사용자에게 속한 게시물 목록만 반환하기 위해 셀렉터 함수 내부에서 filter()를 호출하고 있습니다.

안타깝게도, 이는 useSelector가 이 셀렉터에 대해 항상 새로운 배열 참조를 반환한다는 의미이며, 따라서 게시물 데이터가 변경되지 않았더라도 모든 액션 이후에 컴포넌트가 다시 렌더링된다는 뜻입니다!.

이는 Redux 애플리케이션에서 흔히 발생하는 실수입니다. 이 때문에 React-Redux는 개발 모드에서 실수로 항상 새로운 참조를 반환하는 셀렉터를 검사합니다. 브라우저 개발자 도구를 열고 콘솔로 이동하면 다음과 같은 경고 메시지를 볼 수 있습니다:

Selector unknown returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized:
at UserPage (http://localhost:5173/src/features/users/UserPage.tsx)

대부분의 경우 오류는 셀렉터의 실제 변수명을 알려줍니다. 이 경우에는 useAppSelector 내부에서 익명 함수를 사용하고 있기 때문에 오류 메시지에 셀렉터의 구체적인 이름이 없습니다. 하지만 <UserPage>에 있다는 점을 알면 범위를 좁힐 수 있습니다.

현실적으로 이 특정 예제 앱에서는 성능에 큰 문제가 되지 않습니다. <UserPage> 컴포넌트는 작고 앱에서 디스패치되는 액션도 많지 않기 때문입니다. 하지만 실제 애플리케이션에서는 매우 큰 성능 문제가 될 수 있으며, 그 영향은 앱 구조에 따라 다릅니다. 따라서 필요하지 않은 컴포넌트가 다시 렌더링되는 것은 흔한 성능 문제이며 우리가 해결해야 할 부분입니다.

셀렉터 함수 메모이징하기

우리에게 진정으로 필요한 것은 state.postsuserId가 변경된 경우에만 새 필터링된 배열을 계산하는 방법입니다. 변경되지 않았다면 지난번과 동일한 필터링된 배열 참조를 반환하고 싶습니다.

이 아이디어를 **"메모이제이션(memoization)"**이라고 합니다. 이전 입력값과 계산된 결과를 저장해두고, 입력값이 동일할 경우 다시 계산하지 않고 이전 결과를 반환하고자 합니다.

지금까지는 셀렉터를 평범한 함수로 직접 작성해왔으며, 주로 스토어에서 데이터를 읽는 코드를 복사하여 붙여넣지 않기 위해 사용했습니다. 성능을 개선할 수 있도록 셀렉터 함수를 메모이징하는 방법이 있다면 좋을 것입니다.

Reselect는 메모이징된 셀렉터 함수를 생성하기 위한 라이브러리로, Redux와 함께 사용하기 위해 특별히 설계되었습니다. Reselect에는 입력이 변경될 때만 결과를 재계산하는 메모이징된 셀렉터를 생성하는 createSelector 함수가 있습니다. Redux Toolkit은 createSelector 함수를 내보내기 때문에 이미 사용할 수 있습니다.

createSelector를 사용하여 selectPostsByUser를 메모이징된 함수로 다시 작성해봅시다:

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

// omit slice logic

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

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

export const selectPostsByUser = createSelector(
// Pass in one or more "input selectors"
[
// we can pass in an existing selector function that
// reads something from the root `state` and returns it
selectAllPosts,
// and another function that extracts one of the arguments
// and passes that onward
(state: RootState, userId: string) => userId
],
// the output function gets those values as its arguments,
// and will run when either input value changes
(posts, userId) => posts.filter(post => post.user === userId)
)

createSelector는 먼저 하나 이상의 "입력 셀렉터(input selector)" 함수가 필요합니다(단일 배열 안에 함께 넣거나 개별 인수로 전달 가능). 또한 결과를 계산하는 "출력 함수(output function)"를 전달해야 합니다.

selectPostsByUser(state, userId)를 호출하면 createSelector는 모든 인수를 각 입력 셀렉터에 전달합니다. 입력 셀렉터가 반환하는 값은 출력 셀렉터의 인수가 됩니다. (이전에 selectCurrentUser에서 비슷한 작업을 한 적이 있습니다: const currentUsername = selectCurrentUsername(state)를 먼저 호출했습니다.)

이 경우 출력 셀렉터에 필요한 두 인수는 모든 게시물의 배열과 사용자 ID입니다. 기존의 selectAllPosts 셀렉터를 재사용하여 게시물 배열을 추출할 수 있습니다. 사용자 ID는 selectPostsByUser에 전달하는 두 번째 인수이므로, 단순히 userId를 반환하는 작은 셀렉터를 작성할 수 있습니다.

그러면 출력 함수는 postsuserId를 인수로 받아 해당 사용자의 게시물만 필터링한 배열을 반환합니다.

selectPostsByUser를 여러 번 호출해도 postsuserId가 변경될 때만 출력 셀렉터가 재실행됩니다:

const state1 = getState()
// Output selector runs, because it's the first call
selectPostsByUser(state1, 'user1')
// Output selector does _not_ run, because the arguments haven't changed
selectPostsByUser(state1, 'user1')
// Output selector runs, because `userId` changed
selectPostsByUser(state1, 'user2')

dispatch(fetchUsers())
const state2 = getState()
// Output selector does not run, because `posts` and `userId` are the same
selectPostsByUser(state2, 'user2')

// Add some more posts
dispatch(addNewPost())
const state3 = getState()
// Output selector runs, because `posts` has changed
selectPostsByUser(state3, 'user2')

이제 selectPostsByUser를 메모이제이션했으므로 알림을 가져오는 동안 <UserPage>를 열어두고 React 프로파일러를 다시 실행해 볼 수 있습니다. 이번에는 <UserPage>가 다시 렌더링되지 않는 것을 확인할 수 있습니다:

React DevTools 프로파일러 최적화 렌더링 캡처 - &lt;UserPage&gt;

셀렉터 사용의 균형 맞추기

메모이제이션된 셀렉터는 React+Redux 애플리케이션의 성능 향상을 위한 가치 있는 도구입니다. 불필요한 다시 렌더링을 방지하고 입력 데이터가 변경되지 않았을 때 복잡하거나 비용이 많이 드는 계산을 수행하지 않도록 돕기 때문입니다.

주의: 모든 셀렉터가 메모이제이션될 필요는 없습니다! 우리가 작성한 다른 셀렉터들은 여전히 평범한 함수이며 잘 작동합니다. 셀렉터는 새로운 객체나 배열 참조를 생성하여 반환하거나 계산 로직이 "비용이 많이 드는" 경우에만 메모이제이션이 필요합니다.

예를 들어 selectUnreadNotificationsCount를 다시 살펴보겠습니다:

export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}

이 셀렉터는 내부에서 .filter() 호출을 수행하는 평범한 함수입니다. 그러나 새로운 배열 참조를 반환하는 것이 아니라 숫자를 반환한다는 점에 유의하세요. 이는 더 안전합니다. 알림 배열을 업데이트하더라도 실제 반환 값이 매번 변경되지는 않기 때문입니다.

물론 이 셀렉터가 실행될 때마다 알림 배열을 다시 필터링하는 것은 약간의 낭비입니다. 이를 메모이제이션된 셀렉터로 변환하는 것이 합리적이며 CPU 사이클을 약간 절약할 수 있습니다. 하지만 셀렉터가 매번 새로운 참조를 반환하는 경우만큼 절실히 필요한 것은 아닙니다.

정보

셀렉터 함수 사용 이유와 Reselect로 메모이제이션된 셀렉터 작성 방법에 대한 자세한 내용은 다음을 참조하세요:

게시물 목록 분석하기

<PostsList>로 돌아가서 게시물의 반응 버튼을 클릭하면서 React 프로파일러 추적을 캡처하면 <PostsList>와 업데이트된 <PostExcerpt> 인스턴스가 렌더링될 뿐만 아니라 모든 <PostExcerpt> 컴포넌트가 렌더링되는 것을 볼 수 있습니다:

React DevTools 프로파일러 렌더링 캡처 - &lt;PostsList&gt;

왜 그럴까요? 다른 게시물은 변경되지 않았는데 왜 다시 렌더링되어야 할까요?

React의 기본 동작은 부모 컴포넌트가 렌더링될 때 React가 그 안에 있는 모든 자식 컴포넌트를 재귀적으로 렌더링한다는 것입니다!. 하나의 게시물 객체를 불변하게 업데이트하면 새로운 posts 배열도 생성됩니다. <PostsList>posts 배열이 새로운 참조였기 때문에 다시 렌더링되어야 했고, 렌더링 후 React는 계속해서 아래로 내려가 모든 <PostExcerpt> 컴포넌트도 다시 렌더링했습니다.

이것은 우리의 작은 예제 앱에서는 심각한 문제가 아니지만, 더 큰 실제 앱에서는 매우 긴 목록이나 매우 큰 컴포넌트 트리가 있을 수 있으며, 모든 추가 컴포넌트를 다시 렌더링하면 속도가 느려질 수 있습니다.

목록 렌더링 최적화 옵션

<PostsList>에서 이 동작을 최적화하는 몇 가지 방법이 있습니다.

첫째, React.memo()<PostExcerpt> 컴포넌트를 감쌀 수 있습니다. 이렇게 하면 실제 props가 변경된 경우에만 내부 컴포넌트가 다시 렌더링됩니다. 이 방법은 실제로 매우 잘 작동합니다. 직접 시도해 보세요:

features/posts/PostsList.tsx
let PostExcerpt = ({ post }: PostExcerptProps) => {
// omit logic
}

PostExcerpt = React.memo(PostExcerpt)

또 다른 방법은 <PostsList>를 수정하여 전체 posts 배열 대신 게시글 ID 목록만 스토어에서 선택하도록 하고, <PostExcerpt>postId prop을 받아 useSelector를 호출해 필요한 게시글 객체를 읽도록 변경하는 것입니다. <PostsList>가 이전과 동일한 ID 목록을 받으면 리렌더링할 필요가 없어지므로, 변경된 하나의 <PostExcerpt> 컴포넌트만 렌더링되면 됩니다.

안타깝게도 이 방식은 모든 게시글이 날짜순으로 정렬되어 올바른 순서로 렌더링되어야 한다는 점에서 까다롭습니다. postsSlice를 업데이트하여 항상 정렬된 상태를 유지하도록 하고, 컴포넌트 내에서 정렬하지 않도록 하며, 메모이제드 셀렉터를 사용해 게시글 ID 목록만 추출할 수 있습니다. 또한 useSelector의 결과 비교 함수를 커스터마이즈하여 useSelector(selectPostIds, shallowEqual)처럼 사용하면 ID 배열의 내용이 변경되지 않았을 때 리렌더링을 건너뛸 수 있습니다.

마지막 방법은 리듀서가 모든 게시글에 대한 별도의 ID 배열을 유지하고 게시글이 추가/삭제될 때만 해당 배열을 수정하며, <PostsList><PostExcerpt>를 위에서 설명한 대로 수정하는 것입니다. 이렇게 하면 <PostsList>는 ID 배열이 변경될 때만 리렌더링하면 됩니다.

편리하게도 Redux Toolkit은 이를 돕는 createEntityAdapter 함수를 제공합니다.

데이터 정규화

지금까지 많은 로직이 ID 필드를 기반으로 항목을 조회하는 방식이었습니다. 데이터를 배열에 저장했기 때문에 array.find()를 사용해 원하는 ID를 가진 항목을 찾을 때까지 전체 배열을 순회해야 했습니다.

현실적으로 이 작업이 오래 걸리지는 않지만, 수백 또는 수천 개의 항목이 있는 배열에서 하나의 항목을 찾기 위해 전체 배열을 검색하는 것은 불필요한 작업입니다. 필요한 것은 다른 항목을 모두 확인하지 않고도 ID를 기반으로 단일 항목을 직접 조회할 수 있는 방법이며, 이 과정을 "정규화" 라고 합니다.

정규화된 상태 구조

"정규화된 상태" 란 다음을 의미합니다:

  • 특정 데이터의 복사본이 상태 내에 하나만 존재하므로 중복이 없습니다

  • 정규화된 데이터는 조회 테이블에 저장되며, 항목 ID가 키(key)가 되고 항목 자체가 값(value)이 됩니다. 이는 일반적으로 일반 자바스크립트 객체입니다.

  • 특정 항목 유형의 모든 ID 배열이 추가로 존재할 수 있습니다

자바스크립트 객체는 다른 언어의 "맵"이나 "딕셔너리"와 유사하게 조회 테이블로 사용될 수 있습니다. 다음은 user 객체 그룹에 대한 정규화된 상태 예시입니다:

{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}

이렇게 하면 배열의 다른 사용자 객체를 모두 순회하지 않고도 ID로 특정 user 객체를 쉽게 찾을 수 있습니다:

const userId = 'user2'
const userObject = state.users.entities[userId]
정보

정규화된 상태가 왜 유용한지에 대한 자세한 내용은 정규화된 상태 구조와 Redux Toolkit 사용 가이드의 정규화된 데이터 관리 섹션을 참조하세요.

createEntityAdapter로 정규화된 상태 관리하기

Redux Toolkit의 createEntityAdapter API는 항목 컬렉션을 가져와 { ids: [], entities: {} } 형태로 저장하는 표준화된 방법을 제공합니다. 이 미리 정의된 상태 형태와 함께, 해당 데이터로 작업하는 방법을 알고 있는 리듀서 함수와 셀렉터 세트를 생성합니다.

이것의 장점은 다음과 같습니다:

  • 정규화 관리를 위한 코드를 직접 작성할 필요가 없습니다

  • createEntityAdapter의 사전 정의된 리듀서 함수들은 "모든 항목 추가하기", "단일 항목 업데이트하기", "다중 항목 제거하기"와 같은 일반적인 케이스를 처리합니다

  • createEntityAdapter는 항목 내용을 기반으로 ID 배열을 정렬된 상태로 유지할 수 있으며, 항목이 추가/제거되거나 정렬 순서가 변경될 때만 배열을 업데이트합니다

createEntityAdaptersortComparer 함수를 포함할 수 있는 옵션 객체를 받습니다. 이 함수는 두 항목을 비교하여 항목 ID 배열을 정렬된 순서로 유지하는 데 사용되며(Array.sort()와 동일한 방식으로 작동)

이 함수는 엔티티 상태 객체에서 항목을 추가/업데이트/제거하기 위한 생성된 리듀서 함수 세트를 반환합니다. 이러한 리듀서 함수는 특정 액션 타입에 대한 케이스 리듀서로 사용되거나 createSlice 내 다른 리듀서에서 "변경 가능한" 유틸리티 함수로 사용될 수 있습니다

어댑터 객체에는 getSelectors 함수도 포함됩니다. Redux 루트 상태에서 해당 특정 슬라이스를 반환하는 셀렉터를 전달하면 selectAllselectById 같은 셀렉터들이 생성됩니다

마지막으로 어댑터 객체는 비어 있는 {ids: [], entities: {}} 객체를 생성하는 getInitialState 함수를 가집니다. getInitialState에 추가 필드를 전달하면 해당 필드들이 병합됩니다

Posts 슬라이스 정규화

이를 염두에 두고 postsSlicecreateEntityAdapter를 사용하도록 업데이트해 보겠습니다. 몇 가지 변경이 필요합니다

PostsState 구조가 변경됩니다. posts: Post[] 배열 대신 {ids: string[], entities: Record<string, Post>} 형태가 됩니다. Redux Toolkit에는 {ids, entities} 구조를 설명하는 EntityState 타입이 이미 있으므로 이를 가져와 PostsState의 기반으로 사용합니다. 여전히 statuserror 필드도 필요하므로 이들도 포함합니다

createEntityAdapter를 임포트하고, 올바른 Post 타입이 적용된 인스턴스를 생성하며, 올바른 순서로 게시물을 정렬하는 방법을 알고 있어야 합니다

features/posts/postsSlice.ts
import {
createEntityAdapter,
EntityState
// omit other imports
} from '@reduxjs/toolkit'

// omit thunks

interface PostsState extends EntityState<Post, string> {
status: 'idle' | 'pending' | 'succeeded' | 'rejected'
error: string | null
}

const postsAdapter = createEntityAdapter<Post>({
// Sort in descending date order
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const initialState: PostsState = postsAdapter.getInitialState({
status: 'idle',
error: null
})


// omit thunks

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload

const existingPost = state.entities[id]

if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers(builder) {
builder
// omit other cases
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Save the fetched posts into state
postsAdapter.setAll(state, action.payload)
})
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
}
})

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

export default postsSlice.reducer

// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors((state: RootState) => state.posts)

export const selectPostsByUser = createSelector(
[selectAllPosts, (state: RootState, userId: string) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)

많은 내용이 담겨 있습니다! 하나씩 살펴보겠습니다

먼저 createEntityAdapter를 임포트하고 호출하여 postsAdapter 객체를 생성합니다. 모든 게시물 ID 배열을 최신 게시물이 앞에 오도록 정렬된 상태로 유지하려면 post.date 필드를 기반으로 최신 항목을 앞으로 정렬하는 sortComparer 함수를 전달합니다

getInitialState()는 비어 있는 {ids: [], entities: {}} 정규화된 상태 객체를 반환합니다. postsSlice는 로딩 상태를 위해 statuserror 필드도 유지해야 하므로 getInitialState()에 이들을 전달합니다

이제 게시물들이 state.entities의 조회 테이블로 유지되므로 reactionAddedpostUpdated 리듀서는 이전 posts 배열을 반복할 필요 없이 state.entities[postId]를 통해 ID별로 올바른 게시물을 직접 조회하도록 변경할 수 있습니다

fetchPosts.fulfilled 액션을 수신하면 postsAdapter.setAll 함수를 사용하여 드래프트 stateaction.payload의 게시물 배열을 전달함으로써 모든 수신 게시물을 상태에 추가할 수 있습니다. 이는 createSlice 리듀서 내에서 어댑터 메서드를 "변경 가능한" 헬퍼 함수로 사용하는 예시입니다

addNewPost.fulfilled 액션을 수신하면 하나의 새 게시물 객체를 상태에 추가해야 함을 알고 있습니다. 어댑터 함수를 리듀서로 직접 사용할 수 있으므로 해당 액션을 처리하기 위한 리듀서 함수로 postsAdapter.addOne을 전달합니다. 이 경우 어댑터 메서드를 해당 액션의 실제 리듀서로 사용합니다

마지막으로, 이제 postsAdapter.getSelectors로 생성된 셀렉터 함수로 기존에 수동으로 작성했던 selectAllPostsselectPostById 셀렉터 함수를 대체할 수 있습니다. 셀렉터는 루트 Redux 상태 객체로 호출되므로, Redux 상태에서 게시물 데이터를 찾을 위치를 알려주는 작은 셀렉터인 state.posts를 반환하는 함수를 전달합니다. 생성된 셀렉터 함수는 항상 selectAllselectById로 명명되므로, 내보낼 때 구조 분해 할당을 사용해 기존 셀렉터 이름과 일치하도록 이름을 변경할 수 있습니다. 또한 <PostsList> 컴포넌트에서 정렬된 게시물 ID 목록을 읽어야 하므로 동일한 방식으로 selectPostIds도 내보냅니다.

postUpdated 리듀서를 postsAdapter.updateOne 메서드를 사용하도록 변경하면 몇 줄을 더 줄일 수 있습니다. 이 메서드는 {id, changes} 형태의 객체를 받으며, changes는 덮어쓸 필드가 포함된 객체입니다:

features/posts/postsSlice.ts
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
postsAdapter.updateOne(state, { id, changes: { title, content } })
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
// omit `extraReducers`
})

reactionAdded 리듀서는 조금 더 복잡하므로 postsAdapter.updateOne을 완전히 사용할 수 없습니다. 게시물 객체의 필드를 단순히 _교체_하는 대신, 내부 필드에 중첩된 카운터를 증가시켜야 합니다. 이런 경우에는 지금까지 해왔듯이 객체를 조회하고 "변이(mutating)" 업데이트를 수행하는 것이 적절합니다.

게시물 목록 최적화

이제 게시물 슬라이스가 createEntityAdapter를 사용하므로 <PostsList>의 렌더링 동작을 최적화할 수 있습니다.

정렬된 게시물 ID 배열만 읽고 각 <PostExcerpt>postId를 전달하도록 <PostsList>를 업데이트합니다:

features/posts/PostsList.tsx
// omit other imports

import {
fetchPosts,
selectPostById,
selectPostIds,
selectPostsStatus,
selectPostsError
} from './postsSlice'

interface PostExcerptProps {
postId: string
}

function PostExcerpt({ postId }: PostExcerptProps) {
const post = useAppSelector(state => selectPostById(state, postId))
// omit rendering logic
}

export const PostsList = () => {
const dispatch = useAppDispatch()
const orderedPostIds = useAppSelector(selectPostIds)

// omit other selections and effects

if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}

// omit other rendering
}

이제 React 컴포넌트 성능 프로파일을 캡처한 상태에서 게시물의 반응 버튼을 클릭하면 해당 컴포넌트만 다시 렌더링되는 것을 확인할 수 있습니다:

최적화된 &lt;PostsList&gt;의 React DevTools 프로파일러 렌더링 캡처

사용자 슬라이스 정규화

다른 슬라이스도 createEntityAdapter를 사용하도록 변환할 수 있습니다.

usersSlice는 상대적으로 작으므로 몇 가지 사항만 변경하면 됩니다:

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

import { client } from '@/api/client'
import { createAppAsyncThunk } from '@/app/withTypes'

const usersAdapter = createEntityAdapter<User>()

const initialState = usersAdapter.getInitialState()

export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.users
})

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll)
}
})

export default usersSlice.reducer

export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors((state: RootState) => state.users)

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

여기서 처리하는 유일한 액션은 항상 전체 사용자 목록을 서버에서 가져온 배열로 교체합니다. 이를 구현하기 위해 usersAdapter.setAll을 사용할 수 있습니다.

기존에 수동으로 작성한 selectAllUsersselectUserById 셀렉터를 이미 내보내고 있었습니다. 이를 usersAdapter.getSelectors()로 생성된 버전으로 대체할 수 있습니다.

이제 selectUserById와 약간의 타입 불일치가 발생합니다. 타입에 따르면 currentUsernamenull일 수 있지만, 생성된 selectUserById는 이를 허용하지 않습니다. 간단한 해결 방법은 값이 존재하는지 확인하고 없을 경우 조기에 반환 처리하는 것입니다.

알림 슬라이스 정규화

마지막으로 notificationsSlice도 업데이트합니다:

features/notifications/notificationsSlice.ts
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

import { client } from '@/api/client'

// omit types and fetchNotifications thunk

const notificationsAdapter = createEntityAdapter<ClientNotification>({
// Sort with newest first
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const initialState = notificationsAdapter.getInitialState()

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))

Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})

notificationsAdapter.upsertMany(state, notificationsWithMetadata)
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const { selectAll: selectAllNotifications } =
notificationsAdapter.getSelectors((state: RootState) => state.notifications)

export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}

다시 createEntityAdapter를 임포트하고 호출한 후 슬라이스 설정을 돕기 위해 notificationsAdapter.getInitialState()를 호출합니다.

모든 알림 객체를 순회하며 업데이트해야 하는 부분이 몇 군데 있습니다. 이제 알림이 배열로 유지되지 않으므로 Object.values(state.entities)를 사용해 알림 배열을 가져온 후 순회해야 합니다. 반면 기존 패치 업데이트 로직은 notificationsAdapter.upsertMany로 대체할 수 있습니다.

반응형 로직 작성

지금까지 애플리케이션 동작은 모두 명령형(imperative) 방식이었습니다. 사용자가 무언가를 하면(게시물 추가, 알림 가져오기) 클릭 핸들러나 컴포넌트의 useEffect 훅에서 액션을 디스패치했습니다. 여기에는 fetchPostslogin과 같은 데이터 패칭 썽크도 포함됩니다.

그러나 때로는 특정 액션이 디스패치되는 등 애플리케이션에서 발생한 사건에 반응해 실행되는 추가 로직을 작성해야 합니다.

게시물을 불러오는 경우와 같은 로딩 표시기를 보여준 바 있습니다. 사용자가 새 게시물을 추가할 때 토스트 메시지를 표시하는 것과 같은 시각적 확인 수단이 있으면 좋을 것입니다.

이미 여러 리듀서가 동일한 디스패치된 액션에 응답할 수 있다는 것을 보았습니다. 이는 단순히 '상태의 여러 부분을 업데이트'하는 로직에는 잘 작동하지만, 비동기 로직이나 다른 부수 효과(side effect)가 필요한 경우는 어떨까요? 리듀서에는 이러한 로직을 넣을 수 없습니다. 리듀서는 '순수'해야 하며 어떠한 부수 효과도 가져서는 안 됩니다.

부수 효과가 있는 이 로직을 리듀서에 넣을 수 없다면, 어디에 넣어야 할까요?

정답은 Redux 미들웨어 안에 있습니다. 미들웨어는 부수 효과를 가능하게 하도록 설계되었기 때문입니다.

createListenerMiddleware를 사용한 반응형 로직

'지금 당장' 실행되어야 하는 비동기 로직에는 이미 thunk 미들웨어를 사용해 왔습니다. 하지만 thunk는 단순한 함수입니다. '특정 액션이 디스패치되면 이 추가 로직을 응답으로 실행하라'고 말할 수 있는 다른 종류의 미들웨어가 필요합니다.

Redux Toolkit에는 디스패치된 특정 액션에 응답하여 실행되는 로직을 작성할 수 있도록 createListenerMiddleware API가 포함되어 있습니다. 이 API를 사용하면 어떤 액션을 찾을지 정의하는 '리스너' 항목을 추가할 수 있으며, 액션과 일치할 때마다 실행되는 effect 콜백을 갖게 됩니다.

개념적으로 createListenerMiddlewareReact의 useEffect과 유사하다고 볼 수 있습니다. 다만, React 컴포넌트 내부가 아닌 Redux 로직의 일부로 정의되며, React의 렌더링 생명주기의 일부가 아닌 디스패치된 액션과 Redux 상태 업데이트에 응답하여 실행된다는 점이 다릅니다.

리스너 미들웨어 설정하기

Redux Toolkit의 configureStore가 스토어 설정에 thunk 미들웨어를 자동으로 추가하기 때문에 thunk 미들웨어를 별도로 설정하거나 정의할 필요가 없었습니다. 리스너 미들웨어의 경우, 이를 생성하고 스토어에 추가하기 위해 약간의 설정 작업이 필요합니다.

새로운 app/listenerMiddleware.ts 파일을 생성하고 여기에 리스너 미들웨어 인스턴스를 만듭니다. createAsyncThunk와 유사하게, 상태 필드에 안전하게 접근하고 액션을 디스패치할 수 있도록 올바른 dispatchstate 타입을 전달할 것입니다.

app/listenerMiddleware.ts
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'

export const listenerMiddleware = createListenerMiddleware()

export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening

export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener

createSlice와 마찬가지로, createListenerMiddleware는 여러 필드를 포함하는 객체를 반환합니다:

  • listenerMiddleware.middleware: 스토어에 추가해야 할 실제 Redux 미들웨어 인스턴스

  • listenerMiddleware.startListening: 미들웨어에 직접 새로운 리스너 항목을 추가합니다

  • listenerMiddleware.addListener: listenerMiddleware 객체를 가져오지 않았더라도, dispatch에 접근할 수 있는 코드베이스의 어디에서나 리스너 항목을 추가하기 위해 디스패치할 수 있는 액션 생성자

비동기 thunk와 훅과 마찬가지로, .withTypes() 메서드를 사용하여 미리 타입이 지정된 startAppListeningaddAppListener 함수를 올바른 타입으로 정의할 수 있습니다.

그런 다음, 이를 스토어에 추가해야 합니다:

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'
import notificationsReducer from '@/features/notifications/notificationsSlice'

import { listenerMiddleware } from './listenerMiddleware'

export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer,
notifications: notificationsReducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})

configureStore는 기본적으로 redux-thunk 미들웨어를 스토어 설정에 추가하며, 개발 시에는 안전 검사를 추가하는 몇 가지 미들웨어도 함께 추가합니다. 우리는 이를 유지하면서 리스너 미들웨어도 추가하고자 합니다.

미들웨어 설정 시 순서가 중요할 수 있습니다. 이는 파이프라인을 형성하기 때문입니다: m1 -> m2 -> m3 -> store.dispatch(). 이 경우, 리스너 미들웨어는 일부 액션을 먼저 가로채서 처리할 수 있도록 파이프라인의 시작 부분에 위치해야 합니다.

getDefaultMiddleware()는 설정된 미들웨어 배열을 반환합니다. 배열이므로 이미 배열 끝에 새 항목을 추가하는 .concat() 메서드를 가지고 있지만, configureStore는 배열 시작 부분에 새 항목을 추가하는 동등한 .prepend() 메서드도 제공합니다.

따라서 getDefaultMiddleware().prepend(listenerMiddleware.middleware)를 호출하여 목록 앞에 추가합니다.

새 게시물 토스트 표시

이제 리스너 미들웨어가 설정되었으므로, 새 게시물이 성공적으로 추가될 때마다 토스트 메시지를 표시하는 새 리스너 항목을 추가할 수 있습니다.

적절한 모양으로 토스트를 표시하기 위해 react-tiny-toast 라이브러리를 사용합니다. 이미 프로젝트 저장소에 포함되어 있으므로 설치할 필요가 없습니다.

<App>에서 <ToastContainer> 컴포넌트를 가져와 렌더링해야 합니다:

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

// omit other imports and ProtectedRoute definition

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>{/* omit routes content */}</Routes>
<ToastContainer />
</div>
</Router>
)
}

이제 addNewPost.fulfilled 액션을 감시하는 리스너를 추가하여 "게시물이 추가되었습니다"라는 토스트를 표시하고 일정 시간 후 제거할 수 있습니다.

코드베이스에서 리스너를 정의하는 여러 가지 방법이 있습니다. 일반적으로 추가하려는 로직과 가장 관련이 있는 슬라이스 파일에 리스너를 정의하는 것이 좋습니다. 이 경우 게시물이 추가될 때 토스트를 표시하려고 하므로 postsSlice 파일에 이 리스너를 추가해 보겠습니다:

features/posts/postsSlice.ts
import {
createEntityAdapter,
createSelector,
createSlice,
EntityState,
PayloadAction
} from '@reduxjs/toolkit'
import { client } from '@/api/client'

import type { RootState } from '@/app/store'
import { AppStartListening } from '@/app/listenerMiddleware'
import { createAppAsyncThunk } from '@/app/withTypes'

// omit types, initial state, slice definition, and selectors

export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error

export const addPostsListeners = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: addNewPost.fulfilled,
effect: async (action, listenerApi) => {
const { toast } = await import('react-tiny-toast')

const toastId = toast.show('New post added!', {
variant: 'success',
position: 'bottom-right',
pause: true
})

await listenerApi.delay(5000)
toast.remove(toastId)
}
})
}

리스너를 추가하려면 app/listenerMiddleware.ts에 정의된 startAppListening 함수를 호출해야 합니다. 하지만 슬라이스 파일에 직접 startAppListening을 가져오는 것보다는 import 체인을 일관되게 유지하기 위해 startAppListening을 인자로 받는 함수를 내보내는 것이 좋습니다. 이렇게 하면 app/listenerMiddleware.ts 파일이 app/store.ts 파일이 각 슬라이스 파일에서 리듀서를 가져오는 방식과 유사하게 이 함수를 가져올 수 있습니다.

리스너 항목을 추가하려면 startAppListening을 호출하고 effect 콜백 함수를 포함한 객체를 전달합니다. 이 객체는 효과 콜백 실행 시점을 정의하기 위해 다음 옵션 중 하나를 포함해야 합니다:

  • actionCreator: ActionCreator: reactionAdded 또는 addNewPost.fulfilled 같은 RTK 액션 생성자 함수. 특정 액션이 디스패치될 때 효과를 실행합니다.

  • matcher: (action: UnknownAction) => boolean: isAnyOf(reactionAdded, addNewPost.fulfilled) 같은 RTK "매처" 함수. 매처가 true를 반환할 때마다 효과를 실행합니다.

  • predicate: (action: UnknownAction, currState: RootState, prevState: RootState) => boolean: currStateprevState에 접근할 수 있는 일반 매칭 함수. 액션이나 상태 값에 대해 원하는 검사를 수행할 수 있으며, 상태 조각이 변경되었는지 확인하는 데 사용할 수 있습니다(예: currState.counter.value !== prevState.counter.value)

이 경우 addNewPost 썽크가 성공할 때마다 토스트를 표시하려 하므로 actionCreator: addNewPost.fulfilled로 효과가 실행되도록 지정합니다.

effect 콜백 자체는 비동기 썽크와 유사합니다. 첫 번째 인자로 일치한 action을 받고 두 번째 인자로 listenerApi 객체를 받습니다.

listenerApi에는 일반적인 dispatchgetState 메서드 외에도 복잡한 비동기 로직과 워크플로를 구현하는 데 사용할 수 있는 여러 다른 함수가 포함됩니다. 여기에는 다른 액션이 디스패치되거나 상태 값이 변경될 때까지 일시 중지하는 condition(), 이 리스너 항목의 활성 상태를 변경하는 unsubscribe()/subscribe(), 자식 작업을 시작하는 fork() 등이 포함됩니다.

이 경우에는 실제 react-tiny-toast 라이브러리를 동적으로 가져와 성공 토스트를 표시하고, 몇 초간 대기한 후 토스트를 제거합니다.

마지막으로 addPostsListeners를 실제로 가져와 호출해야 합니다. 여기서는 app/listenerMiddleware.ts 파일로 가져옵니다:

app/listenerMiddleware.ts
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'

import { addPostsListeners } from '@/features/posts/postsSlice'

export const listenerMiddleware = createListenerMiddleware()

export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening

export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener

// Call this and pass in `startAppListening` to let the
// posts slice set up its listeners
addPostsListeners(startAppListening)

이제 새 게시물을 추가하면 페이지 오른쪽 하단에 작은 초록색 토스트가 나타나고 5초 후 사라집니다. 이는 리덕스 스토어의 리스너 미들웨어가 액션 디스패치 후 효과 콜백을 확인하고 실행하기 때문에 작동하며, React 컴포넌트 자체에 추가 로직을 더 넣지 않아도 됩니다.

학습 내용 요약

이번 섹션에서 많은 새로운 기능을 구현했습니다. 이 모든 변경 사항을 반영한 앱의 모습을 살펴보겠습니다:

이번 섹션에서 다룬 내용은 다음과 같습니다:

요약
  • 메모이제이션된 셀렉터 함수로 성능 최적화 가능
    • Redux Toolkit은 Reselect의 createSelector 함수를 재익스포트하여 메모이제이션된 셀렉터 생성
    • 메모이제이션된 셀렉터는 입력 셀렉터가 새 값을 반환할 때만 결과 재계산
    • 메모이제이션으로 고비용 계산 생략 가능, 동일 결과 참조값 반환 보장
  • 리덕스와 React 컴포넌트 렌더링 최적화를 위한 다양한 패턴
    • useSelector 내부에서 새 객체/배열 참조 생성 방지(불필요한 리렌더링 유발)
    • useSelector에 메모이제이션된 셀렉터 함수 전달로 렌더링 최적화
    • useSelector가 참조 동등성 대신 shallowEqual 같은 대체 비교 함수 수락 가능
    • React.memo()로 컴포넌트 감싸서 props 변경 시에만 리렌더링
    • 리스트 부모 컴포넌트가 항목 ID 배열만 읽고 ID를 자식 항목에 전달, 자식에서 ID로 항목 조회하는 방식으로 리스트 렌더링 최적화
  • 정규화된 상태 구조로 항목 저장 권장
    • "정규화"는 데이터 중복 없이 항목 ID별 조회 테이블 유지 의미
    • 정규화된 상태 형태는 일반적으로 {ids: [], entities: {}} 형태
  • Redux Toolkit의 createEntityAdapter API로 슬라이스 내 정규화 데이터 관리
    • sortComparer 옵션으로 항목 ID 정렬 순서 유지 가능
    • 어댑터 객체 포함 요소:
      • 추가 상태 필드(로딩 상태 등) 수락 가능한 adapter.getInitialState
      • setAll, addMany, upsertOne, removeMany 같은 일반적 케이스용 사전 제작 리듀서
      • selectAll, selectById 같은 셀렉터 생성하는 adapter.getSelectors
  • Redux Toolkit의 createListenerMiddleware API로 디스패치된 액션에 반응하는 논리 실행
    • 올바른 스토어 타입이 부착된 리스너 미들웨어를 스토어 설정에 추가
    • 리스너는 일반적으로 슬라이스 파일에 정의되나 다른 구조도 가능
    • 리스너는 개별 액션, 다수 액션 매칭 또는 커스텀 비교 사용 가능
    • 리스너 효과 콜백에 동기/비동기 논리 모두 포함 가능
    • listenerApi 객체가 비동기 워크플로 및 동작 관리용 다양한 메서드 제공

다음 단계

Redux Toolkit에는 "RTK Query"라는 강력한 데이터 페칭 및 캐싱 API도 포함되어 있습니다. RTK Query는 선택적 애드온으로, 직접 데이터 페칭 로직을 작성할 필요를 완전히 제거해 줍니다. Part 7: RTK Query 기본에서는 RTK Query의 정의, 해결 과제, 그리고 애플리케이션에서 캐시된 데이터를 페칭하고 사용하는 방법을 배웁니다.