๋ชจ์œผ์žก-์„œ๋ฒ„์‚ฌ์ด๋“œ ๋žœ๋”๋ง์„ ์ด์šฉํ•œ ์„ฑ๋Šฅ ๊ฐœ์„ 

@choi2021 ยท December 25, 2022 ยท 19 min read

๐Ÿ•ถ์„œ๋ฒ„์‚ฌ์ด๋“œ ๋žœ๋”๋ง์„ ์ด์šฉํ•œ ์„ฑ๋Šฅ ๊ฐœ์„ 

๋ชจ์œผ์žก ํ”„๋กœ์ ํŠธ ๊ธฐํš๊ณผ ๋””์ž์ธ์„ ์ˆ˜์ •ํ•˜๊ณ  ๋‚˜์„œ ๊ทธ๋‹ค์Œ ์ž‘์—…์œผ๋กœ ์„ฑ๋Šฅ ๊ฐœ์„ ์„ ๋ชฉํ‘œ๋กœ ์žก์•˜๋‹ค. ๋‹ค๋ฅธ ๊ธฐ๋Šฅ์„ ์ถ”๊ฐ€ํ•˜๊ธฐ ๋ณด๋‹ค Next ์ž์ฒด๋ฅผ ์ข€ ๋” ์ž˜ ์“ฐ๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ๊ณ ๋ฏผํ–ˆ๊ณ  ๊ทธ ๋ฐฉ๋ฒ•์œผ๋กœ Next์˜ ์žฅ์ ์„ ์‚ด๋ฆฌ๋ ค ๊ณ ๋ฏผํ–ˆ๋‹ค. Next์˜ ์žฅ์ ์€ ์ด๋ฏธ์ง€ ์ตœ์ ํ™”๋‚˜ ์ฝ”๋“œ ์Šคํ”Œ๋ฆฟํŒ… ๋“ฑ์„ ์ž๋™์œผ๋กœ ์ง€์›ํ•ด ์ฃผ๋Š” ์žฅ์ ๋„ ์žˆ์ง€๋งŒ ๊ฐ€์žฅ ํฐ ์žฅ์ ์€ react์—์„œ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋žœ๋”๋ง ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ์ ์ด๋‹ค. ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋žœ๋”๋ง์€ ๊ธฐ์กด SPA์—์„œ๋Š” ๊ตฌํ˜„ํ•˜๊ธฐ ํž˜๋“  SEO ๋ฌธ์ œ๋ฅผ ์‰ฝ๊ฒŒ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๊ณ , ๋จผ์ € ํŽ˜์ด์ง€๋ฅผ ๋ณด์—ฌ ์คŒ์œผ๋กœ์จ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ํ–ฅ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค. ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง์„ ์ ์šฉํ•ด ํ•ด๊ฒฐํ•ด๋‚˜๊ฐ„ ๊ณผ์ •์„ ์ •๋ฆฌํ•ด๋ณด๋ ค ํ•œ๋‹ค.

โ›ณ ๋ชจ์œผ์žก SEO ์ตœ์ ํ™”

SEO ๋Š” Search Engine Optimization์˜ ์•ฝ์ž๋กœ ๋ง ๊ทธ๋Œ€๋กœ ๊ฒ€์ƒ‰์—”์ง„ ์ตœ์ ํ™”๋ฅผ ์˜๋ฏธํ•œ๋‹ค. ๊ฒ€์ƒ‰์—”์ง„์„ ์ตœ์ ํ™” ์‹œํ‚จ๋‹ค๋Š” ๊ฒƒ์€ ๊ตฌ๊ธ€์ด๋‚˜ ๋„ค์ด๋ฒ„์™€ ๊ฐ™์€ ๊ฒ€์ƒ‰ ์‚ฌ์ดํŠธ๋ฅผ ์ด์šฉํ•  ๋•Œ ํ•ด๋‹น ํ‚ค์›Œ๋“œ์— ๋Œ€ํ•ด์„œ ์šฐ์„ ์ˆœ์œ„๊ฐ€ ๋†’๊ฒŒ ๋‚˜์˜ค๊ฒŒ ๋งŒ๋“œ๋Š” ๊ฒƒ์„ ์˜๋ฏธํ•œ๋‹ค. ์‹ค์ œ๋กœ ์šฐ๋ฆฌ๊ฐ€ ์ •๋ณด๋ฅผ ์ฐพ์„ ๋•Œ ๋ฌด์กฐ๊ฑด ๊ตฌ๊ธ€๊ณผ ๊ฐ™์€ ๊ฒ€์ƒ‰ ์‚ฌ์ดํŠธ๋ฅผ ํ†ตํ•ด ์ฐพ๊ฒŒ ๋˜๋Š”๋ฐ ์ด๋Ÿฌํ•œ ๊ฒ€์ƒ‰ ์‚ฌ์ดํŠธ์—์„œ ๋†’์€ ์ˆœ์œ„๋กœ ๋จผ์ € ๋ณด์ธ๋‹ค๋Š” ๊ฒƒ์€ ์—„์ฒญ๋‚œ ๋งˆ์ผ€ํŒ… ํšจ๊ณผ๋ฅผ ๊ฐ€์ง„๋‹ค.

