25.2.17 ~ 25.3.16
개발리뷰회고를 모두 합하여 있었던 일에 대해 회고하며 어려웠던 점이나 재미있었던 점을 기록해본다.
TanStack Query를 흉내낸 미니 버전의 GyulStack Query를 만들기
(따지고 보면, 클론 코딩이다)
useQuery()를 사용하는 코드는 이미 작성되어 있고, useQuery.ts를 구현하면 되는 과제이다.
과제를 받고, 제일 먼저 했던 일은 제공된 코드를 확인함과 동시에 TanStack Query 문서를 확인하는 일이었다. 나는 TanStack Query로 바뀌기 이전, React Query 일 당시에 사용했던 경험이 있어서, 해당 쿼리가 어떤 일을 하는 지 대략적으로 알고 있었다. 그래서 캐싱과 fetch 를 통해 데이터를 돌려보내야겠다 생각했다. 그러기 위해서는 useEffect를 사용하여 통신을 하고 useState에 값을 담아 useQuery 함수에서 반환값으로 넘겨주면 되겠구나 싶었다.
소요 시간: 대략 5시간
리팩토링하는 데 시간을 많이 사용한 것 같다. 기본 스펙이나 추가 스펙 구현에 있어서 어려웠던 점은 특별히 없었다. 여기서 어려웠다는 기준은 소요 시간이 많은지, 적은지에 대해서다. (코드 작성양이 많다고 해서 어려운 건 아니다, 소요 시간이 많다는 건, 그만큼 코드를 작성하는 데 있어서 헤매는 상황이 많다는 의미로 이해하면 되겠다) useEffect를 사용하고, 화면을 새롭게 렌더링해야 하기 때문에 useState를 사용한다. 나는 useState 대신 useReducer를 사용했다. 캐싱은 useRef에 담아 캐시 데이터가 있다면, 그걸 우선적으로 사용하게 했다.
이번 과제에서 주안점으로 둔 것은 액션, 계산, 렌더링 로직이다. 세 가지를 각각 다른 함수에 배치해서 추후 수정할 일이 생기더라도 쉽게 할 수 있도록 코드를 작성해봤다. 그러나 세 가지를 분리하면서 계속 고민 됐던 것은 혹여나 함수로 분리하면서, 괜한 것들까지 찢어버린 건 아닐지… 걱정이 되었다.
type Action =
| { type: 'SET_QUERY'; query: User }
| { type: 'SET_ERROR'; error: Error }
| { type: 'SET_PENDING'; isPending: boolean };
type State = {
query: User | undefined;
error: Error | undefined;
isPending: boolean;
};
const reducer = (state: State, action: Action) => {
switch (action.type) {
case 'SET_QUERY':
return { ...state, query: action.query };
case 'SET_ERROR':
return { ...state, error: action.error };
case 'SET_PENDING':
return { ...state, isPending: action.isPending };
default:
return state;
}
};
const initialState = { query: undefined, error: undefined, isPending: true };
...
const [state, dispatch] = useReducer(reducer, initialState);
const handleData = (data?: User, error?: Error) => {
if (!data && !error) {
dispatch({ type: 'SET_PENDING', isPending: true });
}
if (data) {
dispatch({ type: 'SET_PENDING', isPending: false });
dispatch({ type: 'SET_QUERY', query: data });
} else if (error) {
dispatch({ type: 'SET_PENDING', isPending: false });
if (error) dispatch({ type: 'SET_ERROR', error: error });
}
};
handleData 함수는 data, error 값을 받으면 dispatch 해주는 함수이다. 리렌더링을 유발하는 동작은 이곳에 모아두었다.
useEffect(() => {
(async () => {
if (cache && cacheTime) {
const cachedResult = refreshCachedData(
queryKey,
cacheData.current,
cacheTime
);
if (cachedResult) {
return handleData(cachedResult, undefined);
}
}
const { data, error } = await getData(queryFn, retry);
if (cache && cacheData.current && data) {
cacheData.current[queryKey] = { ...data, timestamp: Date.now() };
}
handleData(data, error);
})();
return () => {
handleData(undefined, undefined);
};
}, [queryKey]);
컴포넌트가 마운트된 이후, 실행되는 로직이다. useEffect를 통해 queryKey가 변경되면, useEffect를 다시 실행한다. useQuery를 구현해야 한다고 생각했을 때, 바로 이러한 구조를 떠올렸는데, 이전에 사용해본 경험이 있었기 때문이다. 또한, queryFn은 함수 객체이기 때문에 useEffect가 재렌더링마다 계속 실행될 거 같았다. 그래서 의존성 값에 queryKey를 넣어주었다.
그런데 useEffect에 너무 많은 로직이 포함되어 있는 게 아닌가 싶다. 캐시 데이터를 가져와서 값을 handleData 함수에 주입하는 내용과 캐시 데이터가 없다면 데이터를 새롭게 가져와 handleData에 전달하고, 캐시 데이터에 최신 데이터를 할당하는 내용.
캐시 저장소에 최신 데이터를 할당하는 로직은 별도의 함수로 만들어 사용할 수도 있을 것 같다. 여기서는 useRef를 사용했기 때문에 리액트와 분리하는 것이 힘들지만, 처음부터 ref가 아닌 Map을 이용하거나 객체를 사용했다면, 분리하는 것이 가능할 거 같다.
과제를 진행하면서 Race Condition 이라는 게 있다는 걸 전혀 몰랐었다. 다른 분의 과제를 리뷰하며, 거기에는 Race Condition을 신경 쓴 내용이 담겨있어 알게 되었다. 찾아보니, useEffect에서 비동기 통신을 진행할 때, 또 한 번의 요청이 발생하여, 중복된 요청이 수행될 수 있다는 것이었다. 이로인해 이전 요청이 최신 요청을 덮어쓰면서 문제가 생겨, 캐싱이나 최적화가 어려워진다는 소리였다.
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
};
}, [person]);
// ...
// 공식문서 예시
이를 방지하기 위해 Boolean 상태 변수를 선언하고 if문을 통해 걸러내면 방지할 수 있다는 것이다. 클린업 함수에서 변수를 다시 변경하면, 이전 상태가 최신 상태를 덮어쓰기 할 문제를 예방할 수 있다.
혹은 **AbortController** 를 사용할 수도 있다.
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
setBio(null);
try{
const res = fetch("...", {
signal: abortController.signal,
}).then((data)=> setBio(data.json()))
}catch(error){
//...
}
return () => {
abortController.abort() // 요청 중지
};
}, [person]);
// ...
// 공식문서 예시
AbortController.signal 읽기 전용으로 통신하거나 취소하는 데 사용되는 AbortSignal 객체를 반환한다. 그리고 AbortController.abort() 를 통해 fetch 요청을 완료되기 전에 취소할 수 있다.
그러나 위의 방법은 fetch 요청에 abortController를 전달해야 하기 때문에 사용하기에 제한이 있을 수 있다. 또한 리소스가 상대적으로 (boolean 방법보다) 많이 사용된다. 따라서 boolean 상태 변수를 사용하여 컨트롤하는 방법이 좋은 거 같다.
useEffect(() => {
(async () => {
let ignore = false;
if (cache && cacheTime) {
const cachedResult = refreshCachedData(
queryKey,
cacheData.current,
cacheTime
);
if (cachedResult) {
return handleData(cachedResult, undefined);
}
}
const { data, error } = await getData(queryFn, retry);
if(!ignore){
if (cache && cacheData.current && data) {
cacheData.current[queryKey] = { ...data, timestamp: Date.now() };
}
handleData(data, error);
}
})();
return () => {
handleData(undefined, undefined);
ignore = true;
};
}, [queryKey]);
Race Condition을 생각해 코드를 수정한다면, 위처럼 되겠다! 이번 상황의 경우, fetch 함수를 조작할 수 없기 때문에 boolean 상태 변수를 사용하여 간단히 예방해보았다.
위의 작동 화면을 확인하면 알 수 있겠지만, User #number 숫자가 변경되는 순간과 데이터 응답 시간이 맞지 않는 걸 볼 수 있다. 버튼을 누르면, 숫자는 바로 반응하지만, 비동기 통신인 데이터는 곧바로 응답할 수가 없다. 이걸 isPending을 이용해 데이터 요청을 보낼 때 isPending을 true로 변경하면 어떻냐는 의견을 받았다.
const handleData = (data?: User, error?: Error) => {
if (!data && !error) {
dispatch({ type: 'SET_PENDING', isPending: true });
}
if (data) {
dispatch({ type: 'SET_PENDING', isPending: false });
dispatch({ type: 'SET_QUERY', query: data });
} else {
dispatch({ type: 'SET_PENDING', isPending: false });
if (error) dispatch({ type: 'SET_ERROR', error: error });
}
};
그렇지만 이미 데이터나 에러가 없을 때는 isPending을 true로 처리해놓았다. useReducer를 사용하여 state를 반환하고 있으니, isPending이 변화하면 화면은 리렌더링 될 텐데…
const handleData = (data?: User, error?: Error) => {
if (!data && !error) {
dispatch({ type: 'SET_PENDING', isPending: true });
}
if (data) {
dispatch({ type: 'SET_PENDING', isPending: false });
dispatch({ type: 'SET_QUERY', query: data });
} else {
dispatch({ type: 'SET_PENDING', isPending: false });
if (error) dispatch({ type: 'SET_ERROR', error: error });
}
};
답은 if문에 있었다. data와 error가 없다면, 제일 처음 if(!data && !error) 조건에 걸릴 것이다. isPending: true로 될 것이고, 당연히 data가 없으니 두번째 if문에서는 else로 넘어가게 된다! 즉, 여기서 isPending: false로 설정되어 화면이 계속 보여지고 있던 것.
const handleData = (data?: User, error?: Error) => {
if (!data && !error) {
dispatch({ type: 'SET_PENDING', isPending: true });
}
if (data) {
dispatch({ type: 'SET_PENDING', isPending: false });
dispatch({ type: 'SET_QUERY', query: data });
} else if (error) {
dispatch({ type: 'SET_PENDING', isPending: false });
if (error) dispatch({ type: 'SET_ERROR', error: error });
}
};
else if (error)로 변경하여 다시 작동시켜보니, 기대했던 대로 움직였다.
나는 useState로 코드를 작성한 뒤, 관련된 state가 3가지 이상이라면 useReducer로 취합하는 편이다. 이전 과제에서 그래왔는데, 다른 분들의 코드를 리뷰할 때 보면 useReducer를 쓰는 사람이 없었다. 물론, 그렇게 큰 규모의 과제도 아니라서 사용하지 않아도 되겠지만, 실제로도 useReducer보다 useState를 많이 쓸까? 궁금증이 생겼다. 다만, 이 궁금증은 여러 회사를 다니고, 여러 오픈 소스를 봐야 알 수 있을 듯 싶다.
useQuery를 알고 있었어서 처음부터 캐싱 기능을 넣었었는데, 알고보니 추가 스펙이었다… 캐싱 기능을 할 지, 하지 않을 지에 대해 분기 처리를 하며, 리팩토링 하는 것이 제일 힘들고 어려웠는데, 이건 어느 과제든 어느 코드든 똑같은 것 같다. 유지보수가 제일 어렵다…그래서 가급적 처음부터 제대로된 코드를 작성하고 싶다. 적어도 코드가 꼬이는 일은 없도록!