TIL - 20260825

juni·2026년 8월 25일

TIL

목록 보기
439/468

0825 백엔드 아키텍처 고도화 (2/N): Repository Pattern과 Prisma 의존성 관리


✅ 1. Repository Pattern이란 무엇인가?

  • Repository Pattern은 DB 접근 로직을 한 곳에 모아두는 구조입니다.
  • NestJS + Prisma 프로젝트에서는 prisma.consult.findMany, prisma.consult.update, prisma.product.findUnique 같은 코드를 Controller나 Use Case에 직접 흩뿌리지 않고 Repository로 모읍니다.
  • Repository는 비즈니스 로직을 처리하는 곳이 아니라, “데이터를 어떻게 조회하고 저장할지”를 담당하는 계층입니다.
Controller
  ↓
Use Case
  ↓
Repository
  ↓
Prisma
  ↓
PostgreSQL

➕ 1-1. Repository가 필요한 이유

Prisma query가 여러 파일에 흩어지는 것을 방지
Soft Delete 조건 누락 방지
목록 조회 select 기준 통일
상태 변경 query 기준 통일
Transaction 안팎에서 같은 query 재사용
테스트와 리팩토링 범위 축소
AI/Codex 작업 시 수정 위치 명확화
  • 프로젝트가 작을 때는 Service에서 Prisma를 바로 써도 큰 문제가 없습니다.
  • 하지만 상담/상품/관리자/이력/알림/엑셀처럼 도메인이 커지면 Prisma query를 분리하는 편이 유지보수에 좋습니다.

✅ 2. Prisma를 직접 쓰는 구조의 문제

  • Prisma는 사용하기 편해서 어디서든 바로 query를 작성하기 쉽습니다.
  • 하지만 그 편함 때문에 시간이 지나면 같은 조건과 비슷한 query가 여러 곳에 중복됩니다.

➕ 2-1. 나쁜 구조 예시

@Injectable()
export class ConsultService {
  constructor(private readonly prisma: PrismaService) {}

  async getList() {
    return this.prisma.consult.findMany({
      where: {
        deletedAt: null,
      },
    });
  }

  async getDetail(id: number) {
    return this.prisma.consult.findUnique({
      where: {
        id,
      },
      include: {
        product: true,
        statusHistories: true,
      },
    });
  }

  async updateStatus(id: number, status: ConsultStatus) {
    return this.prisma.consult.update({
      where: {
        id,
      },
      data: {
        status,
      },
    });
  }
}

문제:

Service가 DB query를 직접 많이 가짐
목록/상세/상태 변경 기준이 뒤섞임
상태 변경 이력 누락 가능
Soft Delete 조건이 상세 조회에서 빠질 수 있음
select/include 기준이 일관되지 않음

✅ 3. Repository를 둔 구조

  • Repository를 두면 Use Case는 업무 흐름에 집중하고, Repository는 DB 접근에 집중할 수 있습니다.
UpdateConsultStatusUseCase:
상태 변경 업무 흐름 담당

ConsultStatusService:
상태 전이 규칙 담당

ConsultRepository:
consults/status_histories DB 접근 담당

AuditLogRepository:
audit_logs DB 접근 담당

➕ 3-1. 구조 예시

consult/
  application/
    create-consult.use-case.ts
    update-consult-status.use-case.ts
    get-consult-list.use-case.ts

  domain/
    consult-status.service.ts
    duplicate-consult-policy.ts

  infra/
    consult.repository.ts

➕ 3-2. Repository 사용 예시

@Injectable()
export class GetConsultListUseCase {
  constructor(private readonly consultRepository: ConsultRepository) {}

  async execute(query: GetConsultListQuery) {
    return this.consultRepository.findAdminList(query);
  }
}
  • Use Case는 “관리자 상담 목록을 조회한다”는 업무를 실행합니다.
  • 실제 Prisma where, select, orderBy, skip, take는 Repository에 둡니다.

✅ 4. Repository의 책임

  • Repository는 DB 접근 계층입니다.
  • 비즈니스 규칙을 많이 넣으면 Repository도 금방 무거워집니다.

➕ 4-1. Repository가 담당할 것

Prisma query 작성
where/orderBy/select 구성
Soft Delete 조건 적용
pagination query 실행
상태 변경 update query
상태 이력 insert query
transaction client 사용
raw SQL 캡슐화

➕ 4-2. Repository가 담당하지 않을 것

HTTP 요청/응답 처리
권한 판단
상태 전이 규칙 판단
중복 신청 정책 결정
알림톡 발송
Audit Log action 결정
비즈니스 에러 메시지 남발
  • Repository가 “이 상태로 바꿔도 되는지”까지 판단하면 Domain Service와 역할이 섞입니다.
  • Repository는 “바꿔라”는 명령을 받으면 안전한 query로 바꾸는 역할에 가깝습니다.

