이전 포스트에서 모바일에서의 클릭 이벤트로 인해 6가지 이벤트가 연쇄적으로 발생하는 것을 발견했습니다.

touchstart → touchend → mousemove → mousedown → mouseup → click

 

이번에는 다양한 상황에서의 클릭 이벤트의 시퀸스를 테스트해보고 정리해보겠습니다.

또한, 여러가지 상황에 따른 이벤트 시퀸스가 다른 이유에 대해서도 탐구해보겠습니다.

 

먼저, 모바일과 데스크톱 상황에서의 클릭 이벤트는 서로 다르게 적용됩니다.

데스크톱은 마우스를 움직이는 도중에 특정 시점에서의 클릭으로 이벤트가 발생하지만, 모바일에서는 마우스는 존재하지 않고 특정 시점의 유저 손가락 터치로 이벤트가 발생하기 때문입니다.

 

따라서, 먼저 데스크톱 상황에서 발생하는 이벤트에 대해 살펴보겠습니다.

 

 

실험 방법

  1. 크롬 브라우저를 토대로 실험하였습니다.
  2. 클릭 이벤트와 관련된 이벤트를 body 에 eventListener 를 등록한 이후, 이벤트 발생시 로그를 찍도록 만들었습니다.
  useEffect(() => {
    ;['touchstart', 'touchmove', 'touchend', 'mousedown', 'mousemove', 'mouseup', 'click', 'contextmenu'].forEach(eventType => {
      document.addEventListener(eventType, event => {
        console.log(`Event detected: ${eventType}`)
        console.log(`Event target:`, event.target)
      })
    })
  }, [])

 

 

데스크톱 클릭 이벤트

데스크톱에서의 클릭 이벤트는 간단합니다.

마우스 클릭시 mousedown → mouseup → click 순으로 이벤트가 발생합니다.

데스크톱에서의 클릭 이벤트 시퀸스

 

모바일 클릭 이벤트

모바일에서의 클릭 시퀸스가 다양했습니다.

4가지 모바일 터치 이벤트 발생 상황에서의 이벤트 시퀸스는 다음과 같습니다.

모바일 환경 테스트를 위해서는 개발자 도구의 반응형 페이지를 관찰하는 “Toogle Device Toolbar” 기능으로 진행하였습니다.

 

 

1. 일반적인 터치

터치를 한 후에 마우스(손)을 이동하지 않고 빠른 시간 내에 원래 자리에서 떼는 경우입니다.

[1편] 에서 보았듯이, 6가지 이벤트 시퀸스가 연속적으로 발생하고 있습니다

  • touchstart > touchend > mousemove > mousedown > mouseup > click

일반적인 터치에서의 이벤트 시퀸스

 

2. 일반적인 터치 + 시간이 지난 후에 떼기

일반적인 터치 실험과 마찬가지로 제자리에서 누르고 떼는 동작을 실행하지만, 일정 시간이 지난 후에 떼는 경우입니다.

  • touchstart > mousemove > contextmenu

일반 터치 + 시간 지난 후 떼기 이벤트 시퀸스

 

특이하게도, 해당 경우 contextmenu 이벤트가 발생합니다.

이를 “long press” 라고 하며, 여러분은 이미 모바일에서 수 없이 많이 사용하고 있습니다.

 

예를 들어, 앱 아이콘을 꾹 눌러서 위치를 바꾼 경험이 있지 않으신가요?

바로 이러한 상황이 long press가 작동되고 있던 상황입니다.

 

Q. 일반 Touch와 Long Press를 구분하는 시간적 간격이 존재할 것 같은데요. 몇 초 인가요?

운영체제 마다 다르다고 합니다. 기본적으로 300 ~ 500ms 이지만, 변동의 여지가 있으며 핸드폰 사용자마다 이를 다르게 설정할 수 있는 기능이 있어, 확정할 수 없습니다.

ios에서 Long Press 인식 시간을 바꾸는 설정

