Ver3.0 Next.js와 타입스크립트로 SSR 구현하기 🚀

Next.js와 타입스크립트로 SSR 구현하기 🚀
SSR이 뭔데? 왜 필요한 거야? 🤔
야, 웹 개발하다 보면 SSR이라는 말 진짜 많이 듣지? Server-Side Rendering의 약자인데, 말 그대로 서버에서 렌더링을 해준다는 뜻이야. 🖥️
예전에는 웹사이트가 다 이렇게 작동했어. 서버에서 HTML을 완성해서 보내주면 브라우저는 그냥 보여주기만 하면 됐거든. 근데 React 같은 SPA(Single Page Application) 프레임워크가 나오면서 CSR(Client-Side Rendering)이 대세가 됐지. 브라우저에서 자바스크립트로 모든 걸 렌더링하는 방식이야.
그런데 말이야, CSR에는 치명적인 문제가 있어. 첫 페이지 로딩이 느리고, SEO(검색 엔진 최적화)가 안 좋다는 거지. 구글 봇이 와서 페이지를 크롤링할 때 빈 HTML만 보게 되니까 검색 결과에 제대로 안 뜨는 거야. 😱
🔍 SEO 최적화: 검색 엔진 봇이 완성된 HTML을 크롤링할 수 있어서 검색 노출이 잘 돼
📱 저사양 기기 지원: 클라이언트에서 무거운 자바스크립트 처리를 안 해도 되니까 성능이 좋아져
Next.js는 이런 SSR을 엄청 쉽게 구현할 수 있게 해주는 프레임워크야. React 기반이라서 React 개발자라면 금방 배울 수 있고, 타입스크립트 지원도 완벽해서 타입 안정성까지 챙길 수 있지. ✨
Next.js 프로젝트 시작하기 🎯
자, 이제 본격적으로 Next.js와 타입스크립트로 SSR을 구현해보자! 먼저 프로젝트를 세팅해야 하는데, Next.js 팀이 만든 create-next-app을 사용하면 진짜 간단해. 🎨
프로젝트 생성하기
터미널을 열고 다음 명령어를 입력해봐:
npx create-next-app@latest my-ssr-app --typescript
이 명령어를 실행하면 몇 가지 질문이 나올 거야. 나는 보통 이렇게 선택해:
- ✅ Would you like to use ESLint? → Yes
- ✅ Would you like to use Tailwind CSS? → Yes (스타일링 편하게 하려면)
- ✅ Would you like to use `src/` directory? → Yes (코드 정리하기 좋아)
- ✅ Would you like to use App Router? → Yes (Next.js 13+ 최신 기능)
- ✅ Would you like to customize the default import alias? → No
설치가 끝나면 프로젝트 폴더로 이동해서 개발 서버를 실행해보자:
cd my-ssr-app
npm run dev
브라우저에서 http://localhost:3000을 열어보면 Next.js 기본 페이지가 뜰 거야. 축하해! 벌써 SSR이 작동하고 있어. 🎉
프로젝트 구조 이해하기
Next.js 13 이후 버전에서는 App Router를 사용하는데, 폴더 구조가 이렇게 생겼어:
my-ssr-app/
├── src/
│ └── app/
│ ├── layout.tsx # 전체 레이아웃
│ ├── page.tsx # 홈페이지
│ └── globals.css # 전역 스타일
├── public/ # 정적 파일
├── next.config.js # Next.js 설정
├── tsconfig.json # TypeScript 설정
└── package.json
app 폴더 안에 있는 파일들이 핵심이야. page.tsx는 라우트의 실제 페이지를 정의하고, layout.tsx는 여러 페이지에서 공유하는 레이아웃을 정의해. 📁
타입스크립트 설정 완벽하게 하기 ⚙️
Next.js는 타입스크립트를 기본으로 지원하지만, 좀 더 엄격하게 타입 체크를 하고 싶다면 tsconfig.json을 수정해야 해. 내가 실무에서 쓰는 설정을 공유할게!
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
타입 정의 파일 만들기
프로젝트에서 사용할 공통 타입들을 정의해두면 코드 재사용성이 높아져. src/types 폴더를 만들고 타입 정의 파일을 추가해보자:
// src/types/index.ts
export interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
export interface Post {
id: number;
title: string;
content: string;
author: User;
createdAt: string;
updatedAt: string;
}
export interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}
이렇게 타입을 정의해두면 다른 파일에서 import해서 쓸 수 있어. 타입 안정성도 높아지고 자동완성도 잘 돼서 개발 속도가 빨라져! ⚡
SSR의 핵심: Server Components 이해하기 🔥
Next.js 13부터 도입된 Server Components는 SSR의 게임 체인저야. 기본적으로 app 디렉토리 안의 모든 컴포넌트는 Server Component로 작동해. 🚀
Server Component는 서버에서만 실행되고, 클라이언트로 자바스크립트 번들을 보내지 않아. 그래서 번들 크기가 줄어들고 성능이 좋아지는 거지!
Server Component 만들기
간단한 블로그 포스트 목록을 보여주는 Server Component를 만들어보자:
// src/app/posts/page.tsx
import { Post } from '@/types';
async function getPosts(): Promise<Post[]> {
// 실제로는 API나 데이터베이스에서 가져오겠지만
// 여기서는 예시로 fetch를 사용할게
const res = await fetch('https://api.example.com/posts', {
// Next.js의 강력한 캐싱 기능
next: { revalidate: 60 } // 60초마다 재검증
});
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">블로그 포스트</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<article
key={post.id}
className="border rounded-lg p-6 hover:shadow-lg transition"
>
<h2 className="text-2xl font-semibold mb-2">
{post.title}
</h2>
<p className="text-gray-600 mb-4">
{post.content.substring(0, 150)}...
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
by {post.author.name}
</span>
</div>
</article>
))}
</div>
</div>
);
}
🎯 await를 직접 사용해서 데이터를 가져와. useEffect나 useState 필요 없어
🎯 타입스크립트로 Post[] 타입을 명시해서 타입 안정성 확보
🎯 next: { revalidate: 60 }으로 ISR(Incremental Static Regeneration) 설정
Loading과 Error 처리하기
Next.js는 로딩 상태와 에러 처리를 위한 특별한 파일들을 제공해. 같은 폴더에 loading.tsx와 error.tsx를 만들면 자동으로 적용돼:
// src/app/posts/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse">
<div className="h-12 bg-gray-200 rounded w-1/4 mb-8"></div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="border rounded-lg p-6">
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</div>
</div>
);
}
// src/app/posts/error.tsx
'use client'; // Error 컴포넌트는 Client Component여야 해
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="container mx-auto px-4 py-8 text-center">
<h2 className="text-3xl font-bold mb-4">문제가 발생했어요! 😢</h2>
<p className="text-gray-600 mb-6">{error.message}</p>
<button
onClick={reset}
className="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600"
>
다시 시도하기
</button>
</div>
);
}
이렇게 하면 데이터를 가져오는 동안 로딩 UI가 자동으로 보이고, 에러가 발생하면 에러 UI가 보여져. 사용자 경험이 훨씬 좋아지지! ✨
Client Components와 상호작용 추가하기 🎮
Server Component는 좋지만, 사용자 인터랙션이 필요한 경우에는 Client Component를 써야 해. 버튼 클릭, 폼 입력, 애니메이션 같은 거 말이야. 🖱️
Client Component를 만들려면 파일 맨 위에 'use client' 지시어를 추가하면 돼.
좋아요 버튼 만들기
// src/components/LikeButton.tsx
'use client';
import { useState } from 'react';
interface LikeButtonProps {
postId: number;
initialLikes: number;
}
export default function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleLike = async () => {
if (isLoading) return;
setIsLoading(true);
try {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
setLikes(prev => isLiked ? prev - 1 : prev + 1);
setIsLiked(prev => !prev);
}
} catch (error) {
console.error('Failed to like post:', error);
} finally {
setIsLoading(false);
}
};
return (
<button
onClick={handleLike}
disabled={isLoading}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg transition
${isLiked
? 'bg-red-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
${isLoading ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<span>{isLiked ? '❤️' : '🤍'}</span>
<span>{likes}</span>
</button>
);
}
이제 이 Client Component를 Server Component에서 사용할 수 있어:
// src/app/posts/page.tsx
import LikeButton from '@/components/LikeButton';
export default async function PostsPage() {
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-8">
{posts.map((post) => (
<article key={post.id} className="border rounded-lg p-6 mb-6">
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-4">{post.content}</p>
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
))}
</div>
);
}
• 데이터 페칭이 필요할 때
• 백엔드 리소스에 직접 접근할 때
• 민감한 정보를 다룰 때 (API 키 등)
• 큰 의존성 라이브러리를 사용할 때
Client Component를 써야 할 때:
• 이벤트 리스너가 필요할 때 (onClick, onChange 등)
• State나 Effect를 사용할 때
• 브라우저 전용 API를 사용할 때
• 커스텀 훅을 사용할 때
Dynamic Routes로 상세 페이지 만들기 🛣️
블로그 포스트 상세 페이지처럼 동적인 라우트가 필요할 때는 폴더 이름을 대괄호로 감싸면 돼. [id] 이런 식으로 말이야. 📄
동적 라우트 구조
src/app/posts/
├── page.tsx # /posts
├── [id]/
│ ├── page.tsx # /posts/123
│ ├── loading.tsx
│ └── error.tsx
상세 페이지 구현하기
// src/app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';
import { Post } from '@/types';
import LikeButton from '@/components/LikeButton';
interface PostPageProps {
params: {
id: string;
};
}
async function getPost(id: string): Promise<Post | null> {
try {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 60 }
});
if (!res.ok) {
return null;
}
return res.json();
} catch (error) {
console.error('Failed to fetch post:', error);
return null;
}
}
// 메타데이터 생성 (SEO 최적화!)
export async function generateMetadata({ params }: PostPageProps) {
const post = await getPost(params.id);
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.title,
description: post.content.substring(0, 160),
openGraph: {
title: post.title,
description: post.content.substring(0, 160),
type: 'article',
authors: [post.author.name],
},
};
}
export default async function PostPage({ params }: PostPageProps) {
const post = await getPost(params.id);
if (!post) {
notFound(); // 404 페이지로 리다이렉트
}
return (
<article className="container mx-auto px-4 py-8 max-w-4xl">
<header className="mb-8">
<h1 className="text-5xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600">
<div className="flex items-center gap-2">
{post.author.avatar && (
<img
src={post.author.avatar}
alt={post.author.name}
className="w-10 h-10 rounded-full"
/>
)}
<span>{post.author.name}</span>
</div>
<time dateTime={post.createdAt}>
{new Date(post.createdAt).toLocaleDateString('ko-KR')}
</time>
</div>
</header>
<div className="prose prose-lg max-w-none mb-8">
{post.content}
</div>
<footer className="border-t pt-6">
<LikeButton postId={post.id} initialLikes={post.likes} />
</footer>
</article>
);
}
Static Generation으로 성능 극대화하기
포스트 ID 목록을 미리 알고 있다면, generateStaticParams를 사용해서 빌드 타임에 페이지를 미리 생성할 수 있어:
// src/app/posts/[id]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
return posts.map((post: Post) => ({
id: post.id.toString(),
}));
}
// 이제 이 페이지는 빌드 타임에 미리 생성돼!
// 엄청나게 빠른 로딩 속도를 자랑하지 🚀
이렇게 하면 SSG(Static Site Generation)와 SSR의 장점을 모두 가져갈 수 있어. 자주 바뀌지 않는 콘텐츠는 미리 생성하고, 필요할 때만 재검증하는 거지. ⚡
API Routes로 백엔드 구축하기 🔧
Next.js의 또 다른 강력한 기능은 API Routes야. 별도의 백엔드 서버 없이도 API 엔드포인트를 만들 수 있어. 🎯
App Router에서는 route.ts 파일을 사용해서 API를 만들어.
간단한 API 만들기
// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Post } from '@/types';
// GET /api/posts
export async function GET(request: NextRequest) {
try {
// 실제로는 데이터베이스에서 가져오겠지만
// 여기서는 예시로 하드코딩
const posts: Post[] = [
{
id: 1,
title: 'Next.js 시작하기',
content: 'Next.js는 정말 멋진 프레임워크야...',
author: {
id: 1,
name: '김개발',
email: 'dev@example.com',
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
// ... 더 많은 포스트
];
// 쿼리 파라미터 처리
const searchParams = request.nextUrl.searchParams;
const limit = searchParams.get('limit');
const filteredPosts = limit
? posts.slice(0, parseInt(limit))
: posts;
return NextResponse.json({
data: filteredPosts,
success: true,
message: 'Posts fetched successfully',
});
} catch (error) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Failed to fetch posts',
},
{ status: 500 }
);
}
}
// POST /api/posts
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 타입 검증
if (!body.title || !body.content) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Title and content are required',
},
{ status: 400 }
);
}
// 새 포스트 생성 로직
const newPost: Post = {
id: Date.now(),
title: body.title,
content: body.content,
author: body.author,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return NextResponse.json(
{
data: newPost,
success: true,
message: 'Post created successfully',
},
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Failed to create post',
},
{ status: 500 }
);
}
}
Dynamic API Routes
특정 포스트를 가져오는 API도 만들어보자:
// src/app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface RouteParams {
params: {
id: string;
};
}
// GET /api/posts/[id]
export async function GET(
request: NextRequest,
{ params }: RouteParams
) {
try {
const postId = parseInt(params.id);
// 데이터베이스에서 포스트 조회
// const post = await db.post.findUnique({ where: { id: postId } });
// 예시 데이터
const post = {
id: postId,
title: `포스트 ${postId}`,
content: '내용...',
};
if (!post) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Post not found',
},
{ status: 404 }
);
}
return NextResponse.json({
data: post,
success: true,
message: 'Post fetched successfully',
});
} catch (error) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Failed to fetch post',
},
{ status: 500 }
);
}
}
// PATCH /api/posts/[id]
export async function PATCH(
request: NextRequest,
{ params }: RouteParams
) {
try {
const postId = parseInt(params.id);
const body = await request.json();
// 포스트 업데이트 로직
const updatedPost = {
id: postId,
...body,
updatedAt: new Date().toISOString(),
};
return NextResponse.json({
data: updatedPost,
success: true,
message: 'Post updated successfully',
});
} catch (error) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Failed to update post',
},
{ status: 500 }
);
}
}
// DELETE /api/posts/[id]
export async function DELETE(
request: NextRequest,
{ params }: RouteParams
) {
try {
const postId = parseInt(params.id);
// 포스트 삭제 로직
// await db.post.delete({ where: { id: postId } });
return NextResponse.json({
data: { id: postId },
success: true,
message: 'Post deleted successfully',
});
} catch (error) {
return NextResponse.json(
{
data: null,
success: false,
message: 'Failed to delete post',
},
{ status: 500 }
);
}
}
• 인증/인가 체크 필수
• 입력값 검증 및 새니타이징
• Rate Limiting 구현
• CORS 설정
• 민감한 정보는 환경 변수로 관리
실제 프로덕션에서는 NextAuth.js 같은 라이브러리를 사용하는 걸 추천해! 🔒
데이터 페칭 전략 마스터하기 📊
Next.js는 다양한 데이터 페칭 전략을 제공해. 상황에 맞게 선택하면 성능을 극대화할 수 있어! 🚀
1. Static Data Fetching (기본)
// 빌드 타임에 한 번만 실행
async function getData() {
const res = await fetch('https://api.example.com/data');
return res.json();
}
2. Revalidation (ISR)
// 60초마다 재검증
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
});
return res.json();
}
3. Dynamic Data Fetching
// 매 요청마다 새로 가져오기
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
return res.json();
}
4. Parallel Data Fetching
여러 데이터를 동시에 가져와서 성능을 높일 수 있어:
export default async function Page() {
// 병렬로 실행돼서 빨라!
const [posts, users, comments] = await Promise.all([
getPosts(),
getUsers(),
getComments(),
]);
return (
<div>
{/* 렌더링 */}
</div>
);
}
5. Sequential Data Fetching
이전 데이터에 의존하는 경우 순차적으로 가져와야 해:
export default async function Page({ params }: { params: { id: string } }) {
// 먼저 사용자 정보를 가져오고
const user = await getUser(params.id);
// 그 다음 사용자의 포스트를 가져와
const posts = await getUserPosts(user.id);
return (
<div>
<h1>{user.name}의 포스트</h1>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
);
}
6. Streaming with Suspense
React Suspense를 사용하면 데이터를 스트리밍할 수 있어. 일부 콘텐츠는 먼저 보여주고, 느린 데이터는 나중에 로드하는 거지:
// src/app/dashboard/page.tsx
import { Suspense } from 'react';
async function SlowComponent() {
// 느린 데이터 페칭
await new Promise(resolve => setTimeout(resolve, 3000));
const data = await fetch('https://api.example.com/slow-data').then(r => r.json());
return <div>{data.content}</div>;
}
export default function Dashboard() {
return (
<div>
<h1>대시보드</h1>
{/* 이 부분은 즉시 보여져 */}
<div>빠른 콘텐츠</div>
{/* 이 부분은 로딩 후 보여져 */}
<Suspense fallback={<div>로딩 중...</div>}>
<SlowComponent />
</Suspense>
</div>
);
}
ISR: 제품 목록, 뉴스 피드, 자주 업데이트되는 콘텐츠
Dynamic: 사용자 대시보드, 실시간 데이터, 개인화된 콘텐츠
Streaming: 복잡한 대시보드, 여러 데이터 소스가 있는 페이지
실전 프로젝트: 블로그 플랫폼 만들기 🎨
지금까지 배운 걸 종합해서 실제로 동작하는 블로그 플랫폼을 만들어보자! 재능넷 같은 플랫폼에서 지식을 공유하는 블로그를 상상해봐. 📝
프로젝트 구조
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── posts/
│ │ ├── page.tsx
│ │ ├── [slug]/
│ │ │ ├── page.tsx
│ │ │ └── loading.tsx
│ │ └── new/
│ │ └── page.tsx
│ ├── api/
│ │ └── posts/
│ │ ├── route.ts
│ │ └── [id]/
│ │ └── route.ts
│ └── globals.css
├── components/
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── PostCard.tsx
│ ├── PostEditor.tsx
│ └── LikeButton.tsx
├── lib/
│ ├── db.ts
│ └── utils.ts
└── types/
└── index.ts
레이아웃 구성하기
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
default: '재능넷 블로그',
template: '%s | 재능넷 블로그',
},
description: '다양한 재능과 지식을 공유하는 블로그 플랫폼',
keywords: ['블로그', '재능공유', '지식', 'Next.js', 'TypeScript'],
authors: [{ name: '재능넷' }],
openGraph: {
type: 'website',
locale: 'ko_KR',
url: 'https://blog.jaenung.net',
siteName: '재능넷 블로그',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className={inter.className}>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</div>
</body>
</html>
);
}
헤더 컴포넌트
// src/components/Header.tsx
import Link from 'next/link';
export default function Header() {
return (
<header className="bg-white shadow-sm">
<nav className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="text-2xl font-bold text-purple-600">
재능넷 블로그 ✨
</Link>
<div className="flex items-center gap-6">
<Link
href="/posts"
className="text-gray-700 hover:text-purple-600 transition"
>
포스트
</Link>
<Link
href="/posts/new"
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition"
>
글쓰기
</Link>
</div>
</div>
</nav>
</header>
);
}
포스트 카드 컴포넌트
// src/components/PostCard.tsx
import Link from 'next/link';
import { Post } from '@/types';
interface PostCardProps {
post: Post;
}
export default function PostCard({ post }: PostCardProps) {
return (
<Link href={`/posts/${post.id}`}>
<article className="border rounded-lg p-6 hover:shadow-lg transition-shadow cursor-pointer">
<h2 className="text-2xl font-semibold mb-3 text-gray-900 hover:text-purple-600 transition">
{post.title}
</h2>
<p className="text-gray-600 mb-4 line-clamp-3">
{post.content}
</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center gap-2">
{post.author.avatar && (
<img
src={post.author.avatar}
alt={post.author.name}
className="w-8 h-8 rounded-full"
/>
)}
<span>{post.author.name}</span>
</div>
<time dateTime={post.createdAt}>
{new Date(post.createdAt).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
</article>
</Link>
);
}
포스트 목록 페이지
// src/app/posts/page.tsx
import { Post } from '@/types';
import PostCard from '@/components/PostCard';
async function getPosts(): Promise<Post[]> {
const res = await fetch('http://localhost:3000/api/posts', {
next: { revalidate: 60 }
});
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
const data = await res.json();
return data.data;
}
export const metadata = {
title: '모든 포스트',
description: '재능넷 블로그의 모든 포스트를 확인하세요',
};
export default async function PostsPage() {
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-12">
<div className="mb-12">
<h1 className="text-5xl font-bold mb-4">모든 포스트 📚</h1>
<p className="text-xl text-gray-600">
재능넷에서 다양한 지식과 경험을 공유하는 글들을 만나보세요
</p>
</div>
{posts.length === 0 ? (
<div className="text-center py-20">
<p className="text-2xl text-gray-500 mb-4">아직 포스트가 없어요 😢</p>
<Link
href="/posts/new"
className="inline-block bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700 transition"
>
첫 포스트 작성하기
</Link>
</div>
) : (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
);
}
폼 처리와 Server Actions 🎯
Next.js 13.4부터 도입된 Server Actions는 폼 처리를 혁신적으로 바꿨어. 클라이언트 자바스크립트 없이도 폼을 제출할 수 있고, 타입 안정성도 보장돼! 🚀
Server Action 만들기
// src/app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// 유효성 검사
if (!title || !content) {
return {
error: '제목과 내용을 모두 입력해주세요',
};
}
if (title.length < 5) {
return {
error: '제목은 최소 5자 이상이어야 합니다',
};
}
try {
// 데이터베이스에 저장
const response = await fetch('http://localhost:3000/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
throw new Error('Failed to create post');
}
const result = await response.json();
// 캐시 무효화
revalidatePath('/posts');
// 새 포스트 페이지로 리다이렉트
redirect(`/posts/${result.data.id}`);
} catch (error) {
return {
error: '포스트 생성에 실패했습니다',
};
}
}
export async function updatePost(id: number, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
try {
const response = await fetch(`http://localhost:3000/api/posts/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
throw new Error('Failed to update post');
}
revalidatePath(`/posts/${id}`);
revalidatePath('/posts');
return { success: true };
} catch (error) {
return {
error: '포스트 수정에 실패했습니다',
};
}
}
export async function deletePost(id: number) {
try {
const response = await fetch(`http://localhost:3000/api/posts/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete post');
}
revalidatePath('/posts');
redirect('/posts');
} catch (error) {
return {
error: '포스트 삭제에 실패했습니다',
};
}
}
폼 컴포넌트 만들기
// src/components/PostForm.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '@/app/actions/posts';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={`
w-full bg-purple-600 text-white py-3 rounded-lg font-semibold
hover:bg-purple-700 transition
${pending ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
{pending ? '작성 중...' : '포스트 작성하기'}
</button>
);
}
export default function PostForm() {
const [state, formAction] = useFormState(createPost, { error: '' });
return (
<form action={formAction} className="space-y-6">
{state?.error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{state.error}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
제목
</label>
<input
type="text"
id="title"
name="title"
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="포스트 제목을 입력하세요"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
내용
</label>
<textarea
id="content"
name="content"
required
rows={15}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="포스트 내용을 입력하세요"
/>
</div>
<SubmitButton />
</form>
);
}
새 포스트 작성 페이지
// src/app/posts/new/page.tsx
import PostForm from '@/components/PostForm';
export const metadata = {
title: '새 포스트 작성',
description: '새로운 포스트를 작성하세요',
};
export default function NewPostPage() {
return (
<div className="container mx-auto px-4 py-12 max-w-4xl">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-4">새 포스트 작성 ✍️</h1>
<p className="text-gray-600">
재능넷에서 당신의 지식과 경험을 공유해보세요
</p>
</div>
<div className="bg-white rounded-lg shadow-sm border p-8">
<PostForm />
</div>
</div>
);
}
- ✅ 타입 안정성: 타입스크립트로 완벽하게 타입 체크
- ✅ Progressive Enhancement: 자바스크립트 없이도 작동
- ✅ 자동 재검증: revalidatePath로 캐시 자동 갱신
- ✅ 간단한 에러 처리: try-catch로 쉽게 에러 핸들링
- ✅ 보안: 서버에서만 실행되어 민감한 로직 보호
성능 최적화 팁 ⚡
SSR을 구현했다고 끝이 아니야. 성능 최적화를 통해 사용자 경험을 극대화해야 해! 🚀
1. 이미지 최적화
Next.js의 Image 컴포넌트를 사용하면 자동으로 이미지를 최적화해줘:
import Image from 'next/image';
export default function PostImage() {
return (
<Image
src="/images/post-cover.jpg"
alt="포스트 커버 이미지"
width={1200}
height={630}
priority // 중요한 이미지는 우선 로드
placeholder="blur" // 블러 효과로 로딩 개선
blurDataURL="data:image/jpeg;base64,..." // 블러 이미지 데이터
/>
);
}
2. 폰트 최적화
// src/app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 폰트 로딩 중에도 텍스트 표시
variable: '--font-inter',
});
const notoSansKR = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-noto-sans-kr',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
<body>{children}</body>
</html>
);
}
3. 코드 스플리팅
동적 import를 사용해서 필요한 코드만 로드해:
import dynamic from 'next/dynamic';
// 클라이언트에서만 필요한 무거운 컴포넌트
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div>차트 로딩 중...</div>,
ssr: false, // SSR 비활성화
});
export default function Dashboard() {
return (
<div>
<h1>대시보드</h1>
<HeavyChart />
</div>
);
}
4. 메타데이터 최적화
// src/app/posts/[id]/page.tsx
export async function generateMetadata({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return {
title: post.title,
description: post.content.substring(0, 160),
keywords: post.tags,
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.content.substring(0, 160),
type: 'article',
publishedTime: post.createdAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.content.substring(0, 160),
images: [post.coverImage],
},
};
}
5. 캐싱 전략
// 다양한 캐싱 옵션
const res = await fetch('https://api.example.com/data', {
// 1. 정적 생성 (기본)
cache: 'force-cache',
// 2. 매 요청마다 새로 가져오기
cache: 'no-store',
// 3. 시간 기반 재검증
next: { revalidate: 3600 }, // 1시간
// 4. 태그 기반 재검증
next: { tags: ['posts'] },
});
// 다른 곳에서 태그로 재검증
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
• Web Vitals: Next.js가 자동으로 측정해주는 핵심 지표
• Next.js Analytics: Vercel에서 제공하는 실시간 분석
• Bundle Analyzer: 번들 크기 분석 도구
정기적으로 성능을 측정하고 개선하는 습관을 들이자! 📊
배포하기 🚀
개발이 끝났으면 이제 세상에 공개할 차례야! Next.js 앱을 배포하는 방법은 여러 가지가 있어. 🌍
1. Vercel (가장 쉬움)
Next.js를 만든 Vercel에서 배포하면 정말 간단해:
2. Vercel 계정 생성 (https://vercel.com)
3. "New Project" 클릭
4. GitHub 저장소 연결
5. 자동으로 빌드 및 배포! 🎉
환경 변수도 대시보드에서 쉽게 설정할 수 있어.
2. Docker로 배포
# Dockerfile
FROM node:18-alpine AS base
# 의존성 설치
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 빌드
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 프로덕션
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
nextjs:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
restart: unless-stopped
3. 정적 내보내기
완전히 정적인 사이트로 만들고 싶다면:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true, // 정적 내보내기에서는 이미지 최적화 비활성화
},
};
module.exports = nextConfig;
# 빌드 후 out 폴더가 생성돼
npm run build
# 이 폴더를 어디든 호스팅할 수 있어
# Netlify, GitHub Pages, AWS S3 등
환경 변수 설정
# .env.local (로컬 개발용)
DATABASE_URL=postgresql://localhost:5432/mydb
NEXT_PUBLIC_API_URL=http://localhost:3000/api
SECRET_KEY=your-secret-key
# .env.production (프로덕션용)
DATABASE_URL=postgresql://production-db-url
NEXT_PUBLIC_API_URL=https://api.yoursite.com
SECRET_KEY=production-secret-key
• 민감한 정보(API 키, 비밀번호)는 절대 NEXT_PUBLIC_을 붙이지 마
• .env.local은 절대 Git에 커밋하지 마
• 프로덕션 환경 변수는 호스팅 플랫폼에서 안전하게 관리해
실전 팁과 베스트 프랙티스 💎
마지막으로 실무에서 유용한 팁들을 공유할게! 🎓
1. 에러 바운더리 활용
// src/app/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 에러 로깅 서비스로 전송
console.error('Error:', error);
// Sentry, LogRocket 등으로 전송
}, [error]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-3xl font-bold mb-4">앗! 문제가 발생했어요 😢</h2>
<p className="text-gray-600 mb-6">
{error.message || '알 수 없는 오류가 발생했습니다'}
</p>
<button
onClick={reset}
className="bg-purple-600 text-white px-6 py-3 rounded-lg hover:bg-purple-700"
>
다시 시도하기
</button>
</div>
</div>
);
}
2. 로딩 상태 개선
// src/app/posts/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto px-4 py-12">
<div className="animate-pulse">
{/* 스켈레톤 UI */}
<div className="h-12 bg-gray-200 rounded w-1/3 mb-8"></div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="border rounded-lg p-6">
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-2/3"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
3. 타입 안전한 환경 변수
// src/lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_API_URL: z.string().url(),
SECRET_KEY: z.string().min(32),
});
export const env = envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
SECRET_KEY: process.env.SECRET_KEY,
});
// 이제 env.DATABASE_URL은 타입 안전해!
4. 커스텀 훅 만들기
// src/hooks/useDebounce.ts
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// 사용 예시
function SearchComponent() {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 500);
useEffect(() => {
// API 호출
if (debouncedSearch) {
fetchResults(debouncedSearch);
}
}, [debouncedSearch]);
return (
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="검색..."
/>
);
}
5. 접근성 고려하기
// 시맨틱 HTML 사용
<article>
<header>
<h1>{post.title}</h1>
<time dateTime={post.createdAt}>
{formatDate(post.createdAt)}
</time>
</header>
<main>
{post.content}
</main>
<footer>
<button aria-label="좋아요">
❤️ {post.likes}
</button>
</footer>
</article>
// 키보드 네비게이션
<button
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
tabIndex={0}
>
클릭
</button>
• Husky + lint-staged: 커밋 전 자동 검사
• TypeScript strict 모드: 타입 안정성 극대화
• Storybook: 컴포넌트 독립적으로 개발
• Jest + Testing Library: 테스트 자동화
처음엔 설정이 귀찮아도, 나중에 엄청난 시간을 절약해줘! ⏰
마무리하며 🎉
와! 여기까지 왔다니 정말 대단해! Next.js와 타입스크립트로 SSR을 구현하는 방법을 처음부터 끝까지 다뤄봤어. 🎊
우리가 배운 내용을 정리해보면:
- ✅ SSR의 개념과 장점 이해
- ✅ Next.js 프로젝트 세팅과 타입스크립트 설정
- ✅ Server Components와 Client Components 구분
- ✅ Dynamic Routes와 메타데이터 최적화
- ✅ API Routes로 백엔드 구축
- ✅ Server Actions로 폼 처리
- ✅ 다양한 데이터 페칭 전략
- ✅ 성능 최적화 기법
- ✅ 배포 방법과 베스트 프랙티스
Next.js는 계속 발전하고 있어. 새로운 기능들이 계속 추가되고 있으니, 공식 문서를 자주 확인하는 습관을 들이는 게 좋아. 📚
그리고 재능넷 같은 플랫폼에서 다른 개발자들과 지식을 공유하면서 함께 성장하는 것도 잊지 마! 혼자 공부하는 것보다 훨씬 빠르게 배울 수 있거든. 🤝
이제 당신도 Next.js와 타입스크립트로 멋진 SSR 애플리케이션을 만들 수 있어. 처음엔 어려울 수 있지만, 하나씩 따라하다 보면 어느새 전문가가 되어 있을 거야! 💪
궁금한 점이 있으면 언제든 Next.js 공식 문서나 커뮤니티에 물어봐. 개발자 커뮤니티는 정말 친절하거든! 😊
자, 이제 당신의 프로젝트를 시작해볼 시간이야. 화이팅! 🚀
🎯 ✨ 💻 🚀 🎉
Happy Coding!
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.