๊ทธ๋ ‡๊ธฐ ๋•Œ๋ฌธ์— SEO๋Š” ์„œ๋น„์Šค์˜ ์ค‘์š”ํ•œ ๋ถ€๋ถ„์ด ๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— SEO ์ตœ์ ํ™”๋Š” ์ค‘์š”ํ•œ ์„ฑ๋Šฅ ์ง€ํ‘œ ์ค‘ ํ•˜๋‚˜๊ฐ€ ๋˜์—ˆ๋‹ค. SEO ์ตœ์ ํ™”๋ฅผ ์œ„ํ•ด ๋จผ์ € ๊ฒ€์ƒ‰์—”์ง„์ด ๋™์ž‘ํ•˜๋Š” ๋ฐฉ์‹์„ ์ •๋ฆฌํ•ด๋ณด์ž

  1. ํฌ๋กค๋ง: ์›น ํฌ๋กค๋Ÿฌ๊ฐ€ ์‚ฌ์ดํŠธ๋ฅผ ๋ฐฉ๋ฌธํ•ด ์ปจํ…์ธ ๋ฅผ ๋ณต์‚ฌํ•ด์„œ ๊ฒ€์ƒ‰์—”์ง„์œผ๋กœ ๊ฐ€์ ธ์˜จ๋‹ค.
  2. ์ธ๋ฑ์‹ฑ: ๊ฐ€์ ธ์˜จ ์ปจํ…์ธ ๋ฅผ ์ฃผ์ œ ๋ณ„๋กœ ์ƒ‰์ธํ•ด์„œ ๋ณด๊ด€ํ•œ๋‹ค.
  3. ๋žญํ‚น: ๊ฒ€์ƒ‰ ์š”์ฒญ์— ๋”ฐ๋ผ ์ƒ‰์ธ๋œ ์ปจํ…์ธ ์— ์ˆœ์œ„๋ฅผ ๋ถ€์—ฌํ•ด์„œ ๊ฒฐ๊ณผ๋กœ ์ œ๊ณตํ•œ๋‹ค.

์—ฌ๊ธฐ์„œ CSR์ด SEO์˜ ๋‹จ์ ์„ ๊ฐ€์ง€๋Š” ์ด์œ ๋Š” ํฌ๋กค๋ง ๊ณผ์ •์—์„œ ํฌ๋กค๋Ÿฌ๊ฐ€ ๋ฐฉ๋ฌธํ–ˆ์„ ๋•Œ ๋น„์–ด์žˆ๋Š” html๋ฌธ์„œ๋งŒ ๋ณต์‚ฌํ•˜๊ฒŒ ๋˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค. ์ด๋•Œ SSR์„ ํ†ตํ•ด ๋งŒ๋“ค์–ด์ง„ ํŽ˜์ด์ง€๋ผ๋ฉด ๋จผ์ € ์ปจํ…์ธ ๊ฐ€ ์žˆ๋Š” html์„ ํฌ๋กค๋Ÿฌ๊ฐ€ ๋ณต์‚ฌํ•ด์„œ ๊ฐ€์ ธ๊ฐ„๋‹ค๋ฉด ๋†’์€ ์ˆœ์œ„๋กœ ๋ณด์ด๊ฒŒ ๋œ๋‹ค.

CSR๋กœ ๋ฌด์กฐ๊ฑด SEO ์ตœ์ ํ™”๋ฅผ ๋ชปํ•˜๋Š” ๊ฒƒ์€ ์•„๋‹ˆ๋‹ค. ์›ํ‹ฐ๋“œ ํ”„๋ฆฌ์˜จ๋ณด๋”ฉ ๊ณผ์ œ๋กœ SNS์˜ ๊ณต์œ ์‹œ ์ด๋ฏธ์ง€์™€ ์„ค๋ช…์„ ๋‹ด์„ ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋Šฅ์ด ํ•„์š”ํ•ด CSR๋กœ react-helmet๊ณผ react-snap์„ ์ด์šฉํ•ด ๊ตฌํ˜„์„ ํ–ˆ์—ˆ๋‹ค. ํ•˜์ง€๋งŒ ์˜ค๋žœ ์‹œ๊ฐ„ ๊ฑธ๋ ธ๊ณ  react-snap์€ 2๋…„์ด๋‚˜ ์—…๋ฐ์ดํŠธ๊ฐ€ ๋˜๊ณ  ์žˆ์ง€ ์•Š๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ผ๋Š” ๋ฌธ์ œ์ ๋„ ์žˆ์—ˆ๋‹ค.

๊ทธ๋ ‡๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฒˆ์—๋Š” SSR๋ฅผ ์ด์šฉํ•ด ๋ชจ์œผ์žก ํ”„๋กœ์ ํŠธ์— SEO ์ตœ์ ํ™”๋ฅผ ์ ์šฉํ•ด๋ณด๋ ค ํ•œ๋‹ค.

๊ธฐ์กด ์ƒํ™ฉ