3. 터치 후 이동

일반적인 터치 이후 옆으로 이동했을 때 발생하는 이벤트입니다.

중간의 touchmove 이벤트는 커서가 이동할 때 계속 발생합니다. (아래 예시는 한번만 나오도록 찍은 것)

  • touchstart > touchmove > touchend

터치 후 이동 이벤트 시퀸스

 

4. 두 손가락 터치

두 손가락 터치는 안드로이드의 유선 디버깅 환경에서 테스트하였습니다.

다음과 같은 시퀸스로 이벤트가 발생했습니다.

  • touchstart > touchstart > touchend > touchend

두 손가락 터치는 pinch-zoom(두손가락으로 확대,축소) 기능을 사용하기 위한 초기 단계로 이해하시면 되겠습니다.

 

💡 Pinch-Zoom에 대한 자세한 공부는 더 해봐야겠습니다.

 


 

이렇게 이벤트 시퀸스가 다른 이유는 무엇일까요?

앞서 살펴보았듯이, 모바일과 데스크톱 환경에서의 클릭(터치)는 매우 다른 성격을 지니고 있습니다.

 

모바일 환경에서의 클릭은 Context 메뉴를 여는 long-press, 확대/축소를 하는 Pinch-Zoom 등 다양한 상황으로의 전환 가능성이 있습니다.

 

따라서, 이벤트 중간에 시간적 간격을 두어 다양한 상황이 발생할 수 있는 시간적 임계치를 설정한 것입니다.

이러한 임계치는 브라우저마다 · 운영체제마다 다르다고 합니다.

 

현업에서 어떻게 모든 상황에 대처하는지 궁금해지네요. 아시는 분은 댓글로 남겨주시면 감사하겠습니다.

 

다음 편에는 학습한 지식을 토대로 제가 제작했던 외부 클릭 커스텀 훅을 제작했던 과정을 소개해드리겠습니다.

 

문제 상황

포토폴리오 프로젝트에서, 커스텀 훅 라이브러리인 `usehooks-ts`를 사용하여 헤더의 햄버거 버튼을 구현하고 있었습니다.

구현하고 싶었던 기능은 "햄버거 버튼을 클릭해서 헤더 메뉴바를 열고 난 뒤, 메뉴바 외부를 클릭 시 닫기"의 간단한 기능이었다.

문제는 아래 영상처럼 햄버거 버튼 클릭으로 모달이 열린 상태에서 외부 클릭을 했을 때 닫혔다가 다시 열리는 현상이 발생했다는 것이다.

(간단해 보이는 기능에서 오류가 발생하면 골때리는 거, 아시죠..?)

검은 화면은 죄송해요. 편집에는 서툴러서

 

🔖 해당 오류를 해결했던 트러블 슈팅 과정과 해결과정 (1편), 각종 클릭 이벤트의 시퀸스 (2편), 개인 NPM 커스텀 훅 개발 과정(3편)을 3편에 걸쳐 공유하고자 합니다.


기존 구현 방식

먼저, usehooks-ts 라이브러리는 리액트 ES6 기반의 커스텀 훅을 제공하는 유용한 라이브러리입니다.

 

저는 `usehookts-ts` 에서 햄버거 버튼을 눌러 메뉴바를 키고 닫기 위한 `useToggle` , 메뉴바의 외부를 클릭했을 때 지정한 DOM 요소를 닫히도록 설정하는 `useOnClickOutside` 2가지 Custom-Hook을 사용하였습니다.

  1. 헤더에 useToggle 훅을 사용하여, 메뉴바가 보이는 여부를 관리하였습니다. (코드가 직관적이라 이해하시기 편할겁니다)
// 상위 Header 컴포넌트
const Header = (): ReactNode => {
  const [value, toggle, setValue] = useToggle(false)
	  return (
    <header>
	  1️⃣ <LucideIcon name='Menu' size={35} className='lg:hidden' onClick={toggle} />
     <MobileNavBar
        isOpen={value}
        onClose={toggle}
        className='absolute right-1/2 top-full block translate-x-1/2 lg:hidden'
      />
    </header>
  )
}

 

