Logo

Next.js + Prisma 실무 개발: 데이터베이스 연동과 ORM 최적화 완전정복

🎯 요약

Next.js와 Prisma ORM의 조합은 현대 웹 개발에서 가장 강력한 풀스택 솔루션 중 하나입니다. 타입 안전성, 자동완성, 마이그레이션 등 개발자 경험을 극대화하면서 성능까지 챙길 수 있어요. 특히 프론트엔드 웹 개발에서 백엔드 로직을 효율적으로 처리하고 싶은 개발자들에게 필수적인 기술 조합입니다.

📋 목차

  1. Next.js + Prisma 기본 개념
  2. Prisma 초기 설정과 스키마 설계
  3. 고급 스키마 패턴과 관계 설정
  4. Prisma Client 활용과 쿼리 최적화
  5. 실무 성능 최적화 전략
  6. 배포와 프로덕션 환경 설정

Next.js + Prisma 기본 개념

📍 Prisma ORM이란?

Prisma는 차세대 타입스크립트 ORM으로, 데이터베이스 스키마를 코드로 관리하고 타입 안전한 쿼리를 작성할 수 있게 해주는 도구입니다.

🚀 실무에서의 경험담

처음 Prisma를 도입하기 전에는 SQL 쿼리를 직접 작성하고 타입을 수동으로 관리해야 했습니다. 특히 복잡한 조인이나 관계형 데이터를 다룰 때 타입 에러가 자주 발생했죠.

Prisma를 도입한 후에는 스키마 변경 시 타입이 자동으로 업데이트되고, 쿼리 작성 시 자동완성까지 지원돼서 개발 속도가 2배 이상 빨라졌습니다. 런타임 에러도 거의 사라져서 코드 품질이 크게 향상되었어요.

Next.js + Prisma 초기 설정 5단계

Prisma ORM은 타입스크립트와 완벽하게 통합되는 차세대 데이터베이스 도구입니다. 스키마 정의부터 쿼리 작성까지 모든 과정에서 타입 안전성을 보장해줘요.

핵심 특징:

  • 선언적 스키마 정의로 데이터 모델 관리
  • 자동 타입 생성과 IntelliSense 지원
  • 직관적인 쿼리 API와 관계형 데이터 처리
  • 마이그레이션 시스템으로 스키마 버전 관리

Prisma ORM은 기존 SQL ORM들의 복잡함을 해결하고 개발자 경험을 혁신적으로 개선한 도구입니다. 특히 타입스크립트 생태계에서는 필수적인 선택이 되었죠.

💡 왜 Next.js와 Prisma를 함께 써야 할까?

실제로 제가 개발하면서 겪었던 상황을 예로 들어보겠습니다:

// 기존 방식 (문제점이 많음)
// pages/api/users.ts
import { NextApiRequest, NextApiResponse } from 'next';
import mysql from 'mysql2';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const connection = mysql.createConnection({
    host: 'localhost',
    user: 'root',
    database: 'myapp'
  });

  // ❌ SQL 직접 작성으로 타입 안전성 부족
  const users = await new Promise((resolve, reject) => {
    connection.query('SELECT * FROM users WHERE active = 1', (err, results) => {
      if (err) reject(err);
      resolve(results);
    });
  });

  // ❌ 타입 추론 불가, any 타입 사용
  res.json(users);
}

Prisma를 써야 하는 5가지 이유

  1. 타입 안전성: 컴파일 타임에 모든 데이터베이스 에러 감지
  2. 개발자 경험: 자동완성과 IntelliSense로 생산성 극대화
  3. 마이그레이션: 스키마 변경사항을 안전하게 버전 관리
  4. 관계형 데이터: 복잡한 관계도 직관적인 API로 처리
  5. 성능 최적화: N+1 쿼리 문제 해결과 쿼리 최적화

기존 방식의 문제점:

  • SQL 쿼리 작성 시 오타나 문법 오류 발생
  • 타입 정의와 데이터베이스 스키마 불일치
  • 스키마 변경 시 관련 코드 누락으로 런타임 에러

Prisma 초기 설정과 스키마 설계

Next.js 프로젝트 초기 설정

# Next.js 프로젝트 생성
npx create-next-app@latest nextjs-prisma --typescript --tailwind --eslint --app

# 프로젝트 디렉토리로 이동
cd nextjs-prisma