์ž‘์—… ์ „ ์ƒํ™ฉ์€ ์ผ๋ถ€ ํŽ˜์ด์ง€์— ๊ฐ„๋‹จํ•œ title์ •๋ณด๋งŒ <Head/>ํƒœ๊ทธ ๋‚ด์— ์ž‘์„ฑ๋˜์–ด ์žˆ๋Š” ์ƒํƒœ์˜€๋‹ค. ๊ฐœ์„ ํ•˜๊ณ  ๋‚˜์„œ ๋น„๊ต๋ฅผ ์œ„ํ•ด ๊ฒ€์ƒ‰์—”์ง„ ์ตœ์ ํ™” ์ ์ˆ˜๋ฅผ light house๋ฅผ ์ด์šฉํ•ด ์ธก์ •ํ–ˆ์„ ๋•Œ ์•„๋ž˜์™€ ๊ฐ™์€ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜ํƒ€๋‚ฌ๋‹ค.

[์ˆ˜์ • ์ „ light house ๊ฒ€์ƒ‰์—”์ง„ ์ ์ˆ˜]

๊ฒ€์ƒ‰์—”์ง„
๊ฒ€์ƒ‰์—”์ง„

Next-seo ์ ์šฉ๊ณผ OG ๋ฌธ์ œ์ 

๊ฐœ์„ ์„ ์œ„ํ•ด์„œ head๋‚ด์šฉ์„ ๋” ๊ฐ„ํŽธํ•˜๊ฒŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” Next-SEO๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ด์šฉํ•ด metaํƒœ๊ทธ์™€ SNS๊ณต์œ ๋ฅผ ์œ„ํ•œ Open Graph ๋‚ด์šฉ์„ ์ถ”๊ฐ€ํ–ˆ๋‹ค. Next-seo๋Š” DefaultSeo๋ผ๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์žˆ์–ด ๊ณตํ†ต๋˜๋Š” ๋ถ€๋ถ„์„ ํ•œ ๊ณณ์—์„œ ์ •์˜ํ•  ์ˆ˜ ์žˆ๋‹ค.

// _app.tsx
const defaultSEO = {
  defaultTitle: "๋ชจ์œผ์žก",
  titleTemplate: "%s | ๋ชจ์œผ์žก", // %s๋กœ ํŽ˜์ด์ง€๋งˆ๋‹ค title์„ ์ „๋‹ฌํ•ด์ค„ ์ˆ˜ ์žˆ์–ด
  description: "์›ํ•˜๋Š” ํšŒ์‚ฌ์˜ ์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ๋ชจ์œผ๊ณ  ๋น„๊ตํ•ด๋ณด์ž",
  canonical: "https://moejob.vercel.app/",
  keywords: ["moejob", "choi2021", "๋ชจ์œผ์žก"],
  icon: "/favicon.ico",
  openGraph: {
    type: "website",
    locale: "ko_KR",
    url: "https://moejob.vercel.app",
    title: "๋ชจ์œผ์žก",
    site_name: "๋ชจ์œผ์žก",
    description: "์›ํ•˜๋Š” ํšŒ์‚ฌ์˜ ์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ๋ชจ์œผ๊ณ  ๋น„๊ตํ•ด๋ณด์ž",
    images: [
      {
        url: "/banner.jpg",
        width: 285,
        height: 167,
        alt: "์ด๋ฏธ์ง€",
      },
    ],
  },
}

function MyApp({ Component, pageProps }: AppProps) {
  const [queryClient] = useState(() => new QueryClient())
  const dbService = new DBServiceImpl(firebaseApp)
  return (
    <>
      <DefaultSeo {...defaultSEO} />
      ...
    </>
  )
}
export default MyApp

๊ฐ ํŽ˜์ด์ง€๋งˆ๋‹ค ๋‚ด์šฉ์„ NextSeo์ปดํฌ๋„ŒํŠธ์˜ props๋กœ ์ „๋‹ฌํ•ด ์„ฑ๋Šฅ์„ ๋‹ค์‹œ ๊ฒ€์‚ฌํ–ˆ์„ ๋•Œ ๊ฐ„๋‹จํ•˜๊ฒŒ SEO ์ตœ์ ํ™”๋ฅผ ํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

// pages/login.tsx
const Login = (...
) => {
  return (
    <>
      <NextSeo
        title="๋กœ๊ทธ์ธ"
        description="์›ํ•˜๋Š” ํšŒ์‚ฌ์˜ ์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ๋ชจ์œผ๊ณ  ๋น„๊ตํ•ด๋ณด์ž"
        openGraph={% raw %}{{
          type: 'website',
          url: `${process.env.NEXT_PUBLIC_BASE_URL}/login`,
          title: '๋กœ๊ทธ์ธ | ๋ชจ์œผ์žก',
          description: '์›ํ•˜๋Š” ํšŒ์‚ฌ์˜ ์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ๋ชจ์œผ๊ณ  ๋น„๊ตํ•ด๋ณด์ž',
        }}{% endraw %}
      />
     ...
    </>
  );
};

// pages/jobs/[id]
function Index() {
    ...
  return (
    <>
      <NextSeo
        title={job?.name}
        openGraph={% raw %}{{
          title: `${job?.name}`,
          url: `${process.env.NEXT_PUBLIC_BASE_URL}/jobs/${job?.id}`,
          images: [
            {
              url: job?.img || '',
              width: 285,
              height: 167,
              alt: '์ƒ์„ธ ์ด๋ฏธ์ง€',
            },
          ],
        }}{% endraw %}
      />
    	...
    </>
  );
}

