Front-end#FE#품질관리#성능#최적화

Next.js 블로그에서 고려한 웹 성능 최적화

yerim.dev 블로그에서 ISR, Server Component, 동적 임포트, CLS 방지 등 실제 적용한 성능 최적화 기법 정리

2026년 6월 4일7분 읽기조회 19
공유

들어가며

이 블로그(yerim.dev)는 Next.js 14 App Router와 Notion CMS를 직접 연동해서 만든 개인 기술 블로그입니다. UI 프레임워크 없이 직접 구현하다 보니, 성능에 대해 의식적으로 고민할 수밖에 없었습니다.

이 글에서는 구현 과정에서 실제로 적용하거나 의식적으로 고려한 웹 성능 최적화 기법들을 정리했습니다. 기법별로 "왜 이 선택을 했는가"에 초점을 맞춰 작성했습니다.

아직 저도 계속 배우는 과정이라 더 좋은 방법이 있을 수 있습니다.

글을 읽으시면서 개선할 점이나 추천하고 싶은 최적화 기법이 있다면 댓글로 공유해주세요! 함께 이야기하며 더 좋은 방향을 찾아가면 좋겠습니다!


측정 기준: Core Web Vitals

성능 최적화를 하기 전에 먼저 어떤 기준으로 성능을 측정할지 정해야 합니다.

측정 도구인 Lighthouse에 대해서는 예전에 따로 정리한 글이 있으니 참고해 주세요!