✅ 5. Repository 메서드 이름 기준

  • Repository 메서드 이름은 query 의도가 보여야 합니다.
  • getData, updateInfo, findAll처럼 모호한 이름은 피하는 것이 좋습니다.

➕ 5-1. 좋은 이름

findAdminList
findDetailForAdmin
findActiveById
findByIdOrThrow
createWithSnapshot
updateStatus
createStatusHistory
findDuplicateCandidate
countByListFilter

➕ 5-2. 나쁜 이름

get
getList
find
update
process
handle
data
list

➕ 5-3. 기준

누가 쓰는 조회인가?
어떤 조건이 포함되는가?
어떤 목적의 update인가?
Soft Delete 조건이 포함되는가?
관리자용인가 고객용인가?
  • findActiveById와 findByIdOrThrow는 의미가 다릅니다.
  • 이름으로 의도를 드러내야 query 조건을 잘못 쓰는 일을 줄일 수 있습니다.

✅ 6. PrismaService 의존성 관리

  • NestJS에서는 보통 PrismaService를 만들어 전역 또는 모듈에서 주입받습니다.
  • Repository는 이 PrismaService를 주입받아 query를 실행합니다.

➕ 6-1. PrismaService 예시

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

➕ 6-2. Repository에서 사용

@Injectable()
export class ConsultRepository {
  constructor(private readonly prisma: PrismaService) {}

  findByIdOrThrow(id: number) {
    return this.prisma.consult.findUniqueOrThrow({
      where: { id },
    });
  }
}

➕ 6-3. 주의

PrismaClient를 여러 번 new 하지 않기
모든 Repository가 같은 PrismaService를 사용
테스트에서 mock 또는 test DB 기준 정하기
connection lifecycle 관리
  • PrismaClient를 여기저기서 새로 만들면 connection 관리가 꼬일 수 있습니다.
  • NestJS에서는 하나의 PrismaService를 주입받는 방식이 기본입니다.

✅ 7. TransactionClient를 받는 Repository

  • Transaction을 Use Case에서 관리하려면 Repository 메서드가 transaction client를 받을 수 있어야 합니다.
  • Prisma의 $transaction(async tx => {}) 안에서는 tx를 사용해야 같은 transaction에 묶입니다.

➕ 7-1. 기본 형태

type PrismaTx = Prisma.TransactionClient;

@Injectable()
export class ConsultRepository {
  constructor(private readonly prisma: PrismaService) {}

  findByIdOrThrow(id: number, tx: PrismaTx = this.prisma) {
    return tx.consult.findUniqueOrThrow({
      where: { id },
    });
  }
}

➕ 7-2. 사용 예시

await this.prisma.$transaction(async (tx) => {
  const consult = await this.consultRepository.findByIdOrThrow(
    consultId,
    tx,
  );

  await this.consultRepository.updateStatus(
    {
      consultId,
      nextStatus,
    },
    tx,
  );
});

➕ 7-3. 장점

transaction 안팎에서 같은 메서드 재사용
Use Case가 transaction boundary 관리
Repository는 전달받은 client로 query 실행
  • 이 패턴은 NestJS + Prisma에서 실무적으로 꽤 유용합니다.
  • 다만 모든 메서드에 tx를 넘기는 것이 번거로울 수 있으므로 프로젝트 컨벤션을 정해두는 것이 좋습니다.

✅ 8. TransactionClient 타입 정리

  • Repository마다 Prisma.TransactionClient를 반복해서 쓰면 코드가 길어질 수 있습니다.
  • 공통 타입으로 분리해두면 좋습니다.

➕ 8-1. 타입 파일 예시

import { Prisma } from '@prisma/client';
import { PrismaService } from './prisma.service';

export type PrismaTx = Prisma.TransactionClient | PrismaService;

➕ 8-2. Repository 예시

@Injectable()
export class ProductRepository {
  constructor(private readonly prisma: PrismaService) {}

  findActiveById(id: number, tx: PrismaTx = this.prisma) {
    return tx.product.findFirst({
      where: {
        id,
        deletedAt: null,
        isActive: true,
      },
    });
  }
}

➕ 8-3. 주의

PrismaService와 TransactionClient는 완전히 같은 타입이 아님
$transaction 같은 메서드는 tx에 없음
Repository 안에서 다시 transaction을 열지 않기
  • Repository는 전달받은 client로 query만 실행해야 합니다.
  • Transaction을 새로 열어야 하는 판단은 Use Case에서 하는 편이 좋습니다.