export default Index;

[Next-seo๋ฅผ ์ ์šฉํ•˜๊ณ  ๊ฒ€์ƒ‰์—”์ง„ ์ตœ์ ํ™” ์ ์ˆ˜]

๊ฒ€์ƒ‰์—”์ง„์ตœ์ ํ™”
๊ฒ€์ƒ‰์—”์ง„์ตœ์ ํ™”

ํ•˜์ง€๋งŒ SNS๋กœ ๊ณต์œ ํ–ˆ์„ ๋•Œ ์ƒ์„ธ ํŽ˜์ด์ง€์—์„œ๋„ Default SEO์— ์„ค์ •ํ–ˆ๋˜ ์ด๋ฏธ์ง€์™€ description์ด ๊ณต์œ ๋˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ๋‹ค. ์นด์นด์˜ค ํ†ก, ํŽ˜์ด์Šค๋ถ, ์Šฌ๋ž™ ๋ชจ๋‘ ๊ธฐ๋ณธ ๋‚ด์šฉ์ด ์ „๋‹ฌ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ–ˆ๊ณ , ๊ฐœ๋ฐœ์ž ๋„๊ตฌ๋กœ ๋ธŒ๋ผ์šฐ์ € ์ƒ์˜ head๋ฅผ ๋ณด์•˜์„ ๋•Œ๋Š” ๋ฌธ์ œ๊ฐ€ ์—†์—ˆ๋‹ค.

[์ƒ์„ธํŽ˜์ด์ง€์—์„œ๋„ ๋‚˜์˜ค๋Š” default ์ด๋ฏธ์ง€์™€ description]

og
og
๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๊ณ ๋ฏผ์„ ํ•˜๋‹ค๊ฐ€ ์ƒ์„ธ ํŽ˜์ด์ง€์— ์ „๋‹ฌ๋˜๋Š” job ์„ <u>CSR์—์„œ ๋ถˆ๋Ÿฌ์™€</u> ๋‚ด์šฉ์ด ์ „๋‹ฌ์ด ๋˜์ง€ ์•Š์€ ๊ฒƒ ๊ฐ™๋‹ค๊ณ  ์ƒ๊ฐ์ด ๋“ค์—ˆ๋‹ค. ๊ทธ๋ž˜์„œ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ๋กœ ๋‚ด์šฉ์„ prefetching์„ ํ•ด์„œ ์ „๋‹ฌํ•ด์ฃผ๋ฉด ๋ฐ”๋กœ header์— ์ „๋‹ฌํ•ด ์ค„ ์ˆ˜ ์žˆ๊ฒ ๋‹ค๊ณ  ์˜ˆ์ƒํ–ˆ๋‹ค.

SSR์„ ์ด์šฉํ•œ data fetching

data fetching์€ ์ฒ˜์Œ ํ”„๋กœ์ ํŠธ๋ฅผ ๊ธฐํš๋ถ€ํ„ฐ ๊ณ ๋ฏผํ–ˆ๋˜ ๋ถ€๋ถ„์ด์—ˆ๋‹ค. ์„œ๋ฒ„์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ค๊ธฐ ๋•Œ๋ฌธ์— ๋ณ„๋„์˜ loading์—†์ด ๋ณด์—ฌ์ค„ ์ˆ˜ ์žˆ์ง€๋งŒ ๋‹น์žฅ ํ•„์š”ํ•œ ๋ถ€๋ถ„์ผ๊นŒ ๊ณ ๋ฏผ์ด ๋˜์–ด์„œ ๋ฏธ๋ค„๋’€์—ˆ๋‹ค. ์ด๋ฒˆ ๊ธฐํšŒ์— next์˜ data fetching์— ๋Œ€ํ•ด ๊ณต๋ถ€ํ•˜๊ณ  ์ ์šฉํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

Next์—์„œ data fetching์€ SSG, SSR, CSR ๋‹ค ์ง€์›ํ•œ๋‹ค. ์ด์ค‘์—์„œ ๊ธฐ์กด์— ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋˜ ๋ฐฉ์‹์€ CSR์ด์—ˆ๊ณ , ํ”„๋กœ์ ํŠธ์— ํ•„์š”ํ•œ ๋ฐฉ์‹์€ SSR์ด๋ผ๊ณ  ์ƒ๊ฐ๋˜์—ˆ๋‹ค. SSG๋ฅผ next์—์„œ ๋จผ์ € ์ถ”์ฒœํ•ด ์ฃผ๊ธฐ๋„ ํ•˜๊ณ  ์ด๋ฏธ ๋นŒ๋“œ์—์„œ ๋งŒ๋“  ํŽ˜์ด์ง€๋ฅผ ์‘๋‹ตํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋” ๋น ๋ฅธ ํŽ˜์ด์ง€ ๋กœ๋”ฉ ์†๋„๋ฅผ ๊ฐ€์ง€์ง€๋งŒ, ๋ชจ์œผ์žก ํ”„๋กœ์ ํŠธ ํŠน์„ฑ ์ƒ ๊ณ„์†ํ•ด์„œ ์ฑ„์šฉ๊ณต๊ณ  ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€, ์‚ญ์ œ, ์ˆ˜์ •ํ•˜๊ธฐ ๋•Œ๋ฌธ์— SSR์„ ์ ์šฉํ•˜๋Š” ๊ฒŒ ์•Œ๋งž์€ ์„ ํƒ์ด๋ผ ์ƒ๊ฐํ–ˆ๋‹ค.