👉 Lighthouse란? (https://yerim-blog.vercel.app/blog/quality-management)

이 글에서는 Lighthouse가 측정하는 지표 중 실제로 이 블로그에서 신경 쓴 Core Web Vitals만 다루겠습니다.

저는 Google에서 사용자 경험을 평가하는 핵심 지표로 제시하는 Core Web Vitals를 기준으로 성능을 측정했습니다.

  • LCP (Largest Contentful Paint): 페이지에서 가장 큰 콘텐츠 요소가 렌더링되는 시간. 2.5초 이하가 Good.
  • FCP (First Contentful Paint): 첫 번째 콘텐츠가 화면에 나타나는 시간.
  • CLS (Cumulative Layout Shift): 시각적 안정성. 갑작스러운 레이아웃 이동 정도. 0.1 이하가 Good.
  • INP (Interaction to Next Paint): 사용자 입력에 대한 응답성.
  • TBT (Total Blocking Time): 메인 스레드가 블로킹되는 총 시간.

이 블로그에서 특히 신경 쓴 지표는 LCP와 CLS입니다.

기술 블로그는 사용자가 글을 읽기 위해 방문하는 서비스인 만큼, 콘텐츠가 얼마나 빠르게 보이는지(LCP)와 화면이 얼마나 안정적으로 유지되는지(CLS)가 사용자 경험에 직접적인 영향을 준다고 판단했습니다. 따라서 최적화 과정에서도 이 두 지표를 우선적으로 개선하는 데 집중했습니다.


1. 렌더링 전략: SSG + ISR

이 블로그는 콘텐츠가 자주 변경되지 않는 기술 블로그입니다. 따라서 요청이 들어올 때마다 HTML을 생성하는 SSR(Server Side Rendering)보다, 빌드 시 HTML을 미리 생성해 두는 SSG(Static Site Generation)가 더 적합하다고 판단했습니다.

다만 SSG만 사용할 경우 새로운 글을 작성하거나 기존 글을 수정했을 때 전체 사이트를 다시 빌드해야 한다는 단점이 있습니다. 이를 해결하기 위해 Next.js의 ISR(Incremental Static Regeneration)을 함께 적용했습니다.

// app/blog/[slug]/page.tsx
export const revalidate = 86400  // 24시간마다 재검증

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

페이지의 특성에 따라 revalidate 주기를 다르게 설정했습니다.

페이지revalidate이유
홈 /3600s (1시간)조회수, 최근 댓글 반영 빈도
포스트 /blog/[slug]86400s (24시간)본문 수정 빈도 낮음
태그 /blog/tag/[tag]3600s새 글 발행 시 태그 목록 변경
RSS86400s구독자 갱신 주기 고려

ISR은 stale-while-revalidate 방식으로 동작합니다. 사용자는 항상 캐시된 정적 페이지를 즉시 전달받고, 캐시가 만료된 경우에는 백그라운드에서 새로운 페이지가 생성됩니다.

결과적으로 SSG의 빠른 응답 속도를 유지하면서도, 콘텐츠 변경 사항을 일정 주기 내에 자동으로 반영할 수 있었습니다. 기술 블로그처럼 읽기 중심의 서비스에서는 SSR보다 훨씬 효율적인 선택이라고 판단했습니다.


2. Server Component 우선 설계

Next.js 14 App Router에서는 기본적으로 모든 컴포넌트가 Server Component로 동작합니다. Server Component는 렌더링 결과만 클라이언트에 전달하고 JavaScript 번들에는 포함되지 않기 때문에, 브라우저가 다운로드하고 실행해야 하는 JavaScript 양을 줄일 수 있습니다.

이 블로그에서는 특별한 이유가 없는 한 Server Component를 기본으로 사용하고, 다음과 같은 경우에만 'use client'를 선언했습니다.

  • 브라우저 API가 필요한 경우 (scroll, clipboard, IntersectionObserver 등)
  • 이벤트 핸들러나 로컬 상태(useState)가 필요한 경우

예를 들어 TableOfContents는 IntersectionObserver를 사용하고, ReadingProgress는 스크롤 이벤트를 사용하며, CopyButton은 클립보드 API를 사용합니다. 이러한 컴포넌트들은 각각 독립적인 Client Component로 분리하고, 포스트 페이지 자체(app/blog/[slug]/page.tsx)는 Server Component로 유지했습니다.

즉, 페이지 전체를 Client Component로 전환하는 대신 인터랙션이 필요한 최소 단위만 클라이언트로 격리하는 방식을 선택했습니다.

이 접근 방식의 장점은 명확합니다. 클라이언트로 전달되는 JavaScript 번들 크기를 줄일 수 있고, 브라우저가 수행해야 하는 Hydration 비용도 최소화할 수 있습니다. 결과적으로 초기 로딩 성능과 사용자 경험 개선에 도움이 됩니다.


3. React cache()로 중복 요청 방지 와 Promise.all() 활용한 병렬 처리

포스트 상세 페이지를 렌더링할 때 여러 Server Component가 동일한 Notion 데이터를 필요로 하는 경우가 있습니다. 이때 단순히 함수를 여러 번 호출하면 동일한 API 요청이 중복으로 발생할 수 있습니다.

이를 방지하기 위해 React 18의 cache()를 활용했습니다.

// lib/notion.ts
import { cache } from 'react'

export const getAllPosts = cache(async (): Promise<Post[]> => {
  const response = await notion.databases.query({ ... })
  return response.results.map(extractPostMeta).filter(p => p.published)
})

cache()로 감싼 함수는 하나의 렌더링 사이클 내에서 동일한 인자로 호출될 경우 기존 결과를 재사용합니다. 덕분에 불필요한 Notion API 호출을 줄이고 서버 측 데이터 조회 비용을 절약할 수 있습니다.

또한 서로 의존성이 없는 비동기 작업은 직렬이 아닌 병렬로 처리했습니다.

// app/blog/[slug]/page.tsx
const [content, allPosts] = await Promise.all([
  getPostContent(post.id),
  getAllPosts(),
])

만약 아래와 같이 작성하면 첫 번째 작업이 끝난 뒤 두 번째 작업이 시작됩니다.

const content = await getPostContent(post.id)
const allPosts = await getAllPosts()

반면 Promise.all()을 사용하면 두 작업이 동시에 실행되므로 전체 대기 시간을 줄일 수 있습니다.

특히 외부 API를 호출하는 작업은 네트워크 지연 시간이 존재하기 때문에, 이러한 병렬 처리만으로도 체감 성능 향상을 얻을 수 있습니다. 결과적으로 중복 요청은 줄이고 데이터 조회는 병렬화하여 서버 렌더링 시간을 최소화했습니다.


4. 동적 임포트로 무거운 라이브러리 지연 로드

기술 블로그에는 모든 페이지에서 반드시 필요한 기능과 특정 상황에서만 필요한 기능이 공존합니다. 후자의 경우 초기 번들에 포함시키면 사용하지 않는 페이지에서도 불필요한 JavaScript를 다운로드하게 됩니다.

대표적인 예가 Mermaid.js입니다. Mermaid는 다이어그램을 SVG로 렌더링하는 라이브러리로 기능이 강력한 만큼 번들 크기도 비교적 큰 편입니다. 모든 페이지의 초기 로드 과정에 포함시키기보다는 실제로 필요한 시점에만 불러오는 것이 효율적이라고 판단했습니다.

그래서 MermaidBlock 컴포넌트에서는 동적 임포트(Dynamic Import)를 사용해 라이브러리를 지연 로드했습니다.

// components/blog/MermaidBlock.tsx
useEffect(() => {
  const render = async () => {
    const { default: mermaid } = await import('mermaid')  // 필요할 때만 로드
    mermaid.initialize({ startOnLoad: false, theme: 'neutral' })
    const { svg } = await mermaid.render(`mermaid-${uid}`, code)
    if (ref.current) ref.current.innerHTML = svg
  }
  render()
}, [code, uid])

이 방식의 장점은 명확합니다. Mermaid 코드 블록이 없는 포스트에서는 해당 라이브러리가 아예 다운로드되지 않습니다. 즉, 사용하지 않는 기능 때문에 초기 번들 크기가 증가하는 문제를 방지할 수 있습니다.

동일한 원리로 이미지 확대 기능을 제공하는 MediumZoom 라이브러리 역시 포스트 상세 페이지에서만 로드하도록 구성했습니다.

결과적으로 초기 페이지 로드에 필요한 JavaScript 양을 줄일 수 있었고, 브라우저가 처리해야 하는 작업량도 감소했습니다. 특히 첫 화면 렌더링과 인터랙션 가능 시점(TTI)에 긍정적인 영향을 기대할 수 있습니다.


5. CLS 방지와 렌더링 최적화

사용자가 화면을 읽는 동안 레이아웃이 바뀌지 않도록 하는 것 역시 성능 최적화의 중요한 요소라고 생각했습니다.

CLS(Cumulative Layout Shift)는 사용자가 페이지를 읽는 도중 화면 요소의 위치가 갑자기 변경되는 현상을 의미합니다. 기술 블로그처럼 읽기 경험이 중요한 서비스에서는 작은 레이아웃 이동도 사용자 경험에 부정적인 영향을 줄 수 있습니다.

반응형 레이아웃을 구현할 때도 CLS가 발생하지 않도록 주의했습니다. 클라이언트에서 화면 크기를 측정한 뒤 모바일/데스크톱 레이아웃을 분기하는 방식은 서버 렌더링 결과와 클라이언트 초기 렌더링 결과가 달라질 수 있습니다.

// 🙅🏻‍♀️ 이렇게 하면 CLS 발생 가능
const [isMobile, setIsMobile] = useState(false)
useEffect(() => { setIsMobile(window.innerWidth < 768) }, [])
return isMobile ? <MobileLayout /> : <DesktopLayout />

이 경우 사용자는 데스크톱 레이아웃을 먼저 본 뒤 모바일 레이아웃으로 변경되는 화면 이동을 경험할 수 있습니다.

대신 모든 반응형 처리는 CSS 미디어 쿼리로 해결했습니다.

/* 🙆🏻‍♀️ CSS 미디어 쿼리로 처리 — styles/globals.css */
@media (max-width: 768px) {
  .home-grid { grid-template-columns: 1fr !important; }
  .home-sidebar { display: none; }
}

이 방식은 서버에서 생성된 HTML과 클라이언트 초기 렌더링 결과가 항상 동일하기 때문에 불필요한 레이아웃 이동이나 Hydration Mismatch를 방지할 수 있습니다.

애니메이션 구현에서도 레이아웃 변경이 발생하지 않도록 신경 썼습니다. HeroBanner의 슬라이드 전환 효과는 transform과 opacity만 사용하도록 구성했습니다.

// HeroBanner.tsx
style={{
  opacity: animating ? 0 : 1,
  transform: animating ? 'translateY(8px)' : 'translateY(0)',
  transition: 'opacity 0.3s, transform 0.3s',
}}

transform과 opacity는 브라우저의 Compositor 레이어에서 처리되기 때문에 레이아웃 재계산(Reflow)을 유발하지 않습니다. 반면 width, height, top, left와 같은 속성은 요소의 배치를 다시 계산해야 하므로 애니메이션 성능에 불리합니다.

결과적으로 반응형 레이아웃은 CSS에 맡기고, 애니메이션은 GPU 친화적인 속성만 사용하여 화면의 안정성과 렌더링 성능을 모두 확보할 수 있었습니다.


6. next/image로 이미지 최적화

기술 블로그에서 이미지는 사용자가 가장 먼저 인식하는 콘텐츠 중 하나입니다. 특히 포스트 커버 이미지는 LCP(Largest Contentful Paint)의 대상이 되는 경우가 많기 때문에 이미지 최적화는 필수적입니다.

이 블로그에서는 Notion에서 가져오는 커버 이미지를 모두 next/image를 통해 렌더링합니다.

// next.config.js
const nextConfig = {
  images: {
    minimumCacheTTL: 1800,  // 30분 캐시
    remotePatterns: [
      { protocol: 'https', hostname: 'prod-files-secure.s3.us-west-2.amazonaws.com' },
      { protocol: 'https', hostname: 'www.notion.so' },
      // ...
    ],
  },
}

next/image는 브라우저와 디바이스 환경에 맞춰 이미지를 자동 최적화합니다.

  • WebP, AVIF 등 최신 포맷 자동 변환
  • 뷰포트 크기에 맞는 이미지 리사이징
  • 기본 Lazy Loading 적용
  • 이미지 크기 기반 레이아웃 예약으로 CLS 방지

덕분에 별도의 이미지 최적화 로직을 구현하지 않아도 네트워크 전송량을 줄이고 렌더링 성능을 개선할 수 있습니다.

다만 Notion에서 제공하는 S3 이미지 URL은 일정 시간이 지나면 만료된다는 제약이 있습니다. 이 블로그에서는 해당 문제를 완화하기 위해 minimumCacheTTL: 1800(30분)을 설정해 URL 만료 전에 캐시가 갱신될 수 있도록 구성했습니다.

물론 이것은 근본적인 해결책은 아닙니다. 가장 안정적인 방법은 Cloudinary와 같은 이미지 CDN 또는 별도의 이미지 프록시 서버를 두고 영구 URL을 사용하는 것입니다. 하지만 개인 블로그 규모에서는 추가 인프라 운영 비용보다 구현 단순성을 우선시했고, 현재는 이 트레이드오프를 수용하고 있습니다.

성능 최적화는 항상 "최고의 기술"을 적용하는 것이 아니라, 서비스 규모와 운영 비용을 고려해 적절한 균형점을 찾는 과정이라고 생각합니다.


7. 조건부 렌더링으로 무거운 UI 관리

검색 모달처럼 사용자가 특정 액션을 수행하기 전까지 필요하지 않은 UI는 초기 렌더링 대상에서 제외했습니다.

// CSS로 숨기는 것보다 렌더 자체를 안 하는 게 낫다
{isSearchOpen && <SearchModal />}

단순히 CSS의 display: none으로 숨기는 방식은 컴포넌트가 이미 마운트된 상태이기 때문에 초기 렌더링 비용과 메모리 사용량이 발생합니다.

반면 조건부 렌더링을 사용하면 조건이 충족되기 전까지 컴포넌트 자체가 생성되지 않습니다. 즉, React는 해당 컴포넌트를 렌더링하지 않고 관련 상태나 이벤트 리스너도 등록하지 않습니다.

특히 검색 모달처럼 내부 상태가 많거나 데이터 처리 로직이 포함된 컴포넌트는 이러한 차이가 더욱 의미 있습니다.

기능이 필요해지는 시점에만 컴포넌트를 마운트함으로써 초기 렌더링 비용을 줄이고, 브라우저가 처리해야 할 작업량도 최소화할 수 있었습니다.


마치며

직접 블로그를 만들면서 성능 최적화는 "나중에 하면 되는 것"이 아니라 설계 단계부터 함께 고려해야 하는 요소라는 점을 체감했습니다.

특히 Next.js 14 App Router는 Server Component를 기본으로 사용하기 때문에 많은 최적화가 프레임워크 수준에서 자연스럽게 이루어집니다. 하지만 그 위에서도 'use client' 경계를 최소화하고, 필요한 코드만 동적으로 로드하며, CLS를 유발하지 않는 UI를 설계하는 등 개발자가 직접 고민해야 하는 부분이 여전히 존재합니다.

이번 글에서 소개한 방법들은 절대적인 정답이라기보다, 개인 기술 블로그를 운영하며 현재 시점에서 내린 선택들의 기록에 가깝습니다. 성능 최적화는 결국 서비스의 특성과 요구사항에 따라 달라지는 트레이드오프의 연속이라고 생각합니다.

그래서 단순히 성능 점수를 높이는 것보다, 각 기법의 원리를 이해하고 **"왜 이 상황에서 이 선택을 했는가"**를 설명할 수 있는 것이 더 중요하다고 느꼈습니다.

혹시 글을 읽으면서 더 효율적인 방법이나 제가 놓친 부분이 있다면 댓글로 공유해주세요. 다양한 관점의 피드백을 통해 더 좋은 방향을 배우고, 블로그도 계속 개선해 나가고 싶습니다. 모두모두 화이팅!

관련 포스트

댓글

방명록을 남겨주세요