Recent Posts
Recent Comments
반응형
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

오늘도 공부

NextJs 풀스택 개발 가이드(예제: 블로그) 본문

스터디/WEB

NextJs 풀스택 개발 가이드(예제: 블로그)

행복한 수지아빠 2025. 7. 11. 17:09
반응형

프로젝트 개요

Next.js 15 App Router, NeonDB, Drizzle ORM, tRPC, Better Auth를 활용한 현대적인 풀스택 블로그 애플리케이션 구축 가이드입니다. 이 가이드는 완전한 타입 안전성과 최적화된 성능을 갖춘 프로덕션 레벨의 애플리케이션을 만드는 방법을 단계별로 설명합니다.

1. 프로젝트 초기 설정

Next.js 15 프로젝트 생성

npx create-next-app@latest my-blog-app

설정 프롬프트에서 다음과 같이 선택합니다:

✔ Would you like to use TypeScript? → Yes
✔ Would you like to use ESLint? → Yes
✔ Would you like to use Tailwind CSS? → Yes
✔ Would you like your code inside a `src/` directory? → Yes
✔ Would you like to use App Router? (recommended) → Yes
✔ Would you like to use Turbopack for `next dev`? → Yes
✔ Would you like to customize the import alias? → Yes (기본값 @/*)

프로젝트 구조 설정

📦 my-blog-app/
├── 📂 src/
│   ├── 📂 app/
│   │   ├── 📄 globals.css
│   │   ├── 📄 layout.tsx
│   │   ├── 📄 page.tsx
│   │   ├── 📂 api/
│   │   │   ├── 📂 auth/[...all]/
│   │   │   └── 📂 trpc/[trpc]/
│   │   └── 📂 blog/
│   ├── 📂 components/
│   ├── 📂 db/
│   │   └── 📄 schema.ts
│   ├── 📂 lib/
│   │   ├── 📄 auth.ts
│   │   └── 📄 db.ts
│   └── 📂 trpc/
│       ├── 📄 init.ts
│       ├── 📄 react.tsx
│       └── 📂 routers/
├── 📄 .env.local
├── 📄 drizzle.config.ts
└── 📄 package.json

필수 패키지 설치

# 데이터베이스 관련
npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit

# tRPC 관련
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@latest
npm install zod superjson server-only

# Better Auth
npm install better-auth

# 유틸리티
npm install dotenv clsx

2. NeonDB 데이터베이스 설정

NeonDB 프로젝트 생성

  1. Neon Console에 접속하여 계정 생성
  2. "New Project" 클릭하여 새 프로젝트 생성
  3. 데이터베이스 연결 문자열 복사

환경 변수 설정

.env.local 파일 생성:

# NeonDB
DATABASE_URL="postgresql://username:password@ep-cool-darkness-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
DIRECT_URL="postgresql://username:password@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb"

# Better Auth
BETTER_AUTH_SECRET=your-secret-key-here
BETTER_AUTH_URL=http://localhost:3000

3. Drizzle ORM 설정 및 스키마 정의

Drizzle 설정 파일

drizzle.config.ts:

import { defineConfig } from 'drizzle-kit';
import { config } from 'dotenv';

config({ path: '.env.local' });

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

데이터베이스 연결 설정

src/lib/db.ts:

import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { config } from 'dotenv';
import * as schema from '../db/schema';

config({ path: '.env.local' });

const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

블로그 스키마 정의

src/db/schema.ts:

import { 
  pgTable, 
  serial, 
  text, 
  timestamp, 
  varchar, 
  integer,
  boolean 
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// Users 테이블
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  avatar: text('avatar'),
  bio: text('bio'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

// Posts 테이블
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  slug: varchar('slug', { length: 255 }).notNull().unique(),
  content: text('content').notNull(),
  excerpt: text('excerpt'),
  featuredImage: text('featured_image'),
  published: boolean('published').default(false).notNull(),
  authorId: integer('author_id').references(() => users.id, { 
    onDelete: 'cascade' 
  }).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  deletedAt: timestamp('deleted_at'), // 소프트 삭제용
});

// Comments 테이블
export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  content: text('content').notNull(),
  authorId: integer('author_id').references(() => users.id, { 
    onDelete: 'cascade' 
  }).notNull(),
  postId: integer('post_id').references(() => posts.id, { 
    onDelete: 'cascade' 
  }).notNull(),
  parentId: integer('parent_id').references(() => comments.id, { 
    onDelete: 'cascade' 
  }), // 대댓글 지원
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  deletedAt: timestamp('deleted_at'),
});

// Relations 정의
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
  comments: many(comments),
}));

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  comments: many(comments),
}));

export const commentsRelations = relations(comments, ({ one, many }) => ({
  author: one(users, {
    fields: [comments.authorId],
    references: [users.id],
  }),
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
  parent: one(comments, {
    fields: [comments.parentId],
    references: [comments.id],
  }),
  replies: many(comments),
}));

// Type exports
export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;
export type InsertPost = typeof posts.$inferInsert;
export type SelectPost = typeof posts.$inferSelect;
export type InsertComment = typeof comments.$inferInsert;
export type SelectComment = typeof comments.$inferSelect;

마이그레이션 실행

package.json에 스크립트 추가:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  }
}

마이그레이션 실행:

npm run db:generate
npm run db:push

4. tRPC 설정 (App Router 호환)

tRPC 초기화

src/trpc/init.ts:

import { initTRPC, TRPCError } from "@trpc/server";
import { cache } from "react";
import superjson from "superjson";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { db } from "@/lib/db";

export const createTRPCContext = cache(async () => {
  const session = await auth.api.getSession({
    headers: await headers(),
  });
  
  return {
    db,
    session,
    user: session?.user,
  };
});

export type Context = Awaited<ReturnType<typeof createTRPCContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson,
});

// 미들웨어
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.session || !ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "인증이 필요합니다.",
    });
  }
  
  return next({
    ctx: {
      ...ctx,
      session: ctx.session,
      user: ctx.user,
    },
  });
});

export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;
export const protectedProcedure = baseProcedure.use(isAuthenticated);

API 라우트 핸들러

src/app/api/trpc/[trpc]/route.ts:

import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createTRPCContext } from "@/trpc/init";
import { appRouter } from "@/trpc/routers/_app";

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: createTRPCContext,
  });

export { handler as GET, handler as POST };

클라이언트 설정

src/trpc/react.tsx:

"use client";
import type { QueryClient } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { PropsWithChildren, useState } from "react";
import { makeQueryClient } from "./query-client";
import type { AppRouter } from "./routers/_app";
import superjson from "superjson";

export const trpc = createTRPCReact<AppRouter>();

let clientQueryClientSingleton: QueryClient;
function getQueryClient() {
  if (typeof window === "undefined") return makeQueryClient();
  return (clientQueryClientSingleton ??= makeQueryClient());
}

export function TRPCProvider(props: PropsWithChildren) {
  const queryClient = getQueryClient();
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          transformer: superjson,
          url: "/api/trpc",
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {props.children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

5. Better Auth 통합

Better Auth 설정

src/lib/auth.ts:

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false,
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7일
    updateAge: 60 * 60 * 24, // 24시간
  },
});

Auth API 라우트

src/app/api/auth/[...all]/route.ts:

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth.handler);

6. 블로그 CRUD 기능 구현

Post 라우터

src/trpc/routers/posts.ts:

import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createTRPCRouter, baseProcedure, protectedProcedure } from '../init';
import { posts } from '../../db/schema';
import { eq, and, desc, isNull } from 'drizzle-orm';

export const postRouter = createTRPCRouter({
  // 포스트 생성
  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(255),
      content: z.string().min(1),
      slug: z.string().min(1).max(255),
      excerpt: z.string().optional(),
      published: z.boolean().default(false),
    }))
    .mutation(async ({ ctx, input }) => {
      const { db, user } = ctx;
      
      // 슬러그 중복 체크
      const existingPost = await db.query.posts.findFirst({
        where: and(
          eq(posts.slug, input.slug),
          isNull(posts.deletedAt)
        ),
      });
      
      if (existingPost) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: '이미 존재하는 슬러그입니다.',
        });
      }
      
      const [newPost] = await db.insert(posts).values({
        ...input,
        authorId: user.id,
      }).returning();
      
      return newPost;
    }),

  // 포스트 목록 조회 (페이지네이션)
  getAll: baseProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.number().optional(),
      published: z.boolean().optional(),
    }))
    .query(async ({ ctx, input }) => {
      const { db } = ctx;
      const { limit, cursor } = input;
      
      const items = await db.query.posts.findMany({
        limit: limit + 1,
        where: and(
          cursor ? gt(posts.id, cursor) : undefined,
          input.published !== undefined ? eq(posts.published, input.published) : undefined,
          isNull(posts.deletedAt)
        ),
        orderBy: [desc(posts.createdAt)],
        with: {
          author: {
            columns: {
              id: true,
              name: true,
            },
          },
        },
      });
      
      let nextCursor: number | undefined = undefined;
      if (items.length > limit) {
        const nextItem = items.pop();
        nextCursor = nextItem!.id;
      }
      
      return {
        items,
        nextCursor,
      };
    }),

  // 개별 포스트 조회
  getBySlug: baseProcedure
    .input(z.string())
    .query(async ({ ctx, input }) => {
      const { db } = ctx;
      
      const post = await db.query.posts.findFirst({
        where: and(
          eq(posts.slug, input),
          isNull(posts.deletedAt)
        ),
        with: {
          author: true,
          comments: {
            where: isNull(comments.deletedAt),
            with: {
              author: true,
            },
          },
        },
      });
      
      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '포스트를 찾을 수 없습니다.',
        });
      }
      
      return post;
    }),

  // 포스트 수정
  update: protectedProcedure
    .input(z.object({
      id: z.number(),
      title: z.string().min(1).max(255).optional(),
      content: z.string().min(1).optional(),
      slug: z.string().min(1).max(255).optional(),
      published: z.boolean().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const { db, user } = ctx;
      const { id, ...updateData } = input;
      
      // 권한 검증
      const existingPost = await db.query.posts.findFirst({
        where: and(
          eq(posts.id, id),
          isNull(posts.deletedAt)
        ),
      });
      
      if (!existingPost) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '포스트를 찾을 수 없습니다.',
        });
      }
      
      if (existingPost.authorId !== user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: '수정 권한이 없습니다.',
        });
      }
      
      const [updatedPost] = await db.update(posts)
        .set(updateData)
        .where(eq(posts.id, id))
        .returning();
      
      return updatedPost;
    }),

  // 포스트 삭제 (소프트 삭제)
  delete: protectedProcedure
    .input(z.number())
    .mutation(async ({ ctx, input }) => {
      const { db, user } = ctx;
      
      // 권한 검증
      const existingPost = await db.query.posts.findFirst({
        where: and(
          eq(posts.id, input),
          isNull(posts.deletedAt)
        ),
      });
      
      if (!existingPost) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '포스트를 찾을 수 없습니다.',
        });
      }
      
      if (existingPost.authorId !== user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: '삭제 권한이 없습니다.',
        });
      }
      
      await db.update(posts)
        .set({ deletedAt: new Date() })
        .where(eq(posts.id, input));
      
      return { success: true };
    }),
});

7. 댓글 기능 구현

Comment 라우터

src/trpc/routers/comments.ts:

import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createTRPCRouter, baseProcedure, protectedProcedure } from '../init';
import { comments, posts } from '../../db/schema';
import { eq, and, isNull, asc } from 'drizzle-orm';

export const commentRouter = createTRPCRouter({
  // 댓글 작성
  create: protectedProcedure
    .input(z.object({
      content: z.string().min(1),
      postId: z.number(),
      parentId: z.number().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const { db, user } = ctx;
      
      // 포스트 존재 확인
      const post = await db.query.posts.findFirst({
        where: and(
          eq(posts.id, input.postId),
          isNull(posts.deletedAt)
        ),
      });
      
      if (!post) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '포스트를 찾을 수 없습니다.',
        });
      }
      
      const [newComment] = await db.insert(comments).values({
        content: input.content,
        postId: input.postId,
        parentId: input.parentId,
        authorId: user.id,
      }).returning();
      
      return newComment;
    }),

  // 댓글 목록 조회 (네스트된 구조)
  getByPostId: baseProcedure
    .input(z.number())
    .query(async ({ ctx, input }) => {
      const { db } = ctx;
      
      // 모든 댓글 조회
      const allComments = await db.query.comments.findMany({
        where: and(
          eq(comments.postId, input),
          isNull(comments.deletedAt)
        ),
        orderBy: [asc(comments.createdAt)],
        with: {
          author: {
            columns: {
              id: true,
              name: true,
              avatar: true,
            },
          },
        },
      });
      
      // 네스트된 구조로 변환
      const commentMap = new Map();
      const rootComments: any[] = [];
      
      allComments.forEach(comment => {
        commentMap.set(comment.id, { ...comment, replies: [] });
      });
      
      allComments.forEach(comment => {
        if (comment.parentId) {
          const parent = commentMap.get(comment.parentId);
          if (parent) {
            parent.replies.push(commentMap.get(comment.id));
          }
        } else {
          rootComments.push(commentMap.get(comment.id));
        }
      });
      
      return rootComments;
    }),

  // 댓글 수정
  update: protectedProcedure
    .input(z.object({
      id: z.number(),
      content: z.string().min(1),
    }))
    .mutation(async ({ ctx, input }) => {
      const { db, user } = ctx;
      
      // 권한 검증
      const existingComment = await db.query.comments.findFirst({
        where: and(
          eq(comments.id, input.id),
          isNull(comments.deletedAt)
        ),
      });
      
      if (!existingComment) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '댓글을 찾을 수 없습니다.',
        });
      }
      
      if (existingComment.authorId !== user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: '수정 권한이 없습니다.',
        });
      }
      
      const [updatedComment] = await db.update(comments)
        .set({ content: input.content })
        .where(eq(comments.id, input.id))
        .returning();
      
      return updatedComment;
    }),

  // 댓글 삭제 (소프트 삭제)
  delete: protectedProcedure
    .input(z.number())
    .mutation(async ({ ctx, input }) => {
      const { db, user } = ctx;
      
      // 권한 검증
      const existingComment = await db.query.comments.findFirst({
        where: and(
          eq(comments.id, input),
          isNull(comments.deletedAt)
        ),
      });
      
      if (!existingComment) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: '댓글을 찾을 수 없습니다.',
        });
      }
      
      if (existingComment.authorId !== user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: '삭제 권한이 없습니다.',
        });
      }
      
      await db.update(comments)
        .set({ deletedAt: new Date() })
        .where(eq(comments.id, input));
      
      return { success: true };
    }),
});

UI 컴포넌트 예제

src/components/blog/PostList.tsx:

'use client';

import { trpc } from '@/trpc/react';
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import Link from 'next/link';

export function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = trpc.posts.getAll.useInfiniteQuery(
    { limit: 10, published: true },
    {
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    }
  );
  
  const { ref, inView } = useInView();
  
  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, fetchNextPage]);
  
  const posts = data?.pages.flatMap(page => page.items) ?? [];
  
  return (
    <div className="space-y-6">
      {posts.map(post => (
        <article key={post.id} className="border p-6 rounded-lg">
          <h2 className="text-2xl font-bold mb-2">
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          </h2>
          <p className="text-gray-600 mb-2">작성자: {post.author.name}</p>
          <p className="text-gray-500">{post.excerpt}</p>
        </article>
      ))}
      
      {hasNextPage && (
        <div ref={ref} className="text-center py-4">
          {isFetchingNextPage ? '로딩 중...' : '더 보기'}
        </div>
      )}
    </div>
  );
}

8. 타입 안전성 확보

Zod 스키마 통합

src/lib/schemas/post.ts:

import { z } from 'zod';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { posts } from '@/db/schema';

// Drizzle 스키마에서 Zod 스키마 생성
export const insertPostSchema = createInsertSchema(posts, {
  title: z.string().min(3, '제목은 3자 이상이어야 합니다').max(255),
  content: z.string().min(10, '내용은 10자 이상이어야 합니다'),
  slug: z.string().regex(/^[a-z0-9-]+$/, '슬러그는 소문자, 숫자, 하이픈만 가능합니다'),
});

export const selectPostSchema = createSelectSchema(posts);

export type InsertPost = z.infer<typeof insertPostSchema>;
export type SelectPost = z.infer<typeof selectPostSchema>;

타입 안전한 폼 컴포넌트

src/components/forms/PostForm.tsx:

'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { insertPostSchema, type InsertPost } from '@/lib/schemas/post';
import { trpc } from '@/trpc/react';
import { useRouter } from 'next/navigation';

export function PostForm() {
  const router = useRouter();
  const createPost = trpc.posts.create.useMutation({
    onSuccess: (data) => {
      router.push(`/blog/${data.slug}`);
    },
  });

  const { register, handleSubmit, formState: { errors } } = useForm<InsertPost>({
    resolver: zodResolver(insertPostSchema),
  });

  const onSubmit = async (data: InsertPost) => {
    await createPost.mutateAsync(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <input
          {...register('title')}
          placeholder="제목"
          className="w-full p-2 border rounded"
        />
        {errors.title && (
          <p className="text-red-500 text-sm">{errors.title.message}</p>
        )}
      </div>
      
      <div>
        <input
          {...register('slug')}
          placeholder="슬러그 (예: my-first-post)"
          className="w-full p-2 border rounded"
        />
        {errors.slug && (
          <p className="text-red-500 text-sm">{errors.slug.message}</p>
        )}
      </div>
      
      <div>
        <textarea
          {...register('content')}
          placeholder="내용"
          rows={10}
          className="w-full p-2 border rounded"
        />
        {errors.content && (
          <p className="text-red-500 text-sm">{errors.content.message}</p>
        )}
      </div>
      
      <button
        type="submit"
        disabled={createPost.isLoading}
        className="px-4 py-2 bg-blue-500 text-white rounded"
      >
        {createPost.isLoading ? '작성 중...' : '작성하기'}
      </button>
    </form>
  );
}

9. 에러 핸들링

전역 에러 처리

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('Application error:', error);
  }, [error]);

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="max-w-md w-full space-y-4 text-center">
        <h2 className="text-2xl font-bold">문제가 발생했습니다!</h2>
        <p className="text-gray-600">{error.message}</p>
        <button
          onClick={reset}
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          다시 시도
        </button>
      </div>
    </div>
  );
}

tRPC 에러 처리

src/trpc/error-handler.ts:

import { TRPCError } from '@trpc/server';
import { TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc';

export class CustomError extends TRPCError {
  constructor(code: TRPC_ERROR_CODE_KEY, message: string) {
    super({ code, message });
  }
}

export function handleTRPCError(error: unknown): string {
  if (error instanceof TRPCError) {
    switch (error.code) {
      case 'NOT_FOUND':
        return '요청한 리소스를 찾을 수 없습니다.';
      case 'UNAUTHORIZED':
        return '로그인이 필요합니다.';
      case 'FORBIDDEN':
        return '접근 권한이 없습니다.';
      default:
        return error.message;
    }
  }
  
  return '알 수 없는 오류가 발생했습니다.';
}

10. 성능 최적화 및 배포

Next.js 캐싱 전략

src/app/blog/[slug]/page.tsx:

import { cache } from 'react';
import { notFound } from 'next/navigation';
import { trpc } from '@/trpc/server';
import { PostContent } from './PostContent';
import { Comments } from './Comments';

// 캐시된 데이터 페칭
const getCachedPost = cache(async (slug: string) => {
  return await trpc.posts.getBySlug(slug);
});

export default async function BlogPost({ 
  params 
}: { 
  params: { slug: string } 
}) {
  const post = await getCachedPost(params.slug);
  
  if (!post) {
    notFound();
  }
  
  return (
    <article className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="text-gray-600 mb-8">
        <span>작성자: {post.author.name}</span>
        <span className="mx-2">•</span>
        <span>{new Date(post.createdAt).toLocaleDateString()}</span>
      </div>
      <PostContent content={post.content} />
      <Comments postId={post.id} />
    </article>
  );
}

// 정적 생성을 위한 params 생성
export async function generateStaticParams() {
  const posts = await trpc.posts.getAll({ published: true, limit: 100 });
  
  return posts.items.map((post) => ({
    slug: post.slug,
  }));
}

이미지 최적화

src/components/OptimizedImage.tsx:

import Image from 'next/image';

interface OptimizedImageProps {
  src: string;
  alt: string;
  width: number;
  height: number;
  priority?: boolean;
}

export function OptimizedImage({ 
  src, 
  alt, 
  width, 
  height, 
  priority = false 
}: OptimizedImageProps) {
  return (
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      priority={priority}
      loading={priority ? 'eager' : 'lazy'}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

데이터베이스 최적화

src/db/indexes.ts:

import { index } from 'drizzle-orm/pg-core';

// 성능 최적화를 위한 인덱스
export const postsIndexes = {
  publishedIdx: index('published_idx').on(posts.published),
  createdAtIdx: index('created_at_idx').on(posts.createdAt),
  slugIdx: index('slug_idx').on(posts.slug),
  authorIdIdx: index('author_id_idx').on(posts.authorId),
};

export const commentsIndexes = {
  postIdIdx: index('post_id_idx').on(comments.postId),
  authorIdIdx: index('comment_author_id_idx').on(comments.authorId),
  parentIdIdx: index('parent_id_idx').on(comments.parentId),
};

배포 설정

next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['@node-rs/bcrypt'],
  },
  
  images: {
    domains: ['res.cloudinary.com', 'images.unsplash.com'],
    formats: ['image/webp', 'image/avif'],
  },
  
  // 정적 최적화
  output: 'standalone',
  
  // 압축 활성화
  compress: true,
  
  // 보안 헤더
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'X-DNS-Prefetch-Control',
            value: 'on'
          },
          {
            key: 'Strict-Transport-Security',
            value: 'max-age=63072000; includeSubDomains; preload'
          },
          {
            key: 'X-Frame-Options',
            value: 'SAMEORIGIN'
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff'
          },
          {
            key: 'Referrer-Policy',
            value: 'origin-when-cross-origin'
          }
        ]
      }
    ];
  },
};

module.exports = nextConfig;

Vercel 배포

# Vercel CLI 설치
npm i -g vercel

# 배포
vercel

# 환경 변수 설정
vercel env add DATABASE_URL
vercel env add BETTER_AUTH_SECRET

마무리

이 가이드를 따라 구축한 블로그 애플리케이션은 다음과 같은 특징을 가집니다:

  • 완전한 타입 안전성: TypeScript, tRPC, Zod를 통한 엔드투엔드 타입 안전성
  • 최신 기술 스택: Next.js 15 App Router의 모든 장점 활용
  • 확장 가능한 구조: 깔끔한 아키텍처로 기능 추가가 용이
  • 성능 최적화: 캐싱, 이미지 최적화, 데이터베이스 인덱싱
  • 보안: Better Auth를 통한 안전한 인증, 소프트 삭제 패
반응형

'스터디 > WEB' 카테고리의 다른 글

Next.js + Resend를 활용한 이메일 인증 구현 가이드  (2) 2025.07.11
Rust 최신 웹 프레임워크 비교  (0) 2025.02.03
Clipping CSS - Day3  (0) 2019.05.22
Sexy Typograping - Day2  (0) 2019.05.19
Css next grid 클론 ( 1 ~ 5 )  (0) 2018.11.17