Next.js 포트폴리오 성능 최적화 회고 — LCP·TBT·CLS 개선기

DAM·2026년 4월 28일

[Project]

목록 보기
5/8
post-thumbnail

들어가며

개인 포트폴리오 사이트(Next.js 15 / App Router)를 만들고 Lighthouse를 돌려보니
첫 페인트 시점, 메인 스레드 점유 시간, 폰트 로딩 시 레이아웃 시프트 등에서
개선할 만한 지점들이 보였다.

이번 글에서는 LCP / TBT / CLS 세 가지 지표를 중심으로,
지표별 원인과 해결 과정을 코드와 함께 남겨둔다.

적용 스택: Next.js 15, React 18, App Router, Tailwind CSS, Framer Motion


1. 이미지 최적화 — PNG → WebP/AVIF 전환

이번 최적화 과정에서 가장 효과가 컸던 부분이다. 프로젝트 썸네일 이미지를 모두 PNG로 관리하고 있었는데,
용량이 큰 데다, 호버 시 두 번째 이미지까지 함께 로드되면서 네트워크 비용이 커지는 문제가 있었다.

1-1. 원본 이미지를 WebP로 일괄 전환

- defaultImage: "/images/projects/panzy-main.png",
- hoverImage: "/images/projects/panzy-hover.png",
+ defaultImage: "/images/projects/panzy-main.webp",
+ hoverImage: "/images/projects/panzy-hover.webp",

PNG 대비 평균 60~70% 정도 용량이 감소했다. 화질 손실은 육안으로 거의 차이가 없는 수준이다.

1-2. next.config.ts로 이미지 파이프라인 튜닝

// next.config.ts
const nextConfig: NextConfig = {
    images: {
        // 브라우저가 지원하는 최적 포맷 자동 선택 (AVIF 우선)
        formats: ["image/avif", "image/webp"],
        // 이미지 CDN 캐시 1년
        minimumCacheTTL: 31536000,
        // 뷰포트별 최적 크기
        deviceSizes: [640, 750, 828, 1080, 1200, 1920],
        // 176 포함: 프로필 이미지(176×176) 정확히 매칭 → 리사이즈 비용 절감
        imageSizes: [16, 32, 48, 64, 96, 128, 176, 256, 384],
    },
};

포인트는 imageSizes 배열에 실제 사용하는 사이즈를 추가한 것.
프로필 이미지가 176×176인데 기본 imageSizes에는 176이 없어서
가장 가까운 256으로 리사이즈된 후 표시되고 있었다.
실제 사용하는 크기를 명시하면 Next.js의 이미지 최적화 단계에서 불필요한 리사이즈 과정을 줄일 수 있다.

1-3. next/imagequality, sizes 명시

<Image
    src={project.defaultImage}
    alt={`${project.name} 프로젝트 메인 이미지`}
    fill
    sizes="(max-width: 768px) 100vw, 50vw"
    loading="lazy"
    quality={85}        // 기본 75 → 85, 화질과 용량 사이 절충
    className="object-cover transition-all duration-500"
/>
  • sizes를 정확히 지정해야 Next.js가 viewport에 맞는 이미지만 다운로드한다.
  • 기본 quality는 75인데, 프로젝트 썸네일은 화질이 중요해 85로 약간 올렸다.
    대신 메인 LCP 요소인 프로필 이미지는 90으로 유지했다.

2. 폰트 최적화 — 다운로드 크기 줄이고 CLS 막기

next/font/google를 쓰면 self-host로 자동 처리되긴 하지만,
굵기(weight)를 명시하지 않으면 사용하지도 않는 굵기까지 다 받아온다.

2-1. 실제 사용하는 weight만 로드

// src/app/layout.tsx
const inter = Inter({
    subsets: ["latin"],
    variable: "--font-inter",
    display: "swap",
    adjustFontFallback: true,
    preload: true,
    // 실제 사용하는 굵기만 로드 → 폰트 다운로드 크기 절감
    weight: ["400", "500", "600", "700"],
});

weight를 명시하지 않으면 200~900 범위의 variable font 전체가 함께 로드된다.
실제로 쓰는 4개 굵기만 지정하면 폰트 페이로드가 크게 줄어든다.

2-2. 보조 폰트는 display: "optional"로 CLS 방지

Fira Code는 Nav 로고 한 곳에만 쓰는 보조 폰트라 굳이 swap으로
시스템 폰트에서 웹폰트로 전환되는 과정을 사용자에게 보여줄 필요가 없었다.

const firaCode = Fira_Code({
    subsets: ["latin"],
    weight: ["600"],
    variable: "--font-fira-code",
    display: "optional",  // swap → optional
});
display 값동작CLS
swap시스템 폰트로 먼저 표시 → 웹폰트 로드되면 교체발생 (폰트 메트릭 차이)
optional100ms 안에 못 받으면 시스템 폰트 유지, 다음 방문에서 사용없음

핵심 본문 폰트(Inter)는 시각적 일관성이 중요해서 swap을 유지하고,
사이드 폰트만 optional로 바꿨다.

