๐Ÿš€ ์†๋„์— ์ง„์‹ฌ์ธ Next.js ํ”„๋กœ์ ํŠธ NextFaster ํŒŒํ—ค์น˜๊ธฐ

WONOHยท2024๋…„ 11์›” 24์ผ
0

์–ผ๋งˆ ์ „, ์œ ํŠœ๋ธŒ๋ฅผ ํ†ตํ•ด ํฅ๋ฏธ๋กœ์šด ํ”„๋กœ์ ํŠธ ํ•˜๋‚˜๋ฅผ ๋ฐœ๊ฒฌํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ฐ”๋กœ NextFaster๋ผ๋Š” ํ”„๋กœ์ ํŠธ์ธ๋ฐ์š”, ์ด๋ฆ„์—์„œ ๋Š๊ปด์ง€๋“ฏ์ด ์˜ค๋กœ์ง€ ํ•˜๋‚˜์˜ ๋ชฉํ‘œ๋ฅผ ํ–ฅํ•ด ๋‹ฌ๋ฆฌ๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

"์–ด๋–ป๊ฒŒ ํ•˜๋ฉด Next.js ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ฐ€์žฅ ๋น ๋ฅด๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์„๊นŒ?"

์ด ํ”„๋กœ์ ํŠธ๋Š” Next.js 15๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ, ์ธํ”„๋ผ ๋น„์šฉ์ด๋‚˜ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ๋Ÿ‰๋ณด๋‹ค๋Š” ์˜ค๋กœ์ง€ '์†๋„'๋ผ๋Š” ํ•˜๋‚˜์˜ ๊ฐ€์น˜์— ๋ชจ๋“  ์ดˆ์ ์„ ๋งž์ถ”๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์–ด๋–ป๊ฒŒ ํ•˜๋ฉด ์‚ฌ์šฉ์ž์—๊ฒŒ ๊ฐ€์žฅ ๋น ๋ฅธ ๊ฒฝํ—˜์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ์„์ง€, ๊ทธ ๊ทนํ•œ์˜ ์ตœ์ ํ™”๋ฅผ ์ถ”๊ตฌํ–ˆ์ฃ . ํŠนํžˆ ๋‘ ๊ฐ€์ง€ ์ „๋žต์ด ๋ˆˆ์— ๋„์—ˆ์Šต๋‹ˆ๋‹ค.

1. Aggressive Prefetching: "๋ฐฉ๋ฌธํ•  ๊ฒƒ ๊ฐ™์€ ํŽ˜์ด์ง€๋Š” ์ „๋ถ€ ๋ฏธ๋ฆฌ ๊ฐ€์ ธ์˜จ๋‹ค"

Next.js์—์„œ๋Š” Link ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ†ตํ•ด ๊ธฐ๋ณธ์ ์ธ ํ”„๋ฆฌํŒจ์นญ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ Next.js์˜ ๊ธฐ๋ณธ Link ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ™•์žฅํ•œ ์ปค์Šคํ…€ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋”์šฑ ์ ๊ทน์ ์ธ ํ”„๋ฆฌํŒจ์นญ ์ „๋žต์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

//src/components/ui/link.tsx

// ์ด๋ฏธ์ง€ ํ”„๋ฆฌํŒจ์น˜ ํ•จ์ˆ˜
async function prefetchImages(href: string) {
  // ์™ธ๋ถ€ ๋งํฌ๋‚˜ ํŠน์ • ๊ฒฝ๋กœ(/order, /)๋Š” ํ”„๋ฆฌํŒจ์น˜ ์ œ์™ธ
  if (!href.startsWith("/") || href.startsWith("/order") || href === "/") {
    return [];
  }
  
  // API๋ฅผ ํ†ตํ•ด ์ด๋ฏธ์ง€ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ (๋‚ฎ์€ ์šฐ์„ ์ˆœ์œ„๋กœ ์š”์ฒญ)
  const url = new URL(href, window.location.href);
  const imageResponse = await fetch(`/api/prefetch-images${url.pathname}`, {
    priority: "low",
  });
  
  const { images } = await imageResponse.json();
  return images as PrefetchImage[];
}

// ์ด๋ฏธ ํ”„๋ฆฌํŒจ์น˜๋œ ์ด๋ฏธ์ง€ ์ถ”์ ์„ ์œ„ํ•œ Set
const seen = new Set<string>();

