Skip to content

Conversation

@dkr-sjr
Copy link

@dkr-sjr dkr-sjr commented Jan 2, 2026

안녕하세요, 김민석 리뷰어님!
오랜만에 뵙게 되어 반갑습니다. 벌써 2026년이 되었습니다. 시간이 참 빠른 것 같네요!
올해에 좋은 일 있길 바라며, 남은 미션도 잘 부탁 드리겠습니다! 감사합니다.

미션 요구 사항

  • TanStack Query를 사용해 서버 상태를 클라이언트 상태와 분리하고, 효율적인 데이터 캐싱과 요청 관리를 구현해 보세요.
    • 쿼리 및 뮤테이션 설정은 명확한 이유가 있다면 자유롭게 변경해도 좋습니다.
  • TanStack Query를 사용하는지, 서버 상태와 클라이언트 상태를 분리 하였을 때 어떤 점이 달랐는지, 또 trade-off가 있는지 적어주세요.
    • 기술적인 것도 좋고 개발자의 경험 측면에서도 좋습니다.
  • TanStack Query Devtools를 이용하여 Query의 변화와 Mutation의 발생을 확인해보세요.
  • (선택) 뮤테이션 로직에 낙관적 업데이트(Optimistic Update)를 적용해 보고 어떤 상황에서 낙관적 업데이트가 효과적인지, 그리고 주의해야 할 점은 무엇인지 적어주세요.
    • Browser Throttling 기능을 활용하여 네트워크 속도를 느리게 설정한 뒤 낙관적 업데이트가 실제로 어떻게 동작하는지 확인해 보세요.

미션 진행 내용

App
  MainContent
  AsideContent

현재 코드는 위와 같은 구조로 작성되어 있으며, App에서는 상태를 저장하거나 전역 상태를 불러오고 있지 않고, MainContentAsideContent에서 zustand를 사용해서 필요한 상태와 액션을 불러와, 하위 컴포넌트에 proprs로 전달중입니다.

이번 미션은 TanStack Query를 적용해 서버 상태와 클라이언트 상태를 분리하는 것이었습니다.
서버에 저장되는 상태를 restaurantInfoList로 관련 api를 TanStack Query에 적용해 주었습니다.
useQueryuseMutation 매번 실행시켜 주는 대신 커스텀 훅으로 만들어 주었습니다.

useQuery의 경우 staleTime은 1분으로 gcTime은 5분으로 해주었습니다.
여러 사람이 레스토랑 추가 기능을 사용하더라도, 그렇게 많이 호출되는 기능이라고 생각하지 않아 staleTime을 1분으로 해주었습니다. 점심 뭐 먹지는 여러 페이지가 존재하지 않고 MainContent가 항상 렌더링 되기 때문에 'restaurantInfoList' 쿼리 키를 가진 데이터는 비활성화 될 일이 없기에 사실상 gcTime은 돌아갈 일이 없어 기본 값인 5분으로 설정해주었습니다.

useMutation의 경우 낙관적 업데이트를 적용해 주었습니다.
onMutate에서 cancelQueries로 진행중인 fetch를 중단시키고, 현재 캐싱되어 있는 값을 가져와 저장한 다음 서버에 값이 업데이트 되기 전에 setQueryData를 통해 클라이언트 값을 미리 업데이트 해주었습니다. 그 이후 onError에서 서버 업데이트에 오류가 난 경우 onMutate에서 저장해준 값으로 미리 업데이트 한 것을 롤백하게 해주었습니다. onSettled에서 업데이트 성공, 실패와 상관없이 서버와 동기화 해주었습니다. 낙관적 업데이트 도중 isPending을 이용해 서버와 통신하는 사이에 GlobalNavigationBar에 음식점 추가 버튼을 누를 경우 새로운 데이터 추가를 하지 못하도록 해주었습니다.
화면 캡처 2026-01-02 044843

TanStack Query 사용

서버에서 데이터를 가져와 사용하는 경우 시간이 지나면 불러온 데이터가 서버에 있는 데이터와 다른 경우가 생기게 됩니다. 지금은 json 서버를 사용 중이기에 여러 사용자가 접속하는 경우가 없지만, 여러 사람이 동시에 새로운 레스토랑을 추가하게 된다면 사용자의 클라이언트는 서로 다른 화면을 보여주게 될 것입니다. 이 때 TanStack Query는 신선도를 사용하여 데이터를 자동으로 refetching 하여 서버와 동기화 해줍니다. TanStack Query를 사용하지 않고 직접 서버와의 동기화를 구현할 경우 동기화가 필요한 시점을 결정하고 필요한 시점마다 직접 서버 데이터와 클라이언트 데이터를 비교하여 다시 업데이트 하는 등 코드가 많이 길어지고 복잡한 작업이 필요 했을 것 같습니다. 이 과정을 라이브러리에서 자동으로 관리해줘서 많이 편리한 것 같습니다.