2-3. adjustFontFallback: true

adjustFontFallback 옵션을 사용해 폴백 폰트의 메트릭(글자 크기와 행간)을 웹폰트와 유사하게 자동 보정했다.
이로 인해 폰트 교체 과정에서 발생하는 미세한 레이아웃 시프트를 줄이고,
Inter 적용 시에도 보다 안정적인 렌더링을 유지할 수 있었다.


3. LCP 개선 — 히어로 영역 프로필 이미지

LCP 요소는 첫 화면의 프로필 이미지(176×176).
원래 Framer Motion의 scaleIn 변형을 그대로 사용하고 있었는데,
이로 인해 LCP 측정 시점이 불필요하게 지연되고 있었다.

3-1. LCP 요소에 opacity: 0이 들어가면 안 된다

// 기존: scaleIn — opacity 0 → 1 트랜지션 포함
// 문제: Lighthouse는 opacity:0인 동안 LCP가 "보이지 않는다"고 판단해
//       transition이 끝날 때까지 LCP 측정을 미룬다.

scaleIn 안에 opacity: 0 → 1이 포함되어 있어
이미지 자체는 빨리 다운로드되었지만 LCP 시간이 트랜지션 종료 시점까지 늦춰지는 현상이 발생했다.

3-2. opacity 없이 scale만 사용하는 변형 추가

// LCP 요소(프로필 이미지)는 opacity:0 으로 시작하면 LCP가 늦게 측정됨
// → opacity 없이 scale만 사용해 즉시 visible 상태 유지
const profileImageVariant: Variants = {
    hidden: { scale: 0.9 },
    visible: {
        scale: 1,
        transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
    },
};

이미지는 처음부터 화면에 보이는 상태로 렌더링되고, 단지 scale만 변경되며 애니메이션이 적용된다.
이 경우 요소가 opacity: 0 상태를 거치지 않기 때문에,
브라우저는 해당 이미지를 DOM에 페인트하는 시점에 바로 LCP를 측정한다.

3-3. priority + 정확한 sizes + 적절한 quality

<Image
    src={PROFILE_IMAGE_PATH}
    alt={`${personalInfo.name} 프로필 사진`}
    width={176}
    height={176}
    sizes="176px"   // 고정 크기라 정확히 명시
    priority         // <head>에 preload 자동 삽입
    quality={90}     // 얼굴 사진이라 약간 높게
    className="..."
/>

priority를 주면 Next.js가 <link rel="preload">를 자동으로 넣어줘서
HTML 파싱 직후 이미지 다운로드가 시작된다.


4. TBT 개선 — scroll 이벤트를 IntersectionObserver로

상단 Nav가 현재 활성 섹션을 하이라이트하기 위해
window.scroll 이벤트로 위치를 계산하고 있었다.

4-1. 기존 코드의 문제

// Before: scroll 이벤트 + rAF 쓰로틀링
useEffect(() => {
    const sections = Array.from(document.querySelectorAll("section[id]"));
    let ticking = false;

    const handleScroll = () => {
        if (ticking) return;
        ticking = true;
        requestAnimationFrame(() => {
            let current = "";
            sections.forEach((el) => {
                if (window.scrollY >= el.offsetTop - 100) {
                    current = el.getAttribute("id") ?? "";
                }
            });
            setActiveSection(current);
            ticking = false;
        });
    };
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);
}, []);

requestAnimationFrame으로 실행 횟수를 60fps로 제한했지만, 근본적인 문제는 그대로다.
스크롤하는 내내 매 프레임 메인 스레드에서 JS가 실행되고,
el.offsetTop 접근이 강제 레이아웃(forced layout)을 유발해 추가 비용까지 생긴다.
TBT(Total Blocking Time)는 메인 스레드가 50ms 이상 점유되는 구간을 집계하는 지표라,
스크롤 중 누적되는 JS 실행 시간이 점수에 직접적인 영향을 준다.

4-2. IntersectionObserver로 교체

useEffect(() => {
    /**
     * IntersectionObserver 기반 활성 섹션 감지
     *
     * - 스크롤마다 실행 ❌, 섹션이 진입/이탈할 때만 실행 ✅
     * - 브라우저 내부에서 처리 → 메인 스레드 부담 없음 → TBT 개선
     *
     * rootMargin: "-40% 0px -55% 0px"
     *  → 뷰포트 상단 40%, 하단 55% 제외한 중간 5% 영역에서만 감지
     *  → 스크롤 중간쯤 왔을 때 활성화돼 자연스러운 하이라이트
     */
    const observer = new IntersectionObserver(
        (entries) => {
            entries.forEach((entry) => {
                if (entry.isIntersecting) {
                    setActiveSection(entry.target.getAttribute("id") ?? "");
                }
            });
        },
        { rootMargin: "-40% 0px -55% 0px" },
    );

    document.querySelectorAll("section[id]").forEach((s) => observer.observe(s));
    return () => observer.disconnect();
}, []);
방식실행 빈도메인 스레드 부담
scroll + rAF스크롤 중 매 프레임있음
IntersectionObserver진입/이탈 시 1회거의 없음

