[기업과제 회고] cache storage 및 기타 고생내용...

🖲 들어가기 앞서

이 외에도 이것저것 많이 들어갔는데 돌이켜보니 너무 힘들었다...

이번에 제작하게 된 개인과제에서는 데이터를 조회하여 카드형태로 나타내기만 하면 어떻게 보면 가벼운 과제라고 생각했으나 그 내부에서 이것저것 추가하다보니 여러모로 너.... 무 고생하고 힘들었던 순간이 많았다.

특히 그 과정 중에서 스트레스를 유발하던 것들이 몇개 있었는데 이것들을 나열해보고자 한다.

🏍 1. cache storage?

이제까지 브라우저 저장공간이 localstorage, cookie storage 정도라고 생각했던 나에게 cache stotrage는 몹시도 생소하고 흥미로운 장소였다.

이번 요구사항 내용 중에서 초기 요청이 일어난 이후에는 캐시 형태로 내용을 저장해서 거기에서 조회해달라는 요구사항이 존재했어서 이것을 구현하기 위해 이것저것 찾아본게 많았다.

<round 1. 아 리퀘스트 캐싱하라는 의미인걸까?>

처음엔 정말 단순하게 생각했다. 그냥 리퀘스트 내용을 캐싱해서 계속 그것을 사용하면 된다고 말이다.

그런데, 예기치 못한 조건이 더 있었다.

"새로고침을 하게 되면 다시 요청을 보내지 않고 캐싱했던 내용을 사용해서 데이터를 조회하고..."

캐싱이라는 소리를 보자마자 바로 React-Query를 사용해서 구현해야지 하고 생각했던 나에게는 청천벽력과 같은 내용이었다.

왜냐하면 React-Query의 캐싱은 새로고침을 하면 유지가 되지 않기 때문이다.
(다시말하면, 새로고침을 해도 브라우저에 데이터가 저장되어있어야 한다)

import { QueryClient, QueryClientProvider } from "react-query";

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Home />
    </QueryClientProvider>
  );
} // 리엑트 쿼리는 기본적으로 앱이 실행되는 순간 ContextAPI 처럼 props를 자식들에게 보내주는 구조로 되어있다. 그리고 요청의 캐싱내용도 마찬가지로 provider인 queryClient에 저장되었다가 새로고침하면 휘발되는 구조기 때문에 맞지 않다.

다른 의미로 axios의 캐싱도 만지작거려봤지만 동일한 결과를 낳았다.

위 이미지처럼 axios의 인스턴스를 생성하면서 옵션으로 adapter을 전하며 기본 캐싱행위를 저지시키고 헤더로도 명시적으로 no-cache를 해둔 후,
내가 필요할 때 ajax 요청을 하는 대상에 선택적으로 캐싱하게 만들어두면 재요청을 하지 않고 캐싱내용을 전달한다.

위처럼 요청은 계속 가지만,

네트워크 요청은 한번만 하는 것을 알 수 있다.

만약 react-query를 사용하지 않고 axios만으로 캐싱을 반영하고 싶다면 해당 방식이 좋은 솔루션이 될 것 같다고 배운 경험이 되었다.

뭐... 그치만 지금 그게 중요한게 아니고 새로고침에 캐싱이 되어야하는게 더 중요하니

<round 2. 그럼 로컬스토리지로?>

새로고침에 대해서 방지책을 생각해보라면 가장 먼저 떠오르는 것이 localstorage일 것이다. 그런데 로컬 스토리지는 사실 데이터를 캐싱하기에는 조금 부담스러운 부분이 존재한다.

그것은 바로 용량 공간이 생각보다 많지 않다는 것!

api요청을 한번 해보니 데이터가 수천개가 날아오는 것을 보며 나중에라도 데이터 내용이 확장한다면 이것을 죄다 localstorage에 저장하면서 사용하기엔 무리가 있다 라는 판단을 하게되었다.
그래서 이것저것 찾아보니

위 스크린샷은 Google 엔지니어 성님의 홈페이지 답변 내용이다. 생각보다 localstroage에 대해서 몹시 부정적인 의견을 내고 있다는 것에 한번 놀랐고,