2. 메뉴바에 (`MobileNavBar`) `useOnClickOutside` 훅을 설정하여, 메뉴바 외부를 클릭하면 메뉴바가 닫히도록 하였습니다.

[useOnClickOutside의 인자]

  • ref : 외부 클릭 감지를 적용할 대상
  • Function: 외부 클릭 감지하였을 경우 수행할 함수
const MobileNavBar = ({ isOpen, onClose, className }: MobileNavBarProps): ReactNode => {
	const ref = useRef<HTMLElement>(null)
2️⃣  useOnClickOutside(ref as RefObject<HTMLElement>, onClose)
  
  return (
      {isOpen && (
        <nav ref={ref}>
          <ul className='flex w-full flex-col items-center justify-start text-lg'>
            {LINKS.map((link, index) => (
              <li>{link}</li>
            ))}
          </ul>
        </nav>
      )}
  )

 

이렇게 구현하였을 때, 처음 영상처럼  햄버거 버튼 위를 클릭하면 닫히고 바로 열리는 로직이 실행되며 메뉴바가 닫히지 않았습니다.

그러면 왜 발생했을까요?


트러블 슈팅 과정

몇 가지 정보를 토대로 가설과 검증을 통해 에러 원인을 찾아보았습니다.

  • 햄버거 버튼 위를 제외한 외부를 클릭하면 다시 열리지 않는다. (정상 작동한다)
  • (오류 발생 시) 외부 클릭 이벤트 → 햄버거 버튼 클릭 이벤트 순으로 이벤트가 실행된다. (콘솔 확인)

이를 토대로, 내린 가설은 “외부 클릭 이벤트로 메뉴바가 닫히고, 바로 햄버거 버튼 이벤트가 실행되나?” 였습니다.

 

(엥? 나는 클릭을 분명히 한번만 했는데?)

 

 

해당 가설을 확인해보기 위해, 이벤트의 종류를 담는 `event.type`을 각각 이벤트 실행함수에 담아 같이 실행해보았더니, 각각 아래와 같은 이벤트가 실행되었습니다.

  • 햄버거 버튼 이벤트: Click Event
  • 외부 클릭 이벤트: mousedown Event
mousedown Event가 등장하는 이유는 해당 문제가 모바일 환경에서의 트러블 슈팅을 담았기 때문입니다. 😁

또한, `useOnClickOutside` 가 mousedown Event를 발생시키는 이유는 라이브러리 코드 자체가, mousedown 이벤트를 세 번쨰 인자가 없을 시, 디폴트 이벤트로 선택하고 있었습니다.
// src/useOnClickOutside/useOnClickOutside.ts
function useOnClickOutside(ref, handler, ⭐ eventType = "mousedown" ⭐, eventListenerOptions = {}) {
  // 라이브러리 내부 코드입니다. 
  useEventListener(
    eventType,
    (event) => {
      const target = event.target;
      if (!target || !target.isConnected) {
        return;
      }
      const isOutside = Array.isArray(ref) ? ref.filter((r) => Boolean(r.current)).every((r) => r.current && !r.current.contains(target)) : ref.current && !ref.current.contains(target);
      if (isOutside) {
        handler(event);
      }
    },
    void 0,
    eventListenerOptions
  );
}​

 

문제는, 분명 한번을 클릭 했는데 mousedown AND click 총 2번의 이벤트가 발생하는 것이 이해가 되지 않아, 모든 클릭 이벤트에 대한 로그를 아래 코드로 찍어보았습니다.

// Header 컴포넌트
  useEffect(() => {
    ;['touchstart', 'touchmove', 'touchend', 'mousedown', 'mousemove', 'mouseup', 'click'].forEach(eventType => {
      document.addEventListener(eventType, event => {
        console.log(`Event detected: ${eventType}`)
        console.log(`Event target:`, event.target)
      })
    })
  }, [])

 

이후, 에러가 발생했던 상황을 재현해보았습니다.

그 결과, 메뉴창을 닫기 위한 클릭 한 번에 총 6번의 이벤트가 발생했습니다.

 

mousemove 이벤트에서 외부 클릭 이벤트가 실행되며, click 이벤트에서 햄버거 버튼 이벤트가 실행되는 것을 관찰할 수 있습니다.

touchstart → touchend → mousemove (외부 클릭 이벤트) → mousedown → mouseup → click (햄버거 버튼)

 

콘솔로 찍어본 이벤트 실행 순서

 

그렇다면, “외부 클릭 이벤트가 발생하는 mousemove 이벤트에 event.preventDefault()로 하위 이벤트를 막자” 고 생각하였고 이를 아래와 같이 시도했지만, 여전히 마지막 클릭 이벤트가 실행되었습니다.

 

// 상위 Header 컴포넌트
const Header = (): ReactNode => {
  const [value, toggle, setValue] = useToggle(false)
  
  🔖
  const NavbarOutsideEvent = (e) => {
	  e.preventDefault()
	  toggle()
  }
  
	  return (
    <header>
	  1️⃣ <LucideIcon name='Menu' size={35} className='lg:hidden' onClick={toggle} />
     <MobileNavBar
        isOpen={value}
        onClose={NavbarOutsideEvent}
        className='absolute right-1/2 top-full block translate-x-1/2 lg:hidden'
      />
    </header>
  )
}

 

많은 질문들, 블로그을 서핑하다가 결국 W3C 공식문서에서 이 해답을 찾을 수 있었습니다.

 

공식문서에서는 이렇게 언급합니다.

 

If the user agent intreprets a sequence of touch events as a click, then it should dispatch mousemove, mousedown, mouseup, and click events (in that order) at the location of the touchend event for the corresponding touch input. 

If the preventDefault method of touchstart or touchmove is called, the user agent should not dispatch any mouse event that would be a consequential result of the the prevented touch event.

 

"touch 이벤트 다음에, mouse이벤트, click이벤트가 실행되며, touch 이벤트에 preventDefault를 설정하면, 이후에 발생할 이벤트를 막을 수 있다"

 

 

GPT에게도 질문을 통해 확인해본 결과

터치 이벤트의 경우, 기본 동작에는 다음이 포함됩니다. 이때, 마우스 이벤트를 생성하는 것은 터치 이벤트 이므로, 이에 해당하는 `preventDefault`를 해주어야 마우스 이벤트 자체를 방지할 수 있습니다.
- 화면 스크롤
- 확대 / 축소
- 마우스 이벤트 생성

 

해결방법

이를 토대로, 기존 코드에서 `useOnClickOutside` 의 세 번째 인자로 이벤트를 `touchstart`로 변경하였더니 다음과 같은 오류가 발생했습니다.

useOnClickOutside(ref as RefObject<HTMLElement>, onClose, 'touchstart')

 

passive 설정으로 인한 오류

addEventListener의 세 번째 인자의 옵션 중 하나인 `passive` 옵션은 false를 기본값으로 가지지만 일부 브라우저(특히 크롬과 파이어폭스)에서는 스크롤 성능 향상을 위해서 touchstart touchmove 이벤트에 대해 기본값이 true로 설정합니다.

 

만약 `touchstart` 이벤트를 사용한다고 했을 때, passive 옵션이 true로 설정되어 있으면 콜백 함수 내부에서 `preventDefault()`를 호출해도 콘솔 경고만 출력할 뿐 preventDefault()가 제대로 동작하지 않는다고 합니다.

 

따라서, `touchstart` 이벤트 대신, `touchend` 이벤트로 바꾸어 실행하였더니 목표했던 기능에 성공하였습니다.

기능구현에 성공한 모습 야호

 

마무리

이렇게, 클릭과 관련된 이벤트로 인해 발생했던 문제점과 해결방안을 알아보았습니다.

하지만, 에러를 해결했어도 에러가 발생했던 자세한 이유를 살펴보아야 제대로 해결했다고 할 수 있습니다.

 

2편에는 모바일 환경에서 단순 클릭에 이벤트가 왜 6번이나 발생했는지에 대해 살펴보고, 다양한 클릭 상황의 이벤트 발생 시퀸스를 알아보겠습니다.

(3편은 해당 내용을 반영하여 개선된 커스텀 훅 NPM 패키지 제작 과정입니다)

공모전에 참가했던 우리 팀은 `Tanstack Query`를 사용하면서 `caching` 및 `mutation`에 사용되는 `queryKey`, `mutationKey`를 효과적으로 관리하는 방법에 대해 고민을 많이 하였습니다.

1. 문제인식

1. `useMutation`으로 인해 컴포넌트가 너무 길어져 가독성을 저해한다.
2. 각 컴포넌트에서 키를 선언하면 통합 및 관리가 어렵다
3. 같은 `mutation`을 사용하는 경우, 컴포넌트마다 선언해줘야 한다.

 

이러한 문제를 해결하기 위해 저희 팀은 모듈화를 통한 Key 관리 를 진행하였습니다.

 

어떻게 모듈화를 진행했는지 설명해보겠습니다.

 


2. 기본 Tanstack Query 호출

useMutation
공식문서
queries와는 다르게, mutations는 생성(POST) / 수정(PATCH) / 삭제(DELETE) 작업을 수행할 때 사용됩니다.

 

`useMutation`을 사용하여 비동기 호출을 하면 다음과 같은 코드로 작성됩니다.

const App = ():ReactNode => {
  const { data, isPending, isSuccess, isError, error, mutate } = useMutation({
    mutationFn: newTodo => {
      return axios.post('/todos', newTodo)
    },
  })

  let content
  if (isPending) {
    content = 'Adding todo...'
  }
  if (isError) {
    content = <div>An error occurred: {error.message}</div>
  }
  if (data) {
    if (isSuccess) content = <div>Todo added!</div>
  }

  return (
    <>
      <button
        onClick={() => {
          mutate({ id: new Date(), title: 'Do Laundry' })
        }}
      >
        Create Todo
      </button>
      {content}
    </>
  )
}

 

하지만, 대부분의 경우 `mutation`은 `queries`와 함께 사용됩니다.

 

예를 들어, `Todo`의 정보를 데이터베이스에 저장하는 위의 코드에서, 한 가지 기능을 추가해보겠습니다.

유저의 기존 `Todo`를 불러와서 렌더링하고, 새로운 `Todo`를 생성하면 불러온 값을 `Update`하는 코드로 수정해야 한다면 어떻게 할까요?

 

invalidateQuries를 통해 caching된 값을 stale 상태로 만들어, Refetch하도록 유도해야 합니다.

 

실행방법

  1. `useQuery`를 통해 데이터를 불러와야 합니다.
  2. 유저가 `Todo`를 추가하면 `useMutation`을 호출하고, 데이터베이스를 업데이트합니다.
  3. 기존에 페칭했던 `useQuery` 데이터를 `invalidate` 해서 refetch를 유도해야 합니다.

(주석을 따라가며 이해해 보세요)

const Todo = () => {
  const queryClient = useQueryClient();

  // 1. useQuery로 Todo 데이터 불러오기
  const { data: todos, isLoading, isError } = useQuery(['todos'], fetchTodos);

  // 2. useMutation으로 Todo 추가
  const mutation = useMutation(addTodo, {
    onSuccess: () => {
      // 3. 데이터를 invalidate하여 최신 상태를 가져오기
      queryClient.invalidateQueries(['todos']);
    },
  });

  // mutate 사용처
  const handleAddTodo = () => {
    const newTodo = { title: `Todo ${Date.now()}` };
    mutation.mutate(newTodo);
  };

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading todos</p>;

  return (
    <div>
      <h1>Todo List</h1>
      <ul>
        {todos.map((todo: { id: number; title: string }) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={handleAddTodo} disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
    </div>
  );
};

 

저희도 처음에는 이렇게 개발 초기에는 개발을 하다보니, 몇 가지 문제점들이 발생했습니다.

  1. `useMutation`으로 인해 컴포넌트가 너무 길어져 가독성을 저해한다.
    • 모든 `Mutation` 마다 `invalidateQueries`를 해주는 행위는 상당히 번잡합니다.
    • `useMutation`의 `onSuccess`, `onError`에 모달, 토스트 띄우기 등 로직을 추가하다 보면, 코드가 길어집니다.
    • 혹여나, 하나의 컴포넌트에서 여러개의 `Mutation`을 사용한다면, 컴포넌트는 겉잡을 수 없을 정도로 길어집니다.
    // 하나의 컴포넌트에서 Mutation이 너무 많은 경우
    // 1. Todo 추가
    const { mutate : addTodoMutation }= useMutation(addTodo, {
      onSuccess: () => {
        queryClient.invalidateQueries(['todos']);
      },
    });
    
    // 2. Todo 수정
    const { mutate : updateTodoMutation }= useMutation(updateTodo, {
      onSuccess: () => {
        queryClient.invalidateQueries(['todos']);
      },
    });
    
    // 3. Todo 삭제
    const { mutate : deleteTodoMutation }= useMutation(deleteTodo, {
      onSuccess: () => {
        queryClient.invalidateQueries(['todos']);
      },
    });
    
    
  2. queryKey 통합 관리가 어렵다.
    • 특정 mutation 이후 `invalidate` 하는 `queryKey`를 찾기 위해 코드를 찾아다녀야 합니다.
    • 새로운 `Query`의 `queryKey` 설정을 위해서는 기존에 사용되었던 값 들을 찾아다녀야 합니다
  3. 반복되는 `mutation`을 계속해서 선언해주어야 한다.
    • 좋아요, 스크랩을 추가하고 삭제하는 기능을 개발한다고 할 때, 좋아요를 누르는 행위는 많은 페이지에서 동일하게 이루어졌습니다.
    • 모든 컴포넌트에서 동일한 `useMutation`을 선언하여 사용해야 했습니다. 이는 매우 번거롭고 귀찮은 행위입니다. (유지보수에 최악)
    • 이때, 같은 `mutation`은 같은 `invalidate`를 수행해야 하므로, 반복 코드가 발생할 뿐만 아니라, 실수의 가능성도 있습니다.

 

💡 이러한 이유로, queryKey와 mutationKey, useMutation 함수를 모두 하나의 파일에서 관리하는 모듈화 방법을 사용하였습니다.


3. 모듈화 방법

1. Key 관리 (QueryKey, MutationKey)

QueryKey

  • `QueryKey`는 `caching`된 값에 접근할 수 있는 매우 중요한 값입니다.
  • 매번 접근할 때마다 잘못된 값에 접근하지 않고 편리하게 접근하기 위해 객체로 관리합니다.
// cache-key.ts
export const QUERY_KEYS = {
  USER: {
    PLANS: {
      INDEX: ['plans', 'user'],
      SCRAP: ['plans', 'user', 'scrap'],
    },
    PLAN: {},
    PLACES: {
      SCRAP: ['places', 'user', 'scrap'],
    },
    PLACE: {},
  },
  GENERAL: {
    PLANS: {
      INDEX: ['plans'],
    },
    PLAN: {
      INDEX: (planId: number) => ['plan', planId],
    },
    PLACES: {
      INDEX: ['places'], // 일반 여행지
    },
  },
}

MutationKey

  •  `mutationKey`는 `queryKey` 보다는 중요하지 않습니다. `mutationKey`는 `mutation`을 구분하거나, `useMutationState`를 통해 이전 `mutation` 값을 불러오는데 사용됩니다.
  • `mutation`에는 항상 비동기 함수가 매핑됩니다. 따라서, `mutationKey` 값 이외에 비동기 함수도 같이 값으로 저장해주었습니다.
// todo가 아닌, 실제 사용했던 코드로 대체합니다
// cache-key.ts
export const MUTATION_KEYS = {
  PLAN: {
    CREATE: {
      key: ['createPlan'],
      fc: createPlan, // 비동기 함수
    },
    UPDATE: {
      key: ['updatePlan'],
      fc: updatePlan,
    },
     ...
    },
    SCRAPS: {
      ADD: {
        key: ['addPlanScrap'],
        fc: addPlanScrap,
      },
      DELETE: {
        key: ['deletePlanScrap'],
        fc: deletePlanScrap,
      },
    },
    COMMENTS: {
      ADD: {
        key: ['addPlanComment'],
        fc: addPlanComment,
      },
    },
  },
} as const

💡setDefaultMutation으로 미리 useMutation 정의하기

setDefaultMutation 공식문서

 

왜 `setDefaultMutation`을 사용하나요❓
`setDefaultMutation`은 `mutationKey`와 `mutationFn`을 연결하여 미리 선언된 `queryClient`에 정의할 수 있습니다.

⭐이후, 컴포넌트에서 `mutationKey`만 으로 간편하게 `mutation`에 접근 가능합니다.⭐

 

어떻게 선언하나요?

 

1. 아래와 같이 미리 `mutation`에 `mutationKey`와 `mutationFn`이 연결되도록 선언합니다.

// #1. Plan
queryClient.setMutationDefaults(MUTATION_KEYS.PLAN.CREATE.key, {
  mutationFn: MUTATION_KEYS.PLAN.CREATE.fc,
  onSuccess(data, variables, context) {
    queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.PLANS.INDEX })
  },
  onError: () => {
    toast({ title: '서버 오류 다시 시도해주세요' })
  },
})

 

