Photo by Denny Müller
TkDodo의 Breaking React Query's API on purpose를 번역한 글입니다.
라이브러리의 인터페이스용 API를 설계하는 건 꽤 어려운 일입니다. 사람들은 API가 유연하고 모든 요구 사항을 만족하길 원하는 동시에, 미니멀하고 직관적이어서 학습 곡선이 가파르지 않길 바랍니다. 두 가지는 동전의 양면과 같아서 그 중간 어디쯤에서 균형을 유지하길 원하죠. 기본 기능이 좋은 것도 아주 중요합니다.
또한, 설계 초기부터 '제대로' 하는 것도 어렵습니다. 한번 내린 결정들은 쉽게 되돌릴 수 없으니까요. 버그는 수정할 수 있습니다. API는 확장할 수 있고, 기능이 필요하면 추가할 수 있습니다. 하지만 기존 API의 변경이나 제거는 완전히 다른 문제로서 메이저 버전을 변경해야 합니다.
breaking change(호환성이 손상되는 변경)을 좋아하는 사람은 없고, 메이저 버전 변경으로 사람들을 들뜨게 하는 건 힘듭니다. 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가 다섯번째 실패작 API에 가까워지며 저희는 어떤 결정을 내렸습니다. 제가 어제 그 결정을 발표하자 꽤나 논란이 되었는데요, 바로 useQuery
에서 콜백을 제거하는 겁니다.
📢 v5에서는
useQuery
에서 onSuccess / onError / onSettled 콜백을 제거할 가능성이 높습니다. 좋은 용례를 생각해 내려고 아주 열심히 노력했지만, 제 생각엔 하나도 없어요.
곧 RFC를 작성할 텐데 좋은 용례가 있다면 지금 알려주세요! - 2023. 4. 15
Dominik의 트윗
이로 인해 제 트위터에 첫 번째 💩 폭풍이 일어날 뻔했지만 어느 정도 예상했던 일이었습니다. "왜 뺏어 가세요? 제가 원하는 방식으로 코드를 작성하게 해주세요. 이건 끔찍한 생각이에요!"
여러분이 이 분위기에 휩쓸리기 전에, 이건 useMutation
이 아니라 useQuery
의 콜백에만 해당한다는 걸 알아주세요. 제가 이 사실과 해당 콜백의 문제를 설명하면 대부분은 이 결정에 동의하는 경향을 보였습니다.
처음에는 무조건 반사적으로 악수(惡手)라 생각했습니다. 하지만 콜백을 놔두는 게 오히려 악수인 여러가지 이유를 읽고나니 지금은 동의합니다. 말이 되네요. - 2023. 4. 16
Michael Carniato의 트윗
그렇다면 대안이 없어보이는 기존 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의 큰 장점으로 여깁니다. 위 예시는 useEffect
를 사용한 접근 방식의 문제점을 확실히 보여주죠. 바로, 앱에서 useTodos()
를 두 번 호출하면 오류 알림을 두 번 받게 되는 겁니다!
useEffect
로 구현한 걸 보면 한눈에 보입니다. 두 컴포넌트 모두 커스텀 훅을 호출해서 effect를 등록한 뒤, 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
를 호출한 컴포넌트 바깥에 존재하므로 클로저 문제가 없습니다.
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
는 길이가 5인 목록을 반환한다고 가정하겠습니다. 위 코드에서 렌더링 사이클은 3회입니다.
todos
는 undefined
이고 길이는 0입니다. Query가 fetch되는 동안의 초기 상태이며 올바른 상태입니다.todos
는 길이가 5인 배열이고 todoCount
는 아직 0입니다. useQuery
와 onSuccess
는 이미 실행을 마쳤고 setTodoCount
가 예약된, 중간에 낀 렌더링 사이클입니다. 값들이 동기화되지 않았으므로 잘못된 상태입니다.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
를 사용하면 심각한 문제를 발생할 수 있습니다. 인수로 filters
와 staleTime
을 추가해볼게요.
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))
},
})
}
이러면 아래와 같은 일이 생길 수도 있습니다.
done: true
로 필터링합니다. React Query는 해당 데이터를 캐시에 저장하고, onSuccess
는 redux에 넣습니다.done: false
로 필터링합니다. React Query는 해당 데이터를 캐시에 저장하고, onSuccess
는 redux에 넣습니다.done: true
로 필터링합니다. 앱이 고장납니다.이유가 뭘까요? onSuccess
를 다시 호출하지 않아서 dispatch
가 실행되지 않기 때문입니다. useTodos
의 데이터를 사용하는 쪽은 올바르게 필터링된 값을 볼 수 있지만, redux에서 읽어오는 쪽은 그럴 수 없습니다.
staleTime
을 정의하면, React Query는 최신 데이터를 가져오기 위해 queryFn
을 항상 호출할 필요가 없어집니다. 캐시된 데이터는 2분 동안 fresh하다고 정의했으므로 2분 동안은 캐시에서만 데이터를 읽어옵니다. 이는 fetch를 과도하게 반복하지 않을 수 있다는 점에서는 좋습니다.
하지만 fetch를 해야 콜백이 실행되므로 onSuccess
는 호출되지 않습니다. 따라서 동기화가 어긋나게 되며, 어떤 경우는 제대로 동작하고 어떤 경우에는 안 될 겁니다. 이런 버그는 정확한 이유를 알지 못하면 추적하기 어렵습니다.
'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
가 반환하는 data
와 error
의 참조값은 안정적이므로 effect가 너무 자주 실행될 거란 걱정없이 effect를 세팅해도 되죠.
API는 단순하고 직관적이며 일관되어야 합니다. useQuery
의 콜백들은 이 기준에 부합하는 것처럼 위장한 버그 생성기입니다. 처음 구현할 때는 원하는 대로 동작할 가능성이 높지만, 앱이 성장하며 리팩터링이나 확장을 할 때 대가가 따르므로 좋지 않습니다. 또한 오류가 발생하기 쉬운 상태 동기화를 도입하면서 이게 나쁜 거라는 느낌을 받지 않으므로 안티 패턴을 초래하기도 하죠.
제가 본 거의 모든 예시에서 버그가 발생할 수 있고 발생할 것이며, 이런 사례가 이슈, 토론, 디스코드, 스택오버플로우 질문에서 정기적으로 보고되는 걸 보아왔습니다. 이 버그는 추적하기가 너무 어렵죠. 제가 못 미더우신가요? 그럼 Theo에게 맡기도록 하죠.
업데이트:
onSuccess
의 인스턴스가 27개. 그 중 4개가 쿼리에 속함, 그 중 3개가 버그의 출처인 게 거의 확실.
deprecate 하세요 🙏 - 2023. 4. 15
Theo-t3.gg의 트윗
이 API가 애초에 존재하지 않는 게 더 나은 이유이며, 그렇기에 저희가 제거하는 겁니다.
좋은 글 번역 감사합니다.
메이저 버전이 나온다는 건 이전 버전의 설계 실패를 의미한다... 재미있네요.