Redux Fundamentals, Part 6: 비동기 로직과 데이터 가져오기
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
- 비동기 데이터와 함께 작동하는 Redux 데이터 흐름 방식
- 비동기 로직을 위한 Redux 미들웨어 사용법
- 비동기 요청 상태 처리 패턴
- 서버에서 데이터를 가져오고 업데이트하기 위한 HTTP 요청 사용 경험
- Promises를 포함한 JS 비동기 로직 이해
소개
Part 5: UI와 React에서는 React 컴포넌트가 Redux 스토어와 상호작용할 수 있도록 React-Redux 라이브러리를 사용하는 방법을 살펴보았습니다. 여기에는 Redux 상태를 읽기 위한 useSelector 호출, dispatch 함수 접근을 위한 useDispatch 호출, 그리고 해당 훅이 스토어에 접근할 수 있도록 앱을 <Provider> 컴포넌트로 감싸는 작업이 포함됩니다.
지금까지 우리가 다룬 모든 데이터는 React+Redux 클라이언트 애플리케이션 내부에 직접 존재했습니다. 그러나 대부분의 실제 애플리케이션은 HTTP API 호출을 통해 서버에서 항목을 가져오고 저장해야 합니다.
이번 섹션에서는 할 일 앱을 업데이트하여 API에서 할 일 목록을 가져오고, 새로운 할 일을 API에 저장하여 추가하도록 하겠습니다.
이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →
참고: 이 튜토리얼은 Redux의 원리와 개념을 설명하기 위해 의도적으로 오래된 스타일의 Redux 로직 패턴을 보여줍니다. 이 패턴은 오늘날 우리가 Redux 앱 구축을 위한 올바른 접근법으로 가르치는 '현대적인 Redux(modern Redux)' 패턴(Redux Toolkit 사용)보다 더 많은 코드가 필요합니다. 이 튜토리얼은 프로덕션 환경에서 바로 사용할 수 있는 프로젝트가 아닙니다.
'현대적인 Redux'와 Redux Toolkit 사용법을 배우려면 다음 페이지를 참조하세요:
- 전체 "Redux Essentials" 튜토리얼: 실제 애플리케이션을 위한 Redux Toolkit을 사용한 "올바른 Redux 사용법"을 가르칩니다. 모든 Redux 학습자는 'Essentials' 튜토리얼을 필독할 것을 권장합니다!
- Redux Fundamentals, Part 8: Redux Toolkit을 사용한 현대적인 Redux: 이전 섹션의 저수준 예제를 현대적인 Redux Toolkit 방식으로 변환하는 방법을 보여줍니다
Redux Toolkit에는 RTK Query 데이터 가져오기 및 캐싱 API가 포함되어 있습니다. RTK Query는 Redux 앱을 위해 특별히 제작된 데이터 가져오기 및 캐싱 솔루션으로, 데이터 가져오기를 관리하기 위해 어떤 썽크(thunk)나 리듀서도 작성할 필요가 없습니다. 우리는 데이터 가져오기에 대한 기본 접근 방식으로 RTK Query를 특별히 가르치며, RTK Query는 이 페이지에서 보여준 패턴 위에 구축되었습니다.
데이터 가져오기에 RTK Query를 사용하는 방법은 Redux Essentials, Part 7: RTK Query 기본에서 배워보세요.
예제 REST API 및 클라이언트
예제 프로젝트를 격리되면서도 현실적으로 유지하기 위해, 초기 프로젝트 설정에는 이미 가짜 메모리 내 REST API가 포함되어 있습니다(Mirage.js 모의 API 도구로 구성됨). 이 API는 엔드포인트의 기본 URL로 /fakeApi를 사용하며, /fakeApi/todos에 대해 일반적인 GET/POST/PUT/DELETE HTTP 메서드를 지원합니다. 이는 src/api/server.js에 정의되어 있습니다.
또한 프로젝트에는 인기 있는 HTTP 라이브러리(axios 등)와 유사하게 client.get() 및 client.post() 메서드를 노출하는 소형 HTTP API 클라이언트 객체가 포함되어 있습니다. 이는 src/api/client.js에 정의되어 있습니다.
이 섹션에서는 이 client 객체를 사용해 메모리 내 가짜 REST API에 HTTP 호출을 할 것입니다.
Redux 미들웨어와 사이드 이펙트
Redux 스토어 자체는 비동기 로직에 대해 아무것도 알지 못합니다. 동기적으로 액션을 디스패치하고, 루트 리듀서 함수를 호출하여 상태를 업데이트하며, UI에 변경 사항을 알리는 방법만 알고 있습니다. 모든 비동기 작업은 스토어 외부에서 발생해야 합니다.
앞서 우리는 Redux 리듀서가 절대 "사이드 이펙트"를 포함해서는 안 된다고 말했습니다. "사이드 이펙트"란 함수에서 값을 반환하는 것 외부에서 관찰 가능한 상태 또는 동작의 변경을 의미합니다. 일반적인 사이드 이펙트 유형은 다음과 같습니다:
-
콘솔에 값 기록하기
-
파일 저장하기
-
비동기 타이머 설정하기
-
HTTP 요청 만들기
-
함수 외부에 존재하는 상태 수정하거나 함수 인자 변형하기
-
난수 또는 고유 ID 생성하기(예:
Math.random()또는Date.now())
그러나 실제 애플리케이션은 이런 작업들을 어딘가에서 반드시 수행해야 합니다. 그렇다면 리듀서에 사이드 이펙트를 넣을 수 없다면, 어디에 넣어야 할까요?
Redux 미들웨어는 사이드 이펙트가 있는 로직 작성을 가능하게 설계되었습니다.
Part 4에서 언급했듯이, Redux 미들웨어는 디스패치된 액션을 처리할 때 _다양한 작업_을 수행할 수 있습니다: 로깅, 액션 수정, 액션 지연, 비동기 호출 등. 또한 미들웨어는 실제 store.dispatch 함수 주위에 파이프라인을 형성하므로, 미들웨어가 해당 값을 가로채 리듀서에 도달하지 못하게 하는 한, 평범한 액션 객체가 아닌 것을 dispatch에 전달할 수도 있습니다.
미들웨어는 dispatch와 getState에도 접근할 수 있습니다. 이는 미들웨어 내부에 비동기 로직을 작성하면서도 액션을 디스패치해 Redux 스토어와 상호작용할 수 있음을 의미합니다.
미들웨어를 통한 비동기 로직 활성화
미들웨어가 어떻게 Redux 스토어와 상호작용하는 비동기 로직 작성을 가능하게 하는지 몇 가지 예를 살펴보겠습니다.
한 가지 방법은 특정 액션 타입을 감지하고 해당 액션이 발생할 때 비동기 로직을 실행하는 미들웨어를 작성하는 것입니다. 예를 들면 다음과 같습니다:
import { client } from '../api/client'
const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}
return next(action)
}
const fetchTodosMiddleware = storeAPI => next => action => {
if (action.type === 'todos/fetchTodos') {
// Make an API call to fetch todos from the server
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
storeAPI.dispatch({ type: 'todos/todosLoaded', payload: todos })
})
}
return next(action)
}
Redux가 비동기 로직에 미들웨어를 사용하는 이유와 방법에 대한 자세한 내용은 Redux 창시자 Dan Abramov의 다음 StackOverflow 답변을 참고하세요:
비동기 함수 미들웨어 작성하기
이전 섹션의 두 미들웨어는 매우 특수화되어 단일 기능만 수행했습니다. 미들웨어 자체와 분리되어 미리 어떤 비동기 로직이든 작성할 수 있는 방법이 있다면 좋을 것입니다. 여전히 dispatch와 getState에 접근해 스토어와 상호작용할 수 있으면서 말이죠.
액션 객체 대신 dispatch에 _함수_를 전달할 수 있는 미들웨어를 작성한다면 어떨까요? 미들웨어가 "액션"이 실제로 함수인지 확인하고, 함수라면 즉시 호출하도록 할 수 있습니다. 이렇게 하면 미들웨어 정의 외부에서 별도의 함수로 비동기 로직을 작성할 수 있습니다.
이러한 미들웨어는 다음과 같은 모습일 수 있습니다:
const asyncFunctionMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(storeAPI.dispatch, storeAPI.getState)
}
// Otherwise, it's a normal action - send it onwards
return next(action)
}
그리고 이 미들웨어를 다음과 같이 사용할 수 있습니다:
const middlewareEnhancer = applyMiddleware(asyncFunctionMiddleware)
const store = createStore(rootReducer, middlewareEnhancer)
// Write a function that has `dispatch` and `getState` as arguments
const fetchSomeData = (dispatch, getState) => {
// Make an async HTTP request
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
dispatch({ type: 'todos/todosLoaded', payload: todos })
// Check the updated store state after dispatching
const allTodos = getState().todos
console.log('Number of todos after loading: ', allTodos.length)
})
}
// Pass the _function_ we wrote to `dispatch`
store.dispatch(fetchSomeData)
// logs: 'Number of todos after loading: ###'
다시 강조하자면, 이 "비동기 함수 미들웨어"는 dispatch에 _함수_를 전달할 수 있게 해줍니다! 함수 내부에서는 비동기 로직(HTTP 요청 등)을 작성할 수 있으며, 요청 완료 시 일반 액션 객체를 디스패치할 수 있습니다.
Redux 비동기 데이터 흐름
미들웨어와 비동기 로직이 Redux 애플리케이션의 전체 데이터 흐름에 어떤 영향을 미칠까요?
일반 액션과 마찬가지로, 먼저 버튼 클릭 같은 사용자 이벤트를 처리합니다. 그런 다음 dispatch()를 호출해 평범한 액션 객체, 함수, 혹은 미들웨어가 인식할 수 있는 다른 값을 전달합니다.
디스패치된 값이 미들웨어에 도달하면, 미들웨어는 비동기 호출을 수행할 수 있으며 비동기 호출 완료 시 실제 액션 객체를 디스패치할 수 있습니다.
이전에 정상적인 동기식 Redux 데이터 흐름 다이어그램을 보셨을 겁니다. Redux 애플리케이션에 비동기 로직을 추가하면 미들웨어가 HTTP 요청 같은 로직을 실행한 후 액션을 디스패치할 수 있는 추가 단계가 생깁니다. 이로 인해 비동기 데이터 흐름은 다음과 같이 변합니다:

Redux Thunk 미들웨어 사용하기
사실 Redux에는 이미 이러한 "비동기 함수 미들웨어"의 공식 버전인 Redux "Thunk" 미들웨어가 존재합니다. Thunk 미들웨어는 dispatch와 getState를 인자로 받는 함수 작성이 가능하게 합니다. Thunk 함수 내부에는 원하는 어떤 비동기 로직도 포함될 수 있으며, 이 로직은 필요에 따라 액션을 디스패치하고 스토어 상태를 읽을 수 있습니다.
비동기 로직을 썽크(thunk) 함수로 작성하면 어떤 Redux 스토어를 사용할지 미리 알지 못해도 해당 로직을 재사용할 수 있습니다.
"썽크(thunk)"는 프로그래밍 용어로 "지연된 작업을 수행하는 코드 조각"을 의미합니다. 썽크 사용 방법에 대한 자세한 내용은 다음 가이드 페이지를 참조하세요:
또한 다음 글도 참고할 수 있습니다:
스토어 구성하기
Redux 썽크 미들웨어는 redux-thunk라는 패키지로 NPM에서 제공됩니다. 앱에서 사용하려면 이 패키지를 설치해야 합니다:
npm install redux-thunk
설치가 완료되면 할 일 앱의 Redux 스토어를 업데이트하여 해당 미들웨어를 사용할 수 있습니다:
import { createStore, applyMiddleware } from 'redux'
import { thunk } from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'
const composedEnhancer = composeWithDevTools(applyMiddleware(thunk))
// The store now has the ability to accept thunk functions in `dispatch`
const store = createStore(rootReducer, composedEnhancer)
export default store
서버에서 할 일 목록 가져오기
현재 할 일 항목은 클라이언트 브라우저에만 존재할 수 있습니다. 앱이 시작될 때 서버에서 할 일 목록을 불러오는 방법이 필요합니다.
먼저 /fakeApi/todos 엔드포인트에 HTTP 호출을 보내 할 일 객체 배열을 요청한 다음, 해당 배열을 페이로드로 포함한 액션을 디스패치하는 썽크 함수를 작성하겠습니다. 이는 할 일 기능 전반과 관련되므로 todosSlice.js 파일에 썽크 함수를 작성합니다:
import { client } from '../../api/client'
const initialState = []
export default function todosReducer(state = initialState, action) {
// omit reducer logic
}
// Thunk function
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}
이 API 호출은 애플리케이션이 처음 로드될 때 한 번만 수행하려고 합니다. 이 로직을 배치할 수 있는 몇 가지 위치가 있습니다:
-
<App>컴포넌트의useEffect훅 내부 -
<TodoList>컴포넌트의useEffect훅 내부 -
스토어를 임포트한 직후의
index.js파일 내부
일단 index.js에 직접 배치해 보겠습니다:
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import './index.css'
import App from './App'
import './api/server'
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'
store.dispatch(fetchTodos)
const root = createRoot(document.getElementById('root'))
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
페이지를 새로고침하면 UI에 눈에 띄는 변화는 없습니다. 그러나 Redux DevTools 확장 프로그램을 열면 'todos/todosLoaded' 액션이 디스패치된 것을 확인할 수 있으며, 가짜 서버 API에서 생성된 일부 할 일 객체가 포함되어 있어야 합니다:

액션을 디스패치했음에도 상태 변경이 일어나지 않는다는 점에 유의하세요. 상태를 업데이트하려면 리듀서에서 이 액션을 처리해야 합니다.
스토어에 이 데이터를 로드하기 위해 리듀서에 케이스를 추가하겠습니다. 서버에서 데이터를 가져오므로 기존 할 일 항목을 완전히 대체하려면 action.payload 배열을 반환하여 새로운 할 일 state 값으로 만들 수 있습니다:
import { client } from '../../api/client'
const initialState = []
export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other reducer cases
case 'todos/todosLoaded': {
// Replace the existing state entirely by returning the new value
return action.payload
}
default:
return state
}
}
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}
액션 디스패치는 즉시 스토어를 업데이트하므로, 디스패치 후 업데이트된 상태 값을 읽기 위해 썽크 내에서 getState를 호출할 수도 있습니다. 예를 들어 'todos/todosLoaded' 액션을 디스패치하기 전과 후에 총 할 일 항목 수를 콘솔에 기록할 수 있습니다:
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
const stateBefore = getState()
console.log('Todos before dispatch: ', stateBefore.todos.length)
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
const stateAfter = getState()
console.log('Todos after dispatch: ', stateAfter.todos.length)
}
할 일 항목 저장하기
새 할 일 항목을 생성할 때마다 서버도 업데이트해야 합니다. 'todos/todoAdded' 액션을 즉시 디스패치하는 대신, 초기 데이터를 서버에 API 호출로 전송하고, 서버가 새로 저장된 할 일 항목의 사본을 반환할 때까지 기다린 다음, 그런 다음 해당 할 일 항목으로 액션을 디스패치해야 합니다.
그러나 이 로직을 썽크 함수로 작성하려고 하면 문제가 발생합니다: todosSlice.js 파일에 별도의 함수로 썽크를 작성하기 때문에 API 호출을 수행하는 코드는 새 할 일의 텍스트가 무엇이어야 하는지 알지 못합니다:
async function saveNewTodo(dispatch, getState) {
// ❌ We need to have the text of the new todo, but where is it coming from?
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
text를 매개변수로 받는 함수를 작성해야 하지만, 실제로는 이 text 값을 사용해 API 호출을 수행할 수 있도록 thunk 함수를 생성해야 합니다. 외부 함수는 컴포넌트에서 dispatch에 전달할 수 있도록 thunk 함수를 반환해야 합니다.
// Write a synchronous outer function that receives the `text` parameter:
export function saveNewTodo(text) {
// And then creates and returns the async thunk function:
return async function saveNewTodoThunk(dispatch, getState) {
// ✅ Now we can use the text value and send it to the server
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
}
이제 <Header> 컴포넌트에서 이를 사용할 수 있습니다:
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'
import { saveNewTodo } from '../todos/todosSlice'
const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()
const handleChange = e => setText(e.target.value)
const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function with the text the user wrote
const saveNewTodoThunk = saveNewTodo(trimmedText)
// Then dispatch the thunk function itself
dispatch(saveNewTodoThunk)
setText('')
}
}
// omit rendering output
}
컴포넌트에서 thunk 함수를 바로 dispatch에 전달할 것이므로 임시 변수 생성을 건너뛸 수 있습니다. 대신 saveNewTodo(text)를 호출하고 생성된 thunk 함수를 바로 dispatch에 전달합니다:
const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function and immediately dispatch it
dispatch(saveNewTodo(trimmedText))
setText('')
}
}
이제 컴포넌트는 thunk 함수를 디스패치하는지조차 알지 못합니다. saveNewTodo 함수가 실제 동작을 캡슐화하고 있습니다. <Header> 컴포넌트는 사용자가 엔터를 누를 때 _어떤 값_을 디스패치해야 한다는 것만 알고 있습니다.
dispatch에 전달될 내용을 준비하기 위한 함수를 작성하는 이 패턴을 "액션 생성자(action creator)" 패턴이라고 하며, 다음 섹션에서 더 자세히 다룰 것입니다.
이제 업데이트된 'todos/todoAdded' 액션이 디스패치되는 것을 확인할 수 있습니다:

여기서 마지막으로 변경해야 할 부분은 todos 리듀서 업데이트입니다. /fakeApi/todos에 POST 요청을 보내면 서버는 완전히 새로운 todo 객체(새 ID 값 포함)를 반환합니다. 즉, 리듀서는 새 ID를 계산하거나 다른 필드를 채울 필요 없이 새 todo 항목을 포함한 새로운 state 배열만 생성하면 됩니다:
const initialState = []
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Return a new todos state array with the new todo item at the end
return [...state, action.payload]
}
// omit other cases
default:
return state
}
}
이제 새로운 todo 추가가 올바르게 작동합니다:

Thunk 함수는 비동기 및 동기 로직 모두에 사용될 수 있습니다. Thunk는 dispatch와 getState에 접근해야 하는 재사용 가능한 로직을 작성하는 방법을 제공합니다.
학습 내용 요약
이제 todo 앱을 성공적으로 업데이트하여 가짜 서버 API에 HTTP 요청을 보내기 위한 "thunk" 함수를 사용해 todo 항목 목록을 가져오고 새로운 todo 항목을 저장할 수 있게 되었습니다.
이 과정에서 Redux 미들웨어가 비동기 호출을 수행하고, 호출 완료 후 액션을 디스패치하여 스토어와 상호작용할 수 있게 하는 방법을 확인했습니다.
현재 앱의 모습은 다음과 같습니다:
- Redux 미들웨어는 사이드 이펙트가 있는 로직 작성을 가능하게 설계되었습니다
- "사이드 이펙트"는 HTTP 요청, 함수 인수 수정, 난수 생성 등 함수 외부에서 상태/동작을 변경하는 코드입니다
- 미들웨어는 표준 Redux 데이터 흐름에 추가 단계를 도입합니다
- 미들웨어는
dispatch에 전달된 다른 값을 가로챌 수 있습니다 - 미들웨어는
dispatch와getState에 접근할 수 있으므로 비동기 로직의 일부로 추가 액션을 디스패치할 수 있습니다
- 미들웨어는
- Redux "Thunk" 미들웨어는 함수를
dispatch에 전달할 수 있게 합니다- "Thunk" 함수를 사용하면 어떤 Redux 스토어가 사용되는지 모르는 상태에서 미리 비동기 로직을 작성할 수 있습니다
- Redux thunk 함수는
dispatch와getState를 인수로 받으며 "이 데이터는 API 응답에서 수신됨"과 같은 액션을 디스패치할 수 있습니다
다음 단계
이제 Redux 사용 방법의 모든 핵심 요소를 다뤘습니다! 다음 내용을 확인했습니다:
-
디스패치된 액션에 따라 상태를 업데이트하는 리듀서 작성법
-
리듀서, enhancer, 미들웨어로 Redux 스토어 생성 및 구성법
-
액션을 디스패치하는 비동기 로직 작성을 위한 미들웨어 사용법
7부: 표준 Redux 패턴에서는 실제 Redux 앱에서 일반적으로 사용되는 여러 코드 패턴을 살펴보고, 애플리케이션 성장에 따라 코드를 더 일관성 있게 만들고 확장성을 높이는 방법을 다룰 것입니다.