✅ 9. Repository 안에서 Transaction을 열면 안 될까?

  • 무조건 안 되는 것은 아니지만, 대부분의 복잡한 업무에서는 Use Case에서 transaction을 여는 편이 낫습니다.

➕ 9-1. Repository에서 transaction을 열 때 문제

Use Case가 여러 Repository 작업을 하나로 묶기 어려움
Repository 내부 transaction 범위가 숨겨짐
다른 Repository 작업과 정합성 맞추기 어려움

➕ 9-2. 예시 문제

ConsultRepository.updateStatus()
  내부에서 transaction 시작/commit

AuditLogRepository.create()
  별도 query 실행

문제:
상태 변경은 성공했는데 Audit Log 실패 가능

➕ 9-3. 추천 기준

단일 DB 작업:
Repository

여러 DB 작업을 하나로 묶는 업무:
Use Case에서 transaction

외부 API/Job까지 포함된 흐름:
Use Case에서 DB 작업만 transaction
  • transaction boundary는 업무 흐름을 가장 잘 아는 Use Case가 관리하는 것이 안전합니다.

✅ 10. ConsultRepository 설계 예시

  • 상담 도메인은 관리자 목록, 상세, 상태 변경, 이력 저장, 중복 확인 등 다양한 query가 필요합니다.
@Injectable()
export class ConsultRepository {
  constructor(private readonly prisma: PrismaService) {}

  findAdminList(
    params: {
      where: Prisma.ConsultWhereInput;
      skip: number;
      take: number;
      orderBy: Prisma.ConsultOrderByWithRelationInput[];
    },
    tx: PrismaTx = this.prisma,
  ) {
    return tx.consult.findMany({
      where: params.where,
      skip: params.skip,
      take: params.take,
      orderBy: params.orderBy,
      select: {
        id: true,
        customerName: true,
        phoneMasked: true,
        status: true,
        source: true,
        productNameSnapshot: true,
        carrierSnapshot: true,
        createdAt: true,
        updatedAt: true,
      },
    });
  }

  countAdminList(
    where: Prisma.ConsultWhereInput,
    tx: PrismaTx = this.prisma,
  ) {
    return tx.consult.count({ where });
  }

  findDetailForAdmin(id: number, tx: PrismaTx = this.prisma) {
    return tx.consult.findFirst({
      where: {
        id,
        deletedAt: null,
      },
      include: {
        statusHistories: {
          orderBy: {
            createdAt: 'desc',
          },
          take: 50,
        },
      },
    });
  }

  findByIdOrThrow(id: number, tx: PrismaTx = this.prisma) {
    return tx.consult.findUniqueOrThrow({
      where: { id },
    });
  }

  updateStatus(
    params: {
      consultId: number;
      nextStatus: ConsultStatus;
      lastHandledByAdminId: number;
      changedAt: Date;
    },
    tx: PrismaTx = this.prisma,
  ) {
    return tx.consult.update({
      where: { id: params.consultId },
      data: {
        status: params.nextStatus,
        lastHandledByAdminId: params.lastHandledByAdminId,
        lastStatusChangedAt: params.changedAt,
      },
    });
  }

  createStatusHistory(
    params: {
      consultId: number;
      fromStatus: ConsultStatus;
      toStatus: ConsultStatus;
      changedByAdminId: number;
      reason?: string;
      memo?: string;
    },
    tx: PrismaTx = this.prisma,
  ) {
    return tx.consultStatusHistory.create({
      data: params,
    });
  }
}

➕ 10-1. 설계 포인트

목록은 select 최소화
상세는 include 허용
상태 변경은 updateStatus로 분리
상태 이력 생성도 Repository에 둠
tx를 받아 transaction 안에서 재사용 가능
  • 목록과 상세를 같은 query로 처리하면 성능이 나빠질 수 있습니다.
  • Repository 메서드도 목적별로 분리하는 것이 좋습니다.

✅ 11. ProductRepository 설계 예시

  • 상품은 고객 화면과 관리자 화면에서 조회 기준이 다릅니다.
  • 고객용은 isActive=true, deletedAt=null 조건이 중요하고, 관리자용은 삭제된 데이터 조회도 필요할 수 있습니다.
@Injectable()
export class ProductRepository {
  constructor(private readonly prisma: PrismaService) {}

  findActiveById(id: number, tx: PrismaTx = this.prisma) {
    return tx.product.findFirst({
      where: {
        id,
        isActive: true,
        deletedAt: null,
      },
      include: {
        options: {
          where: {
            isActive: true,
            deletedAt: null,
          },
        },
      },
    });
  }

