웹 개발을 시작하면서 'Next.js 라우터'라는 말을 들어보셨나요?
•
특히 AI 애플리케이션을 만들 때 "라우터가 중요하다"는 이야기를 많이 듣게 됩니다. 하지만 정확히 무엇인지, 왜 중요한지 막막하실 수 있습니다.
•
오늘은 Next.js 라우터에 대해 처음부터 끝까지 자세히 알아보겠습니다.
1. Next.js 라우터의 기본 개념 이해하기
라우터란 정확히 무엇인가?
웹사이트를 하나의 큰 건물이라고 생각해보세요. 이 건물에는 수많은 방(페이지)이 있고, 각 방마다 고유한 방 번호(URL)가 있습니다. 라우터는 이 건물의 안내 데스크 직원과 같습니다. 방문객(사용자)이 특정 방을 찾아가고 싶다고 하면, 라우터가 그 방까지 안내해주는 역할을 합니다.
예를 들어 설명하면:
•
사용자가 www.mysite.com/products 주소를 입력하면
•
라우터가 "아, 제품 페이지를 보고 싶구나!"라고 이해하고
•
해당하는 제품 페이지 컴포넌트를 화면에 보여줍니다
전통적인 웹사이트 vs Next.js의 차이점
전통적인 웹사이트에서는 각 페이지마다 별도의 HTML 파일이 필요했습니다. 10개의 페이지가 있다면 10개의 HTML 파일을 만들어야 했죠. 하지만 Next.js는 혁신적인 방식을 사용합니다.
전통적인 방식:
/index.html
/about.html
/products.html
/contact.html
Next.js 방식:
/app
/page.js (홈페이지)
/about/page.js
/products/page.js
/contact/page.js
Plain Text
복사
Next.js는 폴더 구조만으로 자동으로 라우팅을 생성합니다. 파일을 만들기만 하면 URL이 자동으로 만들어지는 마법 같은 일이 일어납니다!
라우터가 Next.js에서 차지하는 비중
Next.js 애플리케이션에서 라우터는 심장과 같은 역할을 합니다. 모든 사용자 상호작용, 데이터 흐름, 페이지 전환이 라우터를 통해 이루어집니다. 실제로 Next.js 애플리케이션의 약 70-80%가 라우터와 직간접적으로 연결되어 있다고 볼 수 있습니다.
2. AI 개발에서 라우터가 특별히 중요한 이유
보안의 최전선
AI 서비스를 사용하려면 API 키가 필요합니다. 이 키는 여러분의 은행 계좌 비밀번호만큼 중요한 정보입니다. 만약 이 키가 노출되면 누군가 여러분의 비용으로 AI 서비스를 무제한 사용할 수 있습니다.
// 🚫 절대 하면 안 되는 방법 (클라이언트에서 직접 호출)
const response = await fetch('<https://api.openai.com/v1/chat>', {
headers: {
'Authorization': 'Bearer sk-xxxxx' // API 키가 브라우저에 노출됨!
}
});
// ✅ 올바른 방법 (API 라우트 사용)
// 클라이언트 코드
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message: '안녕하세요' })
});
// 서버 코드 (app/api/chat/route.js)
export async function POST(request) {
// API 키는 서버에서만 사용
const apiKey = process.env.OPENAI_API_KEY; // 환경변수에서 안전하게 가져옴
// AI 서비스 호출...
}
JavaScript
복사
데이터 처리의 중앙 허브
AI 애플리케이션에서는 다양한 데이터 처리가 필요합니다:
1.
요청 전처리: 사용자 입력을 AI가 이해할 수 있는 형태로 변환
2.
응답 후처리: AI의 응답을 사용자가 보기 좋게 포맷팅
3.
에러 처리: AI 서비스 오류 시 사용자에게 친절한 메시지 전달
4.
사용량 제한: 사용자별 API 호출 횟수 관리
5.
로깅: 모든 요청과 응답을 기록하여 문제 추적
이 모든 작업이 API 라우트에서 중앙집중식으로 처리됩니다.
성능 최적화의 핵심
// app/api/ai-cache/route.js
const cache = new Map(); // 간단한 메모리 캐시
export async function POST(request) {
const { query } = await request.json();
// 캐시 확인
if (cache.has(query)) {
return Response.json({
answer: cache.get(query),
cached: true
});
}
// AI 호출
const answer = await callAI(query);
// 결과 캐싱 (5분간 유지)
cache.set(query, answer);
setTimeout(() => cache.delete(query), 5 * 60 * 1000);
return Response.json({ answer, cached: false });
}
JavaScript
복사
3. Next.js 라우터의 모든 종류 상세 분석
1) 페이지 라우트 (Page Routes) - 사용자가 보는 화면
페이지 라우트는 사용자가 실제로 보는 웹페이지를 만듭니다. page.js 또는 page.tsx 파일로 정의합니다.
// app/page.js - 홈페이지 (/)
export default function HomePage() {
return (
<main>
<h1>AI 챗봇에 오신 것을 환영합니다</h1>
<p>무엇이든 물어보세요!</p>
</main>
);
}
// app/dashboard/page.js - 대시보드 (/dashboard)
export default function DashboardPage() {
return (
<div>
<h1>사용자 대시보드</h1>
<p>오늘의 AI 사용량: 15회</p>
</div>
);
}
// app/chat/page.js - 채팅 페이지 (/chat)
'use client'; // 클라이언트 컴포넌트 선언
import { useState } from 'react';
export default function ChatPage() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const sendMessage = async () => {
if (!input.trim()) return;
// 사용자 메시지 추가
const userMessage = { role: 'user', content: input };
setMessages(prev => [...prev, userMessage]);
setInput('');
setLoading(true);
try {
// API 라우트 호출
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: input })
});
const data = await response.json();
// AI 응답 추가
setMessages(prev => [...prev, {
role: 'assistant',
content: data.answer
}]);
} catch (error) {
console.error('에러 발생:', error);
} finally {
setLoading(false);
}
};
return (
<div className="chat-container">
<div className="messages">
{messages.map((msg, idx) => (
<div key={idx} className={`message ${msg.role}`}>
{msg.content}
</div>
))}
{loading && <div>AI가 생각 중...</div>}
</div>
<div className="input-area">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="메시지를 입력하세요..."
/>
<button onClick={sendMessage}>전송</button>
</div>
</div>
);
}
JavaScript
복사
2) API 라우트 (API Routes) - 서버의 두뇌
API 라우트는 서버에서 실행되는 코드로, 데이터 처리와 외부 서비스 통신을 담당합니다.
// app/api/chat/route.js - 기본 채팅 API
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
export async function POST(request) {
try {
const { message } = await request.json();
// 입력 검증
if (!message || typeof message !== 'string') {
return Response.json(
{ error: '유효한 메시지를 입력해주세요' },
{ status: 400 }
);
}
// AI 호출
const completion = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: "당신은 친절하고 도움이 되는 AI 어시스턴트입니다."
},
{
role: "user",
content: message
}
],
temperature: 0.7,
max_tokens: 1000
});
return Response.json({
answer: completion.choices[0].message.content
});
} catch (error) {
console.error('AI 호출 에러:', error);
return Response.json(
{ error: '일시적인 오류가 발생했습니다' },
{ status: 500 }
);
}
}
// GET 메서드도 지원 가능
export async function GET(request) {
return Response.json({
status: 'API is running',
version: '1.0.0'
});
}
JavaScript
복사
3) 동적 라우트 (Dynamic Routes) - 유연한 URL 처리
동적 라우트는 URL의 일부를 변수로 사용할 수 있게 해줍니다.
// app/blog/[slug]/page.js
// URL 예시: /blog/ai-tutorial, /blog/nextjs-guide
export default async function BlogPost({ params }) {
const { slug } = params;
// 실제로는 데이터베이스나 CMS에서 가져옴
const post = await getPostBySlug(slug);
if (!post) {
notFound(); // 404 페이지로 이동
}
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// app/users/[userId]/posts/[postId]/page.js
// URL 예시: /users/123/posts/456
export default function UserPost({ params }) {
const { userId, postId } = params;
return (
<div>
<h1>사용자 {userId}의 게시물 {postId}</h1>
</div>
);
}
JavaScript
복사
4) 캐치올 라우트 (Catch-all Routes) - 모든 하위 경로 처리
// app/docs/[...slug]/page.js
// 매칭되는 URL:
// /docs/getting-started
// /docs/guides/routing
// /docs/api/reference/components
export default function DocPage({ params }) {
// params.slug는 배열
// /docs/guides/routing → ['guides', 'routing']
const breadcrumb = params.slug.join(' > ');
return (
<div>
<nav>{breadcrumb}</nav>
<h1>문서 페이지</h1>
</div>
);
}
// app/shop/[[...category]]/page.js - 옵셔널 캐치올
// 매칭되는 URL:
// /shop (category = undefined)
// /shop/electronics (category = ['electronics'])
// /shop/electronics/phones (category = ['electronics', 'phones'])
export default function ShopPage({ params }) {
const category = params.category || [];
if (category.length === 0) {
return <h1>전체 상품</h1>;
}
return <h1>{category.join(' > ')} 카테고리</h1>;
}
JavaScript
복사
5) 병렬 라우트 (Parallel Routes) - 동시 렌더링
병렬 라우트를 사용하면 한 페이지에서 여러 컴포넌트를 독립적으로 렌더링할 수 있습니다.
// app/layout.js
export default function Layout({ children, team, analytics }) {
return (
<div>
<main>{children}</main>
<aside>{team}</aside>
<div>{analytics}</div>
</div>
);
}
// app/@team/page.js
export default function TeamSection() {
return <div>팀 멤버 목록</div>;
}
// app/@analytics/page.js
export default function AnalyticsSection() {
return <div>실시간 분석 데이터</div>;
}
JavaScript
복사
6) 인터셉팅 라우트 (Intercepting Routes) - 모달 구현
// app/photos/[id]/page.js - 전체 페이지
export default function PhotoPage({ params }) {
return (
<div className="full-page">
<img src={`/photos/${params.id}.jpg`} />
<h1>사진 {params.id}</h1>
</div>
);
}
// app/feed/(.)photos/[id]/page.js - 모달로 표시
export default function PhotoModal({ params }) {
return (
<div className="modal-overlay">
<div className="modal">
<img src={`/photos/${params.id}.jpg`} />
<button>닫기</button>
</div>
</div>
);
}
JavaScript
복사
7) 라우트 그룹 (Route Groups) - 코드 정리
app/
(marketing)/ # URL에 영향 없음
about/
page.js → /about
contact/
page.js → /contact
layout.js # marketing 페이지 공통 레이아웃
(shop)/ # URL에 영향 없음
products/
page.js → /products
cart/
page.js → /cart
checkout/
page.js → /checkout
layout.js # shop 페이지 공통 레이아웃
Plain Text
복사
8) 미들웨어 (Middleware) - 요청 가로채기
// middleware.js (프로젝트 루트)
import { NextResponse } from 'next/server';
export function middleware(request) {
// 1. 인증 확인
const token = request.cookies.get('auth-token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 2. 지역별 리다이렉션
const country = request.geo?.country || 'US';
if (country === 'KR' && request.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/kr', request.url));
}
// 3. 요청 헤더 수정
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-custom-header', 'my-value');
// 4. 응답 헤더 추가
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
response.headers.set('x-custom-response', 'value');
return response;
}
// 특정 경로에만 적용
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
'/((?!_next/static|favicon.ico).*)',
],
};
JavaScript
복사
4. 라우터 호출 방법 완벽 가이드
1) Link 컴포넌트 - 기본 네비게이션
import Link from 'next/link';
export default function Navigation() {
return (
<nav>
{/* 기본 사용법 */}
<Link href="/">홈</Link>
{/* 동적 라우트 */}
<Link href="/blog/my-first-post">첫 번째 글</Link>
{/* 객체 형태 */}
<Link
href={{
pathname: '/blog/[slug]',
query: { slug: 'my-post' }
}}
>
블로그 글
</Link>
{/* 프리페치 비활성화 */}
<Link href="/heavy-page" prefetch={false}>
무거운 페이지
</Link>
{/* 스크롤 유지 */}
<Link href="/products" scroll={false}>
제품 목록
</Link>
</nav>
);
}
JavaScript
복사
2) useRouter 훅 - 프로그래밍적 네비게이션
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
export default function InteractiveComponent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// 페이지 이동
const handleNavigation = () => {
router.push('/dashboard'); // 히스토리에 추가
// router.replace('/dashboard'); // 현재 히스토리 대체
// router.back(); // 뒤로가기
// router.forward(); // 앞으로가기
};
// 쿼리 파라미터 추가
const addQueryParam = () => {
const params = new URLSearchParams(searchParams);
params.set('filter', 'active');
router.push(`${pathname}?${params.toString()}`);
};
// 페이지 새로고침
const refreshPage = () => {
router.refresh();
};
// 프리페치
const prefetchPage = () => {
router.prefetch('/heavy-content');
};
return (
<div>
<button onClick={handleNavigation}>대시보드로 이동</button>
<button onClick={addQueryParam}>필터 추가</button>
<button onClick={refreshPage}>새로고침</button>
<button onClick={prefetchPage}>미리 로드</button>
</div>
);
}
JavaScript
복사
3) fetch API - 데이터 통신
// 클라이언트 컴포넌트에서 API 호출
async function callAPI() {
try {
// GET 요청
const getResponse = await fetch('/api/users');
const users = await getResponse.json();
// POST 요청
const postResponse = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: '홍길동',
email: 'hong@example.com'
})
});
if (!postResponse.ok) {
throw new Error('API 호출 실패');
}
const newUser = await postResponse.json();
// 파일 업로드
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('description', '프로필 사진');
const uploadResponse = await fetch('/api/upload', {
method: 'POST',
body: formData // Content-Type 자동 설정
});
} catch (error) {
console.error('에러:', error);
}
}
JavaScript
복사
4) 서버 액션 (Server Actions) - Next.js 14+
// app/actions.js
'use server';
export async function createUser(formData) {
const name = formData.get('name');
const email = formData.get('email');
// 데이터베이스에 저장
const user = await db.user.create({
data: { name, email }
});
// 페이지 재검증
revalidatePath('/users');
return { success: true, user };
}
// app/user-form/page.js
import { createUser } from '../actions';
export default function UserForm() {
return (
<form action={createUser}>
<input name="name" placeholder="이름" />
<input name="email" type="email" placeholder="이메일" />
<button type="submit">생성</button>
</form>
);
}
JavaScript
복사
5. AI 개발 실전 예제: 완전한 챗봇 만들기
프로젝트 구조
app/
layout.js # 전체 레이아웃
page.js # 홈페이지
chat/
page.js # 채팅 인터페이스
layout.js # 채팅 레이아웃
api/
chat/
route.js # 기본 채팅 API
stream/
route.js # 스트리밍 채팅 API
conversations/
route.js # 대화 목록 API
[id]/
route.js # 특정 대화 API
upload/
route.js # 파일 업로드 API
dashboard/
page.js # 사용자 대시보드
stats/
page.js # 통계 페이지
(auth)/
login/
page.js # 로그인
register/
page.js # 회원가입
middleware.js # 인증 미들웨어
Plain Text
복사
채팅 인터페이스 구현
// app/chat/page.js
'use client';
import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function ChatInterface() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef(null);
const router = useRouter();
// 자동 스크롤
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 일반 메시지 전송
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage = {
id: Date.now(),
role: 'user',
content: input,
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: input,
conversationId: localStorage.getItem('conversationId')
})
});
if (!response.ok) throw new Error('API 오류');
const data = await response.json();
const aiMessage = {
id: Date.now() + 1,
role: 'assistant',
content: data.answer,
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, aiMessage]);
// 대화 ID 저장
if (data.conversationId) {
localStorage.setItem('conversationId', data.conversationId);
}
} catch (error) {
console.error('에러:', error);
alert('메시지 전송 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
};
// 스트리밍 메시지 전송
const sendStreamingMessage = async () => {
if (!input.trim() || isStreaming) return;
const userMessage = {
id: Date.now(),
role: 'user',
content: input,
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, userMessage]);
setInput('');
setIsStreaming(true);
// AI 응답을 위한 빈 메시지 추가
const aiMessageId = Date.now() + 1;
const aiMessage = {
id: aiMessageId,
role: 'assistant',
content: '',
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, aiMessage]);
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMessage.content })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// 스트리밍 응답을 기존 메시지에 추가
setMessages(prev =>
prev.map(msg =>
msg.id === aiMessageId
? { ...msg, content: msg.content + chunk }
: msg
)
);
}
} catch (error) {
console.error('스트리밍 에러:', error);
} finally {
setIsStreaming(false);
}
};
// 파일 업로드
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
// 파일 정보를 메시지로 추가
const fileMessage = {
id: Date.now(),
role: 'user',
content: `파일 업로드: ${file.name}`,
fileUrl: data.url,
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, fileMessage]);
} catch (error) {
console.error('업로드 에러:', error);
}
};
return (
<div className="chat-container">
<header className="chat-header">
<h1>AI 어시스턴트</h1>
<button onClick={() => router.push('/dashboard')}>
대시보드
</button>
</header>
<div className="messages-container">
{messages.map((message) => (
<div
key={message.id}
className={`message ${message.role}`}
>
<div className="message-content">
{message.content}
{message.fileUrl && (
<a href={message.fileUrl} target="_blank">
첨부파일 보기
</a>
)}
</div>
<time className="message-time">
{new Date(message.timestamp).toLocaleTimeString()}
</time>
</div>
))}
{(isLoading || isStreaming) && (
<div className="typing-indicator">
AI가 입력 중...
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="input-container">
<input
type="file"
id="file-upload"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<label htmlFor="file-upload" className="file-button">
📎
</label>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="메시지를 입력하세요..."
disabled={isLoading || isStreaming}
/>
<button
onClick={sendMessage}
disabled={isLoading || isStreaming}
>
전송
</button>
<button
onClick={sendStreamingMessage}
disabled={isLoading || isStreaming}
>
스트리밍
</button>
</div>
</div>
);
}
JavaScript
복사
API 라우트 구현
// app/api/chat/route.js
import { OpenAI } from 'openai';
import { NextResponse } from 'next/server';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// 대화 기록 저장 (실제로는 데이터베이스 사용)
const conversations = new Map();
export async function POST(request) {
try {
const { message, conversationId } = await request.json();
// 입력 검증
if (!message || typeof message !== 'string') {
return NextResponse.json(
{ error: '유효한 메시지를 입력해주세요' },
{ status: 400 }
);
}
// 대화 기록 가져오기 또는 생성
let conversation;
if (conversationId && conversations.has(conversationId)) {
conversation = conversations.get(conversationId);
} else {
conversation = {
id: crypto.randomUUID(),
messages: [],
createdAt: new Date().toISOString()
};
conversations.set(conversation.id, conversation);
}
// 사용자 메시지 추가
conversation.messages.push({
role: 'user',
content: message
});
// OpenAI API 호출
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: `당신은 도움이 되고 친절한 AI 어시스턴트입니다.
한국어로 자연스럽게 대화하며,
사용자의 질문에 정확하고 유용한 답변을 제공합니다.`
},
...conversation.messages
],
temperature: 0.7,
max_tokens: 1000,
});
const aiResponse = completion.choices[0].message.content;
// AI 응답 저장
conversation.messages.push({
role: 'assistant',
content: aiResponse
});
return NextResponse.json({
answer: aiResponse,
conversationId: conversation.id,
messageCount: conversation.messages.length
});
} catch (error) {
console.error('Chat API 에러:', error);
if (error.code === 'insufficient_quota') {
return NextResponse.json(
{ error: 'API 사용 한도를 초과했습니다' },
{ status: 429 }
);
}
return NextResponse.json(
{ error: '일시적인 오류가 발생했습니다' },
{ status: 500 }
);
}
}
// 대화 목록 조회
export async function GET(request) {
const conversationList = Array.from(conversations.values()).map(conv => ({
id: conv.id,
messageCount: conv.messages.length,
createdAt: conv.createdAt,
lastMessage: conv.messages[conv.messages.length - 1]?.content || ''
}));
return NextResponse.json({ conversations: conversationList });
}
JavaScript
복사
스트리밍 API 구현
// app/api/chat/stream/route.js
import { OpenAI } from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export async function POST(request) {
const { message } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: '당신은 도움이 되는 AI 어시스턴트입니다.'
},
{
role: 'user',
content: message
}
],
stream: true,
});
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
controller.enqueue(encoder.encode(content));
}
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
JavaScript
복사
파일 업로드 API
// app/api/upload/route.js
import { writeFile } from 'fs/promises';
import { NextResponse } from 'next/server';
import path from 'path';
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get('file');
if (!file) {
return NextResponse.json(
{ error: '파일이 없습니다' },
{ status: 400 }
);
}
// 파일 크기 제한 (10MB)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: '파일 크기는 10MB 이하여야 합니다' },
{ status: 400 }
);
}
// 허용된 파일 타입
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: '허용되지 않은 파일 형식입니다' },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 파일명 생성
const filename = `${Date.now()}-${file.name}`;
const filepath = path.join(process.cwd(), 'public/uploads', filename);
// 파일 저장
await writeFile(filepath, buffer);
// AI에 파일 분석 요청 (예: 이미지 설명)
let analysis = null;
if (file.type.startsWith('image/')) {
// 실제로는 Vision API 등을 사용
analysis = '업로드된 이미지 파일입니다.';
}
return NextResponse.json({
success: true,
url: `/uploads/${filename}`,
filename: file.name,
size: file.size,
type: file.type,
analysis
});
} catch (error) {
console.error('업로드 에러:', error);
return NextResponse.json(
{ error: '파일 업로드 중 오류가 발생했습니다' },
{ status: 500 }
);
}
}
JavaScript
복사
미들웨어로 인증 처리
// middleware.js
import { NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const publicPaths = ['/', '/login', '/register', '/api/auth'];
export async function middleware(request) {
const { pathname } = request.nextUrl;
// 공개 경로는 통과
if (publicPaths.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// JWT 토큰 확인
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
// 토큰 검증
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret);
// 사용자 정보를 헤더에 추가
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId);
requestHeaders.set('x-user-email', payload.email);
// API 사용량 체크
if (pathname.startsWith('/api/chat')) {
const usage = await checkAPIUsage(payload.userId);
if (usage.exceeded) {
return NextResponse.json(
{ error: '일일 사용 한도를 초과했습니다' },
{ status: 429 }
);
}
}
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
} catch (error) {
console.error('토큰 검증 실패:', error);
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
// 사용량 체크 함수 (예시)
async function checkAPIUsage(userId) {
// 실제로는 데이터베이스나 Redis에서 확인
const dailyLimit = 100;
const currentUsage = 50; // 임시 값
return {
exceeded: currentUsage >= dailyLimit,
remaining: dailyLimit - currentUsage
};
}
JavaScript
복사
6. 라우터 최적화 및 성능 향상 팁
캐싱 전략
// app/api/cached-data/route.js
let cache = {
data: null,
timestamp: 0
};
const CACHE_DURATION = 5 * 60 * 1000; // 5분
export async function GET() {
const now = Date.now();
// 캐시가 유효한 경우
if (cache.data && (now - cache.timestamp) < CACHE_DURATION) {
return NextResponse.json({
...cache.data,
cached: true,
age: Math.floor((now - cache.timestamp) / 1000)
});
}
// 새로운 데이터 가져오기
const freshData = await fetchExpensiveData();
// 캐시 업데이트
cache = {
data: freshData,
timestamp: now
};
return NextResponse.json({
...freshData,
cached: false
});
}
JavaScript
복사
에러 바운더리
// app/error.js
'use client';
export default function Error({ error, reset }) {
return (
<div className="error-container">
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>다시 시도</button>
</div>
);
}
// app/api/chat/route.js - 에러 처리 개선
export async function POST(request) {
try {
// ... API 로직
} catch (error) {
// 에러 타입별 처리
if (error.code === 'ECONNREFUSED') {
return NextResponse.json(
{ error: 'AI 서비스에 연결할 수 없습니다' },
{ status: 503 }
);
}
if (error.code === 'rate_limit_exceeded') {
return NextResponse.json(
{
error: 'API 호출 한도 초과',
retryAfter: error.retryAfter
},
{
status: 429,
headers: {
'Retry-After': error.retryAfter.toString()
}
}
);
}
// 알 수 없는 에러는 로깅하고 일반 메시지 반환
console.error('Unexpected error:', error);
return NextResponse.json(
{ error: '서버 오류가 발생했습니다' },
{ status: 500 }
);
}
}
JavaScript
복사
로딩 상태 처리
// app/chat/loading.js
export default function Loading() {
return (
<div className="loading-container">
<div className="spinner" />
<p>AI 어시스턴트를 준비하고 있습니다...</p>
</div>
);
}
// app/dashboard/loading.js
import { Skeleton } from '@/components/ui/skeleton';
export default function DashboardLoading() {
return (
<div className="dashboard-skeleton">
<Skeleton className="h-8 w-48 mb-4" />
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
</div>
);
}
JavaScript
복사
7. 보안 고려사항
API 키 관리
// .env.local
OPENAI_API_KEY=sk-...
JWT_SECRET=your-secret-key
DATABASE_URL=postgresql://...
// app/api/secure/route.js
export async function POST(request) {
// 환경변수에서 안전하게 가져오기
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.error('API 키가 설정되지 않았습니다');
return NextResponse.json(
{ error: '서버 설정 오류' },
{ status: 500 }
);
}
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const isRateLimited = await checkRateLimit(ip);
if (isRateLimited) {
return NextResponse.json(
{ error: '너무 많은 요청입니다' },
{ status: 429 }
);
}
// CORS 설정
const origin = request.headers.get('origin');
const allowedOrigins = ['<https://yourdomain.com>'];
if (!allowedOrigins.includes(origin)) {
return NextResponse.json(
{ error: '허용되지 않은 출처' },
{ status: 403 }
);
}
// ... 실제 API 로직
}
JavaScript
복사
입력 검증
// app/api/validate/route.js
import { z } from 'zod';
// 스키마 정의
const ChatSchema = z.object({
message: z.string().min(1).max(1000),
conversationId: z.string().uuid().optional(),
temperature: z.number().min(0).max(2).default(0.7)
});
export async function POST(request) {
try {
const body = await request.json();
// 입력 검증
const validatedData = ChatSchema.parse(body);
// SQL 인젝션 방지
const sanitizedMessage = validatedData.message
.replace(/[<>]/g, '') // HTML 태그 제거
.trim();
// XSS 방지
const safeMessage = escapeHtml(sanitizedMessage);
// ... API 로직
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: '입력값이 올바르지 않습니다',
details: error.errors
},
{ status: 400 }
);
}
throw error;
}
}
JavaScript
복사
8. 디버깅과 모니터링
로깅 시스템
// lib/logger.js
class Logger {
static log(level, message, data = {}) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level,
message,
...data
};
if (process.env.NODE_ENV === 'development') {
console.log(JSON.stringify(logEntry, null, 2));
} else {
// 프로덕션에서는 로깅 서비스로 전송
// sendToLoggingService(logEntry);
}
}
static info(message, data) {
this.log('INFO', message, data);
}
static error(message, error) {
this.log('ERROR', message, {
error: error.message,
stack: error.stack
});
}
static metric(name, value, tags = {}) {
this.log('METRIC', name, { value, tags });
}
}
// 사용 예시
export async function POST(request) {
const startTime = Date.now();
try {
Logger.info('Chat API 호출 시작', {
ip: request.headers.get('x-forwarded-for'),
userAgent: request.headers.get('user-agent')
});
// ... API 로직
const duration = Date.now() - startTime;
Logger.metric('api.chat.duration', duration, {
status: 'success'
});
return response;
} catch (error) {
Logger.error('Chat API 에러', error);
const duration = Date.now() - startTime;
Logger.metric('api.chat.duration', duration, {
status: 'error',
errorType: error.code
});
throw error;
}
}
JavaScript
복사
성능 모니터링
// app/api/monitor/route.js
export async function GET() {
const metrics = {
uptime: process.uptime(),
memory: process.memoryUsage(),
apiCalls: {
total: await getAPICallCount(),
today: await getTodayAPICallCount(),
errors: await getErrorCount()
},
responseTime: {
average: await getAverageResponseTime(),
p95: await getP95ResponseTime()
}
};
return NextResponse.json(metrics);
}
JavaScript
복사
9. 마무리
Next.js의 라우터 시스템은 단순히 URL을 페이지에 연결하는 것 이상의 강력한 기능을 제공합니다. 특히 AI 애플리케이션 개발에서는 보안, 성능, 사용자 경험의 핵심이 되는 중요한 요소입니다.
이 가이드에서 다룬 내용을 정리하면:
1.
기본 개념: 라우터는 웹 애플리케이션의 길 안내 시스템
2.
AI 개발에서의 중요성: 보안, 데이터 처리, 성능 최적화의 핵심
3.
다양한 라우터 종류: 페이지, API, 동적, 병렬, 인터셉팅 등
4.
호출 방법: Link, useRouter, fetch, 서버 액션 등
5.
실전 예제: 완전한 AI 챗봇 구현
6.
최적화: 캐싱, 에러 처리, 로딩 상태
7.
보안: API 키 관리, 입력 검증, CORS
8.
모니터링: 로깅, 성능 측정
Next.js 라우터를 제대로 이해하고 활용하면,
•
안전하고 빠르며 사용자 친화적인 AI 애플리케이션을 만들 수 있습니다.
•
처음에는 복잡해 보일 수 있지만, 하나씩 실습하면서 익히다 보면 자연스럽게 마스터할 수 있을 것입니다.