[번역] React Query API의 의도된 중단

이춘구·2023년 4월 16일
68

translation

목록 보기
6/11

Photo by Denny Müller

TkDodoBreaking React Query's API on purpose를 번역한 글입니다.


라이브러리 인터페이스용 API를 설계하는 건 꽤 어려운 일입니다. 사람들은 API가 유연하고 모든 요구 사항을 만족하길 원하는 동시에, 미니멀하고 직관적이어서 학습 곡선이 가파르지 않길 원합니다. 두 가지는 동전의 양면과 같아서 그 중간 어딘가에서 균형잡길 원하죠. 좋은 기본 기능도 아주 중요합니다.

또 어려운 건 설계 초기부터 "제대로" 하는 겁니다. 왜냐면 한번 내린 결정들은 쉽게 되돌릴 수 없기 때문이죠. 버그는 수정할 수 있습니다. API는 확장할 수 있고, 기능은 필요하다면 추가할 수 있습니다. 하지만 기존 API를 변경하거나 제거하는 건 완전히 다른 문제로, 메이저 버전 변경이 필요합니다.

주요 변경 사항

breaking changes(호환성이 손상되는 변경)을 좋아하는 사람은 없고, 메이저 버전 변경으로 사람들을 흥분시키기는 어렵습니다. React Query v4의 networkMode처럼 새로운 기능을 제공할 수 있는 근본적인 설계 변경이 있는 게 아닌 이상, 메이저 버전은 새로운 기능에 관한 게 아닙니다.

React는 v16.8에서 훅을 출시했고, React Router는 v6.4에서 로더를 출시했습니다. 모두 마이너 버전이었죠.

메이저 버전이 새로운 기능에 관한 게 아니라면 대체 무엇에 관한 걸까요? 이에 대해선 Will McGugan이 제일 잘 설명하고 있습니다.

완전 동의합니다. semver의 메이저 버전 숫자는 새로운 걸 의미하는 게 아니라 API를 몇번이나 잘못 만든 건지 영원히 상기시켜주는 겁니다. semver는 메이저.마이너.패치가 아니라 실패.기능.버그를 의미합니다. - 2021. 8. 6
Will McGugan의 트윗

윽, 아프지만 맞는 말입니다. 저는 과거의 설계가 차선책인 것으로 드러나서 되돌리려는 목적으로 메이저 버전 변경을 한 적이 종종 있습니다. API를 설계할 때 항상 모든 것을 고려할 수는 없습니다. 미처 생각지도 못한 예외 사항이 발생할 수도 있고 기대한대로 동작하지 않아서 API를 바꾸는 게 최선임을 깨닫게 되기도 합니다.

React Query v5

React Query는 API로서 5번째 실패작에 가까워지고 있고 저희는 결정을 내렸습니다. 그리고 제가 어제 그 결정을 발표하자 꽤나 논란이 되었습니다. 바로 useQuery에서 콜백을 제거하는 겁니다.

📢 v5에서는 useQuery의 onSuccess / onError / onSettled 콜백을 제거할 가능성이 높습니다. 좋은 용례를 생각해 내기 위해 아주 열심히 노력했지만, 제 생각엔 하나도 없습니다.
곧 RFC를 작성할 텐데 좋은 사용 사례가 있다면 지금 알려주세요! - 2023. 4. 15
Dominik의 트윗

이것 때문에 제 트위터에 첫 번째 💩 폭풍이 일어날 뻔했는데, 어느 정도 예상했던 일이었습니다. "왜 제게서 무언가를 뺏어 가나요? 제가 원하는 방식으로 코드를 작성하게 해주세요. 이건 끔찍한 생각이에요!"

여러분이 시류에 편승하기 전에 이건 useMutation의 콜백이 아니라 useQuery의 콜백에만 해당하는 이야기란 걸 알아주시기 바랍니다. 제가 이 사실과 해당 콜백의 문제점에 대해 설명하면 대부분의 사람들은 이 결정에 동의하는 경향을 보였습니다.

저는 처음에 무조건 반사적으로 이건 악수라고 생각했습니다. 하지만 콜백을 유지하는 게 왜 악수인지 여러가지 이유를 읽고나니 지금은 동의합니다. 말이 되네요. - 2023. 4. 16
Michael Carniato의 트윗

그렇다면 대안이 없어보이는 기존 API를 삭제하는 이유가 뭘까요? 간단히 말하자면 예상대로 동작하지 않을 가능성이 높은 나쁜 API이기 때문입니다. API에서 가장 중요한 것은 일관성인데 콜백은 그렇지 않습니다.

