๐Ÿ”ง Next.js ํŽ˜์ด์ง€ ์„ฑ๋Šฅ ์ตœ์ ํ™”๊ธฐ โ‘  - ๋А๋ คํ„ฐ์ง„ ํŽ˜์ด์ง€, ๋‚ด๊ฐ€ ๊ณ ์นœ๋‹ค

ํ˜œ์—ฐยท2025๋…„ 6์›” 2์ผ
0

Next.js

๋ชฉ๋ก ๋ณด๊ธฐ
15/20

์ด๋ฒˆ ๊ธ€์—์„œ๋Š” Next.js ๊ธฐ๋ฐ˜ ํ”„๋กœ์ ํŠธ์˜ ํŽ˜์ด์ง€๋ฅผ ์„ฑ๋Šฅ ์ตœ์ ํ™”ํ•˜๋Š” ๊ณผ์ •์„ ๊ธฐ๋กํ•  ๊ฒƒ์ด๋‹ค.
๋‚˜๋Š” Google์˜ Lighthouse ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•ด ์›น ์„ฑ๋Šฅ์„ ์ธก์ •ํ•˜๊ณ  ๊ฐœ์„ ํ•ด๋ณด์•˜๋‹ค.


์›น ์„ฑ๋Šฅ ์ตœ์ ํ™”๊ฐ€ ์ค‘์š”ํ•œ ์ด์œ 

์‚ฌ์šฉ์ž๋Š” ์‚ฌ์ดํŠธ๊ฐ€ ๋А๋ฆฐ๊ฑธ ์‹ซ์–ดํ•œ๋‹ค. ๋กœ๋”ฉ์ด ์ข€๋งŒ ๊ธธ์–ด์ ธ๋„ ์‘ ์•ˆ์จ. ํ•˜๊ณ  ๋‚˜๊ฐ
์„ฑ๋Šฅ์ด ๋‚ฎ์œผ๋ฉด ์‚ฌ์šฉ์ž ์ดํƒˆ๋ฅ ์ด ๋†’์•„์ง€๊ณ , ๊ฒ€์ƒ‰์—”์ง„ ์ตœ์ ํ™”(SEO)์—๋„ ๋ถˆ๋ฆฌํ•˜๋‹ค.
ํŠนํžˆ Next.js๋Š” SSR์„ ์ง€์›ํ•˜์ง€๋งŒ, ์ž˜๋ชป์“ฐ๋ฉด ์˜คํžˆ๋ ค ๋А๋ ค์งˆ ์ˆ˜ ์žˆ๋‹ค.
๊ทธ๋ž˜์„œ ๋‚˜๋Š” Lighthouse๋ฅผ ํ†ตํ•ด ์„ฑ๋Šฅ ๋ณ‘๋ชฉ์„ ํ™•์ธํ•˜๊ณ  ์ง์ ‘ ๊ฐœ์„ ํ•ด๋ณด๊ธฐ๋กœ ํ–ˆ๋‹ค.

Lighthouse๋ž€?

Lighthouse๋Š” Chrome ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ œ๊ณตํ•˜๋Š” ์›น์‚ฌ์ดํŠธ ํ’ˆ์งˆ ๋ถ„์„ ๋„๊ตฌ์ด๋‹ค.
์„ฑ๋Šฅ ์ ์ˆ˜๋ถ€ํ„ฐ ์ ‘๊ทผ์„ฑ, SEO, Best Practice๊นŒ์ง€ ์•Œ์•„์„œ ๋ถ„์„ํ•ด์ค€๋‹ค.

  • Performance : ํŽ˜์ด์ง€ ๋กœ๋”ฉ ์†๋„, ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ ์‹œ๊ฐ„ ๋“ฑ
  • Accessibility : ์ ‘๊ทผ์„ฑ ๋ฌธ์ œ
  • Best Practices : ๋ณด์•ˆ/์ฝ”๋”ฉ ๊ด€๋ก€ ์ค€์ˆ˜ ์—ฌ๋ถ€
  • SEO : ๊ฒ€์ƒ‰ ์ตœ์ ํ™” ๊ด€๋ จ ์ง€ํ‘œ

