Prisma vs 직접 SQL, 언제 뭘 써야 할까
시작하며
개발을 하다 보면 "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로 하려면 groupBy와 aggregate를 조합해야 하는데, 가독성이 떨어지고 제약이 많다.
// 이렇게 해도 되지만...
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만 고집하면 생산성이 떨어진다.