Next.js 13 버전 이상(App Router)을 도입해 개발을 시작하면 가장 먼저 마주치는 거대한 벽이 있습니다. 바로 컴포넌트 파일 맨 상단에 습관처럼 붙이게 되는 'use client'; 선언입니다.

저 역시 초기에는 컴포넌트마다 에러(예: useState can not be used in Server Component)가 뜰 때마다 귀찮다는 이유로 파일마다 상단에 무지성으로 'use client';를 붙여가며 개발했습니다.

하지만 나중에 프로젝트 번들 사이즈(Bundle Size)를 측정해 보니, 서버에서 가볍게 처리되어야 할 정적 UI들까지 전부 클라이언트(브라우저)로 자바스크립트 코드가 내려가면서 초기 로딩 속도가 뚝 떨어지는 심각한 부작용을 마주쳤습니다.

오늘은 서버 컴포넌트(RSC)와 클라이언트 컴포넌트(RCC)의 근본적인 차이점과, 실무에서 어떤 기준으로 분리해야 하는지 명확한 가이드를 공유해 보려고 합니다.

1. 내가 마주친 문제: "왜 모든 컴포넌트에 'use client'를 붙이면 안 될까?"

Next.js App Router의 기본 원칙은 모든 컴포넌트가 기본적으로 서버 컴포넌트(RSC)라는 점입니다.

  • 서버 컴포넌트 (RSC): HTML 렌더링과 데이터베이스(DB) 조회를 서버에서 끝내고, 가벼운 HTML 결과물만 브라우저로 보냅니다. (자바스크립트 번들 사이즈 0KB!)
  • 클라이언트 컴포넌트 (RCC): 'use client';를 선언하는 순간, 해당 컴포넌트와 그 하위에 딸린 모든 자식 코드가 브라우저로 다운로드되어 실행됩니다.

만약 페이지 전체를 감싸는 최상단 부모나 단순 마크업 컴포넌트에 무심코 클라이언트 선언을 해버리면, 서버의 강력한 성능 이점을 전혀 살리지 못하고 브라우저 과부하를 유발하게 됩니다.

2. 언제 '서버 컴포넌트(RSC)'를 써야 할까? (기본값)

대부분의 일반적인 컴포넌트는 서버 컴포넌트로 두는 것이 가장 이상적입니다.

  • 가장 적합한 곳: 백엔드 API나 DB에서 데이터를 직접 가져와야 하는 경우, 민감한 시크릿 키(API Key)를 다뤄야 하는 경우, 무거운 서드파티 라이브러리를 서버에서 미리 렌더링하고 싶을 때.
TypeScript
 
// ⭕ 서버 컴포넌트 예시 (async/await로 서버에서 직접 데이터 페칭 가능)
// 'use client' 선언이 없음 = 기본 서버 컴포넌트
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  // 서버 내부에서 직접 안전하게 DB 또는 API 호출
  const res = await fetch(`https://api.example.com/products/${params.id}`);
  const product = await res.json();

  return (
    <div className="p-6">
      <h1 className="text-xl font-bold">{product.title}</h1>
      <p className="text-slate-400">{product.description}</p>
    </div>
  );
}
 

클라이언트 브라우저가 자바스크립트를 다운로드하고 실행할 필요 없이, 서버가 이미 완성된 HTML을 보내주므로 SEO 최적화와 초기 로딩 속도(FCP)가 극적으로 빨라집니다.

'use client' 선언이 없고 async/await로 데이터를 바로 가져오는 서버 컴포넌트 구조

3. 언제 '클라이언트 컴포넌트(RCC)'를 써야 할까?

브라우저의 동적 기능이나 사용자 인터랙션이 반드시 필요한 순간에만 제한적으로 'use client';를 붙여야 합니다.

  • 가장 적합한 곳: useState, useEffect, useContext 등의 훅을 써야 할 때, onClick, onChange 같은 브라우저 이벤트 리스너가 필요할 때, 브라우저 전용 API(localStorage, window)를 쓸 때.
TypeScript
 
'use client'; // 👈 브라우저 상호작용이 필요할 때만 명시!

import { useState } from 'react';

export default function CounterButton() {
  const [count, setCount] = useState<number>(0);

  return (
    <button
      onClick={() => setCount((prev) => prev + 1)}
      className="px-4 py-2 bg-indigo-600 text-white rounded-xl"
    >
      클릭 횟수: {count}
    </button>
  );
}

