BaseModelClass는 모든 Model이 상속받는 기본 클래스로, 데이터베이스 접근, 쿼리 실행, 트랜잭션 관리 등의 핵심 기능을 제공합니다.
데이터베이스 접근 메서드
getDB(preset)
Knex 데이터베이스 연결을 반환합니다.
getDB(preset: DBPreset): Knex
파라미터:
preset: "r" (읽기) 또는 "w" (쓰기)
사용 예시:
async findCustomQuery(): Promise<User[]> {
const rdb = this.getDB("r");
return rdb("users")
.select("*")
.where("is_active", true);
}
DBPreset 종류:
"r": Read - 읽기 전용 (SELECT)
"w": Write - 쓰기 가능 (INSERT, UPDATE, DELETE)
읽기/쓰기를 분리하면 데이터베이스 리플리케이션에서 읽기 부하를 분산할 수 있습니다.
getPuri(preset)
Puri 쿼리 빌더를 반환합니다. 트랜잭션이 활성화된 경우 트랜잭션 연결을 자동으로 사용합니다.
getPuri(preset: DBPreset): PuriWrapper
파라미터:
preset: "r" (읽기) 또는 "w" (쓰기)
사용 예시:
async findActive(): Promise<User[]> {
const rdb = this.getPuri("r");
return rdb
.table("users")
.where("is_active", true)
.orderBy("created_at", "desc")
.many();
}
getPuri()는 트랜잭션 컨텍스트를 자동으로 감지합니다. @transactional() 데코레이터 내에서 호출하면 트랜잭션 연결을 반환합니다.
Subset 쿼리 메서드
getSubsetQueries(subset)
특정 Subset에 대한 쿼리 빌더를 반환합니다.
getSubsetQueries<T extends TSubsetKey>(
subset: T
): {
qb: Puri;
onSubset: <S>(subset: S) => Puri;
}
반환값:
qb: 쿼리 빌더 (조건 추가용)
onSubset: Subset별 타입 캐스팅 함수
사용 예시:
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
// 조건 추가
if (params.keyword) {
qb.whereLike("users.email", `%${params.keyword}%`);
}
// 실행
return this.executeSubsetQuery({ subset, qb, params });
}
onSubset()은 타입 체크용입니다. 실제로는 같은 qb 객체를 반환하므로, 성능에 영향을 주지 않습니다.// 이 두 코드는 동일합니다
qb.where("users.id", 1);
onSubset("A").where("users.id", 1);
executeSubsetQuery(params)
Subset 쿼리를 실행하고 결과를 반환합니다. 페이지네이션, Loader, Hydration, Enhancer를 자동으로 처리합니다.
executeSubsetQuery<T extends TSubsetKey>(
params: {
subset: T;
qb: Puri;
params: {
num: number;
page: number;
queryMode?: "list" | "count" | "both";
};
enhancers?: EnhancerMap;
debug?: boolean;
optimizeCountQuery?: boolean;
}
): Promise<ListResult<TSubsetMapping[T]>>
파라미터:
| 파라미터 | 타입 | 설명 | 기본값 |
|---|
subset | string | Subset 키 | 필수 |
qb | Puri | 쿼리 빌더 | 필수 |
params.num | number | 페이지 크기 | 필수 |
params.page | number | 페이지 번호 (1부터 시작) | 필수 |
params.queryMode | string | 쿼리 모드 | "both" |
enhancers | object | Virtual 필드 계산 함수 | - |
debug | boolean | 쿼리 디버깅 출력 | false |
optimizeCountQuery | boolean | COUNT 쿼리 최적화 | false |
queryMode 옵션:
| 모드 | 반환값 | 사용 예시 |
|---|
"both" | { rows, total } | 일반 목록 조회 (기본값) |
"list" | { rows } | total 불필요한 경우 |
"count" | { total } | 개수만 필요한 경우 |
사용 예시:
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
if (params.keyword) {
qb.whereLike("users.email", `%${params.keyword}%`);
}
const enhancers = this.createEnhancers({
A: (row) => row,
SS: (row) => row,
});
return this.executeSubsetQuery({
subset,
qb,
params: {
num: params.num ?? 24,
page: params.page ?? 1,
},
enhancers,
});
}
실행 순서:
- COUNT 쿼리 실행 (total 계산)
- LIST 쿼리 실행 (페이지네이션 적용)
- Loader 실행 (HasMany, ManyToMany 데이터 로딩)
- Hydrate (flat 객체 → 중첩 객체 변환)
- Enhancer 적용 (virtual 필드 계산)
- Internal 필드 제거
createEnhancers(enhancers)
Enhancer 객체를 생성하는 헬퍼 함수입니다. 타입 검증과 추론을 제공합니다.
createEnhancers<T extends TSubsetKey>(
enhancers: EnhancerMap<T>
): EnhancerMap<T>
Enhancer란?
Enhancer는 쿼리 결과에 가상 필드를 추가하거나 데이터를 변환하는 함수입니다.
사용 예시:
const enhancers = this.createEnhancers({
A: (row) => ({
...row,
full_name: `${row.first_name} ${row.last_name}`,
age: calculateAge(row.birth_date),
}),
SS: (row) => row, // 변환 없음
});
await this.executeSubsetQuery({
subset: "A",
qb,
params,
enhancers,
});
Enhancer는 각 row마다 호출되므로, 무거운 작업은 피하는 것이 좋습니다. 필요하다면 Loader를 사용하거나 별도 API로 분리하세요.
유틸리티 메서드
getInsertedIds(wdb, rows, tableName, unqKeyFields, chunkSize)
삽입된 레코드의 ID를 조회합니다. Unique 키 기반으로 조회하므로 upsert 후에 유용합니다.
getInsertedIds(
wdb: Knex,
rows: Record<string, unknown>[],
tableName: string,
unqKeyFields: string[],
chunkSize?: number
): Promise<number[]>
파라미터:
| 파라미터 | 타입 | 설명 | 기본값 |
|---|
wdb | Knex | 데이터베이스 연결 | - |
rows | object[] | 삽입된 레코드 | - |
tableName | string | 테이블명 | - |
unqKeyFields | string[] | Unique 키 필드명 | - |
chunkSize | number | 한번에 조회할 개수 | 500 |
사용 예시:
async saveUsers(users: User[]): Promise<number[]> {
const wdb = this.getDB("w");
// 이메일로 upsert
await wdb("users")
.insert(users)
.onConflict("email")
.merge();
// 이메일로 ID 조회
const ids = await this.getInsertedIds(
wdb,
users,
"users",
["email"]
);
return ids;
}
hydrate(rows)
Flat 레코드를 중첩 객체로 변환합니다. JOIN 결과의 table__field 형식을 객체로 변환합니다.
hydrate<T>(rows: T[]): T[]
변환 규칙:
user__name → { user: { name } }
user__profile__bio → { user: { profile: { bio } } }
- nullable relation의 id가 null이면 객체 전체를 null로
사용 예시:
// Flat 데이터
const flatRows = [
{
id: 1,
name: "John",
user__id: 10,
user__email: "[email protected]",
user__profile__bio: "Hello",
}
];
// 중첩 객체로 변환
const nested = this.hydrate(flatRows);
// [
// {
// id: 1,
// name: "John",
// user: {
// id: 10,
// email: "[email protected]",
// profile: {
// bio: "Hello"
// }
// }
// }
// ]
hydrate()는 executeSubsetQuery() 내부에서 자동으로 호출되므로, 직접 호출할 일은 거의 없습니다.
omitInternalFields(row, fields)
Internal 필드를 객체에서 제거합니다. 중첩 필드와 배열도 처리합니다.
omitInternalFields<T extends object>(
row: T,
fields: string[]
): T
사용 예시:
const row = {
id: 1,
email: "[email protected]",
password: "hashed_password",
employee: {
id: 10,
salary: 50000,
department: { name: "IT" }
}
};
// Internal 필드 제거
const cleaned = this.omitInternalFields(row, [
"password",
"employee.salary"
]);
// {
// id: 1,
// email: "[email protected]",
// employee: {
// id: 10,
// department: { name: "IT" }
// }
// }
Subset의 internal 옵션으로 지정된 필드는 executeSubsetQuery()에서 자동으로 제거됩니다.{
"subsets": {
"A": [
"id",
"email",
{ "field": "password", "internal": true },
{ "field": "employee.salary", "internal": true }
]
}
}
destroy()
데이터베이스 연결을 종료합니다. 일반적으로 애플리케이션 종료 시 호출합니다.
async destroy(): Promise<void>
사용 예시:
// 애플리케이션 종료 시
process.on("SIGTERM", async () => {
await UserModel.destroy();
process.exit(0);
});
destroy()를 호출하면 모든 Model의 DB 연결이 종료됩니다. 테스트 환경에서만 사용하세요.
실전 사용 패턴
표준 findMany 패턴
async findMany<T extends UserSubsetKey, LP extends UserListParams>(
subset: T,
rawParams?: LP
): Promise<ListResult<LP, UserSubsetMapping[T]>> {
// 1. 기본값 설정
const params = {
num: 24,
page: 1,
search: "id" as const,
orderBy: "id-desc" as const,
...rawParams,
};
// 2. Subset 쿼리 획득
const { qb, onSubset } = this.getSubsetQueries(subset);
// 3. 필터링 조건 추가
if (params.id) {
qb.whereIn("users.id", asArray(params.id));
}
if (params.keyword && params.search) {
if (params.search === "email") {
qb.whereLike("users.email", `%${params.keyword}%`);
} else if (params.search === "username") {
qb.whereLike("users.username", `%${params.keyword}%`);
}
}
// 4. 정렬
if (params.orderBy === "id-desc") {
qb.orderBy("users.id", "desc");
} else if (params.orderBy === "created_at-desc") {
qb.orderBy("users.created_at", "desc");
}
// 5. Enhancer 정의
const enhancers = this.createEnhancers({
A: (row) => ({
...row,
full_name: `${row.first_name} ${row.last_name}`,
}),
SS: (row) => row,
});
// 6. 쿼리 실행
return this.executeSubsetQuery({
subset,
qb,
params,
enhancers,
});
}
복잡한 필터링 패턴
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
// 날짜 범위 필터
if (params.created_after || params.created_before) {
if (params.created_after) {
qb.where("users.created_at", ">=", params.created_after);
}
if (params.created_before) {
qb.where("users.created_at", "<=", params.created_before);
}
}
// 다중 값 필터
if (params.roles && params.roles.length > 0) {
qb.whereIn("users.role", params.roles);
}
// OR 조건
if (params.keyword) {
qb.where((builder) => {
builder
.whereLike("users.email", `%${params.keyword}%`)
.orWhereLike("users.username", `%${params.keyword}%`)
.orWhereLike("users.phone", `%${params.keyword}%`);
});
}
// 복잡한 조건
if (params.is_premium_or_admin) {
qb.where((builder) => {
builder
.where("users.role", "admin")
.orWhere("users.subscription_level", "premium");
});
}
return this.executeSubsetQuery({
subset,
qb,
params: { num: params.num ?? 24, page: params.page ?? 1 },
});
}
커스텀 집계 쿼리
async getStatistics(): Promise<UserStatistics> {
const rdb = this.getDB("r");
const [stats] = await rdb("users")
.select([
rdb.raw("COUNT(*) as total_users"),
rdb.raw("COUNT(CASE WHEN is_active THEN 1 END) as active_users"),
rdb.raw("COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_count"),
rdb.raw("AVG(age) as average_age"),
])
.first();
return {
total_users: Number(stats.total_users),
active_users: Number(stats.active_users),
admin_count: Number(stats.admin_count),
average_age: Number(stats.average_age),
};
}
다음 단계