2. `useMutation`을 사용하고자 하는 컴포넌트에서 `mutationKey`만 지정해주면 간편하게 사용 가능합니다.

const { mutate: createPlanMutate, isPending: isCreating } = useMutationStore<CreatePlanType>(MUTATION_KEYS.PLAN.CREATE.key)

 

 `useMutationStore`은 무엇인가요

`useMutationStore`는 Tanstack Query의 기본 함수가 아닙니다.

useMutation의 Response Type을 지정하고 (백엔드 팀과 Response 형태 맞추기), 비동기 호출의 매개변수 variables의 Type을 재네릭으로 지정하여 사용한 함수입니다.

/**
 * Type T에서 key K의 value들로 이루어진 Type 생성하는 유틸함수 (재귀)
 */
export type ExtractValueByKey<T, K extends string> = T extends { [key in K]: infer V }
  ? V
  : {
      [key in keyof T]: ExtractValueByKey<T[key], K>
    }[keyof T]

// 위에서 정의한 MUTATION_KEYS의 key값들을 유니온 값으로 갖는 Type
export type MutationKeyType = ExtractValueByKey<typeof MUTATION_KEYS, 'key'>

export const useMutationStore = <T>(mutationKey: MutationKeyType) => {
  return useMutation<SuccessResponse, Error, T, unknown>({ mutationKey })
}

  • 제너릭 타입으로 제공하는 Variables Type은 해당 useMutation이 사용하는 비동기 함수의 객체 형태로 제공하는 매개변수의 Type입니다.