4. 실무 아키텍처 패턴: "서버 컴포넌트 안에 클라이언트 컴포넌트 배치하기"

실무에서 가장 권장되는 패턴은 큰 틀(뼈대와 데이터 페칭)은 서버 컴포넌트로 만들고, 클릭 이벤트가 발생하는 아주 작은 버튼 조각만 클라이언트 컴포넌트로 분리하는 것입니다.

TypeScript
 
// 1. 부모 페이지 (서버 컴포넌트 - 데이터 로딩 담당)
import CounterButton from './CounterButton';

export default async function Page() {
  const data = await fetch('...').then((res) => res.json());

  return (
    <div>
      <h1>{data.title}</h1>
      {/* 2. 인터랙션이 필요한 작은 조각만 클라이언트 컴포넌트로 주입 */}
      <CounterButton initialCount={data.count} />
    </div>
  );
}

이렇게 하면 전체 페이지의 90%는 서버에서 가볍게 렌더링되고, 오직 버튼 컴포넌트만 클라이언트에서 작동하여 번들 크기를 최소화할 수 있습니다.

서버 데이터와 클라이언트 인터랙션 버튼이 함께 동작

5. 결론 및 느낀 점

Next.js App Router를 다룰 때 'use client';는 마법의 열쇠가 아니라 성능을 깎아먹을 수 있는 양날의 검입니다.

  1. 데이터 조회와 정적 마크업은 기본 서버 컴포넌트(RSC)로 유지할 것
  2. 상태(State)나 이벤트(Event)가 필요한 최소한의 하위 조각에만 클라이언트 컴포넌트(RCC)를 적용할 것

이 원칙을 지키는 것만으로도 웹 사이트의 번들 사이즈를 대폭 줄이고 구글 성능 점수를 크게 끌어올릴 수 있습니다!

프론트엔드 개발을 처음 배우고 백엔드 API와 통신을 연결하던 날, 데이터가 화면에 멋지게 뜰 것이라는 기대와 달리 브라우저 콘솔창에는 시뻘건 에러 메시지가 떴습니다.

Access to fetch at 'http://localhost:5000/api/users' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

분명 포스트맨(Postman)으로 테스트했을 때는 데이터가 아주 잘 넘어왔는데, 브라우저에서 fetch나 axios로 요청만 날리면 무조건 에러가 터지는 기이한 현상에 밤을 새워가며 삽질했던 기억이 납니다.

오늘은 초보 개발자들의 멘탈을 흔드는 CORS(Cross-Origin Resource Sharing)의 진짜 동작 원리와, 실무 및 개발 환경에서 이를 깔끔하게 해결하는 3가지 방법을 정리해 봅니다.

1. 내가 마주친 문제: "Postman에서는 되는데 왜 브라우저에서만 안 될까?"

당시 React 개발 서버는 http://localhost:3000에서 돌고 있었고, 백엔드 Expres

s 서버는 http://localhost:5000에서 돌고 있었습니다.

  • Postman 요청 시: 상태 코드 200 OK와 함께 유저 데이터가 정상 수신됨.
  • 리액트 앱에서 요청 시: 네트워크 요청 실패 및 빨간색 CORS 에러 발생.

💡 CORS는 '서버의 에러'가 아니라 '브라우저의 보안 정책'이다!

많은 분들이 CORS 에러가 뜨면 백엔드 서버가 다운되었거나 잘못 만들어졌다고 오해합니다. 하지만 CORS 에러를 띄우는 주체는 '웹 브라우저'입니다.

웹 브라우저는 보안상의 이유로 SOP(Same-Origin Policy, 동일 출처 정책)를 따릅니다. 즉, 현재 웹 페이지가 열려 있는 출처(Origin = 프로토콜 + 도메인 + 포트번호)와 다른 출처로 데이터를 요청할 경우, 서버가 명시적으로 *"이 출처의 요청을 허용한다"*는 응답 헤더를 내려주지 않으면 브라우저가 응답을 차단해 버리는 것입니다.

2. 해결 방법 1: 백엔드 서버에서 CORS 헤더 허용하기 (가장 정석)