SSR์„ ์ ์šฉํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” react-query์—์„œ SSR์„ ์ ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๊ณต๋ถ€ํ•ด์•ผ ํ–ˆ๋‹ค. React query๋Š” ๋‘ ๊ฐ€์ง€ ๋ฐฉ๋ฒ•์„ ์ง€์›ํ•œ๋‹ค.

  • initialState
  • hydration

initialState๋Š” SSR์—์„œ getServersideprops๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ ํ›„์— ์ง์ ‘ props๋กœ ์ „๋‹ฌํ•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ณด๋‹ค ์ง๊ด€์ ์ด๊ณ  ์ถ”๊ฐ€์ ์ธ ์ฝ”๋“œ ๋Ÿ‰์ด ์ ์—ˆ๋‹ค. ํ•˜์ง€๋งŒ useQuery์ž์ฒด๊ฐ€ ๊นŠ์€ ์ž์‹์ด ์ด์šฉ ์‹œ inital data ์ž์ฒด๋ฅผ ์ „๋‹ฌํ•ด์•ผ ํ•˜๊ธฐ ๋•Œ๋ฌธ์— prop-driliing์ด ์ƒ๊ธฐ๊ณ , ์—ฌ๋Ÿฌ ๊ณณ์—์„œ useQuery๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•  ๊ฒฝ์šฐ์—๋„ ๋‹ค ์ „๋‹ฌํ•ด์ค˜์•ผ ํ•˜๋Š” ๋‹จ์ ์ด ์žˆ์—ˆ๋‹ค.

// react-query ๊ณต์‹ํ™ˆํŽ˜์ด์ง€ ์˜ˆ์‹œ

export async function getStaticProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

function Posts(props) {
  const { data } = useQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
    initialData: props.posts,
  })

  // ...
}

๊ทธ๋ž˜์„œ ํ™•์žฅ์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜๋ฅผ ๊ณ ๋ คํ–ˆ์„ ๋•Œ ๋‚ด๊ฐ€ ์„ ํƒํ•œ ๋ฐฉ์‹์€ ๋‘ ๋ฒˆ์งธ Hydration ๋ฐฉ์‹์ด์—ˆ๋‹ค. prefetchํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌํ•ด์ค˜ ๋งˆํฌ ์—…์„ ํ•œ ํ›„์— hydrate(์ˆ˜๋ถ„๊ณต๊ธ‰, ์ด๋ฒคํŠธ๋ฅผ ์—ฐ๊ฒฐํ•˜๋Š” ๊ณผ์ •)ํ•˜๋Š” ๊ฒƒ์„ ์˜๋ฏธํ•œ๋‹ค. ์ด๊ฒƒ์„ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์•ž์„  initialData๋ณด๋‹ค๋Š” ์กฐ๊ธˆ ๋” ๋ณต์žกํ•œ ๋ณ„๋„์˜ ์„ธํŒ…์ด ํ•„์š”ํ•˜๋‹ค.

// react-query ๊ณต์‹ํ™ˆํŽ˜์ด์ง€ ์˜ˆ์‹œ

// _app.jsx
import {
  Hydrate,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query"

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(() => new QueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
    </QueryClientProvider>
  )
}

// pages/posts.jsx
import { dehydrate, QueryClient, useQuery } from "@tanstack/react-query"

export async function getStaticProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery(["posts"], getPosts)

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

์œ„์˜ ์˜ˆ์‹œ๋ฅผ ์ฐธ๊ณ ํ•ด์„œ _app.tsx ์ ์šฉํ–ˆ์„ ๋•Œ ์•„๋ž˜ ์ฝ”๋“œ์™€ ๊ฐ™์ด ๋œ๋‹ค.

// _app.tsx
function MyApp({ Component, pageProps }: AppProps) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60,
          },
        },
      })
  );
	...
  return (
    <>
      <DefaultSeo {...defaultSEO} />
      <QueryClientProvider client={queryClient}>
        ...
           <Hydrate state={pageProps.dehydratedState}>
             <Component {...pageProps} />
           </Hydrate>
         ...
        <ReactQueryDevtools initialIsOpen={false} />
      </QueryClientProvider>
    </>
  );
}
export default MyApp;