  findAdminList(
    params: {
      skip: number;
      take: number;
      where: Prisma.ProductWhereInput;
      orderBy: Prisma.ProductOrderByWithRelationInput[];
    },
    tx: PrismaTx = this.prisma,
  ) {
    return tx.product.findMany({
      where: params.where,
      skip: params.skip,
      take: params.take,
      orderBy: params.orderBy,
      select: {
        id: true,
        modelName: true,
        carrier: true,
        isActive: true,
        displayOrder: true,
        deletedAt: true,
        createdAt: true,
      },
    });
  }

  softDelete(
    params: {
      productId: number;
      deletedAt: Date;
      deletedByAdminId: number;
      deleteReason?: string;
    },
    tx: PrismaTx = this.prisma,
  ) {
    return tx.product.update({
      where: {
        id: params.productId,
      },
      data: {
        isActive: false,
        deletedAt: params.deletedAt,
        deletedByAdminId: params.deletedByAdminId,
        deleteReason: params.deleteReason,
      },
    });
  }

  restore(productId: number, tx: PrismaTx = this.prisma) {
    return tx.product.update({
      where: {
        id: productId,
      },
      data: {
        deletedAt: null,
        deletedByAdminId: null,
        deleteReason: null,
      },
    });
  }
}

➕ 11-1. 고객용과 관리자용 구분

고객용 조회:
isActive=true
deletedAt=null
노출 가능한 옵션만

관리자용 조회:
isActive=false도 볼 수 있음
deletedAt 포함 여부 선택
삭제함 조회 가능
  • 고객용 query와 관리자용 query를 구분하지 않으면 삭제된 상품이 고객 화면에 노출될 수 있습니다.
  • Repository 메서드 이름에서 의도를 명확히 드러내는 것이 좋습니다.

✅ 12. AuditLogRepository 설계

  • Audit Log는 여러 Use Case에서 공통으로 사용됩니다.
  • 별도 Repository로 두면 action, actor, target 저장 기준을 일관되게 만들 수 있습니다.
@Injectable()
export class AuditLogRepository {
  constructor(private readonly prisma: PrismaService) {}

  create(
    params: {
      actorType: ActorType;
      actorId?: number;
      action: string;
      targetType: string;
      targetId?: string;
      beforeValue?: Prisma.InputJsonValue;
      afterValue?: Prisma.InputJsonValue;
      requestId?: string;
      ipAddress?: string;
      userAgent?: string;
    },
    tx: PrismaTx = this.prisma,
  ) {
    return tx.auditLog.create({
      data: {
        actorType: params.actorType,
        actorId: params.actorId,
        action: params.action,
        targetType: params.targetType,
        targetId: params.targetId,
        beforeValue: params.beforeValue,
        afterValue: params.afterValue,
        requestId: params.requestId,
        ipAddress: params.ipAddress,
        userAgent: params.userAgent,
      },
    });
  }
}

➕ 12-1. 주의

Repository는 개인정보 마스킹을 자동으로 다 해주지 않음
Use Case에서 before/after에 넣을 값 선별 필요
토큰/Secret/전화번호 원본 저장 금지
  • AuditLogRepository는 저장을 담당합니다.
  • 어떤 값을 저장할지 결정하는 것은 Use Case 또는 별도 AuditLogFactory가 담당하는 편이 좋습니다.

✅ 13. Repository와 Query Builder 함수

  • 목록 조회는 where/orderBy 구성 로직이 길어질 수 있습니다.
  • 이때 Repository 안에 전부 넣기보다 query builder 함수를 분리할 수 있습니다.

➕ 13-1. 예시 구조

consult/
  infra/
    consult.repository.ts
    consult-admin-list-query.builder.ts

➕ 13-2. Query Builder 예시

export function buildConsultAdminListWhere(
  query: ConsultListQuery,
): Prisma.ConsultWhereInput {
  return {
    deletedAt: null,
    ...(query.status && {
      status: query.status,
    }),
    ...(query.source && {
      source: query.source,
    }),
    ...(query.productId && {
      productId: query.productId,
    }),
    ...(query.dateFrom || query.dateTo
      ? {
          createdAt: {
            ...(query.dateFrom && { gte: query.dateFrom }),
            ...(query.dateTo && { lt: query.dateTo }),
          },
        }
      : {}),
  };
}

➕ 13-3. 장점

Repository 파일이 길어지는 것 방지
검색 조건 테스트 가능
목록 조회 조건 재사용 가능
ExportJob 조건과 일치시키기 쉬움
  • 관리자 목록과 엑셀 Export가 같은 검색 조건을 써야 한다면 query builder를 공유하는 것이 좋습니다.
  • 그래야 목록에서 본 조건과 엑셀 다운로드 조건이 어긋나지 않습니다.

