TanStack Query를 Next.js 캐싱 전략으로 바꾸기

    반응형

    현재 [로그트립 1.1] 앱에서 캐싱 전략으로 TanStack Query를 사용하고 있습니다
    최초에는 React Native로 구성된 앱이었으나 설계를 바꾸면서
    React Native + Next.js 구조의 하이브리드 앱으로 바뀐 상태입니다

    React Native는 CSR 형태에 가깝습니다 (정확히는 좀 다름)
    아무튼 SSR이 안되는걸로 알고 있는데 레딧에서 어떤 글을 발견... [문서]

     

    Reddit의 reactnative 커뮤니티

    reactnative 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요

    www.reddit.com

    (그래서 가능하다는 문서 어딨는건데.. 찾질 못하겠음 React Native web을 말하는건가 싶음...)

    아무튼 CSR 형태의 React Native에서 데이터 캐싱에 대한 고민이 있었습니다
    내부 API -> 외부 API -> 대용량 데이터 패칭 -> 렌더링을 하는 데이터가 있는데
    이게 geojson 데이터라 무겁기도 하고 오래 걸릴때도 있었거든요
    그래서 한번 받아오면 업데이트가 일어나지 않는 이상 쭉 메모리에 캐싱해두고 재사용을 하고 싶었습니다
    그렇게 자주 쓰던 Tanstack Query가 떠올랐고, 당연히 사용하게 되었습니다

    최초 완성때는 문제가 없었습니다. Tanstack Query의 장점을 최대한 활용했고,
    고민했던 대용량 데이터 캐싱도 잘 해놨습니다

    근데 이제 하이브리드 앱 구조로 바꾸면서
    일단 RN에 있던 모든 로직을 Next.js에 다 옮기고 나서
    구조가 바뀌다보니 문제점들이 보이기 시작합니다

    저는 GNB(Global Navigation Bar)가 될 메뉴들은 RN쪽 네비게이션 바(탭바)로 구성해놨습니다
    이유는 앱의 고유한 스무스한 페이지 전환을 누리고 싶었기 때문입니다
    그렇다. 이게 복병이었습니다 ^_ㅠ

    이 메뉴들의 각 탭은 웹뷰로 구성되어있습니다

    export default function HomeScreen() {
      return (
          <WebView />
      );
    }
    
    export default function DiaryScreen() {
      return (
          <WebView />
      );
    }
    
    export default function MyPageScreen() {
      return (
          <WebView />
      );
    }

    위와 같은 형태로 구성되어있는건데,
    이게 알고보니 각각의 브라우저를 띄우는것과 같은 구조였던 것입니다...
    (앱 개발도 처음, 웹뷰로 구성도 처음이다보니 몰랐던 사실)

    그렇다보니 JS엔진 메모리 힙에 존재하는 Tanstack Query는 서로 공유가 안되는 상태가 되어버리게 됩니다
    (Tanstack Query는 하나의 브라우저 메모리 힙을 사용한다)
    어쩐지 A탭에서 데이터 생성할 경우 B탭 데이터가 업데이트 되어야해서
    캐시 무효화 및 리패칭을 진행해도 무반응이었던게 이 이유였던 것이다...

    그리고 이 또한 뒤늦게 알게된 사실인데, 어쩔땐 리패칭이 되고 안되고하는 현상이 있었습니다
    원리상으론 무조건 안되는게 맞는건데 뭐지? 싶었는데,
    발견한 패턴이 데이터를 생성하고 페이지를 이동할때만 페이지 리로드가 되는점!
    즉, expo-router의 router.push 동작 원리로 인해 (원래는 리패칭이 안일어나는게 맞는데)
    router.push 동작 원리가 현재 화면에서 새 화면이 새로 쌓이는거라
    페이지가 새롭게 마운트 되면서 API를 호출하니 마치 데이터 리패칭이 일어났다고 내가 착각을 했던거다...[공식문서] 
    이 부분도 대규모 수정이 필요할 것 같다

    아무튼 내가 기존에 쓴 캐싱 전략이 무용지물이 되었고
    이 쯤에서 SSR을 더 활용하고 싶은 욕심도 생기고,
    내가 Next.js 프레임워크를 잘 활용하고 있는가?란 고민도 생기면서
    Tanstack Query를 걷어내고 Next.js에서 제공하는 캐싱 메커니즘을 적극 활용해보자로 결정하게 됩니다
    (카카오에서 나와 비슷한 고민으로 쓴 글이 있는데
    Tanstack Query의 broadcastQueryClient를 활용해볼까 싶었지만 패스)

    그렇게 RN에서 쿠키 설정하고, Next.js Supabase SSR SDK 세팅까지 완료 후
    본격적으로 Tanstak Query 걷어내기를 시작했습니다

    import { createClient } from "@/shared";
    
    import { IDiaryRegions } from "..";
    
    export const getDiaryRegions = async (
      id?: string | null,
    ): Promise<IDiaryRegions[] | null> => {
      const supabase = createClient();
    
      const { data, error } = await supabase
        .from("diary_regions")
        .select(
          `
              *,
              diaries!inner(user_id)
            `,
        )
        .eq("diaries.user_id", id);
    
      if (error) throw error;
    
      return data;
    };
    
    
    export const diaryQueries = {
    	/* ... */
      regions: (userId?: string | null) =>
        queryOptions<IDiaryRegions[] | null>({
          queryKey: diaryRegionKeys.byUser(userId),
          queryFn: () => getDiaryRegions(userId),
          // ...
        }),
    };
    
    export const useFetchDiaryRegions = (userId?: string | null) => {
      return useQuery({
        ...diaryQueries.regions(userId),
      });
    };
    
    
    // widgets/world-map/index.tsx
    "use client";
    
    export function WorldMap() {
      const qc = useQueryClient();
      const { data: userId } = useFetchUserId();
      const { data, isFetching } = useFetchDiaryRegions(userId);
      
      return (
          /* ... */
      );
    }
    
    // app/world-map/page.tsx
    import { WorldMap } from "@/widgets/world-map";
    
    export default function WorldMapPage() {
      return <WorldMap />;
    }

    기존 코드는 위와 같은 코드여서 사실상 CSR 형태인게 대부분이었습니다
    물론 아닌것도 있었고, 위 코드는 예시일뿐입니다

    export default async function Diary() {
      const queryClient = new QueryClient();
      const options = diaryQueries.mineList();
    
      await queryClient.prefetchQuery(options);
    
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <DiaryList queryKey={options.queryKey} />
        </HydrationBoundary>
      );
    }

    이렇게 짠 코드도 있긴 했었습니다

    아무튼 이제 Tanstack Query문과 custom hook을 삭제하고 API 호출 로직쪽을 수정하게 됩니다

    import { SupabaseClient } from "@supabase/supabase-js";
    import { unstable_cache } from "next/cache";
    
    import { createServerClient } from "@/shared";
    
    const fetchDiaryRegions = (userId: string, supabase: SupabaseClient) =>
      unstable_cache(
        async () => {
          const { data } = await supabase
            .from("diary_regions")
            .select(`*, diaries!inner(user_id)`)
            .eq("diaries.user_id", userId);
    
          return data;
        },
        ["diary-regions", userId],
        { tags: ["diary-regions", `diary-regions-${userId}`] },
      )();
    
    export const getDiaryRegions = async (id?: string | null) => {
      if (!id) throw new Error("id가 없습니다");
      const supabase = await createServerClient();
      return await fetchDiaryRegions(id, supabase);
    };

    unstable_cache API를 사용했는데 이게 언제... 또 업데이트 된거죠...? [공식문서] 
    use cache로 바꾸는 작업을 한번 더 진행해야할 것 같고
    또 한가지... supabase 객체를 인자로 넘기는데 이게 어떤 잠재적 이슈가 있을지 모르는거?
    unstable_cache는 인수와 함수의 문자열화된 버전을 캐시 키로 사용한다는대
    supabase객체는 복잡한 인스턴스이기에 직렬화가 안될 수 있다는점...

    (내가 이해한게.. 맞겠지? 근데 대체 왜 AI는 위 코드를 추천했을까.....)
    그래서 이것도 뭐... 실패한 과정 중 하나의 코드이고요.. 아무튼 use cache로 바꿔야함

    export default async function PublicDiary() {
      const data = await getPublicDiaries();
    
      return <DiaryList data={data} />;
    }

    이제 이렇게 서버 컴포넌트에서 데이터를 받아 넘겨줄 수 있는 형태로 바뀌었습니다!!!
    페이지 컴포넌트에 SSR이 잘 적용된 모습!!

    그리고 데이터를 리패칭 해야하는 경우의 트리거를 만들어줬습니다

    "use server";
    import { revalidateTag } from "next/cache";
    
    export const revalidateAllData = async (userId?: string) => {
      revalidateTag("all-regions", "default");
      revalidateTag("geojson", "default");
      revalidateTag("diary-regions", "default");
    
      if (userId) {
        revalidateTag(`diary-regions-${userId}`, "default");
      }
    };

    일단 이렇게 해놓으면 트리거 발생시 알아서 데이터가 리패칭 되겠지? 룰루리^0^/ 생각했는데 아니었습니다...ㅋ
    만약 내 서비스가 일반적인 웹 브라우저 환경이었다면
    revalidateTag가 호출된 후 해당 페이지로 이동하거나 새로고침 했을때
    새로운 데이터를 받아오겠지만

    웹뷰라는 특성상 이미 한번 로드된 웹뷰는 백그라운드 상태에 남아있기에
    해당 트리거가 발생되었음을 알리지 않으면 백그라운드에 있던 페이지는 이를 알 수 없다는 이슈가 발생합니다
    결국 네이티브 <- (브릿지) -> 웹뷰를 만들어야 했고,
    웹뷰에서도 신호를 받아 수동으로 업데이트 해주는 로직을 추가해주게 됩니다

    const WebviewRefContext = createContext<{
      mapWebviewRef: React.RefObject<WebView | null> | null;
    }>({ mapWebviewRef: null });
    
    export default function TabLayout() {
      const mapWebviewRef = useRef<WebView>(null);
    
      return (
        <WebviewRefContext.Provider value={{ mapWebviewRef }}>
        	/* ... */
        </WebviewRefContext.Provider>
      );
    }

    위와 같이 RN쪽에 모든 탭에 걸쳐 웹뷰를 인식하고 사용할 수 있는 프로바이더를 만들어주고

    export default function HomeScreen() {
      const { mapWebviewRef } = useWebviewRefs();
    
      return (
          <WebView
            ref={mapWebviewRef}
            source={{ uri: `${process.env.EXPO_PUBLIC_WEBVIEW_URL}/world-map` }}
          />
      );
    }

    ref 연결해주고 (A탭)

    export default function DiaryScreen() {
      return (
          <WebView
            source={{ uri: `${process.env.EXPO_PUBLIC_WEBVIEW_URL}/diary` }}
            onMessage={(event) => {
              try {
                const data = JSON.parse(event.nativeEvent.data);
    
                if (data.type === "REFRESH_MAP_DATA") {
                  mapWebviewRef?.current?.injectJavaScript(`
                  if (window.forceRefreshMap) {
                    window.forceRefreshMap();
                  }
                  true;
                `);
                }
              } catch (e) {
                console.error(e);
              }
            }}
          />
      );
    }

    데이터 생성이 일어날 웹뷰 컴포넌트(B탭)에 위와 같이 웹에서 신호를 받아 A탭에 보내줄 로직을 작성

    export const DiaryForm = () => {
      // 데이터 생성
      const handleCreateDiary = (data) => {
            const { success } = await createDiaryAction(data);
    
            if (success) {
              if (window.ReactNativeWebView) {
                window.ReactNativeWebView.postMessage(
                  JSON.stringify({
                    type: "REFRESH_MAP_DATA",
                  }),
                );
            }
          };
        };
    
      return (
        <button
          type="submit"
          onClick={handleCreateDiary}
        >
          등록
        </button>
      );
    };

    이제 웹으로 넘어와 B탭 컴포넌트에서 RN쪽으로 보낼 신호를 작성

    "use client";
    
    export function WorldMap() {
      const router = useRouter();
    
      useEffect(() => {
        window.forceRefreshMap = () => {
          router.refresh();
        };
    
        return () => {
          delete window.forceRefreshMap;
        };
      }, [handleRefresh]);
    
      return (
          <div
            id="map-container"
            ref={mapContainerRef}
            className="w-full h-screen relative"
          />
      );
    }

    A탭 컴포넌트에는 신호를 받아 router.refresh를 해주는 로직을 작성해줍니다
    이렇게 하면 B탭에서 데이터 생성(또는 업데이트)후 A탭으로 넘어오면
    업데이트 된 UI와 데이터를 확인할 수 있습니다

    지금도 아직 코드들을 바꿔나가는 단계이고,
    이제 어느정도 바꿔놓으면 또 한번 리팩토링 하려고 합니다

    마지막으로 사용자 탈퇴, 로그아웃, 세션 만료, 앱 백그라운드에 있다가 오랜만에 컴백했을때(?)
    위 상황들에서 웹뷰 언마운트 또는 캐싱 삭제 등등등등등...................ㅁ7ㅁ8
    다시 짜야하는 로직이 아직 많습니다....

    아무래도 실무에서 경험하지 못한 설계이기도 하고
    모든게 다 처음이다보니 시행착오가 많은 것 같습니다
    그렇다보니 특히 틀린 과정에 대해 더 꼼꼼히 기록하려고 합니다
    피드백 환영합니다!

     

    반응형

    댓글