export const Link: typeof NextLink = (({ children, ...props }) => {
  const [images, setImages] = useState<PrefetchImage[]>([]); // ํ”„๋ฆฌํŒจ์น˜ํ•  ์ด๋ฏธ์ง€ ๋ชฉ๋ก
  const [preloading, setPreloading] = useState<(() => void)[]>([]); // ํ˜„์žฌ ํ”„๋ฆฌ๋กœ๋”ฉ ์ค‘์ธ ์ด๋ฏธ์ง€๋“ค์˜ cleanup ํ•จ์ˆ˜
  const linkRef = useRef<HTMLAnchorElement>(null); 
  const router = useRouter();
  let prefetchTimeout: NodeJS.Timeout | null = null; // ํ”„๋ฆฌํŒจ์น˜ ํƒ€์ด๋จธ ID

  useEffect(() => {
    // prefetch prop์ด false๋ฉด ํ”„๋ฆฌํŒจ์น˜ ๋น„ํ™œ์„ฑํ™”
    if (props.prefetch === false) {
      return;
    }

    const linkElement = linkRef.current;
    if (!linkElement) return;

    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry.isIntersecting) {
          // ๋งํฌ๊ฐ€ ๋ทฐํฌํŠธ์— ๋ณด์ด๋ฉด 300ms ํ›„ ํ”„๋ฆฌํŒจ์น˜ ์‹œ์ž‘
          prefetchTimeout = setTimeout(async () => {
            router.prefetch(String(props.href));
            await sleep(0); // ๋ฌธ์„œ ํ”„๋ฆฌํŒจ์น˜๊ฐ€ ๋จผ์ € ์ผ์–ด๋‚˜๋„๋ก ์ง€์—ฐ
            void prefetchImages(String(props.href)).then((images) => {
              setImages(images);
            }, console.error);
            observer.unobserve(entry.target);
          }, 300);
        } else if (prefetchTimeout) {
          // ๋งํฌ๊ฐ€ ๋ทฐํฌํŠธ์—์„œ ์‚ฌ๋ผ์ง€๋ฉด ํ”„๋ฆฌํŒจ์น˜ ์ทจ์†Œ
          clearTimeout(prefetchTimeout);
          prefetchTimeout = null;
        }
      },
      { rootMargin: "0px", threshold: 0.1 } // 10% ์ด์ƒ ๋ณด์ด๋ฉด ํ™œ์„ฑํ™”
    );

    observer.observe(linkElement);

    return () => {
      observer.disconnect();
      if (prefetchTimeout) {
        clearTimeout(prefetchTimeout);
      }
    };
  }, [props.href, props.prefetch]);

  return (
    <NextLink
      ref={linkRef}
      prefetch={false} // ๊ธฐ๋ณธ ํ”„๋ฆฌํŒจ์น˜ ๋น„ํ™œ์„ฑํ™” (useEffect ๋‚ด๋ถ€ ์ปค์Šคํ…€ ๊ตฌํ˜„ ์‚ฌ์šฉ)
      onMouseEnter={() => {
        // ๋งˆ์šฐ์Šค๊ฐ€ ๋งํฌ ์œ„๋กœ ์˜ค๋ฉด ์ฆ‰์‹œ ํ”„๋ฆฌํŒจ์น˜
        router.prefetch(String(props.href));
        if (preloading.length) return;
        const p: (() => void)[] = [];
        for (const image of images) {
          const remove = prefetchImage(image);
          if (remove) p.push(remove);
        }
        setPreloading(p);
      }}
      onMouseLeave={() => {
        // ๋งˆ์šฐ์Šค๊ฐ€ ๋– ๋‚˜๋ฉด ํ”„๋ฆฌ๋กœ๋”ฉ ์ทจ์†Œ ๋ฐ ์ •๋ฆฌ
        for (const remove of preloading) {
          remove();
        }
        setPreloading([]);
      }}
      onMouseDown={(e) => {
        // ๋‚ด๋ถ€ ๋งํฌ์ผ ๊ฒฝ์šฐ ๊ธฐ๋ณธ ๋™์ž‘ ๋ฐฉ์ง€ ๋ฐ ๋ผ์šฐํ„ฐ ์‚ฌ์šฉ
        const url = new URL(String(props.href), window.location.href);
        if (
          url.origin === window.location.origin &&
          e.button === 0 &&          // ์ขŒํด๋ฆญ๋งŒ
          !e.altKey &&               // ์ˆ˜์ •์ž ํ‚ค ์—†์ด
          !e.ctrlKey &&
          !e.metaKey &&
          !e.shiftKey
        ) {
          e.preventDefault();
          router.push(String(props.href));
        }
      }}
      {...props}
    >
      {children}
    </NextLink>
  );
}) as typeof NextLink;