✅ 14. Repository와 Mapper의 관계

  • Repository는 DB에서 데이터를 가져오는 곳입니다.
  • 응답 형태로 바꾸는 일은 Mapper가 담당하는 것이 깔끔합니다.
Repository:
DB row 조회

Mapper:
Response DTO로 변환

Controller:
응답 반환

➕ 14-1. Repository에서 응답 DTO까지 만들 때 문제

DB 접근과 응답 가공 책임이 섞임
프론트 응답 요구사항이 바뀌면 Repository 수정
다른 Use Case에서 재사용 어려움

➕ 14-2. Mapper 예시

export function toConsultListItemDto(item: ConsultAdminListItem) {
  return {
    id: item.id,
    customerName: item.customerName,
    phone: item.phoneMasked,
    status: item.status,
    productName: item.productNameSnapshot,
    carrier: item.carrierSnapshot,
    source: item.source,
    createdAt: item.createdAt,
  };
}

➕ 14-3. 기준

Repository:
필요한 DB 필드 조회

Mapper:
프론트에 줄 형태로 변환

Use Case:
Repository 결과를 Mapper로 변환해 반환
  • DB 모델을 그대로 반환하면 내부 컬럼이나 개인정보가 노출될 수 있습니다.
  • Mapper는 보안과 프론트 안정성에 도움이 됩니다.

✅ 15. Repository에서 Soft Delete 조건 관리

  • Soft Delete를 쓰면 deletedAt: null 조건을 누락하지 않는 것이 중요합니다.
  • Repository를 쓰는 가장 큰 장점 중 하나가 이 조건을 한 곳에서 관리할 수 있다는 점입니다.

➕ 15-1. 위험한 조회

return this.prisma.product.findMany();

문제:

삭제된 상품까지 조회됨
고객 화면에 노출될 수 있음
관리자 일반 목록에 삭제된 데이터가 섞일 수 있음

➕ 15-2. Repository 메서드로 명확히 구분

findActiveProducts()
findAdminProducts()
findDeletedProducts()
findRestorableProductById()

➕ 15-3. 기준

고객용:
deletedAt null 필수

관리자 일반 목록:
deletedAt null 기본

삭제함:
deletedAt not null

복구:
deletedAt not null 대상만
  • Soft Delete 조건은 개발자가 매번 기억하면 안 됩니다.
  • Repository 메서드 이름과 내부 조건으로 실수를 줄여야 합니다.

✅ 16. Repository와 권한 체크

  • 권한 체크는 Repository에 넣지 않는 것이 좋습니다.
  • Repository는 DB 접근 담당이고, 권한은 Guard, Policy, Use Case에서 처리하는 편이 명확합니다.

➕ 16-1. 나쁜 예시

async updateProduct(adminId: number, productId: number, data: UpdateProductData) {
  const admin = await this.prisma.adminUser.findUnique({
    where: { id: adminId },
  });

  if (admin.role !== 'SUPER_ADMIN') {
    throw new ForbiddenException();
  }

  return this.prisma.product.update({
    where: { id: productId },
    data,
  });
}

문제:

Repository가 권한 판단까지 담당
테스트 복잡
다른 권한 정책 재사용 어려움
DB 접근과 비즈니스 정책이 섞임

➕ 16-2. 좋은 구조

Use Case:
권한 Policy 호출
  ↓
권한 통과
  ↓
Repository update 호출

➕ 16-3. 기준

Guard:
로그인 여부, 기본 role

Policy:
세부 권한 판단

Use Case:
업무 흐름에서 권한 확인 호출

Repository:
권한 통과 후 DB 작업
  • Repository가 권한을 알기 시작하면 책임이 커집니다.
  • 권한은 다음 회차 Permission Policy에서 더 깊게 다루면 좋습니다.

✅ 17. Repository와 Error 처리

  • Repository에서 모든 에러를 HTTP Exception으로 바꾸는 것은 조심해야 합니다.
  • Repository가 HTTP 계층에 의존하면 재사용성이 떨어집니다.

➕ 17-1. Repository에서 허용할 수 있는 것

findByIdOrThrow(id: number, tx: PrismaTx = this.prisma) {
  return tx.consult.findUniqueOrThrow({
    where: { id },
  });
}

➕ 17-2. Use Case에서 업무 에러로 변환

try {
  const consult = await this.consultRepository.findByIdOrThrow(
    command.consultId,
    tx,
  );
} catch {
  throw new NotFoundException('상담을 찾을 수 없습니다.');
}

➕ 17-3. 기준

Repository:
DB 조회 실패를 그대로 던질 수 있음

Use Case:
업무 의미에 맞는 에러로 변환