가장 기본적이고 정석적인 해결법은 백엔드 서버의 응답 헤더에 Access-Control-Allow-Origin을 추가해 주는 것입니다.

Node.js(Express) 환경이라면 cors 미들웨어를 설치해 간단히 해결할 수 있습니다.

JavaScript
 
// Express 백엔드 서버 설정 (server.js)
const express = require('express');
const cors = require('cors');
const app = express();

// ⭕ 특정 프론트엔드 출처(Origin)만 안전하게 허용
app.use(
  cors({
    origin: 'http://localhost:3000', // 프론트엔드 주소
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    credentials: true, // 쿠키/인증 헤더 포함 허용
  })
);

app.get('/api/users', (req, res) => {
  res.json({ message: 'CORS 설정 성공!' });
});

app.listen(5000, () => console.log('백엔드 서버 가동 (Port 5000)'));

3. 해결 방법 2: 프론트엔드 개발 서버 프록시(Proxy) 설정하기

백엔드 코드를 당장 수정할 권한이 없거나, 외부 공공 API를 로컬에서 테스트해야 할 때는 프론트엔드 개발 서버의 프록시(Proxy) 기능을 활용하는 것이 최고입니다.

브라우저는 같은 출처인 localhost:3000/api로 요청을 보내고, 프론트엔드 개발 서버(Node.js)가 대신 백엔드로 요청을 전달하여 받아오기 때문에 브라우저의 CORS 검열을 완벽히 우회할 수 있습니다.

JSON
 
// package.json (Create React App 기준)
{
  "name": "my-frontend-app",
  "version": "0.1.0",
  "proxy": "http://localhost:5000"
}

또는 Vite를 사용 중이라면 vite.config.ts 파일에서 아래와 같이 깔끔하게 설정할 수 있습니다.

TypeScript
 
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true,
      },
    },
  },
});

4. 해결 방법 3: Next.js API Routes (BFF 패턴) 활용하기

Next.js를 사용하는 풀스택/프론트엔드 환경이라면 프레임워크 자체의 API 라우트를 일종의 중간 서버(BFF, Backend For Frontend)로 사용할 수 있습니다.

브라우저 ➔ Next.js 서버(서버 사이드) ➔ 외부 백엔드 API 순서로 통신하면, 서버 간 통신에는 브라우저의 CORS 제약이 적용되지 않으므로 아주 안전하게 데이터를 가져올 수 있습니다.

TypeScript
 
// app/api/proxy-user/route.ts (Next.js App Router API Route)
import { NextResponse } from 'next/server';

export async function GET() {
  // 서버 사이드에서 외부 API 호출 (CORS 제약 없음)
  const response = await fetch('http://localhost:5000/api/users');
  const data = await response.json();

  return NextResponse.json(data);
}

200 OK 성공 응답 화면


5. 결론 및 느낀 점

CORS 에러는 처음에 만나면 당황스럽고 막막하지만, "브라우저가 사용자를 악의적인 크로스 사이트 요청(CSRF/XSS 등)으로부터 보호하기 위해 작동하는 정상적인 보안 방패"라는 점을 이해하면 대처 방법이 명확해집니다.

  • 개발 환경에서는 프론트엔드 Proxy 설정으로 빠르게 개발하고,
  • 배포 환경에서는 백엔드 서버의 CORS 미들웨어 및 Nginx 리버스 프록시로 명확한 도메인을 허용해 주는 것이 가장 이상적입니다.

이제 브라우저 콘솔창에 빨간색 CORS 에러가 떠도 당황하지 마시고, 프록시 설정이나 서버 헤더부터 침착하게 점검해 보세요!

이번에 개인 프로젝트를 새로 런칭하고 설레는 마음으로 크롬 개발자 도구의 Lighthouse 성능 검사를 돌려보았습니다.

결과는 참담하게도 성능 점수 62점, LCP(최초 대형 콘텐츠 도출) 속도 3.2초.

스마트폰 모바일 LTE 환경으로 접속해 보니 메인 페이지 배너 이미지가 위에서부터 턱-턱-턱 둔하게 렌더링되면서 아래쪽에 있던 텍스트들이 밑으로 밀려나는 현상(CLS)까지 발생하고 있었습니다.

