Prisma 쿼리 조건 정의 패턴 비교
결론
현재 사용 중인 Builder 패턴 외에 5가지 주요 대안이 있습니다:
- Builder 패턴 (현재 코드) - 선언적이고 타입 안전하며 재사용 가능한 PRESETS 제공
- Repository 패턴 - 데이터 접근 로직을 완전히 캡슐화하여 계층 분리
- Specification 패턴 - DDD 방식으로 비즈니스 규칙을 조합 가능한 객체로 표현
- Direct Service Layer - 서비스에서 직접 Prisma 쿼리 작성
- Function Composition - 작은 함수들을 조합하여 조건 생성
각 패턴은 프로젝트의 복잡도, 팀 규모, 요구사항에 따라 적합성이 다릅니다.
1. Builder 패턴 (현재 코드)
개념
SearchTarget enum으로 검색 대상을 정의하고, conditionMap을 통해 각 타겟에 대한 Prisma 조건을 선언적으로 매핑합니다.
장점
- 타입 안전성: enum으로 검색 대상이 명시적으로 정의됨
- 재사용성: PRESETS로 일반적인 조합을 사전 정의
- 가독성: 선언적 방식으로 코드 의도가 명확
- 확장성: 새 SearchTarget 추가가 구조화되어 있음
- 테스트 용이성: 각 타겟별로 독립적인 단위 테스트 가능
단점
- 보일러플레이트: 새 필드 추가 시 여러 곳 수정 필요 (enum, conditionMap, PRESETS)
- 유연성 제한: 복잡한 동적 조건 조합이 어려움
- 확장 오버헤드: 간단한 조건에도 enum 추가 필요
적합한 경우
- 검색 대상이 명확히 정의되어 있을 때
- 사전 정의된 검색 조합이 자주 재사용될 때
- 타입 안전성이 매우 중요한 프로젝트
Repository pattern with prisma (opens in a new tab)에서 "Prisma is quite different from traditional ORMs in that it doesn't offer any way to inherit or extend methods as there are no classes involved"라고 명시되어 있어, 클래스 기반 빌더 패턴이 Prisma와 잘 맞는다는 점을 시사합니다.
2. Repository 패턴
개념
모든 데이터 접근 로직을 Repository 클래스에 캡슐화하여 비즈니스 로직과 완전히 분리합니다.
코드 예시
@Injectable()
export class FanficRepository {
constructor(private prisma: PrismaService) {}
async searchByTitle(query: string): Promise<Fanfic[]> {
return this.prisma.fanfic.findMany({
where: {
title: { search: `"${query}"` },
isRemoved: 'N',
},
});
}
async searchByWriter(nickname: string): Promise<Fanfic[]> {
return this.prisma.fanfic.findMany({
where: {
writer: {
NICK: { search: `"${nickname}"` },
IS_DEACTIVATED: 'N',
},
},
});
}
async searchComplex(params: SearchParams): Promise<Fanfic[]> {
// 복잡한 검색 로직
}
}장점
- 완전한 캡슐화: Prisma가 서비스 레이어에 노출되지 않음
- 테스트 용이성: Repository를 쉽게 모킹 가능
- 계층 분리: 비즈니스 로직과 데이터 접근 로직이 명확히 분리
- ORM 교체 용이: Prisma를 다른 ORM으로 교체 시 Repository만 수정
단점
- 중복 추상화: Prisma 자체가 이미 Repository 역할을 하므로 불필요한 레이어 추가 Is the Repository Pattern needed with Prisma (opens in a new tab)
- 보일러플레이트 증가: 각 쿼리마다 래퍼 메서드 작성 필요
- 타입 안전성 저하: Prisma의 강력한 타입 추론 이점을 잃을 수 있음
- 성능 오버헤드: 추가 함수 호출 레이어
Repository pattern with prisma (opens in a new tab)에서 "Most of the time, wrapping Prisma looks like writing wrapper functions around Prisma queries instead of the usual class-based pattern"이라고 언급되어 있습니다.
적합한 경우
- ORM 교체 가능성이 있는 프로젝트
- 대규모 팀에서 명확한 계층 분리가 필요할 때
- 데이터 접근 로직을 완전히 격리해야 할 때
3. Specification 패턴
개념
비즈니스 규칙을 재사용 가능한 Specification 객체로 표현하고, AND/OR/NOT으로 자유롭게 조합합니다.
코드 예시
// Specification 인터페이스
interface Specification<T> {
toWhereInput(): Prisma.FanficWhereInput;
and(spec: Specification<T>): Specification<T>;
or(spec: Specification<T>): Specification<T>;
not(): Specification<T>;
}
// 구체적인 Specification 구현
class TitleContainsSpec implements Specification<Fanfic> {
constructor(private query: string) {}
toWhereInput(): Prisma.FanficWhereInput {
return {
title: { search: `"${this.query}"` },
isRemoved: 'N',
};
}
and(spec: Specification<Fanfic>): Specification<Fanfic> {
return new AndSpecification(this, spec);
}
or(spec: Specification<Fanfic>): Specification<Fanfic> {
return new OrSpecification(this, spec);
}
not(): Specification<Fanfic> {
return new NotSpecification(this);
}
}
class WriterNicknameSpec implements Specification<Fanfic> {
constructor(private nickname: string) {}
toWhereInput(): Prisma.FanficWhereInput {
return {
writer: {
NICK: { search: `"${this.nickname}"` },
IS_DEACTIVATED: 'N',
},
};
}
and(spec: Specification<Fanfic>): Specification<Fanfic> {
return new AndSpecification(this, spec);
}
or(spec: Specification<Fanfic>): Specification<Fanfic> {
return new OrSpecification(this, spec);
}
not(): Specification<Fanfic> {
return new NotSpecification(this);
}
}
// 조합 Specification
class AndSpecification<T> implements Specification<T> {
constructor(
private left: Specification<T>,
private right: Specification<T>,
) {}
toWhereInput(): Prisma.FanficWhereInput {
return {
AND: [this.left.toWhereInput(), this.right.toWhereInput()],
};
}
// and, or, not 메서드 구현...
}
// 사용 예시
const spec = new TitleContainsSpec('로맨스')
.or(new WriterNicknameSpec('작가명'))
.and(new NotRemovedSpec());
const whereInput = spec.toWhereInput();장점
- 극대화된 재사용성: 작은 Specification을 조합하여 복잡한 조건 생성
- 동적 조건 구성: 런타임에 조건을 자유롭게 조합 가능
- 비즈니스 규칙 명시: 도메인 로직이 코드로 명확히 표현됨
- 테스트 용이성: 각 Specification을 독립적으로 테스트 가능
단점
- 높은 초기 복잡도: 인터페이스와 구현 클래스 작성 필요
- 러닝 커브: DDD와 Specification 패턴 이해 필요
- 과도한 추상화: 간단한 쿼리에는 오버엔지니어링
- 타입 추론 어려움: Prisma의 타입 시스템과 충돌 가능성
적합한 경우
- 매우 복잡하고 동적인 쿼리 조건이 필요할 때
- 비즈니스 규칙이 자주 변경되는 프로젝트
- DDD(Domain-Driven Design)를 적용하는 프로젝트
- 엔터프라이즈급 대규모 애플리케이션
4. Direct Service Layer 접근
개념
추상화 레이어 없이 서비스에서 직접 Prisma Client를 사용합니다.
코드 예시
@Injectable()
export class FanficSearchService {
constructor(private prisma: PrismaService) {}
async search(query: string, lang?: USER_LANG) {
const conditions: Prisma.FanficWhereInput[] = [];
// 제목 검색
conditions.push({
title: { search: `"${query}"` },
isRemoved: 'N',
});
// 번역 제목 검색
if (lang) {
conditions.push({
translations: {
some: {
title: { search: `"${query}"` },
lang,
deletedAt: null,
},
},
});
}
// 작가 닉네임 검색
conditions.push({
writer: {
NICK: { search: `"${query}"` },
IS_DEACTIVATED: 'N',
},
});
return this.prisma.fanfic.findMany({
where: { OR: conditions },
});
}
}장점
- 단순성: 추가 추상화 없이 직관적
- 완전한 타입 안전성: Prisma의 타입 추론을 100% 활용 Type safety | Prisma Documentation (opens in a new tab)
- 성능: 추가 레이어가 없어 가장 효율적
- 빠른 개발: 즉시 코드 작성 가능
Prisma 공식 문서에서 "Prisma Client is auto-generated and entirely type-safe" 라고 명시되어 있습니다.
단점
- 코드 중복: 유사한 쿼리가 여러 곳에 반복될 수 있음
- 재사용성 부족: 조건 재사용이 어려움
- 혼재된 관심사: 쿼리 로직이 비즈니스 로직과 섞임
- 테스트 어려움: 실제 Prisma 인스턴스 필요
적합한 경우
- 간단한 CRUD 애플리케이션
- 빠른 프로토타이핑이 필요할 때
- 작은 규모의 프로젝트
- 검색 조건이 복잡하지 않을 때
5. Function Composition 방식
개념
작은 순수 함수들을 조합하여 복잡한 쿼리 조건을 생성합니다.
코드 예시
// 기본 조건 생성 함수들
const titleContains = (query: string): Prisma.FanficWhereInput => ({
title: { search: `"${query}"` },
isRemoved: 'N',
});
const descriptionContains = (query: string): Prisma.FanficWhereInput => ({
description: { search: `"${query}"` },
isRemoved: 'N',
});
const writerNickname = (nickname: string): Prisma.FanficWhereInput => ({
writer: {
NICK: { search: `"${nickname}"` },
IS_DEACTIVATED: 'N',
},
});
const translationTitle = (
query: string,
lang: USER_LANG,
): Prisma.FanficWhereInput => ({
translations: {
some: {
title: { search: `"${query}"` },
lang,
deletedAt: null,
},
},
});
// 조합 유틸리티
const combineOr = (
...conditions: Prisma.FanficWhereInput[]
): Prisma.FanficWhereInput => ({
OR: conditions,
});
const combineAnd = (
...conditions: Prisma.FanficWhereInput[]
): Prisma.FanficWhereInput => ({
AND: conditions,
});
// 사용 예시
@Injectable()
export class FanficSearchService {
search(query: string, lang?: USER_LANG) {
const conditions = [
titleContains(query),
descriptionContains(query),
writerNickname(query),
];
if (lang) {
conditions.push(translationTitle(query, lang));
}
return this.prisma.fanfic.findMany({
where: combineOr(...conditions),
});
}
}장점
- 함수형 스타일: 순수 함수로 예측 가능하고 테스트 용이
- 조합 가능성: 작은 함수를 자유롭게 조합
- 타입 추론 우수: TypeScript와 Prisma의 타입 시스템이 잘 작동
- 동적 조건: 조건부 로직을 자연스럽게 추가 가능
- 간결함: 클래스 기반보다 보일러플레이트가 적음
단점
- 구조 부족: 명시적인 구조가 없어 함수가 많아지면 관리 어려움
- 네이밍 중요: 함수명이 명확하지 않으면 혼란
- 디버깅: 중첩된 함수 조합 시 디버깅이 어려울 수 있음
적합한 경우
- 함수형 프로그래밍을 선호하는 팀
- 중간 정도의 복잡도를 가진 프로젝트
- 유연성과 단순성의 균형이 필요할 때
패턴 선택 가이드
프로젝트 특성별 추천
| 특성 | 추천 패턴 | 이유 |
|---|---|---|
| 간단한 CRUD | Direct Service Layer | 불필요한 추상화 제거, 빠른 개발 |
| 중간 복잡도 | Function Composition 또는 Builder | 재사용성과 유연성의 균형 |
| 높은 복잡도 | Specification | 동적 조건 조합의 유연성 |
| 대규모 팀 | Repository 또는 Builder | 명확한 계층 분리와 협업 용이성 |
| DDD 적용 | Specification | 비즈니스 규칙의 명시적 표현 |
| 검색 엔진 | Builder (현재 코드) | PRESETS로 검색 시나리오 관리 |
의사결정 플로우차트
검색 조건이 명확히 정의되어 있나?
├─ Yes → 사전 정의된 조합이 자주 재사용되나?
│ ├─ Yes → Builder 패턴 ✅
│ └─ No → Function Composition
└─ No → 동적 조건이 매우 복잡한가?
├─ Yes → Specification 패턴
└─ No → Direct Service Layer
ORM 교체 가능성이 있나?
└─ Yes → Repository 패턴 추가 고려현재 Builder 패턴의 개선 방향
현재 코드는 이미 좋은 패턴이지만, 다음과 같이 개선할 수 있습니다:
1. Function Composition 요소 추가
// 중복되는 이스케이프 로직을 재사용 가능한 함수로
private createSearchCondition(
field: string,
query: string,
additionalConditions: Record<string, any> = {},
): Prisma.FanficWhereInput {
return {
[field]: { search: this.escapeForPhraseSearch(query) },
...additionalConditions,
};
}2. 동적 조건 추가 지원
buildWithCustomConditions(
normalizedQuery: string,
config: SearchConditionConfig,
customConditions?: Prisma.FanficWhereInput[],
): Prisma.FanficWhereInput[] {
const baseConditions = this.build(normalizedQuery, config);
return customConditions
? [...baseConditions, ...customConditions]
: baseConditions;
}3. Specification 패턴의 조합 로직 차용
static combineWithOr(
...configs: SearchConditionConfig[]
): SearchConditionConfig {
return {
targets: configs.flatMap((c) => c.targets),
lang: configs[0]?.lang,
};
}실무 권장사항
Prisma 공식 입장
Why Prisma ORM? (opens in a new tab)에서 다음과 같이 언급합니다:
"Prisma ORM's main goal is to make application developers more productive when working with databases. Abstraction should impose certain 'healthy' constraints that prevent developers from making mistakes."
즉, Prisma는 이미 적절한 추상화를 제공하므로 과도한 추가 추상화는 지양해야 합니다.
2025년 트렌드
Drizzle vs Prisma (opens in a new tab)에서 언급된 바와 같이:
"For its combination of type-safe API, intuitive data modeling, broad Postgres/MySQL support, and strong community momentum, Prisma is an excellent default choice for new Node.js projects."
Prisma의 타입 안전성을 최대한 활용하는 것이 중요하며, 이는 Direct Service Layer나 Function Composition 방식과 잘 맞습니다.
최종 추천
- 시작: Direct Service Layer로 빠르게 구현
- 패턴 발견: 중복되는 쿼리 조건이 보이면 Function Composition으로 추출
- 구조화: 검색 대상이 명확해지면 Builder 패턴으로 전환 (현재 코드)
- 고도화: 매우 복잡한 동적 조건이 필요하면 Specification 패턴 고려
참고 자료
- Repository pattern with prisma - GitHub Discussion (opens in a new tab)
- Is the Repository Pattern needed with Prisma - Stack Overflow (opens in a new tab)
- Why Prisma ORM? - Prisma Documentation (opens in a new tab)
- Type safety | Prisma Documentation (opens in a new tab)
- Drizzle vs Prisma: the Better TypeScript ORM in 2025 (opens in a new tab)
- Prisma vs TypeORM: The Better TypeScript ORM in 2025 (opens in a new tab)
- SQL vs ORMs vs Query Builders - Prisma's Data Guide (opens in a new tab)