# Prisma 설치
npm install prisma @prisma/client
npm install -D prisma

# Prisma 초기화
npx prisma init

환경 변수 설정

# .env
# PostgreSQL 예시
DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public"

# MySQL 예시
DATABASE_URL="mysql://username:password@localhost:3306/mydb"

# SQLite 예시 (개발용)
DATABASE_URL="file:./dev.db"

기본 스키마 설계

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  avatar    String?
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 관계형 데이터
  posts     Post[]
  comments  Comment[]
  likes     Like[]

  @@map("users")
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  slug      String   @unique
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 관계 설정
  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId String

  comments Comment[]
  likes    Like[]
  tags     TagOnPost[]

  @@map("posts")
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId String

  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId String

  @@map("comments")
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  color String @default("#3b82f6")

  posts TagOnPost[]

  @@map("tags")
}

model TagOnPost {
  id String @id @default(cuid())

  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId String

  tag   Tag    @relation(fields: [tagId], references: [id], onDelete: Cascade)
  tagId String

  @@unique([postId, tagId])
  @@map("tag_on_posts")
}

model Like {
  id String @id @default(cuid())

  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId String

  post   Post   @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId String

  createdAt DateTime @default(now())

  @@unique([userId, postId])
  @@map("likes")
}

enum Role {
  USER
  ADMIN
  MODERATOR
}

Prisma Client 설정

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  });

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

export default prisma;

첫 마이그레이션 실행

# 데이터베이스 스키마 생성
npx prisma migrate dev --name init

# Prisma Client 생성
npx prisma generate

고급 스키마 패턴과 관계 설정

복합 키와 인덱스 최적화

// prisma/schema.prisma
model UserSession {
  id        String   @id @default(cuid())
  userId    String
  token     String   @unique
  expiresAt DateTime
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  // 복합 인덱스로 쿼리 성능 최적화
  @@index([userId, expiresAt])
  @@index([token, expiresAt])
  @@map("user_sessions")
}

model PostView {
  id     String @id @default(cuid())
  postId String
  userId String?
  ip     String
  viewedAt DateTime @default(now())

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  user User? @relation(fields: [userId], references: [id], onDelete: SetNull)

  // 중복 방지를 위한 복합 유니크
  @@unique([postId, ip, userId])
  @@index([postId, viewedAt])
  @@map("post_views")
}

자기 참조와 계층 구조

model Category {
  id       String  @id @default(cuid())
  name     String
  slug     String  @unique
  parentId String?

  // 자기 참조 관계
  parent   Category?  @relation("CategoryHierarchy", fields: [parentId], references: [id])
  children Category[] @relation("CategoryHierarchy")

  posts PostCategory[]

  @@map("categories")
}

model PostCategory {
  id String @id @default(cuid())

  post       Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId     String
  category   Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  categoryId String

  @@unique([postId, categoryId])
  @@map("post_categories")
}

JSON 필드와 고급 데이터 타입

model UserSettings {
  id     String @id @default(cuid())
  userId String @unique

  // JSON 필드로 유연한 설정 저장
  preferences Json    @default("{}")
  metadata    Json?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("user_settings")
}

model Post {
  id      String @id @default(cuid())
  title   String
  content String?

  // SEO 메타데이터를 JSON으로 저장
  seoData Json? @default("{}")

  // 배열 타입 (PostgreSQL 전용)
  tags String[]

  @@map("posts")
}

Prisma Client 활용과 쿼리 최적화

기본 CRUD 작업

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';

export async function GET() {
  try {
    const users = await prisma.user.findMany({
      select: {
        id: true,
        name: true,
        email: true,
        role: true,
        createdAt: true,
        _count: {
          select: {
            posts: true,
            comments: true,
          },
        },
      },
      orderBy: {
        createdAt: 'desc',
      },
      take: 20,
    });

    return NextResponse.json(users);
  } catch (error) {
    console.error('사용자 조회 실패:', error);
    return NextResponse.json(
      { error: '사용자 데이터를 불러올 수 없습니다.' },
      { status: 500 }
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    const { name, email, role } = await request.json();

    // 입력 검증
    if (!name || !email) {
      return NextResponse.json(
        { error: '이름과 이메일은 필수 항목입니다.' },
        { status: 400 }
      );
    }

    const user = await prisma.user.create({
      data: {
        name,
        email,
        role: role || 'USER',
      },
      select: {
        id: true,
        name: true,
        email: true,
        role: true,
        createdAt: true,
      },
    });

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error.code === 'P2002') {
      return NextResponse.json(
        { error: '이미 존재하는 이메일입니다.' },
        { status: 409 }
      );
    }

    console.error('사용자 생성 실패:', error);
    return NextResponse.json(
      { error: '사용자 생성에 실패했습니다.' },
      { status: 500 }
    );
  }
}