_app.tsx๋Š” ์ถ”๊ฐ€ํ•˜๊ธฐ๋งŒ ํ•˜๋ฉด ๋˜์—ˆ์ง€๋งŒ, ํŽ˜์ด์ง€์—์„œ ์ง์ ‘ datafetching์„ ์ ์šฉํ•˜๋ฉด์„œ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒผ๋‹ค. ์šฐ์„ ์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์˜ฌ ๋•Œ ๊ธฐ์กด์˜ ๊ฒฝ์šฐ๋Š” jobList์ปดํฌ๋„ŒํŠผ ๋‚ด๋ถ€์—์„œ session์„ ์ „๋‹ฌ๋ฐ›์•„ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๊ณ  ์žˆ์—ˆ๋‹ค. ํŽ˜์ด์ง€์—์„œ data-fetching์„ ํ•œ๋‹ค๋ฉด ์ปดํฌ๋„ŒํŠธ๋Š” data-fetching์— ๋Œ€ํ•ด์„œ๋Š” ๋ชจ๋ฅด๊ณ  ์ฃผ์ž…๋ฐ›์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด์—ฌ์ฃผ๊ธฐ ๋งŒ ํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์ง€ ์•Š์„๊นŒ ์ƒ๊ฐ๋˜์–ด ์ˆ˜์ •ํ–ˆ๋‹ค.

// ๊ธฐ์กด Joblist ์ปดํฌ๋„ŒํŠธ
export default function JobList({ session }: { session: Session | undefined }) {
  const { pathname } = useRouter()
  const isUser = pathname === "/user" || pathname === "/user/[id]"
  const user = session?.user
  const { getFilteredJobs } = useJobs(isUser ? user : undefined)
  const { isLoading, data: jobs } = getFilteredJobs
  const vacantJobs = jobs?.length === 0
  if (isLoading) {
    return <GuideBox>์ฑ„์šฉ๊ณต๊ณ ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค...</GuideBox>
  }
  if (vacantJobs) {
    return <GuideBox>์ฑ„์šฉ๊ณต๊ณ ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค๐Ÿ˜‰</GuideBox>
  }

  return (
    <Wrapper>
      {jobs && jobs.map(job => <JobItem key={job.id} job={job} />)}
    </Wrapper>
  )
}

// ์ˆ˜์ •ํ•œ Joblist ์ปดํฌ๋„ŒํŠธ

export default function JobList({ jobs }: JobListProps) {
  const vacantJobs = jobs?.length === 0 || !jobs
  if (vacantJobs) {
    return <GuideBox>์ฑ„์šฉ๊ณต๊ณ ๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค๐Ÿ˜‰</GuideBox>
  }

  return (
    <Wrapper>
      {!vacantJobs && jobs.map(job => <JobItem key={job.id} job={job} />)}
    </Wrapper>
  )
}

์ตœ๋Œ€ํ•œ ์ปดํฌ๋„ŒํŠธ๋Š” ๋กœ์ง์— ๋Œ€ํ•ด์„œ ๋ชจ๋ฅด๊ฒŒ ํ•˜๊ณ  ํŽ˜์ด์ง€์—์„œ ํ•„์š”ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๋„˜๊ฒจ์ฃผ๋Š” ๋ฐฉ์‹์œผ๋กœ ์ˆ˜์ •ํ•ด, ํ›จ์”ฌ ์˜์กด์„ฑ์„ ๋‚ฎ์ถœ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

// pages/index.tsx

function Home() {
  const { getJobs } = useJobs()
  const { data } = getJobs
  return (
    <MainLayout>
      <JobSection jobs={data} />
    </MainLayout>
  )
}

export default Home

export const getServerSideProps = async () => {
  const queryClient = new QueryClient()
  const dbService = new DBServiceImpl(firebaseApp)
  await queryClient.prefetchQuery<Jobs, AxiosError, Jobs, [string, string]>(
    [JOBS_KEY, "all"],
    () => dbService.getJobs()
  )

  return {
    props: { dehydratedState: dehydrate(queryClient) },
  }
}

// pages/jobs/[id].tsx

function Index() {
  const { getFilteredJobs, getJobById } = useJobs()
  const { data: job } = getJobById
  const { data: allJobs } = getFilteredJobs

  return (
    <>
      ...
      <MainLayout>
        {!job && <NotFound />}
        {job && (
          <>
            <DetailJob job={job} />
            <JobSection jobs={allJobs} />
          </>
        )}
      </MainLayout>
    </>
  )
}

export default Index

export const getServerSideProps = async (context: NextPageContext) => {
  const query = context.query
  const id = query.id?.toString()
  const queryClient = new QueryClient()
  const dbService = new DBServiceImpl(firebaseApp)
  if (!id) {
    return {
      redirect: {
        destination: "/",
      },
    }
  }

  await queryClient.prefetchQuery<Jobs, AxiosError, Jobs, [string, string]>(
    [JOBS_KEY, "all"],
    () => dbService.getJobs()
  )

  return {
    props: { dehydratedState: dehydrate(queryClient) },
  }
}

์ด๋ ‡๊ฒŒ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง data-fetching์œผ๋กœ ์ˆ˜์ •ํ•˜๊ณ  ๋‹ค์‹œ ํ™•์ธํ–ˆ์„ ๋•Œ ์ •์ƒ์ ์œผ๋กœ ์นด์นด์˜ค ํ†ก, ํŽ˜์ด์Šค๋ถ, slack ๋ชจ๋‘ ์ž˜ ๋‚˜์˜ค๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

sns
sns

