지난 포스트에 이어 당근 윈터테크 인턴십 두 번째 사전 질문에도 답해보려고한다. 질문을 읽자마자 React Query로 개발했던 경험이 떠올랐는데, 만약 React Query가 없다면 어떻게 구현할 수 있지? 라는 고민을 시작으로 공부한 내용을 담았다. 이후 React Query 라이브러리 코드를 간단하게 분석해보고, 어떻게 "잘" 사용할 수 있을지 의견을 제시하며 마무리한다.
- 추가
폴링, 롱 폴링으로의 구현은 내가 아닌 다른 사람이 서버 상태를 변경했을 때도 반영 가능하고, React Query는 내가 변경했을 때 반영할 수 있는 방법이다.
서버 데이터가 변경되었을 때 해당 데이터를 사용하는 클라이언트 상태가 변경돼야 한다.
먼저, 특정 라이브러리 없이 자바스크립트로 이를 구현할 수 있는 방법을 조사했다. 다양한 방법이 있었지만 일반적으로 이 두 가지가 언급되었다.
클라이언트가 특정 주기를 가지고 서버에 HTTP 요청을 한다.
흐름을 이해하는 데에 좋은 코드가 있어 사용 허락을 받고 가져왔다.
function startPolling(callApiFn, testFn, doFn, time) {
let intervalId = setInterval(() => { // [1]
callApiFn() // [2]
.then((data) => {
if (intervalId && testFn(data)) { // [3]
stopPolling();
doFn(data);
}
})
.catch((e) => { // [4]
stopPolling();
throw new Error("Polling cancelled due to API error");
});
}, time);
function stopPolling() { // [5]
if (intervalId) {
console.log(new Date(), "Stopping polling...");
clearInterval(intervalId);
intervalId = null;
} else {
console.log(new Date(), "Polling was already stopped...");
}
}
return stopPolling; // [6]
}
setInterval()
으로 주기적으로 API를 호출하고 결과를 체크하는 루프를 생성한다. 폴링이 취소되면 intervalId
는 null
이 된다.doFn()
을 수행한다.clearInterval()
을 사용한다. 1번에서 말했듯 intervalId
를 null
로 만든다.stopPolling
함수를 리턴한다.해당 코드는 폴링 함수 안에 doFn
이 묶여있기 때문에 유연성이 떨어진다고 생각했다.
function startPolling(callApiFn, testFn, time) {
let polling = false; // [1]
let rejectThis = null;
const stopPolling = () => { // [2]
if (!polling) {
console.log(new Date(), "Polling was already stopped...");
} else {
console.log(new Date(), "Stopping polling..."); // [3]
polling = false;
rejectThis(new Error("Polling cancelled"));
}
};
const promise = new Promise((resolve, reject) => {
polling = true; // [4]
rejectThis = reject; // [5]
const executePoll = async () => { // [6]
try {
const result = await callApiFn(); // [7]
if (polling && testFn(result)) { // [8]
polling = false;
resolve(result);
} else { // [9]
setTimeout(executePoll, time);
}
} catch (error) { // [10]
polling = false;
reject(new Error("Polling cancelled due to API error"));
}
};
setTimeout(executePoll, time); // [11]
});
return { promise, stopPolling }; // [12]
}
doFn()
을 인자로 넘기지 않는다. polling
변수는 폴링이 유지될 때 true
를 가지고, rejectThis
변수는 Promise로 부터 reject
함수를 저장한다(4, 5번 참고).stopPolling()
함수는 폴링이 실행 중이면 중단한다.polling
을 false
로 하고, 저장된 rejectThis
함수로 Promise를 즉시 reject한다. polling
이 true
로 세팅된다.stopPolling
에서 Promise를 강제로 reject할 수 있도록, reject
파라미터를 rejectThis
에 담는다(3번 참고).excutePoll
은 폴링과 테스팅을 한다.time
의 지연 이후 새로운 폴링을 세팅한다.time
의 지연 이후 executePoll
로 인해 시작된다.promise
와 stopPolling
을 리턴한다.위 코드는 Promise를 리턴하기 때문에 API 응답 성공 이후 행위에 대해 보다 자유로운 핸들링이 가능하다.
아래는 사용하는 코드이다.
console.log(new Date(), "Starting polling");
const { promise, stopPolling } = startPolling(callFakeApi, testCondition, 1000);
promise.then(doSomething).catch(() => { /* do something on error */ });
await timeout(6300);
console.log(new Date(), "Canceling polling");
stopPolling();
이러한 단점을 극복하기 위해 롱 폴링 기법이 생긴 것이라고 한다.
폴링 함수를 참고하여 롱 폴링을 구현해보았다. 롱 폴링은 서버 측에서 이벤트 또는 타임아웃이 발생할 때까지 연결을 유지해주어야 하기 때문에, 서버 측 구현이 더 복잡하다.
사실 나는 서버 개발을 해본 적이 거의 없다. 하지만 Observer 개념을 이용해서 간단히 코드를 작성해보았다.
먼저 register
, unregister
, notify
기능을 가진 Observer 클래스를 작성했다.
class DataObserver {
constructor() {
this.subscribers = [];
}
register(subscriber) {
this.subscribers.push(subscriber);
}
unregister(unsubscriber) {
this.subscribers = this.subscribers.filter(subscriber => subscriber !== unsubscriber);
}
notify(data) {
this.subscribers.forEach(subscriber => subscriber(data));
}
}
다음은 이 객체를 이용해서 서버 이벤트가 발생했을 때, 또는 타임아웃이 발생했을 때 응답을 주는 서버를 구현해보았다.
const express = require('express');
const app = express();
const PORT = 3000;
let data = 'Initial data'; // [1]
app.use(express.json());
const dataObserver = new DataObserver();
app.get('/posts', (req, res) => {
const onDataUpdate = (data) => { // [2]
res.json({ data });
dataObserver.unsubscribe(onDataUpdate);
};
const timeoutId = setTimeout(()=>{ // [3]
res.json({ data });
dataObserver.unsubscribe(onDataUpdate);
}, 30000);
dataObserver.subscribe(onDataUpdate); // [4]
});
app.post('/posts/:id/data', (req, res) => {
data = req.body.data;
dataObserver.notify(data); // [5]
res.json({ success: true });
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
observer
를 unsubscribe
하는 함수이다.observer
를 subscribe
하는 함수이다.observer
에 notify
하는 함수이다.function startLongPolling(callApiFn, testFn, doFn) {
let polling = false;
const stopLongPolling = () => {
if (!polling) {
console.log(new Date(), "Polling was already stopped...");
} else {
console.log(new Date(), "Stopping polling...");
polling = false;
}
};
const executeLongPolling = async () => {
try {
const result = await callApiFn();
if (polling){
if (testFn(result)){
const data = await result.json();
doFn(data); // [1]
}
executeLongPolling(); // [2]
}
} catch (error){
console.log("Long Polling Error");
executeLongPolling(); // [3]
}
}
executeLongPolling();
return { stopLongPolling };
}
doFn()
을 실행한다.정리하자면 이러한 플로우의 코드이다.
import React, { useEffect } from 'react';
function Component() {
useEffect(() => {
const { stopPolling } = startPolling(
callApiFn,
testFn,
doFn
);
return () => {
stopPolling();
};
}, []);
}
폴링, 롱 폴링을 위와 같은 방식으로 구현한다면 반드시 언마운트 시 폴링을 멈춰줘야 한다. 아니면 setInterval()
, setTimeout()
, 재귀 함수 등이 해제되지 않아 메모리 문제가 발생할 수 있다.
폴링, 롱 폴링에서 발생하는 부하 문제를 해결하려면 갱신해야 되는 데이터를 클라이언트에서 알려주어, 해당 데이터만 리패치하는 방법을 사용할 수 있다.
React Query의 invalidateQueries
나, SWC의 mutate
가 사용하는 방식이다. 나에게는 React Query가 조금 더 익숙해서, 이 라이브러리가 어떻게 리패치할 데이터를 알려주는지 간단히 알아보았다.
나는 invalidateQuries
함수에 대해서만 분석할 것인데, React Query의 전체적인 동작원리에 대해 자세히 분석해둔 좋은 아티클이 있어 첨부한다.
// [1]
queryClient.invalidateQueries()
// [2]
queryClient.invalidateQueries({ queryKey: ['todos'] })
// [3]
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10
})
version
프로퍼티가 10 이상인 모든 쿼리를 무효화한다.쿼리 캐시에서 일치하는 키를 가진 모든 캐시를 무효화하고, refetch 한다.
invalidateQueries(
filters: InvalidateQueryFilters = {},
options: InvalidateOptions = {},
): Promise<void> {
return notifyManager.batch(() => {
this.#queryCache.findAll(filters).forEach((query) => { // [1]
query.invalidate() // [2]
})
if (filters.refetchType === 'none') { // [3]
return Promise.resolve()
}
const refetchFilters: RefetchQueryFilters = {
...filters,
type: filters.refetchType ?? filters.type ?? 'active',
}
return this.refetchQueries(refetchFilters, options) // [4]
})
}
findAll()
으로 queryClient
에서 인자로 넣어준 필터와 일치하는 쿼리 캐시를 찾는다.refetchType
이 'none'
이면 그대로 Promise를 resolve한다.refetchQueries()
를 수행한다.쿼리 캐시에서 일치하는 쿼리를 모두 반환하는 함수이다.
export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore
constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}
getAll(): Array<Query> {
return [...this.#queries.values()]
}
findAll(filters: QueryFilters = {}): Array<Query> {
const queries = this.getAll()
return Object.keys(filters).length > 0
? queries.filter((query) => matchQuery(filters, query))
: queries
}
}
filters가 있다면, matchQuery()
를 통해 일치하는 쿼리를 가져온다. 없다면, 모든 쿼리를 가져온다.
쿼리 키 혹은 다른 인자값을 통해 쿼리의 일치 여부를 반환하는 함수이다.
export function matchQuery(
filters: QueryFilters,
query: Query<any, any, any, any>,
): boolean {
const {
exact,
predicate,
queryKey,
} = filters
// [1]
if (queryKey) {
if (exact) {
if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
return false
}
} else if (!partialMatchKey(query.queryKey, queryKey)) {
return false
}
}
// [2]
if (predicate && !predicate(query)) {
return false
}
return true
}
hashQueryKeyByOptions()
를 통해 해시값이 일치하는지 확인한다.partialMatchKey()
를 통해 일부가 일치하는지 확인한다.refetch 함수까지 알아보기에는 시간적 제약이 있어서, 이 정도로 간단히 분석을 마쳤다.
React Query의 구현은 복잡하지만, invalidateQueries
함수에서 복잡한 패턴을 사용하지는 않는다. 넣어준 인자를 통해 일치하는 쿼리를 찾아서 무효화해준 뒤, refetch 하여 새로운 데이터를 불러오는 구조이다.
invalidateQueries()
는 주로 useMutation
과 함께 쓴다. Mutation이 성공했을 때 여러 쿼리가 refetch 돼야 하는 경우를 생각해보자.
const {...} = useMutation("...", {
onSuccess: () =>{
queryClient.invalidateQueries("Query Key 1");
queryClient.invalidateQueries("Query Key 2");
queryClient.invalidateQueries("Query Key 3");
}
})
무효화 시켜야 할 쿼리들이 해당 뮤테이션에 의존하게 되고, 비즈니스 요구사항이 변경되면 관련된 컴포넌트를 일일히 찾아서 관리해주어야 한다.
그럼 이제 쿼리 키를 어떻게 관리할 수 있을까? 라는 고민을 하게 된다.
위 아티클을 참고하여 scope-entity-id
라는 구조로 쿼리 키를 관리해보았다. 게시글이라는 도메인에서는 다음과 같이 작성해볼 수 있다.
* 그림에서 게시글 댓글 조회의 Unique Id가 누락되었습니다. 참고해주세요.
import { useQueryClient } from "@tanstack/react-query";
interface QueryKey{
scope: string;
entity?: string;
id?: number;
}
const queryClient = useQueryClient();
const postKeys = {
base: { scope: "posts" },
getPosts: () => [{ ...postKeys.base }],
getPost: (id: number) => [{ ...postKeys.base, id }],
getComments: (id: number) => [{ ...postKeys.base, entity: "getComments", id }],
};
그리고 데이터 수정에 따라 리패치하고 싶은 쿼리를 관리하기 위해 Dependencies라는 이름의 객체를 도입해보았다.
수정 요청에 따라 리패치하고 싶은 쿼리의 키를 배열로 넣어준 단순한 객체이다.
const postDependencies = {
base: [postKeys.getPosts()],
like: (id: number) =>
[...postDependencies.base, postKeys.getPost(id)],
post: () => [...postDependencies.base],
comment: (id: number) => [...postDependencies.base, postKeys.getComments(id)]
};
const invalidate = (dependencies: Array<Array<QueryKey>>) => {
dependencies.forEach((queryKey) => {
queryClient.invalidateQueries({ queryKey, exact: true });
});
};
다음과 같이 사용할 수 있다.
import { postDependencies } from '...';
const {...} = useMutation("...", {
onSuccess: () =>{
invalidate(postDependencies.like(id))
}
})
쿼리 키 구조화와 쿼리 무효화 dependency 관리 대해서 고민을 충분히 하지 못해서 퀄리티가 떨어진다. 이 부분에 대해서는 지속적으로 개선점을 찾아가야 할 것 같다.
폴링과 롱 폴링을 코드로 구현하면서 이해하니까 이론으로만 접하는 것보다 훨씬 와닿았다. JavaScript로 A to Z까지 프레임워크를 구축하고 해당 코드를 직접 붙여보면 재밌겠다는 생각이 들었다.
이 아티클을 작성하면서 오랜만에 다른 사람이 작성한 코드를 심도 있게 읽고 분석했는데, 정말 만만치 않은 일이라는 것을 다시금 느꼈다. 아직도 React Query의 전반적인 동작원리에 대해서는 아리송하다. 참고했던 아티클을 몇 번 더 읽어봐야겠다.
그리고 나는 두 가지 사전 질문에 대해 이렇게 답해보았는데, 다른 사람들은 어떤 답변을 했는지 궁금하다. 정답이 있는 문제가 아니기 때문에 생각을 공유하면서 디벨롭해나가면 좋을 것 같다.
해당 포스트에 대한 의견이나 조언이 있으시면 댓글로 나누어주세요! 감사합니다.
좋은 글 잘 읽었습니다!
저는 abortController를 이용해서 구현했었는데, 관심있으시면 알아보세요 :)