🙀 이거.. 어떻게 하는거지..?
프로젝트를 진행하던 와중 약간의 난관에 부딪혔습니다.
참고로 프로젝트는 타입스크립트와 리액트로 진행되고 있습니다.
제 프로젝트는 createApi로 생성한 비동기 요청 데이터를 가지고 화면에 뿌려주는 형태입니다.
RTK Query를 통해 비동기 데이터를 받아오는 로직을 작성했습니다.
그런데 약간의 문제가 생겼습니다.
서버를 따로 두지 않는 작은 프로젝트인 탓에 클라이언트측의 데이터 변화를 useMutation을 통해서
서버에 변화된 데이터를 전송 -> 서버에서 업데이트 -> 다시 서버에게 데이터를 받아 클라이언트도 업데이트
와 같은 로직으로만 업데이트하는 것을 상상했는데
클라이언트측에서만 비동기 데이터를 조작해야하는 상황이 있었습니다.
예컨대 이 bookmark의 boolean 값을 토글해줘야하는 필요가 있었습니다.
(이 bookmark 프로퍼티를 추가시키는 것도 꽤 힘든 일이었지만 다른 포스트에서 이야기하겠습니다.)
그런데 의문이 생깁니다.
리덕스툴킷의 createApi 함수는 createSlice처럼
상태를 클라이언트 단에서만 변경하는 함수를 지원해주나..?
라는 생각이 듭니다.
이거 어떻게하면 좋을까요..?
🤡 문제 인식
쿼리 데이터를 클라이언트 단에서만 변경하는 방법이 필요하다.
😀고려할 수 있는 방법
우선 검색을 해봅니다. RTK Query 클라이언트 데이터만. RTK Query 데이터 처리 등등
그다지 영양가 있는 정보는 얻지 못했습니다.
분명 리덕스에서 제공을 해줄 것 같기는 합니다.
하지만 키워드를 모르기때문에 일단 최악의 상황을 상정해서 없을 경우를 대비해 생각을 합니다.
만약 쿼리 데이터를 직접 수정하는 것이 불가능하다면 어떻게 해결해야 할까요?
1. 쿼리 데이터가 받아와지면 그 쿼리 데이터를 initialstate로 갖는 slice를 생성하고
그 slice를 구독하는 형식으로 하며 북마크를 그 slice의 reducer로 조작하면 어떨까요?
-> 예상되는 문제점이 좀 많았지만 꽤 괜찮은 생각인 것 같았습니다.
만약 이렇게 로직을 구현한다면 캐시데이터를 새로 받아올때마다 괴리가 생길것입니다.
하지만 이 부분은 한번에 많은 데이터를 받아오고 업데이트를 포기하면 괜찮을 것 같았습니다.
정 방법이 없으면 이걸 고려해볼 생각이었지만
실제로 시도해본 결과 api를 받아오고 initialstate로 전달하는 과정에서 논리적 오류가 많이 발생했고
이를 해결하는 시간이 오래 걸릴 것이라고 판단하여 폐기하게되었습니다.
2. 혹시 createSlice로 만든 slice에서 스토어에 같이 있는 다른 slice의 상태를 변경시킬 수 있지 않을까요?
권장되는 방법은 아닐듯하지만 이 방법 역시 수요가 있을 것 같습니다.
따라서 만들어졌을 수도 있을 것 같습니다.
찾아볼 가치는 충분할 듯 합니다.
3. 아쉽지만 RTK Query를 통해 데이터를 페칭하는 것을 포기합니다.
그냥 fetch로 데이터를 받아오거나 리액트 쿼리에는 지원해줄지도 모르니 리액트 쿼리를 공부합니다.
아무리 생각해봐도 대안들은 제각기의 문제가 있어보입니다.
성능적인 측면에서 생각해봤을때도 하나 바뀔때마다 일일히 서버에 뮤테이션 요청을 보내는 것보다
한꺼번에 몰아서 보내는 게 더 이득일 것입니다.
따라서 클라이언트 측에서만 데이터를 변형하는 함수를 안만들어줬을리가 없을 것 입니다.
chat gpt에게 관련된 키워드를 얻을 수 있을지 질문을 해봅니다.
그다지 쓸모 있는 답변이 오지는 않지만 그 와중에 updateQuery라는 키워드를 얻었습니다.
chat gpt가 거짓말을 하는 것일 수 있으니 공식문서에서 updateQuery를 검색해봅니다.
https://redux-toolkit.js.org/rtk-query/api/created-api/api-slice-utils#updatequerydata
API Slices: Utilities | Redux Toolkit
redux-toolkit.js.org
문서가 있는 걸 보니 거짓말은 아니군요
하지만 제가 원하는 기능을 하는 함수일지는 읽어봐야 알 것 같습니다.
그래도 어느정도 해결의 실마리가 보입니다.
역시 지원을 안해줄리가 없을 것이다.라는 제 확신이 맞아떨어질지도 모르겠습니다.
공식문서를 읽어봅니다.
😀updateQueryData
updateQueryData는 api.util이라는 API개체 내부에 포함되는 객체의 프로퍼티 내부에 있는 함수입니다.
내부 유형이 상당히 복잡하기 때문에 타입에 대해서는 수도코드로 구조를 설명한다고합니다.
const updateQueryData = (
endpointName: string,
args: any,
updateRecipe: (draft: Draft<CachedState>) => void
) => ThunkAction<PatchCollection, PartialState, any, AnyAction>;
interface PatchCollection {
patches: Patch[];
inversePatches: Patch[];
undo: () => void;
}
기대하는 매개변수가 3개 인것이 눈에 띕니다. 하나하나 뜯어보아야 사용법을 익힐 수 있겠네요
endpointName:string | 기존 endpoint의 이름과 일치하는 문자열 (createApi의 endpoints 프로퍼티에서 쿼리를 정의할 때 지정해준 프로퍼티 이름이 들어가야 합니다.) |
args:any | 업데이트 해야하는 캐시된 데이터 세트를 결정하는 데 사용되는 이전 쿼리 호출에 사용되는 인수 일치 번역된 내용이 조금 난해하지만 쉽게 생각하면 endpointName의 쿼리함수에 전달할 인자를 뜻합니다. |
updateRecipe:(draft:Draft<CachedState>) | 캐시된 상태에 변경 사항을 적용할 수 있는 immer 콜백 |
이 endpointName은
export const cozShoppingAPI = createApi({
baseQuery: fetchBaseQuery({
baseUrl: `http://cozshopping.codestates-seb.link/api/v1/`,
}),
endpoints: (
builder: EndpointBuilder<
BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError,
object,
FetchBaseQueryMeta
>,
never,
'api'
>,
) => ({
getProduct: builder.query({ << 여기서는 getProduct입니다.
query: (count?: string | number) => ({
url: count ? `/products?count=${count}` : `/products`,
}),
}),
}),
});
이런 형식의 createApi를 만들었다면 getProduct가 문자열 형태로 들어가주어야합니다.
아무래도 저 첫번째 인수가 키값과 비슷한 역할을 수행하는 듯 합니다.
그리고 updateRecipe 라는 세번째 인수에서는 immer 함수의 형태로 된 인수를 받습니다.
불변성을 제공해주니까 맘편하게 수정할 수 있겠네요
updateRecipe의 함수에 들어가는 draft는 1번째 인수의 캐싱데이터에 대한 객체가 담깁니다.
근데 이걸 콘솔로 찍어보면 draft는 캐싱데이터를 보여주는게 아니라 이상한 객체를 보여줍니다.
아마.. immer함수의 내부로직과 관련이 있는게 아닐까 싶은데 일단은 패스하겠습니다.
어쨌든 캐싱데이터의 구조는 알고있으니 그것을 기반으로 수정해주면 되겠습니다.
updateRecipe: (draft: Draft<CachedState>) => void
) => ThunkAction<PatchCollection, PartialState, any, AnyAction>;
이건 updateRecipe를 설명해주는 코드입니다.
이런 형태로 되어있는데 대충 미들웨어랑 비슷한 구조로 되어있다는 느낌이 듭니다.
낙관적 업데이트를 하려고 할 때 유용하게 사용할 수 있는 모양인데
지금은 낙관적 업데이트를 고려할 상황은 아닌듯하니 함수의 사용법부터 알아봐야겠습니다.
그래도 낙관적 업데이트에 도움이 되는 키워드를 얻을 수 있어서 좋네요!
const patchCollection = dispatch(
api.util.updateQueryData('getPosts', undefined, (draftPosts) => {
draftPosts.push({ id: 1, name: 'Teddy' })
})
)
사용법은 다음과 같습니다.
api는 어디서 튀어나온 객체인가 살짝 혼란스러웠지만 문서를 조금 더 읽다보니 알아챌 수 있었습니다.
api는 createApi의 결과물이라고 생각하시면 됩니다.
const api = createApi({~~~~})
대충 위에서 이런 코드를 작성했었다라고 생각하고 읽어주시면 됩니다.
처음의 두 인수(endpointName, args)는 업데이트할 기존 캐시 항목을 결정하는 데에 사용됩니다.
기존에 캐시된 항목이 없으면 updateRecipe 콜백이 실행되지 않습니다.
지금까지 쿼리데이터로 생각하고 있었는데 생각해보니 리덕스툴킷쿼리는 요청에 대한 응답을 캐싱해둡니다.
그리고 지금 저는 그 캐싱해둔 데이터를 조작하려고 하고 있었습니다.
제가 진짜 원했던 것은 엄밀히 말하면 캐싱 데이터를 조작하는 일이었군요
공식문서의 예제를 보니 이제 사용해볼 수 있겠다는 생각이 듭니다.
즉 createApi가 반환한 객체의 util.updateQueryData에 접근해서 함수를 사용해주면 되는거군요
최종적인 형태는 api.util.updateQueryData가 될것이고
createApi를 모듈화해두었다면 createApi의 반환값을 import하거나
updateQueryData만 export해주면 될것입니다.
공식문서의 설명을 더 읽다보니 의문이 들었던 args도 어느정도 감이 옵니다.
(getPosts, undefined, ()=>) 구조는 getPosts(undefined)를 한것과 동일하다고 하는군요
예컨대 arguments를 줄인말이라고 유추할 수 있는데
getPosts 엔드포인트의 매개변수로 넣어줄 값을 정할 수 있는 기능이다.
라고 보입니다.(아니면 말고)
또 주목해야할것은 당연하다면 당연하지만 dispatch함수로 한번 감싸주어야한다는 것입니다.
dispatch로 액션을 전달해주는 과정이 수반되어야만 합니다.
const patchCollection = dispatch(
api.util.updateQueryData('getPostById', 1, (draftPost) => {
draftPost.name = 'Lilly'
})
)
이번엔 args에 대해 undefined가 아닌 다른 값을 넣어준 경우를 봅시다.
이것은 getPostById(1)과 일치하게됩니다.
api.endpoints.getPostById.useQuery(1)
useGetPostByIdQuery(1)
useGetPostByIdQuery(1, { ...options })
dispatch(api.endpoints.getPostById.initiate(1))
dispatch(api.endpoints.getPostById.initiate(1, { ...options }))
이 경우에는 위 호출 중 하나를 통하여 생성되었을 수도 있는 캐시항목과 일치하게 됩니다.
getProduct: builder.query({ << 여기서는 getProduct입니다.
query: (count?: string | number) => ({
url: count ? `/products?count=${count}` : `/products`,
제 상황으로 치면 이 예제에서 count값에 1을 넣어준것과 같은 효과를 보인다고 볼 수 있겠네요
😀이제 프로젝트에 적용
이 updateQuery가 제 니즈를 정확히 충족시켜줄 수 있을 것 같습니다.
예제를 보면서 자신감도 생겼습니다.
그 이전에 updateQuery를 사용할 때 주의해야할 사항을 다시 한번 기억해보고 가겠습니다.
1. updateQuery를 사용하기 위해서는 createApi 함수가 반환한 객체가 필요하다 (즉 모듈화해두었다면 import 해야한다.) |
2. updateQuery의 인자는 총 세개가 있는데 첫번째 인수에 들어가야할 값은 내가 만든 쿼리의 이름(키값)이다. |
3. updateQuery의 두번째 인수에 들어가야할 값은 내가 만든 쿼리 함수의 인수에 들어갈 값이다. |
4. updateQuery를 이용하여 캐싱데이터를 변경시키고 싶다면 dispatch함수로 한번 updateQuery를 감싸주어야한다. (useDispatch 함수를 사용하거나 connect를 이용해야할 것이다.) |
5. updateQuery의 세번째 인수에 들어가야할 값은 함수이다. 그런데 이 함수는 immer와 같기에 불변성이 보장된다. 따라서 바로 값을 변경하듯이 작성하는 리덕스툴킷의 문법을 그대로 적용해도 된다. |
좋습니다.
간단히 요약도 된 것 같네요
이제 실제로 적용을 해보도록 하겠습니다.
export const cozShoppingAPI = createApi({
대충 쿼리 내용 어쩌구저쩌구
getProduct: builder.query(~~~)
})
먼저 createApi의 반환값을 export해줍니다.
만약 타입스크립트를 사용한다면 draft에 넣어줄 타입값도 같이 export해주면 좋습니다.
전 타입스크립트를 사용하고 있으니 같이 export해주겠습니다.
이제 logic이 필요한 곳으로 돌아와 필요한 것들을 import 해주겠습니다.
import 해야할 것은 총 세가지입니다.(타입스크립트를 사용하지 않는다면 두가지)
react-redux가 제공하는 useDispatch()
내가 만든 createApi의 반환값
(TS 일시 : Api의 타입 인터페이스)
import { useDispatch } from 'react-redux';
import { CozApiInterface, cozShoppingAPI } from '../modules/cozShoppingAPI';
필요한 것들을 import 해주었습니다.
이제 updateQuery를 사용해보겠습니다.
const dispatch = useDispatch();
dispatch(cozShoppingAPI.util.updateQueryData('getProduct' , undefined , (draft:CozApiInterface[]) => {
const index = draft.findIndex((data:CozApiInterface) => data.id === 1)
draft[index].bookmark = !draft[index].bookmark
}))
이런 형식으로 사용해주면 되겠군요
간단하게 id가 1인 요소의 인덱스를 알아내고
그 요소의 bookmark에 담긴 불린을 반전시키는 코드를 작성했습니다.
여러분들도 자기만의 예제를 만들어서 redux development 도구를 이용해 테스트 해보세요
그런데 로직이 조금 지저분하기도하고
저 긴 내용들을 하나하나 작성하기가 귀찮다는 생각이 듭니다.
읽기 쉽게 조금 코드를 개선하고 커스텀훅으로 로직을 분리시켜주면 더 좋을 것 같네요
🥶커스텀 훅으로 로직분리
useBookmark.tsx
import { useDispatch } from 'react-redux';
import { CozApiInterface, cozShoppingAPI } from '../modules/cozShoppingAPI';
const useBookmark = () => {
const dispatch = useDispatch();
const patch = cozShoppingAPI.util.updateQueryData;
const bookmarkHanlder = (apiData: CozApiInterface): void =>
dispatch(
patch('getProduct', undefined, (draft: CozApiInterface[]) => {
const index = draft.findIndex(
(data: CozApiInterface) => data.id === apiData.id,
);
console.log(index);
if (index !== -1) {
draft[index].bookmark = !draft[index].bookmark;
} else {
console.log('일단 뭐라도 한다..');
}
}),
);
return bookmarkHanlder;
};
export default useBookmark;
위와 같이 커스텀훅으로 만들어주었습니다.
apiData를 인수로 받아 캐싱데이터에서 apiData와 id가 일치하는 요소의 인덱스를 찾아낸다음
해당 요소의 북마크 값을 반전시키는 코드입니다.
이제 어디서든 bookmark를 토글 해야하는 경우에는
간단하게 useBookmark를 호출하여 함수를 반환받고
그 함수에 api 객체를 넘겨주기만 하면 알아서 처리할 수 있게 되었습니다!
이후 로컬스토리지 로직을 추가할 예정이지만 우선 목표는 해결했기때문에 여기까지 작성하도록 하겠습니다.
읽어주셔서 감사합니다!
'redux' 카테고리의 다른 글
RTK Query 데이터에 local Storage를 사용하는 방법 (0) | 2023.05.15 |
---|---|
RTK Query의 Middleware에 대하여 (0) | 2023.05.14 |
예제와 함께 TS ReduxToolkit Query Slow start (1) | 2023.05.01 |
Redux와 Redux-toolkit (0) | 2023.04.15 |
상태관리 라이브러리 리덕스 이해하기 with parcel.. (0) | 2023.04.03 |