Controller:
HTTP 응답 처리
  • 작은 프로젝트에서는 Repository에서 NotFoundException을 던져도 동작은 합니다.
  • 하지만 구조를 깔끔하게 가져가려면 업무 의미는 Use Case에서 해석하는 편이 좋습니다.

✅ 18. Repository와 Raw SQL

  • Prisma로 표현하기 어려운 query는 raw SQL을 사용할 수 있습니다.
  • 하지만 raw SQL은 Repository 안에 숨기고, 사용 이유를 명확히 남기는 것이 좋습니다.

➕ 18-1. Raw SQL 후보

복잡한 통계 query
EXPLAIN 기반으로 튜닝한 목록 query
partial index migration
materialized view refresh
정합성 점검 query

➕ 18-2. Repository 예시

async findStatusConsistencyIssues() {
  return this.prisma.$queryRaw<
    Array<{
      consultId: number;
      currentStatus: string;
      lastHistoryStatus: string;
    }>
  >`
    SELECT c.id AS "consultId",
           c.status AS "currentStatus",
           h.to_status AS "lastHistoryStatus"
    FROM consults c
    LEFT JOIN LATERAL (
      SELECT to_status
      FROM consult_status_histories h
      WHERE h.consult_id = c.id
      ORDER BY h.created_at DESC
      LIMIT 1
    ) h ON true
    WHERE h.to_status IS NOT NULL
      AND c.status <> h.to_status
  `;
}

➕ 18-3. 주의

$queryRawUnsafe 남용 금지
사용자 입력 직접 문자열 삽입 금지
SQL injection 주의
컬럼명 변경 시 깨질 수 있음
테스트 필요
  • raw SQL은 무조건 나쁜 것이 아닙니다.
  • 다만 반드시 Repository 내부에 모으고, 파라미터 바인딩을 안전하게 써야 합니다.

✅ 19. Repository 테스트 전략

  • Repository는 DB 접근 계층이므로 테스트 전략을 분리해야 합니다.
  • Domain Service처럼 순수 단위 테스트만으로는 부족할 수 있습니다.

➕ 19-1. 테스트 종류

Unit Test:
Prisma mock 사용

Integration Test:
Test DB 사용

E2E Test:
API부터 DB까지 전체 흐름 확인

➕ 19-2. Repository 테스트 후보

findActiveById가 deletedAt 상품을 제외하는가?
findAdminList가 select 최소 필드만 반환하는가?
updateStatus가 status와 lastStatusChangedAt을 변경하는가?
createStatusHistory가 이력을 생성하는가?
softDelete가 deletedAt/isActive를 함께 변경하는가?

➕ 19-3. 실무 기준

핵심 Repository만 테스트
Soft Delete 조건 테스트
상태 변경 query 테스트
복잡한 목록 query 테스트
raw SQL은 반드시 테스트
  • 모든 Repository 메서드를 테스트할 필요는 없습니다.
  • 데이터 손실이나 운영 오류와 연결되는 query부터 테스트하면 됩니다.

✅ 20. Repository가 너무 많아질 때

  • 모든 테이블마다 무조건 Repository를 만들면 파일이 과하게 늘어날 수 있습니다.
  • 현재 규모에 맞게 도메인 중심으로 나누는 것이 좋습니다.

➕ 20-1. 좋은 기준

도메인 단위:
ConsultRepository
ProductRepository
AdminUserRepository
AuditLogRepository
ExportJobRepository

단순 설정 테이블:
기존 Service에서 직접 처리하거나 작은 Repository

➕ 20-2. 과한 구조

CarrierRepository
ColorRepository
StorageRepository
TinySettingRepository
EveryTableRepository

➕ 20-3. 현실적인 기준

복잡한 query가 많으면 Repository
여러 Use Case에서 재사용하면 Repository
Soft Delete/권한/이력 조건이 중요하면 Repository
단순 CRUD이고 거의 안 쓰면 과하게 분리하지 않기
  • Repository Pattern도 남용하면 구조가 무거워집니다.
  • 핵심 도메인부터 적용하는 것이 좋습니다.

✅ 21. 현재 프로젝트 우선 적용 후보

➕ 21-1. 1순위: ConsultRepository

이유:
상담 목록 조회
상담 상세 조회
상태 변경
상태 이력
중복 신청 확인
개인정보 select 관리

적용 효과:

상담 관련 Prisma query 정리
목록/상세 query 분리
상태 변경 transaction에 재사용
Soft Delete/개인정보 조건 관리

➕ 21-2. 2순위: ProductRepository

이유:
고객용 상품 조회
관리자용 상품 조회
상품/옵션 soft delete
상품 snapshot 생성
노출 여부 관리

적용 효과:

고객용/관리자용 query 분리
삭제된 상품 노출 방지
상담 신청 시 active 상품 확인