// ๊ฐœ๋ณ„ ์ด๋ฏธ์ง€ ํ”„๋ฆฌํŒจ์น˜ ํ•จ์ˆ˜
function prefetchImage(image: PrefetchImage) {
  // lazy ๋กœ๋”ฉ์ด๊ฑฐ๋‚˜ ์ด๋ฏธ ํ”„๋ฆฌํŒจ์น˜๋œ ์ด๋ฏธ์ง€๋Š” ์Šคํ‚ต
  if (image.loading === "lazy" || seen.has(image.srcset)) {
    return;
  }
  
  // ์ƒˆ ์ด๋ฏธ์ง€ ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ์„ค์ •
  const img = new Image();
  img.decoding = "async";           // ๋น„๋™๊ธฐ ๋””์ฝ”๋”ฉ
  img.fetchPriority = "low";        // ๋‚ฎ์€ ์šฐ์„ ์ˆœ์œ„๋กœ ๊ฐ€์ ธ์˜ค๊ธฐ
  img.sizes = image.sizes;
  seen.add(image.srcset);           // ์ค‘๋ณต ํ”„๋ฆฌํŒจ์น˜ ๋ฐฉ์ง€
  img.srcset = image.srcset;
  img.src = image.src;
  img.alt = image.alt;
  
  let done = false;
  // ๋กœ๋“œ ์™„๋ฃŒ ๋˜๋Š” ์—๋Ÿฌ ์‹œ ์™„๋ฃŒ ํ‘œ์‹œ
  img.onload = img.onerror = () => {
    done = true;
  };
  
  // cleanup ํ•จ์ˆ˜ ๋ฐ˜ํ™˜
  return () => {
    if (done) return;
    img.src = img.srcset = "";      // ๋กœ๋“œ ์ทจ์†Œ
    seen.delete(image.srcset);      // Set์—์„œ ์ œ๊ฑฐ
  };
}

์ด ์ปค์Šคํ…€ Link ์ปดํฌ๋„ŒํŠธ๋Š” ๋‹จ์ˆœํžˆ ํŽ˜์ด์ง€ ์ฝ˜ํ…์ธ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ, ํ•ด๋‹น ํŽ˜์ด์ง€์— ์žˆ๋Š” ์ด๋ฏธ์ง€๋“ค๊นŒ์ง€ ๋ฏธ๋ฆฌ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. IntersectionObserver๋ฅผ ์‚ฌ์šฉํ•ด ๋ทฐํฌํŠธ์— ๋งํฌ๊ฐ€ 10%๋งŒ ๋ณด์—ฌ๋„ ๋ง์ด์ฃ . ๋งˆ์น˜ "์ด ๋งํฌ๊ฐ€ ์กฐ๊ธˆ์ด๋ผ๋„ ๋ณด์ด๋„ค? ๊ทธ๋Ÿผ ๋ฏธ๋ฆฌ ๊ฐ€์ ธ์™€์•ผ์ง€!"๋ผ๋Š” ๋Š๋‚Œ์ž…๋‹ˆ๋‹ค.

ํ•˜์ง€๋งŒ ๋ฌด์ž‘์ • ๊ฐ€์ ธ์˜ค์ง€๋Š” ์•Š์Šต๋‹ˆ๋‹ค. 300ms์˜ ์ง€์—ฐ ์‹œ๊ฐ„์„ ๋‘์–ด ๋ถˆํ•„์š”ํ•œ ํ”„๋ฆฌํŽ˜์นญ์„ ๋ฐฉ์ง€ํ•˜๊ณ , ์‚ฌ์šฉ์ž๊ฐ€ ๋งˆ์šฐ์Šค๋ฅผ ๋งํฌ์—์„œ ๋–ผ๋ฉด ์ฆ‰์‹œ ํ”„๋ฆฌํŽ˜์นญ์„ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ seen์ด๋ผ๋Š” Set ๊ฐ์ฒด๋ฅผ ์‚ฌ์šฉํ•ด ์ด๋ฏธ ํ”„๋ฆฌํŒจ์น˜๋œ ์ด๋ฏธ์ง€๋Š” ๋‹ค์‹œ ๊ฐ€์ ธ์˜ค์ง€ ์•Š๋„๋ก ํ•˜๊ณ , lazy loading์ด ์„ค์ •๋œ ์ด๋ฏธ์ง€๋Š” ํ”„๋ฆฌํŽ˜์นญ์—์„œ ์ œ์™ธ์‹œํ‚ต๋‹ˆ๋‹ค. ๋ชจ๋“  ์ด๋ฏธ์ง€๋Š” fetchPriority: "low"๋กœ ์„ค์ •๋˜์–ด ์ค‘์š”ํ•œ ๋ฆฌ์†Œ์Šค์˜ ๋กœ๋”ฉ์„ ๋ฐฉํ•ดํ•˜์ง€ ์•Š๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

2. Aggressive Caching: "ํ•œ ๋ฒˆ ๊ฐ€์ ธ์˜จ ๋ฐ์ดํ„ฐ๋Š” ๋‹ค์‹œ ์š”์ฒญํ•˜์ง€ ์•Š๋Š”๋‹ค"