f12 โ†’ Lighthouse ํƒญ โ†’ "ํŽ˜์ด์ง€ ๋กœ๋“œ ๋ถ„์„" ํด๋ฆญํ•˜๋ฉด ์ธก์ • ๋!

์ฃผ์š” ์„ฑ๋Šฅ ์ง€ํ‘œ

์ง€ํ‘œ์„ค๋ช…
LCP (Largest Contentful Paint)๊ฐ€์žฅ ํฐ ์ฝ˜ํ…์ธ ๊ฐ€ ํ™”๋ฉด์— ๋ Œ๋”๋ง๋  ๋•Œ๊นŒ์ง€ ๊ฑธ๋ฆฐ ์‹œ๊ฐ„
CLS (Cumulative Layout Shift)ํ™”๋ฉด ์š”์†Œ๊ฐ€ ๊ฐ‘์ž๊ธฐ ์›€์ง์ด๋Š” ์ •๋„
TBT (Total Blocking Time)๋ฉ”์ธ ์Šค๋ ˆ๋“œ๊ฐ€ ๋ง‰ํ˜€ ์‚ฌ์šฉ์ž๊ฐ€ ์•„๋ฌด๊ฒƒ๋„ ํ•  ์ˆ˜ ์—†๋Š” ์‹œ๊ฐ„
TTFB (Time To First Byte)์ฒซ ๋ฐ”์ดํŠธ๊ฐ€ ๋ธŒ๋ผ์šฐ์ €์— ๋„๋‹ฌํ•˜๋Š” ์‹œ๊ฐ„ (์„œ๋ฒ„ ์‘๋‹ต ์†๋„)

์ดˆ๊ธฐ ์ธก์ • ๊ฒฐ๊ณผ

  • Performance: 62์ 
  • Main-thread work: 6.7์ดˆ
  • Script Evaluation: 3,715ms
  • Script Parsing & Compilation: 975ms
  • Garbage Collection: 467ms

๐Ÿ‘‰ /study(์ฒซ ํŽ˜์ด์ง€) ์ง„์ž…ํ•  ๋•Œ ๋„ˆ๋ฌด ๋А๋ ธ์Œ. ์ธ๊ธฐ๊ธ€ + ์ „์ฒด ๊ฒŒ์‹œ๊ธ€ ํ•œ๊บผ๋ฒˆ์— ๋ถˆ๋Ÿฌ์˜ค๋ฉด์„œ, ์ค‘์š”ํ•œ ์ฝ˜ํ…์ธ ๊ฐ€ ๋Šฆ๊ฒŒ ๋œจ๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ์Œ


โ‘  Minimize main-thread work : use client ๋‹ค์ด์–ดํŠธ
use client๊ฐ€ ๋ถ™์€ ์ปดํฌ๋„ŒํŠธ ์ค‘์—์„œ ์ •์  UI๋งŒ ์ฒ˜๋ฆฌํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ๋Š” ์„œ๋ฒ„ ์ปดํฌ๋„ŒํŠธ๋กœ ์ „ํ™˜ํ•ด ๋ฉ”์ธ ์Šค๋ ˆ๋“œ์˜ ๋ถ€๋‹ด์„ ์ค„์—ฌ๋ณด์ž.

์˜ˆ์‹œ: StudyList โ†’ StudyCard ๋ถ„๋ฆฌ

  • StudyCard
import React from "react";
import { formatRelativeTime } from "@/lib/date";
import {  Study, User } from "@prisma/client";

import Image from "next/image";
import { FaBookmark } from "react-icons/fa";
import Link from "next/link";

type StudyType = Study & {
  author: User;
  comments: Comment[];
};