"요즘 같은 세상에 로딩에 3초나 걸리는 웹사이트를 누가 기다려줄까?" 하는 위기감이 들었고, 원인을 분석해 보니 아무 생각 없이 넣어둔 고용량 메인 배너 이미지(PNG, 3.8MB)가 범인이었습니다.

오늘은 기본 HTML <img> 태그를 버리고 Next.js의 Image 컴포넌트(next/image)를 도입해 로딩 속도를 0.8초까지 끌어올렸던 생생한 최적화 과정을 공유해 봅니다.

1. 내가 범했던 실수: "원본 PNG 이미지를 그냥 태그에 꽂아 넣었다"

처음엔 그냥 디자인 도구에서 추출한 3.8MB짜리 고해상도 PNG 배너를 public/images 폴더에 넣고 아래처럼 코드를 짰었습니다.

TypeScript
 
// ❌ 처음 내가 작성했던 최적화 안 된 코드
export default function HeroBanner() {
  return (
    <div className="banner-container">
      <img src="/images/main-banner.png" alt="메인 배너" />
    </div>
  );
}

이 코드가 왜 문제였냐면:

  1. 모바일 단말기에서도 3.8MB 원본을 통째로 다운로드받고 있었습니다.
  2. 이미지가 로딩되기 전까지 div 높이가 0px이었다가, 이미지 다운로드가 끝나는 순간 갑자기 400px로 커지며 아래 요소들을 강제로 밀어내는 불쾌한 현상이 일어났습니다.
  3. 브라우저가 PNG 확장자 그대로 다운로드하느라 용량이 전혀 줄어들지 않았습니다.

2. 구원투수: Next.js Image 컴포넌트로 리팩토링하기

Next.js 공식 문서를 뒤져가며 next/image 패키지를 적용해 코드를 고쳐나갔습니다.

TypeScript
 
import Image from 'next/image';

// ⭕ 내 경험을 바탕으로 수정한 최적화 코드
export default function HeroBanner() {
  return (
    <div className="relative w-full h-[400px] overflow-hidden rounded-2xl shadow-lg">
      <Image
        src="/images/main-banner.png"
        alt="메인 프로모션 배너 이미지"
        fill
        priority // 1. LCP 핵심 이미지이므로 지연 로딩을 끄고 최우선 다운로드!
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
        className="object-cover"
        placeholder="blur" // 2. 이미지가 다 떠오르기 전 부드러운 블러 미리보기 적용
        blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU58EUg=="
      />
    </div>
  );
}

💡 직접 적용해보며 깨달은 꿀팁 2가지

  • priority 속성의 중요성: 메인 페이지 상단에 바로 보이는 배너 이미지에는 반드시 priority를 붙여주어야 합니다. 이걸 안 붙이면 Next.js가 기본적으로 lazy loading(스크롤 내릴 때 로드)을 적용해버려서 오히려 초기 로딩이 더 늦어집니다.
  • fill과 부모 relative 관계: 이미지 크기를 반응형으로 고정할 때 fill을 쓰면 편리한데, 이때 반드시 감싸고 있는 부모 div에 relative 포지션과 height 값이 지정되어 있어야 배너가 깨지지 않고 예쁘게 잡힙니다.

Image 컴포넌트 사용 코드

3. 외부 이미지(Unsplash/S3) 불러올 때 만난 에러와 해결

개발하다가 유저 프로필 사진을 외부 서버 URL로 불러왔을 때 Invalid src prop ... hostname is not configured under images 라는 빨간색 에러 화면을 만났습니다.

Next.js는 보안과 악성 이미지 서버 공격을 막기 위해 허용된 도메인의 외부 이미지만 가져오도록 제한하고 있었습니다. next.config.js 파일에 제가 사용하는 외부 이미지 도메인을 명시해 주니 깔끔하게 해결되었습니다.

JavaScript
 
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com', // 사용 중인 외부 이미지 서버 도메인 추가
      },
    ],
  },
};

module.exports = nextConfig;

4. 최종 결과: 3.8MB ➔ 320KB, 성능 점수 94점 달성!

수정을 마치고 개발 서버를 다시 빌드한 뒤 크롬 개발자 도구 Network 탭을 열어보았습니다.

  • 이미지 포맷 변화: 원본 .png 파일이 브라우저에서 자동으로 최신 압축 포맷인 .webp로 변환되어 들어오고 있었습니다.
  • 용량 감축: 3.8MB ➔ 320KB (약 90% 용량 감소)
  • Lighthouse 점수: 62점 ➔ 94점으로 폭등!

Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면

5. 회고 및 느낀 점

이전에는 "이미지는 그냥 img 태그 써서 띄우면 되는 거 아닌가?" 하고 안일하게 생각했었습니다.

하지만 이번에 직접 성능 수치를 측정해 보고 next/image를 도입해 리팩토링해 보면서, 작은 이미지 최적화 하나가 전체 서비스의 품질과 사용자 경험(UX)을 얼마나 크게 좌우하는지 뼈저리게 체감할 수 있었습니다.

앞으로 작성하는 모든 프로젝트에서는 개발 초기 단계부터 이미지 최적화 전략을 적용하면 좋을 것 같습니다!

Next.js App Router의 핵심은 "모든 컴포넌트는 기본적으로 서버 컴포넌트(Server Components)다"라는 선언입니다. 과거 Pages Router 시절에는 모든 컴포넌트가 브라우저로 전송되어 하이드레이션(Hydration) 과정을 거쳐야 했지만, 이제는 서버에서만 실행되고 결과물인 HTML만 브라우저로 전달되는 컴포넌트를 만들 수 있게 되었습니다.

하지만 개발을 하다 보면 인터랙션(클릭, 상태 관리)이 필요한 시점이 오고, 자연스럽게 'use client'를 선언하게 됩니다. 이때 가장 중요한 역량은 "어디까지를 서버 영역으로 두고, 어디서부터 클라이언트 영역으로 나눌 것인가"를 결정하는 설계 능력입니다.


1. 서버 컴포넌트(RSC) vs 클라이언트 컴포넌트(RCC)

먼저 두 컴포넌트의 역할과 제약을 명확히 구분해야 합니다.

1.1 서버 컴포넌트 (Default)

  • 실행 위치: 서버에서만 실행됩니다.
  • 장점: 브라우저로 전송되는 자바스크립트 번들 크기가 줄어들며, 서버 자원(DB, 파일 시스템)에 직접 접근할 수 있습니다. 보안에 민감한 API 키 등을 숨기기에도 유리합니다.
  • 제약: useState, useEffect 같은 훅이나 onClick 같은 이벤트 핸들러를 사용할 수 없습니다. 브라우저 API(window, localStorage)에도 접근할 수 없습니다.

1.2 클라이언트 컴포넌트 ('use client')

  • 실행 위치: 서버에서 프리렌더링된 후 브라우저에서 하이드레이션됩니다.
  • 장점: 사용자와의 인터랙션이 가능하고 브라우저 API를 모두 사용할 수 있습니다.
  • 제약: 자바스크립트 번들 용량을 차지하며, 데이터 패칭 시 서버 컴포넌트보다 네트워크 비용이 더 발생할 수 있습니다.

2. 효율적인 경계 설계 전략 (Component Pattern)

성능을 최적화하기 위해서는 "클라이언트 컴포넌트를 컴포넌트 트리의 가장 말단으로 밀어내는 것"이 핵심입니다.

2.1 리프 컴포넌트 패턴 (Leaf Component Pattern)

페이지 전체를 'use client'로 만드는 대신, 상태가 필요한 작은 조각만 별도의 클라이언트 컴포넌트로 분리하세요.

  • Bad: 검색 결과 페이지 전체를 클라이언트 컴포넌트로 선언 (검색 결과 리스트까지 모두 JS 번들에 포함됨)
  • Good: 검색어 입력을 받는 SearchBar만 클라이언트 컴포넌트로 만들고, 결과 리스트는 서버 컴포넌트로 유지
TypeScript
 
// SearchPage.tsx (Server Component)
import SearchBar from './SearchBar'; // 'use client'
import SearchResults from './SearchResults'; // Server Component

export default function SearchPage() {
  return (
    <main>
      <h1>검색 페이지</h1>
      {/* 인터랙션이 필요한 부분만 클라이언트 영역 */}
      <SearchBar /> 
      {/* 데이터 렌더링은 서버 영역 */}
      <SearchResults />
    </main>
  );
}

3. 서버 컴포넌트 안에 클라이언트 컴포넌트 넣기 (Composition)