이전 2.2 미션에서는 api를 통해 불러온 데이터를 zustand를 사용해서 저장한 후 사용했습니다. 이때도 서버 상태와 클라이언트 상태가 분리되어 있긴 하지만, 데이터를 관리하는 방식이 서버 데이터가 아닌 클라이언트 데이터만 있는 것처럼 관리했습니다. api로 불러온 데이터를 사용할 때 fetch한 시점으로부터 시간이 지나면 해당 데이터가 아직 서버 데이터와 동일한지 유효성 검사가 필요했지만 이 코드가 없었습니다. TanStack Query를 사용함으로서 이 부분이 해결되었습니다.

낙관적 업데이트는 사용자가 서버에 요청한 작업이 즉시 완료된 것처럼 클라이언트 상태를 업데이트 하는 것입니다. 실제로는 서버와 아직 통신 중이지만, 사용자는 요청한 작업이 바로 완료되어 적용된 화면을 보게 됩니다. 사용자 측면에서는 서버와의 통신 시간이 짧든 길든 동일한 경험을 할 수 있다는 점에서 좋은 것 같습니다. 대신 사용자가 요청한 작업이 서버에서 거부된 경우 클라이언트 상태는 요청이 성공한 것을 가정하고 변경되어 있기 때문에 서버와 상태가 달라지게 됩니다. 이러한 서버 요청이 거부된 경우를 고려하여 데이터를 롤백하는 과정이 꼭 필요합니다.
요청한 작업이 승인될 확률이 높은 경우나 단순한 작업의 경우 낙관적 업데이트를 사용하기에 적절해 보이지만 거부될 확률이 높거나 중요한 작업인 경우 낙관적 업데이트를 사용한다면 오히려 승인된 것처럼 보이다가 다시 돌아가기에 사용자가 불쾌한 기분을 느낄게 될 것 같습니다.


구조도

image

실행영상

adv-2.3.mp4

이상한 부분이나 고칠 부분 알려주시면 바로 반영하겠습니다.
항상 리뷰해 주셔서 감사드립니다!

Copy link
Member

@shackstack shackstack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요, 강건님! 새해덕담 감사합니다.
건님도 좋은 일만 있길 바래요 🙌

짧은시간동안 공부하고 적용하느라 고생하셨는데, 시간이 짧아서인지 설명주신 부분에서 이해가 안되는 부분이 있었어요. 그래서 몇가지 질문 남겨봅니다.

useQuery의 경우 staleTime은 1분으로 gcTime은 5분으로 해주었습니다.
여러 사람이 레스토랑 추가 기능을 사용하더라도, 그렇게 많이 호출되는 기능이라고 생각하지 않아 staleTime을 1분으로 해주었습니다. 점심 뭐 먹지는 여러 페이지가 존재하지 않고 MainContent가 항상 렌더링 되기 때문에 'restaurantInfoList' 쿼리 키를 가진 데이터는 비활성화 될 일이 없기에 사실상 gcTime은 돌아갈 일이 없어 기본 값인 5분으로 설정해주었습니다.

1분으로 설정해주신 staleTime이 완료되면 앱에선 어떤일이 벌어질까요? 어떤 동작을 기대하고 이렇게 설정을 해주신건지 알려주시면 감사하겠습니다.

TanStack Query는 신선도를 사용하여 데이터를 자동으로 refetching 하여 서버와 동기화 해줍니다.

현재처럼 staleTime이 1분으로 설정하면 언제 자동으로 refetching 될까요?

캐싱된 데이터는 어느시점에 메모리에서 해제될까요?