➕ 21-3. 3순위: AuditLogRepository

이유:
상태 변경
상품 수정
삭제/복구
엑셀 다운로드
관리자 권한 변경

적용 효과:

Audit Log 저장 기준 통일
requestId/actor/target 구조 일관화
운영 추적성 개선

➕ 21-4. 4순위: ExportJobRepository

이유:
엑셀 다운로드 요청
작업 상태 변경
Worker 처리
실패 사유 저장
파일 만료 처리

적용 효과:

Queue/Worker 구조와 연결
ExportJob 상태 관리 일관화
개인정보 파일 만료 정책 적용 쉬움
  • 모든 도메인에 한 번에 Repository를 붙이려고 하지 않는 것이 좋습니다.
  • 상담, 상품, Audit Log, ExportJob 순서가 현실적입니다.

✅ 22. AI/Codex에게 Repository 리팩토링을 맡길 때 규칙

  • Repository 리팩토링은 AI/Codex에게 맡기기 좋은 작업입니다.
  • 하지만 기준 없이 맡기면 오히려 파일만 늘어나고 구조가 이상해질 수 있습니다.

➕ 22-1. Codex 요청 예시

Consult 관련 Prisma query를 ConsultRepository로 분리해줘.

조건:
1. Controller에는 Prisma query를 직접 두지 마
2. Use Case는 ConsultRepository를 호출하게 해줘
3. 상담 목록 조회는 findAdminList로 분리해줘
4. 상담 상세 조회는 findDetailForAdmin으로 분리해줘
5. 상태 변경은 updateStatus로 분리해줘
6. 상태 이력 생성은 createStatusHistory로 분리해줘
7. Repository 메서드는 transaction client를 선택적으로 받을 수 있게 해줘
8. 목록 조회는 select로 필요한 필드만 가져오게 해줘
9. phoneNormalized, token, Secret은 응답에 노출하지 마
10. 기존 API 응답이 깨지지 않게 Mapper가 필요하면 추가해줘
11. 변경 후 수정 파일 목록, 위험 요소, 테스트/QA 체크리스트를 정리해줘

➕ 22-2. 검토 기준

Repository에 비즈니스 규칙을 넣지 않았는가?
Use Case의 transaction이 유지되는가?
Soft Delete 조건이 누락되지 않았는가?
목록 select가 과하게 넓어지지 않았는가?
기존 API 응답이 바뀌지 않았는가?
타입 에러가 없는가?
테스트 또는 최소 QA가 가능한가?
  • AI에게는 “무엇을 어디로 옮길지”를 명확히 줘야 합니다.
  • 단순히 “Repository Pattern으로 바꿔줘”는 범위가 너무 넓습니다.

✅ 23. 실무 체크리스트

➕ 23-1. Repository 설계 체크리스트

  • 핵심 도메인부터 Repository를 적용하는가?
  • Repository가 DB 접근만 담당하는가?
  • 비즈니스 규칙이 Repository에 들어가지 않는가?
  • 메서드 이름이 조회/변경 의도를 설명하는가?
  • 고객용/관리자용 조회가 구분되는가?
  • 목록/상세 조회가 구분되는가?
  • Soft Delete 조건이 일관되게 적용되는가?
  • raw SQL은 Repository 내부에 모여 있는가?

➕ 23-2. Prisma 의존성 체크리스트

  • PrismaClient를 여러 곳에서 직접 new 하지 않는가?
  • PrismaService를 주입받아 사용하는가?
  • Repository가 PrismaService에만 의존하는가?
  • Use Case가 Prisma query를 직접 남발하지 않는가?
  • transaction client를 Repository에 전달할 수 있는가?
  • Repository 안에서 불필요하게 transaction을 새로 열지 않는가?
  • query log는 개발/스테이징에서만 사용하는가?
  • 운영 query 로그에 개인정보가 남지 않는가?

➕ 23-3. Transaction 체크리스트

  • transaction boundary가 Use Case에 있는가?
  • 여러 Repository 작업이 같은 tx를 사용하는가?
  • 상태 변경과 이력 저장이 같은 transaction인가?
  • Audit Log 저장이 필요한 작업에서 함께 묶이는가?
  • 외부 API 호출은 transaction 밖으로 빠져 있는가?
  • Repository 내부 숨은 transaction이 없는가?
  • tx 타입이 프로젝트 전체에서 일관되는가?
  • transaction 실패 시 rollback 범위가 명확한가?