google ์ž์ฒด์—๋„ ๋“ฑ๋กํ•˜๊ธฐ ์œ„ํ•ด Google Search Console ์— ์‚ฌ์ดํŠธ๋ฅผ ๋“ฑ๋กํ•˜๊ณ  ํ™•์ธํ–ˆ์„ ๋•Œ ์•„์ง์€ ์‹ค์ œ๋กœ ๊ฒ€์ƒ‰์ด ๋˜์ง€ ์•Š๋Š”๋‹ค. ์‹œ๊ฐ„์ด ๊ฑธ๋ฆฌ๋Š” ๋ถ€๋ถ„์ด๋ผ ๊ณ„์†ํ•ด์„œ ํ™•์ธ์„ ํ•ด๋ณผ ์˜ˆ์ •์ด๋‹ค.

๐Ÿ™„ SSR๋กœ ์ง„์งœ ์„ฑ๋Šฅ์ด ๊ฐœ์„ ๋˜์—ˆ์„๊นŒ?

SSR์„ ์ด์šฉํ•ด data-fetching์œผ๋กœ ์ˆ˜์ •์„ ํ•˜๊ณ  ๋‚˜์„œ ์ฝ”๋“œ ์ ์œผ๋กœ ๋” ๊ฐ€๋…์„ฑ์ด ๋†’์•„์ง€๊ณ , ๋กœ๋”ฉ ์ฐฝ์„ ๋ณด์—ฌ์ฃผ์ง€ ์•Š๊ณ  ํ•œ๋ฒˆ์— ํ™”๋ฉด์ด ๋ณด์—ฌ ๋” UX๊ฐ€ ์ฒด๊ฐ ์ƒ ์ข‹์•„์กŒ๋‹ค. ํ•˜์ง€๋งŒ ์ •๋ง ์ข‹์•„์กŒ๋Š”์ง€ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด์„œ google์˜ page speed insights์„ ์ด์šฉํ•ด ์ธก์ •ํ•ด ๋ณด๊ธฐ๋กœ ํ–ˆ๋‹ค. ๊ธฐ์กด๊ณผ ๋น„๊ตํ•˜๊ธฐ ์œ„ํ•ด์„œ EC2๋กœ ๋ฐฐํฌ๋˜์–ด์žˆ๋Š” CSR์„ ์ด์šฉํ•˜๋Š” ๋ฐฐํฌ ๋ฒ„์ „๊ณผ vercel๋กœ SSR์ด ์ ์šฉ๋˜์–ด์žˆ๋Š” ๋ฒ„์ „์˜ ์„ฑ๋Šฅ์„ ์ธก์ •ํ–ˆ๋‹ค.

์ธก์ •์„ ํ•˜๊ณ  ๋ถ„์„ํ•˜๋Š”๋ฐ ํ•„์š”ํ•œ 6๊ฐ€์ง€ ์š”์†Œ์— ๋Œ€ํ•ด์„œ ์ •๋ฆฌํ•˜๊ณ  ์š”์†Œ๋“ค์„ ๋ถ„์„ํ•ด๋ณด์•˜๋‹ค.

FCP

FCP๋Š” First Contentful Paint๋กœ ํŽ˜์ด์ง€๊ฐ€ ๋กœ๋“œ๋˜๊ณ  ํŽ˜์ด์ง€ ์ฝ˜ํ…์ธ  ์ผ๋ถ€๊ฐ€ ํ™”๋ฉด์— ๋ Œ๋”๋ง ๋  ๋•Œ ๊นŒ์ง€์˜ ์‹œ๊ฐ„์„ ์ธก์ •ํ•œ๋‹ค.

TTI

TTI๋Š” ์‚ฌ์šฉ์ž์™€ ์ƒํ˜ธ์ž‘์šฉํ•˜๊ธฐ ์œ„ํ•ด ์ค€๋น„๋œ ์‹œ์ ์œผ๋กœ, ์ด๋ฒคํŠธ ํ—จ๋“ค๋Ÿฌ๊ฐ€ ์ž‘๋™ํ•  ์ˆ˜ ์žˆ๋Š” ์‹œ์ ์„ ์˜๋ฏธํ•œ๋‹ค.

Speed Index

Speed index๋Š” ์ปจํ…์ธ ๊ฐ€ ์‹œ๊ฐ์ ์œผ๋กœ ํ‘œ์‹œ๋˜๋Š” ์ง„ํ–‰์†๋„๋ฅผ ์˜๋ฏธํ•œ๋‹ค.

LCP

LCP๋Š” Largest Contentful Paint๋กœ ๋กœ๋”ฉ ์„ฑ๋Šฅ์„ ์ธก์ •ํ•˜๋Š” ์ง€ํ‘œ๋กœ ์ฒ˜์Œ ๋กœ๋“œ๋ฅผ ์‹œ์ž‘ํ•˜๊ณ  ViewPort ๋‚ด์—์„œ ๊ฐ€์žฅ ํฐ ์ด๋ฏธ์ง€๋‚˜ ํ…์ŠคํŠธ ๋ธ”๋Ÿญ์˜ ๋ Œ๋”๋ง ์‹œ๊ฐ„์„ ์ธก์ •ํ•œ๋‹ค.

FID์™€ TBT

