콘텐츠 대표 이미지 - Next.js와 타입스크립트로 SSR 구현하기 🚀

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

서버 사이드 렌더링의 모든 것을 친구처럼 쉽게 알려줄게! 💡

SSR이 뭔데? 왜 필요한 거야? 🤔

야, 웹 개발하다 보면 SSR이라는 말 진짜 많이 듣지? Server-Side Rendering의 약자인데, 말 그대로 서버에서 렌더링을 해준다는 뜻이야. 🖥️

예전에는 웹사이트가 다 이렇게 작동했어. 서버에서 HTML을 완성해서 보내주면 브라우저는 그냥 보여주기만 하면 됐거든. 근데 React 같은 SPA(Single Page Application) 프레임워크가 나오면서 CSR(Client-Side Rendering)이 대세가 됐지. 브라우저에서 자바스크립트로 모든 걸 렌더링하는 방식이야.

그런데 말이야, CSR에는 치명적인 문제가 있어. 첫 페이지 로딩이 느리고, SEO(검색 엔진 최적화)가 안 좋다는 거지. 구글 봇이 와서 페이지를 크롤링할 때 빈 HTML만 보게 되니까 검색 결과에 제대로 안 뜨는 거야. 😱

SSR의 핵심 장점 3가지: ⚡ 빠른 초기 로딩: 서버에서 완성된 HTML을 받아오니까 사용자가 콘텐츠를 빨리 볼 수 있어
🔍 SEO 최적화: 검색 엔진 봇이 완성된 HTML을 크롤링할 수 있어서 검색 노출이 잘 돼
📱 저사양 기기 지원: 클라이언트에서 무거운 자바스크립트 처리를 안 해도 되니까 성능이 좋아져

Next.js는 이런 SSR을 엄청 쉽게 구현할 수 있게 해주는 프레임워크야. React 기반이라서 React 개발자라면 금방 배울 수 있고, 타입스크립트 지원도 완벽해서 타입 안정성까지 챙길 수 있지. ✨

Server Next.js TypeScript 🖥️ Browser React Hydration 🌐 HTML

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"]
}
💡 Pro Tip: strict: true를 켜면 타입 체크가 엄격해져서 처음엔 에러가 많이 날 수 있어. 하지만 이게 나중에 버그를 엄청 줄여주거든. 처음부터 strict 모드로 개발하는 습관을 들이는 게 좋아! 💪

타입 정의 파일 만들기

프로젝트에서 사용할 공통 타입들을 정의해두면 코드 재사용성이 높아져. 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>
  );
}
여기서 주목할 점: 🎯 컴포넌트 함수가 async야! Server Component는 비동기 함수로 만들 수 있어
🎯 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>
  );
}
💡 Server vs Client Component 선택 가이드: Server Component를 써야 할 때:
• 데이터 페칭이 필요할 때
• 백엔드 리소스에 직접 접근할 때
• 민감한 정보를 다룰 때 (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>
  );
}
generateMetadata의 마법 ✨ generateMetadata 함수는 Next.js의 킬러 기능 중 하나야. 각 페이지마다 동적으로 메타데이터를 생성해서 SEO를 최적화할 수 있어. 소셜 미디어에 공유했을 때 예쁜 카드가 나오는 것도 이 덕분이지! 🎴

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의 장점을 모두 가져갈 수 있어. 자주 바뀌지 않는 콘텐츠는 미리 생성하고, 필요할 때만 재검증하는 거지. ⚡

SSG Build Time ⚡ Pre-rendered Ultra Fast ISR Revalidate 🔄 Best of Both Smart Cache SSR Request Time 🎯 Always Fresh Dynamic

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 }
    );
  }
}
⚠️ 보안 주의사항: API Routes를 만들 때는 항상 보안을 고려해야 해:

• 인증/인가 체크 필수
• 입력값 검증 및 새니타이징
• 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>
  );
}
💡 데이터 페칭 전략 선택 가이드: Static (기본): 블로그 포스트, 문서, 마케팅 페이지
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>
  );
}
Server Actions의 장점:
  • ✅ 타입 안정성: 타입스크립트로 완벽하게 타입 체크
  • ✅ 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');
💡 성능 측정 도구: • Lighthouse: 크롬 개발자 도구에서 성능 점수 확인
• Web Vitals: Next.js가 자동으로 측정해주는 핵심 지표
• Next.js Analytics: Vercel에서 제공하는 실시간 분석
• Bundle Analyzer: 번들 크기 분석 도구

정기적으로 성능을 측정하고 개선하는 습관을 들이자! 📊
⚡ Performance 🎯 SEO ✨ UX

배포하기 🚀

개발이 끝났으면 이제 세상에 공개할 차례야! Next.js 앱을 배포하는 방법은 여러 가지가 있어. 🌍

1. Vercel (가장 쉬움)

Next.js를 만든 Vercel에서 배포하면 정말 간단해:

Vercel 배포 단계: 1. GitHub에 코드 푸시
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
⚠️ 보안 주의: • NEXT_PUBLIC_ 접두사가 붙은 환경 변수만 클라이언트에 노출돼
• 민감한 정보(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>
💡 개발 생산성 팁: • ESLint + Prettier: 코드 스타일 자동 정리
• 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
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.