From c89ab1906f90cc7a5349dc6d64eb661ad95328e6 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 1 Jun 2026 02:32:21 +0900 Subject: [PATCH] =?UTF-8?q?refactor(prisma):=20PrismaService=20=EC=BA=90?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=EC=A0=9C=EA=B1=B0=20+=20abstract=20class?= =?UTF-8?q?=20DI=20=ED=86=A0=ED=81=B0=20+=20useFactory=20(P2-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존: PrismaClient 상속 + 생성자 return + 'as PrismaService' 캐스팅 (타입 함정). 신규: abstract class extends PrismaClient + useFactory. - prisma.service.ts - createExtendedPrismaClient() factory 함수 - PrismaService: abstract class extends PrismaClient - abstract 라 `new PrismaService()` compile error → 잘못된 인스턴스화 원천 차단 - extends PrismaClient 로 model accessor / $connect 등 API 타입 노출 - declaration merging 불필요, ESLint 룰 disable 0, 캐스팅 0 - 생성자 return 트릭 / 'as PrismaService' / dead 메서드 모두 제거 - prisma.module.ts - useFactory 로 PrismaService 토큰에 factory 결과 등록 - 모듈이 OnModuleInit/OnModuleDestroy 구현 (라이프사이클 owner) - prisma.service.spec.ts - factory 직접 호출 + softDelete 자동 필터 검증 - PrismaModule init/destroy 시 $connect/$disconnect 호출 검증 - PrismaService 토큰 주입 인스턴스의 모델 accessor 노출 검증 callsite 영향 0 (33 파일 그대로) — abstract class 의 TS 타입 그대로, NestJS DI 가 factory 결과를 토큰에 매핑. 검토 과정에서 (a) Symbol+@Inject 33 곳, (a') class+interface merging+rule disable, (a'') abstract class extends 셋 비교 후 (a'') 가 disable/캐스팅/매직 패턴 모두 0 인 최적안임을 확인. --- src/prisma/prisma.module.ts | 36 ++++++-- src/prisma/prisma.service.spec.ts | 140 +++++++++++++++++++----------- src/prisma/prisma.service.ts | 52 ++++------- 3 files changed, 135 insertions(+), 93 deletions(-) diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts index 9b6c73d..b7fc1dd 100644 --- a/src/prisma/prisma.module.ts +++ b/src/prisma/prisma.module.ts @@ -1,13 +1,39 @@ -import { Global, Module } from '@nestjs/common'; +import { + Global, + Module, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; -import { PrismaService } from '@/prisma/prisma.service'; +import { + createExtendedPrismaClient, + PrismaService, +} from '@/prisma/prisma.service'; /** - * Prisma 모듈 + * Prisma 모듈. + * + * - PrismaService 토큰은 useFactory 로 확장(soft-delete) 적용된 PrismaClient 인스턴스를 제공한다. + * - 클라이언트의 connect/disconnect 라이프사이클은 본 모듈이 소유한다. */ @Global() @Module({ - providers: [PrismaService], + providers: [ + { + provide: PrismaService, + useFactory: createExtendedPrismaClient, + }, + ], exports: [PrismaService], }) -export class PrismaModule {} +export class PrismaModule implements OnModuleInit, OnModuleDestroy { + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + await this.prisma.$connect(); + } + + async onModuleDestroy(): Promise { + await this.prisma.$disconnect(); + } +} diff --git a/src/prisma/prisma.service.spec.ts b/src/prisma/prisma.service.spec.ts index 882d34c..3fd5448 100644 --- a/src/prisma/prisma.service.spec.ts +++ b/src/prisma/prisma.service.spec.ts @@ -1,4 +1,10 @@ -import { PrismaService } from '@/prisma/prisma.service'; +import { Test } from '@nestjs/testing'; + +import { PrismaModule } from '@/prisma/prisma.module'; +import { + createExtendedPrismaClient, + PrismaService, +} from '@/prisma/prisma.service'; import { disconnectTestPrismaClient, getTestDatabaseUrl, @@ -7,14 +13,17 @@ import { import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; /** - * PrismaService는 기본 DATABASE_URL을 사용하는 Prisma 클라이언트. - * 테스트에서는 프로세스의 DATABASE_URL을 test container URL로 치환한 뒤 생성한다. + * Prisma 모듈/서비스 — useFactory + class+interface declaration merging 패턴 검증. + * + * - createExtendedPrismaClient: 확장(soft-delete) 적용된 PrismaClient 인스턴스 생성 + * - PrismaModule: 인스턴스 라이프사이클 (connect/disconnect) 소유 + * - DI 토큰으로 사용된 PrismaService 클래스 → 실제 주입되는 인스턴스는 factory 반환값 */ -describe('PrismaService (real DB)', () => { +describe('Prisma (real DB)', () => { let originalDatabaseUrl: string | undefined; beforeAll(async () => { - // test container가 기동되도록 한 번 호출하여 URL 확보 + // test container 기동 + DATABASE_URL 치환 (createExtendedPrismaClient 가 이 URL 을 사용) await getTestPrismaClient(); originalDatabaseUrl = process.env.DATABASE_URL; process.env.DATABASE_URL = getTestDatabaseUrl(); @@ -31,56 +40,83 @@ describe('PrismaService (real DB)', () => { await truncateAll(); }); - it('onModuleInit이 connect를 수행하고 쿼리 실행이 가능하다', async () => { - const service = new PrismaService(); - await service.onModuleInit(); - try { - // softDelete extension이 적용되어 있으면 Account 쿼리가 정상 작동 - const count = await service.account.count(); - expect(typeof count).toBe('number'); - } finally { - await service.onModuleDestroy(); - } - }); + describe('createExtendedPrismaClient', () => { + it('확장 적용된 Prisma 클라이언트를 반환한다 + 기본 쿼리 동작', async () => { + const client = createExtendedPrismaClient(); + await client.$connect(); + try { + const count = await client.account.count(); + expect(typeof count).toBe('number'); + } finally { + await client.$disconnect(); + } + }); + + it('softDelete 확장이 적용되어 deleted_at 이 null 인 row 만 자동 필터한다', async () => { + const client = createExtendedPrismaClient(); + await client.$connect(); + try { + const active = await client.account.create({ + data: { + account_type: 'USER', + status: 'ACTIVE', + email: 'active@test.com', + name: 'A', + }, + }); + await client.account.create({ + data: { + account_type: 'USER', + status: 'ACTIVE', + email: 'deleted@test.com', + name: 'D', + deleted_at: new Date(), + }, + }); - it('onModuleDestroy가 disconnect까지 수행한다 (연속 호출해도 throw 없음)', async () => { - const service = new PrismaService(); - await service.onModuleInit(); - await service.onModuleDestroy(); - // 재호출이 no-op처럼 동작하는지 (Prisma는 idempotent disconnect) - await expect(service.onModuleDestroy()).resolves.not.toThrow(); + const found = await client.account.findMany({ + where: { email: { in: ['active@test.com', 'deleted@test.com'] } }, + }); + // 자동으로 deleted_at: null 필터가 주입되어 active 만 반환 + expect(found.map((a) => a.id)).toEqual([active.id]); + } finally { + await client.$disconnect(); + } + }); }); - it('생성된 인스턴스에 softDelete extension이 적용되어 있다 (deleted_at 자동 필터)', async () => { - const service = new PrismaService(); - await service.onModuleInit(); - try { - // soft-delete된 account를 생성하고 findFirst로는 안 나오는 것을 확인 - const active = await service.account.create({ - data: { - account_type: 'USER', - status: 'ACTIVE', - email: 'active@test.com', - name: 'A', - }, - }); - await service.account.create({ - data: { - account_type: 'USER', - status: 'ACTIVE', - email: 'deleted@test.com', - name: 'D', - deleted_at: new Date(), - }, - }); + describe('PrismaModule (라이프사이클 owner)', () => { + it('모듈 init 시 $connect, destroy 시 $disconnect 가 호출된다', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [PrismaModule], + }).compile(); + // compile 단계에서 useFactory 가 즉시 호출되어 클라이언트 인스턴스가 생성된다. + + const prisma = moduleRef.get(PrismaService); + const connectSpy = jest.spyOn(prisma, '$connect'); + const disconnectSpy = jest.spyOn(prisma, '$disconnect'); + + // init/close 를 호출하면 모듈 라이프사이클 훅이 동작한다. + await moduleRef.init(); + expect(connectSpy).toHaveBeenCalledTimes(1); + + await moduleRef.close(); + expect(disconnectSpy).toHaveBeenCalledTimes(1); + }); - const found = await service.account.findMany({ - where: { email: { in: ['active@test.com', 'deleted@test.com'] } }, - }); - // softDelete extension이 자동으로 deleted_at: null 필터를 붙이므로 active만 반환 - expect(found.map((a) => a.id)).toEqual([active.id]); - } finally { - await service.onModuleDestroy(); - } + it('PrismaService 토큰으로 주입된 인스턴스는 확장 적용된 클라이언트이다 (account 모델 접근 가능)', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [PrismaModule], + }).compile(); + await moduleRef.init(); + try { + const prisma = moduleRef.get(PrismaService); + // 확장 클라이언트는 모든 model accessor 를 그대로 노출한다. + expect(typeof prisma.account.count).toBe('function'); + await prisma.account.count(); + } finally { + await moduleRef.close(); + } + }); }); }); diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 61eb583..d05cb4e 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,43 +1,23 @@ -import { - Injectable, - type OnModuleInit, - type OnModuleDestroy, -} from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { softDeleteExtension } from '@/prisma/soft-delete.middleware'; /** - * Prisma 클라이언트를 NestJS DI 컨테이너에 제공하는 서비스 + * 확장(soft-delete) 이 적용된 Prisma 클라이언트를 생성한다. + * + * NestJS provider 의 `useFactory` 로 호출되어 단일 인스턴스를 만든다. + * 라이프사이클(connect/disconnect) 은 PrismaModule 이 owner. */ -@Injectable() -export class PrismaService - extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ - constructor() { - super(); - const extended = this.$extends(softDeleteExtension) as PrismaService; - extended.onModuleInit = async () => { - await extended.$connect(); - }; - extended.onModuleDestroy = async () => { - await extended.$disconnect(); - }; - return extended; - } - - /** - * 모듈 초기화 시 Prisma 클라이언트 연결 - */ - async onModuleInit(): Promise { - await this.$connect(); - } - - /** - * 모듈 종료 시 Prisma 클라이언트 연결 해제 - */ - async onModuleDestroy(): Promise { - await this.$disconnect(); - } +export function createExtendedPrismaClient() { + return new PrismaClient().$extends(softDeleteExtension); } + +/** + * NestJS DI 토큰 역할의 abstract class. + * + * - `new PrismaService()` 호출은 abstract 라 compile error → 잘못된 인스턴스화 원천 차단 + * - `extends PrismaClient` 로 model accessor 등 API 타입을 그대로 노출 + * → callsite 는 `prisma.account.findMany(...)` 등을 그대로 사용 + * - 실제 주입되는 인스턴스는 PrismaModule 의 `useFactory` 가 반환한 `createExtendedPrismaClient()` 결과 + */ +export abstract class PrismaService extends PrismaClient {}