관계형 데이터 조회와 Include

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const { id } = await params;

    const post = await prisma.post.findUnique({
      where: { id },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
          },
        },
        comments: {
          include: {
            author: {
              select: {
                id: true,
                name: true,
                avatar: true,
              },
            },
          },
          orderBy: {
            createdAt: 'asc',
          },
        },
        tags: {
          include: {
            tag: {
              select: {
                id: true,
                name: true,
                color: true,
              },
            },
          },
        },
        _count: {
          select: {
            likes: true,
            comments: true,
          },
        },
      },
    });

    if (!post) {
      return NextResponse.json(
        { error: '포스트를 찾을 수 없습니다.' },
        { status: 404 }
      );
    }

    return NextResponse.json(post);
  } catch (error) {
    console.error('포스트 조회 실패:', error);
    return NextResponse.json(
      { error: '포스트 데이터를 불러올 수 없습니다.' },
      { status: 500 }
    );
  }
}

트랜잭션과 복합 작업

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';

export async function POST(request: NextRequest) {
  try {
    const { title, content, tags, authorId } = await request.json();

    // 트랜잭션으로 포스트와 태그 관계 생성
    const result = await prisma.$transaction(async (tx) => {
      // 1. 포스트 생성
      const post = await tx.post.create({
        data: {
          title,
          content,
          slug: title.toLowerCase().replace(/\s+/g, '-'),
          authorId,
        },
      });

      // 2. 태그 처리 (존재하지 않으면 생성)
      if (tags && tags.length > 0) {
        for (const tagName of tags) {
          // upsert로 태그 생성 또는 조회
          const tag = await tx.tag.upsert({
            where: { name: tagName },
            update: {},
            create: { name: tagName },
          });

          // 포스트-태그 관계 생성
          await tx.tagOnPost.create({
            data: {
              postId: post.id,
              tagId: tag.id,
            },
          });
        }
      }

      // 3. 완성된 포스트 반환 (관계 데이터 포함)
      return await tx.post.findUnique({
        where: { id: post.id },
        include: {
          author: {
            select: {
              id: true,
              name: true,
            },
          },
          tags: {
            include: {
              tag: true,
            },
          },
        },
      });
    });

    return NextResponse.json(result, { status: 201 });
  } catch (error) {
    console.error('포스트 생성 실패:', error);
    return NextResponse.json(
      { error: '포스트 생성에 실패했습니다.' },
      { status: 500 }
    );
  }
}

고급 쿼리 패턴

// lib/queries/posts.ts
import prisma from '@/lib/prisma';

interface PostFilters {
  authorId?: string;
  tags?: string[];
  published?: boolean;
  search?: string;
  skip?: number;
  take?: number;
}

export async function getFilteredPosts({
  authorId,
  tags,
  published = true,
  search,
  skip = 0,
  take = 10,
}: PostFilters) {
  const where: any = {
    published,
  };

  // 작성자 필터
  if (authorId) {
    where.authorId = authorId;
  }

  // 태그 필터
  if (tags && tags.length > 0) {
    where.tags = {
      some: {
        tag: {
          name: {
            in: tags,
          },
        },
      },
    };
  }

  // 검색 필터
  if (search) {
    where.OR = [
      {
        title: {
          contains: search,
          mode: 'insensitive',
        },
      },
      {
        content: {
          contains: search,
          mode: 'insensitive',
        },
      },
    ];
  }

  const [posts, totalCount] = await prisma.$transaction([
    prisma.post.findMany({
      where,
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
          },
        },
        tags: {
          include: {
            tag: {
              select: {
                name: true,
                color: true,
              },
            },
          },
        },
        _count: {
          select: {
            likes: true,
            comments: true,
          },
        },
      },
      orderBy: {
        createdAt: 'desc',
      },
      skip,
      take,
    }),
    prisma.post.count({ where }),
  ]);

  return {
    posts,
    pagination: {
      total: totalCount,
      pages: Math.ceil(totalCount / take),
      current: Math.floor(skip / take) + 1,
      hasNext: skip + take < totalCount,
      hasPrev: skip > 0,
    },
  };
}