rootMargin-40% 0px -55% 0px로 줘서 뷰포트의 정중앙(약 5% 영역)을
지날 때만 활성화되도록 했다. 섹션이 스크롤 중간쯤 왔을 때 부드럽게 바뀐다.

4-3. 불필요한 whileInView 제거

Skills 섹션에서 그룹마다 stagger 애니메이션을 돌리고 있었는데,
그룹 안 뱃지마다 또다시 whileInView를 거는 불필요하게 중복된 구조였다.

// Before: 그룹과 뱃지 양쪽에서 IntersectionObserver 등록
<m.div variants={staggerContainer(0.05)} initial="hidden" whileInView="visible">
    {group.skills.map((skill) => (
        <m.span key={skill.id} variants={badgeVariant} ... />
    ))}
</m.div>

// After: 부모 stagger를 자식이 상속받게 단순화
<div className="flex flex-wrap gap-2">
    {group.skills.map((skill) => (
        <m.span key={skill.id} variants={badgeVariant} ... />
    ))}
</div>

Framer Motion은 부모의 variants를 자식이 자동으로 상속받는다.
따라서 중간 wrapper에 whileInView를 따로 걸 필요가 없다.
이를 제거하자 스크롤마다 동작하던 IntersectionObserver 인스턴스 수가 줄었고, 그만큼 TBT도 개선됐다.


5. CSS·번들 최적화

5-1. Critical CSS 자동 인라인 (optimizeCss)

// next.config.ts
const nextConfig: NextConfig = {
    experimental: {
        // 첫 화면에 필요한 CSS만 추출해 <head>에 인라인
        optimizeCss: true,
    },
};

critters 의존성을 함께 추가하면 Next.js가 빌드 단계에서
첫 화면 렌더링에 필요한 CSS만 골라 <head>에 인라인해준다.
나머지는 비동기로 로드되므로 FCP가 빨라진다.

5-2. Tailwind safelist로 동적 클래스 누락 방지

// tailwind.config.ts
safelist: [
    "object-contain",
    "object-cover",
],

JIT 모드는 사용하지 않는 클래스를 제거하는데,
데이터 파일에서 문자열로 조립되는 클래스는 빌드 시 감지되지 않아 누락될 수 있다.
런타임에서 동적으로 쓰는 클래스는 safelist에 명시해 둔다.

5-3. 모던 브라우저 타깃으로 polyfill 축소

// package.json
"browserslist": {
    "production": [
        "chrome >= 90",
        "firefox >= 90",
        "safari >= 14",
        "edge >= 90"
    ]
}

포트폴리오 사이트는 채용 담당자가 주로 보는 용도라, 방문자층이 좁고 구형 브라우저 지원이 불필요한 만큼,
모던 브라우저로 타깃을 좁히면 transpile 결과물이 가벼워지고 불필요한 polyfill도 줄어든다.


6. 작은 디테일 — willChange와 metadataBase

6-1. 무한 애니메이션 요소에 willChange

<m.div
    animate={{ y: [0, -18, 0] }}
    transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
    style={{ willChange: "transform" }}  // GPU 레이어 분리
/>

Hero 배경의 둥둥 떠다니는 그라디언트 원에 will-change: transform을 적용했다.
브라우저가 미리 GPU 레이어로 합성해서 애니메이션 중 리페인트가 발생하지 않는다.

단, 모든 요소에 남발하면 메모리 사용량이 늘어나니
무한 반복되는 transform 애니메이션에만 선별 적용했다.

6-2. metadataBase로 OG 이미지 절대 경로 자동 처리

metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://..."),

OG 이미지 경로를 상대 경로로 적어도 자동으로 절대 URL로 변환된다.
환경별 URL을 환경변수로 분기할 수 있어 Vercel preview/production 모두 대응할 수 있다.


마치며

이번 작업을 돌아보면, 성능 문제의 대부분은 브라우저가 이미 잘 처리하는 일을 JS로 다시 구현하면서 생겼다.

"브라우저가 이미 잘하는 일은 브라우저에게 맡기고, JS는 꼭 필요한 곳에만 쓴다."

이미지 포맷 변환은 Next.js가, 스크롤 위치 감지는 IntersectionObserver가, 폰트 폴백 보정은 adjustFontFallback이 처리하도록 역할을 넘겼다.
각자가 맡은 일을 하도록 두었더니 지표도 함께 개선됐다.

그 중 LCP 요소에 opacity: 0을 넣었던 실수가 특히 기억에 남았다.
애니메이션을 넣고 싶었을 뿐인데, Lighthouse가 이를 "보이지 않는 요소"로 판단해 LCP 측정 대상에서 아예 제외해버린다는 걸 직접 겪고 나서야 알았다.

결국 브라우저가 어떻게 해석하는지를 먼저 이해하는 게 중요하다는 걸 다시 확인했다.


참고

profile
🌐 DOM 위에서 살아남기

0개의 댓글