interface Props {
  study: StudyType;
  isLast: boolean;
  observerRef: React.RefObject<HTMLDivElement | null>;
}

const StudyCard = ({ study, isLast, observerRef }: Props) => {
  const today = new Date();
  today.setHours(0, 0, 0, 0); // ์˜ค๋Š˜ 00:00:00

  return (
    <Link href={`/study/${study.id}`} key={study.id}>
      <div
        ref={isLast ? observerRef : null}
        className=" min-h-[200px] group bg-white rounded shadow-sm border border-gray-200 p-6 hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
      >
        <div className="space-y-4">
          <div className="flex items-start gap-5">
            <span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-200">
              {study.category}
            </span>
            <span
              className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
                study.startDate && new Date(study.startDate) < today
                  ? "bg-gray-300 text-gray-500"
                  : "bg-green-200 text-green-700"
              }`}
            >
              {study.startDate && new Date(study.startDate) < today
                ? "๋ชจ์ง‘ ๋งˆ๊ฐ"
                : "๋ชจ์ง‘์ค‘"}
            </span>
          </div>

          <div className="space-y-2">
            <h3 className="text-lg font-semibold text-gray-900  transition-colors line-clamp-1">
              {study.title}
            </h3>
            <p
              className="text-sm text-gray-600 line-clamp-2 leading-relaxed
                  min-h-[48px]"
            >
              {study.content.replace(/[#_*~`>[\]()\-!\n]/g, "").slice(0, 100)}
            </p>
          </div>

          <div className="flex items-center gap-3 text-xs text-gray-500">
            <span>{formatRelativeTime(new Date(study.createdAt))}</span>
            <span>โ€ข</span>
            <span>{study._count.comments}๊ฐœ์˜ ๋Œ“๊ธ€</span>
          </div>

          <div className="flex items-center justify-between pt-3 border-t border-gray-100">
            <div className="flex items-center gap-3">
              <div>
                <div className="w-8 h-8 rounded-full overflow-hidden relative">
                  <Image
                    src={study.author.profileImage ?? "/default-avatar.png"}
                    alt="ํ”„๋กœํ•„"
                    fill
                    className="object-cover"
                  />
                </div>
              </div>
              <span className="text-sm text-gray-700 font-medium ">
                {study.author.nickname}
              </span>
            </div>
            <div className="flex items-center gap-1 text-red-500">
              <FaBookmark className="text-sm" />

              <span className="text-sm font-medium">{study.scrap}</span>
            </div>
          </div>
        </div>
      </div>
    </Link>
  );
};

export default StudyCard;

๊ทธ๋ฆฌ๊ณ  StudyList.tsx์—์„œ๋Š” ์ด๋ ‡๊ฒŒ ์‚ฌ์šฉ

import StudyCard from "./StudyCard";

// ...

{studies.map((study, index) => (
  <StudyCard
    key={study.id}
    study={study}
    isLast={index === studies.length - 1}
    observerRef={observerRef}
  />
))}

์„ฑ๋Šฅ ์ ์ˆ˜๋Š” ๊ทธ๋Œ€๋กœ์˜€์ง€๋งŒ, ๊ธธ์—ˆ๋˜ ์ฝ”๋“œ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ๊ฐ€๋…์„ฑ์ด ์ข‹์•„์กŒ๊ณ , ์œ ์ง€๋ณด์ˆ˜์„ฑ ํ–ฅ์ƒ


์ง€์—ฐ ๋กœ๋”ฉ, DB ์ฟผ๋ฆฌ ์ตœ์ ํ™”, dynamic import ์จ์„œ
์ ์ˆ˜๊ฐ€ ์ง„์งœ๋กœ ์˜ค๋ฅด๊ธฐ ์‹œ์ž‘ํ•œ ์ด์•ผ๊ธฐ๋Š” ๋‹ค์Œ ํŽธ์—์„œ

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