FID๋Š” First Input Delay๋กœ ์ƒํ˜ธ์ž‘์šฉ์„ ์ธก์ •ํ•˜๋Š” ์ง€ํ‘œ๋กœ ์‚ฌ์šฉ์ž์˜ ์ฒ˜์Œ ์ƒํ˜ธ์ž‘์šฉ๋ถ€ํ„ฐ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ์ฒ˜๋ฆฌ๋ฅผ ์‹œ์ž‘ํ•˜๊ธฐ๊นŒ์ง€ ์‹œ๊ฐ„์„ ์ธก์ •ํ•œ๋‹ค.

๊ฒ€์‚ฌ์— ์‚ฌ์šฉํ•œ page speed insights์€ FID ๋Œ€์‹ ์— TBT(Time Blocking Time)๋ฅผ ์ธก์ •ํ–ˆ๋‹ค. TBT๋Š” FCP๋ถ€ํ„ฐ TTI ๊นŒ์ง€์˜ ์‹œ๊ฐ„์„ ์ธก์ •ํ•ด ์‚ฌ์šฉ์ž ์ž…๋ ฅ์— ํŽ˜์ด์ง€๊ฐ€ ์‘๋‹ตํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ์ฐจ๋‹จ๋œ ์‹œ๊ฐ„์„ ์˜๋ฏธํ•œ๋‹ค.

CLS

CLS๋Š” Cumulative Layout Shift๋กœ ์‚ฌ์šฉ์ž๊ฐ€ ์˜ˆ์ƒํ•˜์ง€ ๋ชปํ•œ ๋ ˆ์ด์•„์›ƒ ์ด๋™์„ ๊ฒฝํ—˜ํ•˜๋Š” ๊ฒƒ์— ๋Œ€ํ•œ ์ง€ํ‘œ๋‹ค.

[์™ผ์ชฝ์€ CSR๋กœ ์ธก์ •ํ•œ ์„ฑ๋Šฅ, ์˜ค๋ฅธ์ชฝ์€ SSR๋กœ ์ธก์ •ํ•œ ์„ฑ๋Šฅ]

์„ฑ๋Šฅ๋น„๊ต
์„ฑ๋Šฅ๋น„๊ต

๊ฐ ์ง€ํ‘œ๋“ค ์ค‘์—์„œ ํฌ๊ฒŒ ์ฐจ์ด๊ฐ€ ๋‚œ ๋ถ€๋ถ„์€ TTI์™€ LCP๋กœ TTI๋Š” ์ด์ „๋ณด๋‹ค ์ฆ๊ฐ€ํ–ˆ๊ณ  LCP๋Š” 83% ๊ฐ์†Œํ•œ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์—ฌ์ฃผ์—ˆ๋‹ค. ์ด์ „์—๋Š” ๊ฐ€์žฅ ํฐ ์ปจํ…์ธ ์ธ ์ด๋ฏธ์ง€๊ฐ€ ๋กœ๋”ฉ ์ดํ›„์— ๋ณด์—ฌ์ฃผ์—ˆ์ง€๋งŒ, ์„œ๋ฒ„์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„์„œ ๋จผ์ € ๋ Œ๋”๋ง์„ ํ•œ ํ›„์— ์ด๋ฒคํŠธ๋ฅผ ๋ถ™์—ฌ ์ฃผ๋Š” hydration ๊ณผ์ •์ด ์ผ์–ด๋‚˜๊ธฐ ๋•Œ๋ฌธ์— TTI๋Š” ์ฆ๊ฐ€ํ•˜๊ณ  LCP๋Š” ๊ฐ์†Œํ•œ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์—ฌ์ฃผ์—ˆ๋‹ค.

๊ฒฐ๊ณผ์ ์œผ๋กœ๋Š” ์ „๋ณด๋‹ค ๊ฐœ์„ ์ด ๋œ ๊ฒฐ๊ณผ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์—ˆ๋‹ค. ์—ฌ๊ธฐ์„œ ๋” ๊ฐœ์„ ์„ ํ•œ๋‹ค๋ฉด ์ด๋ฏธ์ง€ ์ž์ฒด๋ฅผ react-query๋ฅผ ํ†ตํ•ด ์–ด๋–ป๊ฒŒ ์บ์‹ฑ์„ ํ•ด ๋ Œ๋”๋ง ํšŸ์ˆ˜๋ฅผ ๊ด€๋ฆฌํ•  ์ง€๊ฐ€ ๋œ๋‹ค๊ณ  ์ƒ๊ฐํ•œ๋‹ค. ๋‹จ์ˆœํžˆ ๋А๋‚Œ์ ์œผ๋กœ ์ข‹์•„์กŒ๋‹ค๊ณ  ์ƒ๊ฐํ•˜๋Š” ๋ฐ์„œ ๊ทธ์น˜์ง€ ์•Š๊ณ  ์ง์ ‘ ์ธก์ •ํ•จ์œผ๋กœ์จ ๊ฐ๊ด€์ ์ธ ๋ฐ์ดํ„ฐ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ๋Š” ์ข‹์€ ๊ฒฝํ—˜์ด ๋˜์—ˆ๋‹ค.

[์ฐธ์กฐ]

@choi2021
๋งค์ผ์˜ ์‹œํ–‰์ฐฉ์˜ค๋ฅผ ๊ธฐ๋กํ•˜๋Š” ๊ฐœ๋ฐœ์ผ์ง€์ž…๋‹ˆ๋‹ค.