나쁜 API

우리가 무슨 API에 대해 얘기하는 건지 빠르게 요약해 봅시다. useQuery에는 Query가 성공적으로 실행되었거나 오류가 발생했을 때, 또는 두 경우 모두에 호출되는 세 가지 콜백 함수 onSuccess, onError, onSettled가 있습니다. 이 함수를 사용해서 에러 알림 같은 부작용을 실행할 수 있죠.

export function useTodos() {
  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    onError: (error) => {
      toast.error(error.message)
    },
  })
}

사용자들은 이 API가 직관적이라 좋아합니다. 이 콜백이 없었다면 이런 부작용을 일으키기 위해 꺼림칙한 useEffect 훅이 필요했겠죠.

export function useTodos() {
  const query = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
  })

  React.useEffect(() => {
    if (query.error) {
      toast.error(query.error.message)
    }
  }, [query.error])

  return query
}

많은 사용자들은 더이상 useEffect를 작성할 필요가 없다는 걸 React Query의 큰 장점으로 여깁니다. 위 예제에서 effect는 useEffect를 사용한 접근 방식의 문제점을 확실히 보여주죠. 바로 앱에서 useTodos()를 두 번 호출하면 두 개의 에러 알림을 받게 되는 겁니다!

useEffect를 사용한 구현을 딱 보면 알 수 있습니다. 두 컴포넌트 모두 커스텀 훅을 호출하고, 둘 다 effect를 등록한 다음, 해당 컴포넌트 내부에서 실행됩니다. 하지만 onError 콜백의 경우는 한눈에 알 수 없습니다. 두 호출의 중복이 제거될 거라고 예상했겠지만 그런 일은 일어나지 않습니다. 콜백이 지역 상태 값을 가둬둘 수 있기 때문에(클로저) 각각의 컴포넌트에서 실행됩니다. 한 컴포넌트에서는 콜백을 호출하면서 다른 컴포넌트에서는 안 했다면 상당히 일관성이 떨어졌을 테고, 한 컴포넌트에서만 실행된다고 했을 때 둘 중 어느 컴포넌트에서 실행해야 할지 우리가 결정할 수도 없습니다.

하지만 저희는 여러분이 이런 시나리오를 위해 useEffect를 작성하도록 두지 않을 겁니다. 방금 지적했듯이 이 effect는 두가지 방식(useEffect, onError) 모두 잘못됐습니다. 해결책으로는 Query당 한 번만 호출되는 콜백이 있는데, 이거에 대해 전에 #11: React Query Error Handling에서 작성한 적이 있습니다.

이 문제를 해결하는 최고의 방법은 바로 QueryClient를 세팅할 때 전역 캐시 단계에서 콜백을 사용하는 겁니다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) =>
      toast.error(`Something went wrong: ${error.message}`),
  }),
})

이 콜백은 Query당 한 번만 호출될 겁니다. 그리고 useQuery를 호출한 컴포넌트 외부에 존재하므로 클로져 문제가 없습니다.

on-demand 메시지 정의

error 내부의 자체적인 메시지가 아니라 Query마다 다른 메시지를 보여주고 싶을 수 있다는 것도 이해합니다. 그러기 위해 queryFn의 Promise을 reject 할 때 커스텀 Error를 사용할 수도 있지만, 간단한 해결책은 Query의 meta 필드를 사용하는 겁니다.

meta는 원하는 어떤 정보로든 채울 수 있는 임의의 객체인데, 전역 콜백 등 Query에 접근할 수 있는 모든 곳에서 사용할 수 있습니다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.meta.errorMessage) {
        toast.error(query.meta.errorMessage)
      }
    },
  }),
})

export function useTodos() {
  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    meta: {
      errorMessage: 'Failed to fetch todos',
    },
  })
}

이렇게 하면 meta.errorMessage가 정의된 모든 Query는 토스트 알림을 받게 됩니다. 알림을 보여줘야 하는 useQuery 인스턴스에서 onError를 설정하는 것과 아주 비슷하지만, 그와 달리 안전장치가 되어 있는 방법입니다.

상태 동기화

콜백이 잠재적으로 여러 번 호출될 수 있다는 게 이 API의 유일한 단점은 아닙니다. 제 경험에 따르면 많은 사람들이 콜백을 사용해서 상태를 동기화합니다.

export function useTodos() {
  const [todoCount, setTodoCount] = React.useState(0)
  const { data: todos } = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    //😭 제발 이러지 마세요
    onSuccess: (data) => {
      setTodoCount(data.length)
    },
  })

  return { todos, todoCount }
}