onClose={() => updateClickedRestaurantID(null)}
restaurantInfo={clickedRestaurantInfo}
/>
{!isLoading && !isError && (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isLoading 일 때에는 혹은 isError일 때에는 각각 어떻게 처리되어햐 할까요?

Copy link
Author

@dkr-sjr dkr-sjr Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모달의 경우엔 데이터가 아직 로딩중이거나 에러가 있을 때 단순하게 모달을 보여주지 않도록 해주었는데,
그보다는 어떤 상황인데 사용자에게 보여 주는게 좋을 것 같습니다.
각 상황에 맞게 알맞은 텍스트를 보여주도록 수정했습니다.

  const getRestaurantInfo = () => {
    if (isLoading) return { name: '로딩중...', description: '' };
    if (isError) return { name: 'Error!', description: error.message };
    return clickedRestaurantInfo;
  };
  const restaurantInfo = getRestaurantInfo();

다음과 같이 로딩이거나 에러가 있을 때 해당하는 내용을 restaurantInfo객체로 만들어 넘겨주었습니다.

const { data: restaurantInfoList, isLoading, isError } = useRestaurantInfoListQuery();
const { mutate: addRestaurantInfo } = useAddRestaurantInfoMutation();

const isVisibleAddRestaurantModal = useAddRestaurantModalStore(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 상태가 의미하는 바는 무엇인가요?
'보이는 레스토랑 추가 모달인지아닌지 여부'라고 읽혀지는데, 의도하는 바가 맞을까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddRestaurantModal 컴포넌트를 보여줄지 말지 저장해주는 상태입니다!
어울리지 않은 이름일까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 형용사랑 명사위치를 바꾸는게 좋을 것 같네요.

isVisibleAddRestaurantModal: 보이는 AddRestaurant 모달이냐?
isAddRestaurantModalVisible: AddRestaurant이 보이냐?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변경했습니다!

import styled from 'styled-components';
import { useMutationState } from '@tanstack/react-query';

export default function GlobalNavigationBar({ onClickAddButton }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구조가 다소 특이한것 같습니다. onClickAddButton를 넘겨받는 이유가 궁금하네요.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onClickAddButtonshowAddRestaurantModal를 넘겨받고 있습니다.
스토어 내부에서 Zustand로 함수를 받아올 수 있지만, 컴포넌트의 의존성을 줄이기 위해서 props로 넘겨주었습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스토어 내부라는 표현은 컴포넌트 내부를 잘못 표현한거겠죠?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의존성을 줄이기 위한 의도치곤 다른 영역은 너무 도메인 종속적인 것처럼 보이네요

 const status = useMutationState({
    filters: { mutationKey: ['addRestaurantInfo'] },
    select: (mutation) => mutation.state.status,
  });
     <GNBTitle>점심 뭐 먹지</GNBTitle>
      <GNBButton type="button" aria-label="음식점 추가" onClick={onClickAddButton}>
      <GNBButton type="button" aria-label="음식점 추가" onClick={handleClick}>
        <img src="templates/add-button.png" alt="음식점 추가" />

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 것이 맞습니다. 필요한 함수를 props로 넘겨주기로 결정한 이유는 말씀드린 것 처럼 종속성을 줄이고자 한 것이지만, 다른 부분은 고려되어 있지 않습니다.

처음 여쭤보신

구조가 다소 특이한것 같습니다. onClickAddButton를 넘겨받는 이유가 궁금하네요.

이 질문이 왜 굳이 props로 함수를 넘겨서 사용 하는지를 물어보신 것이 맞을까요? 제가 이해한 것이 맞다면 답변은 '의존성을 줄이기 위해서'이지만, 적절치 않은 방법이었던 같습니다.

컴포넌트들 중에 이미 컴포넌트가 종속적인데 의존성을 줄이기 만을 위해 props를 넘겨 받던 컴포넌트들을 내부에서 상태를 불러오는 것으로 변경했습니다.

GlobalNavigationBar
CategoryFilter
RestaurantList
AddRestaurantModal
RestaurantDetailModal

다음과 같은 컴포넌트를 수정했습니다.

Comment on lines 5 to 17
const status = useMutationState({
filters: { mutationKey: ['addRestaurantInfo'] },
select: (mutation) => mutation.state.status,
});
const isPending = status.includes('pending');

const handleClick = () => {
if (isPending) {
alert('데이터를 추가중입니다! 잠시만 기다려주십시오.');
} else {
onClickAddButton();
}
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

레스토랑 추가모달과 관련된 상태를 이 컴포넌트가 알아야하는 것이 딱히 유지보수에 좋아보이지 않습니다.
pending상태인동안 레스토랑 추가모달이 닫히지 않도록만 만들어도, 이 버튼이 눌려질 일은 없을 것 같은데 어떻게 생각하시나요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

낙관적 업데이트를 적용하는 과정에서 이렇게 작성하게 되었습니다. `pending 상태 동안 모달이 닫히지 않게 하면 낙관적 업데이트를 적용하는 이유가 없어지기 때문입니다.

사용자가 새로운 레스토랑을 추가하였을 때, 서버에는 아직 데이터가 추가되지 않았지만 클라이언트에서는 이미 추가된 것 처럼 보이게 됩니다. 이때 서버에 데이터가 아직 성공적으로 추가되지 않은 상태에서 새로운 데이터를 추가하는 동작을 방지하고자 하였습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 기능에서는 낙관적 업데이트를 쓰는 것이 적합하진 않습니다. 학습을 목적으로 적용해봤다는 점에서는 이해는 되지만 그로인해 유지보수에도 악영향을 준다면 걷어내는 것이 좋을 것 같네요.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후 낙관적 업데이트 사용을 고려할 때 유지보수 측면도 고려해서 생각해보겠습니다. 감사합니다.

다른 답변을 작성하는 과정에서 뮤테이션을 진행하는 도중에는 뮤테이션으로 인한 리패치가 이루어지지 않도록 코드를 수정했습니다. 이를 고려하면 뮤테이션 도중 버튼을 누르지 못하게 하지 않아도 될 것 같아 이 기능을 제거하도록 하겠습니다.


const filteredRestaurantInfoList = (
category === '전체' ? restaurantInfoList : restaurantInfoList.filter(
category === '전체' ? restaurantInfoList : restaurantInfoList?.filter(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

옵셔널 체이닝을 하는 이유가 궁금합니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MainContent의 return 문에서 restaurantInfoList의 로딩이나 오류 여부에 따라 렌더링 여부를 결정해 주고 있지만, RestaurantList의 렌더링 여부와 상관없이 filteredRestaurantInfoList는 매번 계산이 됩니다. 이때 restaurantInfoList가 아직 로딩되지 않거나 에러로 인해 undefined 이거나 null 인 경우에 fillter를 실행하고자 하는 것을 막기 위해 옵셔널 체이닝을 사용하여 데이터가 유효하지 않을 때는 바로 undefined를 반환하도록 해주었습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 restaurantInfoList가 undefined가 될 수 있죠.
그런데 변수명이 filteredRestaurantInfoList인데 undefined가 할당될 수 있는 건 어색하지 않으신가요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변수 이름을 생각한다면 undefined보다는 빈 리스트를 할당하는게 어울리는 것 같습니다.

  const getFilteredRestaurantInfoList = () => {
    if (!restaurantInfoList) {
      return [];
    }
    if (category !== '전체') {
      return restaurantInfoList.filter((restaurantInfo) => (restaurantInfo.category === category));
    }
    return restaurantInfoList;
  };
  const filteredRestaurantInfoList = getFilteredRestaurantInfoList();

restaurantInfoListundefined인 경우는 빈 리스트를 가지도록 하는 코드로 변경했습니다!

Comment on lines 13 to 19
const {
data: restaurantInfoList,
isLoading,
isError,
isSuccess,
error,
} = useRestaurantInfoListQuery();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 데이터는 RestaurantList 컴포넌트에서만 필요한 것처럼 보입니다.
useRestaurantInfoListQuery를 호출하는 위치가 MainContent 인 이유가 있을까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

데이터를 보면 모두 MainContentAsideContent에서만 상태를 저장하고 불러오고 있습니다. 처음 전역 변수를 도입할 때, 컴포넌트에서 전역변수를 사용하게 되면 해당 컴포넌트는 해당 전역 변수에 의존성을 가지게 되어 나중에 컴포넌트를 재사용하기 힘들어 집니다. 이를 방지하고자 상위 컴포넌트에서 상태를 불러오기 아래로 props로 넘겨주는 방식을 사용하였습니다.
사실 '점심 뭐 먹지'에서는 컴포넌트들을 재사용할 일이 없어서 컴포넌트 내부에서 상태를 불러와도 상관 없을 것 같기는 합니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모든 상황에서 재사용을 고려하시는군요 👍
재사용도 고려하면서, 비즈니스 로직을 관심사별로 분리할 수 있는 방안은 없을까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재사용할 컴포넌트와 그렇지 않은 컴포넌트를 확실히 구분해야 할 것 같습니다. 제가 작성한 코드를 보니 재사용을 고려할 컴포넌트가 아닌데도 무작정 재사용을 위하겠다고 작성한 것이 많은 것 같습니다.
재사용 가능하지 않은 컴포넌트에 비즈니스 로직을 작성하여 관리하면 관심사 별로 분리하기 용이할 것 같습니다. 제 코드를 보면 MainContentAsideContent에 로직이 집중되어 있었는데 이를 각 컴포넌트로 이전해 주었습니다.

Comment on lines +8 to +9
staleTime: 1000 * 60 * 1,
gcTime: 1000 * 60 * 5,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

읽기 쉽게 나누어 처리해주셨군요 👍

mutationFn: addNewRestaurantInfo,

onMutate: async (newInfo) => {
await queryClient.cancelQueries({ queryKey: ['restaurantInfoList'] });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancelQueries를 처리하는 이유가 궁금합니다.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onMutate에 할당한 함수를 보면 사용자가 데이터 추가를 요청한 시점에 있는 restaurantInfoList에 새로운 식당을 추가하고 해당 리스트를 렌더링 합니다. 이때 cancelQueries를 실행시키지 않는다면 다른 사용자로 인해 화면이 바뀌게 됩니다.
상황을 가정해보면
A 사용자가 먼저 1번 식당 추가 요청을 보냅니다.
B 사용자도 2번 식당 추가 요청을 보냅니다.
B 사용자 화면은 2번 식당이 추가된 리스트를 렌더링 중입니다.
이때 A 사용자가 보낸 요청이 반영됩니다.
B 사용자 화면에 우연히 그 시점에 있던 Get 요청에 따라 1번 식당만 추가된 리스트로 변경됩니다.
이후 B 사용자가 보낸 요청이 반영되고 다시 1번 2번 식당이 추가된 리스트로 변경됩니다.
이렇게 순간 적으로 보여지는 화면이 여러번 바뀔 수 있습니다.

이때 cancelQueries를 실행해주면 Get 요청을 중단하고 2번 식당만 추가된 리스트를 보여주다가
1번 2번 식당이 둘 다 추가된 리스트로 변경하게 됩니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B 사용자 화면에 우연히 그 시점에 있던 Get 요청에 따라 1번 식당만 추가된 리스트로 변경됩니다
이후 B 사용자가 보낸 요청이 반영되고 다시 1번 2번 식당이 추가된 리스트로 변경됩니다.

어떻게 해야 이런 "GET요청이 두번 실행되는 상황"이 발생할 수 있을까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생각해보니 우연히 그 시점에 Get 요청이 있는 것이 아니라, 근접한 시간에 두 번의 데이터 추가 요청이 온다면 onSettledinvalidateQueries를 통해 Get 요청이 2번 일어나게 됩니다.
첫 번째 데이터 요청이 완료되고 실행된 Get요청은 적용되지 않은 시점에서 두 번째 데이터 요청이 있다면 이는 cancelQueries로 첫 번째 Get요청을 지울 수 있습니다. 하지만 첫 번째 데이터 추가 요청이 완료되기 전에 두 번째 데이터 추가 요청이 온다면 이때는 cancelQueries는 현재 실행 중인 Get요청을 지우는 것이기 때문에 이후에 요청된 Get은 중단 시킬 수 없습니다.

onSettledinvalidateQueries 실행 조건에 현재 뮤테이션이 진행 중이지 않을 때라는 조건을 추가하면 cancelQueries로 막고자 했던 낙관적 업데이트로 인해 짧은 시간 동안 화면이 여러 번 바뀌게 되는 현상을 해결하는데 도움이 될 것 같습니다.

    onSettled: () => {
      if (queryClient.isMutating({ mutationKey: ['addRestaurant'] }) === 0) {
        queryClient.invalidateQueries({ queryKey: ['restaurantInfoList'] });
      }
    },

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

잘 이해가 되지않습니다.

Get 요청이 2번 일어난다는게
B의 입장에서 봤을 때 A가 날린 invalidateQueries 1번, B가 날린 invalidateQueries 1번 그래서 총 2번이라는 말씀이신걸까요?

Comment on lines +29 to +34
onError: (error, newInfo, context) => {
if (context?.previousData) {
queryClient.setQueryData(['restaurantInfoList'], context.previousData);
}
alert(`${newInfo.name} 추가 실패: ${error.message}`);
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context.previousData는 어떤 데이터인가요?
해당 로직 설명한번 부탁드립니다!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

낙관적 업데이트의 방어 로직을 위한 데이터 입니다!
낙관적 업데이트는 요청한 작업이 성공한다는 것을 가정하고 미리 클라이언트 상태를 변경합니다. 하지만 해당 요청이 실패할 수 있는데, 이때 변경하기 전 클라이언트 상태로 다시 롤백하는 작업이 필요합니다. onMutate에서 업데이트 전 클라이언트 상태를 저장하고 return 해주면 이 데이터를 onError의 context 변수를 통해 받을 수 있습니다! 이렇게 받은 데이터로 롤백해줍니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSettled 에서 invalidateQueries를 해주는데도 이 과정이 필요한가요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onError이 없더라도 onSettledinvalidateQueries 를 통해 데이터가 결과적으로 정상적으로 돌아옵니다. 하지만 이제 네트워크의 속도가 느린 경우 invalidateQueries로 인한 리패치가 즉각 이루어 지지 않고 지연 시간이 발생하게 됩니다. onError에서 즉시 복구해주지 않는다면 사용자는 데이터 추가가 실패했다는 알람을 받지만 화면 상에서는 추가되어 있는 데이터가 그대로 존재하게 됩니다. 이를 방지 하기 위해서 onError에서 데이터를 복구해주는 것입니다.

네트워크가 매우 빠르다고 가정한다면 onError 코드는 없어도 될것 같습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도는 이해했고, 네트워크 속도가 느린환경에서는 괜찮은 전략같습니다.

하지만 한가지 고려해야할 점이 있다면, 화면전환이 여러번 발생할 수 있습니다.
기존 리스트 => 음식점 추가된 리스트 =(요청실패)=> 실패로 인해 원래로 복구한 리스트 => invalidateQueries로 받아온 리스트

invalidateQueries를 받아온 리스트가 기존 리스트랑 다를 수 있기 때문이에요.
그래서 더 나은 UX를 고려한다면 invalidateQueries를 하지 않는 것도 방법이 될 수 있습니다.

Copy link
Author

@dkr-sjr dkr-sjr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

질문에 답변 드리겠습니다!

1분으로 설정해주신 staleTime이 완료되면 앱에선 어떤일이 벌어질까요? 어떤 동작을 기대하고 이렇게 설정을 해주신건지 알려주시면 감사하겠습니다.

staleTime이 완료되면 데이터의 상태가 fresh에서 stale로 변하게 됩니다. 이때 바로 refetching이 일어나는 것은 아닙니다. 데이터의 상태가 stale 일때 특정 트리거가 일어나면 데이터를 refetching 하게 됩니다. 다르게 말하면 데이터가 아직 fresh 일 때는 특정 트리거가 있더라고 refetching 하지 않습니다. 점심 뭐 먹지 서비스의 경우 식당 추가 작업이 자주 발생하지 않을것 같다고 생각하였고, 1분 정도는 상태가 서버 상태와 다르더라도 괜찮다고 생각하여 1분으로 설정해 주었습니다!

현재처럼 staleTime이 1분으로 설정하면 언제 자동으로 refetching 될까요?

위에서 설명한 대로 1분이 지나고 상태가 stale이 되었을때 특정 트리거가 있으면 refetching 되게 됩니다.

  1. 다른 화면을 보다가 이 앱의 탭을 클릭하였을 때
  2. 다른 페이지를 보다가 해당 데이터가 사용되는 페이지로 이동하였을 때('점심 뭐 먹지'의 경우 페이지가 하나 밖에 존재하지 않기 때문에 이 트리거가 발생할 일은 없습니다.)
  3. 네트워크가 끊겼다가 다시 연결 되었을 때
  4. 코드로 invalidateQueries가 실행되었을 때

이러한 트리거가 있을 때 refetching이 일어나게 됩니다!

캐싱된 데이터는 어느시점에 메모리에서 해제될까요?

해당 쿼리를 사용하는 컴포넌트가 모두 화면에서 사라질 경우 해당 데이터의 상태는 Inactive 상태가 됩니다. 이때 설정한 gcTime에 해당하는 타이머가 실행됩니다. 타이머가 돌아가는 동안 데이터가 다시 사용되지 않는다면 타이머가 끝나는 시점에 캐싱된 데이터가 메모리에서 해제됩니다.

리뷰 감사드립니다!

const { data: restaurantInfoList, isLoading, isError } = useRestaurantInfoListQuery();
const { mutate: addRestaurantInfo } = useAddRestaurantInfoMutation();

const isVisibleAddRestaurantModal = useAddRestaurantModalStore(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddRestaurantModal 컴포넌트를 보여줄지 말지 저장해주는 상태입니다!
어울리지 않은 이름일까요?

onClose={() => updateClickedRestaurantID(null)}
restaurantInfo={clickedRestaurantInfo}
/>
{!isLoading && !isError && (
Copy link
Author

@dkr-sjr dkr-sjr Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모달의 경우엔 데이터가 아직 로딩중이거나 에러가 있을 때 단순하게 모달을 보여주지 않도록 해주었는데,
그보다는 어떤 상황인데 사용자에게 보여 주는게 좋을 것 같습니다.
각 상황에 맞게 알맞은 텍스트를 보여주도록 수정했습니다.

  const getRestaurantInfo = () => {
    if (isLoading) return { name: '로딩중...', description: '' };
    if (isError) return { name: 'Error!', description: error.message };
    return clickedRestaurantInfo;
  };
  const restaurantInfo = getRestaurantInfo();

다음과 같이 로딩이거나 에러가 있을 때 해당하는 내용을 restaurantInfo객체로 만들어 넘겨주었습니다.

import styled from 'styled-components';
import { useMutationState } from '@tanstack/react-query';

export default function GlobalNavigationBar({ onClickAddButton }) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onClickAddButtonshowAddRestaurantModal를 넘겨받고 있습니다.
스토어 내부에서 Zustand로 함수를 받아올 수 있지만, 컴포넌트의 의존성을 줄이기 위해서 props로 넘겨주었습니다.

Comment on lines 5 to 17
const status = useMutationState({
filters: { mutationKey: ['addRestaurantInfo'] },
select: (mutation) => mutation.state.status,
});
const isPending = status.includes('pending');

const handleClick = () => {
if (isPending) {
alert('데이터를 추가중입니다! 잠시만 기다려주십시오.');
} else {
onClickAddButton();
}
};
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

낙관적 업데이트를 적용하는 과정에서 이렇게 작성하게 되었습니다. `pending 상태 동안 모달이 닫히지 않게 하면 낙관적 업데이트를 적용하는 이유가 없어지기 때문입니다.

사용자가 새로운 레스토랑을 추가하였을 때, 서버에는 아직 데이터가 추가되지 않았지만 클라이언트에서는 이미 추가된 것 처럼 보이게 됩니다. 이때 서버에 데이터가 아직 성공적으로 추가되지 않은 상태에서 새로운 데이터를 추가하는 동작을 방지하고자 하였습니다.

Comment on lines 13 to 19
const {
data: restaurantInfoList,
isLoading,
isError,
isSuccess,
error,
} = useRestaurantInfoListQuery();
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

데이터를 보면 모두 MainContentAsideContent에서만 상태를 저장하고 불러오고 있습니다. 처음 전역 변수를 도입할 때, 컴포넌트에서 전역변수를 사용하게 되면 해당 컴포넌트는 해당 전역 변수에 의존성을 가지게 되어 나중에 컴포넌트를 재사용하기 힘들어 집니다. 이를 방지하고자 상위 컴포넌트에서 상태를 불러오기 아래로 props로 넘겨주는 방식을 사용하였습니다.
사실 '점심 뭐 먹지'에서는 컴포넌트들을 재사용할 일이 없어서 컴포넌트 내부에서 상태를 불러와도 상관 없을 것 같기는 합니다.


const filteredRestaurantInfoList = (
category === '전체' ? restaurantInfoList : restaurantInfoList.filter(
category === '전체' ? restaurantInfoList : restaurantInfoList?.filter(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MainContent의 return 문에서 restaurantInfoList의 로딩이나 오류 여부에 따라 렌더링 여부를 결정해 주고 있지만, RestaurantList의 렌더링 여부와 상관없이 filteredRestaurantInfoList는 매번 계산이 됩니다. 이때 restaurantInfoList가 아직 로딩되지 않거나 에러로 인해 undefined 이거나 null 인 경우에 fillter를 실행하고자 하는 것을 막기 위해 옵셔널 체이닝을 사용하여 데이터가 유효하지 않을 때는 바로 undefined를 반환하도록 해주었습니다.

mutationFn: addNewRestaurantInfo,

onMutate: async (newInfo) => {
await queryClient.cancelQueries({ queryKey: ['restaurantInfoList'] });
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onMutate에 할당한 함수를 보면 사용자가 데이터 추가를 요청한 시점에 있는 restaurantInfoList에 새로운 식당을 추가하고 해당 리스트를 렌더링 합니다. 이때 cancelQueries를 실행시키지 않는다면 다른 사용자로 인해 화면이 바뀌게 됩니다.
상황을 가정해보면
A 사용자가 먼저 1번 식당 추가 요청을 보냅니다.
B 사용자도 2번 식당 추가 요청을 보냅니다.
B 사용자 화면은 2번 식당이 추가된 리스트를 렌더링 중입니다.
이때 A 사용자가 보낸 요청이 반영됩니다.
B 사용자 화면에 우연히 그 시점에 있던 Get 요청에 따라 1번 식당만 추가된 리스트로 변경됩니다.
이후 B 사용자가 보낸 요청이 반영되고 다시 1번 2번 식당이 추가된 리스트로 변경됩니다.
이렇게 순간 적으로 보여지는 화면이 여러번 바뀔 수 있습니다.

이때 cancelQueries를 실행해주면 Get 요청을 중단하고 2번 식당만 추가된 리스트를 보여주다가
1번 2번 식당이 둘 다 추가된 리스트로 변경하게 됩니다.

Comment on lines +29 to +34
onError: (error, newInfo, context) => {
if (context?.previousData) {
queryClient.setQueryData(['restaurantInfoList'], context.previousData);
}
alert(`${newInfo.name} 추가 실패: ${error.message}`);
},
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

낙관적 업데이트의 방어 로직을 위한 데이터 입니다!
낙관적 업데이트는 요청한 작업이 성공한다는 것을 가정하고 미리 클라이언트 상태를 변경합니다. 하지만 해당 요청이 실패할 수 있는데, 이때 변경하기 전 클라이언트 상태로 다시 롤백하는 작업이 필요합니다. onMutate에서 업데이트 전 클라이언트 상태를 저장하고 return 해주면 이 데이터를 onError의 context 변수를 통해 받을 수 있습니다! 이렇게 받은 데이터로 롤백해줍니다.

const { data: restaurantInfoList, isLoading, isError } = useRestaurantInfoListQuery();
const { mutate: addRestaurantInfo } = useAddRestaurantInfoMutation();

const isVisibleAddRestaurantModal = useAddRestaurantModalStore(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 형용사랑 명사위치를 바꾸는게 좋을 것 같네요.

isVisibleAddRestaurantModal: 보이는 AddRestaurant 모달이냐?
isAddRestaurantModalVisible: AddRestaurant이 보이냐?

import styled from 'styled-components';
import { useMutationState } from '@tanstack/react-query';

export default function GlobalNavigationBar({ onClickAddButton }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스토어 내부라는 표현은 컴포넌트 내부를 잘못 표현한거겠죠?

import styled from 'styled-components';
import { useMutationState } from '@tanstack/react-query';

export default function GlobalNavigationBar({ onClickAddButton }) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의존성을 줄이기 위한 의도치곤 다른 영역은 너무 도메인 종속적인 것처럼 보이네요

 const status = useMutationState({
    filters: { mutationKey: ['addRestaurantInfo'] },
    select: (mutation) => mutation.state.status,
  });
     <GNBTitle>점심 뭐 먹지</GNBTitle>
      <GNBButton type="button" aria-label="음식점 추가" onClick={onClickAddButton}>
      <GNBButton type="button" aria-label="음식점 추가" onClick={handleClick}>
        <img src="templates/add-button.png" alt="음식점 추가" />

Comment on lines 5 to 17
const status = useMutationState({
filters: { mutationKey: ['addRestaurantInfo'] },
select: (mutation) => mutation.state.status,
});
const isPending = status.includes('pending');

const handleClick = () => {
if (isPending) {
alert('데이터를 추가중입니다! 잠시만 기다려주십시오.');
} else {
onClickAddButton();
}
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 기능에서는 낙관적 업데이트를 쓰는 것이 적합하진 않습니다. 학습을 목적으로 적용해봤다는 점에서는 이해는 되지만 그로인해 유지보수에도 악영향을 준다면 걷어내는 것이 좋을 것 같네요.

Comment on lines 13 to 19
const {
data: restaurantInfoList,
isLoading,
isError,
isSuccess,
error,
} = useRestaurantInfoListQuery();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모든 상황에서 재사용을 고려하시는군요 👍
재사용도 고려하면서, 비즈니스 로직을 관심사별로 분리할 수 있는 방안은 없을까요?


const filteredRestaurantInfoList = (
category === '전체' ? restaurantInfoList : restaurantInfoList.filter(
category === '전체' ? restaurantInfoList : restaurantInfoList?.filter(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 restaurantInfoList가 undefined가 될 수 있죠.
그런데 변수명이 filteredRestaurantInfoList인데 undefined가 할당될 수 있는 건 어색하지 않으신가요?

mutationFn: addNewRestaurantInfo,

onMutate: async (newInfo) => {
await queryClient.cancelQueries({ queryKey: ['restaurantInfoList'] });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B 사용자 화면에 우연히 그 시점에 있던 Get 요청에 따라 1번 식당만 추가된 리스트로 변경됩니다
이후 B 사용자가 보낸 요청이 반영되고 다시 1번 2번 식당이 추가된 리스트로 변경됩니다.

어떻게 해야 이런 "GET요청이 두번 실행되는 상황"이 발생할 수 있을까요?

Comment on lines +29 to +34
onError: (error, newInfo, context) => {
if (context?.previousData) {
queryClient.setQueryData(['restaurantInfoList'], context.previousData);
}
alert(`${newInfo.name} 추가 실패: ${error.message}`);
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSettled 에서 invalidateQueries를 해주는데도 이 과정이 필요한가요?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants