Vue.js 생태계의 패러다임 역시 끊임없이 진화해왔습니다. 브라우저에서 모든 것을 그리는 CSR(클라이언트 사이드 렌더링)에서 시작하여, 초기 로딩 속도와 SEO 문제를 해결하기 위한 전통적인 SSR(서버 사이드 렌더링)을 거쳐, 마침내 초경량 Nitro 엔진과 유연한 하이브리드 렌더링을 지원하는 Nuxt 4 아키텍처라는 최신 렌더링 모델에 도달했습니다.
ref, computed 같은 Vue 유틸리티나 커스텀 컴포넌트를 일일이 import 할 필요 없이 즉시 사용할 수 있습니다. (Zero-config)// Nuxt 4의 루트 디렉토리에 위치하는 핵심 설정 파일입니다.
export default defineNuxtConfig({
// 1. Nuxt 4의 핵심! 새로운 폴더 구조 호환성을 위한 설정
// 향후 기본값이 될 예정이나 명시적으로 지정할 수 있습니다.
future: {
compatibilityVersion: 4,
},
// 2. 개발 도구 활성화 (브라우저 하단에 강력한 DevTools가 생성됨)
devtools: { enabled: true },
// 3. 환경 변수 관리 (프라이빗 토큰과 퍼블릭 설정 분리)
runtimeConfig: {
// 여기 적힌 값은 서버(SSR) 환경에서만 접근 가능합니다. (보안 유지)
apiSecretKey: process.env.NUXT_API_SECRET_KEY || 'default-secret',
// public 안에 적힌 값은 클라이언트(브라우저)에서도 접근 가능합니다.
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
}
}
})Nuxt 3까지는 프로젝트 루트 공간에 모든 소스 폴더가 혼재되어 있었습니다. Nuxt 4에서는 소스 코드의 경계를 명확히 하기 위해 프론트엔드 관련 파일은 app/ 디렉토리 내부로 분리하고, 서버 관련 파일은 server/로 완전히 나누는 혁신적인 구조 변경을 단행했습니다.
app/ 디렉토리 도입: 프론트엔드 코드(components, pages, layouts 등)가 모두 이 폴더 아래로 이동했습니다. 더 이상 루트 폴더가 혼잡해지지 않습니다.server/ 와의 완벽한 분리: 풀스택 프레임워크인 만큼 server/ 폴더에서 API 라우트를 만들 수 있는데, 이제 클라이언트 코드(app/)와 서버 코드가 물리적으로 깔끔하게 분리되었습니다.public/ 디렉토리: 정적 파일(이미지, 폰트 등)을 담는 곳으로, 루트 경로부터 바로 접근 가능한 에셋들만 보관합니다.<template>
<!--
Nuxt 4에서는 src/app.vue 나 루트의 app.vue 가 아닌,
'app/app.vue' 가 최상단 메인 파일이 됩니다.
-->
<div>
<!-- 전역적으로 적용될 공통 요소를 여기에 넣을 수 있습니다. -->
<NuxtRouteAnnouncer />
<!--
<NuxtLayout>은 app/layouts 폴더의 공통 껍데기를 적용합니다.
<NuxtPage>는 app/pages 폴더의 개별 화면을 끼워 넣습니다.
-->
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>기존 Vue에서는 복잡한 vue-router 설정 파일을 수동으로 작성해야 했습니다. 반면 Nuxt에서는 app/pages/ 디렉토리에 Vue 파일을 만들기만 하면, 파일 구조가 즉시 브라우저의 URL 주소로 자동 변환됩니다.
<template>
<div class="min-h-screen bg-gray-50 text-gray-900">
<header class="bg-indigo-600 text-white p-4 shadow-md flex gap-4">
<!-- <a> 태그 대신 NuxtLink를 사용하여 페이지 전환 시 깜빡임을 방지합니다. -->
<NuxtLink to="/" class="font-bold hover:text-indigo-200 transition">홈</NuxtLink>
<NuxtLink to="/about" class="font-bold hover:text-indigo-200 transition">소개</NuxtLink>
<NuxtLink to="/users/999" class="font-bold text-yellow-300 hover:text-yellow-100 transition">유저 정보 (동적)</NuxtLink>
</header>
<main class="p-8 max-w-4xl mx-auto">
<!-- pages 폴더 안의 각 화면이 여기에 쏙 들어옵니다! -->
<slot />
</main>
</div>
</template>Nuxt 4에서는 정적 파일을 다루는 방법이 두 가지로 나뉩니다. 브라우저가 직접 접근해야 하는 파일(예: favicon.ico, robots.txt)은 public/ 폴더에 넣고, 빌드 과정(Sass 컴파일, JS 최소화 등)이 필요한 파일은 app/assets/ 폴더에 넣습니다.
서버 사이드 렌더링(SSR) 환경에서 일반적인 fetch 함수를 쓰면, 서버에서 한 번 데이터를 부르고 클라이언트 브라우저가 다시 똑같은 데이터를 부르는 이중 호출(Double Fetch) 문제가 발생합니다.
Nuxt 4에서는 이를 막기 위해 useFetch라는 강력한 내장 함수를 제공하여, 서버에서 가져온 데이터를 HTML과 함께 묶어서 클라이언트로 배달해 줍니다.
<!-- ==========================================
// 📂 app/pages/movies.vue (데이터 패칭 예제)
// ========================================== -->
<template>
<div class="p-6">
<h1 class="text-2xl font-bold mb-4">인기 영화 목록</h1>
<!-- 데이터가 로딩 중일 때 보여줄 스켈레톤 UI -->
<div v-if="status === 'pending'" class="text-blue-500 font-bold">
데이터를 불러오는 중입니다...
</div>
<!-- 에러가 발생했을 때 보여줄 에러 메시지 -->
<div v-else-if="error" class="text-red-500 font-bold">
이런! 데이터를 불러오지 못했습니다.
</div>
<!-- 로딩이 끝나면 화면에 리스트를 렌더링 -->
<ul v-else class="grid grid-cols-1 gap-4">
<!-- data 변수에 API 응답값이 담겨있습니다. -->
<li
v-for="movie in data"
:key="movie.id"
class="bg-white p-4 shadow rounded"
>
<h2 class="text-xl font-bold">{{ movie.title }}</h2>
<p class="text-gray-600">{{ movie.overview }}</p>
</li>
</ul>
</div>
</template>
<script setup>
// useFetch는 API 주소에 요청을 보내고,
// 그 결과를 반응형(reactive) 변수 묶음으로 돌려줍니다.
const { data, status, error } = await useFetch('https://api.example.com/movies', {
// 캐시 효율을 위한 키(key) 지정 가능
key: 'popular-movies',
// 서버 응답에서 필요한 데이터만 골라오기 (Pick)
// pick: ['id', 'title', 'overview']
})
</script>
React나 기본 Vue.js 환경에서 상태(State)를 관리할 때는 보통 useState나 ref를 사용합니다. 하지만 SSR(Server-Side Rendering) 프레임워크인 Nuxt.js에서는, 서버에서 한 번 만들어진 상태가 클라이언트 브라우저로 넘어갔을 때 초기화되지 않고 그대로 이어받는(Hydration) 특별한 메커니즘이 필요합니다. 이를 위해 Nuxt 3는 고유의 useState() 컴포저블을 제공하며, 이는 서버에서 생성된 상태를 페이로드(Payload) 형태로 직렬화하여 클라이언트에 완벽하게 전달해 줍니다.
만약 클라이언트에서만 상태가 발생하거나(예: 모달 팝업 열기/닫기), 데이터 페칭 없이 단순 UI 상태만 제어한다면 ref()를 써도 무방합니다. 하지만 서버에서 페칭한 API 데이터를 클라이언트에서 재사용해야 하거나, SSR 과정 중 변경된 상태를 브라우저에 그대로 넘겨주어야 한다면 반드시 useState()를 사용해야 중복 API 호출(Double Fetching)을 막을 수 있습니다.
Nuxt.js의 가장 강력한 무기 중 하나는 바로 SSR을 통한 완벽한 SEO(검색 엔진 최적화) 지원입니다. Nuxt 3에서는 useSeoMeta() 컴포저블 하나만으로 오픈그래프(OG), 트위터 카드, 타이틀 등 거의 모든 메타 태그를 매우 직관적으로 주입할 수 있습니다.
| 속성명 (Property) | 설명 및 SEO 기여도 | 예시 (코드) |
|---|---|---|
| title | 브라우저 탭 및 구글 검색 결과에 표시되는 가장 중요한 제목입니다. | title: 'Minstudio' |
| description | 구글 검색 결과 제목 아래에 표시되는 요약 설명입니다. (클릭률(CTR)에 결정적 영향) | description: '프론트엔드 포트폴리오' |
| ogTitle / ogDescription | 카카오톡, 페이스북, 슬랙 등에 링크를 공유할 때 나타나는 오픈그래프(OG) 카드 정보입니다. | ogImage: 'https://.../img.png' |
| twitterCard | 트위터(X) 공유 시 카드의 형태를 결정합니다. 보통 summary_large_image를 씁니다. |
twitterCard: 'summary_large_image' |
<div class="nuxt-demo">
<div class="demo-header">
<h3>🔄 Nuxt 3 Hydration & useState 시뮬레이터</h3>
<p>서버에서 <code>useState</code>로 초기화한 데이터가 클라이언트로 전달되어(Serialization), 클라이언트 렌더링 시 중복 페칭 없이 재사용되는 <strong>Hydration</strong> 과정을 콘솔에서 확인해 보세요.</p>
</div>
<div class="demo-columns">
<div class="demo-panel">
<div class="panel-header">
<i class="ph-duotone ph-server" style="color:#00DC82;"></i> 서버 & 클라이언트 라이프사이클
</div>
<div class="code-preview">
<code>const counter = useState('counter', () => 0)</code><br>
<code>useSeoMeta({ title: 'My Nuxt App' })</code>
</div>
<button id="btnRun387" class="btn primary">
<i class="ph-duotone ph-play"></i> 페이지 요청 (새로고침) 시뮬레이션
</button>
<div id="hydration-status" class="status-box" style="margin-top: 1rem; padding: 0.8rem; background: rgba(0, 0, 0, 0.3); border-radius: 6px; font-family: monospace; font-size: 0.85rem; color: #94a3b8;">
대기 중...
</div>
</div>
</div>
</div>Nuxt 4에서는 존재하지 않는 페이지를 방문(404)하거나 서버에서 오류(500)가 났을 때 보여줄 전역 에러 화면을 app/error.vue 파일 하나로 완벽하게 통제할 수 있습니다.
또한 페이지 간 부드러운 애니메이션을 적용하고 싶다면 app.vue의 <NuxtPage>에 트랜지션 속성을 부여하기만 하면 됩니다.
<!-- ==========================================
// 📂 app/error.vue (전역 에러 처리 컴포넌트)
// ========================================== -->
<template>
<div class="h-screen flex flex-col items-center justify-center bg-gray-50 text-center">
<!-- 에러 정보 객체(error)를 자동으로 전달받습니다. -->
<h1 class="text-9xl font-bold text-red-500">{{ error.statusCode }}</h1>
<p class="text-2xl mt-4 font-semibold text-gray-800">
{{ error.statusCode === 404 ? '페이지를 찾을 수 없습니다.' : '서버에 문제가 발생했습니다.' }}
</p>
<p class="text-gray-500 mt-2">{{ error.message }}</p>
<!-- 에러 상태를 초기화하고 홈으로 돌아가는 버튼 -->
<button @click="handleError" class="mt-8 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700">
홈으로 돌아가기
</button>
</div>
</template>
<script setup>
// 이 페이지는 Nuxt가 에러 발생 시 자동으로 렌더링하며 error props를 내려줍니다.
const props = defineProps({
error: Object
})
const handleError = () => {
// clearError 훅을 실행하면 현재 에러 상태를 지우고, 지정된 경로로 리다이렉트합니다.
clearError({ redirect: '/' })
}
</script>Nuxt의 진정한 마법은 백엔드를 따로 구축할 필요가 없다는 것입니다! 내장된 Nitro(니트로) 엔진 덕분에, server/api/ 폴더에 파일을 만들기만 하면 즉시 무적의 백엔드 API 서버가 완성됩니다.
배포 시 npm run build를 실행하면, 프론트엔드 코드와 Nitro 백엔드 코드가 가볍고 완벽하게 압축된 단 하나의 .output 폴더로 묶여 나옵니다.
프론트엔드와 백엔드가 한 프로젝트 안에서 어떻게 통신하는지 테스트해 보세요.
Nuxt의 가장 큰 마법 중 하나는 Auto-imports(자동 임포트)입니다. composables/와 utils/ 폴더에 파일을 생성하고 함수를 export하기만 하면, 애플리케이션 어디에서든 import 문 없이 즉시 사용할 수 있습니다.
이 기능은 Vue의 ref, computed 같은 내장 함수뿐만 아니라 여러분이 직접 만든 커스텀 함수에도 동일하게 적용되어, 코드 상단의 지저분한 import 선언문들을 완전히 제거해 줍니다.
<!-- ==========================================
// 📂 app.vue (최상위 컴포넌트)
// ========================================== -->
<template>
<div class="p-8 bg-slate-900 min-h-screen text-white">
<h1 class="text-2xl font-bold mb-4">마우스 위치 추적기</h1>
<!-- useMouse에서 반환된 반응형 상태를 바로 바인딩 -->
<div class="bg-slate-800 p-6 rounded-xl border border-slate-700 font-mono">
<p class="text-xl mb-2 text-slate-300">X: <span class="text-emerald-400 font-black">{{ x }}</span> px</p>
<p class="text-xl text-slate-300">Y: <span class="text-emerald-400 font-black">{{ y }}</span> px</p>
</div>
</div>
</template>
<script setup>
// 💡 마법: import { ref } from 'vue' 도 없고,
// import { useMouse } from '~/composables/useMouse' 도 없습니다!
// Nuxt가 빌드 시점에 자동으로 추적하여 연결해 줍니다.
const { x, y } = useMouse()
</script>Nuxt에서 제공하는 useState는 SSR 환경에서 서버와 클라이언트 간의 상태를 직렬화하여 Hydration 에러를 막아주는 가벼운 상태 관리 도구입니다. 하지만 복잡한 비즈니스 로직(Actions)이나 파생 상태(Getters)가 필요할 때는 Pinia 모듈을 도입하는 것이 실무 표준입니다.
| 도구 | 주요 목적 | 적합한 사용 사례 |
|---|---|---|
| useState | SSR 친화적인 가벼운 전역 상태 공유 | 다크모드 토글, 단순한 토스트 메시지 상태, 모달 열림 여부 |
| Pinia | 복잡한 로직과 구조화된 스토어 관리 | 로그인된 유저 정보, 쇼핑몰 장바구니 관리, 대규모 데이터 캐싱 |
<div class="app-container">
<h3>상태 관리 데모</h3>
<div class="card useState-card">
<h4>[useState] 테마 상태</h4>
<p>현재 테마: <strong id="themeText">Light</strong></p>
<button id="toggleThemeBtn" class="btn btn-blue">테마 토글</button>
</div>
<div class="card pinia-card">
<h4>[Pinia] 장바구니 스토어</h4>
<p>상품 수량: <strong id="cartCount">0</strong>개</p>
<div class="btn-group">
<button id="addCartBtn" class="btn btn-green">추가 (+1)</button>
<button id="resetCartBtn" class="btn btn-red">초기화</button>
</div>
</div>
</div>Nuxt는 단순한 SSR 프레임워크가 아닙니다. 하이브리드 렌더링(Hybrid Rendering)을 통해 nuxt.config.ts의 routeRules 옵션 하나만으로 경로(URL)마다 완전히 다른 렌더링 엔진을 가동할 수 있습니다.
실시간으로 변하는 메인 화면은 SSR로, SEO가 중요하지 않고 반응성이 생명인 어드민 패널은 SPA 모드로, 한번 작성되면 잘 바뀌지 않는 블로그 포스트는 빌드 시 정적 생성(Prerender)으로, 성능을 극대화하고 서버 비용을 절약하세요.
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<div id="nuxt-app" class="bg-slate-900 min-h-[500px] font-sans flex flex-col relative overflow-hidden rounded-xl shadow-2xl border border-slate-700">
<div class="absolute -top-4 -left-4 bg-sky-500 text-white text-xs font-bold px-8 py-2 rounded-full shadow-lg transform -rotate-12 z-50">
🎛️ Hybrid Rendering Simulator
</div>
<header class="bg-gradient-to-r from-slate-800 to-slate-900 text-white p-6 shadow-md flex justify-between items-center border-b border-slate-700">
<div>
<div class="font-black text-2xl tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-sky-400 to-blue-500">RouteRules Configurator</div>
<p class="text-xs text-slate-400 mt-1">경로별로 어떤 렌더링 엔진이 할당되는지 시뮬레이션 해보세요.</p>
</div>
</header>
<main class="w-full flex-grow relative flex bg-slate-950 p-6 gap-6">
<!-- Sidebar: URL 입력창 -->
<div class="w-1/3 flex flex-col gap-4">
<div class="bg-slate-800 p-5 rounded-xl border border-slate-700">
<h3 class="text-white font-bold mb-3 flex items-center gap-2">
<span class="text-xl">🌐</span> URL 접속
</h3>
<div class="space-y-2">
<button @click="testRoute('/')" class="w-full text-left px-4 py-3 rounded bg-slate-900 hover:bg-slate-700 text-slate-300 font-mono text-sm border border-slate-600 transition-colors">
/ (Home)
</button>
<button @click="testRoute('/admin/dashboard')" class="w-full text-left px-4 py-3 rounded bg-slate-900 hover:bg-slate-700 text-slate-300 font-mono text-sm border border-slate-600 transition-colors">
/admin/dashboard
</button>
<button @click="testRoute('/blog/post-1')" class="w-full text-left px-4 py-3 rounded bg-slate-900 hover:bg-slate-700 text-slate-300 font-mono text-sm border border-slate-600 transition-colors">
/blog/post-1
</button>
<button @click="testRoute('/old-page')" class="w-full text-left px-4 py-3 rounded bg-slate-900 hover:bg-slate-700 text-slate-300 font-mono text-sm border border-slate-600 transition-colors">
/old-page
</button>
</div>
</div>
</div>
<!-- Main Content: Rendering Engine Display -->
<div class="w-2/3 flex flex-col">
<div class="flex-grow bg-slate-800/50 rounded-xl border border-sky-500/30 p-6 flex flex-col justify-center items-center relative overflow-hidden transition-all duration-300" :class="activeColor">
<!-- Background Icon -->
<div class="absolute inset-0 flex items-center justify-center opacity-5 text-9xl pointer-events-none">
{{ activeIcon }}
</div>
<div v-if="!currentRoute" class="text-slate-500 text-center relative z-10">
<div class="text-4xl mb-2">👈</div>
<div>좌측에서 접속할 URL을 선택하세요.</div>
</div>
<div v-else class="text-center relative z-10 w-full max-w-sm">
<div class="bg-slate-900/80 p-3 rounded-lg font-mono text-sm text-sky-300 mb-6 border border-slate-700 shadow-inner">
GET {{ currentRoute }}
</div>
<div class="text-6xl mb-4 drop-shadow-xl animate-bounce">{{ activeIcon }}</div>
<h2 class="text-3xl font-black mb-2 tracking-tight text-white drop-shadow-md">{{ activeMode }}</h2>
<p class="text-slate-300 font-medium mb-6">{{ activeDesc }}</p>
<div class="text-left bg-slate-900/80 p-4 rounded-lg font-mono text-xs border border-slate-700 w-full">
<div class="text-slate-500 mb-1">// 매칭된 설정</div>
<div class="text-emerald-400">{{ activeRule }}</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
const checkVue = setInterval(() => {
if (typeof Vue !== 'undefined') {
clearInterval(checkVue);
const { createApp } = Vue;
const App = {
data() {
return {
currentRoute: null,
activeMode: '',
activeDesc: '',
activeRule: '',
activeIcon: '',
activeColor: 'bg-slate-800/50 border-slate-700'
}
},
methods: {
testRoute(path) {
this.currentRoute = path;
console.log(`🌐 RouteRules 엔진: ${path} 접속 시도`);
if (path.startsWith('/admin')) {
this.activeMode = 'SPA Mode';
this.activeDesc = '서버 렌더링 무시. 클라이언트 브라우저에서 즉시 Vue 앱을 마운트하여 빠른 화면 전환을 제공합니다.';
this.activeRule = "'/admin/**': { ssr: false }";
this.activeIcon = '⚡';
this.activeColor = 'border-pink-500/50 shadow-[0_0_30px_rgba(244,114,182,0.15)]';
} else if (path.startsWith('/blog')) {
this.activeMode = 'Prerender Mode';
this.activeDesc = '사전에 빌드된 정적 HTML 파일을 제공합니다. 초고속 로딩과 완벽한 SEO를 보장합니다.';
this.activeRule = "'/blog/**': { prerender: true }";
this.activeIcon = '📄';
this.activeColor = 'border-amber-500/50 shadow-[0_0_30px_rgba(245,158,11,0.15)]';
} else if (path === '/old-page') {
this.activeMode = 'Redirect';
this.activeDesc = '301 상태 코드와 함께 새로운 URL로 즉시 자동 이동시킵니다.';
this.activeRule = "'/old-page': { redirect: '/new-page' }";
this.activeIcon = '↪️';
this.activeColor = 'border-purple-500/50 shadow-[0_0_30px_rgba(168,85,247,0.15)]';
} else {
this.activeMode = 'SSR Mode';
this.activeDesc = '기본 동작 방식입니다. Node.js 서버에서 HTML을 렌더링하여 클라이언트에 전달합니다.';
this.activeRule = "'/': { ssr: true } // Default";
this.activeIcon = '🖥️';
this.activeColor = 'border-emerald-500/50 shadow-[0_0_30px_rgba(16,185,129,0.15)]';
}
}
},
mounted() {
console.log("🚀 Nuxt 4 RouteRules Simulator Initialized!");
}
};
createApp(App).mount('#nuxt-app');
}
}, 100);
setTimeout(() => clearInterval(checkVue), 10000);
</script>Nuxt는 복잡한 Webpack/Vite 설정을 단 한 줄로 끝내주는 방대한 Module 생태계를 보유하고 있습니다. nuxt.config.ts의 modules 배열에 추가하기만 하면 TailwindCSS, Pinia, Nuxt UI 등이 즉시 프로젝트 전체에 세팅됩니다.
또한, 페이지를 이동할 때마다 권한을 검사하는 라우트 미들웨어(Route Middleware)를 제공합니다. middleware/ 폴더에 파일을 작성하면 로그인 여부에 따라 페이지 접근을 완벽히 차단하고 리다이렉트 시킬 수 있습니다.
<!-- ==========================================
// 📂 app/pages/mypage/index.vue (미들웨어 적용)
// ========================================== -->
<template>
<div class="p-8 bg-slate-900 min-h-screen">
<h1 class="text-xl font-bold text-amber-500">비밀 마이페이지 🤫</h1>
</div>
</template>
<script setup>
// 페이지 레벨에서 특정 미들웨어만 선택적으로 실행되도록 지정합니다.
// 이 페이지에 접근하는 순간 위에서 만든 'auth' 미들웨어가 먼저 검사합니다.
definePageMeta({
middleware: ['auth']
})
</script>Nuxt 공식 모듈이 아닌 일반 Vue.js 라이브러리(예: 구글 애널리틱스, 특수 차트 툴, 커스텀 토스트 UI 등)를 사용하려면 plugins/ 폴더를 활용해야 합니다. 파일 이름에 .client.ts 또는 .server.ts를 붙이면 해당 환경에서만 플러그인이 실행되도록 완벽히 제어할 수 있습니다.
플러그인 내부에서 return { provide: { myTool: myFunc } } 형태로 반환하면, 앱 내 어디서든 컴포저블 형태로 $myTool()을 전역 호출할 수 있습니다.
<div class="app-container">
<h3>플러그인 주입 시뮬레이터</h3>
<div class="toast-area">
<button id="showToastBtn" class="btn btn-purple">전역 토스트 띄우기 ($toast)</button>
</div>
<div id="toastContainer" class="toast-container"></div>
</div>모던 웹의 핵심 퍼포먼스 지표(Core Web Vitals)를 개선하기 위해 @nuxt/image 모듈은 필수입니다. 기존 <img> 태그를 <NuxtImg>로 바꾸기만 해도, WebP 자동 변환, 스크롤 반응형 지연 로딩(Lazy Loading), 화면 크기에 맞춘 리사이징을 서버단에서 자동으로 처리해 줍니다.
<div class="app-container">
<h3>Lazy Loading 시뮬레이터</h3>
<div class="scroll-area" id="scrollArea">
<div class="spacer">⬇️ 스크롤을 내려보세요</div>
<!-- NuxtImg 흉내 -->
<div class="image-wrapper">
<img id="lazyImg" class="lazy-image" data-src="https://via.placeholder.com/300x200.webp?text=Optimized+WebP" alt="Lazy 로딩 이미지" />
<div id="loader" class="loader">로딩 대기 중...</div>
</div>
<div class="spacer" style="height: 100px;"></div>
</div>
</div>