React 생태계의 패러다임은 끊임없이 진화해왔습니다. 과거 브라우저에서 모든 것을 그리는 CSR(클라이언트 사이드 렌더링)에서 시작하여, SEO와 초기 로딩 속도 개선을 위해 서버에서 페이지를 미리 그리는 SSR/SSG(서버 사이드 렌더링)를 거쳐, 마침내 Next.js 14+의 App Router라는 혁명적인 컴포넌트 단위 렌더링 모델에 도달했습니다.
Suspense 활용)
Next.js 13부터 도입된 App Router는 디렉토리 구조 기반의 직관적인 라우팅(File-based Routing)과 서버 컴포넌트(React Server Components)를 완벽하게 지원하는 차세대 웹 아키텍처입니다. 폴더 이름이 곧 브라우저 URL 경로가 되며, page.tsx와 같은 특수 파일들을 통해 UI의 레이아웃과 뷰를 명시적으로 분리할 수 있습니다.
Next.js의 라우팅은 엄격한 규칙 하나를 따릅니다. "폴더 이름은 URL 경로가 되고, 그 경로가 화면에 보이려면 반드시 page.tsx 파일이 존재해야 한다."
app/page.tsx ➔ / (메인 페이지)app/dashboard/page.tsx ➔ /dashboardapp/dashboard/settings/page.tsx ➔ /dashboard/settings만약 폴더를 만들었더라도 내부에 page.tsx가 없다면 해당 경로는 브라우저에서 접근할 수 없는 404가 됩니다. 이를 통해 컴포넌트, 스타일, 테스트 파일 등을 안전하게 같은 폴더 내에 배치할 수 있습니다 (Colocation 패턴).
블로그 포스트, 쇼핑몰 상품 상세 페이지처럼 미리 경로를 알 수 없는 경우 대괄호 [folderName]를 사용하여 동적 라우팅을 구현합니다.
수만 개의 상품을 파는 쇼핑몰을 만든다고 상상해 보세요. /product/apple, /product/banana 등 모든 상품의 이름마다 폴더를 일일이 만드는 것은 불가능하겠죠?
이럴 때 /product/[id]라는 빈 상자(변수) 역할을 하는 폴더 하나만 만들어 두면, 사용자가 어떤 주소로 들어오든 Next.js가 [id]라는 상자 안에 사용자가 입력한 값('apple', 'banana' 등)을 쏙 집어넣어서 페이지에게 자동으로 전달해 준답니다!
app/blog/[slug]/page.tsx ➔ /blog/react-guide, /blog/nextjs-tips 매칭slug)은 페이지 컴포넌트의 params 객체로 전달됩니다.const { slug } = await params;
프로젝트 규모가 커지면 연관된 페이지들을 묶어서 관리하고 싶지만, URL 경로가 길어지는 것은 원치 않을 수 있습니다. 이때 소괄호 (folderName)를 사용합니다.
소괄호로 감싼 폴더는 라우팅 구조를 그룹화하는 역할만 하며, 실제 브라우저 URL 경로에는 영향을 주지 않습니다. 주로 공통 레이아웃을 그룹별로 다르게 적용할 때 매우 유용합니다.
app/(auth)/login/page.tsx ➔ /loginapp/(auth)/register/page.tsx ➔ /registerapp/(marketing)/about/page.tsx ➔ /about폴더 안에 배치되는 특정 이름을 가진 파일들은 각자의 고유한 역할을 가집니다.
page.tsx를 감싸는 공통 레이아웃(헤더, 사이드바 등)을 정의합니다. 페이지 이동 시에도 상태가 보존되며 다시 렌더링되지 않습니다.
'use client'여야 함)
<!-- HIDDEN_TAB -->
<div class="product-detail-container">
<div class="product-header">
<span class="badge">Dynamic Route</span>
<h1>상품 상세 페이지</h1>
</div>
<div class="product-info">
<p>조회 요청된 상품 ID:</p>
<div class="id-display">apple-watch-ultra</div>
</div>
</div>
Next.js 13부터 도입된 App Router 환경에서는 기본적으로 모든 컴포넌트가 서버 컴포넌트(React Server Components)입니다. 이 혁신적인 패러다임은 서버와 클라이언트가 각자 가장 잘하는 일에 집중하게 하여, 페이지 로딩 속도를 극대화하고 보안을 강화합니다. 상태(State)나 이벤트(onClick 등)가 필요한 컴포넌트만 명시적으로 "use client"를 선언하여 클라이언트로 분리합니다.
Next.js의 모든 컴포넌트는 아무런 선언을 하지 않으면 기본적으로 서버 컴포넌트로 동작합니다. 말 그대로 브라우저가 아닌 서버에서만 실행되는 컴포넌트입니다.
사용자와의 동적인 상호작용(Interaction)이 필요할 때는 클라이언트 컴포넌트를 사용해야 합니다. 파일의 가장 첫 번째 줄에 "use client"; 라고 적어주면 클라이언트 컴포넌트로 전환됩니다.
onClick), 입력창 변경(onChange) 등 이벤트 리스너가 필요할 때useState, useReducer)나 생명주기 훅(useEffect)이 필요할 때window, document 등 브라우저 전용 API를 써야 할 때주의점: "use client"를 남발하면 Next.js App Router의 성능적 이점을 잃게 됩니다. 전체 페이지를 "use client"로 만들기보다는, 꼭 필요한 버튼이나 폼(Form) 요소만 잘게 쪼개서 클라이언트 컴포넌트로 분리하는 것이 핵심 최적화 기법입니다.
현대적인 Next.js 개발의 핵심은 서버 컴포넌트를 베이스로 깔고, 그 안의 인터랙티브한 작은 조각들을 클라이언트 컴포넌트로 끼워 넣는 것(Colocation)입니다.
✅ 올바른 패턴: <페이지(서버)> 안에서 <좋아요 버튼(클라이언트)> 컴포넌트를 import 해서 사용하기.
❌ 피해야 할 패턴: 클라이언트 컴포넌트 내부에서 서버 컴포넌트를 import 하기. (클라이언트 컴포넌트 하위에 있는 모든 컴포넌트는 자동으로 클라이언트 환경에서 실행되어 버립니다.)
<!-- HIDDEN_TAB -->
<div class="post-container">
<h1 class="post-title">Next.js 14 완벽 가이드</h1>
<div class="post-content">
<p>서버 컴포넌트는 서버에서 미리 렌더링되어 HTML로 전송되므로 초기 로딩이 매우 빠르고 SEO에 유리합니다.</p>
<p>반면, 이벤트 리스너나 상태 관리가 필요한 부분은 클라이언트 컴포넌트로 만들어 트리에 주입합니다.</p>
</div>
<div class="interaction-area">
<button class="like-button" onclick="handleLike(this)">
❤️ 좋아요 <span class="like-count">42</span>
</button>
</div>
</div>
<script>
function handleLike(btn) {
const countSpan = btn.querySelector('.like-count');
let count = parseInt(countSpan.textContent, 10);
countSpan.textContent = count + 1;
btn.style.transform = 'scale(1.1)';
setTimeout(() => btn.style.transform = 'scale(1)', 150);
console.log('[Client Component] State updated: Likes = ' + (count + 1));
}
</script>
Next.js 13+ App Router에서는 복잡한 상태 관리 라이브러리 없이도, 네이티브 fetch() API의 확장을 통해 완벽한 서버 데이터 요청과 캐싱 제어가 가능합니다. force-cache(SSG), no-store(SSR), 그리고 revalidate(ISR) 옵션을 라우트별, 심지어 요청 단위별로 유연하게 섞어서 사용할 수 있어 혁신적인 아키텍처를 제공합니다.
Next.js 14에서는 복잡했던 상태 관리 라이브러리(Redux, React Query 등) 없이도, 네이티브 fetch() API의 확장을 통해 데이터 요청과 캐싱을 완벽하게 제어할 수 있습니다.
Promise.all을 사용하여 클라이언트 측에서 발생하는 병목 현상을 해결할 수 있습니다.fetch 요청이 여러 번 발생해도, Next.js가 자체적으로 1번만 요청하도록 중복 제거(Deduplication) 해줍니다.<!-- HIDDEN_TAB -->
<div class="dashboard">
<h1 class="dash-title">📈 실시간 금융 및 뉴스 대시보드</h1>
<div class="actions">
<button onclick="refreshDashboard()" class="refresh-btn">🔄 전체 새로고침 (페이지 새로고침 시뮬레이션)</button>
</div>
<div class="grid-container">
<div class="card real-time">
<h3>🚀 실시간 주가 (no-store)</h3>
<div class="price" id="stock-price">78,500 원</div>
<p class="caption">매 요청마다 서버에서 <strong>새로운 데이터</strong>를 가져옵니다.</p>
</div>
<div class="card isr-time">
<h3>📰 10초 캐시 뉴스 (revalidate: 10)</h3>
<ul class="news-list">
<li>AI 기술의 혁신적 발전...</li>
<li>글로벌 경제 지표 발표...</li>
</ul>
<div class="time-stamp" id="news-time">마지막 업데이트: 10:00:00</div>
<p class="caption">10초 동안은 수만 명이 새로고침해도 <strong>캐시된 데이터(매우 빠름)</strong>를 보여줍니다.</p>
</div>
</div>
</div>
<script>
let lastFetchTime = Date.now();
let cachedTimeStr = new Date().toLocaleTimeString();
document.getElementById('news-time').textContent = '마지막 업데이트: ' + cachedTimeStr;
function refreshDashboard() {
const now = Date.now();
// 1. no-store (항상 바뀜)
const newPrice = 78000 + Math.floor(Math.random() * 2000);
document.getElementById('stock-price').textContent = newPrice.toLocaleString() + ' 원';
console.log('[SSR] 실시간 주가 API 재요청 됨! (' + newPrice + '원)');
// 2. revalidate: 10 (10초 경과시에만 바뀜)
if (now - lastFetchTime > 10000) {
cachedTimeStr = new Date().toLocaleTimeString();
lastFetchTime = now;
console.warn('[ISR] 10초 경과! 백그라운드에서 뉴스 데이터를 새로 가져옵니다.');
} else {
const remain = Math.ceil((10000 - (now - lastFetchTime)) / 1000);
console.log('[ISR] 캐시 히트! (빠른 응답) 갱신까지 ' + remain + '초 남음');
}
document.getElementById('news-time').textContent = '마지막 업데이트: ' + cachedTimeStr;
}
</script>
App Router에서는 React Suspense와 Error Boundary가 파일 시스템에 기본 내장되어 있습니다. loading.tsx를 추가하면 서버에서 컴포넌트가 완전히 렌더링되기를 기다리지 않고 스켈레톤 UI를 즉시 브라우저로 스트리밍(Streaming)하여 체감 로딩 시간을 극단적으로 줄입니다. 또한 error.tsx를 통해 특정 영역에서 에러가 나더라도 전체 앱이 멈추지 않고, 에러 발생 영역만 교체한 뒤 reset() 함수로 복구를 시도할 수 있습니다.
loading.tsx를 추가하면 서버 컴포넌트가 데이터를 패치하는 동안 스켈레톤 UI를 스트리밍(Streaming)하여 사용자 경험(UX)을 극대화합니다. error.tsx는 특정 컴포넌트 트리에 에러가 발생했을 때 전체 페이지가 다운되는 것을 막고, 해당 부분만 에러 UI로 대체하며 복구(Recover) 버튼을 제공할 수 있습니다.
<!-- HIDDEN_TAB -->
<div class="app-container">
<nav class="top-nav">
<div class="nav-item">홈</div>
<div class="nav-item active">대시보드</div>
<div class="nav-item">설정</div>
</nav>
<main class="main-content">
<div class="header-row">
<h2>📊 대시보드 데이터</h2>
<button onclick="simulateLoad()" class="action-btn">🔄 컴포넌트 다시 로드하기</button>
</div>
<div class="component-wrapper">
<!-- 로딩 상태 (loading.tsx 역할) -->
<div id="loading-ui" class="loading-skeleton">
<div class="skeleton title"></div>
<div class="skeleton-grid">
<div class="skeleton block"></div>
<div class="skeleton block"></div>
</div>
</div>
<!-- 성공 상태 (page.tsx 역할) -->
<div id="success-ui" class="success-ui hidden">
<div class="success-header">
<h3>방문자 통계</h3>
<span class="badge">✅ 로드 성공</span>
</div>
<div class="stats-grid">
<div class="stat-box">오늘 방문자<br/><strong>1,420명</strong></div>
<div class="stat-box">어제 방문자<br/><strong>1,300명</strong></div>
</div>
</div>
<!-- 에러 상태 (error.tsx 역할) -->
<div id="error-ui" class="error-ui hidden">
<div class="error-icon">⚠️</div>
<h3 class="error-title">데이터를 불러오는 중 문제가 발생했습니다!</h3>
<p class="error-desc">서버와 연결이 끊어졌거나 API 에러가 발생했습니다.</p>
<button onclick="simulateLoad()" class="retry-btn">다시 시도하기 (reset)</button>
</div>
</div>
</main>
</div>
<script>
function simulateLoad() {
const loadingUI = document.getElementById('loading-ui');
const successUI = document.getElementById('success-ui');
const errorUI = document.getElementById('error-ui');
// 1. Show Loading
loadingUI.classList.remove('hidden');
successUI.classList.add('hidden');
errorUI.classList.add('hidden');
console.log('[Streaming] React Suspense가 loading.tsx의 스켈레톤을 즉시 보여줍니다...');
// 2. Mock 2초 뒤 결과 (50% 확률로 성공/실패)
setTimeout(() => {
loadingUI.classList.add('hidden');
const isError = Math.random() < 0.5;
if (isError) {
errorUI.classList.remove('hidden');
console.error('[Error Boundary] 에러 발생! error.tsx 컴포넌트로 화면을 교체합니다.');
} else {
successUI.classList.remove('hidden');
console.log('[Loaded] 서버 데이터를 성공적으로 가져왔습니다! page.tsx 렌더링 완료.');
}
}, 2000);
}
// 초기 실행
simulateLoad();
</script>
상품 상세 페이지나 블로그 포스팅처럼 ID별로 동적으로 변하는 페이지를 만들 때는 [folderName] 문법을 사용하여 동적 라우팅을 구현합니다. 나아가, Next.js의 강력한 generateMetadata 함수를 사용하면 컴포넌트가 렌더링되기 전에 각 페이지(ID)에 맞는 검색 엔진(SEO) 최적화 메타 태그와 Open Graph(카톡 공유 썸네일 등)를 서버에서 완벽하게 생성하여 주입할 수 있습니다.
상품 상세 페이지나 블로그 포스팅처럼 ID별로 동적으로 변하는 페이지를 만들 때는 [folderName] 문법을 사용합니다. 나아가, 각 페이지마다 검색 엔진(SEO) 최적화 메타 태그를 동적으로 부여하여 검색 노출을 극대화할 수 있습니다.
과거 CSR 방식에서는 자바스크립트가 로딩된 후에야 문서 제목(Title)을 바꿀 수 있어, 검색 엔진이나 카카오톡 공유 시 제목과 이미지가 제대로 나오지 않는 문제가 있었습니다. Next.js에서는 generateMetadata 함수를 통해 서버 측에서 완벽하게 <meta> 태그를 생성하여 클라이언트로 쏴줍니다.
<!-- HIDDEN_TAB -->
<div class="browser-mockup">
<!-- Browser URL Bar -->
<div class="browser-header">
<div class="browser-dots">
<span></span><span></span><span></span>
</div>
<div class="url-bar">
https://minstudio.com/products/ <input type="text" id="productIdInput" value="77" class="url-input" />
<button onclick="navigateRoute()" class="nav-btn">이동 (Enter)</button>
</div>
</div>
<!-- SEO Head Viewer -->
<div class="head-viewer">
<div class="viewer-label">생성된 <head> 메타 태그 (SEO)</div>
<pre id="head-output" class="head-code"></pre>
</div>
<!-- Rendered Page Content -->
<div class="page-content">
<div class="breadcrumb">홈 > 상품 > <span id="page-id">77</span></div>
<div class="product-grid">
<div id="product-img" class="product-img"></div>
<div class="product-info">
<h1 id="product-title">최고급 게이밍 마우스</h1>
<p id="product-desc">반응 속도가 매우 빠른 무선 마우스입니다.</p>
<h2 id="product-price" class="price">159,000원</h2>
<button class="buy-btn">장바구니 담기</button>
</div>
</div>
</div>
</div>
<script>
const mockDB = {
'77': {
name: '최고급 게이밍 마우스',
desc: '반응 속도가 매우 빠른 무선 마우스입니다.',
price: '159,000',
color: '#3b82f6'
},
'99': {
name: '인체공학 기계식 키보드',
desc: '장시간 타이핑해도 손목이 편안한 키보드',
price: '280,000',
color: '#10b981'
},
'123': {
name: '4K 울트라와이드 모니터',
desc: '생생한 화질과 압도적인 몰입감',
price: '890,000',
color: '#8b5cf6'
}
};
function navigateRoute() {
const id = document.getElementById('productIdInput').value.trim();
const product = mockDB[id] || {
name: '알 수 없는 상품',
desc: '요청하신 상품을 찾을 수 없습니다.',
price: '0',
color: '#94a3b8'
};
// 1. Render Head (generateMetadata)
const headHtml = `<title>${product.name} 구매하기 | Minstudio Store</title>
<meta name="description" content="${product.desc}">
<meta property="og:title" content="${product.name}">
<meta property="og:image" content="https://minstudio.com/images/${id}.jpg">`;
document.getElementById('head-output').innerHTML = headHtml;
console.log(`[SEO] generateMetadata() 실행됨! (id: ${id})`);
// 2. Render Page
document.getElementById('page-id').textContent = id;
document.getElementById('product-title').textContent = product.name;
document.getElementById('product-desc').textContent = product.desc;
document.getElementById('product-price').textContent = product.price + '원';
document.getElementById('product-img').style.background = product.color;
console.log(`[Render] ProductDetailPage 컴포넌트 렌더링 완료!`);
}
document.getElementById('productIdInput').addEventListener('keypress', function(e) {
if(e.key === 'Enter') navigateRoute();
});
// Initial Load
navigateRoute();
</script>
Next.js는 성능 저하의 주범인 무거운 이미지 다운로드와 느린 페이지 이동 문제를 완전히 해결하기 위해 <Image> 컴포넌트와 <Link> 컴포넌트를 기본 제공합니다. 이들을 사용하면 브라우저에 맞게 이미지가 자동 압축(WebP 변환)되고, 화면 밖의 이미지는 지연 로딩(Lazy Loading)됩니다. 또한 링크가 시야에 들어오거나 마우스가 올라가면 백그라운드에서 페이지 리소스를 미리 가져와(Prefetching) 번개처럼 빠른 화면 전환을 체감할 수 있습니다.
Next.js는 웹 사이트의 성능을 저하시키는 주된 원인인 무거운 이미지 리소스와 느린 페이지 이동 문제를 해결하기 위해, 매우 강력하게 최적화된 내장 컴포넌트를 제공합니다. 초보자도 기존 HTML 태그 대신 Next.js의 컴포넌트로 바꿔 쓰기만 하면 즉각적인 성능 향상을 경험할 수 있습니다.
<Image> 컴포넌트기존 HTML의 <img> 태그는 원본 용량 그대로 다운로드되어 화면 로딩을 지연시키고, 이미지가 뒤늦게 뜨면서 화면이 덜컹거리는 현상(CLS: Layout Shift)을 유발합니다. Next.js의 <Image> 컴포넌트는 이를 자동으로 완벽하게 해결합니다.
width와 height 속성을 명시하게 강제하여, 이미지가 다운로드되기 전부터 브라우저에 빈 공간을 확보해 둡니다. 덕분에 이미지가 뜬다고 텍스트가 팍 밀리는 현상이 사라집니다.<Link> 컴포넌트일반적인 HTML의 <a> 태그는 클릭 시 브라우저가 전체 페이지를 하얗게 지우고 다시 로딩하는 '새로고침'을 발생시킵니다. 반면 Next.js의 <Link> 컴포넌트는 단일 페이지 애플리케이션(SPA)처럼 부드럽고 찰나의 순간에 페이지를 이동시킵니다.
<Link> 컴포넌트가 노출되는 순간, Next.js는 똑똑하게 백그라운드에서 해당 연결 페이지의 데이터를 미리 슬쩍 가져옵니다. 그래서 사용자가 클릭했을 때 기다림 없이 즉시 페이지가 열리게 됩니다.<a> 대신 <Link>를, 일반 이미지 태그 대신 <Image>를 사용하는 것이 성능 최적화를 위한 가장 기본적이고 훌륭한 첫걸음입니다!
<!-- HIDDEN_TAB -->
<div class="app-container">
<!-- Header Nav with Link Prefetching -->
<nav class="top-nav">
<div class="logo">Minstudio</div>
<div class="nav-links">
<a href="#" class="nav-link" onmouseenter="simulatePrefetch('/about')">회사소개</a>
<a href="#" class="nav-link" onmouseenter="simulatePrefetch('/products')">제품안내</a>
<a href="#" class="nav-link" onmouseenter="simulatePrefetch('/blog')">기술블로그</a>
</div>
</nav>
<main class="main-content">
<div class="content-header">
<h2>이미지 최적화 데모</h2>
<button onclick="resetImages()" class="action-btn">🔄 스크롤 및 이미지 초기화</button>
</div>
<p class="desc-text">스크롤을 내려보세요! 이미지가 화면에 보일 때만 (Lazy Loading) 다운로드되며, 로딩 전에는 흐린 잔상(Blur)이 표시됩니다.</p>
<!-- Scroll container -->
<div class="scroll-area" id="scrollArea">
<div class="spacer">↓ 아래로 스크롤하세요 ↓</div>
<div class="image-card" id="img-card-1">
<div class="next-image-wrapper">
<div class="blur-placeholder" id="blur-1"></div>
<img src="https://images.unsplash.com/photo-1506744626753-1fa28f6f53cb?w=600&h=400&fit=crop" class="real-image" id="real-1" alt="풍경 1" />
</div>
<div class="img-caption">최적화된 이미지 1 (WebP, Lazy Load)</div>
</div>
<div class="spacer">↓ 더 스크롤하세요 ↓</div>
<div class="image-card" id="img-card-2">
<div class="next-image-wrapper">
<div class="blur-placeholder" id="blur-2" style="background-color: #d1d5db;"></div>
<img src="https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=600&h=400&fit=crop" class="real-image" id="real-2" alt="풍경 2" />
</div>
<div class="img-caption">최적화된 이미지 2 (WebP, Lazy Load)</div>
</div>
</div>
</main>
</div>
<script>
// 1. Link Prefetching Mock
const prefetched = new Set();
function simulatePrefetch(route) {
if (!prefetched.has(route)) {
console.log(`[Next.js <Link>] 사용자가 마우스를 올렸습니다! '${route}' 페이지에 필요한 JS/데이터를 백그라운드에서 미리 다운로드합니다 (Prefetching)...`);
prefetched.add(route);
}
}
// 2. Image Lazy Loading Mock using IntersectionObserver
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const cardId = entry.target.id;
const num = cardId.split('-')[2];
const blurEl = document.getElementById('blur-' + num);
const realImg = document.getElementById('real-' + num);
if (!realImg.classList.contains('loaded')) {
console.log(`[Next.js <Image>] 이미지 ${num}번이 시야에 들어왔습니다! Lazy Loading을 시작합니다...`);
// Simulate network delay
setTimeout(() => {
blurEl.style.opacity = '0';
realImg.classList.add('loaded');
console.log(`[Next.js <Image>] 이미지 ${num}번 다운로드 완료! WebP 변환 및 사이즈 압축 적용됨.`);
}, 1500);
}
}
});
}, { threshold: 0.5 });
function setupImages() {
observer.observe(document.getElementById('img-card-1'));
observer.observe(document.getElementById('img-card-2'));
}
function resetImages() {
document.getElementById('scrollArea').scrollTop = 0;
[1, 2].forEach(num => {
document.getElementById('blur-' + num).style.opacity = '1';
document.getElementById('real-' + num).classList.remove('loaded');
});
prefetched.clear();
console.log('--- 상태가 초기화되었습니다 ---');
}
// Initialize
setTimeout(setupImages, 500);
</script>
웹 개발에서 폼(Form) 데이터를 처리하려면 별도의 API(엔드포인트)를 만들고, 프론트엔드에서 fetch로 호출하는 귀찮은 과정이 필수였습니다. Next.js 14의 서버 액션(Server Actions)은 이 중간 과정을 완벽하게 삭제합니다. "use server" 지시어가 붙은 비동기 함수를 폼의 action 속성에 직접 넘기기만 하면, 브라우저가 알아서 백엔드 함수를 직접 호출하며 즉시 데이터베이스를 조작(Mutation)할 수 있습니다!
웹 개발에서 가장 귀찮은 작업 중 하나는 폼(Form) 데이터를 전송하기 위해 별도의 API(백엔드 엔드포인트)를 만들고, 프론트엔드에서 fetch로 호출하는 과정입니다. Next.js 14의 서버 액션(Server Actions)은 이 번거로운 과정을 완벽하게 지워버립니다.
/api/submit 같은 별도의 라우트를 만들 필요 없이, 컴포넌트 내부에서 함수 하나만 만들면 끝납니다."use server" 디렉티브: 함수의 최상단에 이 마법의 단어를 적으면, Next.js가 알아서 이 함수를 서버에서만 실행되는 보안 통신(RPC)으로 변환해 줍니다.revalidatePath('/') 한 줄이면 최신 데이터를 즉시 불러옵니다.<!-- HIDDEN_TAB -->
<div class="app-container">
<div class="mock-browser">
<div class="browser-header">
<div class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
<div class="url-bar">localhost:3000/posts/new</div>
</div>
<div class="browser-content">
<!-- Server Action Form UI -->
<div class="form-card">
<h2 class="form-title">새 글 작성 (Server Action Demo)</h2>
<p class="form-desc">기존 방식(fetch API) 없이, 제출 버튼을 누르면 서버(DB)로 데이터가 즉시 직행합니다!</p>
<form id="serverActionForm">
<div class="input-group">
<label>제목</label>
<input type="text" name="title" id="postTitle" placeholder="포스트 제목을 입력하세요" required />
</div>
<div class="input-group">
<label>내용</label>
<textarea name="content" id="postContent" placeholder="포스트 내용을 입력하세요" required></textarea>
</div>
<button type="submit" class="submit-btn" id="submitBtn">
<span class="btn-text">서버로 데이터 저장하기</span>
<span class="loader" id="btnLoader" style="display:none;"></span>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.getElementById('serverActionForm').addEventListener('submit', function(e) {
e.preventDefault();
const title = document.getElementById('postTitle').value;
const content = document.getElementById('postContent').value;
const btnText = document.querySelector('.btn-text');
const loader = document.getElementById('btnLoader');
const btn = document.getElementById('submitBtn');
// Loading UI
btn.disabled = true;
btn.style.opacity = '0.7';
btnText.innerText = '서버 함수 실행 중...';
loader.style.display = 'inline-block';
console.log(`[Next.js Client] 폼이 제출되었습니다. 'createPost' 서버 액션을 직접 호출합니다.`);
// Simulate Server Action delay (No explicit API fetch needed in real Next.js!)
setTimeout(() => {
console.log(`--- [Next.js SERVER 환경 진입] ---`);
console.log(`["use server"] formData 파싱 중... 제목: "${title}"`);
console.log(`[Database] db.post.create({ title: "${title}", content: "${content}" }) 실행 성공! ✅`);
console.log(`[Next.js Cache] revalidatePath('/posts') 실행 -> '/posts' 라우트의 캐시를 무효화하고 최신화합니다.`);
console.log(`--- [Next.js CLIENT 환경 복귀] ---`);
// Reset UI
btn.disabled = false;
btn.style.opacity = '1';
btnText.innerText = '저장 완료! (폼 리셋)';
loader.style.display = 'none';
btn.style.backgroundColor = '#10b981';
document.getElementById('serverActionForm').reset();
setTimeout(() => {
btnText.innerText = '서버로 데이터 저장하기';
btn.style.backgroundColor = '#2563eb';
}, 2000);
}, 1500);
});
</script>
서버 액션(Server Actions)이 폼 전송에 특화되어 있다면, 모바일 앱(iOS/Android)이나 외부 서비스에 순수한 JSON 데이터(REST API)를 제공해야 할 때는 어떻게 할까요? 이때 사용하는 것이 바로 라우트 핸들러(Route Handlers)입니다. app/api/.../route.ts 파일을 만들고 GET, POST 함수를 정의하기만 하면, Next.js 애플리케이션 자체가 강력한 풀스택 백엔드 API 서버로 변신합니다!
서버 액션(Server Actions)이 폼 전송에 특화되어 있다면, 모바일 앱이나 외부 서비스에 순수한 JSON 데이터(REST API)를 제공해야 할 때는 어떻게 할까요? 이때 사용하는 것이 바로 라우트 핸들러(Route Handlers)입니다.
route.ts: 폴더 구조 기반 라우팅에서, page.tsx가 UI를 담당한다면 route.ts는 백엔드 API를 담당합니다. (같은 폴더에 두 개를 같이 둘 수 없습니다.)GET, POST, PUT, DELETE 등의 이름으로 함수를 export 하면 Next.js가 해당 메서드의 요청을 자동으로 매칭해 줍니다.Request와 Response 객체(Next.js 확장은 NextRequest, NextResponse)를 사용하여 매우 직관적입니다.<!-- HIDDEN_TAB -->
<div class="app-container">
<div class="api-client">
<div class="client-header">
<h2 class="title">REST API 테스트 클라이언트 (Mock)</h2>
</div>
<div class="request-panel">
<!-- GET Request -->
<div class="api-row">
<div class="method get">GET</div>
<div class="endpoint">/api/users</div>
<button class="send-btn" onclick="mockGetRequest()">Send</button>
</div>
<!-- POST Request -->
<div class="api-row">
<div class="method post">POST</div>
<div class="endpoint">/api/users</div>
<button class="send-btn" onclick="mockPostRequest()">Send</button>
</div>
<div class="payload-box">
<div class="payload-label">Request Body (JSON):</div>
<pre>{ "name": "새로운 멤버", "role": "developer" }</pre>
</div>
</div>
</div>
</div>
<script>
function mockGetRequest() {
console.log(`[Frontend] GET /api/users 요청을 보냅니다...`);
setTimeout(() => {
console.log(`[Route Handler] GET 함수 실행됨. 데이터베이스 조회 중...`);
const response = [
{ id: 1, name: '김민수', role: 'admin' },
{ id: 2, name: '이영희', role: 'user' }
];
console.log(`[Frontend] 응답 수신 (Status: 200 OK) 👇\n` + JSON.stringify(response, null, 2));
}, 800);
}
function mockPostRequest() {
const payload = { name: "새로운 멤버", role: "developer" };
console.log(`[Frontend] POST /api/users 요청을 보냅니다. Body:`, payload);
setTimeout(() => {
console.log(`[Route Handler] POST 함수 실행됨. Request Body 파싱 중...`);
const newUser = { id: 3, ...payload };
console.log(`[Database] Insert 완료! 새 유저:`, newUser);
const response = { message: '유저가 성공적으로 생성되었습니다.', user: newUser };
console.log(`[Frontend] 응답 수신 (Status: 201 Created) 👇\n` + JSON.stringify(response, null, 2));
}, 1200);
}
</script>
사용자가 마이페이지나 결제 페이지 등 보안이 필수적인 경로에 접속하려 할 때, "이 사람이 로그인한 유저인가?"를 컴포넌트가 렌더링되기 전에 미리 검사해서 튕겨내고 싶다면 어떻게 해야 할까요? 프로젝트 최상단에 middleware.ts 파일을 생성하기만 하면 됩니다. 사용자의 모든 요청을 가로채서 쿠키(토큰)를 검사하고, 권한이 없다면 즉시 로그인 페이지로 리다이렉트(Redirect) 시키는 무적의 방화벽 역할을 수행합니다.
사용자가 마이페이지나 결제 페이지에 접속하려 할 때, "이 사람이 로그인한 유저인가?"를 컴포넌트가 렌더링 되기 전에 미리 검사해서 튕겨내고 싶다면 어떻게 해야 할까요? 바로 미들웨어(Middleware)가 그 역할을 완벽하게 수행합니다.
Next.js의 미들웨어는 무거운 Node.js 서버가 아닌, 사용자와 물리적으로 가장 가까운 글로벌 Edge Network에서 가볍고 엄청나게 빠른 속도로 실행됩니다. 따라서 페이지가 뜨기 한참 전에 번개처럼 라우팅 방향을 틀어버릴 수 있습니다.
<!-- HIDDEN_TAB -->
<div class="app-container">
<div class="mock-browser">
<div class="browser-header">
<div class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
<div class="url-bar" id="mockUrl">localhost:3000/</div>
</div>
<div class="browser-content">
<div class="auth-control">
<div class="control-label">가상 쿠키(토큰) 상태:</div>
<label class="switch">
<input type="checkbox" id="authToggle" onchange="toggleAuth()">
<span class="slider round"></span>
</label>
<span id="authStatus" class="status-text out">로그아웃 상태 (토큰 없음)</span>
</div>
<div class="nav-buttons">
<button class="nav-btn safe" onclick="simulateNav('/about')">
<span class="icon">🌍</span>
<div class="text-group">
<strong>/about (소개)</strong>
<small>누구나 접근 가능</small>
</div>
</button>
<button class="nav-btn protected" onclick="simulateNav('/dashboard')">
<span class="icon">🔒</span>
<div class="text-group">
<strong>/dashboard (대시보드)</strong>
<small>로그인 필수 경로</small>
</div>
</button>
</div>
</div>
</div>
</div>
<script>
let isLoggedIn = false;
function toggleAuth() {
isLoggedIn = document.getElementById('authToggle').checked;
const statusEl = document.getElementById('authStatus');
if (isLoggedIn) {
statusEl.innerText = '로그인 상태 (토큰 보유 ✅)';
statusEl.className = 'status-text in';
console.log(`[Browser] 'auth_session_token' 쿠키가 구워졌습니다! 🍪`);
} else {
statusEl.innerText = '로그아웃 상태 (토큰 없음 ❌)';
statusEl.className = 'status-text out';
console.log(`[Browser] 쿠키가 삭제되었습니다.`);
}
}
function simulateNav(path) {
console.log(`[User] '${path}' 경로로 이동을 시도합니다...`);
document.getElementById('mockUrl').innerText = `localhost:3000${path} (로딩중...)`;
setTimeout(() => {
console.log(`[Middleware] 요청 가로챔! 목적지: ${path}`);
if (path.startsWith('/dashboard')) {
if (!isLoggedIn) {
console.log(`[Middleware] 🚨 토큰이 없습니다! 무단 접근 차단 -> '/login'으로 리다이렉트 (Redirecting...)`);
document.getElementById('mockUrl').innerText = `localhost:3000/login`;
document.getElementById('mockUrl').style.color = '#ef4444';
setTimeout(() => { document.getElementById('mockUrl').style.color = '#64748b'; }, 1000);
} else {
console.log(`[Middleware] ✅ 유효한 토큰 확인 완료. 통과 (NextResponse.next())`);
document.getElementById('mockUrl').innerText = `localhost:3000${path}`;
}
} else {
console.log(`[Middleware] 🟢 보호된 경로가 아닙니다. 통과 (NextResponse.next())`);
document.getElementById('mockUrl').innerText = `localhost:3000${path}`;
}
}, 600);
}
</script>
병렬 라우트(Parallel Routes)는 폴더 이름 앞에 @를 붙여(예: @analytics) 하나의 레이아웃 안에서 여러 페이지를 동시에 병렬로 렌더링하는 고급 기법입니다. 대시보드 화면을 만들 때 통계 차트와 유저 목록을 각각 분리된 슬롯(Slot)으로 쪼개면, 통계 데이터를 불러오느라 유저 목록 화면까지 멈춰버리는 현상을 방지할 수 있습니다. 각 슬롯은 각자의 속도로 완전히 독립적으로 로딩됩니다!
병렬 라우트(Parallel Routes)는 이름이 @로 시작하는 폴더(Slots)를 사용하여, 여러 페이지를 하나의 레이아웃 안에서 동시에 렌더링하는 기법입니다.
이를 활용하면 대시보드의 네비게이션, 사용자 정보 위젯, 실시간 통계 차트 등 서로 독립적인 데이터를 가진 화면 조각들을 각기 다른 속도로 비동기 로딩할 수 있으며, 코드 응집도를 비약적으로 높일 수 있습니다.
<!-- HIDDEN_TAB -->
<div class="app-container">
<div class="mock-browser">
<div class="browser-header">
<div class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
<div class="url-bar">localhost:3000/dashboard</div>
<button class="reload-btn" onclick="simulateParallelLoading()">새로고침 🔄</button>
</div>
<div class="browser-content">
<header class="dashboard-header">
<h2 style="margin:0; font-size: 1.2rem; color: #1e293b;">Admin Dashboard (children)</h2>
<p style="margin:0; font-size: 0.8rem; color: #64748b;">메인 레이아웃 렌더링 완료</p>
</header>
<div class="grid">
<!-- Analytics Slot -->
<section class="slot purple">
<div class="slot-title">@analytics 슬롯</div>
<div class="slot-content" id="analyticsContent">
<div class="loader-wrap">
<div class="loader p"></div>
<div class="loading-text">차트 데이터 Fetching 중...<br>(1초 소요 예상)</div>
</div>
</div>
</section>
<!-- Team Slot -->
<section class="slot emerald">
<div class="slot-title">@team 슬롯</div>
<div class="slot-content" id="teamContent">
<div class="loader-wrap">
<div class="loader e"></div>
<div class="loading-text">팀원 DB 조회 중...<br>(3초 소요 예상)</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
<script>
function setLoader(id, colorClass, text) {
document.getElementById(id).innerHTML = `
<div class="loader-wrap">
<div class="loader ${colorClass}"></div>
<div class="loading-text">${text}</div>
</div>
`;
}
function setDone(id, emoji, text, value) {
document.getElementById(id).innerHTML = `
<div class="done-wrap">
<div class="emoji">${emoji}</div>
<div class="done-title">${text}</div>
<div class="done-value">${value}</div>
</div>
`;
}
function simulateParallelLoading() {
console.log(`[DashboardLayout] 페이지 진입! 레이아웃 렌더링 완료.`);
console.log(`[Parallel Routes] @analytics 와 @team 의 독립적인 데이터 Fetching이 동시에 시작됩니다...`);
// Reset UI to loading state
setLoader('analyticsContent', 'p', '차트 데이터 Fetching 중...<br>(1초 소요 예상)');
setLoader('teamContent', 'e', '팀원 DB 조회 중...<br>(3초 소요 예상)');
// Analytics resolves fast (1s)
setTimeout(() => {
console.log(`[Slot @analytics] ✅ 통계 데이터 Fetch 완료! 화면에 즉시 표시됩니다. (나머지 슬롯은 블로킹하지 않음)`);
setDone('analyticsContent', '📈', '주간 접속량', '14,250 뷰');
}, 1000);
// Team resolves slow (3s)
setTimeout(() => {
console.log(`[Slot @team] ✅ 팀원 DB 조회 완료! 이제 @team 슬롯이 화면에 표시됩니다.`);
setDone('teamContent', '👨💻', '활성 멤버 수', '42명 대기중');
}, 3000);
}
// Run on load
setTimeout(simulateParallelLoading, 500);
</script>
가로채기 라우트(Intercepting Routes)는 사용자가 링크를 클릭했을 때 화면 전환을 "가로채서" 현재 보던 화면(Context)을 유지한 채 모달(Modal) 형태로 페이지를 띄워주는 고급 UX 기술입니다. 인스타그램 피드에서 사진을 클릭하면 피드 위에 사진이 모달로 뜨지만, 그 URL을 복사해서 새 창에 붙여넣으면 모달이 아닌 전체 화면 단독 페이지로 열리는 마법 같은 기능이 바로 이 기술을 통해 구현됩니다. 폴더 이름 앞에 (..) 를 붙여 상위 라우트를 가로챕니다.
가로채기 라우트(Intercepting Routes)는 사용자가 링크를 클릭했을 때 브라우저의 현재 상태(Context)를 잃지 않고, 라우트 이동을 "가로채서" 모달(Modal) 형태로 띄워주는 고급 UX 기술입니다.
인스타그램 피드에서 사진을 클릭하면 모달로 뜨지만, 새로고침 하거나 URL을 직접 공유하면 단독 페이지로 열리는 기능이 바로 이 기술로 구현됩니다. (..)folder 형태의 네이밍 컨벤션을 사용합니다.
<!-- HIDDEN_TAB -->
<div class="app-container">
<div class="mock-browser">
<div class="browser-header">
<div class="dots"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span></div>
<div class="url-bar" id="mockUrl">localhost:3000/feed</div>
<button class="reload-btn" onclick="simulateHardRefresh()" title="URL 주소 복사해서 새 탭 열기 (새로고침)">새 창 열기 🔗</button>
</div>
<div class="browser-content" id="browserContent">
<!-- Feed View -->
<div class="feed-view" id="feedView">
<h2 style="margin:0 0 1rem 0; color: #1e293b;">📸 포토 피드</h2>
<div class="photo-grid">
<div class="photo-card" onclick="simulateSoftNavigate('/photo/123')">
<div class="img-placeholder">🌅</div>
<div class="desc">일몰 사진 (클릭)</div>
</div>
<div class="photo-card">
<div class="img-placeholder">🐱</div>
<div class="desc">귀여운 고양이</div>
</div>
</div>
</div>
<!-- Modal Overlay (Intercepted) -->
<div class="modal-overlay" id="modalOverlay" style="display: none;">
<div class="modal-content">
<button class="close-btn" onclick="closeModal()">✖</button>
<div class="img-large">🌅</div>
<h3>일몰 사진 (가로채기 모달)</h3>
<p>배경의 피드 화면이 그대로 유지된 상태로 모달이 열렸습니다! URL은 변경되었습니다.</p>
</div>
</div>
<!-- Full Page View (Hard Refresh) -->
<div class="full-page-view" id="fullPageView" style="display: none;">
<h3 style="color: #2563eb;">/photo/123 단독 페이지</h3>
<div class="img-large full">🌅</div>
<p>새 창으로 접속(Hard Refresh)했기 때문에 가로채기(Intercept)가 발생하지 않고, 원본 페이지가 전체화면으로 렌더링되었습니다.</p>
<button onclick="resetToFeed()" style="padding: 8px 16px; margin-top: 10px;">피드로 돌아가기</button>
</div>
</div>
</div>
</div>
<script>
function simulateSoftNavigate(path) {
console.log(`[Next.js Router] 클라이언트 내비게이션 발생! (Soft Link Click)`);
console.log(`[Intercepting Routes] 🚀 '(..)photo' 라우트가 내비게이션을 가로챘습니다!`);
console.log(`[UI Update] 원본 피드를 유지한 채 모달 슬롯(@modal)에 사진을 렌더링합니다.`);
// Update URL visually
document.getElementById('mockUrl').innerText = `localhost:3000${path}`;
// Show Modal
document.getElementById('modalOverlay').style.display = 'flex';
document.getElementById('feedView').classList.add('blurred');
}
function closeModal() {
console.log(`[Next.js Router] 뒤로가기(Back) 실행 -> '/feed' 복귀`);
document.getElementById('mockUrl').innerText = `localhost:3000/feed`;
document.getElementById('modalOverlay').style.display = 'none';
document.getElementById('feedView').classList.remove('blurred');
}
function simulateHardRefresh() {
const currentUrl = document.getElementById('mockUrl').innerText;
if (!currentUrl.includes('/photo/')) {
alert("먼저 사진을 클릭하여 모달을 연 뒤, 새 창 열기를 테스트해보세요!");
return;
}
console.log(`[Browser] URL을 직접 입력하여 접속했습니다. (Hard Refresh)`);
console.log(`[Next.js Router] 클라이언트 내비게이션이 아니므로 가로채기(Intercepting)가 무시됩니다.`);
console.log(`[UI Update] 모달이 아닌 진짜 '/photo/[id]/page.tsx' 단독 화면을 렌더링합니다.`);
// Hide Feed & Modal, Show Full Page
document.getElementById('feedView').style.display = 'none';
document.getElementById('modalOverlay').style.display = 'none';
document.getElementById('feedView').classList.remove('blurred');
document.getElementById('fullPageView').style.display = 'block';
}
function resetToFeed() {
document.getElementById('mockUrl').innerText = `localhost:3000/feed`;
document.getElementById('feedView').style.display = 'block';
document.getElementById('fullPageView').style.display = 'none';
console.clear();
console.log(`피드로 초기화 되었습니다.`);
}
</script>
리액트 컴포넌트를 예쁘게 꾸미는 방법은 아주 다양합니다. 그중 현대 프론트엔드 실무에서 가장 사랑받는 두 가지 방식이 있습니다.
클래스 이름 충돌을 원천 차단해 주는 CSS Modules와, CSS 파일 작성 없이 HTML(JSX) 안에서 클래스명만으로 디자인을 완성하는 유틸리티 퍼스트 프레임워크 Tailwind CSS입니다. 상황에 맞게 이 두 가지를 섞어 쓰는 통합 전략을 배워봅시다.
CSS Modules (맞춤복): 나만의 컴포넌트를 위해 치수를 재고 패턴을 만들어 딱 맞게 재단한 옷입니다. 다른 사람(컴포넌트)의 옷과 디자인(클래스명)이 겹칠 일이 없습니다.
Tailwind CSS (기성복): 미리 예쁘게 재단된 수천 개의 파츠(Utility Class)들을 조합해서 즉석에서 옷을 만들어 입는 방식입니다. 매우 빠르고 트렌디합니다.
| 비교 항목 | CSS Modules | Tailwind CSS |
|---|---|---|
| 방식 | 컴포넌트마다 .module.css 파일을 개별 생성 |
JSX의 className에 유틸리티 클래스 나열 |
| 장점 | 기존 CSS 문법 100% 활용, 완벽한 관심사 분리 | 개발 속도가 압도적으로 빠름, 파일 이동 불필요 |
| 단점 | 파일이 많아지고 이름 짓기가 귀찮음 | HTML 코드가 길어지고 지저분해 보일 수 있음 |
| 추천 상황 | 복잡하고 정교한 애니메이션, 독창적인 커스텀 디자인 | 빠른 프로토타이핑, 일관된 디자인 시스템 적용 시 |
<div class="app-container">
<h3>스타일링 전략 시뮬레이션</h3>
<div class="cards-wrapper">
<!-- 1. CSS Modules 컨셉 컨테이너 -->
<div id="module-card">
<h4 class="cardTitle_3x8Yq">CSS Modules 스타일</h4>
<p class="cardDesc_a9F2p">고유한 해시값이 붙은 클래스로 충돌을 방지합니다.</p>
<button class="btnPrimary_7zV1a">모듈 버튼</button>
</div>
<!-- 2. Tailwind CSS 컨셉 컨테이너 (실제 Tailwind는 아니지만 유틸리티 클래스 조합 흉내) -->
<div class="tw-bg-slate tw-rounded-lg tw-p-6 tw-shadow-md">
<h4 class="tw-text-blue tw-font-bold tw-text-xl tw-mb-2">Tailwind CSS 스타일</h4>
<p class="tw-text-gray tw-mb-4">유틸리티 클래스를 조합하여 빠르고 직관적으로 꾸밉니다.</p>
<button class="tw-bg-blue tw-text-white tw-px-4 tw-py-2 tw-rounded tw-hover">테일윈드 버튼</button>
</div>
</div>
</div>
실무 웹 서비스 개발에서 환경 변수(Environment Variables) 관리는 보안의 핵심입니다. Next.js는 .env.local 파일을 통해 환경 변수를 관리하며, 기본적으로 모든 변수는 안전한 Node.js 서버(Server) 환경에서만 접근 가능합니다. 만약 브라우저(Client)에서도 해당 변수를 사용하게 하려면 변수명 앞에 반드시 NEXT_PUBLIC_을 붙여야만 합니다. 이 간단한 접두어 하나가 DB 비밀번호 유출과 같은 치명적인 대형 보안 사고를 완벽하게 막아줍니다.
실무 배포 환경에서는 보안이 생명입니다. Next.js는 .env.local을 통해 서버 전용 시크릿(DB 비밀번호 등)과 클라이언트 노출 변수(API Endpoint 등)를 완벽히 분리 관리합니다.
접두어 NEXT_PUBLIC_이 붙은 변수만이 브라우저 번들에 포함되며, 나머지 변수는 오직 안전한 Node.js 서버 환경에서만 접근 가능합니다. Vercel 플랫폼과 결합하면 브랜치별 환경 변수 구성과 Edge Network 캐싱을 손쉽게 세팅할 수 있습니다.
<!-- HIDDEN_TAB -->
<div class="app-container">
<div class="env-board">
<div class="board-header">
<h2 style="margin:0; color:#1e293b; font-size:1.2rem;">🔍 브라우저 소스코드 유출 테스트</h2>
</div>
<div class="code-files">
<div class="file-header">
<span style="color:#cbd5e1;">📄 .env.local</span>
</div>
<pre><code>DATABASE_PASSWORD="secret1234!"
NEXT_PUBLIC_API_KEY="pk_public_9999"</code></pre>
</div>
<div class="board-content">
<!-- Server Output -->
<div class="env-section server">
<div class="env-title">
<span class="icon">🖥️</span> 서버 사이드 렌더링 (Node.js)
</div>
<div class="var-row">
<span class="var-name">process.env.DATABASE_PASSWORD</span>
<span class="var-value">"secret1234!"</span>
</div>
<div class="var-row">
<span class="var-name">process.env.NEXT_PUBLIC_API_KEY</span>
<span class="var-value public">"pk_public_9999"</span>
</div>
<p class="desc">안전한 서버 환경이므로 <strong>모든 변수</strong>에 접근할 수 있습니다.</p>
</div>
<div class="arrow-down">▼ Next.js 빌드 및 브라우저 전송 ▼</div>
<!-- Client Output -->
<div class="env-section client">
<div class="env-title">
<span class="icon">🌐</span> 클라이언트 브라우저 (JavaScript 번들)
</div>
<div class="var-row">
<span class="var-name">process.env.DATABASE_PASSWORD</span>
<span class="var-value undefined">undefined</span>
</div>
<div class="var-row">
<span class="var-name">process.env.NEXT_PUBLIC_API_KEY</span>
<span class="var-value public">"pk_public_9999"</span>
</div>
<p class="desc" style="color:#ef4444;">🚨 NEXT_PUBLIC_ 이 없는 변수는 브라우저 번들에서 완전히 <strong>삭제(undefined)</strong>되어 소스코드 분석을 통한 탈취가 불가능합니다!</p>
</div>
</div>
</div>
</div>