Repository: sparcs-kaist/clubs
Pull request #1548: [TU-115] refactor base repository
Author: login: pbc1017
Created: 2025-03-22T12:08:39Z | Merged: 2025-03-27T15:10:32Z

# 📌 요약 \*
<!-- 닫는 이슈 번호 및 PR 내용에 대한 간단한 요약 표기. -->

It closes #issue_number

- 작업요약1
- 작업요약2

## ⭐문서 링크⭐
<!-- 코드 작성 시 참고한 api시트, figma 링크 첨부 -->
<!-- (백엔드) api 시트: 시트에 변경사항이 발생한 경우 , figma: 리뷰에 도움 될만한 화면(필수아님)-->
1. API sheet: 
2. Figma: 


## 디펜던시 있는 변경사항(혹은 리뷰어가 알아야 할 참고사항)
<!-- 이슈에 관련된 변경사항 외에 주의 깊게 봐야 할 변경사항(예: /common 쪽 코드 변경) -->
- 없음

## 이후 작업해야 할 todo list
<!-- pr이 머지된 후 후속 작업, 혹은 draft pr의 경우 남아있는 todo  -->
- 없음


<!-- 리뷰 요청 시 언제까지 리뷰 해줬으면 좋겠다를 적어주세요  -->
<!-- 급한 경우는 꼭 적어주세요! 안 급하면 생략해도 됨  -->
# 🔴 리뷰 Due Date: x월 x일 (x요일) 


# 📸 스크린샷 
<!-- PR 변경 사항에 대한 스크린샷이나 .gif 파일 -->
프론트의 경우 리뷰가 필요한 화면 URL 목록: 

1. 동아리 상세조회 페이지: http://localhost:3000/clubs/1