서버 컴포넌트에서 클라이언트 컴포넌트를 불러오는 것은 쉽지만, 그 반대는 불가능하다고 생각하는 경우가 많습니다. 하지만 컴포넌트 합성(Composition)을 이용하면 클라이언트 컴포넌트 '내부'에 서버 컴포넌트를 배치할 수 있습니다.

TypeScript
 
// ClientWrapper.tsx ('use client')
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>토글</button>
      {/* children으로 들어온 서버 컴포넌트는 여전히 서버에서 실행됨 */}
      {isOpen && children}
    </div>
  );
}

// Page.tsx (Server Component)
export default function Page() {
  return (
    <ClientWrapper>
      <HeavyServerComponent /> {/* 클라이언트 컴포넌트 안에서도 서버 컴포넌트의 이점 유지 */}
    </ClientWrapper>
  );
}

이 패턴을 활용하면 레이아웃이나 상태를 관리하는 래퍼(Wrapper)는 클라이언트 쪽에 두면서도, 실제 무거운 데이터 렌더링은 서버 컴포넌트로 처리하는 고도화된 설계가 가능해집니다.


4. 데이터 패칭의 최적 위치

App Router에서는 데이터 패칭을 서버 컴포넌트에서 수행하는 것을 강력히 권장합니다.

  • 이유: 서버에서 직접 DB에 접근하므로 속도가 빠르고, 클라이언트-서버 간의 Waterfall 현상을 방지할 수 있습니다. 또한 패칭된 데이터를 하위 클라이언트 컴포넌트로 Props를 통해 쉽게 전달할 수 있습니다.

5. 실무 체크리스트: 언제 'use client'를 쓸까?

필요한 기능 서버 컴포넌트 클라이언트 컴포넌트
데이터 패칭 (Fetching Data) ✅ ❌ (권장 안 함)
백엔드 자원 직접 접근 (DB, API) ✅ ❌
보안 정보 유지 (API Keys, Tokens) ✅ ❌
상태 관리 및 생명주기 (useState, useEffect) ❌ ✅
브라우저 API 사용 (window, localStorage) ❌ ✅
커스텀 훅 사용 (대부분의 UI 관련 훅) ❌ ✅

6. 결론: "서버가 먼저, 클라이언트는 나중에"

Next.js App Router 설계의 미학은 '자바스크립트 다이어트'에 있습니다. 최대한 많은 로직을 서버로 옮기고, 클라이언트는 오직 사용자의 손가락이 닿는 곳(Click, Type, Swipe)에만 집중하게 하세요.

오늘의 요약:

  • 기본은 서버: 모든 파일은 일단 서버 컴포넌트로 시작하세요.
  • 말단 분리: 상태가 필요한 부분만 핀셋으로 집어내듯 클라이언트 컴포넌트로 만드세요.
  • 합성 활용: 클라이언트 컴포넌트가 서버 컴포넌트를 children으로 받을 수 있음을 잊지 마세요.
  • 데이터는 상단 서버에서: 데이터 패칭은 가능한 트리 상단의 서버 컴포넌트에서 해결하세요.

웹사이트의 첫인상을 결정짓는 가장 중요한 요소 중 하나는 '로딩 속도'입니다. 구글은 이를 정량적으로 측정하기 위해 Core Web Vitals라는 지표를 도입했으며, 그중에서도 가장 큰 비중을 차지하는 것이 바로 LCP(Largest Contentful Paint)입니다.

LCP는 페이지에서 가장 큰 콘텐츠(보통 히어로 이미지나 큰 텍스트 블록)가 화면에 렌더링 되는 시간을 의미합니다. 놀랍게도 대부분의 웹사이트에서 LCP 점수를 갉아먹는 주범은 바로 '최적화되지 않은 이미지'입니다. 오늘은 Next.js가 제공하는 강력한 도구인 next/image를 활용하여 LCP 점수를 획기적으로 개선하는 전략을 심층 분석해 보겠습니다.


1. 왜 일반 <img> 태그보다 next/image인가?