// 인기 포스트 조회 (좋아요 순)
export async function getPopularPosts(limit: number = 5) {
  return await prisma.post.findMany({
    where: {
      published: true,
    },
    include: {
      author: {
        select: {
          id: true,
          name: true,
          avatar: true,
        },
      },
      _count: {
        select: {
          likes: true,
          comments: true,
        },
      },
    },
    orderBy: {
      likes: {
        _count: 'desc',
      },
    },
    take: limit,
  });
}

실무 성능 최적화 전략

쿼리 성능 최적화

// lib/performance/query-optimization.ts
import prisma from '@/lib/prisma';

// ❌ N+1 쿼리 문제 (비효율적)
export async function getBadPostsWithAuthors() {
  const posts = await prisma.post.findMany();

  // 각 포스트마다 개별 쿼리 실행 (N+1 문제!)
  const postsWithAuthors = await Promise.all(
    posts.map(async (post) => ({
      ...post,
      author: await prisma.user.findUnique({
        where: { id: post.authorId },
      }),
    }))
  );

  return postsWithAuthors;
}

// ✅ 최적화된 쿼리 (단일 쿼리)
export async function getOptimizedPostsWithAuthors() {
  return await prisma.post.findMany({
    include: {
      author: {
        select: {
          id: true,
          name: true,
          avatar: true,
        },
      },
    },
  });
}

// 데이터 로더 패턴 (캐싱 활용)
export class PostDataLoader {
  private cache = new Map<string, any>();

  async getPostsByIds(ids: string[]) {
    const uncachedIds = ids.filter(id => !this.cache.has(id));

    if (uncachedIds.length > 0) {
      const posts = await prisma.post.findMany({
        where: {
          id: {
            in: uncachedIds,
          },
        },
        include: {
          author: {
            select: {
              id: true,
              name: true,
              avatar: true,
            },
          },
        },
      });

      posts.forEach(post => {
        this.cache.set(post.id, post);
      });
    }

    return ids.map(id => this.cache.get(id)).filter(Boolean);
  }
}

커넥션 풀과 메모리 최적화

// lib/prisma-optimized.ts
import { PrismaClient } from '@prisma/client';

declare global {
  var __prisma: PrismaClient | undefined;
}

const prisma = globalThis.__prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']
      : ['error'],
    datasources: {
      db: {
        url: process.env.DATABASE_URL,
      },
    },
  });

if (process.env.NODE_ENV !== 'production') {
  globalThis.__prisma = prisma;
}

// 연결 풀 최적화
export const optimizedPrisma = prisma.$extends({
  query: {
    $allModels: {
      async $allOperations({ operation, model, args, query }) {
        const start = Date.now();
        const result = await query(args);
        const end = Date.now();

        // 느린 쿼리 로깅 (500ms 이상)
        if (end - start > 500) {
          console.warn(
            `Slow query detected: ${model}.${operation} took ${end - start}ms`
          );
        }

        return result;
      },
    },
  },
});

export default prisma;

캐싱 전략

// lib/cache/post-cache.ts
import { unstable_cache } from 'next/cache';
import prisma from '@/lib/prisma';

// Next.js 캐시 활용
export const getCachedPosts = unstable_cache(
  async (page: number = 1, limit: number = 10) => {
    return await prisma.post.findMany({
      where: {
        published: true,
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
          },
        },
        _count: {
          select: {
            likes: true,
            comments: true,
          },
        },
      },
      orderBy: {
        createdAt: 'desc',
      },
      skip: (page - 1) * limit,
      take: limit,
    });
  },
  ['posts-list'],
  {
    revalidate: 300, // 5분 캐시
    tags: ['posts'],
  }
);

// 인메모리 캐시 (Redis 대안)
class MemoryCache {
  private cache = new Map<string, { data: any; expiry: number }>();

  set(key: string, data: any, ttl: number = 300000) { // 5분 기본값
    const expiry = Date.now() + ttl;
    this.cache.set(key, { data, expiry });
  }

