Notice
Recent Posts
Recent Comments
반응형
오늘도 공부
NextJs 풀스택 개발 가이드(예제: 블로그) 본문
반응형
프로젝트 개요
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 프로젝트 생성
- Neon Console에 접속하여 계정 생성
- "New Project" 클릭하여 새 프로젝트 생성
- 데이터베이스 연결 문자열 복사
환경 변수 설정
.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 |