전통적인 <img> 태그는 단순히 이미지를 불러올 뿐, 성능 최적화에 대한 어떠한 책임도 지지 않습니다. 반면 Next.js의 Image 컴포넌트는 다음과 같은 작업을 자동으로 수행합니다.

  1. 이미지 포맷 최적화: 브라우저가 지원한다면 WebP나 AVIF 같이 용량이 훨씬 작은 최신 포맷으로 자동 변환합니다.
  2. 리사이징 (Resizing): 디바이스 크기에 맞춰 최적화된 크기의 이미지를 생성하여 전송합니다. 4K 이미지를 모바일 사용자에게 전송하는 낭비를 막아줍니다.
  3. Lazy Loading: 화면에 보이지 않는 이미지는 로딩을 뒤로 미뤄 초기 로딩 속도를 높입니다.
  4. Placeholder 제공: 이미지가 로드되기 전 저해상도 이미지나 블러(Blur) 효과를 보여주어 사용자 경험을 개선합니다.

2. LCP 개선의 핵심: priority 속성

LCP 점수를 높이는 가장 쉽고 강력한 방법은 '가장 중요한 이미지'를 먼저 로드하는 것입니다.

보통 페이지 상단의 히어로 이미지나 배너가 LCP 요소가 됩니다. next/image는 기본적으로 레이지 로딩(Lazy Loading)이 적용되는데, LCP 요소에 레이지 로딩이 걸려 있으면 오히려 렌더링이 늦어져 LCP 점수가 나빠집니다.

JavaScript
 
// LCP 대상이 되는 히어로 이미지 예시
import Image from 'next/image';

export default function Hero() {
  return (
    <section>
      <Image
        src="/hero-banner.png"
        alt="메인 배너 이미지"
        width={1200}
        height={600}
        priority // 이 속성이 LCP 개선의 핵심입니다!
        className="object-cover"
      />
    </section>
  );
}

priority 속성을 추가하면 Next.js는 해당 이미지를 높은 우선순위(Fetch Priority)로 처리하고 프리로드(Preload) 태그를 삽입합니다. 이는 브라우저가 HTML을 파싱 할 때 해당 이미지를 가장 먼저 다운로드하도록 유도하여 LCP 시간을 단축시킵니다.


3. 레이아웃 시프트 방지와 sizes 속성 활용

이미지 로딩 중 화면이 덜컥거리는 현상인 CLS(Cumulative Layout Shift) 역시 성능 점수에 큰 영향을 줍니다. 이를 방지하려면 이미지의 비율을 미리 브라우저에 알려줘야 합니다.

3.1 정적 이미지와 고정 비율

width와 height를 명시하면 Next.js는 이미지 비율에 맞춰 공간을 미리 확보합니다. 만약 반응형 이미지를 구현해야 한다면 sizes 속성을 반드시 사용해야 합니다.

3.2 sizes 속성 최적화

sizes는 브라우저에게 "이 이미지는 뷰포트 크기에 따라 이 정도 너비를 차지할 거야"라고 미리 알려주는 힌트입니다.

JavaScript
 
<Image
  src="/product.jpg"
  alt="상품 이미지"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

위 설정은 모바일에서는 화면 꽉 차게(100vw), 태블릿에서는 절반(50vw), 데스크탑에서는 1/3(33vw) 크기의 이미지만 요청하도록 만듭니다. 불필요하게 큰 이미지를 다운로드하지 않게 되어 로딩 속도가 비약적으로 향상됩니다.


4. 이미지 포맷과 퀄리티 조정 (next.config.js)

더 극단적인 최적화를 원한다면 설정 파일을 통해 이미지 포맷을 제어할 수 있습니다. WebP보다 압축률이 높은 AVIF를 지원하도록 설정해 보세요.

JavaScript
 
// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

또한, 기본 퀄리티(75)를 프로젝트 성격에 맞춰 조정하여 용량과 화질 사이의 균형을 맞출 수 있습니다.


5. 실무 트러블슈팅: 외부 도메인 이미지 처리

많은 실무 프로젝트(예: CMS, 쇼핑몰 가비아 이미지 서버 등)에서 외부 URL의 이미지를 가져옵니다. 이때 next/image를 쓰려면 반드시 허용된 도메인 설정을 거쳐야 합니다.

JavaScript
 
// next.config.js
images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'your-cdn-server.com',
      pathname: '/images/**',
    },
  ],
},

이 설정을 통해 외부 서버의 이미지도 Next.js 서버가 대신 최적화(Optimization API)하여 사용자에게 전달할 수 있게 됩니다.

+ Recent posts