개발을 하다 보면 ORM의 편리함에 익숙해지지만, 때로는 복잡한 쿼리나 성능 최적화가 필요한 순간이 찾아온다. Prisma Raw SQL은 ORM의 추상화 레벨을 벗어나 직접 SQL을 작성할 수 있게 해주는 강력한 기능을 한다. 특히 복잡한 조인이나 집계 함수, 데이터베이스 특화 기능을 사용해야 할 때 유용하다.
❓ Prisma Raw SQL
- Prisma Raw SQL은 Prisma Client에서 제공하는 기능으로, 개발자가 직접 SQL 쿼리를 작성하여 데이터베이스에 전송할 수 있게 해준다. 일반적인 Prisma Client API로는 표현하기 어려운 복잡한 쿼리나 성능 최적화가 필요한 경우에 사용한다.
Prisma 5.19.0부터는 TypedSQL이라는 새로운 방식이 도입되어 타입 안전성까지 보장하면서 Raw SQL을 사용할 수 있게 되었다. 이는 기존의 수동 타입 정의 방식의 번거로움을 해결하고, 스키마 변경 시 자동으로 타입이 업데이트되지 않는 문제를 근본적으로 해결했다.
▶ 기본 사용 예시
// 기본적인 SELECT 쿼리
const users = await prisma.$queryRaw`SELECT * FROM User`;
// 매개변수를 포함한 쿼리
const email = '[email protected]';
const user = await prisma.$queryRaw`SELECT * FROM User WHERE email = ${email}`;
// Prisma.sql 사용 (타입 안정성 더 높음)
import { Prisma } from '@prisma/client';
const result = await prisma.$queryRaw(
Prisma.sql`SELECT * FROM user WHERE id = ${userId}`
);
▶ 타입 캐스팅
// 반환 타입 명시
const users = await prisma.$queryRaw`
SELECT id, name FROM user
` as Array<{ id: string; name: string }>;
// 또는 Prisma 타입 사용
type UserResult = {
id: string;
name: string;
};
const users = await prisma.$queryRaw<UserResult[]>`
SELECT id, name FROM user
`;
❗ 사용이유
✅ 성능 최적화
- 복잡한 집계 쿼리나 다중 테이블 조인이 필요할 때, Prisma Client API로는 비효율적인 쿼리가 생성될 수 있다. 이런 상황에서 직접 SQL을 작성하면 데이터베이스 엔진에 최적화된 쿼리를 실행할 수 있다. 특히 대용량 데이터를 다루거나 실시간 처리가 중요한 애플리케이션에서는 이런 최적화가 필수적이다.
✅ Prisma Client가 지원하지 않는 기능 사용
- 데이터베이스별 특화 기능이나 최신 SQL 표준 기능을 사용해야 할 때가 있다. 예를 들어, PostgreSQL의 전문 검색 기능이나 MySQL의 특정 함수들은 Prisma Client API로는 직접 접근하기 어렵다. 이런 경우 Raw SQL을 통해 데이터베이스의 모든 기능을 활용할 수 있다.
💠 예시
1️⃣ 안전한 쿼리 실행 ($queryRaw)
const userId = '7d2c979c-f356-4ac0-8005-2b1f1f196a31';
const result = await prisma.$queryRaw`
SELECT SUM(amount) as totalAmount
FROM Payment
WHERE clientId = ${userId}
`;
2️⃣ 복잡한 조인 쿼리
const articlesWithUsers = await prisma.$queryRaw`
SELECT a.*, u.email, u.name
FROM articles a
INNER JOIN users u ON a.userId = u.id
WHERE a.userId = ${userId}
`;
3️⃣ 집계 함수 활용
const stats = await prisma.$queryRaw`
SELECT
COUNT(*) as totalPosts,
AVG(viewCount) as avgViews,
MAX(createdAt) as lastPost
FROM posts
WHERE authorId = ${authorId}
`;
4️⃣ 영향받은 행 수 반환 ($executeRaw)
const updatedRows = await prisma.$executeRaw`
UPDATE posts
SET status = 'published'
WHERE draft = true AND authorId = ${authorId}
`;
🚩 실제 프로젝트 사례
친구 목록을 조회하는 api를 구현하다가 더 최적화된 로직을 찾으려고 시도했다.
먼저 prisma의 include로 조인하는 방법을 사용하면 2개의 쿼리를 사용한다.
// 1. 내가 친구 요청을 보낸 친구들 (userCuid = 나, friendCuid = 친구)
const sentFriendRequests = await prisma.friend.findMany({
where: {
userCuid: user.id,
status: 'accepted',
},
include: {
friend: {
select: {
id: true,
userId: true,
nickname: true,
profileImg: true,
},
},
},
});
// 2. 나에게 친구 요청을 받아서 수락한 친구들 (friendCuid = 나, userCuid = 친구)
const receivedFriendRequests = await prisma.friend.findMany({
where: {
friendCuid: user.id,
status: 'accepted',
},
include: {
user: {
select: {
id: true,
userId: true,
nickname: true,
profileImg: true,
},
},
},
});
// 3. 중복제거 후 합치기
const friendsFromSent = sentFriendRequests.map(req => req.friend);
const friendsFromReceived = receivedFriendRequests.map(req => req.user);
const allFriends = [...friendsFromSent, ...friendsFromReceived];
const uniqueFriends = allFriends.filter((friend, index, self) =>
index === self.findIndex(f => f.id === friend.id)
);
const friends = uniqueFriends;
두 번째로 조인 없이 구현하면 3개의 쿼리를 사용한다.
// 1. 내가 친구 요청을 보낸 친구들의 ID 조회 (userCuid = 나, friendCuid = 친구)
const sentFriendRequests = await prisma.friend.findMany({
where: {
userCuid: user.id,
status: 'accepted',
},
select: {
friendCuid: true,
},
});
// 2. 나에게 친구 요청을 받아서 수락한 친구들의 ID 조회 (friendCuid = 나, userCuid = 친구)
const receivedFriendRequests = await prisma.friend.findMany({
where: {
friendCuid: user.id,
status: 'accepted',
},
select: {
userCuid: true,
},
});
// 3. 친구들의 ID를 합치고 중복 제거
const sentFriendIds = sentFriendRequests.map(req => req.friendCuid);
const receivedFriendIds = receivedFriendRequests.map(req => req.userCuid);
const allFriendIds = [...sentFriendIds, ...receivedFriendIds];
const uniqueFriendIds = [...new Set(allFriendIds)];
// 4. 친구들의 정보를 별도로 조회
const friends = await prisma.user.findMany({
where: {
id: {
in: uniqueFriendIds,
},
},
select: {
id: true,
userId: true,
nickname: true,
profileImg: true,
},
});
가장 최적화된 방법은 raw query를 사용해서 한 번의 쿼리를 사용하는 것이다. 그리고 타입 안정성을 지키기 위해 인터페이스를 정의해 지정해주었다.
interface FriendInfo {
id: string;
userId: string;
nickname: string;
profileImg: string | null;
}
const friends = await prisma.$queryRaw<FriendInfo[]>`
SELECT DISTINCT
u.id,
u.userId,
u.nickname,
u.profileImg
FROM user u
INNER JOIN friend f ON (
(f.userCuid = ${user.id} AND f.friendCuid = u.id) OR
(f.friendCuid = ${user.id} AND f.userCuid = u.id)
)
WHERE f.status = 'accepted'
`;
✅ 추가
- Raw SQL을 사용할 때는 데이터베이스별 SQL 방언의 차이점을 고려해야 한다. PostgreSQL과 MySQL, SQLite는 각각 다른 함수와 문법을 사용하기 때문에, 다중 데이터베이스 지원이 필요한 경우 주의가 필요하다. 또한 Prisma의 마이그레이션 시스템과의 호환성도 고려해야 한다.
❔ TypedSQL
TypedSQL은 Raw SQL의 타입 안전성 문제를 해결하는 혁신적인 접근 방식이다. 이는 PgTyped나 sqlx와 같은 프로젝트에서 영감을 받아 개발되었으며, 개발자가 SQL을 직접 작성하면서도 완전한 타입 안전성을 보장받을 수 있게 해준다. 앞으로는 Raw SQL보다는 TypedSQL을 우선적으로 고려하는 것이 좋겠다.
[참고]
https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/raw-queries
https://www.prisma.io/blog/announcing-typedsql-make-your-raw-sql-queries-type-safe-with-prisma-orm
https://dev.to/jquagliatini/improving-prisma-raw-query-typing-nhd
https://app.studyraid.com/en/read/11147/345645/raw-sql-queries-in-prisma