이제 제발 이러지 마세요! 절대로요. 기본적으로 API가 이렇게 하도록 유도한다는 걸 알고 있고, 그게 이 API를 삭제하는 또 다른 이유이기도 하지만, 이 시나리오가 얼마나 쉽게 망가지는지 간단히 보여드리겠습니다.

추가 렌더링 사이클

setTodoCount는 또 다른 렌더링 사이클을 끼워 넣습니다. 이건 앱을 필요 이상으로 자주 렌더링 되게 할 뿐만 아니라(문제될 수도, 안될 수도 있음) 중간에 낀 렌더링 사이클에 잘못된 값이 포함될 수 있다는 뜻입니다.

예를 들어 fetchTodos는 length가 5인 목록을 반환한다고 가정해 보겠습니다. 위 코드에서 렌더링 사이클은 세 번입니다.

  1. todosundefined이고 length는 0입니다. Query가 fetch되는 동안의 초기 상태이며 올바른 상태입니다.
  2. todos는 length가 5인 배열이 되고 todoCount는 0이 됩니다. useQueryonSuccess는 이미 실행을 마쳤고 setTodoCount는 예약된, 중간에 낀 렌더링 사이클입니다. 값들이 동기화되지 않았기 때문에 잘못된 상태입니다.
  3. todos는 길이가 5인 배열이 되고 todoCount는 5가 됩니다. 이게 최종 상태이며 다시 올바른 상태가 되었습니다.

샌드박스에서 개발자 도구의 콘솔을 보면 확인할 수 있습니다. 저는 추가 렌더링 사이클에 대해서는 크게 걱정하지 않지만 동기화되지 않은 데이터로 렌더링 된다는 사실은 끔찍합니다. 이 데이터를 기반으로 추가적인 로직을 실행하면 버그가 발생할 수 있죠.

물론 간단한 해결책은 상태를 파생시키는 겁니다. 이 주제에 대해서도 오래된 글인 Don't over useState가 있습니다. 이 예제에 적용하자면 그냥 이렇게 하라는 뜻입니다.

export function useTodos() {
  const { data: todos } = useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
  })

  const todoCount = todos?.length ?? 0

  return { todos, todoCount }
}

이렇게 하면 동기화가 깨질 일이 없습니다. 수정된 샌드박스를 보세요. 이것보다 더 화려하게 하고 싶거나 비용이 높은 계산이라면 select옵션을 살펴보세요.

콜백이 실행되지 않을 가능성

어떤 분들은 점진적으로 React Query를 적용해나가고 있는데, 아직 React Query를 사용하지 않는 곳에서 데이터를 사용할 수 있도록 리덕스 등에 상태를 동기화해야 한다고 합니다. 그 점을 충분히 이해하며 저도 최근 이렇게 해야 했습니다.

export function useTodos() {
  const { dispatch } = useDispatch()

  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    onSuccess: (data) => {
      dispatch(setTodos(data))
    },
  })
}

React Query를 비동기 상태 관리자로 사용하면서 staleTime만 정의해놓고 캐시에서 데이터를 읽어온다면, onSuccess 콜백을 사용하는 건 심각한 문제를 일으킬 수 있습니다. 인자 filtersstaleTime을 추가해보죠.

export function useTodos(filters) {
  const { dispatch } = useDispatch()

  return useQuery({
    queryKey: ['todos', 'list', { filters }],
    queryFn: () => fetchTodos(filters),
    staleTime: 2 * 60 * 1000,
    onSuccess: (data) => {
      dispatch(setTodos(data))
    },
  })
}

이렇게 하면 아래와 같은 일이 일어날 수 있습니다.

  1. todos를 done: true로 필터링하고, React Query가 해당 데이터를 캐시에 저장하고, onSuccess는 redux에 넣습니다.
  2. todos를 done: false로 필터링하고, 동일한 과정이 진행됩니다.
  3. todos를 다시 done: true로 필터링하면 앱이 고장납니다.

왜 그럴까요? onSuccess가 다시 호출되지 않으므로 dispatch가 발생하지 않기 때문입니다. useTodos의 데이터를 사용하는 부분에서는 올바르게 필터링된 값을 볼 수 있지만, redux에서 읽어오는 부분에서는 그럴 수 없습니다.

