
얼마 전에 사이드 프로젝트를 하나 시작했어요. 사실 회사 일만 하다 보면 손이 좀 굳는 느낌이 들 때가 있거든요. 그래서 주말에 커피 한 잔 내려놓고, 요즘 다들 이야기하는 AI 바이브 코딩을 제대로 한번 써봤습니다. “어디까지 맡겨도 될까?” 하는 궁금함도 있었고요.
그런데 막상 AI가 만들어준 코드를 보니까, 음… 편하긴 정말 편한데요. Prisma ORM을 쓰는 방식에서 살짝 등골이 서늘한 부분들이 보이더라고요. 기본 CRUD는 기가 막히게 만들어줘요. 진짜 빠릅니다. 그런데 실제 서비스에 올릴 코드라고 생각하고 보면, “이거 트래픽 조금만 들어와도 DB 힘들겠는데?” 싶은 코드가 꽤 자주 나왔어요.
제가 개발을 20년 가까이 하면서 여러 ORM을 써봤는데, AI가 만들어주는 Prisma 코드는 특히 더 꼼꼼하게 봐야겠다는 생각이 들었습니다. 오늘은 그때 겪었던 이야기들을 좀 편하게 풀어볼게요. 거창한 이론보다는, 제가 실제로 보면서 “아, 이건 그냥 넘기면 안 되겠다” 싶었던 부분들 위주로요.
AI 바이브 코딩은 빠르지만, Prisma의 N+1 문제는 잘 못 챙기더라고요
솔직히 말하면 AI 바이브 코딩의 가장 큰 매력은 속도예요. “User랑 Post를 relation으로 연결해줘”라고 말하면, Prisma 스키마부터 API 라우트까지 몇 초 만에 만들어줍니다. 예전 같으면 문서 열어놓고, 모델 잡고, 타입 맞추고, 테스트 한 번 돌리고… 이러다 한참 갔을 일이죠.
그런데 여기서 함정이 하나 있어요. AI는 대체로 동작하는 코드를 먼저 만들어줍니다. 문제는 그 코드가 꼭 운영 환경에서 버틸 수 있는 코드는 아니라는 거예요. 특히 ORM을 쓸 때는 더 그렇습니다. 겉으로 보기엔 깔끔한데, 안쪽에서 DB 쿼리가 와르르 나가는 경우가 있거든요.
제가 최근에 만든 작은 블로그 API가 딱 그랬어요. 게시글 목록을 가져오는 엔드포인트를 AI에게 부탁했는데, Prisma 코드가 이런 식으로 나왔습니다.
// AI가 처음 만들어준 코드
const posts = await prisma.post.findMany();
for (const post of posts) {
post.author = await prisma.user.findUnique({
where: { id: post.authorId },
});
}
처음 보면 크게 이상해 보이지 않죠. 게시글 가져오고, 각 게시글의 작성자 정보를 붙여주는 코드니까요. 그런데 이게 바로 그 유명한 N+1 문제입니다.
게시글이 10개면 쿼리가 11번 나갑니다. 게시글이 100개면 101번 나가고요. 데이터가 1,000개면요? 생각만 해도 좀 피곤하죠. 로컬에서는 잘 돌아갑니다. 테스트 데이터 몇 개 넣어놓고 볼 때는 아무 문제 없어 보여요. 그런데 운영에서 트래픽이 조금만 붙으면 DB 커넥션 풀이 숨을 헐떡이기 시작합니다.
이런 코드는 Prisma의 include나 상황에 따라 relationLoadStrategy: join을 써서 훨씬 안전하게 바꿔줘야 합니다.
// 사람이 한 번 더 다듬은 코드
const posts = await prisma.post.findMany({
include: {
author: {
select: {
id: true,
name: true,
avatar: true,
},
},
},
});
이렇게 바꾸면 게시글과 작성자 정보를 관계 기반으로 한 번에 가져올 수 있어요. 물론 내부적으로 어떤 SQL이 나가는지는 Prisma 설정이나 DB 종류에 따라 조금 다를 수 있지만, 적어도 루프 안에서 쿼리를 계속 날리는 구조는 피하게 됩니다.
| Prisma 사용 패턴 | DB 쿼리 흐름 | 제가 보는 기준 |
|---|---|---|
| 루프 안에서 findUnique 호출 | N+1 형태로 쿼리 증가 | 운영 코드에서는 거의 피해야 함 |
| include로 relation 함께 조회 | 관계 데이터를 한 번에 조회 | 일반적인 목록 조회에 무난함 |
| select로 필요한 필드만 조회 | 불필요한 컬럼 로딩 감소 | API 응답 최적화에 좋음 |
| raw query 사용 | 직접 SQL 제어 | 복잡한 리포트나 특수한 경우에만 |
여기서 제가 느낀 건 이거예요. AI한테 “N+1 문제 해결해줘”라고 직접 말하면 꽤 잘 고쳐줍니다. 그런데 처음부터 알아서 챙겨주지는 않는 경우가 많아요. 이 차이가 꽤 큽니다. 그러니까 AI를 믿지 말라는 이야기는 아니고요. AI가 만든 Prisma 코드는 반드시 쿼리 관점에서 한 번 더 봐야 한다는 쪽에 가깝습니다.
Next.js와 Prisma로 무한 스크롤 만들 때, offset 방식은 조심해야 해요
요즘은 Next.js에서 Server Actions를 쓰면서 Prisma를 붙이는 조합을 많이 쓰죠. 저도 사이드 프로젝트에서 이 조합을 썼어요. 화면은 게시글 피드였고, 아래로 스크롤하면 다음 게시글이 계속 붙는 구조였습니다. 흔히 말하는 무한 스크롤이죠.
이 부분도 AI에게 맡겨봤습니다. “Next.js에서 Prisma로 무한 스크롤 API 만들어줘”라고 했더니 금방 만들어주더라고요. 그런데 코드를 보니 대부분 skip/take 기반 페이지네이션이었습니다.
// AI가 자주 만들어주는 offset 기반 코드
async function getPosts(page: number) {
return await prisma.post.findMany({
skip: page * 20,
take: 20,
orderBy: {
createdAt: 'desc',
},
include: {
author: true,
tags: true,
},
});
}
이 코드도 처음에는 괜찮습니다. 데이터가 몇 백 건 정도면 큰 문제를 못 느껴요. 그런데 데이터가 10만 건, 100만 건으로 늘어나면 이야기가 달라집니다. 뒤쪽 페이지로 갈수록 DB가 앞의 데이터를 계속 읽고 건너뛰어야 하거든요. 말 그대로 “여기까지는 버리고, 그다음부터 주세요” 하는 방식이라서요.
제가 실제로 로그를 찍어보니, 초기 페이지는 응답이 괜찮은데 뒤로 갈수록 쿼리 시간이 서서히 늘어났습니다. 이게 참 애매해요. 한 번에 터지는 장애가 아니라, 서비스가 커지면서 은근히 느려지는 유형이라 더 무섭습니다. 나중에 원인 찾으려면 괜히 캐시 탓하고, 네트워크 탓하고, 프론트 렌더링 탓하고 돌아다니게 되거든요.
그래서 저는 무한 스크롤에는 가능하면 커서 기반 페이지네이션을 씁니다. Prisma에서도 이런 식으로 작성할 수 있어요.
// 커서 기반으로 다듬은 코드
async function getPosts(cursor?: string) {
return await prisma.post.findMany({
take: 20,
...(cursor && {
cursor: { id: cursor },
skip: 1,
}),
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
title: true,
summary: true,
createdAt: true,
author: {
select: {
name: true,
avatar: true,
},
},
tags: {
select: {
name: true,
},
},
},
});
}
여기서 핵심은 cursor에 쓸 값이 안정적이어야 한다는 겁니다. 보통은 id를 많이 쓰고, 정렬 기준에 따라 createdAt을 함께 고려하기도 해요. 다만 createdAt만 단독으로 쓰면 같은 시간이 들어간 데이터가 있을 때 애매해질 수 있어서, 실제 서비스에서는 id와 조합해서 보는 편이 더 마음이 편합니다.
Prisma 스키마도 같이 봐야 합니다. AI가 cursor 기반 코드만 바꿔놓고, 정작 스키마의 인덱스나 유니크 조건을 대충 넘어가는 경우가 있거든요.
// 예시 Prisma schema
model Post {
id String @id @default(cuid())
title String
summary String?
createdAt DateTime @default(now())
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
tags Tag[]
@@index([createdAt])
}
이런 부분은 AI가 잘하는 듯하면서도 가끔 빠뜨립니다. 특히 onDelete: Cascade 같은 옵션은 꼭 확인해야 해요. 부모 데이터를 지웠는데 자식 데이터가 남아서 삭제 에러가 터지는 경우, 개발 중에는 그냥 불편한 정도지만 운영에서는 꽤 골치 아픕니다.
- 무한 스크롤에는 skip/take보다 cursor 기반을 먼저 검토하기
- cursor로 쓸 필드가 안정적인지 확인하기
- createdAt 정렬만 믿지 말고 id 조합도 고려하기
- Prisma schema에서 @id, @unique, @@index를 직접 확인하기
- relation에 onDelete 정책이 필요한지 꼭 따져보기
뭐랄까, AI가 길은 빠르게 깔아주는데 도로 포장 상태까지 완벽하게 봐주진 않는 느낌이에요. 차가 굴러가긴 하는데, 고속도로에 올려도 되는지는 사람이 확인해야 하는 거죠.
AI가 만든 Prisma 코드를 볼 때 제가 꼭 확인하는 것들
AI와 Prisma를 같이 쓰다 보니, 이제는 거의 습관처럼 보는 항목들이 생겼습니다. 거창한 체크리스트는 아니고요. 코드를 열면 눈이 먼저 가는 부분들이 있어요. 특히 업무 코드라면 더 예민하게 봅니다. 사이드 프로젝트는 내가 고생하면 끝이지만, 회사 서비스는 사용자가 있고 돈이 걸려 있으니까요.
Prisma Client의 $transaction을 빠뜨리지 않았는지 봅니다
AI가 결제, 주문, 포인트 적립, 재고 차감 같은 로직을 만들 때 은근히 자주 놓치는 게 Prisma의 $transaction입니다. 여러 테이블을 순서대로 업데이트해야 하는데, 중간에 하나 실패해도 앞에서 처리한 데이터가 그대로 남는 코드가 나올 때가 있어요.
예를 들어 주문 생성 로직이라면 보통 이런 흐름이 있죠.
- Order 생성
- OrderItem 여러 개 생성
- Product 재고 차감
- User 포인트 사용 처리
- Payment 로그 저장
이 중 하나라도 실패하면 전체가 같이 취소되어야 합니다. 그런데 AI가 만든 코드는 종종 이런 식으로 따로따로 실행됩니다.
// 위험할 수 있는 코드
const order = await prisma.order.create({ data: orderData });
await prisma.orderItem.createMany({ data: orderItems });
await prisma.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
});
로컬에서는 잘 돼요. 그래서 더 위험합니다. 재고 차감에서 에러가 났는데 주문은 이미 만들어진 상태가 될 수 있거든요. 이런 건 나중에 CS로 돌아옵니다. 개발자 입장에서는 정말 피하고 싶은 종류의 문제죠.
// 트랜잭션으로 감싼 코드
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: orderData,
});
await tx.orderItem.createMany({
data: orderItems.map((item) => ({
...item,
orderId: order.id,
})),
});
await tx.product.update({
where: { id: productId },
data: {
stock: { decrement: quantity },
},
});
});
저는 AI가 만들어준 비즈니스 로직을 보면 먼저 “이거 중간에 실패하면 어떻게 되지?”부터 생각합니다. 그 질문 하나만 해도 코드 품질이 꽤 달라져요. AI에게 다시 요청할 때도 그냥 “개선해줘”라고 하지 않고, 이렇게 말하는 편입니다.
이 로직은 주문 생성, 주문 상품 생성, 재고 차감을 함께 처리해.
중간에 하나라도 실패하면 전체 롤백되도록 Prisma $transaction 기반으로 수정해줘.
동시성 이슈가 생길 수 있는 부분도 같이 짚어줘.
이렇게 구체적으로 말하면 결과물이 확 좋아집니다. AI는 애매하게 물어보면 애매하게 답하고, 구체적으로 물어보면 꽤 쓸 만하게 답해요. 사람하고 비슷한 면이 있습니다.
include를 남발하지 말고 select로 줄일 수 있는지 봅니다
AI가 Prisma 쿼리를 만들 때 보면 include를 참 좋아합니다. relation을 붙이는 데 편하니까요. 그런데 include는 조심해야 해요. 관계 데이터를 통째로 가져오다 보면 API 응답이 예상보다 커집니다.
프론트엔드에서는 작성자 이름과 프로필 이미지만 쓰는데, 백엔드에서는 author 전체를 가져오는 경우가 흔합니다. 이메일, 권한, 생성일, 수정일, 내부 메모 같은 필드까지 딸려올 수 있죠. 성능 문제도 있지만, 보안적으로도 썩 좋은 습관은 아닙니다.
| 방식 | 특징 | 제가 쓰는 기준 |
|---|---|---|
| include | 관계 데이터를 함께 가져오기 쉬움 | 관리자 화면이나 내부 도구에서는 편함 |
| select | 필요한 필드만 명확히 가져옴 | 외부 API 응답에는 거의 기본값처럼 사용 |
저는 외부로 나가는 API라면 가능하면 select로 좁힙니다. 처음엔 조금 귀찮아도 나중에 훨씬 편해요. 응답 데이터가 작아지고, 의도치 않은 필드 노출도 막을 수 있고요.
// AI가 자주 만드는 코드
const posts = await prisma.post.findMany({
include: {
author: true,
comments: true,
},
});
// 제가 보통 다시 다듬는 코드
const posts = await prisma.post.findMany({
select: {
id: true,
title: true,
summary: true,
createdAt: true,
author: {
select: {
id: true,
name: true,
avatar: true,
},
},
_count: {
select: {
comments: true,
},
},
},
});
댓글 목록 전체가 필요한 게 아니라 댓글 수만 필요하다면 _count를 쓰는 게 훨씬 낫습니다. 이런 디테일이 쌓이면 서비스가 훨씬 가벼워져요. 개발하면서 느끼는 건데, 성능 최적화라는 게 꼭 대단한 튜닝만은 아니더라고요. 필요 없는 걸 안 가져오는 것, 그게 꽤 큰 최적화입니다.
Prisma Studio와 logging은 AI랑 같이 쓰면 꽤 든든합니다
이건 제가 좋아하는 조합인데요. Prisma Studio와 AI를 같이 쓰면 디버깅 속도가 꽤 빨라집니다. 예전에는 문서 뒤지고, 명령어 찾아보고, 로그 설정 찾아보고 그랬는데 이제는 AI에게 바로 물어봅니다.
현재 Prisma schema 기준으로 Prisma Studio 실행하는 명령어 알려줘.
그리고 개발 환경에서 쿼리 로그 확인하는 설정도 같이 알려줘.
그러면 보통 npx prisma studio부터 Prisma Client logging 설정까지 쭉 알려줍니다. 실제로 저는 개발 중에 이런 설정을 자주 켜둡니다.
const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error'],
});
이렇게 해두면 내가 작성한 Prisma 코드가 실제로 어떤 쿼리 흐름을 만드는지 감이 옵니다. 특히 N+1 문제를 잡을 때는 로그가 정말 유용해요. 화면 한 번 띄웠는데 query 로그가 수십 줄씩 올라오면, 그건 그냥 냄새가 나는 겁니다. 개발자들이 말하는 그 느낌 있잖아요. “아, 여기 뭔가 있다.” 하는 느낌요.
저는 AI를 단순한 코드 생성기로만 쓰지는 않아요. 오히려 문서 탐색 도우미, 디버깅 짝꿍으로 쓸 때 만족도가 더 높았습니다. “이 코드 만들어줘”보다 “이 코드에서 성능상 위험한 부분을 찾아줘”라고 물어볼 때 더 도움이 되는 경우가 많았어요.
제가 AI에게 Prisma 코드를 맡길 때 실제로 쓰는 요청 방식
AI 바이브 코딩을 하다 보면 결국 질문을 어떻게 하느냐가 중요하더라고요. 그냥 “게시글 API 만들어줘”라고 하면 그럭저럭 동작하는 코드는 나옵니다. 하지만 운영까지 생각한 코드는 잘 안 나와요. 그래서 저는 요청할 때 조건을 꽤 구체적으로 붙입니다.
예를 들면 이런 식입니다.
Next.js Server Actions와 Prisma를 사용해서 게시글 목록 조회 함수를 만들어줘.
조건은 아래와 같아.
- 무한 스크롤용 커서 기반 페이지네이션 사용
- skip/take offset 방식은 사용하지 않기
- author는 name, avatar만 select로 가져오기
- comments는 전체 목록이 아니라 개수만 가져오기
- N+1 문제가 생기지 않게 작성
- Prisma schema에서 필요한 index도 같이 제안
이렇게 요청하면 결과물이 확 달라집니다. AI가 알아서 최선의 판단을 해주길 기다리기보다, 내가 원하는 기준을 먼저 던져주는 거죠. 조금 귀찮아 보여도 전체 시간은 오히려 줄어듭니다. 나중에 리팩토링하느라 한숨 쉬는 시간이 줄거든요.
제가 자주 붙이는 조건도 따로 있어요.
- N+1 문제가 없도록 작성해줘
- include 대신 가능한 select를 사용해줘
- 대량 데이터 기준으로 성능상 위험한 부분을 설명해줘
- 트랜잭션이 필요한 부분은 $transaction으로 감싸줘
- Prisma schema의 index와 relation 설정도 같이 검토해줘
- 실제 발생할 SQL 쿼리 흐름을 간단히 설명해줘
이 정도만 붙여도 AI가 만드는 Prisma 코드는 훨씬 쓸 만해집니다. 물론 그래도 최종 검토는 사람이 해야 해요. 이건 제 생각이 좀 확고합니다. AI가 아무리 좋아져도, 서비스의 책임은 결국 개발자가 져야 하니까요.
AI 바이브 코딩은 좋은 도구지만, Prisma에서는 사람이 방향을 잡아줘야 합니다
사실 저는 AI 바이브 코딩을 꽤 긍정적으로 봅니다. 예전보다 훨씬 빠르게 아이디어를 코드로 옮길 수 있고, 귀찮은 반복 작업도 많이 줄었어요. 특히 Prisma처럼 타입이 잘 잡히는 도구와 함께 쓰면 생산성이 꽤 좋습니다. 자동 완성도 좋고, schema 기반으로 코드 흐름을 잡기도 쉽고요.
다만 ORM은 늘 조심해야 합니다. Prisma가 편하다고 해서 DB 비용이 사라지는 건 아니거든요. 오히려 편해서 더 위험할 때가 있습니다. 코드 한 줄은 예쁜데, 뒤에서 쿼리가 여러 번 나가고 있을 수 있으니까요.
제가 이번에 AI로 Prisma 코드를 만들면서 다시 느낀 건 이겁니다. AI는 초안을 정말 잘 만듭니다. 하지만 성능, 트랜잭션, 데이터 정합성, 인덱스, 보안 필드 노출 같은 부분은 아직 개발자의 경험이 필요합니다. 이건 단순히 오래 개발해서 하는 꼰대 같은 이야기는 아니고요. 실제로 운영에서 한 번 터져본 사람은 압니다. 작은 쿼리 하나가 장애의 시작이 될 수 있다는 걸요.
이 글은 AI 바이브 코딩으로 Prisma ORM을 처음 써보는 분, Next.js와 Prisma 조합으로 사이드 프로젝트를 만드는 분, 그리고 N+1 문제나 페이지네이션 성능 때문에 찜찜했던 분이 읽으면 특히 도움이 될 것 같아요.
20년 개발해도 매번 새로 배웁니다. 저도 아직 모르는 게 많고요. 다만 이제는 조금 압니다. AI가 만들어준 코드가 너무 그럴듯할수록, 한 번쯤은 로그를 켜보고 DB 쿼리를 들여다봐야 한다는 걸요. 그 작은 습관 하나가 나중에 주말 장애 대응을 막아줄 때가 있습니다. 주말엔 우리 좀 쉬어야죠. 커피도 마시고, 바람도 쐬고요.
댓글
댓글 쓰기