prisma.consult.findMany, prisma.consult.update, prisma.product.findUnique 같은 코드를 Controller나 Use Case에 직접 흩뿌리지 않고 Repository로 모읍니다.Controller
↓
Use Case
↓
Repository
↓
Prisma
↓
PostgreSQL
Prisma query가 여러 파일에 흩어지는 것을 방지
Soft Delete 조건 누락 방지
목록 조회 select 기준 통일
상태 변경 query 기준 통일
Transaction 안팎에서 같은 query 재사용
테스트와 리팩토링 범위 축소
AI/Codex 작업 시 수정 위치 명확화
@Injectable()
export class ConsultService {
constructor(private readonly prisma: PrismaService) {}
async getList() {
return this.prisma.consult.findMany({
where: {
deletedAt: null,
},
});
}
async getDetail(id: number) {
return this.prisma.consult.findUnique({
where: {
id,
},
include: {
product: true,
statusHistories: true,
},
});
}
async updateStatus(id: number, status: ConsultStatus) {
return this.prisma.consult.update({
where: {
id,
},
data: {
status,
},
});
}
}
문제:
Service가 DB query를 직접 많이 가짐
목록/상세/상태 변경 기준이 뒤섞임
상태 변경 이력 누락 가능
Soft Delete 조건이 상세 조회에서 빠질 수 있음
select/include 기준이 일관되지 않음
UpdateConsultStatusUseCase:
상태 변경 업무 흐름 담당
ConsultStatusService:
상태 전이 규칙 담당
ConsultRepository:
consults/status_histories DB 접근 담당
AuditLogRepository:
audit_logs DB 접근 담당
consult/
application/
create-consult.use-case.ts
update-consult-status.use-case.ts
get-consult-list.use-case.ts
domain/
consult-status.service.ts
duplicate-consult-policy.ts
infra/
consult.repository.ts
@Injectable()
export class GetConsultListUseCase {
constructor(private readonly consultRepository: ConsultRepository) {}
async execute(query: GetConsultListQuery) {
return this.consultRepository.findAdminList(query);
}
}
where, select, orderBy, skip, take는 Repository에 둡니다.Prisma query 작성
where/orderBy/select 구성
Soft Delete 조건 적용
pagination query 실행
상태 변경 update query
상태 이력 insert query
transaction client 사용
raw SQL 캡슐화
HTTP 요청/응답 처리
권한 판단
상태 전이 규칙 판단
중복 신청 정책 결정
알림톡 발송
Audit Log action 결정
비즈니스 에러 메시지 남발
getData, updateInfo, findAll처럼 모호한 이름은 피하는 것이 좋습니다.findAdminList
findDetailForAdmin
findActiveById
findByIdOrThrow
createWithSnapshot
updateStatus
createStatusHistory
findDuplicateCandidate
countByListFilter
get
getList
find
update
process
handle
data
list
누가 쓰는 조회인가?
어떤 조건이 포함되는가?
어떤 목적의 update인가?
Soft Delete 조건이 포함되는가?
관리자용인가 고객용인가?
findActiveById와 findByIdOrThrow는 의미가 다릅니다.PrismaService를 만들어 전역 또는 모듈에서 주입받습니다.PrismaService를 주입받아 query를 실행합니다.@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
@Injectable()
export class ConsultRepository {
constructor(private readonly prisma: PrismaService) {}
findByIdOrThrow(id: number) {
return this.prisma.consult.findUniqueOrThrow({
where: { id },
});
}
}
PrismaClient를 여러 번 new 하지 않기
모든 Repository가 같은 PrismaService를 사용
테스트에서 mock 또는 test DB 기준 정하기
connection lifecycle 관리
$transaction(async tx => {}) 안에서는 tx를 사용해야 같은 transaction에 묶입니다.type PrismaTx = Prisma.TransactionClient;
@Injectable()
export class ConsultRepository {
constructor(private readonly prisma: PrismaService) {}
findByIdOrThrow(id: number, tx: PrismaTx = this.prisma) {
return tx.consult.findUniqueOrThrow({
where: { id },
});
}
}
await this.prisma.$transaction(async (tx) => {
const consult = await this.consultRepository.findByIdOrThrow(
consultId,
tx,
);
await this.consultRepository.updateStatus(
{
consultId,
nextStatus,
},
tx,
);
});
transaction 안팎에서 같은 메서드 재사용
Use Case가 transaction boundary 관리
Repository는 전달받은 client로 query 실행
tx를 넘기는 것이 번거로울 수 있으므로 프로젝트 컨벤션을 정해두는 것이 좋습니다.Prisma.TransactionClient를 반복해서 쓰면 코드가 길어질 수 있습니다.import { Prisma } from '@prisma/client';
import { PrismaService } from './prisma.service';
export type PrismaTx = Prisma.TransactionClient | PrismaService;
@Injectable()
export class ProductRepository {
constructor(private readonly prisma: PrismaService) {}
findActiveById(id: number, tx: PrismaTx = this.prisma) {
return tx.product.findFirst({
where: {
id,
deletedAt: null,
isActive: true,
},
});
}
}
PrismaService와 TransactionClient는 완전히 같은 타입이 아님
$transaction 같은 메서드는 tx에 없음
Repository 안에서 다시 transaction을 열지 않기
Use Case가 여러 Repository 작업을 하나로 묶기 어려움
Repository 내부 transaction 범위가 숨겨짐
다른 Repository 작업과 정합성 맞추기 어려움
ConsultRepository.updateStatus()
내부에서 transaction 시작/commit
AuditLogRepository.create()
별도 query 실행
문제:
상태 변경은 성공했는데 Audit Log 실패 가능
단일 DB 작업:
Repository
여러 DB 작업을 하나로 묶는 업무:
Use Case에서 transaction
외부 API/Job까지 포함된 흐름:
Use Case에서 DB 작업만 transaction
@Injectable()
export class ConsultRepository {
constructor(private readonly prisma: PrismaService) {}
findAdminList(
params: {
where: Prisma.ConsultWhereInput;
skip: number;
take: number;
orderBy: Prisma.ConsultOrderByWithRelationInput[];
},
tx: PrismaTx = this.prisma,
) {
return tx.consult.findMany({
where: params.where,
skip: params.skip,
take: params.take,
orderBy: params.orderBy,
select: {
id: true,
customerName: true,
phoneMasked: true,
status: true,
source: true,
productNameSnapshot: true,
carrierSnapshot: true,
createdAt: true,
updatedAt: true,
},
});
}
countAdminList(
where: Prisma.ConsultWhereInput,
tx: PrismaTx = this.prisma,
) {
return tx.consult.count({ where });
}
findDetailForAdmin(id: number, tx: PrismaTx = this.prisma) {
return tx.consult.findFirst({
where: {
id,
deletedAt: null,
},
include: {
statusHistories: {
orderBy: {
createdAt: 'desc',
},
take: 50,
},
},
});
}
findByIdOrThrow(id: number, tx: PrismaTx = this.prisma) {
return tx.consult.findUniqueOrThrow({
where: { id },
});
}
updateStatus(
params: {
consultId: number;
nextStatus: ConsultStatus;
lastHandledByAdminId: number;
changedAt: Date;
},
tx: PrismaTx = this.prisma,
) {
return tx.consult.update({
where: { id: params.consultId },
data: {
status: params.nextStatus,
lastHandledByAdminId: params.lastHandledByAdminId,
lastStatusChangedAt: params.changedAt,
},
});
}
createStatusHistory(
params: {
consultId: number;
fromStatus: ConsultStatus;
toStatus: ConsultStatus;
changedByAdminId: number;
reason?: string;
memo?: string;
},
tx: PrismaTx = this.prisma,
) {
return tx.consultStatusHistory.create({
data: params,
});
}
}
목록은 select 최소화
상세는 include 허용
상태 변경은 updateStatus로 분리
상태 이력 생성도 Repository에 둠
tx를 받아 transaction 안에서 재사용 가능
isActive=true, deletedAt=null 조건이 중요하고, 관리자용은 삭제된 데이터 조회도 필요할 수 있습니다.@Injectable()
export class ProductRepository {
constructor(private readonly prisma: PrismaService) {}
findActiveById(id: number, tx: PrismaTx = this.prisma) {
return tx.product.findFirst({
where: {
id,
isActive: true,
deletedAt: null,
},
include: {
options: {
where: {
isActive: true,
deletedAt: null,
},
},
},
});
}
findAdminList(
params: {
skip: number;
take: number;
where: Prisma.ProductWhereInput;
orderBy: Prisma.ProductOrderByWithRelationInput[];
},
tx: PrismaTx = this.prisma,
) {
return tx.product.findMany({
where: params.where,
skip: params.skip,
take: params.take,
orderBy: params.orderBy,
select: {
id: true,
modelName: true,
carrier: true,
isActive: true,
displayOrder: true,
deletedAt: true,
createdAt: true,
},
});
}
softDelete(
params: {
productId: number;
deletedAt: Date;
deletedByAdminId: number;
deleteReason?: string;
},
tx: PrismaTx = this.prisma,
) {
return tx.product.update({
where: {
id: params.productId,
},
data: {
isActive: false,
deletedAt: params.deletedAt,
deletedByAdminId: params.deletedByAdminId,
deleteReason: params.deleteReason,
},
});
}
restore(productId: number, tx: PrismaTx = this.prisma) {
return tx.product.update({
where: {
id: productId,
},
data: {
deletedAt: null,
deletedByAdminId: null,
deleteReason: null,
},
});
}
}
고객용 조회:
isActive=true
deletedAt=null
노출 가능한 옵션만
관리자용 조회:
isActive=false도 볼 수 있음
deletedAt 포함 여부 선택
삭제함 조회 가능
@Injectable()
export class AuditLogRepository {
constructor(private readonly prisma: PrismaService) {}
create(
params: {
actorType: ActorType;
actorId?: number;
action: string;
targetType: string;
targetId?: string;
beforeValue?: Prisma.InputJsonValue;
afterValue?: Prisma.InputJsonValue;
requestId?: string;
ipAddress?: string;
userAgent?: string;
},
tx: PrismaTx = this.prisma,
) {
return tx.auditLog.create({
data: {
actorType: params.actorType,
actorId: params.actorId,
action: params.action,
targetType: params.targetType,
targetId: params.targetId,
beforeValue: params.beforeValue,
afterValue: params.afterValue,
requestId: params.requestId,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
},
});
}
}
Repository는 개인정보 마스킹을 자동으로 다 해주지 않음
Use Case에서 before/after에 넣을 값 선별 필요
토큰/Secret/전화번호 원본 저장 금지
consult/
infra/
consult.repository.ts
consult-admin-list-query.builder.ts
export function buildConsultAdminListWhere(
query: ConsultListQuery,
): Prisma.ConsultWhereInput {
return {
deletedAt: null,
...(query.status && {
status: query.status,
}),
...(query.source && {
source: query.source,
}),
...(query.productId && {
productId: query.productId,
}),
...(query.dateFrom || query.dateTo
? {
createdAt: {
...(query.dateFrom && { gte: query.dateFrom }),
...(query.dateTo && { lt: query.dateTo }),
},
}
: {}),
};
}
Repository 파일이 길어지는 것 방지
검색 조건 테스트 가능
목록 조회 조건 재사용 가능
ExportJob 조건과 일치시키기 쉬움
Repository:
DB row 조회
Mapper:
Response DTO로 변환
Controller:
응답 반환
DB 접근과 응답 가공 책임이 섞임
프론트 응답 요구사항이 바뀌면 Repository 수정
다른 Use Case에서 재사용 어려움
export function toConsultListItemDto(item: ConsultAdminListItem) {
return {
id: item.id,
customerName: item.customerName,
phone: item.phoneMasked,
status: item.status,
productName: item.productNameSnapshot,
carrier: item.carrierSnapshot,
source: item.source,
createdAt: item.createdAt,
};
}
Repository:
필요한 DB 필드 조회
Mapper:
프론트에 줄 형태로 변환
Use Case:
Repository 결과를 Mapper로 변환해 반환
deletedAt: null 조건을 누락하지 않는 것이 중요합니다.return this.prisma.product.findMany();
문제:
삭제된 상품까지 조회됨
고객 화면에 노출될 수 있음
관리자 일반 목록에 삭제된 데이터가 섞일 수 있음
findActiveProducts()
findAdminProducts()
findDeletedProducts()
findRestorableProductById()
고객용:
deletedAt null 필수
관리자 일반 목록:
deletedAt null 기본
삭제함:
deletedAt not null
복구:
deletedAt not null 대상만
async updateProduct(adminId: number, productId: number, data: UpdateProductData) {
const admin = await this.prisma.adminUser.findUnique({
where: { id: adminId },
});
if (admin.role !== 'SUPER_ADMIN') {
throw new ForbiddenException();
}
return this.prisma.product.update({
where: { id: productId },
data,
});
}
문제:
Repository가 권한 판단까지 담당
테스트 복잡
다른 권한 정책 재사용 어려움
DB 접근과 비즈니스 정책이 섞임
Use Case:
권한 Policy 호출
↓
권한 통과
↓
Repository update 호출
Guard:
로그인 여부, 기본 role
Policy:
세부 권한 판단
Use Case:
업무 흐름에서 권한 확인 호출
Repository:
권한 통과 후 DB 작업
findByIdOrThrow(id: number, tx: PrismaTx = this.prisma) {
return tx.consult.findUniqueOrThrow({
where: { id },
});
}
try {
const consult = await this.consultRepository.findByIdOrThrow(
command.consultId,
tx,
);
} catch {
throw new NotFoundException('상담을 찾을 수 없습니다.');
}
Repository:
DB 조회 실패를 그대로 던질 수 있음
Use Case:
업무 의미에 맞는 에러로 변환
Controller:
HTTP 응답 처리
NotFoundException을 던져도 동작은 합니다.복잡한 통계 query
EXPLAIN 기반으로 튜닝한 목록 query
partial index migration
materialized view refresh
정합성 점검 query
async findStatusConsistencyIssues() {
return this.prisma.$queryRaw<
Array<{
consultId: number;
currentStatus: string;
lastHistoryStatus: string;
}>
>`
SELECT c.id AS "consultId",
c.status AS "currentStatus",
h.to_status AS "lastHistoryStatus"
FROM consults c
LEFT JOIN LATERAL (
SELECT to_status
FROM consult_status_histories h
WHERE h.consult_id = c.id
ORDER BY h.created_at DESC
LIMIT 1
) h ON true
WHERE h.to_status IS NOT NULL
AND c.status <> h.to_status
`;
}
$queryRawUnsafe 남용 금지
사용자 입력 직접 문자열 삽입 금지
SQL injection 주의
컬럼명 변경 시 깨질 수 있음
테스트 필요
Unit Test:
Prisma mock 사용
Integration Test:
Test DB 사용
E2E Test:
API부터 DB까지 전체 흐름 확인
findActiveById가 deletedAt 상품을 제외하는가?
findAdminList가 select 최소 필드만 반환하는가?
updateStatus가 status와 lastStatusChangedAt을 변경하는가?
createStatusHistory가 이력을 생성하는가?
softDelete가 deletedAt/isActive를 함께 변경하는가?
핵심 Repository만 테스트
Soft Delete 조건 테스트
상태 변경 query 테스트
복잡한 목록 query 테스트
raw SQL은 반드시 테스트
도메인 단위:
ConsultRepository
ProductRepository
AdminUserRepository
AuditLogRepository
ExportJobRepository
단순 설정 테이블:
기존 Service에서 직접 처리하거나 작은 Repository
CarrierRepository
ColorRepository
StorageRepository
TinySettingRepository
EveryTableRepository
복잡한 query가 많으면 Repository
여러 Use Case에서 재사용하면 Repository
Soft Delete/권한/이력 조건이 중요하면 Repository
단순 CRUD이고 거의 안 쓰면 과하게 분리하지 않기
이유:
상담 목록 조회
상담 상세 조회
상태 변경
상태 이력
중복 신청 확인
개인정보 select 관리
적용 효과:
상담 관련 Prisma query 정리
목록/상세 query 분리
상태 변경 transaction에 재사용
Soft Delete/개인정보 조건 관리
이유:
고객용 상품 조회
관리자용 상품 조회
상품/옵션 soft delete
상품 snapshot 생성
노출 여부 관리
적용 효과:
고객용/관리자용 query 분리
삭제된 상품 노출 방지
상담 신청 시 active 상품 확인
이유:
상태 변경
상품 수정
삭제/복구
엑셀 다운로드
관리자 권한 변경
적용 효과:
Audit Log 저장 기준 통일
requestId/actor/target 구조 일관화
운영 추적성 개선
이유:
엑셀 다운로드 요청
작업 상태 변경
Worker 처리
실패 사유 저장
파일 만료 처리
적용 효과:
Queue/Worker 구조와 연결
ExportJob 상태 관리 일관화
개인정보 파일 만료 정책 적용 쉬움
Consult 관련 Prisma query를 ConsultRepository로 분리해줘.
조건:
1. Controller에는 Prisma query를 직접 두지 마
2. Use Case는 ConsultRepository를 호출하게 해줘
3. 상담 목록 조회는 findAdminList로 분리해줘
4. 상담 상세 조회는 findDetailForAdmin으로 분리해줘
5. 상태 변경은 updateStatus로 분리해줘
6. 상태 이력 생성은 createStatusHistory로 분리해줘
7. Repository 메서드는 transaction client를 선택적으로 받을 수 있게 해줘
8. 목록 조회는 select로 필요한 필드만 가져오게 해줘
9. phoneNormalized, token, Secret은 응답에 노출하지 마
10. 기존 API 응답이 깨지지 않게 Mapper가 필요하면 추가해줘
11. 변경 후 수정 파일 목록, 위험 요소, 테스트/QA 체크리스트를 정리해줘
Repository에 비즈니스 규칙을 넣지 않았는가?
Use Case의 transaction이 유지되는가?
Soft Delete 조건이 누락되지 않았는가?
목록 select가 과하게 넓어지지 않았는가?
기존 API 응답이 바뀌지 않았는가?
타입 에러가 없는가?
테스트 또는 최소 QA가 가능한가?
NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰 백엔드에서 Repository Pattern을 적용하려고 해.
서비스 상황:
1. 상담 신청, 상담 목록 조회, 상담 상태 변경, 상태 이력 저장 로직이 있음
2. 상품은 고객용 조회와 관리자용 조회가 다르고, soft delete와 isActive 조건이 있음
3. 상담 상태 변경은 consults.status 업데이트, consult_status_histories 생성, audit_logs 생성을 하나의 transaction으로 묶어야 함
4. 관리자 목록은 page/limit, status/source/date 필터, createdAt desc + id desc 정렬을 사용함
5. 목록에서는 phoneMasked만 응답하고 phoneNormalized는 노출하면 안 됨
6. Audit Log는 여러 Use Case에서 공통으로 사용함
7. ExportJob은 Worker와 연결될 예정임
8. Prisma query가 Service 곳곳에 흩어지는 것을 줄이고 싶음
요청:
- Repository Pattern 적용 기준
- ConsultRepository 메서드 설계
- ProductRepository 메서드 설계
- AuditLogRepository 메서드 설계
- transaction client를 Repository에 전달하는 방식
- Repository와 Use Case의 책임 구분
- Repository와 Domain Service의 책임 구분
- Soft Delete 조건 관리 방법
- 목록 query select 최소화 기준
- raw SQL을 Repository에 둘 때 주의사항
- AI/Codex 리팩토링 요청 프롬프트
를 실무 기준으로 정리해줘.
Repository에 비즈니스 규칙을 과하게 넣지 않는가?
Use Case가 transaction boundary를 갖게 하는가?
Repository가 tx를 받을 수 있게 설계하는가?
Soft Delete 조건 누락 위험을 다루는가?
고객용/관리자용 조회를 구분하는가?
목록/상세 query를 분리하는가?
phoneNormalized 같은 내부 컬럼 노출을 경고하는가?
Prisma raw SQL의 injection 위험을 언급하는가?
현재 규모에 비해 Repository를 과하게 쪼개지 않는가?
PrismaService를 주입받아 사용하고, Repository가 이 PrismaService를 통해 query를 실행하는 방식이 기본입니다.tx를 전달하면 여러 Repository 작업을 하나의 transaction으로 묶을 수 있습니다.ConsultRepository는 상담 목록, 상세, 상태 변경, 상태 이력, 중복 신청 확인처럼 상담 관련 query를 모으는 핵심 Repository가 될 수 있습니다.ProductRepository는 고객용 active 상품 조회와 관리자용 상품 조회, soft delete, restore를 구분해야 합니다.AuditLogRepository는 여러 Use Case에서 공통으로 쓰이며, actor/action/target/requestId 저장 기준을 일관되게 만드는 데 도움이 됩니다.deletedAt: null, deletedAt not null 조건을 명확히 구분해야 삭제된 데이터 노출을 막을 수 있습니다.