staleTime을 정의하면 React Query가 최신 데이터를 가져오기 위해 queryFn을 항상 호출할 필요가 없어집니다. 캐시된 데이터를 2분 동안 신선한 것으로 정의했으므로 2분 동안 데이터는 캐시에서만 읽어옵니다. 이는 과도한 re-fetch를 피할 수 있다는 점에서는 좋습니다.

하지만 fetch가 발생해야 콜백이 실행되기 때문에 onSuccess가 호출되지 않는다는 의미이기도 합니다. 따라서 동기화가 어긋나게 되고, 어떤 경우는 제대로 동작하고 어떤 경우에는 안 될 겁니다. 이런 버그는 정확한 이유를 알지 못하면 추적하기가 아주 어렵습니다.

onDataChanged

그냥 data가 변경될 때마다 호출되는 onDataChanged 콜백을 추가하기.💡 처음에는 이게 좋은 아이디어라고 생각했습니다. 그런데 다시 생각해보니 이 콜백을 어떻게 구현해야 할까요? 현재 존재하는 콜백들은 이미 부작용 스코프 내에 있기 때문에 queryFn이 실행된 후에 호출할 수 있습니다. 하지만 캐시에서만 읽어오고 렌더링한다면 렌더링 중에 콜백을 호출할 수는 없으므로, 자체적인 useEffect를 생성해야 합니다. 그리고 이건 상태 동기화가 정말로 불가피한 경우에 사용자 영역에서도 할 수 있는 겁니다. 적어도 무슨 이유로 데이터가 변경됐는지에 상관없이 예상대로 동작할 겁니다.

export function useTodos(filters) {
  const { dispatch } = useDispatch()

  const query = useQuery({
    queryKey: ['todos', 'list', { filters }],
    queryFn: () => fetchTodos(filters),
    staleTime: 2 * 60 * 1000,
  })

  React.useEffect(() => {
    if (query.data) {
      dispatch(setTodos(query.data))
    }
  }, [query.data])

  return query
}

물론 가장 아름다운 코드는 아니지만 애초에 상태 동기화는 권장되어야 할 게 아닙니다. 저는 그런 코드를 작성하고 싶지 않고 작성해서는 안 될 것 같기 때문에 그런 코드를 작성하면서 더러움을 느끼고 싶습니다.

요약

트위터에서 이뤄진 토론에서 나온 유일한 좋은 사용 사례는 새 채팅 메시지가 도착했을 때 피드를 하단으로 스크롤하는 사례였습니다. 이 예시는 fetch가 실패했을 때 아이템에 애니메이션을 적용하는 것과 비슷하게 좋은 예입니다. 컴포넌트별로 다르게 적용하길 원하는 예시이기 때문입니다. 하지만 이런 사례는 극히 드물고, 원한다면 useEffect로도 할 수 있습니다. useQuery가 반환하는 dataerror는 참조적으로 안정적이므로 effect가 너무 자주 실행되면 어쩌나 하는 걱정없이 쉽게 effect를 설정해도 됩니다.

API는 간단하고 직관적이며 일관되어야 합니다. useQuery의 콜백은 이 기준에 부합하는 것처럼 보이도록 위장한 버그 생산기입니다. 처음 구현할 때는 원하는 대로 작동할 가능성이 높지만, 앱이 성장함에 따라 리팩터링 하거나 확장할 때는 대가가 따르기 때문에 아주 안 좋습니다. 또한 에러가 발생하기 쉬운 상태 동기화를 도입하면서도 이게 나쁘다고 느끼지 않기 때문에 안티 패턴을 초래합니다.

제가 본 거의 모든 예제에서 버그가 발생할 수 있고 그럴 것이며, 이러한 사례가 이슈, 토론, 디스코드, 스택오버플로우 질문에서 정기적으로 보고되는 걸 봐왔습니다. 이 버그는 추적하기가 매우 어렵습니다. 저를 안 믿으시나요? 그럼 Theo에게 맡기겠습니다.

업데이트:
27개의 onSuccess 인스턴스. 그 중 4개가 쿼리의 인스턴스, 그 중 3개가 버그의 원인인 게 거의 확실
deprecate 하세요 🙏 - 2023. 4. 15
Theo-t3.gg의 트윗

이 API가 애초에 없는 게 더 나은 이유이며 이게 바로 저희가 이 API를 없애는 이유입니다.

profile
프런트엔드 개발자

2개의 댓글

comment-user-thumbnail
2023년 5월 30일

좋은 글 번역 감사합니다.
메이저 버전이 나온다는 건 이전 버전의 설계 실패를 의미한다... 재미있네요.

1개의 답글