Diff:
diff --git a/packages/api/src/common/model/entity.model.ts b/packages/api/src/common/model/entity.model.ts
index 86dfac4dd..d2f4180b1 100644
--- a/packages/api/src/common/model/entity.model.ts
+++ b/packages/api/src/common/model/entity.model.ts
@@ -1,3 +1,32 @@
-export abstract class MEntity {
-  id: number;
+import { MySqlColumn } from "drizzle-orm/mysql-core";
+
+import {
+  Exclude,
+  OperationType,
+} from "@sparcs-clubs/interface/common/utils/field-operations";
+
+export type IdType = number | string;
+
+export abstract class MEntity<T extends IdType = number> {
+  @Exclude(OperationType.CREATE, OperationType.PUT)
+  id: T;
+
+  static modelName: string;
+
+  static from(_result: unknown): MEntity<IdType> {
+    throw new Error("Method not implemented. Must be overridden by subclass");
+  }
+
+  static fieldMap(_field: unknown): MySqlColumn {
+    throw new Error("Method not implemented. Must be overridden by subclass");
+  }
+
+  to(_operation: OperationType): unknown {
+    throw new Error("Method not implemented. Must be overridden by subclass");
+  }
+
+  set(param: Partial<this>): this {
+    Object.assign(this, param);
+    return this;
+  }
 }
diff --git a/packages/api/src/common/repository/base.repository.ts b/packages/api/src/common/repository/base.repository.ts
index 2cf6e6eb9..5de77a811 100644
--- a/packages/api/src/common/repository/base.repository.ts
+++ b/packages/api/src/common/repository/base.repository.ts
@@ -1,133 +1,287 @@
-import { Inject, Injectable, NotFoundException } from "@nestjs/common";
-import { ColumnBaseConfig, ColumnDataType, eq, inArray } from "drizzle-orm";
+import { Inject, Injectable } from "@nestjs/common";
+import {
+  and,
+  asc,
+  between,
+  ColumnBaseConfig,
+  ColumnDataType,
+  count,
+  desc,
+  eq,
+  gt,
+  gte,
+  inArray,
+  isNotNull,
+  isNull,
+  like,
+  lt,
+  lte,
+  ne,
+  not,
+  or,
+  SQL,
+} from "drizzle-orm";
 import { MySqlColumn, MySqlTable } from "drizzle-orm/mysql-core";
 import { MySql2Database } from "drizzle-orm/mysql2";
 
+import { OperationType } from "@sparcs-clubs/interface/common/utils/field-operations";
+
 import {
   DrizzleAsyncProvider,
   DrizzleTransaction,
 } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 
-import { MEntity } from "../model/entity.model";
-import { getKSTDate } from "../util/util";
+import { OrderByTypeEnum } from "../enums";
+import { IdType, MEntity } from "../model/entity.model";
+import { getKSTDate, takeAll, takeOnlyOne } from "../util/util";
+
+// 쿼리 조건 래핑을 위해 필요한 타입 선언
+const mysqlQueryConditionOperators = [
+  "eq",
+  "ne",
+  "gt",
+  "gte",
+  "lt",
+  "lte",
+  "like",
+  "startsWith",
+  "endsWith",
+  "in",
+  "isNotNull",
+] as const;
 
-interface TableWithId {
+// 타입으로 변환
+type MysqlQueryConditionOperators =
+  (typeof mysqlQueryConditionOperators)[number];
+
+const nestedQueryWrapper = ["and", "or", "not"] as const;
+type NestedQueryWrapper = (typeof nestedQueryWrapper)[number];
+
+// Clubs에서 사용하는 테이블에 id와 deletedAt 필드가 항상 있음을 보장하기 위한 interface
+interface TableWithIdAndDeletedAt {
   id: MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>;
+  deletedAt: MySqlColumn<ColumnBaseConfig<ColumnDataType, string | null>>;
+}
+
+// MEntity 의 static 메서드를 사용하기 위한 interface
+// 베이스 레포지토리 추상클래스 한정으로, MEntity를 직접 주입을 안해서 이렇게 한번 매핑해줘야 static 메서드들을 사용할 수 있음
+// 실제 repository 에서는 이 인터페이스를 사용 및 구현할 필요 없음
+interface ModelWithMethods<
+  Model extends MEntity<Id>,
+  FromDb,
+  Query,
+  Id extends IdType = number,
+> {
+  modelName: string;
+  from(result: FromDb): Model;
+  fieldMap(field: keyof Query): MySqlColumn;
 }
 
-interface ModelWithFrom<T extends MEntity, D> {
-  from(result: D): T;
+// 기본적인 쿼리 파라미터들
+// id, ids, pagination, orderBy 등을 기본으로 지원하기 위함
+export interface BaseQueryFields<Id extends IdType = number> {
+  id?: Id;
+  ids?: Id[];
+  pagination?: {
+    offset: number;
+    itemCount: number;
+  };
+  orderBy?: Record<string, OrderByTypeEnum>;
 }
 
+// Query 에 쿼리 가능한 타입들을 명시하여 사용
+export type BaseRepositoryQuery<
+  Query,
+  Id extends IdType = number,
+> = BaseQueryFields<Id> & Partial<Query>;
+
+// 베이스 레포지토리 추상클래스
+// 사용 방법
+// 1. Model에 모델 클래스 넣기
+// 2. Table에 FromDB (InferSelectTable) 타입 넣기
+// 3. 쿼리 조건 추가 (id 등 제외)
+// 4. 추가 쿼리 조건이 있을 경우 specialKeys에 추가하여 makeWhereClause를 상속하여 구현
 @Injectable()
 export abstract class BaseRepository<
-  M extends MEntity,
-  R,
-  D,
-  T extends MySqlTable & TableWithId,
+  Model extends MEntity<Id>,
+  DbResult,
+  Table extends MySqlTable & TableWithIdAndDeletedAt, // &TableWith~ 를 통해 테이블에 ID와 deletedAt 필드가 항상 있음을 보장
+  Query,
+  Id extends IdType = number,
 > {
-  @Inject(DrizzleAsyncProvider) private db: MySql2Database;
+  @Inject(DrizzleAsyncProvider) protected db: MySql2Database;
 
   constructor(
-    protected table: T,
-    protected modelClass: ModelWithFrom<M, D>,
+    protected table: Table,
+    protected modelClass: ModelWithMethods<Model, DbResult, Query, Id>, // 모델엔티티 넣으면 됨
   ) {}
 
-  async withTransaction<Result>(
-    callback: (tx: DrizzleTransaction) => Promise<Result>,
-  ): Promise<Result> {
-    return this.db.transaction(callback);
+  /* where 절을 생성하는 메서드
+   * find와 count에서 사용
+   * 상속할 떄 super.makeWhereClause(param, specialKeys) 로 기본적인 쿼리 생성 (id 등)
+   * 추가적인 조건을 추가할 거면 specialKeys에 추가 (ex) duration ... )
+   */
+  protected makeWhereClause(
+    param: BaseRepositoryQuery<Query, Id>,
+    specialKeys?: string[], // 제외할 키들 ex) duration ...
+  ): SQL[] {
+    const whereClause: SQL[] = [];
+
+    whereClause.push(isNull(this.table.deletedAt));
+
+    // 기본 필터링: id와 ids
+    if (param.id) {
+      whereClause.push(eq(this.table.id, param.id));
+    }
+    if (param.ids) {
+      whereClause.push(inArray(this.table.id, param.ids));
+    }
+
+    // Query 타입의 모든 키를 순회하면서 파라미터에 해당 값이 있는지 확인
+    const defaultKeys = ["id", "ids", "pagination", "order"];
+    if (specialKeys) {
+      defaultKeys.push(...specialKeys);
+    }
+
+    Object.keys(param)
+      .filter(key => !defaultKeys.includes(key)) // 기본 키는 제외
+      .forEach(key => {
+        if (key in nestedQueryWrapper) {
+          whereClause.push(
+            this.processNestedQuery(param[key], key as NestedQueryWrapper),
+          );
+        }
+        // 파라미터 값이 존재하는 경우
+        const value = param[key];
+        if (value !== undefined) {
+          // Query 필드를 테이블 필드로 변환
+          const tableField = this.getTableField(key as keyof Query);
+          if (!tableField) {
+            throw new Error(`Invalid query field: ${key}`);
+          }
+
+          // 배열인 경우 IN 연산자 사용
+          if (Array.isArray(value)) {
+            whereClause.push(inArray(tableField, value));
+          }
+          // 복합 조건 객체인 경우 복합 조건 처리 (gt, lt, gte, lte 등)
+          // 예시: { between: [10, 20] }, { gt: 10 }
+          else if (
+            typeof value === "object" &&
+            Object.keys(value).every(k =>
+              mysqlQueryConditionOperators.includes(
+                k as MysqlQueryConditionOperators,
+              ),
+            )
+          ) {
+            whereClause.push(
+              this.processAdvancedOperators(key as keyof Query, value),
+            );
+          }
+          // null 인 경우 isNull 연산자 사용
+          else if (value === null) {
+            whereClause.push(isNull(tableField));
+          }
+          // 단일 값인 경우 eq 연산자 사용
+          else {
+            whereClause.push(eq(tableField, value));
+          }
+        }
+      });
+
+    return whereClause;
   }
 
-  async findTx(tx: DrizzleTransaction, id: number): Promise<M | null> {
-    const result = await tx
+  // find, count는 왠만하면 makeWhereClause를 구현하면 처리 가능
+  async findTx(
+    tx: DrizzleTransaction,
+    param: BaseRepositoryQuery<Query, Id>,
+  ): Promise<Model[]> {
+    let query = tx
       .select()
       .from(this.table)
-      .where(eq(this.table.id, id))
-      .then(rows => rows.map(row => this.modelClass.from(row as D)));
+      .where(and(...this.makeWhereClause(param)))
+      .$dynamic();
 
-    return (result[0] as M) ?? null;
-  }
-
-  async find(id: number): Promise<M | null> {
-    return this.withTransaction(async tx => this.findTx(tx, id));
-  }
+    if (param.pagination) {
+      query = query.limit(param.pagination.itemCount);
+      query = query.offset(
+        (param.pagination.offset - 1) * param.pagination.itemCount,
+      );
+    }
 
-  async fetchTx(tx: DrizzleTransaction, id: number): Promise<M> {
-    const result = await this.findTx(tx, id);
-    if (result === null) {
-      throw new NotFoundException(`Not found: ${this.table} with id: ${id}`);
+    if (param.orderBy) {
+      query = query.orderBy(...this.makeOrderBy(param.orderBy));
     }
-    return result;
-  }
 
-  async fetch(id: number): Promise<M> {
-    return this.withTransaction(async tx => this.fetchTx(tx, id));
-  }
+    const result = await query.execute();
 
-  async fetchAllTx(tx: DrizzleTransaction, ids: number[]): Promise<M[]> {
-    if (ids.length === 0) {
-      return [];
-    }
+    return result.map(row => this.modelClass.from(row as DbResult));
+  }
 
-    const result = await tx
-      .select()
+  async countTx(
+    tx: DrizzleTransaction,
+    param: BaseRepositoryQuery<Query, Id>,
+  ): Promise<number> {
+    const [result] = await tx
+      .select({ count: count() })
       .from(this.table)
-      .where(inArray(this.table.id, ids));
+      .where(and(...this.makeWhereClause(param)));
 
-    return result.map(row => this.modelClass.from(row as D));
+    return result.count;
   }
 
-  async fetchAll(ids: number[]): Promise<M[]> {
-    return this.withTransaction(async tx => this.fetchAllTx(tx, ids));
-  }
+  async createTx(
+    tx: DrizzleTransaction,
+    param: Partial<Model>,
+  ): Promise<Model> {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const modelInstance = new (this.modelClass as any)(param);
 
-  async insertTx(tx: DrizzleTransaction, param: R): Promise<M> {
-    const [result] = await tx.insert(this.table).values(param).execute();
+    const [result] = await tx
+      .insert(this.table)
+      .values(modelInstance.to(OperationType.CREATE) as Table["$inferInsert"])
+      .execute();
 
-    const newId = Number(result.insertId);
+    // insertId를 적절한 타입으로 변환
+    const newId = result.insertId as Id;
 
     return this.fetchTx(tx, newId);
   }
 
-  async insert(param: R): Promise<M> {
-    return this.withTransaction(async tx => this.insertTx(tx, param));
-  }
+  async putTx(
+    tx: DrizzleTransaction,
+    id: Id,
+    param: Partial<Model>,
+  ): Promise<Model> {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const modelInstance = new (this.modelClass as any)(param);
 
-  async putTx(tx: DrizzleTransaction, id: number, param: R): Promise<M> {
     await tx
       .update(this.table)
-      .set(param)
+      .set(modelInstance.to(OperationType.PUT) as Table["$inferInsert"])
       .where(eq(this.table.id, id))
       .execute();
-    return this.fetchTx(tx, id);
-  }
 
-  async put(id: number, param: R): Promise<M> {
-    return this.withTransaction(async tx => this.putTx(tx, id, param));
+    return this.fetchTx(tx, id);
   }
 
   async patchTx(
     tx: DrizzleTransaction,
-    oldbie: M,
-    consumer: (oldbie: M) => M, // eslint-disable-line no-shadow
-  ): Promise<M> {
-    const param = consumer(oldbie);
+    oldId: Id,
+    consumer: (oldbie: Model) => Model,
+  ): Promise<Model> {
+    const param = consumer(await this.fetchTx(tx, oldId));
     await tx
       .update(this.table)
       .set(param)
-      .where(eq(this.table.id, oldbie.id))
+      .where(eq(this.table.id, oldId))
       .execute();
 
-    return this.fetchTx(tx, oldbie.id);
+    return this.fetchTx(tx, oldId);
   }
 
-  // eslint-disable-next-line no-shadow
-  async patch(oldbie: M, consumer: (oldbie: M) => M): Promise<M> {
-    return this.withTransaction(async tx => this.patchTx(tx, oldbie, consumer));
-  }
-
-  async deleteTx(tx: DrizzleTransaction, id: number): Promise<void> {
+  async deleteTx(tx: DrizzleTransaction, id: Id): Promise<void> {
     await tx
       .update(this.table)
       .set({ deletedAt: getKSTDate() })
@@ -135,7 +289,203 @@ export abstract class BaseRepository<
       .execute();
   }
 
-  async delete(id: number): Promise<void> {
+  // 상속 후에도 사용하는 헬퍼 메서드
+  async withTransaction<Result>(
+    callback: (tx: DrizzleTransaction) => Promise<Result>,
+  ): Promise<Result> {
+    return this.db.transaction(callback);
+  }
+
+  ///////////////////////////////////////////////////////////////////////////////
+  // 이 아래는 바꿀 필요 없음
+  // 뭐 이럴꺼면 중첩 쿼리도 처리할 수 있게 만들어 주죠?
+  // ㅋㅋ 왠만하면 쓰지 마세요 (gb)
+  protected processNestedQuery(
+    conditions: Record<string, object>,
+    wrapper: NestedQueryWrapper,
+  ): SQL {
+    const whereClause = Object.entries(conditions).map(([key, value]): SQL => {
+      if (key in nestedQueryWrapper) {
+        return this.processNestedQuery(
+          value as Record<string, object>,
+          key as NestedQueryWrapper,
+        );
+      }
+      return this.processAdvancedOperators(
+        key as keyof Query,
+        value as Record<MysqlQueryConditionOperators, unknown>,
+      );
+    });
+    if (wrapper === "and") {
+      return and(...whereClause);
+    }
+    if (wrapper === "or") {
+      return or(...whereClause);
+    }
+    if (wrapper === "not") {
+      if (whereClause.length !== 1) {
+        throw new Error(
+          "Not operator can only be used with a single condition",
+        );
+      }
+      return not(whereClause[0]);
+    }
+    throw new Error(`Invalid wrapper: ${wrapper}`);
+  }
+
+  // 고급 연산자 처리 (gt, lt, gte, lte, like 등)
+  protected processAdvancedOperators(
+    queryField: keyof Query,
+    conditions: Record<MysqlQueryConditionOperators, unknown>,
+  ): SQL {
+    // Query 필드를 테이블 필드로 변환
+    const tableField = this.getTableField(queryField);
+    if (!tableField) {
+      throw new Error(`Invalid query field: ${String(queryField)}`);
+    }
+    if (Object.keys(conditions).length === 0) {
+      throw new Error(`Empty conditions: ${String(conditions)}`);
+    }
+
+    const whereClause: SQL[] = [];
+    // 객체의 키-값 쌍을 forEach로 순회
+    Object.entries(conditions).forEach(([operator, operand]) => {
+      if (operand === undefined || operand === null) {
+        throw new Error(`Invalid operand: ${operand}`);
+      }
+      switch (operator) {
+        case "eq":
+          whereClause.push(eq(tableField, operand));
+          break;
+        case "ne":
+          whereClause.push(ne(tableField, operand));
+          break;
+        case "gt":
+          whereClause.push(gt(tableField, operand));
+          break;
+        case "gte":
+          whereClause.push(gte(tableField, operand));
+          break;
+        case "lt":
+          whereClause.push(lt(tableField, operand));
+          break;
+        case "lte":
+          whereClause.push(lte(tableField, operand));
+          break;
+        case "isNotNull":
+          whereClause.push(isNotNull(tableField));
+          break;
+        case "between":
+          if (!Array.isArray(operand) || operand.length !== 2) {
+            throw new Error(
+              `Invalid operator and operand for between: ${tableField} ${operand}`,
+            );
+          }
+          whereClause.push(between(tableField, operand[0], operand[1]));
+          break;
+        case "like":
+          whereClause.push(like(tableField, `%${String(operand)}%`));
+          break;
+        case "startsWith":
+          whereClause.push(like(tableField, `${String(operand)}%`));
+          break;
+        case "endsWith":
+          whereClause.push(like(tableField, `%${String(operand)}`));
+          break;
+        case "in":
+          if (Array.isArray(operand)) {
+            whereClause.push(inArray(tableField, operand));
+          }
+          break;
+        default:
+          throw new Error(`Invalid operator: ${operator}`);
+      }
+    });
+    return whereClause.length > 1 ? and(...whereClause) : whereClause[0];
+  }
+
+  // 여러 필드에 대한 정렬을 지원하는 구현
+  // find에서 사용
+  protected makeOrderBy(order: Record<string, OrderByTypeEnum>): SQL[] {
+    const orderClauses: SQL[] = [];
+
+    // 객체의 키-값 쌍을 순회하며 정렬 조건 생성
+    Object.entries(order).forEach(([field, direction]) => {
+      // Query 필드를 테이블 필드로 변환
+      const tableField = this.getTableField(field as keyof Query);
+      // 테이블에 해당 필드가 존재하는지 확인
+      if (tableField) {
+        // direction에 따라 asc() 또는 desc() 호출
+        if (direction === OrderByTypeEnum.ASC) {
+          orderClauses.push(asc(tableField));
+        } else {
+          orderClauses.push(desc(tableField));
+        }
+      } else {
+        throw new Error(`Invalid order field: ${field}`);
+      }
+    });
+
+    return orderClauses;
+  }
+
+  // private 헬퍼 메서드들
+
+  // Query 필드를 테이블 필드로 변환하는 헬퍼 메서드
+  // abstract class의 한계로 인해 필요
+  // 실제 구현 시에는 MEntity.fieldMap(queryField)를 사용하기에, 이 메서드는 사용하지 않음
+  private getTableField(queryField: keyof Query): MySqlColumn {
+    return this.modelClass.fieldMap(queryField);
+  }
+
+  // 재구현 필요 X인 부분들
+  // TX 메서드만 구현하면 자동 반영
+
+  async find(param: BaseRepositoryQuery<Query, Id>): Promise<Model[]> {
+    return this.withTransaction(async tx => this.findTx(tx, param));
+  }
+
+  async count(param: BaseRepositoryQuery<Query, Id>): Promise<number> {
+    return this.withTransaction(async tx => this.countTx(tx, param));
+  }
+
+  async create(param: Partial<Model>): Promise<Model> {
+    return this.withTransaction(async tx => this.createTx(tx, param));
+  }
+
+  async put(id: Id, param: Partial<Model>): Promise<Model> {
+    return this.withTransaction(async tx => this.putTx(tx, id, param));
+  }
+
+  async patch(oldId: Id, consumer: (oldbie: Model) => Model): Promise<Model> {
+    return this.withTransaction(async tx => this.patchTx(tx, oldId, consumer));
+  }
+
+  async delete(id: Id): Promise<void> {
     return this.withTransaction(async tx => this.deleteTx(tx, id));
   }
+
+  async fetchTx(tx: DrizzleTransaction, id: Id): Promise<Model> {
+    const param = { id } as BaseRepositoryQuery<Query, Id>;
+    return this.findTx(tx, param).then(
+      takeOnlyOne<Model>(this.modelClass.modelName),
+    );
+  }
+
+  // id(ids) 로 쿼리하는 함수들
+  // 재구현 필요 X
+  async fetch(id: Id): Promise<Model> {
+    return this.withTransaction(async tx => this.fetchTx(tx, id));
+  }
+
+  async fetchAllTx(tx: DrizzleTransaction, ids: Id[]): Promise<Model[]> {
+    const param = { ids } as BaseRepositoryQuery<Query, Id>;
+    return this.findTx(tx, param).then(
+      takeAll<Model, Id>(ids, this.modelClass.modelName),
+    );
+  }
+
+  async fetchAll(ids: Id[]): Promise<Model[]> {
+    return this.withTransaction(async tx => this.fetchAllTx(tx, ids));
+  }
 }
diff --git a/packages/api/src/common/util/util.ts b/packages/api/src/common/util/util.ts
index 1e74c0c0b..ab105acbf 100644
--- a/packages/api/src/common/util/util.ts
+++ b/packages/api/src/common/util/util.ts
@@ -1,5 +1,7 @@
 import { BadRequestException, NotFoundException } from "@nestjs/common";
 
+import { IdType } from "../model/entity.model";
+
 export const isEmptyObject = obj =>
   obj && Object.keys(obj).length === 0 && obj.constructor === Object;
 
@@ -37,7 +39,6 @@ export const takeOne = <T>(values: T[]): T | null => {
   return values[0];
 };
 
-type IdType = number | string;
 export function takeOnlyOne<T>(name?: string): (array: T[]) => T {
   return (array: T[]) => {
     // 배열의 요소가 하나만 나왔는 지를 검증하는 함수
diff --git a/packages/api/src/feature/division/model/district.model.ts b/packages/api/src/feature/division/model/district.model.ts
index d75274d7f..c90ec3d68 100644
--- a/packages/api/src/feature/division/model/district.model.ts
+++ b/packages/api/src/feature/division/model/district.model.ts
@@ -8,7 +8,7 @@ import { District } from "@sparcs-clubs/api/drizzle/schema/division.schema";
 export type DistrictDBResult = InferSelectModel<typeof District>;
 
 export class MDistrict extends MEntity implements IDistrict {
-  // id: IDistrict["id"];
+  static modelName = "district";
 
   name: IDistrict["name"];
 
@@ -17,7 +17,7 @@ export class MDistrict extends MEntity implements IDistrict {
     Object.assign(this, data);
   }
 
-  static fromDB(result: DistrictDBResult): MDistrict {
+  static from(result: DistrictDBResult): MDistrict {
     return new MDistrict({
       ...result,
     });
diff --git a/packages/api/src/feature/division/model/division.model.ts b/packages/api/src/feature/division/model/division.model.ts
index f7334c6ca..26a5057c1 100644
--- a/packages/api/src/feature/division/model/division.model.ts
+++ b/packages/api/src/feature/division/model/division.model.ts
@@ -8,7 +8,7 @@ import { Division } from "@sparcs-clubs/api/drizzle/schema/division.schema";
 export type DivisionDBResult = InferSelectModel<typeof Division>;
 
 export class MDivision extends MEntity implements IDivision {
-  // id: IDivision["id"];
+  static modelName = "division";
 
   name: IDivision["name"];
 
@@ -23,7 +23,7 @@ export class MDivision extends MEntity implements IDivision {
     Object.assign(this, data);
   }
 
-  static fromDB(result: DivisionDBResult): MDivision {
+  static from(result: DivisionDBResult): MDivision {
     return new MDivision({
       ...result,
       district: {
diff --git a/packages/api/src/feature/division/model/division.summary.model.ts b/packages/api/src/feature/division/model/division.summary.model.ts
index 64f274c19..aa6546ddb 100644
--- a/packages/api/src/feature/division/model/division.summary.model.ts
+++ b/packages/api/src/feature/division/model/division.summary.model.ts
@@ -10,7 +10,7 @@ import { MDivision } from "./division.model";
 export type DivisionDBResult = InferSelectModel<typeof Division>;
 
 export class VDivisionSummary extends MEntity implements IDivisionSummary {
-  // id: IDivision["id"];
+  static modelName = "divisionSummary";
 
   name: IDivisionSummary["name"];
 
@@ -30,7 +30,7 @@ export class VDivisionSummary extends MEntity implements IDivisionSummary {
     }
   }
 
-  static fromDB(result: DivisionDBResult): VDivisionSummary {
+  static from(result: DivisionDBResult): VDivisionSummary {
     return new VDivisionSummary({
       ...result,
     });
diff --git a/packages/api/src/feature/division/repository/division.repository.ts b/packages/api/src/feature/division/repository/division.repository.ts
index 097bdc135..8dc806e1d 100644
--- a/packages/api/src/feature/division/repository/division.repository.ts
+++ b/packages/api/src/feature/division/repository/division.repository.ts
@@ -131,7 +131,7 @@ export default class DivisionRepository {
         throw new NotFoundException("Division not found");
       }
     }
-    return result.map(e => MDivision.fromDB(e));
+    return result.map(e => MDivision.from(e));
   }
 
   async fetchAll(date: Date): Promise<IDivision[]>;
diff --git a/packages/api/src/feature/funding/model/funding.comment.model.ts b/packages/api/src/feature/funding/model/funding.comment.model.ts
index 119bcb649..569a2cb9e 100644
--- a/packages/api/src/feature/funding/model/funding.comment.model.ts
+++ b/packages/api/src/feature/funding/model/funding.comment.model.ts
@@ -1,7 +1,18 @@
-import { InferSelectModel } from "drizzle-orm";
+import {
+  ColumnBaseConfig,
+  ColumnDataType,
+  InferInsertModel,
+  InferSelectModel,
+} from "drizzle-orm";
+import { MySqlColumn } from "drizzle-orm/mysql-core";
 
 import { IFundingComment } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 import { FundingStatusEnum } from "@sparcs-clubs/interface/common/enum/funding.enum";
+import {
+  Exclude,
+  filterExcludedFields,
+  OperationType,
+} from "@sparcs-clubs/interface/common/utils/field-operations";
 
 import { MEntity } from "@sparcs-clubs/api/common/model/entity.model";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
@@ -9,9 +20,19 @@ import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema
 import { MFunding } from "./funding.model";
 import { VFundingSummary } from "./funding.summary.model";
 
-export type FundingCommentDbResult = InferSelectModel<typeof FundingFeedback>;
+export type FromDb = InferSelectModel<typeof FundingFeedback>;
+export type ToDb = InferInsertModel<typeof FundingFeedback>;
+
+export type FundingCommentQuery = {
+  fundingId: number;
+};
+
+export class MFundingComment
+  extends MEntity<number>
+  implements IFundingComment
+{
+  static modelName = "fundingComment";
 
-export class MFundingComment extends MEntity implements IFundingComment {
   funding: { id: number };
 
   executive: {
@@ -24,6 +45,7 @@ export class MFundingComment extends MEntity implements IFundingComment {
 
   approvedAmount: number;
 
+  @Exclude(OperationType.CREATE)
   createdAt: Date;
 
   constructor(data: IFundingComment) {
@@ -39,7 +61,7 @@ export class MFundingComment extends MEntity implements IFundingComment {
     );
   }
 
-  static from(result: FundingCommentDbResult): MFundingComment {
+  static from(result: FromDb): MFundingComment {
     return new MFundingComment({
       id: result.id,
       funding: { id: result.fundingId },
@@ -52,4 +74,35 @@ export class MFundingComment extends MEntity implements IFundingComment {
       createdAt: result.createdAt,
     });
   }
+
+  to(operation: OperationType): ToDb {
+    const filtered = filterExcludedFields(this, operation);
+
+    return {
+      id: filtered.id ?? undefined,
+      fundingId: filtered.funding?.id,
+      executiveId: filtered.executive?.id,
+      feedback: filtered.content,
+      fundingStatusEnum: filtered.fundingStatusEnum,
+      approvedAmount: filtered.approvedAmount,
+      createdAt: filtered.createdAt,
+    };
+  }
+
+  static fieldMap(
+    field: keyof FundingCommentQuery,
+  ): MySqlColumn<ColumnBaseConfig<ColumnDataType, string>> {
+    const fieldMappings: Record<
+      keyof FundingCommentQuery,
+      MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>
+    > = {
+      fundingId: FundingFeedback.fundingId,
+    };
+
+    if (!(field in fieldMappings)) {
+      throw new Error(`Invalid field: ${String(field)}`);
+    }
+
+    return fieldMappings[field];
+  }
 }
diff --git a/packages/api/src/feature/funding/repository/funding.comment.repository.ts b/packages/api/src/feature/funding/repository/funding.comment.repository.ts
index ca6fcfd89..9df9541ea 100644
--- a/packages/api/src/feature/funding/repository/funding.comment.repository.ts
+++ b/packages/api/src/feature/funding/repository/funding.comment.repository.ts
@@ -1,72 +1,21 @@
 import { Injectable } from "@nestjs/common";
-import { desc, eq } from "drizzle-orm";
-
-import { IFundingCommentRequest } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 
 import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
-import { DrizzleTransaction } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
 import {
-  FundingCommentDbResult,
+  FromDb,
+  FundingCommentQuery,
   MFundingComment,
 } from "@sparcs-clubs/api/feature/funding/model/funding.comment.model";
 
 @Injectable()
 export default class FundingCommentRepository extends BaseRepository<
   MFundingComment,
-  IFundingCommentRequest,
-  FundingCommentDbResult,
-  typeof FundingFeedback
+  FromDb,
+  typeof FundingFeedback,
+  FundingCommentQuery
 > {
   constructor() {
     super(FundingFeedback, MFundingComment);
   }
-
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    ids: number[],
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    fundingId: number,
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    arg1: number | number[],
-  ): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAllTx(tx, arg1);
-    }
-
-    const result = await tx
-      .select()
-      .from(FundingFeedback)
-      .where(eq(FundingFeedback.fundingId, arg1))
-      .orderBy(desc(FundingFeedback.createdAt));
-
-    return result.map(row => MFundingComment.from(row));
-  }
-
-  async fetchAll(fundingId: number): Promise<MFundingComment[]>;
-  async fetchAll(ids: number[]): Promise<MFundingComment[]>;
-  async fetchAll(arg1: number | number[]): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAll(arg1);
-    }
-
-    return this.withTransaction(async tx => this.fetchAllTx(tx, arg1));
-  }
-
-  async insertTx(
-    tx: DrizzleTransaction,
-    param: IFundingCommentRequest,
-  ): Promise<MFundingComment> {
-    const comment = {
-      ...param,
-      fundingId: param.funding.id,
-      executiveId: param.executive.id,
-      feedback: param.content,
-    };
-    return super.insertTx(tx, comment);
-  }
 }
diff --git a/packages/api/src/feature/funding/service/funding.service.ts b/packages/api/src/feature/funding/service/funding.service.ts
index 5736d7781..4d7c22c6d 100644
--- a/packages/api/src/feature/funding/service/funding.service.ts
+++ b/packages/api/src/feature/funding/service/funding.service.ts
@@ -52,10 +52,7 @@ import {
   ApiFnd016RequestQuery,
   ApiFnd016ResponseOk,
 } from "@sparcs-clubs/interface/api/funding/endpoint/apiFnd016";
-import {
-  IFundingComment,
-  IFundingCommentRequest,
-} from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
+import { IFundingComment } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 import {
   IFunding,
   IFundingResponse,
@@ -229,7 +226,9 @@ export default class FundingService {
       );
     }
 
-    const comments = await this.fundingCommentRepository.fetchAll(funding.id);
+    const comments = await this.fundingCommentRepository.find({
+      fundingId: funding.id,
+    });
 
     const commentedExecutive =
       await this.userPublicService.fetchExecutiveSummaries(
@@ -260,7 +259,9 @@ export default class FundingService {
     const funding = await this.fundingRepository.fetch(id);
 
     const fundingResponse = await this.buildFundingResponse(funding);
-    const comments = await this.fundingCommentRepository.fetchAll(id);
+    const comments = await this.fundingCommentRepository.find({
+      fundingId: funding.id,
+    });
     const executives = await this.userPublicService.fetchExecutiveSummaries(
       comments.map(comment => comment.executive.id),
     );
@@ -725,9 +726,9 @@ export default class FundingService {
 
     const fundingsWithCommentedExecutive = await Promise.all(
       fundings.map(async funding => {
-        const comments = await this.fundingCommentRepository.fetchAll(
-          funding.id,
-        );
+        const comments = await this.fundingCommentRepository.find({
+          fundingId: funding.id,
+        });
         return {
           ...funding,
           commentedExecutive: comments[0]?.executive,
@@ -818,9 +819,9 @@ export default class FundingService {
 
     const fundingsWithCommentedExecutive = await Promise.all(
       fundings.map(async funding => {
-        const comments = await this.fundingCommentRepository.fetchAll(
-          funding.id,
-        );
+        const comments = await this.fundingCommentRepository.find({
+          fundingId: funding.id,
+        });
         return {
           ...funding,
           commentedExecutive: comments[0]?.executive,
@@ -904,7 +905,9 @@ export default class FundingService {
     const funding = await this.fundingRepository.fetch(id);
 
     const fundingResponse = await this.buildFundingResponse(funding);
-    const comments = await this.fundingCommentRepository.fetchAll(id);
+    const comments = await this.fundingCommentRepository.find({
+      fundingId: funding.id,
+    });
     const executives = await this.userPublicService.fetchExecutiveSummaries(
       comments.map(comment => comment.executive.id),
     );
@@ -978,13 +981,13 @@ export default class FundingService {
 
     const fundingComment = await this.fundingCommentRepository.withTransaction(
       async tx => {
-        const comment = await this.fundingCommentRepository.insertTx(tx, {
+        const comment = await this.fundingCommentRepository.createTx(tx, {
           fundingStatusEnum,
           approvedAmount,
           funding: { id },
           executive: { id: executiveId },
           content,
-        } as IFundingCommentRequest);
+        });
         const funding = await this.fundingRepository.patchStatusTx(tx, {
           id,
           fundingStatusEnum,
diff --git a/packages/api/src/feature/registration/member-registration/model/member.registration.model.ts b/packages/api/src/feature/registration/member-registration/model/member.registration.model.ts
index 9df78dab2..d42b96fb6 100644
--- a/packages/api/src/feature/registration/member-registration/model/member.registration.model.ts
+++ b/packages/api/src/feature/registration/member-registration/model/member.registration.model.ts
@@ -1,41 +1,58 @@
-import { asc, desc, InferSelectModel, SQL } from "drizzle-orm";
+import {
+  ColumnBaseConfig,
+  ColumnDataType,
+  InferInsertModel,
+  InferSelectModel,
+} from "drizzle-orm";
+import { MySqlColumn } from "drizzle-orm/mysql-core";
 
 import { IMemberRegistration } from "@sparcs-clubs/interface/api/registration/type/member.registration.type";
+import { RegistrationApplicationStudentStatusEnum } from "@sparcs-clubs/interface/common/enum/registration.enum";
+import {
+  Exclude,
+  filterExcludedFields,
+  OperationType,
+} from "@sparcs-clubs/interface/common/utils/field-operations";
 
-import { OrderByTypeEnum } from "@sparcs-clubs/api/common/enums";
 import { MEntity } from "@sparcs-clubs/api/common/model/entity.model";
 import { RegistrationApplicationStudent } from "@sparcs-clubs/api/drizzle/schema/registration.schema";
 
-type MemberRegistrationDbResult = InferSelectModel<
-  typeof RegistrationApplicationStudent
->;
+export type FromDb = InferSelectModel<typeof RegistrationApplicationStudent>;
+export type ToDb = InferInsertModel<typeof RegistrationApplicationStudent>;
 
-const orderByFieldMap = {
-  createdAt: RegistrationApplicationStudent.createdAt,
-  registrationApplicationStudentEnum:
-    RegistrationApplicationStudent.registrationApplicationStudentEnumId,
-  semesterId: RegistrationApplicationStudent.semesterId,
+export type MemberRegistrationQuery = {
+  studentId: number;
+  clubId: number;
+  semesterId: number;
+  registrationApplicationStudentEnumId: number;
+  createdAt: Date;
 };
 
-export type IMemberRegistrationOrderBy = Partial<{
-  [key in keyof typeof orderByFieldMap]: OrderByTypeEnum;
-}>;
-
 export class MMemberRegistration
   extends MEntity
   implements IMemberRegistration
 {
+  static modelName = "member_registration";
+
   student: IMemberRegistration["student"];
+
   club: IMemberRegistration["club"];
-  registrationApplicationStudentEnum: IMemberRegistration["registrationApplicationStudentEnum"];
+
+  @Exclude(OperationType.CREATE, OperationType.PUT)
+  registrationApplicationStudentEnum: IMemberRegistration["registrationApplicationStudentEnum"] =
+    RegistrationApplicationStudentStatusEnum.Pending;
+
   semester: IMemberRegistration["semester"];
+
+  @Exclude(OperationType.CREATE)
   createdAt: IMemberRegistration["createdAt"];
+
   constructor(data: IMemberRegistration) {
     super();
     Object.assign(this, data);
   }
 
-  static from(result: MemberRegistrationDbResult): MMemberRegistration {
+  static from(result: FromDb): MMemberRegistration {
     return new MMemberRegistration({
       id: result.id,
       student: { id: result.studentId },
@@ -47,16 +64,40 @@ export class MMemberRegistration
     });
   }
 
-  static makeOrderBy(orderBy: IMemberRegistrationOrderBy): SQL[] {
-    return Object.entries(orderBy)
-      .filter(
-        ([key, orderByType]) =>
-          orderByType && orderByFieldMap[key as keyof typeof orderByFieldMap],
-      )
-      .map(([key, orderByType]) =>
-        orderByType === OrderByTypeEnum.ASC
-          ? asc(orderByFieldMap[key])
-          : desc(orderByFieldMap[key]),
-      );
+  to(operation: OperationType): ToDb {
+    const filtered = filterExcludedFields(this, operation);
+
+    return {
+      id: filtered.id ?? undefined,
+      studentId: filtered.student?.id,
+      clubId: filtered.club?.id,
+      semesterId: filtered.semester?.id,
+      registrationApplicationStudentEnumId:
+        filtered.registrationApplicationStudentEnum ??
+        RegistrationApplicationStudentStatusEnum.Pending,
+      createdAt: filtered.createdAt ?? undefined,
+    } as ToDb;
+  }
+
+  static fieldMap(
+    field: keyof MemberRegistrationQuery,
+  ): MySqlColumn<ColumnBaseConfig<ColumnDataType, string>> {
+    const fieldMappings: Record<
+      keyof MemberRegistrationQuery,
+      MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>
+    > = {
+      studentId: RegistrationApplicationStudent.studentId,
+      clubId: RegistrationApplicationStudent.clubId,
+      semesterId: RegistrationApplicationStudent.semesterId,
+      registrationApplicationStudentEnumId:
+        RegistrationApplicationStudent.registrationApplicationStudentEnumId,
+      createdAt: RegistrationApplicationStudent.createdAt,
+    };
+
+    if (!(field in fieldMappings)) {
+      throw new Error(`Invalid field: ${String(field)}`);
+    }
+
+    return fieldMappings[field];
   }
 }
diff --git a/packages/api/src/feature/registration/member-registration/repository/member-registration.repository.ts b/packages/api/src/feature/registration/member-registration/repository/member-registration.repository.ts
index b7e615dd2..03d59e33a 100644
--- a/packages/api/src/feature/registration/member-registration/repository/member-registration.repository.ts
+++ b/packages/api/src/feature/registration/member-registration/repository/member-registration.repository.ts
@@ -1,210 +1,32 @@
-import { HttpException, HttpStatus, Inject, Injectable } from "@nestjs/common";
-import { and, count, eq, inArray, isNull, SQL } from "drizzle-orm";
-import { MySql2Database } from "drizzle-orm/mysql2";
+import { Injectable } from "@nestjs/common";
+import { and, eq, InferSelectModel, isNull } from "drizzle-orm";
 
-import {
-  IMemberRegistrationCreate,
-  IMemberRegistrationUpdate,
-} from "@sparcs-clubs/interface/api/registration/type/member.registration.type";
-import {
-  RegistrationApplicationStudentStatusEnum,
-  RegistrationDeadlineEnum,
-} from "@sparcs-clubs/interface/common/enum/registration.enum";
+import { RegistrationDeadlineEnum } from "@sparcs-clubs/interface/common/enum/registration.enum";
 
-import { getKSTDate } from "@sparcs-clubs/api/common/util/util";
-import {
-  DrizzleAsyncProvider,
-  DrizzleTransaction,
-} from "@sparcs-clubs/api/drizzle/drizzle.provider";
+import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import {
   RegistrationApplicationStudent,
   RegistrationDeadlineD,
 } from "@sparcs-clubs/api/drizzle/schema/registration.schema";
 import {
-  IMemberRegistrationOrderBy,
+  MemberRegistrationQuery,
   MMemberRegistration,
 } from "@sparcs-clubs/api/feature/registration/member-registration/model/member.registration.model";
 
-type IMemberRegistrationQuery = {
-  id?: number;
-  ids?: number[];
-  studentId?: number;
-  clubId?: number;
-  semesterId?: number;
-  pagination?: {
-    offset: number;
-    itemCount: number;
-  };
-  orderBy?: IMemberRegistrationOrderBy;
-  registrationApplicationStudentEnum?: number;
-  registrationApplicationStudentEnums?: number[];
-};
+type FromDb = InferSelectModel<typeof RegistrationApplicationStudent>;
 
 @Injectable()
-export class MemberRegistrationRepository {
-  @Inject(DrizzleAsyncProvider) private db: MySql2Database;
-  constructor() {}
-
-  async withTransaction<Result>(
-    callback: (tx: DrizzleTransaction) => Promise<Result>,
-  ): Promise<Result> {
-    return this.db.transaction(callback);
-  }
-
-  private makeWhereClause(param: IMemberRegistrationQuery): SQL[] {
-    const whereClause: SQL[] = [];
-    if (param.id) {
-      whereClause.push(eq(RegistrationApplicationStudent.id, param.id));
-    }
-    if (param.ids) {
-      whereClause.push(inArray(RegistrationApplicationStudent.id, param.ids));
-    }
-    if (param.studentId) {
-      whereClause.push(
-        eq(RegistrationApplicationStudent.studentId, param.studentId),
-      );
-    }
-    if (param.clubId) {
-      whereClause.push(eq(RegistrationApplicationStudent.clubId, param.clubId));
-    }
-    if (param.semesterId) {
-      whereClause.push(
-        eq(RegistrationApplicationStudent.semesterId, param.semesterId),
-      );
-    }
-    if (param.registrationApplicationStudentEnum) {
-      whereClause.push(
-        eq(
-          RegistrationApplicationStudent.registrationApplicationStudentEnumId,
-          param.registrationApplicationStudentEnum,
-        ),
-      );
-    }
-    if (param.registrationApplicationStudentEnums) {
-      whereClause.push(
-        inArray(
-          RegistrationApplicationStudent.registrationApplicationStudentEnumId,
-          param.registrationApplicationStudentEnums,
-        ),
-      );
-    }
-    whereClause.push(isNull(RegistrationApplicationStudent.deletedAt));
-
-    return whereClause;
-  }
-
-  async countTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationQuery,
-  ): Promise<number> {
-    const [result] = await tx
-      .select({ count: count() })
-      .from(RegistrationApplicationStudent)
-      .where(and(...this.makeWhereClause(param)));
-
-    return result.count;
-  }
-
-  async count(param: IMemberRegistrationQuery): Promise<number> {
-    return this.withTransaction(async tx => this.countTx(tx, param));
-  }
-
-  async findTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationQuery,
-  ): Promise<MMemberRegistration[]> {
-    let query = tx
-      .select()
-      .from(RegistrationApplicationStudent)
-      .where(and(...this.makeWhereClause(param)))
-      .$dynamic();
-
-    if (param.pagination) {
-      query = query.limit(param.pagination.itemCount);
-      query = query.offset(
-        (param.pagination.offset - 1) * param.pagination.itemCount,
-      );
-    }
-    if (param.orderBy) {
-      query = query.orderBy(...MMemberRegistration.makeOrderBy(param.orderBy));
-    }
-
-    const result = await query.execute();
-
-    return result.map(row => MMemberRegistration.from(row));
-  }
-
-  async find(param: IMemberRegistrationQuery): Promise<MMemberRegistration[]> {
-    return this.withTransaction(async tx => this.findTx(tx, param));
-  }
-
-  async insertTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationCreate,
-  ): Promise<void> {
-    const [result] = await tx.insert(RegistrationApplicationStudent).values({
-      ...param,
-      registrationApplicationStudentEnumId:
-        RegistrationApplicationStudentStatusEnum.Pending,
-    });
-    if (result.insertId === undefined) {
-      throw new HttpException("Failed to insert", HttpStatus.BAD_REQUEST);
-    }
-  }
-
-  async insert(param: IMemberRegistrationCreate): Promise<void> {
-    await this.withTransaction(async tx => this.insertTx(tx, param));
-  }
-
-  async updateTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationUpdate,
-  ): Promise<void> {
-    const [result] = await tx
-      .update(RegistrationApplicationStudent)
-      .set({
-        registrationApplicationStudentEnumId:
-          param.registrationApplicationStudentEnum,
-      })
-      .where(
-        and(
-          eq(RegistrationApplicationStudent.id, param.id),
-          isNull(RegistrationApplicationStudent.deletedAt),
-        ),
-      );
-    if (result.affectedRows === 0) {
-      throw new HttpException("Failed to update", HttpStatus.BAD_REQUEST);
-    }
-  }
-  async update(param: IMemberRegistrationUpdate): Promise<void> {
-    await this.withTransaction(async tx => this.updateTx(tx, param));
-  }
-
-  async deleteTx(
-    tx: DrizzleTransaction,
-    studentId: number,
-    id: number,
-  ): Promise<void> {
-    const cur = getKSTDate();
-    const [result] = await tx
-      .update(RegistrationApplicationStudent)
-      .set({ deletedAt: cur })
-      .where(
-        and(
-          eq(RegistrationApplicationStudent.id, id),
-          eq(RegistrationApplicationStudent.studentId, studentId),
-          isNull(RegistrationApplicationStudent.deletedAt),
-        ),
-      );
-    if (result.affectedRows === 0) {
-      throw new HttpException("Failed to delete", HttpStatus.BAD_REQUEST);
-    }
-  }
-
-  async delete(studentId: number, id: number): Promise<void> {
-    await this.withTransaction(async tx => this.deleteTx(tx, studentId, id));
+export class MemberRegistrationRepository extends BaseRepository<
+  MMemberRegistration,
+  FromDb,
+  typeof RegistrationApplicationStudent,
+  MemberRegistrationQuery
+> {
+  constructor() {
+    super(RegistrationApplicationStudent, MMemberRegistration);
   }
 
+  // 별도 레포지토리로 분리해야 함
   async selectMemberRegistrationDeadline(param: { semesterId: number }) {
     const result = await this.db
       .select()
diff --git a/packages/api/src/feature/registration/member-registration/service/member-registration.service.ts b/packages/api/src/feature/registration/member-registration/service/member-registration.service.ts
index d7491ba30..3dcbdd40f 100644
--- a/packages/api/src/feature/registration/member-registration/service/member-registration.service.ts
+++ b/packages/api/src/feature/registration/member-registration/service/member-registration.service.ts
@@ -106,10 +106,10 @@ export class MemberRegistrationService {
       throw new HttpException("Already applied", HttpStatus.BAD_REQUEST);
 
     // 동아리 가입 신청
-    await this.memberRegistrationRepository.insert({
-      studentId,
-      clubId,
-      semesterId,
+    await this.memberRegistrationRepository.create({
+      student: { id: studentId },
+      club: { id: clubId },
+      semester: { id: semesterId },
     });
     return {};
   }
@@ -187,7 +187,7 @@ export class MemberRegistrationService {
       );
     }
 
-    await this.memberRegistrationRepository.delete(studentId, applyId);
+    await this.memberRegistrationRepository.delete(applyId);
     return {};
   }
 
@@ -261,10 +261,11 @@ export class MemberRegistrationService {
           clubId,
         );
     }
-    await this.memberRegistrationRepository.update({
-      id: applyId,
-      registrationApplicationStudentEnum: applyStatusEnumId,
-    });
+    await this.memberRegistrationRepository.patch(applyId, newbie =>
+      newbie.set({
+        registrationApplicationStudentEnum: applyStatusEnumId,
+      }),
+    );
     return {};
   }
 
diff --git a/packages/interface/src/api/funding/endpoint/apiFnd013.ts b/packages/interface/src/api/funding/endpoint/apiFnd013.ts
index eb14c8ff2..0e479f01e 100644
--- a/packages/interface/src/api/funding/endpoint/apiFnd013.ts
+++ b/packages/interface/src/api/funding/endpoint/apiFnd013.ts
@@ -1,7 +1,7 @@
 import { HttpStatusCode } from "axios";
 import { z } from "zod";
 
-import { zFundingCommentRequest } from "../type/funding.comment.type";
+import { zFundingComment } from "../type/funding.comment.type";
 import { zFunding } from "../type/funding.type";
 
 /**
@@ -25,7 +25,10 @@ const requestParam = z.object({
 
 const requestQuery = z.object({});
 
-const requestBody = zFundingCommentRequest.omit({
+const requestBody = zFundingComment.omit({
+  id: true,
+  createdAt: true,
+  deletedAt: true,
   funding: true,
   executive: true,
 });
diff --git a/packages/interface/src/api/funding/type/funding.comment.type.ts b/packages/interface/src/api/funding/type/funding.comment.type.ts
index db6d0e2ff..7fd83535e 100644
--- a/packages/interface/src/api/funding/type/funding.comment.type.ts
+++ b/packages/interface/src/api/funding/type/funding.comment.type.ts
@@ -20,11 +20,5 @@ export const zFundingCommentResponse = zFundingComment.extend({
   executive: zExecutiveSummary,
 });
 
-export const zFundingCommentRequest = zFundingComment.omit({
-  id: true,
-  createdAt: true,
-});
-
 export type IFundingComment = z.infer<typeof zFundingComment>;
 export type IFundingCommentResponse = z.infer<typeof zFundingCommentResponse>;
-export type IFundingCommentRequest = z.infer<typeof zFundingCommentRequest>;
diff --git a/packages/interface/src/api/registration/type/member.registration.type.ts b/packages/interface/src/api/registration/type/member.registration.type.ts
index e0d120f51..a74f744b1 100644
--- a/packages/interface/src/api/registration/type/member.registration.type.ts
+++ b/packages/interface/src/api/registration/type/member.registration.type.ts
@@ -30,31 +30,7 @@ export const zMemberRegistrationResponse = zMemberRegistration.extend({
   semester: zSemester,
 });
 
-// repository에 전달하는 parameter의 type.
-export const zMemberRegistrationCreate = zMemberRegistration
-  .omit({
-    id: true,
-    registrationApplicationStudentEnum: true,
-    createdAt: true,
-  })
-  .transform(data => ({
-    studentId: data.student.id,
-    clubId: data.club.id,
-    semesterId: data.semester.id,
-  }));
-
-export const zMemberRegistrationUpdate = zMemberRegistration.pick({
-  id: true,
-  registrationApplicationStudentEnum: true,
-});
-
 export type IMemberRegistration = z.infer<typeof zMemberRegistration>;
 export type IMemberRegistrationResponse = z.infer<
   typeof zMemberRegistrationResponse
 >;
-export type IMemberRegistrationCreate = z.infer<
-  typeof zMemberRegistrationCreate
->;
-export type IMemberRegistrationUpdate = z.infer<
-  typeof zMemberRegistrationUpdate
->;
diff --git a/packages/interface/src/common/utils/field-operations.ts b/packages/interface/src/common/utils/field-operations.ts
new file mode 100644
index 000000000..7a7665e25
--- /dev/null
+++ b/packages/interface/src/common/utils/field-operations.ts
@@ -0,0 +1,221 @@
+export enum OperationType {
+  CREATE = "create",
+  PUT = "put",
+}
+
+export interface ClassConstructor<T = unknown> {
+  new (...args: unknown[]): T;
+  prototype: Record<string, unknown>;
+}
+
+// 클래스 생성자 -> 필드이름 -> 제외할 작업 타입 목록 맵
+const exclusionMetadata = new Map<
+  ClassConstructor,
+  Map<string, OperationType[]>
+>();
+
+/**
+ * 필드 메타데이터 데코레이터
+ * 특정 작업에서 해당 필드를 제외하도록 함
+ * @param operations 제외할 작업 타입들
+ */
+export function Exclude(...operations: OperationType[]): PropertyDecorator {
+  return (target: object, propertyKey: string | symbol) => {
+    const constructor = target.constructor as ClassConstructor;
+    const key = propertyKey.toString();
+
+    if (!exclusionMetadata.has(constructor)) {
+      exclusionMetadata.set(constructor, new Map());
+    }
+
+    const classMetadata = exclusionMetadata.get(constructor);
+    if (classMetadata) {
+      classMetadata.set(key, operations);
+    }
+  };
+}
+
+/**
+ * 클래스와 상위 클래스의 모든 메타데이터를 병합하여 가져옵니다.
+ * @param constructor 클래스 생성자
+ * @returns 병합된 메타데이터 맵
+ */
+function getMergedMetadata(
+  constructor: ClassConstructor,
+): Map<string, OperationType[]> {
+  const result = new Map<string, OperationType[]>();
+
+  // 상위 클래스부터 순회하여 메타데이터 병합
+  let currentConstructor: ClassConstructor | null = constructor;
+
+  while (
+    currentConstructor &&
+    currentConstructor !== Object.prototype.constructor
+  ) {
+    const metadata = exclusionMetadata.get(currentConstructor);
+
+    if (metadata) {
+      // 하위 클래스에서 오버라이드되지 않은 필드만 추가
+      metadata.forEach((operations, field) => {
+        if (!result.has(field)) {
+          result.set(field, [...operations]);
+        }
+      });
+    }
+
+    // 상위 클래스로 이동
+    currentConstructor = Object.getPrototypeOf(currentConstructor.prototype)
+      ?.constructor as ClassConstructor | null;
+  }
+
+  return result;
+}
+
+/**
+ * 런타임에 클래스에서 특정 작업에 대해 제외된 필드 목록을 가져옵니다.
+ * @param classType 대상 클래스
+ * @param operation 작업 타입 (선택적)
+ * @returns 제외된 필드 목록 또는 모든 메타데이터
+ */
+export function getExcludedFields(
+  classType: ClassConstructor,
+  operation?: OperationType,
+): string[] | Record<string, OperationType[]> {
+  const mergedMetadata = getMergedMetadata(classType);
+
+  if (!operation) {
+    // 작업 타입이 제공되지 않으면 전체 메타데이터 반환
+    const result: Record<string, OperationType[]> = {};
+    mergedMetadata.forEach((ops, field) => {
+      result[field] = [...ops];
+    });
+    return result;
+  }
+
+  // 특정 작업에 대한 제외 필드 목록 반환
+  const excludedFields: string[] = [];
+  mergedMetadata.forEach((operations, field) => {
+    if (operations.includes(operation)) {
+      excludedFields.push(field);
+    }
+  });
+
+  return excludedFields;
+}
+
+/**
+ * 특정 작업 타입에 따라 필드를 필터링하는 헬퍼 함수
+ * 런타임에 사용됩니다.
+ *
+ * @param data 필터링할 객체
+ * @param operation 작업 타입
+ * @returns 필터링된 객체
+ */
+export function filterExcludedFields<T extends object>(
+  data: T,
+  operation: OperationType,
+): T {
+  // 객체의 생성자 함수를 가져옴
+  const classType = Object.getPrototypeOf(data)
+    .constructor as ClassConstructor<T>;
+
+  const result = { ...data } as T;
+  const excludedFields = getExcludedFields(classType, operation) as string[];
+
+  // 클래스 프로토타입 가져오기
+  const { prototype } = classType;
+
+  // 클래스에 정의된 기본값을 저장할 객체
+  const defaultValues: Record<string, unknown> = {};
+
+  // 클래스에 정의된 모든 속성 및 기본값 수집
+  // 상속 체인을 통해 모든 상위 클래스의 속성도 포함
+  let currentPrototype = prototype;
+  while (currentPrototype && currentPrototype !== Object.prototype) {
+    // 현재 프로토타입에 정의된 속성 및 메서드 가져오기
+    const propertyDescriptors =
+      Object.getOwnPropertyDescriptors(currentPrototype);
+
+    // 각 속성에 대해 기본값이 있는지 확인
+    // eslint-disable-next-line no-restricted-syntax
+    for (const [key, descriptor] of Object.entries(propertyDescriptors)) {
+      // 메서드나 getter/setter 제외
+      if (
+        typeof descriptor.value === "function" ||
+        descriptor.get ||
+        descriptor.set
+      ) {
+        // eslint-disable-next-line no-continue
+        continue;
+      }
+
+      // 이미 처리된 속성은 건너뛰기 (하위 클래스가 우선)
+      if (key in defaultValues) {
+        // eslint-disable-next-line no-continue
+        continue;
+      }
+
+      // 기본값이 있다면 저장
+      if (descriptor.value !== undefined) {
+        defaultValues[key] = descriptor.value;
+      }
+    }
+
+    // 상위 프로토타입으로 이동
+    currentPrototype = Object.getPrototypeOf(currentPrototype);
+  }
+
+  // 인스턴스의 값을 확인하여 추가 기본값 수집
+  // eslint-disable-next-line no-restricted-syntax
+  for (const [key, value] of Object.entries(data)) {
+    if (excludedFields.includes(key) && value !== undefined) {
+      defaultValues[key] = value;
+    }
+  }
+
+  // 제외해야 할 필드 처리
+  excludedFields.forEach(field => {
+    if (field in result) {
+      delete (result as Record<string, unknown>)[field];
+    }
+  });
+
+  // 필수 필드 검증
+  Object.keys(result).forEach(key => {
+    // null 또는 undefined인 필드 검사
+    const value = (result as Record<string, unknown>)[key];
+    if (value === undefined || value === null) {
+      // 이 필드가 제외 대상이 아니고 원래 nullable이 아닌 필드인 경우에만 오류 발생
+      if (!excludedFields.includes(key)) {
+        // 필드 속성 디스크립터를 통해 타입 정보 확인 (가능한 경우)
+        const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
+
+        // 원래 nullable이나 optional로 정의된 필드인지 확인
+        const isNullableOrOptionalField =
+          descriptor?.get?.toString().includes("| null") ||
+          descriptor?.get?.toString().includes("nullable") ||
+          descriptor?.get?.toString().includes("?:") || // optional 필드 (예: field?: type)
+          descriptor?.get?.toString().includes("| undefined") || // union with undefined
+          key === "deletedAt" || // 기본적으로 deletedAt은 nullable
+          key === "id"; // id 필드는 자동생성되므로 CREATE에서 제외
+
+        if (!isNullableOrOptionalField) {
+          throw new Error(`Required field ${key} is missing or null`);
+        }
+      }
+    }
+  });
+
+  // 제외된 필드에 대한 기본값을 결과에 병합
+  const finalResult = { ...result } as Record<string, unknown>;
+
+  // 제외된 필드 중 기본값이 있는 경우에만 추가
+  // eslint-disable-next-line no-restricted-syntax
+  for (const field of excludedFields) {
+    if (field in defaultValues) {
+      finalResult[field] = defaultValues[field];
+    }
+  }
+
+  return finalResult as T;
+}


Reviews:
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-23T13:53:08Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-23T14:01:04Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-23T14:01:35Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T00:46:11Z
user: login: Engineer-HaMa
body: 쭉 봤을때 자세히 안봐서 헷갈리는것일수도 있는데
- baserepository를 상속받을때 어떤것들을 구현해야 하는지
이것만 헷갈린거 같아요 결국 가이드 문서만 있으면 문제없을듯!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T08:25:23Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T08:34:51Z
user: login: Engineer-HaMa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T08:41:36Z
user: login: Gerbera3090
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:52:55Z
user: login: Gerbera3090
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T11:47:08Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T12:04:30Z
user: login: Gerbera3090
body: 좀 보기 쉽게 정리해봤어요
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:40:39Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T17:00:37Z
user: login: pbc1017
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T17:03:38Z

Review comments:
diff_hunk: @@ -8,29 +24,55 @@ import {
   DrizzleTransaction,
 } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 
-import { MEntity } from "../model/entity.model";
-import { getKSTDate } from "../util/util";
+import { OrderByTypeEnum } from "../enums";
+import { IdType, MEntity } from "../model/entity.model";
+import { getKSTDate, takeAll, takeOnlyOne } from "../util/util";
 
 interface TableWithId {
   id: MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>;
 }
 
-interface ModelWithFrom<T extends MEntity, D> {
-  from(result: D): T;
+interface ModelWithFrom<
+  Model extends MEntity<Id>,
+  DbResult,
+  Query,
+  Id extends IdType = number,
+> {
+  from(result: DbResult): Model;
+  modelName: string;
+  fieldMap(field: keyof Query): MySqlColumn;
+}
+
+export interface BaseQueryFields<Id extends IdType = number> {
+  id?: Id;
+  ids?: Id[];
+  pagination?: {
+    offset: number;
+    itemCount: number;
+  };
+  orderBy?: Record<string, OrderByTypeEnum>;
 }
 
+export type BaseRepositoryQuery<
+  Query,
+  Id extends IdType = number,
+> = BaseQueryFields<Id> & Partial<Query>;
+
 @Injectable()
 export abstract class BaseRepository<
-  M extends MEntity,
-  R,
-  D,
-  T extends MySqlTable & TableWithId,
+  Model extends MEntity<Id>,
+  CreateParam,
+  UpdateParam,
+  DbResult,
+  Table extends MySqlTable & TableWithId,
+  Query,
+  Id extends IdType = number,
 > {
   @Inject(DrizzleAsyncProvider) private db: MySql2Database;
path: packages/api/src/common/repository/base.repository.ts
user: login: pbc1017
body: protected로 변경 필요
created_at: 2025-03-23T13:53:01Z
author_association: COLLABORATOR
diff_hunk: @@ -59,4 +75,30 @@ export class MMemberRegistration
           : desc(orderByFieldMap[key]),
       );
   }
+
+  static fieldMap(
+    field: keyof MemberRegistrationQuery,
+  ): MySqlColumn<ColumnBaseConfig<ColumnDataType, string>> {
+    const fieldMappings: Record<
+      keyof MemberRegistrationQuery,
+      MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>
+    > = {
+      studentId: RegistrationApplicationStudent.studentId,
+      clubId: RegistrationApplicationStudent.clubId,
+      semesterId: RegistrationApplicationStudent.semesterId,
+    };
+
+    if (!(field in fieldMappings)) {
+      throw new Error(`Invalid field: ${String(field)}`);
+    }
+
+    return fieldMappings[field];
+  }
+
+  set(param: Partial<MMemberRegistration>): MMemberRegistration {
+    return new MMemberRegistration({
+      ...this,
+      ...param,
+    });
path: packages/api/src/feature/registration/member-registration/model/member.registration.model.ts
user: login: pbc1017
body: 메서드가 setter인데 새 객체를 뱉는게 어색함. 동작상 문제는 없겠지만, 나중에 코드가 복잡해지면 이거 때문에 생기는 문재가 발생했을때 디버깅하기 매우 어려울 것이 우려됨
created_at: 2025-03-23T14:01:00Z
author_association: COLLABORATOR
diff_hunk: @@ -59,4 +75,30 @@ export class MMemberRegistration
           : desc(orderByFieldMap[key]),
       );
   }
+
+  static fieldMap(
+    field: keyof MemberRegistrationQuery,
+  ): MySqlColumn<ColumnBaseConfig<ColumnDataType, string>> {
+    const fieldMappings: Record<
+      keyof MemberRegistrationQuery,
+      MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>
+    > = {
+      studentId: RegistrationApplicationStudent.studentId,
+      clubId: RegistrationApplicationStudent.clubId,
+      semesterId: RegistrationApplicationStudent.semesterId,
+    };
+
+    if (!(field in fieldMappings)) {
+      throw new Error(`Invalid field: ${String(field)}`);
+    }
+
+    return fieldMappings[field];
+  }
+
+  set(param: Partial<MMemberRegistration>): MMemberRegistration {
+    return new MMemberRegistration({
+      ...this,
+      ...param,
+    });
path: packages/api/src/feature/registration/member-registration/model/member.registration.model.ts
user: login: pbc1017
body: 타입스크립트에서 이 문제 잘 해결할수 있는 분 도움 부탁드립니다
created_at: 2025-03-23T14:01:35Z
author_association: COLLABORATOR
diff_hunk: @@ -11,7 +21,16 @@ import { VFundingSummary } from "./funding.summary.model";
 
 export type FundingCommentDbResult = InferSelectModel<typeof FundingFeedback>;
path: packages/api/src/feature/funding/model/funding.comment.model.ts
user: login: pbc1017
body: deletedAt을 제거하려면 여기에 Partial을 붙이거나 해야하는데 실익이 떨어짐. FundingFeedback의 필드는 모두 변환되게끔 강제하는것이 어떨지
created_at: 2025-03-26T00:46:11Z
author_association: COLLABORATOR
diff_hunk: @@ -1,72 +1,35 @@
 import { Injectable } from "@nestjs/common";
-import { desc, eq } from "drizzle-orm";
-
-import { IFundingCommentRequest } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 
 import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import { DrizzleTransaction } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
 import {
   FundingCommentDbResult,
+  FundingCommentQuery,
   MFundingComment,
 } from "@sparcs-clubs/api/feature/funding/model/funding.comment.model";
 
 @Injectable()
 export default class FundingCommentRepository extends BaseRepository<
   MFundingComment,
-  IFundingCommentRequest,
   FundingCommentDbResult,
-  typeof FundingFeedback
+  typeof FundingFeedback,
+  FundingCommentQuery
 > {
   constructor() {
     super(FundingFeedback, MFundingComment);
   }
 
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    ids: number[],
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    fundingId: number,
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    arg1: number | number[],
-  ): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAllTx(tx, arg1);
-    }
-
-    const result = await tx
-      .select()
-      .from(FundingFeedback)
-      .where(eq(FundingFeedback.fundingId, arg1))
-      .orderBy(desc(FundingFeedback.createdAt));
-
-    return result.map(row => MFundingComment.from(row));
-  }
-
-  async fetchAll(fundingId: number): Promise<MFundingComment[]>;
-  async fetchAll(ids: number[]): Promise<MFundingComment[]>;
-  async fetchAll(arg1: number | number[]): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAll(arg1);
-    }
-
-    return this.withTransaction(async tx => this.fetchAllTx(tx, arg1));
-  }
-
-  async insertTx(
+  async createTx(
     tx: DrizzleTransaction,
-    param: IFundingCommentRequest,
+    param: Partial<MFundingComment>,
path: packages/api/src/feature/funding/repository/funding.comment.repository.ts
user: login: Engineer-HaMa
body: 이 `param`은 왜 partial인가요?
created_at: 2025-03-26T07:56:48Z
author_association: COLLABORATOR
diff_hunk: @@ -1,72 +1,35 @@
 import { Injectable } from "@nestjs/common";
-import { desc, eq } from "drizzle-orm";
-
-import { IFundingCommentRequest } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 
 import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import { DrizzleTransaction } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
 import {
   FundingCommentDbResult,
+  FundingCommentQuery,
   MFundingComment,
 } from "@sparcs-clubs/api/feature/funding/model/funding.comment.model";
 
 @Injectable()
 export default class FundingCommentRepository extends BaseRepository<
   MFundingComment,
-  IFundingCommentRequest,
   FundingCommentDbResult,
-  typeof FundingFeedback
+  typeof FundingFeedback,
+  FundingCommentQuery
 > {
   constructor() {
     super(FundingFeedback, MFundingComment);
   }
 
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    ids: number[],
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    fundingId: number,
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    arg1: number | number[],
-  ): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAllTx(tx, arg1);
-    }
-
-    const result = await tx
-      .select()
-      .from(FundingFeedback)
-      .where(eq(FundingFeedback.fundingId, arg1))
-      .orderBy(desc(FundingFeedback.createdAt));
-
-    return result.map(row => MFundingComment.from(row));
-  }
-
-  async fetchAll(fundingId: number): Promise<MFundingComment[]>;
-  async fetchAll(ids: number[]): Promise<MFundingComment[]>;
-  async fetchAll(arg1: number | number[]): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAll(arg1);
-    }
-
-    return this.withTransaction(async tx => this.fetchAllTx(tx, arg1));
-  }
-
-  async insertTx(
+  async createTx(
     tx: DrizzleTransaction,
-    param: IFundingCommentRequest,
+    param: Partial<MFundingComment>,
path: packages/api/src/feature/funding/repository/funding.comment.repository.ts
user: login: pbc1017
body: 원래라면 IFundingCommentCreate가 들어갔겠지만, [이 pr](https://github.com/sparcs-kaist/clubs/pull/1551)의 변경사항으로 인해 create와 put 붙은 인터페이스가 사라집니다. 대신에 동적으로 타입체크가 아니라 런타임에 빠진 필드가 있는지 검사를 수행합니다. 그래서 여기서는 partial로 받고 나면 to에서 실행되는 filterExcludeFileds에서 동적으로 체크해서 모델 레벨에 정의된 exclude 데코레이터를 기준으로 필수 필드 체크를 진행합니다.
created_at: 2025-03-26T08:34:51Z
author_association: COLLABORATOR
diff_hunk: @@ -1,72 +1,35 @@
 import { Injectable } from "@nestjs/common";
-import { desc, eq } from "drizzle-orm";
-
-import { IFundingCommentRequest } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 
 import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import { DrizzleTransaction } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
 import {
   FundingCommentDbResult,
+  FundingCommentQuery,
   MFundingComment,
 } from "@sparcs-clubs/api/feature/funding/model/funding.comment.model";
 
 @Injectable()
 export default class FundingCommentRepository extends BaseRepository<
   MFundingComment,
-  IFundingCommentRequest,
   FundingCommentDbResult,
-  typeof FundingFeedback
+  typeof FundingFeedback,
+  FundingCommentQuery
 > {
   constructor() {
     super(FundingFeedback, MFundingComment);
   }
 
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    ids: number[],
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    fundingId: number,
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    arg1: number | number[],
-  ): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAllTx(tx, arg1);
-    }
-
-    const result = await tx
-      .select()
-      .from(FundingFeedback)
-      .where(eq(FundingFeedback.fundingId, arg1))
-      .orderBy(desc(FundingFeedback.createdAt));
-
-    return result.map(row => MFundingComment.from(row));
-  }
-
-  async fetchAll(fundingId: number): Promise<MFundingComment[]>;
-  async fetchAll(ids: number[]): Promise<MFundingComment[]>;
-  async fetchAll(arg1: number | number[]): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAll(arg1);
-    }
-
-    return this.withTransaction(async tx => this.fetchAllTx(tx, arg1));
-  }
-
-  async insertTx(
+  async createTx(
     tx: DrizzleTransaction,
-    param: IFundingCommentRequest,
+    param: Partial<MFundingComment>,
path: packages/api/src/feature/funding/repository/funding.comment.repository.ts
user: login: Engineer-HaMa
body: dㅏ 그 데코레이터들이 빌드 타임에 검사해주는게 아니었군요
created_at: 2025-03-26T08:41:36Z
author_association: COLLABORATOR
diff_hunk: @@ -1,72 +1,35 @@
 import { Injectable } from "@nestjs/common";
-import { desc, eq } from "drizzle-orm";
-
-import { IFundingCommentRequest } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 
 import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import { DrizzleTransaction } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
 import {
   FundingCommentDbResult,
+  FundingCommentQuery,
   MFundingComment,
 } from "@sparcs-clubs/api/feature/funding/model/funding.comment.model";
 
 @Injectable()
 export default class FundingCommentRepository extends BaseRepository<
   MFundingComment,
-  IFundingCommentRequest,
   FundingCommentDbResult,
-  typeof FundingFeedback
+  typeof FundingFeedback,
+  FundingCommentQuery
 > {
   constructor() {
     super(FundingFeedback, MFundingComment);
   }
 
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    ids: number[],
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    fundingId: number,
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    arg1: number | number[],
-  ): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAllTx(tx, arg1);
-    }
-
-    const result = await tx
-      .select()
-      .from(FundingFeedback)
-      .where(eq(FundingFeedback.fundingId, arg1))
-      .orderBy(desc(FundingFeedback.createdAt));
-
-    return result.map(row => MFundingComment.from(row));
-  }
-
-  async fetchAll(fundingId: number): Promise<MFundingComment[]>;
-  async fetchAll(ids: number[]): Promise<MFundingComment[]>;
-  async fetchAll(arg1: number | number[]): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAll(arg1);
-    }
-
-    return this.withTransaction(async tx => this.fetchAllTx(tx, arg1));
-  }
-
-  async insertTx(
+  async createTx(
     tx: DrizzleTransaction,
-    param: IFundingCommentRequest,
+    param: Partial<MFundingComment>,
path: packages/api/src/feature/funding/repository/funding.comment.repository.ts
user: login: Gerbera3090
body: 저번에 그래서 exclude 되어 있는 필드들을 아예 기본값을 채워 버리자는 이야기를 하지 않았었남 그방법은 어때요
created_at: 2025-03-26T09:52:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,103 +81,279 @@ export abstract class BaseRepository<
     return this.db.transaction(callback);
   }
 
-  async findTx(tx: DrizzleTransaction, id: number): Promise<M | null> {
-    const result = await tx
+  protected makeWhereClause(param: BaseRepositoryQuery<Query, Id>): SQL[] {
+    const whereClause: SQL[] = [];
+
+    // 기본 필터링: id와 ids
+    if (param.id) {
+      whereClause.push(eq(this.table.id, param.id));
+    }
+    if (param.ids) {
+      whereClause.push(inArray(this.table.id, param.ids));
+    }
+
+    // Query 타입의 모든 키를 순회하면서 파라미터에 해당 값이 있는지 확인
+    const specialKeys = ["id", "ids", "pagination", "order"];
+    Object.keys(param).forEach(key => {
+      // 특별한 키는 제외
+      if (specialKeys.includes(key)) {
+        return; // continue 대신 return 사용
+      }
+
+      // 파라미터 값이 존재하는 경우
+      const value = param[key];
+      if (value !== undefined && value !== null) {
+        // Query 필드를 테이블 필드로 변환
+        const tableField = this.getTableField(key as keyof Query);
+        if (!tableField) {
+          throw new Error(`Invalid query field: ${key}`);
+        }
+
+        // 배열인 경우 IN 연산자 사용
+        if (Array.isArray(value)) {
+          whereClause.push(inArray(tableField, value));
+        }
+        // 객체인 경우 복합 조건 처리 (gt, lt, gte, lte 등)
+        else if (typeof value === "object") {
+          this.processAdvancedOperators(whereClause, key as keyof Query, value);
+        }
+        // 단일 값인 경우 = 연산자 사용
+        else {
+          whereClause.push(eq(tableField, value));
+        }
+      }
+    });
+
+    return whereClause;
+  }
+
+  // Query 필드를 테이블 필드로 변환하는 헬퍼 메서드
+  private getTableField(queryField: keyof Query): MySqlColumn {
+    return this.modelClass.fieldMap(queryField);
+  }
+
+  // 고급 연산자 처리 (gt, lt, gte, lte, like 등)
+  private processAdvancedOperators(
+    whereClause: SQL[],
+    queryField: keyof Query,
+    conditions: Record<string, unknown>,
+  ): void {
+    // Query 필드를 테이블 필드로 변환
+    const tableField = this.getTableField(queryField);
+    if (!tableField) {
+      throw new Error(`Invalid query field: ${String(queryField)}`);
+    }
+
+    // 객체의 키-값 쌍을 forEach로 순회
+    Object.entries(conditions).forEach(([operator, operand]) => {
+      if (operand === undefined || operand === null) {
+        throw new Error(`Invalid operand: ${operand}`);
+      }
+
+      switch (operator) {
+        case "eq":
+          whereClause.push(eq(tableField, operand));
+          break;
+        case "ne":
+          whereClause.push(ne(tableField, operand));
+          break;
+        case "gt":
+          whereClause.push(gt(tableField, operand));
+          break;
+        case "gte":
+          whereClause.push(gte(tableField, operand));
+          break;
+        case "lt":
+          whereClause.push(lt(tableField, operand));
+          break;
+        case "lte":
+          whereClause.push(lte(tableField, operand));
+          break;
+        case "like":
+          whereClause.push(like(tableField, `%${String(operand)}%`));
+          break;
+        case "startsWith":
+          whereClause.push(like(tableField, `${String(operand)}%`));
+          break;
+        case "endsWith":
+          whereClause.push(like(tableField, `%${String(operand)}`));
+          break;
+        case "in":
+          if (Array.isArray(operand)) {
+            whereClause.push(inArray(tableField, operand));
+          }
+          break;
+        default:
+          throw new Error(`Invalid operator: ${operator}`);
+      }
+    });
+  }
+
+  // 여러 필드에 대한 정렬을 지원하는 구현
+  protected makeOrderBy(order: Record<string, OrderByTypeEnum>): SQL[] {
+    const orderClauses: SQL[] = [];
+
+    // 객체의 키-값 쌍을 순회하며 정렬 조건 생성
+    Object.entries(order).forEach(([field, direction]) => {
+      // Query 필드를 테이블 필드로 변환
+      const tableField = this.getTableField(field as keyof Query);
+      // 테이블에 해당 필드가 존재하는지 확인
+      if (tableField) {
+        // direction에 따라 asc() 또는 desc() 호출
+        if (direction === OrderByTypeEnum.ASC) {
+          orderClauses.push(asc(tableField));
+        } else {
+          orderClauses.push(desc(tableField));
+        }
+      } else {
+        throw new Error(`Invalid order field: ${field}`);
+      }
+    });
+
+    return orderClauses;
+  }
+
+  async findTx(
+    tx: DrizzleTransaction,
+    param: BaseRepositoryQuery<Query, Id>,
+  ): Promise<Model[]> {
+    let query = tx
       .select()
       .from(this.table)
-      .where(eq(this.table.id, id))
-      .then(rows => rows.map(row => this.modelClass.from(row as D)));
+      .where(and(...this.makeWhereClause(param)))
+      .$dynamic();
+
+    if (param.pagination) {
+      query = query.limit(param.pagination.itemCount);
+      query = query.offset(
+        (param.pagination.offset - 1) * param.pagination.itemCount,
+      );
+    }
+
+    if (param.orderBy) {
+      query = query.orderBy(...this.makeOrderBy(param.orderBy));
+    }
+
+    const result = await query.execute();
 
-    return (result[0] as M) ?? null;
+    return result.map(row => this.modelClass.from(row as DbResult));
   }
 
-  async find(id: number): Promise<M | null> {
-    return this.withTransaction(async tx => this.findTx(tx, id));
+  async find(param: BaseRepositoryQuery<Query, Id>): Promise<Model[]> {
+    return this.withTransaction(async tx => this.findTx(tx, param));
   }
 
-  async fetchTx(tx: DrizzleTransaction, id: number): Promise<M> {
-    const result = await this.findTx(tx, id);
-    if (result === null) {
-      throw new NotFoundException(`Not found: ${this.table} with id: ${id}`);
-    }
-    return result;
+  async fetchTx(tx: DrizzleTransaction, id: Id): Promise<Model> {
+    const param = { id } as BaseRepositoryQuery<Query, Id>;
+    return this.findTx(tx, param).then(
+      takeOnlyOne<Model>(this.modelClass.modelName),
+    );
   }
 
-  async fetch(id: number): Promise<M> {
+  async fetch(id: Id): Promise<Model> {
     return this.withTransaction(async tx => this.fetchTx(tx, id));
   }
 
-  async fetchAllTx(tx: DrizzleTransaction, ids: number[]): Promise<M[]> {
-    if (ids.length === 0) {
-      return [];
-    }
+  async fetchAllTx(tx: DrizzleTransaction, ids: Id[]): Promise<Model[]> {
+    const param = { ids } as BaseRepositoryQuery<Query, Id>;
+    const result = await this.findTx(tx, param);
+    return takeAll<Model, Id>(
+      ids,
+      this.modelClass.modelName,
+    )(result as Array<Model>);
+  }
 
-    const result = await tx
-      .select()
+  async fetchAll(ids: Id[]): Promise<Model[]> {
+    return this.withTransaction(async tx => this.fetchAllTx(tx, ids));
+  }
+
+  async countTx(
+    tx: DrizzleTransaction,
+    param: BaseRepositoryQuery<Query, Id>,
+  ): Promise<number> {
+    const [result] = await tx
+      .select({ count: count() })
path: packages/api/src/common/repository/base.repository.ts
user: login: pbc1017
body: count로 변경
created_at: 2025-03-26T11:34:02Z
author_association: COLLABORATOR
diff_hunk: @@ -20,12 +39,16 @@ export class MFundingComment extends MEntity implements IFundingComment {
 
   content: string;
 
-  fundingStatusEnum: FundingStatusEnum;
+  fundingStatusEnum: FundingStatusEnum = FundingStatusEnum.Applied;
path: packages/api/src/feature/funding/model/funding.comment.model.ts
user: login: pbc1017
body: 기본값 삭제필요
created_at: 2025-03-26T11:40:20Z
author_association: COLLABORATOR
diff_hunk: @@ -11,7 +21,16 @@ import { VFundingSummary } from "./funding.summary.model";
 
 export type FundingCommentDbResult = InferSelectModel<typeof FundingFeedback>;
path: packages/api/src/feature/funding/model/funding.comment.model.ts
user: login: Gerbera3090
body: 생각났다 이거 inferInsert쓰면 돼

created_at: 2025-03-26T11:47:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,72 +1,35 @@
 import { Injectable } from "@nestjs/common";
-import { desc, eq } from "drizzle-orm";
-
-import { IFundingCommentRequest } from "@sparcs-clubs/interface/api/funding/type/funding.comment.type";
 
 import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import { DrizzleTransaction } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 import { FundingFeedback } from "@sparcs-clubs/api/drizzle/schema/funding.schema";
 import {
   FundingCommentDbResult,
+  FundingCommentQuery,
   MFundingComment,
 } from "@sparcs-clubs/api/feature/funding/model/funding.comment.model";
 
 @Injectable()
 export default class FundingCommentRepository extends BaseRepository<
   MFundingComment,
-  IFundingCommentRequest,
   FundingCommentDbResult,
-  typeof FundingFeedback
+  typeof FundingFeedback,
+  FundingCommentQuery
 > {
   constructor() {
     super(FundingFeedback, MFundingComment);
   }
 
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    ids: number[],
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    fundingId: number,
-  ): Promise<MFundingComment[]>;
-  async fetchAllTx(
-    tx: DrizzleTransaction,
-    arg1: number | number[],
-  ): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAllTx(tx, arg1);
-    }
-
-    const result = await tx
-      .select()
-      .from(FundingFeedback)
-      .where(eq(FundingFeedback.fundingId, arg1))
-      .orderBy(desc(FundingFeedback.createdAt));
-
-    return result.map(row => MFundingComment.from(row));
-  }
-
-  async fetchAll(fundingId: number): Promise<MFundingComment[]>;
-  async fetchAll(ids: number[]): Promise<MFundingComment[]>;
-  async fetchAll(arg1: number | number[]): Promise<MFundingComment[]> {
-    if (Array.isArray(arg1)) {
-      return super.fetchAll(arg1);
-    }
-
-    return this.withTransaction(async tx => this.fetchAllTx(tx, arg1));
-  }
-
-  async insertTx(
+  async createTx(
path: packages/api/src/feature/funding/repository/funding.comment.repository.ts
user: login: pbc1017
body: 삭제필요
created_at: 2025-03-26T11:49:40Z
author_association: COLLABORATOR
diff_hunk: @@ -39,103 +81,279 @@ export abstract class BaseRepository<
     return this.db.transaction(callback);
   }
 
-  async findTx(tx: DrizzleTransaction, id: number): Promise<M | null> {
-    const result = await tx
+  protected makeWhereClause(param: BaseRepositoryQuery<Query, Id>): SQL[] {
+    const whereClause: SQL[] = [];
+
+    // 기본 필터링: id와 ids
+    if (param.id) {
+      whereClause.push(eq(this.table.id, param.id));
+    }
+    if (param.ids) {
+      whereClause.push(inArray(this.table.id, param.ids));
+    }
+
+    // Query 타입의 모든 키를 순회하면서 파라미터에 해당 값이 있는지 확인
+    const specialKeys = ["id", "ids", "pagination", "order"];
+    Object.keys(param).forEach(key => {
+      // 특별한 키는 제외
+      if (specialKeys.includes(key)) {
+        return; // continue 대신 return 사용
+      }
+
+      // 파라미터 값이 존재하는 경우
+      const value = param[key];
+      if (value !== undefined && value !== null) {
+        // Query 필드를 테이블 필드로 변환
+        const tableField = this.getTableField(key as keyof Query);
+        if (!tableField) {
+          throw new Error(`Invalid query field: ${key}`);
+        }
+
+        // 배열인 경우 IN 연산자 사용
+        if (Array.isArray(value)) {
+          whereClause.push(inArray(tableField, value));
+        }
+        // 객체인 경우 복합 조건 처리 (gt, lt, gte, lte 등)
+        else if (typeof value === "object") {
path: packages/api/src/common/repository/base.repository.ts
user: login: Gerbera3090
body: 여기 Date 박으면 object로 들어가서 미리 거르는게 더 필요할듯??

created_at: 2025-03-26T11:51:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,210 +1,34 @@
-import { HttpException, HttpStatus, Inject, Injectable } from "@nestjs/common";
-import { and, count, eq, inArray, isNull, SQL } from "drizzle-orm";
-import { MySql2Database } from "drizzle-orm/mysql2";
+import { Injectable } from "@nestjs/common";
+import { and, eq, InferSelectModel, isNull } from "drizzle-orm";
 
-import {
-  IMemberRegistrationCreate,
-  IMemberRegistrationUpdate,
-} from "@sparcs-clubs/interface/api/registration/type/member.registration.type";
-import {
-  RegistrationApplicationStudentStatusEnum,
-  RegistrationDeadlineEnum,
-} from "@sparcs-clubs/interface/common/enum/registration.enum";
+import { RegistrationDeadlineEnum } from "@sparcs-clubs/interface/common/enum/registration.enum";
 
-import { getKSTDate } from "@sparcs-clubs/api/common/util/util";
-import {
-  DrizzleAsyncProvider,
-  DrizzleTransaction,
-} from "@sparcs-clubs/api/drizzle/drizzle.provider";
+import { BaseRepository } from "@sparcs-clubs/api/common/repository/base.repository";
 import {
   RegistrationApplicationStudent,
   RegistrationDeadlineD,
 } from "@sparcs-clubs/api/drizzle/schema/registration.schema";
 import {
-  IMemberRegistrationOrderBy,
+  MemberRegistrationQuery,
   MMemberRegistration,
 } from "@sparcs-clubs/api/feature/registration/member-registration/model/member.registration.model";
 
-type IMemberRegistrationQuery = {
-  id?: number;
-  ids?: number[];
-  studentId?: number;
-  clubId?: number;
-  semesterId?: number;
-  pagination?: {
-    offset: number;
-    itemCount: number;
-  };
-  orderBy?: IMemberRegistrationOrderBy;
-  registrationApplicationStudentEnum?: number;
-  registrationApplicationStudentEnums?: number[];
-};
+type MemberRegistrationDbResult = InferSelectModel<
+  typeof RegistrationApplicationStudent
+>;
 
 @Injectable()
-export class MemberRegistrationRepository {
-  @Inject(DrizzleAsyncProvider) private db: MySql2Database;
-  constructor() {}
-
-  async withTransaction<Result>(
-    callback: (tx: DrizzleTransaction) => Promise<Result>,
-  ): Promise<Result> {
-    return this.db.transaction(callback);
-  }
-
-  private makeWhereClause(param: IMemberRegistrationQuery): SQL[] {
-    const whereClause: SQL[] = [];
-    if (param.id) {
-      whereClause.push(eq(RegistrationApplicationStudent.id, param.id));
-    }
-    if (param.ids) {
-      whereClause.push(inArray(RegistrationApplicationStudent.id, param.ids));
-    }
-    if (param.studentId) {
-      whereClause.push(
-        eq(RegistrationApplicationStudent.studentId, param.studentId),
-      );
-    }
-    if (param.clubId) {
-      whereClause.push(eq(RegistrationApplicationStudent.clubId, param.clubId));
-    }
-    if (param.semesterId) {
-      whereClause.push(
-        eq(RegistrationApplicationStudent.semesterId, param.semesterId),
-      );
-    }
-    if (param.registrationApplicationStudentEnum) {
-      whereClause.push(
-        eq(
-          RegistrationApplicationStudent.registrationApplicationStudentEnumId,
-          param.registrationApplicationStudentEnum,
-        ),
-      );
-    }
-    if (param.registrationApplicationStudentEnums) {
-      whereClause.push(
-        inArray(
-          RegistrationApplicationStudent.registrationApplicationStudentEnumId,
-          param.registrationApplicationStudentEnums,
-        ),
-      );
-    }
-    whereClause.push(isNull(RegistrationApplicationStudent.deletedAt));
-
-    return whereClause;
-  }
-
-  async countTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationQuery,
-  ): Promise<number> {
-    const [result] = await tx
-      .select({ count: count() })
-      .from(RegistrationApplicationStudent)
-      .where(and(...this.makeWhereClause(param)));
-
-    return result.count;
-  }
-
-  async count(param: IMemberRegistrationQuery): Promise<number> {
-    return this.withTransaction(async tx => this.countTx(tx, param));
-  }
-
-  async findTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationQuery,
-  ): Promise<MMemberRegistration[]> {
-    let query = tx
-      .select()
-      .from(RegistrationApplicationStudent)
-      .where(and(...this.makeWhereClause(param)))
-      .$dynamic();
-
-    if (param.pagination) {
-      query = query.limit(param.pagination.itemCount);
-      query = query.offset(
-        (param.pagination.offset - 1) * param.pagination.itemCount,
-      );
-    }
-    if (param.orderBy) {
-      query = query.orderBy(...MMemberRegistration.makeOrderBy(param.orderBy));
-    }
-
-    const result = await query.execute();
-
-    return result.map(row => MMemberRegistration.from(row));
-  }
-
-  async find(param: IMemberRegistrationQuery): Promise<MMemberRegistration[]> {
-    return this.withTransaction(async tx => this.findTx(tx, param));
-  }
-
-  async insertTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationCreate,
-  ): Promise<void> {
-    const [result] = await tx.insert(RegistrationApplicationStudent).values({
-      ...param,
-      registrationApplicationStudentEnumId:
-        RegistrationApplicationStudentStatusEnum.Pending,
-    });
-    if (result.insertId === undefined) {
-      throw new HttpException("Failed to insert", HttpStatus.BAD_REQUEST);
-    }
-  }
-
-  async insert(param: IMemberRegistrationCreate): Promise<void> {
-    await this.withTransaction(async tx => this.insertTx(tx, param));
-  }
-
-  async updateTx(
-    tx: DrizzleTransaction,
-    param: IMemberRegistrationUpdate,
-  ): Promise<void> {
-    const [result] = await tx
-      .update(RegistrationApplicationStudent)
-      .set({
-        registrationApplicationStudentEnumId:
-          param.registrationApplicationStudentEnum,
-      })
-      .where(
-        and(
-          eq(RegistrationApplicationStudent.id, param.id),
-          isNull(RegistrationApplicationStudent.deletedAt),
-        ),
-      );
-    if (result.affectedRows === 0) {
-      throw new HttpException("Failed to update", HttpStatus.BAD_REQUEST);
-    }
-  }
-  async update(param: IMemberRegistrationUpdate): Promise<void> {
-    await this.withTransaction(async tx => this.updateTx(tx, param));
-  }
-
-  async deleteTx(
-    tx: DrizzleTransaction,
-    studentId: number,
-    id: number,
-  ): Promise<void> {
-    const cur = getKSTDate();
-    const [result] = await tx
-      .update(RegistrationApplicationStudent)
-      .set({ deletedAt: cur })
-      .where(
-        and(
-          eq(RegistrationApplicationStudent.id, id),
-          eq(RegistrationApplicationStudent.studentId, studentId),
-          isNull(RegistrationApplicationStudent.deletedAt),
-        ),
-      );
-    if (result.affectedRows === 0) {
-      throw new HttpException("Failed to delete", HttpStatus.BAD_REQUEST);
-    }
-  }
-
-  async delete(studentId: number, id: number): Promise<void> {
-    await this.withTransaction(async tx => this.deleteTx(tx, studentId, id));
+export class MemberRegistrationRepository extends BaseRepository<
+  MMemberRegistration,
+  MemberRegistrationDbResult,
+  typeof RegistrationApplicationStudent,
+  MemberRegistrationQuery
+> {
+  constructor() {
+    super(RegistrationApplicationStudent, MMemberRegistration);
   }
 
+  // 별도 레포지토리로 분리해야 함
path: packages/api/src/feature/registration/member-registration/repository/member-registration.repository.ts
user: login: pbc1017
body: @Gerbera3090 나중에 작업 부탁
created_at: 2025-03-26T11:52:11Z
author_association: COLLABORATOR
diff_hunk: @@ -11,7 +21,16 @@ import { VFundingSummary } from "./funding.summary.model";
 
 export type FundingCommentDbResult = InferSelectModel<typeof FundingFeedback>;
path: packages/api/src/feature/funding/model/funding.comment.model.ts
user: login: pbc1017
body: 근데 그러면 select했을때 id가 없을거잖아

created_at: 2025-03-26T12:00:51Z
author_association: COLLABORATOR
diff_hunk: @@ -127,52 +135,56 @@ export abstract class BaseRepository<
     }
 
     // Query 타입의 모든 키를 순회하면서 파라미터에 해당 값이 있는지 확인
-    const specialKeys = ["id", "ids", "pagination", "order"];
-    Object.keys(param).forEach(key => {
-      // 특별한 키는 제외
-      if (specialKeys.includes(key)) {
-        return; // continue 대신 return 사용
-      }
-
-      // 파라미터 값이 존재하는 경우
-      const value = param[key];
-      if (value !== undefined) {
-        // Query 필드를 테이블 필드로 변환
-        const tableField = this.getTableField(key as keyof Query);
-        if (!tableField) {
-          throw new Error(`Invalid query field: ${key}`);
-        }
+    const defaultKeys = ["id", "ids", "pagination", "order"];
path: packages/api/src/common/repository/base.repository.ts
user: login: pbc1017
body: 아 이거 orderBy 여야 하는데 수정 필요 @Gerbera3090 
created_at: 2025-03-27T17:00:36Z
author_association: COLLABORATOR
diff_hunk: @@ -1,141 +1,491 @@
-import { Inject, Injectable, NotFoundException } from "@nestjs/common";
-import { ColumnBaseConfig, ColumnDataType, eq, inArray } from "drizzle-orm";
+import { Inject, Injectable } from "@nestjs/common";
+import {
+  and,
+  asc,
+  between,
+  ColumnBaseConfig,
+  ColumnDataType,
+  count,
+  desc,
+  eq,
+  gt,
+  gte,
+  inArray,
+  isNotNull,
+  isNull,
+  like,
+  lt,
+  lte,
+  ne,
+  not,
+  or,
+  SQL,
+} from "drizzle-orm";
 import { MySqlColumn, MySqlTable } from "drizzle-orm/mysql-core";
 import { MySql2Database } from "drizzle-orm/mysql2";
 
+import { OperationType } from "@sparcs-clubs/interface/common/utils/field-operations";
+
 import {
   DrizzleAsyncProvider,
   DrizzleTransaction,
 } from "@sparcs-clubs/api/drizzle/drizzle.provider";
 
-import { MEntity } from "../model/entity.model";
-import { getKSTDate } from "../util/util";
+import { OrderByTypeEnum } from "../enums";
+import { IdType, MEntity } from "../model/entity.model";
+import { getKSTDate, takeAll, takeOnlyOne } from "../util/util";
+
+// 쿼리 조건 래핑을 위해 필요한 타입 선언
+const mysqlQueryConditionOperators = [
+  "eq",
+  "ne",
+  "gt",
+  "gte",
+  "lt",
+  "lte",
+  "like",
+  "startsWith",
+  "endsWith",
+  "in",
+  "isNotNull",
+] as const;
 
-interface TableWithId {
+// 타입으로 변환
+type MysqlQueryConditionOperators =
+  (typeof mysqlQueryConditionOperators)[number];
+
+const nestedQueryWrapper = ["and", "or", "not"] as const;
+type NestedQueryWrapper = (typeof nestedQueryWrapper)[number];
+
+// Clubs에서 사용하는 테이블에 id와 deletedAt 필드가 항상 있음을 보장하기 위한 interface
+interface TableWithIdAndDeletedAt {
   id: MySqlColumn<ColumnBaseConfig<ColumnDataType, string>>;
+  deletedAt: MySqlColumn<ColumnBaseConfig<ColumnDataType, string | null>>;
+}
+
+// MEntity 의 static 메서드를 사용하기 위한 interface
+// 베이스 레포지토리 추상클래스 한정으로, MEntity를 직접 주입을 안해서 이렇게 한번 매핑해줘야 static 메서드들을 사용할 수 있음
+// 실제 repository 에서는 이 인터페이스를 사용 및 구현할 필요 없음
+interface ModelWithMethods<
+  Model extends MEntity<Id>,
+  FromDb,
+  Query,
+  Id extends IdType = number,
+> {
+  modelName: string;
+  from(result: FromDb): Model;
+  fieldMap(field: keyof Query): MySqlColumn;
 }
 
-interface ModelWithFrom<T extends MEntity, D> {
-  from(result: D): T;
+// 기본적인 쿼리 파라미터들
+// id, ids, pagination, orderBy 등을 기본으로 지원하기 위함
+export interface BaseQueryFields<Id extends IdType = number> {
+  id?: Id;
+  ids?: Id[];
+  pagination?: {
+    offset: number;
+    itemCount: number;
+  };
+  orderBy?: Record<string, OrderByTypeEnum>;
 }
 
+// Query 에 쿼리 가능한 타입들을 명시하여 사용
+export type BaseRepositoryQuery<
+  Query,
+  Id extends IdType = number,
+> = BaseQueryFields<Id> & Partial<Query>;
+
+// 베이스 레포지토리 추상클래스
+// 사용 방법
+// 1. Model에 모델 클래스 넣기
+// 2. Table에 FromDB (InferSelectTable) 타입 넣기
+// 3. 쿼리 조건 추가 (id 등 제외)
+// 4. 추가 쿼리 조건이 있을 경우 specialKeys에 추가하여 makeWhereClause를 상속하여 구현
 @Injectable()
 export abstract class BaseRepository<
-  M extends MEntity,
-  R,
-  D,
-  T extends MySqlTable & TableWithId,
+  Model extends MEntity<Id>,
+  DbResult,
+  Table extends MySqlTable & TableWithIdAndDeletedAt, // &TableWith~ 를 통해 테이블에 ID와 deletedAt 필드가 항상 있음을 보장
+  Query,
+  Id extends IdType = number,
 > {
-  @Inject(DrizzleAsyncProvider) private db: MySql2Database;
+  @Inject(DrizzleAsyncProvider) protected db: MySql2Database;
 
   constructor(
-    protected table: T,
-    protected modelClass: ModelWithFrom<M, D>,
+    protected table: Table,
+    protected modelClass: ModelWithMethods<Model, DbResult, Query, Id>, // 모델엔티티 넣으면 됨
   ) {}
 
-  async withTransaction<Result>(
-    callback: (tx: DrizzleTransaction) => Promise<Result>,
-  ): Promise<Result> {
-    return this.db.transaction(callback);
+  /* where 절을 생성하는 메서드
+   * find와 count에서 사용
+   * 상속할 떄 super.makeWhereClause(param, specialKeys) 로 기본적인 쿼리 생성 (id 등)
+   * 추가적인 조건을 추가할 거면 specialKeys에 추가 (ex) duration ... )
+   */
+  protected makeWhereClause(
+    param: BaseRepositoryQuery<Query, Id>,
+    specialKeys?: string[], // 제외할 키들 ex) duration ...
+  ): SQL[] {
+    const whereClause: SQL[] = [];
+
+    whereClause.push(isNull(this.table.deletedAt));
+
+    // 기본 필터링: id와 ids
+    if (param.id) {
+      whereClause.push(eq(this.table.id, param.id));
+    }
+    if (param.ids) {
+      whereClause.push(inArray(this.table.id, param.ids));
+    }
+
+    // Query 타입의 모든 키를 순회하면서 파라미터에 해당 값이 있는지 확인
+    const defaultKeys = ["id", "ids", "pagination", "order"];
+    if (specialKeys) {
+      defaultKeys.push(...specialKeys);
+    }
+
+    Object.keys(param)
+      .filter(key => !defaultKeys.includes(key)) // 기본 키는 제외
+      .forEach(key => {
+        if (key in nestedQueryWrapper) {
+          whereClause.push(
+            this.processNestedQuery(param[key], key as NestedQueryWrapper),
+          );
+        }
+        // 파라미터 값이 존재하는 경우
+        const value = param[key];
+        if (value !== undefined) {
+          // Query 필드를 테이블 필드로 변환
+          const tableField = this.getTableField(key as keyof Query);
+          if (!tableField) {
+            throw new Error(`Invalid query field: ${key}`);
+          }
+
+          // 배열인 경우 IN 연산자 사용
+          if (Array.isArray(value)) {
+            whereClause.push(inArray(tableField, value));
+          }
+          // 복합 조건 객체인 경우 복합 조건 처리 (gt, lt, gte, lte 등)
+          // 예시: { between: [10, 20] }, { gt: 10 }
+          else if (
+            typeof value === "object" &&
+            Object.keys(value).every(k =>
+              mysqlQueryConditionOperators.includes(
+                k as MysqlQueryConditionOperators,
+              ),
+            )
+          ) {
+            whereClause.push(
+              this.processAdvancedOperators(key as keyof Query, value),
+            );
+          }
+          // null 인 경우 isNull 연산자 사용
+          else if (value === null) {
+            whereClause.push(isNull(tableField));
+          }
+          // 단일 값인 경우 eq 연산자 사용
+          else {
+            whereClause.push(eq(tableField, value));
+          }
+        }
+      });
+
+    return whereClause;
   }
 
-  async findTx(tx: DrizzleTransaction, id: number): Promise<M | null> {
-    const result = await tx
+  // find, count는 왠만하면 makeWhereClause를 구현하면 처리 가능
+  async findTx(
+    tx: DrizzleTransaction,
+    param: BaseRepositoryQuery<Query, Id>,
+  ): Promise<Model[]> {
+    let query = tx
       .select()
       .from(this.table)
-      .where(eq(this.table.id, id))
-      .then(rows => rows.map(row => this.modelClass.from(row as D)));
+      .where(and(...this.makeWhereClause(param)))
+      .$dynamic();
 
-    return (result[0] as M) ?? null;
-  }
-
-  async find(id: number): Promise<M | null> {
-    return this.withTransaction(async tx => this.findTx(tx, id));
-  }
+    if (param.pagination) {
+      query = query.limit(param.pagination.itemCount);
+      query = query.offset(
+        (param.pagination.offset - 1) * param.pagination.itemCount,
+      );
+    }
 
-  async fetchTx(tx: DrizzleTransaction, id: number): Promise<M> {
-    const result = await this.findTx(tx, id);
-    if (result === null) {
-      throw new NotFoundException(`Not found: ${this.table} with id: ${id}`);
+    if (param.orderBy) {
+      query = query.orderBy(...this.makeOrderBy(param.orderBy));
     }
-    return result;
-  }
 
-  async fetch(id: number): Promise<M> {
-    return this.withTransaction(async tx => this.fetchTx(tx, id));
-  }
+    const result = await query.execute();
 
-  async fetchAllTx(tx: DrizzleTransaction, ids: number[]): Promise<M[]> {
-    if (ids.length === 0) {
-      return [];
-    }
+    return result.map(row => this.modelClass.from(row as DbResult));
+  }
 
-    const result = await tx
-      .select()
+  async countTx(
+    tx: DrizzleTransaction,
+    param: BaseRepositoryQuery<Query, Id>,
+  ): Promise<number> {
+    const [result] = await tx
+      .select({ count: count() })
       .from(this.table)
-      .where(inArray(this.table.id, ids));
+      .where(and(...this.makeWhereClause(param)));
 
-    return result.map(row => this.modelClass.from(row as D));
+    return result.count;
   }
 
-  async fetchAll(ids: number[]): Promise<M[]> {
-    return this.withTransaction(async tx => this.fetchAllTx(tx, ids));
-  }
+  async createTx(
+    tx: DrizzleTransaction,
+    param: Partial<Model>,
+  ): Promise<Model> {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const modelInstance = new (this.modelClass as any)(param);
 
-  async insertTx(tx: DrizzleTransaction, param: R): Promise<M> {
-    const [result] = await tx.insert(this.table).values(param).execute();
+    const [result] = await tx
+      .insert(this.table)
+      .values(modelInstance.to(OperationType.CREATE) as Table["$inferInsert"])
+      .execute();
 
-    const newId = Number(result.insertId);
+    // insertId를 적절한 타입으로 변환
+    const newId = result.insertId as Id;
 
     return this.fetchTx(tx, newId);
   }
 
-  async insert(param: R): Promise<M> {
-    return this.withTransaction(async tx => this.insertTx(tx, param));
-  }
+  async putTx(
+    tx: DrizzleTransaction,
+    id: Id,
+    param: Partial<Model>,
+  ): Promise<Model> {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const modelInstance = new (this.modelClass as any)(param);
 
-  async putTx(tx: DrizzleTransaction, id: number, param: R): Promise<M> {
     await tx
       .update(this.table)
-      .set(param)
+      .set(modelInstance.to(OperationType.PUT) as Table["$inferInsert"])
       .where(eq(this.table.id, id))
       .execute();
-    return this.fetchTx(tx, id);
-  }
 
-  async put(id: number, param: R): Promise<M> {
-    return this.withTransaction(async tx => this.putTx(tx, id, param));
+    return this.fetchTx(tx, id);
   }
 
   async patchTx(
     tx: DrizzleTransaction,
-    oldbie: M,
-    consumer: (oldbie: M) => M, // eslint-disable-line no-shadow
-  ): Promise<M> {
-    const param = consumer(oldbie);
+    oldId: Id,
+    consumer: (oldbie: Model) => Model,
+  ): Promise<Model> {
+    const param = consumer(await this.fetchTx(tx, oldId));
     await tx
       .update(this.table)
       .set(param)
-      .where(eq(this.table.id, oldbie.id))
+      .where(eq(this.table.id, oldId))
       .execute();
 
-    return this.fetchTx(tx, oldbie.id);
+    return this.fetchTx(tx, oldId);
   }
 
-  // eslint-disable-next-line no-shadow
-  async patch(oldbie: M, consumer: (oldbie: M) => M): Promise<M> {
-    return this.withTransaction(async tx => this.patchTx(tx, oldbie, consumer));
-  }
-
-  async deleteTx(tx: DrizzleTransaction, id: number): Promise<void> {
+  async deleteTx(tx: DrizzleTransaction, id: Id): Promise<void> {
     await tx
       .update(this.table)
       .set({ deletedAt: getKSTDate() })
       .where(eq(this.table.id, id))
       .execute();
   }
 
-  async delete(id: number): Promise<void> {
+  // 상속 후에도 사용하는 헬퍼 메서드
+  async withTransaction<Result>(
+    callback: (tx: DrizzleTransaction) => Promise<Result>,
+  ): Promise<Result> {
+    return this.db.transaction(callback);
+  }
+
+  ///////////////////////////////////////////////////////////////////////////////
+  // 이 아래는 바꿀 필요 없음
+  // 뭐 이럴꺼면 중첩 쿼리도 처리할 수 있게 만들어 주죠?
+  // ㅋㅋ 왠만하면 쓰지 마세요 (gb)
+  protected processNestedQuery(
+    conditions: Record<string, object>,
+    wrapper: NestedQueryWrapper,
+  ): SQL {
+    const whereClause = Object.entries(conditions).map(([key, value]): SQL => {
+      if (key in nestedQueryWrapper) {
+        return this.processNestedQuery(
+          value as Record<string, object>,
+          key as NestedQueryWrapper,
+        );
+      }
+      return this.processAdvancedOperators(
+        key as keyof Query,
+        value as Record<MysqlQueryConditionOperators, unknown>,
+      );
+    });
+    if (wrapper === "and") {
+      return and(...whereClause);
+    }
+    if (wrapper === "or") {
+      return or(...whereClause);
+    }
+    if (wrapper === "not") {
+      if (whereClause.length !== 1) {
+        throw new Error(
+          "Not operator can only be used with a single condition",
+        );
+      }
+      return not(whereClause[0]);
+    }
+    throw new Error(`Invalid wrapper: ${wrapper}`);
+  }
+
+  // 고급 연산자 처리 (gt, lt, gte, lte, like 등)
+  protected processAdvancedOperators(
+    queryField: keyof Query,
+    conditions: Record<MysqlQueryConditionOperators, unknown>,
+  ): SQL {
+    // Query 필드를 테이블 필드로 변환
+    const tableField = this.getTableField(queryField);
+    if (!tableField) {
+      throw new Error(`Invalid query field: ${String(queryField)}`);
+    }
+    if (Object.keys(conditions).length === 0) {
+      throw new Error(`Empty conditions: ${String(conditions)}`);
+    }
+
+    const whereClause: SQL[] = [];
+    // 객체의 키-값 쌍을 forEach로 순회
+    Object.entries(conditions).forEach(([operator, operand]) => {
+      if (operand === undefined || operand === null) {
+        throw new Error(`Invalid operand: ${operand}`);
+      }
+      switch (operator) {
+        case "eq":
+          whereClause.push(eq(tableField, operand));
+          break;
+        case "ne":
+          whereClause.push(ne(tableField, operand));
+          break;
+        case "gt":
+          whereClause.push(gt(tableField, operand));
+          break;
+        case "gte":
+          whereClause.push(gte(tableField, operand));
+          break;
+        case "lt":
+          whereClause.push(lt(tableField, operand));
+          break;
+        case "lte":
+          whereClause.push(lte(tableField, operand));
+          break;
+        case "isNotNull":
+          whereClause.push(isNotNull(tableField));
+          break;
+        case "between":
+          if (!Array.isArray(operand) || operand.length !== 2) {
+            throw new Error(
+              `Invalid operator and operand for between: ${tableField} ${operand}`,
+            );
+          }
+          whereClause.push(between(tableField, operand[0], operand[1]));
+          break;
+        case "like":
+          whereClause.push(like(tableField, `%${String(operand)}%`));
+          break;
+        case "startsWith":
+          whereClause.push(like(tableField, `${String(operand)}%`));
+          break;
+        case "endsWith":
+          whereClause.push(like(tableField, `%${String(operand)}`));
+          break;
+        case "in":
+          if (Array.isArray(operand)) {
+            whereClause.push(inArray(tableField, operand));
+          }
+          break;
+        default:
+          throw new Error(`Invalid operator: ${operator}`);
+      }
+    });
+    return whereClause.length > 1 ? and(...whereClause) : whereClause[0];
+  }
+
+  // 여러 필드에 대한 정렬을 지원하는 구현
+  // find에서 사용
+  protected makeOrderBy(order: Record<string, OrderByTypeEnum>): SQL[] {
path: packages/api/src/common/repository/base.repository.ts
user: login: pbc1017
body: 기본으로 order 필드가 없다면 아이디 오름차순 정렬이 있었으면 좋겠어.
아래 이미지 처럼 뒤죽박죽 나오는데, 매번 orderBy 넣기 귀찮을 것 같아
<img width="413" alt="image" src="https://github.com/user-attachments/assets/d6259ded-9753-4c50-8dc9-ef47701ad814" />

created_at: 2025-03-27T17:03:38Z
author_association: COLLABORATOR

PR comments:
user: login: notion-workspace[bot]
created_at: 2025-03-22T12:08:42Z
body: <a href="https://www.notion.so/Base-Repository-1b0c25603b0b8087a6acef2086d04d9b?pvs=8&n=github_linkback">Base Repository 구현</a>
author_association: NONE
user: login: pbc1017
created_at: 2025-03-26T08:35:57Z
body: > 쭉 봤을때 자세히 안봐서 헷갈리는것일수도 있는데
> 
> * baserepository를 상속받을때 어떤것들을 구현해야 하는지
>   이것만 헷갈린거 같아요 결국 가이드 문서만 있으면 문제없을듯!

구현해야하는 것은 모델 클래스에 modelName, from, to, filedMap 정도가 필요할 것 같고, repository에서는 단일 테이블이라면 generic으로 필요한 타입만 전달해주면 됩니다. entity.model.ts를 보면 한번에 파악이 가능함.
author_association: COLLABORATOR
user: login: pbc1017
created_at: 2025-03-26T08:53:37Z
body: 아 방금 생각났는데 find에서 deletedAt에 not null 조건 추가 필요
author_association: COLLABORATOR