이번 톺아보기에서는 리덕스 공식 문서를 통해 비동기 로직을 작성하는 법을 배워보고자 한다.
[ Redux 비동기 코드 다루기 ]
😬 담담한 척하며 공식 문서를 살펴보지만 비동기라는 단어가 왜 이렇게 무서운지. 공식 문서를 읽고 나면 조금 더 나아진 모습을 기대해 본다.
동기식 로직의 경우 액션이 디스패치되고 스토어가 리듀서를 실행하고 새로운 상태를 계산하면 디스패치가 완료된다. 그러나 자바스크립트에는 비동기식 코드를 작성하는 다양한 방법이 있으며 일반적으로 애플리케이션에는 API에서 데이터를 가져오는 것과 같은 작업을 위한 비동기 로직이 존재한다. 이 경우 리덕스에 비동기 로직을 넣을 장소가 필요하다.
thunk는 비동기 논리를 포함할 수 있는 특정 종류의 리덕스 함수이다. thunk는 두 가지 기능을 사용하여 작성된다.
- 인수로 dispatch 및 getState를 가져오는 내부 thunk 함수
- thunk 함수를 생성하고 반환하는 외부 생성자 함수
// 아래의 기능은 thunk라고 하며 비동기 로직을 수행할 수 있게 한다.
// dispatch(incrementAsync(10))처럼 일반적인 액션처럼 디스패치할 수 있다.
// dispatch 함수를 가진 thunk를 첫 번째 인수로 받는다.
// 그런 다음 비동기 코드를 실행하고 다른 액션을 디스패치할 수 있다.
export const incrementAsync = amount => dispatch => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
위의 내보낸 함수는 counterSlice thunk 액션 생성자의 예이다.
store.dispatch(incrementAsync(5))
일반적인 Redux 액션 생성기를 사용하는 것과 같은 방식으로 사용할 수 있다.
그러나 thunk를 사용하려면 redux-thunk 미들웨어를 생성할 때 리덕스 스토어에 추가해야 한다. 다행히 리덕스 툴킷의 configureStore는 이미 자동으로 설정되어 있음으로 thunk 사용이 가능하다.
조금 더 thunk와 비동기 로직에 대해 살펴보자.
const store = configureStore({ reducer: counterReducer })
setTimeout(() => {
store.dispatch(increment())
}, 250)
리듀서에는 어떤 종류의 비동기 로직도 넣을 수 없다. 그러나 비동기 로직은 어딘가에는 있어야 한다. 리덕스 스토어에 접근할 수 있는 경우 비동기 코드를 작성하고 완료되면 store.dispatch()를 호출할 수 있어야 한다. 모든 비동기성은 스토어 외부에서 발생해야 한다.
그러나 실제로 리덕스에서는 스토어를 다른 파일, 특히 리액트 컴포넌트로 가져올 수 없다. 코드를 테스트하고 재사용하기가 더 어려워지기 때문이다. 또한 어떤 스토어와 함께 사용될 것이라는 것을 알고 있지만 어떤 스토어인지 알지 못하는 일 부 비동기 로직을 작성해야 하는 경우가 종종 있을 수 있다.
그럼 현재 스토어 상태를 디스패치하거나 확인하여 비동기 로직이 스토어와 상호 작용하려면 어떻게 해야 할까? 이것이 리덕스 미들웨어가 등장한 이유이다.
리덕스 스토어는 일종의 애드온 플러그인인 미들웨어로 확장이 가능하다. 미들웨어를 사용하는 가장 일반적인 이유는 비동기 로직을 가질 수 있는 코드를 작성하면서도 동시에 스토어와 통신할 수 있도록 하기 위함이다. 또한 dispatch()를 호출하고 함수나 프로미스와 같은 일반 액션 객체가 아닌 값을 전달할 수 있도록 스토어를 수정할 수 있다.
스토어를 확장하여 다음을 수행할 수 있다.
- 액션이 디스패치될 때 추가 로직을 실행시킨다. (예 : 액션 및 상태 기록)
- 디스패치된 액션을 일시 중지, 수정, 지연, 교체, 중지시킨다.
- dispatch 및 getState에 접근할 수 있는 추가 코드를 작성한다.
- 함수 및 프로미스와 같은 일반 작업 객체 이외의 다른 값을 받아들이는 방법을 새신 가로채고 실제 액션 객체를 디스패치하여 알려준다.
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
리덕스 thunk 미들웨어는 스토어를 수정하여 함수를 디스패치로 전달할 수 있다.
디스패치로 전달된 액션이 실제로 일반 액션 객체가 아닌 함수인지 확인한다. 실제로 함수라면 호출하고 결과를 반환한다. 그렇지 않으면 액션 객체여야 하므로 액션을 스토어로 전달한다. 이를 통해 디스패치 및 상태 가져오기에 대한 접근 권한을 유지하면서 원하는 동기화 또는 비동기 코드를 작성할 수 있게 된다.
위의 이미지와 함께 살펴보자. 어떤 경우 액션이 발생하여 store에 디스패치를 하게된다. 이 경우 두 가지의 상황으로 추려볼 수 있다.
- 액션 객체를 디스패치 한다.
- 액션 객체가 아닌 함수를 디스패치 한다.
위의 두 가지 상황에서 thunk의 작동 방식은 다음과 같다. 들어온 액션이 객체인 경우 바로 리듀서로 보내고, 객체가 아닌 함수인 경우 리듀서로 보내지 않고 dispatch와 getState 함수를 다시 호출하게 된다.
액션으로 함수를 디스패치한 경우 비동기 요청을 보내고 요청이 완료될 때까지 기다린다. 요청이 완료되고 응답을 받으면 액션 객체를 다시 디스패치하게 된다.
미들웨어를 사용하는 가장 일반적인 이유는 다양한 종류의 비동기 로직이 스토어와 상호작용할 수 있도록 하기 위함이다. 이렇게 하면 UI와 별도의 로직을 유지하면서 액션을 디스패치하고 스토어 상태를 확인할 수 있는 코드를 작성할 수 있다.
리덕스 용 비동기 미들웨어에는 여러 종류가 있으며, 각가 다른 구문을 사용하여 로직을 작성할 수 있다. 가장 일반적인 비동기 미들웨어는 redux-thunk로 비동기 로직을 직접 포함할 수 있는 함수 작성이 가능하다. 리덕스 툴킷의 conifgureStore 기능은 기본적으로 Thunk 미들웨어를 자동으로 설정하며, Redux로 비동기 로직을 작성하기 위한 표준 접근법으로 Thunk 사용을 권장한다.
[ Redux-toolkit을 사용하여 비동기 로직 처리 ]
마지막으로 Redux-toolkit을 사용하여 비동기 로직을 처리하는 방법을 배워보자.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
// omit imports and state
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await client.get('/fakeApi/todos')
return response.todos
})
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
// omit reducer cases
},
extraReducers: builder => {
builder
.addCase(fetchTodos.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, action) => {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
state.entities = newEntities
state.status = 'idle'
})
}
})
// omit exports
우선 createAsyncThunk는 두 가지 인수를 허용한다.
- 생성된 액션 타입의 접두사로 사용할 문자열
- 프로미스를 반환해야 하는 "payload creator" 콜백 함수.
위의 두 가지 허용 인수처럼 Thunk에 액션 타입을 'todos/fetchTodos'를 문자열로 전달해 주었다. 그리고 API를 호출하고 가져온 데이터를 포함하는 프로미스를 반환하는 "payload creator" 함수를 전달해 주었다. 내부에서 createAsyncThunk는 세 가지 액션 타입을 생성하고 해당 액션을 자동으로 디스패치하는 thunk 함수를 생성한다. 이 경우 action creator와 해당 타입은 다음과 같다.
- fetchTodos.pending
- fetchTodos.fulfilled
- fetchTodos.rejected
하지만 이러한 action creator 및 타입은 createSlice 호출 외부에서 정의되고 있다. createSlice.reducers 필드 내의 액션을 처리할 수 없다. 새 액션 타입도 생성되기 때문이다. createSlice 호출이 다른 곳에서 정의된 다른 액션 타입을 수신할 수 있는 방법이 필요하다.
createSlice는 또한 다른 액션 유형에 대해 동일한 슬라이스 리듀서가 수신할 수 있는 추가 리듀서 옵션(extraReducers)을 허용한다. 이 필드는 Builder 매개 변수가 있는 콜백 함수여야 하며, Builder.addCase(actionCreator, caseReducer)를 호출하여 다른 액션을 들을 수 있게 된다.
그래서 여기서 builder.addCase(fetchTodos.pending, caseReducer)를 호출했다. 해당 액션이 디스패치되면 이전에 스위치 문에 로직을 작성했을 때와 마찬가지로 state.status = "loading"을 설정하는 리듀서가 실행된다. fetchTodos도 똑같이 할 수 있다.
또 다른 예로 saveNewTodo를 변환해 보자. 그러면 새 액션 관리 객체의 텍스트가 매개변수로 사용되고 서버에 저장된다. 이것을 어떻게 처리할까?
saveNewTodo 프로세스는 fetchTodos에서 본 프로세스와 동일하다. 우리는 createAsyncThunk를 호출하고 액션 접두사(문자열)와 payload creator를 전달했다. payload creator 내부에서 비동기 API 호출을 수행하고 결괏값을 반환한다.
이 경우, 디스패치(saveNewTodo(text))를 호출하면 text 값이 payload creator의 첫 번째 인수로 전달된다.
이때 createAsyncThunk 참고 사항은 다음과 같다.
- thunk를 디스패치할 때 인수 하나만 전달이 가능하다. 여러 값을 전달해야 하는 경우 단일 객체로 전달하자.
- payload creator는 {getState, dispatch} 및 기타 유용한 값을 포함하는 객체를 두 번째 인수로 받는다.
- thunk는 payload creator를 실행하기 전에 pending 상태인 액션을 디스패치한 다음 반환 프로미스의 성공 여부에 따라 fulfilled 또는 rejected 액션을 디스패치 한다.
[ 직접 해보자! ]
역시나 공시 문서는 모두 영어로 되어 있어 번역기의 힘을 빌려도 완벽히 이해하기가 어렵다. 내가 잘 이해한 것인지 확신 또한 없다. 직접 코드를 작성해 보면서 비동기 처리 코드를 작성해 보자.
*작성 예시는 jsonplaceholder를 이용하여 http 요청을 보내는 비동기 코드를 처리하는 것이다.
import axios from "axios";
export const fetchGetTodos = async () => {
const response = await axios.get(
"https://jsonplaceholder.typicode.com/todos"
);
return response.data;
};
export const fetchDeleteTodo = async (id) => {
const response = await axios.delete(
`https://jsonplaceholder.typicode.com/todos/${id}`
);
return response.data;
};
jsonplaceholder에 http 요청을 보내는 간단한 API 두 개를 작성했다. 각각의 API는 todo 데이터를 요청하거나 todo 데이터의 id 값을 전달받아 서버로 삭제 요청을 보낸다.
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { fetchGetTodos, fetchDeleteTodo } from "./todoAPI";
const initialState = {
status: "",
todos: [],
};
export const getTodosAsync = createAsyncThunk(
"todo/fetchGetTodos",
async () => {
const todoData = await fetchGetTodos();
return todoData;
}
);
export const deleteTodoAsync = createAsyncThunk(
"todo/fetchDeleteTodo",
async () => {
const status = await fetchDeleteTodo();
console.log(status);
return status;
}
);
export const todoSlice = createSlice({
name: "todo",
initialState,
extraReducers: (builder) => {
builder
.addCase(getTodosAsync.pending, (state) => {
state.status = "loading";
})
.addCase(getTodosAsync.fulfilled, (state, action) => {
state.status = "completed";
state.todos = action.payload;
})
.addCase(deleteTodoAsync.pending, (state) => {
state.status = "loading";
})
.addCase(deleteTodoAsync.fulfilled, (state, action) => {
state.status = "completed";
});
},
});
export const selectTodo = (state) => state.todo;
export default todoSlice.reducer;
공식 문서를 살펴볼 때는 막막했지만 실제로 작성해 보니 생각보다 큰 어려움이 없었다. 하나씩 간략하게 살펴보면 다음과 같다.
우선 slice의 초기 State 값을 설정해 주었다. 다음 단계에서 createSlice 메서드로 만드는 slice initialState의 값으로 전달할 예정이다.
다음으로 앞서 언급한 createSlice 메서드를 사용하여 slice를 생성해 준다. slice에는 해당 slice의 구분자 역할을 하는 name의 값을 문자열로 설정하고, 만든 앞서 만든 initialState를 initialState의 값으로 전달한다.
해당 예시의 경우 비동기 요청을 처리하기 위한 예시 이기 때문에 reducers는 작성하지 않았고, 공식 문서를 살펴볼 때 나왔던 비동기 코드를 처리하기 위한 extraReducers를 설정해 주었다. extraReducers는 후에 살펴보도록 하자.
빨간 박스로 표신 코드는 필수는 아니지만, 공식 문서를 읽어보다 이런 방법이 있구나 싶어 적용해 보았다.
(예시에서는 store에서 관리 중인 status, todos 모두를 확인하기 위해 state.todo.todos로 작성하지 않고 state.todo로 작성했지만 필요시 별도로 코드 작성을 해주어도 좋을 것 같다는 생각이 든다.)
const todos = useSelector(selectTodo); // 1
const todo = useSelector((state) => state.todo); // 2
나는 매번 2번으로 store에서 관리 중인 state에 접근하여 값을 이용했었다. 물론 해당 state를 사용하는 컴포넌트가 많을수록 2번의 코드를 중복적으로 작성해 왔다.
// app / store.js
export const store = configureStore({
reducer: {
todo: todoReducer,
},
});
// index.js
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
위의 예시처럼 store에 리듀서를 연결해 주고 애플리케이션을 Provier로 감싸주고 store를 전달해 주면 초기 설정은 끝이 난다.
초기 설정이 끝이 났으니 다시 todoSlice로 돌아와서 비동기 코드 처리를 위한 createAsyncThunk를 살펴보자.
앞서 공식 문서를 살펴볼 때 언급했던 createAsyncThunk의 인수로 액션 타입에 대한 문자열, 프로미스를 반환해야 하는 payload creator 콜백 함수를 전달해주어야 한다.
현재 예시에서는 todoAPI에 만들어 놓은 fetchGetTodos, fetchDeleteTodo API를 호출하여 API가 반환하는 프로미스를 그대로 반환하도록 했다.
마지막으로 extraReducers에 builder.addcase()를 이용해 앞서 만든 createAsyncThunk를 pending일 때 처리할 로직, fulfilled일 때 처리할 로직을 작성해 준다.
해당 예시에서는 간단하게 http 요청이 pending 상태 일 때 state의 status를 loading으로, fulfilled 상태 일때 tate의 status completed로 변경해 주었다. todo 데이터를 요청하는 경우는 state의 todos에 createAsyncThunk가 반환하는 payload로 변경되도록 해주었다.
여기까지 하고 결과를 살펴보자.
import { useDispatch, useSelector } from "react-redux";
import { getTodosAsync, deleteTodoAsync, selectTodo } from "./todoSlice";
const Todo = () => {
const dispatch = useDispatch();
const todos = useSelector(selectTodo);
console.log(todos);
const handleGetTodosDispatch = () => {
dispatch(getTodosAsync());
};
const handleDeleteTodoDispatch = (id) => {
dispatch(deleteTodoAsync(id));
};
return (
<div>
<button onClick={handleGetTodosDispatch}>todo 데이터 가져오기</button>
<button onClick={() => handleDeleteTodoDispatch(1)}>todo 삭제하기</button>
</div>
);
};
export default Todo;
Todo 컴포넌트에서는 react-redux에서 제공하는 useDispatch와 useSelector hook을 이용하여 디스패치하거나 store에서 관리하는 state에 접근한다.
두 개의 버튼이 존재한다. todo 데이터 가져오기 버튼을 클릭하면 http 요청의 응답으로 들어오는 데이터가 store의 state에 저장되고, todo 삭제하기 버튼을 클릭하면 해당 http 요청에 대한 status를 확인할 수 있을 것이고, 비동기 코드의 pending / fulfilled 상태일 때 state가 적절히 변경되는 것을 기대해 본다.
콘솔로 결과를 확인해 보자.
todo 데이터를 요청할 경우 비동기 코드가 pending 상태일 때 status는 정상적으로 loading으로 변경되었다. 후에 fulfilled 상태로 변경되었을 때 또한 status는 정상적으로 completed로 변경되었고, todo 데이터 또한 정상적으로 state에 저장되었다.
todo 데이터 삭제 요청의 경우 요청이 정상적으로 이루어져도 데이터가 실제로 삭제되지 않기 때문에 응답으로 들어오는 status로 살펴보기 위해 앞서 작성한 API에 콘솔을 출력하도록 해주었다.
todo 데이터 삭제 요청 또한 비동기 코드가 pending 상태일 때 status는 정상적으로 loading으로 변경되었다. 후에 fulfilled 상태로 변경되었을 때 응답으로 들어오는 status가 콘솔로 정삭적으로 출력되고, status는 정상적으로 completed로 변경되었다.
🤔 여전히 공식 문서 통해 배우는 것은 시간이 다소 걸리고 보다 어렵게 느껴진다. 하지만 그만큼 얻는 이점이 큰 것 같다. 살펴보는 거의 대부분의 시간이 나에게는 아하! 모먼트였다. 예를 들어 공식 문서를 살펴보다 알게 된 기존에 작성하던 코드보다 좋은 코드 작성 법. 너무 매력적이다.
'React > 상태관리하기' 카테고리의 다른 글
MobX 톺아보기 1 - MobX core - observable state (0) | 2023.02.13 |
---|---|
MobX 톺아보기 0 - 컨셉, 원칙 (0) | 2023.02.13 |
Redux 톺아보기 1 - (redux-toolkit) (0) | 2023.02.10 |
Redux 톺아보기 0 - ( 핵심 컨셉, 적절한 사용, 개념 및 용어 ) (0) | 2023.02.10 |
Redux toolkit 사용 (0) | 2022.11.08 |