// 객체 형태의 함수 매개변수 Type
export interface CreatePlanType {
  state: StateType
  startDate: Date
  endDate: Date
  accessToken: string
}
/**
 * 새로운 여행 계획 만들기
 * @param param0 `{state: 지역, startDate: Date, endDate: Date, accessToken: string}`
 * @returns data: `{planId: number}`
 */
export const createPlan = async ({ state, startDate, endDate, accessToken }: CreatePlanType) => {
  let backendRoute = BACKEND_ROUTES.PLAN.CREATE
  const body = {
    state: state,
    startDate: formatDateToHyphenDate(startDate),
    endDate: formatDateToHyphenDate(endDate),
  }

  const res = await fetch('/server' + backendRoute.url, {
    method: backendRoute.method,
    headers: {
      Authorization: accessToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
    credentials: 'include',
  })
  ... api logic
  
}

 

참고) `useMutation`의 기본 Type 선언을 참고하여 변형하였습니다.

// 기본 타입선언
// useMutation.d.ts
import { UseMutationOptions, UseMutationResult } from './types.js';
import { DefaultError, QueryClient } from '@tanstack/query-core';

declare function useMutation<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown>(options: UseMutationOptions<TData, TError, TVariables, TContext>, queryClient?: QueryClient): UseMutationResult<TData, TError, TVariables, TContext>;