๋จผ์ € ๋ชจ๋“  ๋‹ค์ด๋‚˜๋ฏน ๊ฒฝ๋กœ์— generateStaticParams๋ฅผ ํ™œ์šฉํ•ด ๋นŒ๋“œ ํƒ€์ž„์— ๊ฐ€๋Šฅํ•œ ๊ฒฝ๋กœ๋ฅผ ๋ฏธ๋ฆฌ ์ƒ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

export async function generateStaticParams() {
  return await db.select({ collection: collections.slug }).from(collections);
}

์—ฌ๊ธฐ์— ๋”ํ•ด Next.js์˜ unstable_cache์™€ React์˜ cache๋ฅผ ๊ฒฐํ•ฉํ•œ ์ „๋žต์ด ํŠนํžˆ ์ธ์ƒ์ ์ด์—ˆ์Šต๋‹ˆ๋‹ค.

export const unstable_cache = <Inputs extends unknown[], Output>(
  callback: (...args: Inputs) => Promise<Output>,
  key: string[],
  options: { revalidate: number },
) => cache(next_unstable_cache(callback, key, options));

React์˜ cache๋กœ ๋ฉ”๋ชจ๋ฆฌ ๋‚ด ์ค‘๋ณต ์š”์ฒญ์„ ๋ฐฉ์ง€ํ•˜๊ณ , Next.js์˜ unstable_cache๋กœ ์˜๊ตฌ์ ์ธ ๋ฐ์ดํ„ฐ ์บ์‹ฑ์„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

๊ฒฐ๊ณผ์ ์œผ๋กœ? ๋Ÿฐํƒ€์ž„์—์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ถˆํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ฟผ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋ชจ๋“  ๊ฒƒ์ด ๋ฏธ๋ฆฌ ์ค€๋น„๋˜์–ด ์žˆ๊ณ , ์บ์‹œ๋˜์–ด ์žˆ๊ณ , ์ตœ์ ํ™”๋˜์–ด ์žˆ๋Š” ๊ฑฐ์ฃ .

๋งˆ๋ฌด๋ฆฌ

์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•ด ์ˆ˜๋งŽ์€ ํ”„๋ฆฌํŒจ์น˜ ์š”์ฒญ์„ ์ƒ์„ฑํ•˜๋Š” ์ ‘๊ทผ ๋ฐฉ์‹์€ ๋น„์šฉ ์ธก๋ฉด์—์„œ ์‹ค๋ฌด์— ์ ์šฉํ•˜๊ธฐ๋Š” ์–ด๋ ต๊ฒ ์ง€๋งŒ, ์˜ค๋กœ์ง€ '์†๋„'๋งŒ์„ ์œ„ํ•ด ๋งŒ๋“ค์–ด์ง„ ์žฌ๋ฏธ์žˆ๋Š” ํ”„๋กœ์ ํŠธ์˜€์Šต๋‹ˆ๋‹ค.

ํ”„๋กœ์ ํŠธ ์ฝ”๋“œ๋ฅผ ๊ตฌ๊ฒฝํ•˜๋ฉฐ Route Segment Config(ex: dynamic = 'force-static')๊ฐ€ ํŽ˜์ด์ง€๋‚˜ ๋ ˆ์ด์•„์›ƒ ํŒŒ์ผ์—์„œ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋‹ค๊ณ  ์•Œ๊ณ  ์žˆ์—ˆ๋Š”๋ฐ, route handler์—์„œ๋„ ํ™œ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๊ฒŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

๋˜ํ•œ React์˜ cache์™€ Next.js์˜ unstable_cache์— ๋Œ€ํ•ด์„œ๋„ ๋‹ค์‹œ ํ•œ ๋ฒˆ ํ•™์Šตํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์ฒ˜์Œ์—๋Š” unstable_cache๊ฐ€ ๋‚ด๋ถ€์ ์œผ๋กœ React์˜ cache๋ฅผ ์‚ฌ์šฉํ•  ๊ฒƒ์ด๋ผ ์ƒ๊ฐํ–ˆ์ง€๋งŒ, ์‹ค์ œ๋กœ๋Š” ์™„์ „ํžˆ ๋‹ค๋ฅธ ๋™์ž‘ ๋ฐฉ์‹์„ ๊ฐ€์ง„ ๋ณ„๊ฐœ์˜ ๊ธฐ๋Šฅ์ด๋ผ๋Š” ๊ฒƒ์„ ์•Œ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.
(unstable_cache๋Š” ์ด๋ฏธ legacy API๊ฐ€ ๋˜์—ˆ์œผ๋ฉฐ, use cache ์‚ฌ์šฉ์„ ๊ถŒ์žฅ)

์ฐธ๊ณ 

https://github.com/ethanniser/NextFaster
https://www.youtube.com/watch?v=7bfTpZxRGto

0๊ฐœ์˜ ๋Œ“๊ธ€