결론적으로 말하자면, 요새는 redux가 store 역할만 하는 것이 아니라 비동기 처리의 역할까지 담당하게 되면서 너무 비대해진다는 점을 지적하는 사람들이 많아지는 것 같다.
나 역시도 개발을 할 때에 비동기 관련 작업을 삽입하다 보면 "pending", "reject", "success" 등등의 호출의 결과에 따른 응답처리를 분기해서 한다는 점이 매우 고역이었다.
특히, redux는 해당 상태에 대해 액션으로 일일이 다 정의해줘야 하는 불편함이 존재해서 솔직히 말하면 있는 그대로의 redux 에서 비동기 작업을 하는 것을 좋아하는 편은 아니다.
// const/action.js
const ACTION1_PENDING = "ACTION1_PENDING"
const ACTION1_FAIL = "ACTION1_FAIL"
const ACTION1_SUCCESS = "ACTION1_SUCCESS"
const ACTION2_PENDING = "ACTION2_PENDING"
const ACTION2_FAIL = "ACTION2_FAIL"
const ACTION2_SUCCESS = "ACTION2_SUCCESS"
const ACTION3_PENDING = "ACTION3_PENDING"
const ACTION3_FAIL = "ACTION3_FAIL"
const ACTION3_SUCCESS = "ACTION3_SUCCESS"
.
.
.
.
// action hell
그래도 초창기 redux에서 "mapStateToProps" 같은 걸 쓰던 시절에 비하면 훨씬 지금 나아졌긴 하지만 그래도 저렇게 액션을 매번 정의하는 것도 불편하고, 이로 인해서 비동기 작업과 관련한 내용이 너무 비대해진다.
그래서 대놓고 공식문서에도 redux-toolkit 쓰라고 권장하고 있는 것 같기도...
redux-toolkit은 액션이 따로 필요하지 않다.
거기다 비동기 처리도 자동으로 props로 전달해주는 방식을 제공해주고 있어서 기존 redux thunk를 이용한 비동기 처리나, saga를 이용한 처리에 비해서는 훨씬 더 좋은 상황이라고 볼 수 있다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId: number, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
.
.
.
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// standard reducer logic, with auto-generated action types per reducer
},
extraReducers: (builder) => {
builder.addCase(fetchUserById.fulfilled, (state, action) => {
// Add user to the state array
state.entities.push(action.payload)
})
},
})
// inside component
dispatch(fetchUserById(123))
위처럼 createAsyncThunk를 import하여 인자를 넣으면서 호출하면 필요한 action creator을 만들 수 있다. (함수를 리턴함)
뭔가 특별한 게 있는게 아니라, 기존에 redux-thunk로 비동기 작업을 했던 내용을 기억하면 된다.
그리고 나서 createSlice에 들어가는 객체의 extraReducers 부분에 자동으로 인자로 보내지는 builder 객체를 사용하여 거기에 있는 addCase 메서드를 호출해 해당 async 함수가 실행됨으로 인해 부수적으로 이루어지는 액션 디스패치들의 케이스들마다 작업할 내용을 기술하면 된다.
공식문서에서 이야기하는 createAsyncThunk의 인자를 살펴보면
위 설명대로, 만약 dispatch로 해당 비동기 함수 액션생성자가 전달된다면 기존 reudx-thunk처럼 해당 함수를 다시 재호출하는데 이때,
재미있는점은 "pending액션" => "fulfiled액션 or rejected액션" 순으로 자동으로 디스패치한다는 점이다.
항상 redux-thunk는 디스패치 대상으로 함수를 받으면 그것을 계속해서 호출하여 액션객체가 나올때까지 실행시킨다는 점을 떠올리면 좋다.
즉,
컴포넌트에서 dispatch로 해당 createAsyncThunk가 호출됨으로 만들어지는 객체를 가지고 그 내부에 있는 pending 메서드를 호출해 해당 액션을 디스패치한다.
그리고나서, 두번째 인자로 전달된 비동기 함수를 호출하여 Promise가 settled가 될 때까지 기다린 후, Promise 객체의 Result 슬롯의 결과에 따라서 fulfilled 메서드나 rejected 메서드를 호출해 액션을 디스패치한다.
그러면 뒤에서 설명할 extraReducre에 등록된 함수를 통해 저장되어 있던 케이스들을 확인한 뒤에 해당 케이스별로 콜백함수를 실행하여 작업을 처리한다.
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
두번째 인자는 Promise 객체를 리턴하는 함수가 들어간다.
이때, 해당 함수의 인자로 들어가는 값은 다음과 같다.
arg : 컴포넌트상에서 처음으로 dispatch로 비동기 작업 액션을 생성할 때에 보내지는 인자이다. 오로지 하나의 값만 전달할 수 있기 때문에, 값이 여러개라면 객체로 보내라고 하고 있다.
thunkAPI : 기존 redux-thunk에서 전달했던 값들에서 조금 더 추가가 된 것들이 있다.
--dispatch : 함수 몸체에서 무언가 작업을 한 후, 특정 액션을 또다시 dispatch할 필요성이 있을 경우 사용하면 된다.
--getState : 함수 몸체에서 특정 작업을 할 때에, 현재 store의 상태를 조회해서 진행해야 할 필요성이 있을 경우 사용하면 된다.
--extra : 이건 사실 사용할 일이 잘 없긴한데, 초기에 redux store을 만들면서 middleware로 해당 값을 붙여놨으면 자동으로 비동기 작업을 할 때마다 해당 데이터가 이 extra 프로퍼티의 값으로 할당되어 들어간다.
//예를들어 store.js에 미들웨어로
export const store = configureStore({
reducer: {
counter: counterReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
thunk: {
extraArgument: "apiService",
serializableCheck: false,
},
}),
});
이렇게 설정해뒀다면
const incrementAsync = createAsyncThunk("counter/fetchCount", async (amount, thunkAPI) => {
console.log(thunkAPI);
const response = await fetchCount(amount);
return response.data;
});
위에 보이는 두번째 인자 "thunkAPI" 의 객체 내용 중에서
dispatch: ƒ dispatch()
extra: "apiService"
fulfillWithValue: ƒ (value, meta)
getState: ƒ i()
rejectWithValue: ƒ (value, meta)
requestId: "UF2SrCaKAZUpbRafRisqL"
signal: AbortSignal
extra 부분에 설정해둔 값이 들어가있는 것을 확인할 수 있다.
--fulfillWithValue, rejectWithValue : 스토어 업데이트를 redux store내에서 하는게 아니라, 컴포넌트에서 확인해서 사용하고 싶을 때 쓰면 된다
export const incrementAsync = createAsyncThunk("counter/fetchCount", async (amount, thunkAPI) => {
try {
const response = await fetchCount(amount);
return thunkAPI.fulfillWithValue(response);
} catch (e) {
return thunkAPI.rejectWithValue(e.response.data);
}
});
위에처럼 설정했을 시, 컴포넌트에서 데이터를 확인해보면
onClick={ () => {
const async = dispatch(incrementAsync(incrementValue));
console.log(async);
}}
//const async =
{meta: {arg: 2, requestId: '-MN1HQ4-FtPK_bC3X0RJX', requestStatus: 'fulfilled'}
payload: {data: 2}
type: "counter/fetchCount/fulfilled"}
이렇게 meta값이 존재하는 객체데이터가 리턴되게 된다.
payload값만 갖고 싶다면 dispatch().unwrap() 으로 호출하면 된다. (단, 이 때에는 unwrap 자체가 Promise객체를 리턴하기에 async await으로 처리해줘야 원하는 결과값을 가져올 수 있다. 안넣어주면 Promise pending을 보게될 것)
--requestId : 해당 비동기 작업 호출의 고유 아이디이다.
위에처럼 비동기 액션함수 생성자를 이용하여 디스패치를 진행할 경우,
자동으로 pending액션을 디스패치하고, 두번째 인자로 들어가는 비동기 함수를 호출하여 이 프로미스의 내부 슬롯상태에 따라 fulfilled나 rejected 메서드를 자동호출한다고 하였다.
이때 호출되면서 전달되는 액션객체는 다음과 같은 타입을 가진다
interface PendingAction<ThunkArg> {
type: string
payload: undefined
meta: {
requestId: string
arg: ThunkArg
}
}
interface FulfilledAction<ThunkArg, PromiseResult> {
type: string
payload: PromiseResult
meta: {
requestId: string
arg: ThunkArg
}
}
interface RejectedAction<ThunkArg> {
type: string
payload: undefined
error: SerializedError | any
meta: {
requestId: string
arg: ThunkArg
aborted: boolean
condition: boolean
}
}
즉, 다시금 액션이 디스패치되므로, 이 디스패치된 액션에 대해서 케이스로 관리해주는 부분이 존재하게 되는데 그것이 바로 "extraReducers" 파트이다
// slice.js
const reducer = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchUserById.fulfilled, (state, action) => {})
builder.addCase(fetchUserById.rejected, (state, action) => {})
},
})
위에처럼, builder객체에 addCase 메서드로 비동기 작업이 완료된 이후 수행할 내용을 두번째 인자의 함수를 통해 실행시키면 된다.
위에서도 언급했지만, 이 후속처리 작업을 이곳에서 하지 않고
컴포넌트 내에서 하고 싶다면 리턴할 때에 thunkAPI에 존재하던 fulfillWithValue나 rejectWithValue의 호출결과를 리턴해주면 컴포넌트에서 해당 액션 객체의 메타데이터를 사용하는 것이 가능하다.
redux-saga에서는 "takeLatest"라는 함수를 통해서 여러 요청이 들어와도 마지막의 단 하나만 실행하도록 하는 디바운스가 자동으로 구현되어 있다.
그런데 redux-thunk에는 그러한 내용이 없어서 조금 아쉬운 감이 있었는데
redux-toolkit에는 디스패치의 캔슬 및, 비동기 작업의 캔슬을 다 구현해놨다는 점이 인상깊다.
우선 비동기 액션의 디스패치를 중간에 미들웨어처럼 취소하는 방법은 아래와 같다.
export const incrementAsync = createAsyncThunk(
"counter/fetchCount",
async (amount, thunkAPI) => {
const response = await fetchCount(amount);
return response.data;
},
{
condition: (amount, { getState, extra }) => {
const state = getState();
if (state.counter.value === 0) {
console.log("do nothing");
return false;
}
},
}
);
설명하자면, createAsyncThunk의 세번째 인자로 들어가는 객체에는 다양한 옵셔널한 값들을 넣을 수 있는데, 그 중에서 "condition" 프로퍼티에 함수를 등록하면 된다.
첫번째 인자로는 해당 비동기 액션 생성자가 호출되는 순간 인자로 받았던 그 값이 들어오고, 두번째 인자로는 현재 리덕스 스토어의 상태값과 위에서 설명했던 초기 등록한 extra 값이 인자로 들어온다.
그래서, 몸체에서 어떠한 조건에 따라 false를 리턴하게 된다면 마치 미들웨어처럼 해당 비동기 액션객체의 디스패치가 실패하며 아무것도 하지 않게 된다.
만약, 해당 취소행위를 reject 액션으로 받아서 처리하고 싶다면 createAsyncThunk의 세번째 인자 객체의 옵션중 하나인 "dispatchConditionRejection: true" 를 설정해주면 된다.
{
condition: (amount, { getState, extra }) => {
const state = getState();
if (state.counter.value === 0) {
console.log("do nothing");
return false;
}
},
dispatchConditionRejection: true,
}
.
.
.
// createSlice 인자 객체 내부
extraReducers: (builder) => {
builder.addCase(incrementAsync.rejected, (state, action) => {
console.log("cancel detected from extraReducers");
});
}, // 리젝트에서 발동하게 된다.
이 케이스는 중복호출에 대한 처리에 알맞다.
솔직히 말하면 나는 유저가 클릭을 하는 순간 아예 UI적으로 클릭이 결과가 도착하기 전까지는 더이상 되지 않도록 비활성화해서 처리하는 편인데, 그런것을 해두지 않는 경우라면 비동기 요청을 취소시키는 로직을 짜는 것도 나쁘지 않다. (마치 디바운스처럼 마지막 요청만 남게 만든다는 의미이다.)
실 사용 케이스별로 분류한다면
a. 버튼을 클릭해서 서버호출은 했는데, 사용자가 참을성이 없어서 결과가 오기전에 나가버려 unmount 될 때
이 때에는 해당 결과값이 도착할 필요도 없어지므로 useEffect로 취소시킨다
function MyComponent(props: { userId: string }) {
const dispatch = useAppDispatch()
React.useEffect(() => {
const promise = dispatch(fetchUserById(props.userId))
return () => {
promise.abort() // unmount를 감지하면, Ajax 호출을 취소시킨다.
}
}, [props.userId])
}
이 경우, 리덕스 스토어에는 자동으로 "thunkName/rejected" 이 디스패치된다.
즉, extraReducers로 리덕스 프로세스의 취소를 처리할 내용을 담고 싶으면 담아서 처리하면 된다.
b. 사용자가 성질이 급해서 버튼을 여러번 눌렀어요. 디바운스 안되나요
디바운스로 해도 된다. 근데 개인적으로 디바운스를 자주 쓰는 입장에서는, 디바운스보다 그냥 ajax 요청을 캔슬시키는게 더 편한거같다. (debounce를 위해서 usecCallback으로 함수 기억시키고 하는 그런게 좀 부담스러움)
axios를 통한 호출을 버튼을 사정없이 눌러서 여러번 했을 때 막는 방법은 아래와 같다
let asyncChecker = null;
export const incrementAsync = createAsyncThunk("counter/fetchCount", async (amount, thunkAPI) => {
if (!asyncChecker) {
asyncChecker = thunkAPI.signal;
} else {
asyncChecker.abort();
}
const response = await axios.get("https://api.publicapis.org/entries",{ signal: asyncChecker });
asyncChecker = null;
return response.data;
});
저기 위에서 보이는 thunkAPI.signal은 뭔가 특별한 게 아니고 그냥 "new AbortController" 을 통해 생성한 인스턴스이다.
저렇게 createAsyncThunk의 렉시컬 환경에 체킹을 할 변수를 하나 만들어두고,
매번 클릭때마다 해당 환경값을 조회하게 만들면 된다.
해당 환경값에는 signal객체가 들어가고, axios 요청 때마다 해당 signal 객체를 전달한다.
만약 여러번 클릭을 하게 된다면, if문에 의해 확인해봤을 때 이미 asynChecker 변수 내부에 signal 객체가 존재하므로, 그 signal 객체의 내부 프로토타입 메서드인 abort를 호출하여 비동기 호출을 취소시킨다.
이렇게 취소를 하게 되면 아무리 수백번을 클릭한다 하더라도 단 한번의 결과만 돌아온다.
도대체 이미 인터넷 망으로 날아간 리퀘스트를 어떻게 취소시키는건지 그게 더 신기하지만 여튼 그러하다.
이렇게 redux-toolkit의 전체적인 기능을 한번 공식문서를 훌어보면서 확인해보았다.
덕분에 리덕스로 비동기 작업을 해당 툴로 하게 된다면 이해를 잘 할 수 있을 것 같다는 자신감은 들지만서도
"이렇게 되면 redux 스토어에 너무 비동기 작업 처리하는 코드가 많아져서 보기싫음"
...
이렇게 보면 그냥 react-query가 정말 좋은 툴이라는 걸 새삼 다시 느끼게 된다.