생전 처음듣는 cache storage와 indexed DB라는 것을 추천하여 네트워크 요청 정보를 저장하라고 하는 것을 알 수 있다. 그렇다면 이렇게 추천을 하는 이유가 따로 있을것이다.

아니 어머나 세상에 localstorage는 5mb 문자열밖에 못하면서 애는 디스크 최대 60%까지 저장이 가능하다고? 수G까지도 저장이 될 수 있다고?

사용하지 않을 이유가 없구만

하는 생각으로 사용했다가 정말 많은 피를 보게 되어 여기에 남긴다.

<round 3. 캐시 스토리지를 사용해보자>

위의 이미지에서 볼 수 있듯, cache storage를 사용하는 방법은 생각보다 간단하다.
특정 url을 설정해두고 caches윈도우의 객체 프로퍼티 내부에 있는 메서드 open을 호출하면 된다. 그리고나서 조건부로 데이터가 존재할때, 그리고 존재하지 않을 때를 나누어서 로직을 처리하면 된다.

다만, 이때 좀 특이한점은 요청정보가 없어서 else로 fetching하여 response 객체를 캐싱하는 경우인데, 이 객체를 한번 캐싱하면 그 뒤에 json()을 통해 data 값을 호출하는 것이 불가능하므로 (무슨 원리인지는 모르겠지만 신기했다) 반드시 put으로 집어넣기 전에 미리 clone 메서드를 호출해야 함을 명심해야 한다.

그리고 또 중요한것이 위의 이미지를 보면 fetch를 사용하는 것을 볼 수 있는데, 만약 이렇게 비동기적인 작업을 try 블록에 집어넣을 경우 에러가 발생하면 잡아내지 않기 때문에 반드시 catch를 이용하여 따로 처리해야 한다.

이와 관련된 내용은 기존 에러로그에 조금 더 자세하게 기술해놓았다.
비동기 에러 처리와 관련하여 주의점!

캐시 스토리지의 데이터내용은 위와같이 어플리케이션의 저장공간에서 확인이 가능하다. 다만, 이 스토리지 내용만 조회해서는 데이터를 전달해주지 않아서 반드시 json 메서드로 변환 호출해서 가져와야 한다.


🖲 2. 리엑트 쓰면서 dependency 함부로 무시하지 마라.

react 에서 useEffect를 사용하다보면 자주 사용하게 되는 두번째 인자 dependency...

useEffect(function, [])

나는 여지껏 저 dependency를 그냥 첫째 인자에 저장되어 있는 함수를 비동기적으로 호출할 때 호출되는 기준으로 dependency 배열 내의 값의 변동을 체크한다, 라고만 생각하고 있었다.

그런데, 그 생각이 이번에 와장창 깨지면서 이것저것 곤란한 상황을 겪어 기록으로 남기게 된다.

물론, 위에 기술한 저 의미는 그 있는 그대로의 의미가 맞다.

하지만 그것보다 더 중요한 개념이 하나 더 있었다. 그것은 바로

첫번째 함수객체가 호출되어 실행이 될 때, 변수들을 최적화할지 말지에 대한 기준점이 된다는 것이다.

이게 무슨 의미인지 사진을 통해서 조금 더 설명하고자 한다.

해당 useEffect는 loading이라는 dependency 배열 내용이 바뀜으로 인해 첫째 인자인 콜백이 호출되는 상황이다.

근데, dependency 배열 내에는 loading만 존재하고 있고, 콜백 함수의 내부에서는 searchList라고 하는 변수를 참조해서 무언가를 하고 있다.

참고로, redux storage는 페이지 접근 순간 searchList가 업데이트가 되는 구조로 되어 있고 확인결과 데이터가 존재하는 것도 알 수 있었다.

그럼 저 결과대로 컴포넌트를 보여주는 UI 로직이 제대로 작동할까?

nope! 전혀 의도한대로 작동을 하지 않는것을 알 수 있다.

도대체 왜 그럴까? 계속 고민하고 시도해본 결과, react에서 dependency 배열이라는 것은 단순하게 첫번째 인자인 콜백을 비동기적으로 호출할지 말지에 대한 기준점이 되는것을 넘어서서, 내부 변수들을 새롭게 마운트된 데이터들을 기준으로 할지 말지에대한 기준점 역시 하고있다는 놀라운 사실을 알게된 것이다.

