🦆 낙관적 업데이트란 무엇일까요?
영어로는 optimistic update
낙관적 업데이트는 UX 개선을 생각하는 프론트엔드 개발자라면 적용할 가치가 충분한 기법입니다.
낙관적 업데이트란 말그대로 낙관적인 관점으로 업데이트를 바라보는 것이라고 생각할 수 있습니다.
기본적으로 다른 서버에게 네트워크 요청을 보내는 행위는 수많은 잠재적인 위험을 내포합니다.
네트워크 요청이 성공할지 실패할지는 내가 작성한 코드가 얼마나 잘 작성되었는지와는 별개로
그 코드가 실행되는 환경 / 요청을 받는 서버의 환경에 전적으로 의존하게 되기 때문입니다.
나는 요청하는 코드를 잘 작성했지만 만약 인터넷이 되지 않는 환경이라면?
-> 당연하게도 네트워크 요청은 실패합니다.
나는 요청하는 코드를 잘 작성했지만 요청을 받은 서버가 내 요청을 처리하는 데 실패한다면?
-> 네트워크 요청은 성공했지만 서버는 개발자의 기대와 달리 400~500 번대 에러를 응답할 것입니다.
나는 요청하는 코드를 잘 작성했지만 CORS 에러에 당한다면?
-> 프리플라이트 요청에서 실패하고 우리는 에러를 반환받습니다.
그 외에도 수많은 위험들이 존재할 수 있습니다.
그래서 개발자는 네트워크 요청을 수행하는 코드를 작성할 때에
try .. catch 구문을 이용하여 만약 네트워크 요청이 실패했을 때를 대비한 코드를 작성합니다.
개발자는 네트워크 요청을 신뢰할 수 없습니다.
내 노력과는 상관없이 네트워크 요청 코드는 언제든지 실패할 수 있으니까요!
그래서 개발자들은 비관적으로 네트워크 요청 코드를 대합니다.
반면 낙관적 업데이트는 이와 반대로 네트워크 요청이 성공할것이라는
낙관적인 믿음을 가지고 업데이트를 진행한다는 것이라고 생각할 수 있습니다.
왜냐하면 비관적으로 네트워크 요청을 다루게 되면 프론트엔드에서 발생하는 커다란 문제가 있거든요!
바로 유저경험이 저해될 수 있다는 것인데요
이와 관련된 유명한 이슈 중 하나인 "좋아요" 이슈가 있습니다.
🐣 좋아요 이슈
당신은 인스타그램을 이용하고 있습니다.
마음에 드는 사진을 발견하면 "좋아요"를 누르고
비어있던 하트가 빨강색으로 채워진 하트로 변하는 것을 확인하죠!
그런데 사실 인스타그램은 당신이 좋아요를 누른 경우
서버와 통신하여 당신이 해당 게시물에 좋아요를 표시했다는 것을 알려야 합니다.
그리고 서버는 당신이 해당 게시물에 좋아요를 표시했으니 반영하라는 요청을 받은 뒤
데이터베이스에 좋아요 표시에 대한 처리를 수행하고 성공했다는 응답을 프론트엔드에 반환합니다.
우리는 서버로부터 잘 처리했다는 응답이 오기전까지
우리의 좋아요가 잘 반영 되었는지 확인할 방법이 없네요..
이제 필요한 것은 이 일련의 과정들이 완료 되고 성공했다는 응답을 기다린 뒤에 하트가 채워지는
1~3초 가량의 긴 시간을 너그러이 기다려줄 인내심 좋은 사용자들만 인스타그램을 이용해주길 기도하는 것입니다.
인스타그램은 대신 빨강색 하트로 채우는 일을 "낙관적으로 수행"합니다.
백엔드로부터 좋아요 작업이 성공했다는 확인을 받기 전에도
사용자에게 좋아요를 표시했다는 피드백을 시각적으로 보여줍니다.
네트워크 요청은 대부분의 상황에서 유효할 것이며 만약 실패한다면
실패에 대한 처리를 수행해주면 됩니다.
이것이 낙관적 업데이트의 기본적인 아이디어입니다.
이제 낙관적 업데이트를 구현하기 위한 준비물을 생각해봅시다.
🐝 낙관적 업데이트를 위한 준비물
낙관적 업데이트를 위한 준비물은 무엇이 있을까요?
차근차근 생각해봅시다.
1. 이전 상태를 기억할 수 있어야 합니다.
-> 만약 우리의 낙관이 틀렸을 때에는 이전 상태로 "롤백"을 시켜줄 수 있어야하니까요!
2. 서버의 응답을 예상할 수 있어야 합니다.
-> 우리의 낙관과 서버의 실제 응답이 다르면 요청이 성공해도 상태가 다를테니까요!
-> 즉 낙관적 업데이트는 서버의 응답을 예상하기 쉬운 작업에 유용합니다.
3. 만약 업데이트가 성공한다면 기존 상태를 적절하게 업데이트할 수 있어야 합니다.
-> 우리의 낙관이 맞았는지 체크하는 용도로도 사용할 수 있을거에요
생각보다 요구사항이 단순합니다.
꼭 라이브러리를 사용하지 않더라도 간단하게 해당 기능을 구현할 수 있을 정도로요!
하지만 리액트쿼리를 사용하면 이 동작들을 아주 간편하게
그리고 다른 많은 기능들과의 오케스트레이션을 수행할 수 있게해줍니다.
이번에는 리액트 쿼리를 이용하여 낙관적 업데이트를 구현하도록 하겠습니다.
실습 환경은 다음과 같습니다.
next.js : 13.5.4 / page router
@tanstack/react-query / ^4.36.1
typescript : 5 ^
css framework : tailwindcss
package manager : npm
🦂 코드와 함께하는 실습
아래 실습에 대한 모든 코드는 제 레포지토리에 공개되어 있습니다.
https://github.com/XionWCFM/optimistic-update-reactquery
먼저 원활한 테스트를 위해 next.js의 api-route 기능을 활용하여
간단한 api를 하나 작성해주도록 하겠습니다.
pages/api/hello.ts 파일에 다음과 같은 코드를 작성해주세요
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
export type StateType = {
isOn: boolean;
};
const state: StateType = {
isOn: false,
};
let count = 0;
const randomChange = () => {
count += 1;
let success = false;
if (count % 2 === 0) {
success = true;
state.isOn = !state.isOn;
}
return {
success: success,
state: state,
};
};
export default function handler(req: NextApiRequest, res: NextApiResponse) {
switch (req.method) {
case 'POST':
const result = randomChange();
if (result.success) {
return res.status(201).json(result.state);
} else {
return res.status(500).json({ error: '변경에 실패했습니다.' });
}
case 'GET':
return res.status(200).json(state);
default:
return res.status(403).json({ error: '허용되지 않는 메서드입니다.' });
}
}
아주 간단한 코드인데요
POST 요청에 대하여 count가 짝수일때는 서버의 상태를 바꿔주고
count가 홀수일 때에는 서버의 상태를 바꾸지 않고 500 에러를 반환하는 api입니다.
GET 요청일 때에는 서버의 상태를 반환해주고요!
이제 /api/hello에 대한 POST 요청은 절반은 성공하고 절반은 실패할 것 입니다.
Lazy initialization
next.js의 page router에서 리액트쿼리를 사용하기 위해서는 약간의 준비과정이 필요합니다.
_app.tsx에서 아래 코드를 작성해주세요
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = React.useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
);
}
이와 같이 useState에 값 대신 함수를 넘기는 기법을 lazy initialization이라고 부릅니다.
이 lazy initialization을 적용하면 오직 state가 처음 생성될 때에만 실행되고
이후 리렌더링에 대해서는 기존 값을 재활용합니다.
기본적으로는 비싼 연산을 한번만 하기 위한 용도로 사용되곤합니다만
이번에는 컴포넌트의 수명주기마다 하나의 쿼리클라이언트만 생성하기 위한 용도로 사용합니다.
이제 pages/index.tsx에서 코드를 작성해보겠습니다.
Get 요청을 통해 상태를 읽어오는 useQuery 함수와
Post 요청을 통해 서버 상태를 변화시키는 useMutation 함수를 작성할 것인데요
먼저 간단한 useQuery 함수부터 작성해보겠습니다.
const randomQuery = useQuery<StateType>({
queryFn: async () => {
const response = await fetch(`/api/hello`);
const data = await response.json();
return data;
},
queryKey: ['random'],
});
if (randomQuery.isLoading || !randomQuery.data) {
return <div className="">로딩중입니다.</div>;
}
pages/api/hello에서 정의하고 export 해줬던 StateType을 import 해서 사용해줍니다.
useQuery의 첫번째 제네릭에는 요청의 반환값에 대한 타입이 들어가게 됩니다.
<main className=" flex justify-center items-center flex-col">
<div
className={` h-10 w-10 my-20 ${
randomQuery.data.isOn ? 'bg-blue-700' : 'bg-red-700'
}`}
></div>
<button
className=" px-4 py-2 rounded-lg text-white font-bold hover:opacity-80 bg-blue-600"
>
뮤테이션 날리기
</button>
</main>
jsx도 작성해줍시다. randomQuery의 isOn 값에 따라
박스의 배경색이 파랑 / 빨강을 오가는 코드입니다.
잘 작성했다면 이제 이런 간단한 화면이 구성되었을 것입니다.
처음에는 isOn 값이 false로 시작하니 빨강색 박스가 생기는 것을 확인할 수 있어요
이제 핵심적인 Mutation 함수를 작성하기 전에!!
Error 객체를 확장하여 Custom Error 객체 만들기
이번 실습에서는 Axios가 아닌 fetch를 사용할 것입니다.
Axios의 경우에는 200번대 응답이 아니면 AxiosError를 throw해주지만
fetch는 네트워크 요청 자체가 실패한게 아니라면 error를 throw하지 않습니다.
따라서 400~500번대의 응답이 오더라도 error로 처리하지 않습니다.
이를 해결하기 위해 fetch를 사용할 때에는 response의 ok 프로퍼티의 값을 통해
분기를 쳐주고 직접 error를 throw 해주어야하는데요
기본 Error 객체를 그대로 쓰기보다는 필요한 값들을 넣어서 확장해주면
더욱 유용하고 견고하게 코드를 설계할 수 있습니다.
자유로운 경로/파일에 다음과 같이 커스텀 에러 클래스를 작성해봅시다.
interface FetchErrorArgument {
message: string;
status: number;
}
export class FetchError extends Error {
status: number;
constructor(error: FetchErrorArgument) {
super(error.message);
this.status = error.status;
}
}
간단하게 http 상태코드에 대한 정보를 지닌 status 프로퍼티를 갖고있는
FetchError 클래스를 만들어주고 만약 fetch에서 에러가 발생한다면
FetchError를 throw 해주도록 하겠습니다.
이런 커스텀 에러 객체를 reactquery와 통합하는 방법은 제네릭을 통하여 수행할 수 있습니다.
커스텀 에러 객체를 타입스크립트에서 사용하는 방법
interface ContextType {
previousData: StateType;
}
interface MutationArgument {
dummy?: string;
}
const randomMutation = useMutation<
StateType,
FetchError,
MutationArgument,
ContextType
>
다음은 useMutation에 전달해줄 수 있는 4개의 제네릭 인수에 대한 코드입니다.
순서대로 나열해서 보면 다음과 같습니다.
첫번째 제네릭 -> mutation의 반환 값
두번째 제네릭 -> error가 발생했을때 에러객체의 타입 / onError, onSettled의 error 인수에서 활용됨
세번째 제네릭 -> mutationFn의 인수의 타입 / mutateFn의 인수 / on~ 함수들의 variable 인수에서도 활용
네번째 제네릭 -> onMutate 함수의 리턴 타입 / onSettled의 4번째 인수에 전달됨
즉 우리가 만든 에러객체는 2번째 제네릭으로 전달해주면 되겠습니다.
이렇게 제네릭으로 전달해주고 나면 onError와 같은 함수의 인수타입이
unknown에서 우리가 넣어준 2번째 제네릭의 타입으로 추론되어 편리합니다.
이제 준비가 끝났으니 본격적으로 mutation 함수를 작성해봅시다.
interface ContextType {
previousData: StateType;
}
interface MutationArgument {
dummy?: string;
}
const queryClient = useQueryClient();
const randomMutation = useMutation<
StateType,
FetchError,
MutationArgument,
ContextType
>({
mutationFn: async () => {
const response = await fetch(`/api/hello`, {
method: 'POST',
});
const data = await response.json();
const isFailed = !response.ok;
if (isFailed) {
throw new FetchError({
status: response.status,
message: data.error,
});
}
return data;
},
onMutate: async (data): Promise<{ previousData: StateType }> => {
await queryClient.cancelQueries({ queryKey: ['random'] });
const previousData = queryClient.getQueryData(['random']) as StateType;
queryClient.setQueryData(['random'], () => {
const optimisticData: StateType = {
isOn: !previousData.isOn,
};
return optimisticData;
});
return { previousData };
},
onError: (err, variable, context) => {
if (context !== undefined) {
queryClient.setQueryData(['random'], context.previousData);
}
},
onSettled: (data) => {
queryClient.invalidateQueries({
queryKey: ['random'],
});
},
});
코드양이 조금 많아서 혼란스러울 수도 있을 것 같아요
바로 읽히는 분은 여기서 멈추셔도 될 것 같구요
바로 읽히지 않더라도 차근차근 살펴보면 전혀 어렵지 않으니 코드를 쪼개서 바라보아봅시다.
response.ok
mutationFn: async () => {
const response = await fetch(`/api/hello`, {
method: 'POST',
});
const data = await response.json();
const isFailed = !response.ok;
if (isFailed) {
throw new FetchError({
status: response.status,
message: data.error,
});
}
return data;
},
response.ok 는 Response 응답이 성공했는지를 체크하는 boolean값의 프로퍼티입니다.
응답의 성공 여부 기준은 응답 status code가 200~299 범위 내에 있는지를 통해 체크하게됩니다.
즉 200~299 범위 밖의 스테이터스 코드가 들어오면 false
200~299 범위 안의 스테이터스 코드라면 true인것입니다.
그리고 만약 스테이터스 코드가 false라면 우리가 미리 만들어두었던 에러객체를 throw 하는거죠!
이 throw 문이 없으면 아무리 서버가 400~500에러를 응답하더라도
에러로 취급되지 않기 때문에 리액트 쿼리의 onError 함수도 실행되지 않게됩니다.
onMutate
const queryClient = useQueryClient();
onMutate: async (data): Promise<{ previousData: StateType }> => {
await queryClient.cancelQueries({ queryKey: ['random'] });
const previousData = queryClient.getQueryData(['random']) as StateType;
queryClient.setQueryData(['random'], () => {
const optimisticData: StateType = {
isOn: !previousData.isOn,
};
return optimisticData;
});
return { previousData };
},
reactquery에서 제공해주는 useQueryClient() 함수를 통해 queryClient를 사용할 수 있습니다.
onMutate는 mutation 함수가 실행되기 이전에 실행되는 함수입니다.
즉 이 코드가 가지는 의미는 다음과 같습니다.
queryClient.cancelQueries({queryKey:['random']})
을 통하여 random이라는 쿼리키를 사용하고있는 쿼리들의 refetch를 모두 취소하겠다는 의미입니다.
이 작업이 필요한 이유는 우리가 낙관적 업데이트를 수행하여 값을 미리 업데이트 하였는데
수행중이던 refetch로 인해 우리의 낙관적인 값이 실제 서버상태값으로 덮어씌워지는 것을 막기 위함입니다.
그 이후 queryClient.getQueryData()를 이용하여 해당 쿼리키에 대한 데이터를
queryClient에서 가져옵니다. 이 값은 인수로 넣은 쿼리키가 가지고있는 상태가 됩니다.
우리는 ['random'] 쿼리키가 갖고있는 상태가 StateType이라는 것을 알고있으니
as 문을 통하여 typescript에게 알려주도록 합니다.
이 작업이 필요한 이유는 이전 상태를 기억해두어야 낙관적 업데이트가 실패했을때에
기존 값으로 롤백을 수행할 수 있기 때문입니다.
queryClient.setQueryData(['random'], () => {
const optimisticData: StateType = {
isOn: !previousData.isOn,
};
return optimisticData;
});
그 이후 setQueryData를 통하여 해당 키값에
우리가 기대하는 낙관적인 값을 넣어줍니다.
우리는 isOn 값이 지금 상태에서 반전되어있기를 기대하니 그렇게 작성해줍니다.
onMutate 함수에서 우리가 수행한 일은 다음과 같아요
1. 이전 상태를 기억한다.
2. 낙관적인 값으로 상태를 미리 업데이트해둔다.
onError
onError: (err, variable, context) => {
if (context !== undefined) {
queryClient.setQueryData(['random'], context.previousData);
}
},
이제 우리의 낙관적인 업데이트가 실패했을 때의 코드를 작성해봅시다.
context에는 우리가 onMutate에서 반환한 값이 들어오게됩니다.
setQueryData를 이용하여 미리 기억해둔 이전상태로 롤백하는 작업을 수행해줍니다.
onSettled
onSettled: (data) => {
queryClient.invalidateQueries({
queryKey: ['random'],
});
},
onSettled는 요청이 성공했든 실패했든 실행되는 함수입니다.
invalidateQueries를 이용하여 random 키를 invalid 시키는 것을 통해
random 키값을 가지고 있는 쿼리들이 데이터 가져오기를 다시 수행하도록 만듭니다.
const buttonHandler = () => {
randomMutation.mutate({});
};
이제 버튼을 누를때마다 실행될 핸들러함수를 만들어주고
onClick 이벤트에 부착해준뒤 실습해봅시다.
낙관적으로 업데이트를 수행했다가 서버 요청이 실패하면 원래 상태로 잘 롤백시키는것을 볼 수 있습니다.
개발자 도구를 통해 네트워크 속도를 의도적으로 낮춰놓고 테스트하면 더욱 크게 차이를 느낄 수 있어요
🐦⬛ 마치며
낙관적 업데이트는 유저 경험에 긍정적인 영향을 크게 줄 수 있는 기법입니다.
잘 기억해두고 적용해보시면 좋을 것 같습니다.
긴 글이지만 읽어주셔서 감사합니다.
🐨 레퍼런스
https://medium.com/@kyledeguzmanx/what-are-optimistic-updates-483662c3e171
https://tanstack.com/query/v4/docs/react/guides/optimistic-updates
https://yceffort.kr/2020/10/IIFE-on-use-state-of-react
https://developer.mozilla.org/en-US/docs/Web/API/Response/ok
'react' 카테고리의 다른 글
리액트 쿼리 v5 에서 바뀐 점들 체크해봐요 (2) | 2023.11.15 |
---|---|
리액트 라이브러리 없이 캘린더, 달력 구현하기 (4) | 2023.10.31 |
리액트 타이핑효과 커스텀 훅 만들기 (0) | 2023.08.15 |
what is react server components 이..이거 뭐냐? (2) | 2023.08.07 |
리액트를 사용하는 이유는 무엇일까? (2) | 2023.08.04 |