export { useMutation };

 


4. 최종 형태

이렇게 하면, 초반에 작성했던 코드는 다음과 같이 매우 짧고 가독성이 좋은 코드로 바뀝니다.

const Todo = () => {
  const queryClient = useQueryClient();

  // 1. useQuery로 Todo 데이터 불러오기
  const { data: todos, isLoading, isError } = useQuery(['todos'], fetchTodos);

  // 2. useMutation으로 Todo 추가 (매우 짧아짐)
  const {mutate, isPending} = useMutationStore<addTodoType>(MUTATION_KEYS.TODO.ADD.key)

  // 새로운 Todo 추가 핸들러
  const handleAddTodo = () => {
    const newTodo = { title: `Todo ${Date.now()}` };
    mutation.mutate(newTodo);
  };

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading todos</p>;

  return (
    <div>
      <h1>Todo List</h1>
      <ul>
        {todos.map((todo: { id: number; title: string }) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={handleAddTodo} disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
    </div>
  );
};

💡 로직 분리하기

개발을 진행하며, 유지보수를 위해 `caching` 로직과 컴포넌트에서 사용될 로직을 구분지어 사용하였습니다.

cache-key.ts`에서의 `onSuccess`와 개별 클라이언트의 onSuccess의 용도를 구분해서 사용했습니다.

컴포넌트의 mutate의 onSuccess(onError)가 cache-key.ts의 onSuccess(onError) 보다 먼저 실행됩니다.
// 컴포넌트
const {mutate, isPending} = useMutationStore<addTodoType>(MUTATION_KEYS.TODO.ADD.key)
  
const handleAddTodo = () => {
  const newTodo = { title: `Todo ${Date.now()}` };
  mutation.mutate(newTodo, {
	  // 실행 1번
	  onSuccess() {
	  	setState(어떤 값으로 바꾼다)
        router.push(어디로 이동한다)
	  }
  });
};

// cache-key.ts
queryClient.setMutationDefaults(MUTATION_KEYS.PLAN.CREATE.key, {
  mutationFn: MUTATION_KEYS.PLAN.CREATE.fc,
  // 실행 2번
  onSuccess(data, variables, context) {
    queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.PLANS.INDEX })
  },
})​
  • 특정 `mutation`이 공통적으로 수행하는 `invalidate`과 같은 행위는 `cache-key.ts`에서 다루며
  • `state`관리 등의 컴포넌트 단위 프로세스는 컴포넌트 내부에서 선언한 `useMutationStore`의 리턴 값인 `mutate` 함수의 `onSuccess`, `onError`에서 관리합니다.

  • 컴포넌트 `mutate`: state 변화, toast 처리, routing 등을 처리
  • `cache-key.ts` : invalidateQuries 등 caching 처리

 


참고 문헌

Tanstack Query useMutation 공식문서

Tanstack Query setMutaitonDefault 공식문서

Tanstack Query invalidate Query 공식문서

+ Recent posts