  get(key: string) {
    const item = this.cache.get(key);
    if (!item) return null;

    if (Date.now() > item.expiry) {
      this.cache.delete(key);
      return null;
    }

    return item.data;
  }

  clear() {
    this.cache.clear();
  }
}

export const memoryCache = new MemoryCache();

export async function getCachedPostById(id: string) {
  const cacheKey = `post:${id}`;
  let post = memoryCache.get(cacheKey);

  if (!post) {
    post = await prisma.post.findUnique({
      where: { id },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            avatar: true,
          },
        },
        comments: {
          include: {
            author: {
              select: {
                id: true,
                name: true,
                avatar: true,
              },
            },
          },
          orderBy: {
            createdAt: 'asc',
          },
        },
      },
    });

    if (post) {
      memoryCache.set(cacheKey, post, 600000); // 10분 캐시
    }
  }

  return post;
}

백그라운드 작업과 큐

// lib/jobs/background-tasks.ts
import prisma from '@/lib/prisma';

interface BackgroundJob {
  id: string;
  type: string;
  payload: any;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  createdAt: Date;
  processedAt?: Date;
  error?: string;
}

// 간단한 작업 큐 시스템
class SimpleJobQueue {
  private processing = false;

  async addJob(type: string, payload: any) {
    return await prisma.backgroundJob.create({
      data: {
        type,
        payload,
        status: 'pending',
      },
    });
  }

  async processJobs() {
    if (this.processing) return;
    this.processing = true;

    try {
      const jobs = await prisma.backgroundJob.findMany({
        where: {
          status: 'pending',
        },
        orderBy: {
          createdAt: 'asc',
        },
        take: 5, // 한 번에 5개씩 처리
      });

      for (const job of jobs) {
        await this.processJob(job);
      }
    } catch (error) {
      console.error('Job processing error:', error);
    } finally {
      this.processing = false;
    }
  }

  private async processJob(job: any) {
    try {
      await prisma.backgroundJob.update({
        where: { id: job.id },
        data: {
          status: 'processing',
          processedAt: new Date(),
        },
      });

      // 작업 타입별 처리
      switch (job.type) {
        case 'send_email':
          await this.sendEmail(job.payload);
          break;
        case 'generate_thumbnail':
          await this.generateThumbnail(job.payload);
          break;
        case 'update_search_index':
          await this.updateSearchIndex(job.payload);
          break;
        default:
          throw new Error(`Unknown job type: ${job.type}`);
      }

      await prisma.backgroundJob.update({
        where: { id: job.id },
        data: {
          status: 'completed',
        },
      });
    } catch (error) {
      await prisma.backgroundJob.update({
        where: { id: job.id },
        data: {
          status: 'failed',
          error: error.message,
        },
      });
    }
  }

  private async sendEmail(payload: any) {
    // 이메일 전송 로직
    console.log('Sending email:', payload);
  }

  private async generateThumbnail(payload: any) {
    // 썸네일 생성 로직
    console.log('Generating thumbnail:', payload);
  }

  private async updateSearchIndex(payload: any) {
    // 검색 인덱스 업데이트 로직
    console.log('Updating search index:', payload);
  }
}

export const jobQueue = new SimpleJobQueue();

// 주기적 작업 실행
if (process.env.NODE_ENV === 'production') {
  setInterval(() => {
    jobQueue.processJobs();
  }, 30000); // 30초마다 실행
}

배포와 프로덕션 환경 설정

Docker를 활용한 배포 설정

# Dockerfile
FROM node:18-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Prisma 클라이언트 생성
RUN npx prisma generate

# Next.js 빌드
RUN npm run build

FROM node:18-alpine 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 /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Prisma 설정 복사
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma

USER nextjs

EXPOSE 3000

ENV PORT 3000

# 마이그레이션 실행 후 서버 시작
CMD ["sh", "-c", "npx prisma migrate deploy && node server.js"]

환경별 설정 관리

// lib/config/database.ts
interface DatabaseConfig {
  url: string;
  maxConnections: number;
  connectionTimeout: number;
  logLevel: string[];
}

const configs: Record<string, DatabaseConfig> = {
  development: {
    url: process.env.DATABASE_URL!,
    maxConnections: 10,
    connectionTimeout: 10000,
    logLevel: ['query', 'error', 'warn'],
  },
  test: {
    url: process.env.TEST_DATABASE_URL!,
    maxConnections: 5,
    connectionTimeout: 5000,
    logLevel: ['error'],
  },
  production: {
    url: process.env.DATABASE_URL!,
    maxConnections: 50,
    connectionTimeout: 20000,
    logLevel: ['error'],
  },
};

