Prisma vs 직접 SQL, 언제 뭘 써야 할까

개발
·Dante Chun

시작하며

개발을 하다 보면 "ORM을 쓸까, 직접 SQL을 쓸까?"라는 질문을 자주 마주한다. 특히 Node.js 생태계에서 Prisma가 인기를 끌면서 이 고민은 더 깊어졌다.

나는 두 가지를 상황에 따라 병행해서 쓴다. 그리고 그 경계가 어디인지 꽤 명확하게 정리되어 있다.

Prisma의 강점

1. 타입 안전성

Prisma의 가장 큰 장점은 TypeScript와의 통합이다. 스키마에서 타입이 자동 생성된다.

// schema.prisma
model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
}

// 사용할 때
const user = await prisma.user.findUnique({
  where: { email: "test@example.com" }
})
// user의 타입이 자동으로 추론됨
// user.name은 string | null

오타나 잘못된 필드명을 컴파일 타임에 잡아준다. SQL 문자열에서는 불가능한 일이다.

2. 관계 처리

관계 데이터를 가져올 때 Prisma는 깔끔하다.

// 사용자와 게시글을 함께 조회
const userWithPosts = await prisma.user.findUnique({
  where: { id: userId },
  include: {
    posts: {
      orderBy: { createdAt: "desc" },
      take: 10
    }
  }
})

같은 작업을 SQL로 하려면 JOIN을 쓰거나 별도 쿼리를 날려야 한다. 그리고 결과를 수동으로 매핑해야 한다.

3. 마이그레이션 관리

prisma migrate dev 한 줄이면 스키마 변경 사항이 자동으로 마이그레이션 파일로 생성된다. 이력 관리도 되고, 프로덕션 배포도 prisma migrate deploy로 간단하다.

# 개발 환경에서 마이그레이션 생성
npx prisma migrate dev --name add_avatar_field

# 프로덕션에서 마이그레이션 적용
npx prisma migrate deploy

4. 개발 속도

간단한 CRUD는 정말 빠르게 구현된다.

// 생성
await prisma.post.create({
  data: { title, body, authorId }
})

// 조회
await prisma.post.findMany({
  where: { published: true }
})

// 수정
await prisma.post.update({
  where: { id },
  data: { title: newTitle }
})

// 삭제
await prisma.post.delete({
  where: { id }
})

직접 SQL이 필요할 때

1. 복잡한 집계 쿼리

이런 쿼리는 Prisma로 표현하기 어렵거나 불가능하다.

SELECT 
  DATE_TRUNC('month', created_at) as month,
  COUNT(*) as total_orders,
  SUM(amount) as revenue,
  AVG(amount) as avg_order_value
FROM orders
WHERE created_at >= NOW() - INTERVAL '1 year'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month DESC;

Prisma로 하려면 groupByaggregate를 조합해야 하는데, 가독성이 떨어지고 제약이 많다.

// 이렇게 해도 되지만...
const result = await prisma.order.groupBy({
  by: ['createdAt'],
  _count: { id: true },
  _sum: { amount: true },
  _avg: { amount: true },
  // DATE_TRUNC는 어떻게?
})

이럴 때는 $queryRaw를 쓴다.

const stats = await prisma.$queryRaw`
  SELECT 
    DATE_TRUNC('month', created_at) as month,
    COUNT(*)::int as total_orders,
    SUM(amount)::float as revenue
  FROM orders
  WHERE created_at >= NOW() - INTERVAL '1 year'
  GROUP BY 1
  ORDER BY 1 DESC
`

2. 성능이 중요한 쿼리

Prisma가 생성하는 쿼리는 때때로 비효율적이다. 특히 N+1 문제가 발생하기 쉽다.

// Prisma - 내부적으로 여러 쿼리 실행 가능
const posts = await prisma.post.findMany({
  include: { author: true, comments: true }
})

대량 데이터를 다룰 때는 최적화된 SQL 한 방이 나을 수 있다.

SELECT 
  p.*,
  u.name as author_name,
  COUNT(c.id) as comment_count
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
LEFT JOIN comments c ON c.post_id = p.id
WHERE p.published = true
GROUP BY p.id, u.name
ORDER BY p.created_at DESC
LIMIT 20;