만약 dependency 배열에 필요한 새로운 데이터의 기준점을 추가해줄 경우 어떻게 될까.

이제서야 원하는 대로 결과가 나오는 것을 확인할 수 있다.
이것은 다른 최적화 관련 hook들도 마찬가지이다.

위의 useEffect는 target이 변동되면 호출되어 어떤 함수를 호출하도록 설계해두었다.

해당 함수는 useCallback으로 최적화하여 캐싱되어 있는 함수객체인데, 이때 호출을 하면 target을 콘솔로 찍도록 설계되어 있다.

그러나, 이때 dependecy 배열에는 target이 존재하지 않는다. 즉 target의 변동에 대해서 내부 콜백함수의 변수가 최적화되지 않는다는 소리다. 그러면 어떤 결과를 가져올까

아무리 store의 target이 변동되고 있더라도 콘솔로 target이 변동되지 않고 계속해서 캐싱된 렉시컬 환경의 target이 호출된다.

그럼 우리는 예상하건데 target을 여기에 넣는다면 내부의 변수가 최적화가 완료되어 항상 새로운 데이터를 기준으로 할 것을 알 수 있다.

dependency의 파워가 느껴지십니까?

나는 이 결과를 보고 너무 큰 충격을 받았고, react를 알면 알수록 끝이 없다는 사실을 경험하게 되었다. 도대체 왜 react는 자꾸만 dependency 배열을 다 안채워두면 뭐라고 화를내는걸까? 이제는 이 경험을 통해 명확하게 알게 되었다.


번외편으로, 그럼 dependency만 잘 설정하면 확실한걸까?

라고 위에처럼 생각하면 정말 안일하다는 것을 방금 전에 개발하다가 후려맞으면서 알았다.

다시금 말하지만 useEffect는 비동기 함수이다. 즉, callback을 호출할 때가 정해지지 않았다는 의미이다.

dependency 배열이 최적화를 보장해주는 것은 "동기적" 업데이트의 최신 결과를 의미하지 "비동기적" 업데이트의 결과까지 다 보장해주며 기다리는 것이 아니다.

사실상 비동기적으로 처리되는 내용이 useEffect로 이것 하나만 있었다면 이렇게까지 큰 문제는 발생하지 않는다.

그러나, 가장 큰 문제는 useEffect들끼리 서로 연결되어서 상태업데이트를 하며 영향을 받을 때이다.

물론 이딴 로직을 안짜는 게 제일 최우선되야겠지만 리펙토링으로 일반화한다고 설치다가 온갖 난리부르스를 겪게 되었다.

useEffect 콜백은 비동기적으로 호출되긴 하는데, 서로 task queue에 들어가기 위해 준비되는 시기가 서로 다르기 때문에 콜스텍으로 들어가는 순서도 정해지지 않고 서로 다르다.

그래서, 만약 한 useEffect가 실행하는 상태변경이 다른 useEffect가 크게 영향을 받을 경우, 이것이 제대로 반영되지가 않는다.

위의 내용은 useEffect의 콜백함수 구조이다. 여기서 보면 조건부로 sliderList라는 배열의 길이가 존재하지 않을 경우 무언가를 한다고 설계되어 있다.

근데 문제는 sliderList라는 애는 searchList라는 최초의 검색 리스트가 존재 해야만 거기서 결과를 뽑아낼 수 있는 구조로 되어있는데, 이 searchList 역시 useEffect로 비동기적으로 업데이트된다는 점이다.

그래서, 해당 내용을 호출해보면 무슨 상황이 벌어지게되냐면

응 아무것도 안보여줄거야 돌아가

이런 슬픈 결과를 마주하게 된다.

따라서 만약에라도 한 useEffect의 실행이 다른 useEffect의 비동기적 실행결과에 따른 상태업데이트에 영향을 받게 된다면 이것을 반드시 콜백 함수 내에서 조건부로 명시해줘야만 한다.

위에서도 볼 수 있듯, if문 내에서 명시적으로 searchList가 존재를 할 때에만 아래의 구문이 실행되도록 설정을 해두니 이제는 원하는대로 결과를 보여주는 것을 알 수 있었다.

결론

진짜 react 너무어렵다.

좋은 웹페이지 즐겨찾기