➕ 23-4. 성능/보안 체크리스트

  • 목록 query는 select 최소화가 되어 있는가?
  • 상세 query에서만 include를 넓게 쓰는가?
  • N+1 query가 발생하지 않는가?
  • 개인정보 컬럼이 불필요하게 조회되지 않는가?
  • 전화번호 원본을 응답하지 않는가?
  • Audit Log before/after에 민감정보를 넣지 않는가?
  • 검색 조건과 index 후보가 맞는가?
  • Repository 변경 후 EXPLAIN 확인이 필요한 query가 있는가?

✅ 24. AI에게 Repository Pattern 설계를 물어볼 때 좋은 질문법

NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰 백엔드에서 Repository Pattern을 적용하려고 해.

서비스 상황:
1. 상담 신청, 상담 목록 조회, 상담 상태 변경, 상태 이력 저장 로직이 있음
2. 상품은 고객용 조회와 관리자용 조회가 다르고, soft delete와 isActive 조건이 있음
3. 상담 상태 변경은 consults.status 업데이트, consult_status_histories 생성, audit_logs 생성을 하나의 transaction으로 묶어야 함
4. 관리자 목록은 page/limit, status/source/date 필터, createdAt desc + id desc 정렬을 사용함
5. 목록에서는 phoneMasked만 응답하고 phoneNormalized는 노출하면 안 됨
6. Audit Log는 여러 Use Case에서 공통으로 사용함
7. ExportJob은 Worker와 연결될 예정임
8. Prisma query가 Service 곳곳에 흩어지는 것을 줄이고 싶음

요청:
- Repository Pattern 적용 기준
- ConsultRepository 메서드 설계
- ProductRepository 메서드 설계
- AuditLogRepository 메서드 설계
- transaction client를 Repository에 전달하는 방식
- Repository와 Use Case의 책임 구분
- Repository와 Domain Service의 책임 구분
- Soft Delete 조건 관리 방법
- 목록 query select 최소화 기준
- raw SQL을 Repository에 둘 때 주의사항
- AI/Codex 리팩토링 요청 프롬프트
를 실무 기준으로 정리해줘.

➕ 24-1. AI 답변 검증 기준

Repository에 비즈니스 규칙을 과하게 넣지 않는가?
Use Case가 transaction boundary를 갖게 하는가?
Repository가 tx를 받을 수 있게 설계하는가?
Soft Delete 조건 누락 위험을 다루는가?
고객용/관리자용 조회를 구분하는가?
목록/상세 query를 분리하는가?
phoneNormalized 같은 내부 컬럼 노출을 경고하는가?
Prisma raw SQL의 injection 위험을 언급하는가?
현재 규모에 비해 Repository를 과하게 쪼개지 않는가?

📌 요약

  • Repository Pattern은 Prisma query를 Controller나 Use Case 곳곳에 흩뿌리지 않고, DB 접근 로직을 도메인별 Repository에 모으는 구조입니다.
  • Repository는 비즈니스 규칙을 판단하는 곳이 아니라, 조회 조건, select, update, insert, Soft Delete 조건, raw SQL 같은 DB 접근 기준을 관리하는 계층입니다.
  • NestJS + Prisma에서는 하나의 PrismaService를 주입받아 사용하고, Repository가 이 PrismaService를 통해 query를 실행하는 방식이 기본입니다.
  • Use Case에서 transaction을 열고 Repository 메서드에 tx를 전달하면 여러 Repository 작업을 하나의 transaction으로 묶을 수 있습니다.
  • Repository 내부에서 transaction을 숨겨서 열면 Use Case가 상태 변경, 상태 이력, Audit Log를 하나로 묶기 어려워질 수 있으므로 주의해야 합니다.
  • ConsultRepository는 상담 목록, 상세, 상태 변경, 상태 이력, 중복 신청 확인처럼 상담 관련 query를 모으는 핵심 Repository가 될 수 있습니다.
  • ProductRepository는 고객용 active 상품 조회와 관리자용 상품 조회, soft delete, restore를 구분해야 합니다.
  • AuditLogRepository는 여러 Use Case에서 공통으로 쓰이며, actor/action/target/requestId 저장 기준을 일관되게 만드는 데 도움이 됩니다.
  • Soft Delete를 쓰는 테이블은 Repository 메서드에서 deletedAt: null, deletedAt not null 조건을 명확히 구분해야 삭제된 데이터 노출을 막을 수 있습니다.
  • Repository는 권한 판단, 상태 전이 규칙, 알림톡 발송 같은 비즈니스/외부 연동 로직을 담당하지 않는 것이 좋습니다.
  • AI/Codex에게 Repository 리팩토링을 맡길 때는 어떤 query를 어떤 Repository 메서드로 옮길지, transaction client를 어떻게 받을지, 응답 필드와 개인정보 노출 금지 기준을 명확히 지시해야 합니다.

0개의 댓글