3. DB 특화 기능

PostgreSQL의 고급 기능들은 Prisma에서 지원이 제한적이다.

-- Full-text search
SELECT * FROM posts
WHERE to_tsvector('korean', title || ' ' || body) 
  @@ plainto_tsquery('korean', '검색어');

-- JSON 쿼리
SELECT * FROM products
WHERE metadata->>'category' = 'electronics'
  AND (metadata->'specs'->'ram')::int >= 16;

-- Window 함수
SELECT *,
  ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) as rank
FROM products;

내 선택 기준

Prisma를 쓸 때

  • 일반적인 CRUD 작업
  • 관계 데이터 조회 (include)
  • 타입 안전성이 중요할 때
  • 빠른 개발이 우선일 때
  • 팀원이 SQL에 익숙하지 않을 때

직접 SQL을 쓸 때

  • 복잡한 집계/분석 쿼리
  • 성능 최적화가 필요할 때
  • DB 특화 기능 사용 시
  • 대량 데이터 일괄 처리
  • 레거시 DB 연동

실제 프로젝트 예시

이 블로그를 예로 들면:

// Prisma 사용 - 글 목록 조회 (간단한 CRUD)
export const listBlogPostsPaginated = async ({
  locale,
  cursor,
  take = 10
}) => {
  const articles = await prisma.blogPost.findMany({
    where: { locale },
    orderBy: { createdAt: "desc" },
    take: take + 1,
    ...(cursor && locale && {
      cursor: { id_locale: { id: cursor, locale } },
      skip: 1
    })
  })
  return {
    articles: articles.slice(0, take),
    nextCursor: articles.length > take ? articles[take - 1].id : null
  }
}
// 직접 SQL - 통계 대시보드 (복잡한 집계)
const viewStats = await prisma.$queryRaw`
  SELECT 
    slug,
    title,
    views,
    DATE(created_at) as published_date,
    views::float / GREATEST(
      EXTRACT(DAY FROM NOW() - created_at), 1
    ) as daily_avg_views
  FROM "BlogPost"
  WHERE locale = ${locale}
  ORDER BY views DESC
  LIMIT 10
`

혼용 시 주의사항

1. 타입 정의

$queryRaw 결과는 타입이 unknown이다. 수동으로 타입을 정의해야 한다.

interface ViewStats {
  slug: string
  title: string
  views: number
  published_date: Date
  daily_avg_views: number
}

const stats = await prisma.$queryRaw<ViewStats[]>`...`

2. SQL Injection

$queryRaw는 템플릿 리터럴을 사용하면 자동으로 이스케이프된다.

// 안전함 - 템플릿 리터럴
const result = await prisma.$queryRaw`
  SELECT * FROM users WHERE id = ${userId}
`

// 위험함 - 문자열 연결
const result = await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE id = '${userId}'`
)

3. 트랜잭션

Prisma 작업과 raw SQL을 함께 트랜잭션으로 묶을 수 있다.

await prisma.$transaction(async (tx) => {
  // Prisma 작업
  const user = await tx.user.create({
    data: { email, name }
  })
  
  // Raw SQL 작업
  await tx.$executeRaw`
    INSERT INTO audit_log (user_id, action)
    VALUES (${user.id}, 'created')
  `
})

결론

"Prisma냐 SQL이냐"는 이분법적 선택이 아니다. 둘 다 도구일 뿐이고, 상황에 맞게 쓰면 된다.

80%의 작업은 Prisma로 빠르게 처리하고, 20%의 복잡한 쿼리는 직접 SQL로 작성한다. 이게 내가 찾은 균형점이다.

처음 ORM을 접하는 분들에게는 Prisma를 추천한다. 일단 편하게 시작하고, SQL이 필요한 순간이 오면 그때 $queryRaw를 배우면 된다. 어차피 SQL을 완전히 피할 수는 없으니까.

중요한 건 둘 다 익숙해지는 것이다. Prisma만 알면 한계에 부딪히고, SQL만 고집하면 생산성이 떨어진다.