export const databaseConfig = configs[process.env.NODE_ENV || 'development'];

모니터링과 로깅

// lib/monitoring/prisma-metrics.ts
import { PrismaClient } from '@prisma/client';

export const instrumentedPrisma = new PrismaClient({
  log: [
    {
      emit: 'event',
      level: 'query',
    },
    {
      emit: 'event',
      level: 'error',
    },
    {
      emit: 'event',
      level: 'info',
    },
    {
      emit: 'event',
      level: 'warn',
    },
  ],
});

// 쿼리 성능 모니터링
instrumentedPrisma.$on('query', (e) => {
  if (e.duration > 1000) { // 1초 이상 걸리는 쿼리
    console.warn('Slow query detected:', {
      query: e.query,
      params: e.params,
      duration: `${e.duration}ms`,
      timestamp: e.timestamp,
    });
  }
});

// 에러 로깅
instrumentedPrisma.$on('error', (e) => {
  console.error('Prisma error:', {
    message: e.message,
    target: e.target,
    timestamp: e.timestamp,
  });
});

// 연결 상태 모니터링
let connectionCount = 0;

instrumentedPrisma.$on('info', (e) => {
  if (e.message.includes('connection')) {
    connectionCount++;
    console.info(`Database connections: ${connectionCount}`);
  }
});

CI/CD 파이프라인 설정

# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Run migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

      - name: Run tests
        run: npm test
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v3

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID }}
          vercel-project-id: ${{ secrets.PROJECT_ID }}
          vercel-args: '--prod'

자주 묻는 질문 (FAQ)

Q1: Prisma와 다른 ORM(TypeORM, Sequelize)과의 차이점은 무엇인가요?

A: Prisma는 스키마 우선 접근법으로 타입 안전성이 뛰어나고, 자동 타입 생성과 마이그레이션 시스템이 더 직관적입니다. 특히 TypeScript 통합이 가장 우수해요.

Q2: N+1 쿼리 문제를 어떻게 해결하나요?

A: includeselect를 사용해서 관련 데이터를 한 번에 조회하거나, DataLoader 패턴을 활용하세요. 대부분의 경우 include만으로도 충분히 해결됩니다.

Q3: Prisma Client를 여러 곳에서 사용할 때 연결 풀 문제가 있나요?

A: 글로벌 인스턴스를 사용하고, 개발 환경에서만 전역 변수에 저장하는 패턴을 권장합니다. 프로덕션에서는 연결 풀이 자동으로 관리되어요.

Q4: 복잡한 쿼리는 어떻게 처리하나요?

A: Prisma의 $queryRaw$executeRaw를 사용해서 직접 SQL을 작성할 수 있습니다. 하지만 대부분의 경우 Prisma의 쿼리 빌더만으로도 충분해요.

Q5: 테스트 환경에서 데이터베이스는 어떻게 관리하나요?

A: 별도의 테스트 데이터베이스를 사용하고, 각 테스트 후에 데이터를 정리하는 패턴을 권장합니다. prisma migrate reset을 활용하면 편리해요.

❓ Next.js + Prisma 마스터 마무리

Next.js와 Prisma의 조합은 현대 웹 개발에서 가장 강력하고 생산성 높은 풀스택 솔루션입니다. 타입 안전성부터 성능 최적화까지 개발자가 원하는 모든 것을 제공해주죠.

특히 프론트엔드 웹 개발에서 백엔드 로직을 효율적으로 관리하고 싶은 개발자들에게는 필수적인 기술 스택이에요. 스키마 설계부터 배포까지 전 과정에서 개발자 경험을 극대화하면서도 높은 성능을 보장할 수 있습니다!

Next.js 고급 기법 더 배우고 싶다면 Next.js Middleware 활용법Next.js 배포 최적화 전략을 꼭 확인해보세요! 💪

🔗 Next.js + Prisma 심화 학습 시리즈

Prisma ORM 마스터가 되셨다면, 다른 고급 기능들도 함께 학습해보세요:

📚 다음 단계 학습 가이드

📚 공식 문서 및 참고 자료