์ผ๋ง ์ , ์ ํ๋ธ๋ฅผ ํตํด ํฅ๋ฏธ๋ก์ด ํ๋ก์ ํธ ํ๋๋ฅผ ๋ฐ๊ฒฌํ์ต๋๋ค. ๋ฐ๋ก NextFaster๋ผ๋ ํ๋ก์ ํธ์ธ๋ฐ์, ์ด๋ฆ์์ ๋๊ปด์ง๋ฏ์ด ์ค๋ก์ง ํ๋์ ๋ชฉํ๋ฅผ ํฅํด ๋ฌ๋ฆฌ๊ณ ์์์ต๋๋ค.
"์ด๋ป๊ฒ ํ๋ฉด Next.js ์ ํ๋ฆฌ์ผ์ด์ ์ ๊ฐ์ฅ ๋น ๋ฅด๊ฒ ๋ง๋ค ์ ์์๊น?"
์ด ํ๋ก์ ํธ๋ Next.js 15๋ฅผ ๊ธฐ๋ฐ์ผ๋ก, ์ธํ๋ผ ๋น์ฉ์ด๋ ๋ฆฌ์์ค ์ฌ์ฉ๋๋ณด๋ค๋ ์ค๋ก์ง '์๋'๋ผ๋ ํ๋์ ๊ฐ์น์ ๋ชจ๋ ์ด์ ์ ๋ง์ถ๊ณ ์์ต๋๋ค. ์ด๋ป๊ฒ ํ๋ฉด ์ฌ์ฉ์์๊ฒ ๊ฐ์ฅ ๋น ๋ฅธ ๊ฒฝํ์ ์ ๊ณตํ ์ ์์์ง, ๊ทธ ๊ทนํ์ ์ต์ ํ๋ฅผ ์ถ๊ตฌํ์ฃ . ํนํ ๋ ๊ฐ์ง ์ ๋ต์ด ๋์ ๋์์ต๋๋ค.
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"
๋ก ์ค์ ๋์ด ์ค์ํ ๋ฆฌ์์ค์ ๋ก๋ฉ์ ๋ฐฉํดํ์ง ์๋๋ก ํ์ต๋๋ค.
๋จผ์ ๋ชจ๋ ๋ค์ด๋๋ฏน ๊ฒฝ๋ก์ 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