공모전에 참가했던 우리 팀은 `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 공식문서

프로젝트를 하다보면, 유저에게서 입력을 받아야 하는 상황이 다수 존재합니다.

대표적으로, 폼을 만들어 정보를 입력받는 상황이 있습니다.

이러한 폼에서는 제어 컴포넌트로 구성할지 비제어 컴포넌트로 구성할지에 대한 고민이 필요합니다.

다음의 2가지 Form 형식을 참고하여 제어 컴포넌트와 비제어 컴포넌트가 무엇인지 알아보겠습니다.


제어 컴포넌트

리액트 공식문서에서 제공하는 제어 컴포넌트의 정의

우리는 React state를 “신뢰 가능한 단일 출처 (single source of truth)“로 만들어 두 요소를 결합할 수 있습니다.
그러면 폼을 렌더링하는 React 컴포넌트는 폼에 발생하는 사용자 입력값을 제어합니다.
이러한 방식으로 React에 의해 값이 제어되는 입력 폼 엘리먼트를 “제어 컴포넌트 (controlled component)“라고 합니다.

 

🔎 즉, State를 생성하여 폼에서 발생하는 값들을 관리하는 방식을 의미한다.

 

다음 예시 코드를 보면 이해가 쉽습니다.

function App() {
  const [input, setInput] = useState("");
  const onChange = (e) => {
    setInput(e.target.value);
  };

  return (
    <div>
      입력값
      <input onChange={onChange} />
    </div>
  );
}

export default App;

 

유저에게 입력을 받는 매우 간단한 코드입니다.

값이 변경될때마다, State로 정의한 input에 값을 반영합니다.

 

그렇다면, 비제어 컴포넌트는 무엇일까요?


비제어 컴포넌트

마찬가지로, 리액트 공식문서에서는 비제어 컴포넌트에 대해 다음과 같이 설명합니다.

비제어 컴포넌트는 DOM 자체에서 폼 데이터가 다루어집니다.

모든 state 업데이트에 대한 이벤트 핸들러를 작성하는 대신 비제어 컴포넌트를 만들려면 ref를 사용하여 DOM에서 폼 값을 가져올 수 있습니다.

 

즉, 기존에 JQuery 혹은 Vanilla Javascript를 통해 DOM 객체에 직접 접근하여 값을 가져오는 방식을 의미합니다.

 

React에서는 useRef를 통해 DOM 객체에 직접 접근가능합니다.

다음 간단한 예시 코드를 살펴봅시다.

function App() {
  const inputRef = useRef(); // ref 사용
  const onClick = () => {
    console.log(inputRef.current.value);
  };

  return (
    <div className="App">
      <input ref={inputRef} />
      <button type="submit" onClick={onClick}>
        전송
      </button>
    </div>
  );
}
export default App;

 

해당 폼은 useRef Hook을 통해 사용자가 입력한 값을 폼 전송시에 접근합니다.

 

이렇게, 리액트에서의 제어 컴포넌트와 비제어 컴포넌트의 개념에 대해 알아보았습니다.

 


 

그렇다면 어떤 컴포넌트 형식을 언제 활용해야 할까요?

이를 알아보기 위해서, 먼저 각 방식의 장단점에 대해 분석해봅시다.

제어 컴포넌트의 장단점

장점

관리하는 값을 최신 상태 유지시킨다.

→ 유효성 검사 가능 (ex. 이메일 형식)

→ 실시간 피드백 제공 (ex. 비밀번호 복잡도)

 

단점

값이 바뀔때마다 리렌더링된다.

제어 컴포넌트에서의 단점은 생각보다 치명적입니다.

이전에 보았던 코드에서, 리렌더링 될때마다 콘솔로그를 찍어봅시다.

let render = 0;
function App() {
  const [input, setInput] = useState("");
  const onChange = (e) => {
    setInput(e.target.value);
  };

  const printRender = () => {
    console.log(render);
  };

  useEffect(() => {
    console.log(input);
    render++;
  }, [input]);

  return (
    <div>
      입력값
      <input onChange={onChange} />
      <button type="button" onClick={printRender}>
	        렌더링 횟수 출력
      </button>
    </div>
  );
}

export default App;

 

결과

 

이메일 하나 입력하는데 무려 20번의 Rerendering이 발생했습니다.

반면, 예상할 수 있듯이 비제어 컴포넌트는  Rerendering 횟수를 최소화할 수 있습니다.

마찬가지로, 기존 코드를 변형하여 봅시다.

import { useEffect, useRef } from "react";

let render = 0;
function App() {
  const inputRef = useRef(); // ref 사용

  const printRender = () => {
    console.log(render);
  };

  useEffect(() => {
    render++;
  }, [inputRef.current?.value]);
  return (
    <div className="App">
      입력값
      <input ref={inputRef} />
      <button type="button" onClick={printRender}>
        렌더링 횟수 출력
      </button>
    </div>
  );
}
export default App;

 

결과

비제어 컴포넌트의 렌더링

비제어 컴포넌트는 렌더링 횟수에서 제어 컴포넌트보다 우위에 있다고 이야기할 수 있습니다.

 


그렇다면 언제, 무엇을 사용해야 할까요?

장단점 분석의 뉘양스로는 제어 컴포넌트는 사용하지 않아야 할 것 같습니다.

하지만, 아닙니다.

무엇을 써야 할지는 상황에 따라 바뀝니다.

 

값을 추적하여 사용자에게 유효성 검사와 같은 기능을 제공해야 한다면 제어 컴포넌트를,

버튼을 클릭했을때 전체 유효성을 검사하는 방식을 선택해야 한다면 비제어 컴포넌트를 사용하면 됩니다.

 

물론, 하나의 폼에서 제어 컴포넌트와 비제어 컴포넌트를 동시에 사용하는 것도 괜찮습니다.

다만 그럴경우, 비제어 컴포넌트와 제어 컴포넌트는 컴포넌트화하여 렌더링되는 부분을 최소화하는 것이 좋겠습니다.

 


 

이상으로, 제어 컴포넌트와 비제어 컴포넌트가 무엇인지 알아보았습니다.

다음 글에서는 비제어 컴포넌트를 효율적으로 관리하는 React-Hook form 라이브러리 사용법에 대해 알아보겠습니다.

+ Recent posts