본문으로 건너뛰기

CoreDatabaseModule 구현 가이드라인

1. 개요

CoreDatabaseModule은 DTA-WIDE 프로젝트의 데이터베이스 연결 및 Prisma ORM 설정을 담당하는 핵심 라이브러리입니다. Prisma를 사용하여 데이터베이스 액세스를 추상화하고, 모든 도메인 모듈에서 일관된 데이터베이스 접근 방식을 제공합니다.

2. 설계 원칙

  • 단일 책임 원칙: 데이터베이스 연결 관리만 담당
  • 타입 안전성: Prisma의 타입 시스템을 활용한 강력한 타입 안전성 제공
  • 확장성: 다양한 데이터베이스 시스템 지원
  • 재사용성: 여러 도메인에서 재사용 가능한 구조
  • 트랜잭션 관리: 일관된 트랜잭션 관리 제공
  • 마이그레이션 지원: 데이터베이스 스키마 변경 관리

3. 주요 컴포넌트

3.1 PrismaService

Prisma 클라이언트 인스턴스를 제공하는 서비스

3.2 DatabaseService

데이터베이스 연결 및 작업을 관리하는 서비스

3.3 TransactionManager

트랜잭션 관리를 위한 유틸리티

3.4 MigrationService

데이터베이스 스키마 마이그레이션 관리

4. 구현 가이드

4.1 프로젝트 구조

libs/core/database/
├── src/
│ ├── lib/
│ │ ├── database.module.ts
│ │ ├── prisma.service.ts
│ │ ├── database.service.ts
│ │ ├── transaction/
│ │ │ ├── transaction.manager.ts
│ │ │ └── transaction.decorator.ts
│ │ ├── migration/
│ │ │ ├── migration.service.ts
│ │ │ └── migration.runner.ts
│ │ ├── utils/
│ │ │ ├── connection.util.ts
│ │ │ └── query.util.ts
│ │ └── index.ts
│ └── index.ts
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ └── seeds/
└── test/

4.2 Prisma 설정

schema.prisma:

// libs/core/database/prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}

// 공통 모델 정의...
model Example {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

4.3 PrismaService 구현

// libs/core/database/src/lib/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@core/config';

@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor(private readonly configService: ConfigService) {
super({
datasources: {
db: {
url: configService.getRequired<string>('DATABASE_URL'),
},
},
log: configService.isDevelopment()
? ['query', 'info', 'warn', 'error']
: ['error'],
});
}

async onModuleInit() {
await this.$connect();
}

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

async enableShutdownHooks(app: any) {
// 애플리케이션 종료 시 정상적으로 DB 연결 종료
this.$on('beforeExit', async () => {
await app.close();
});
}

async cleanDatabase() {
// 테스트용 데이터베이스 정리
if (this.configService.isTest()) {
const modelNames = Reflect.ownKeys(this).filter((key) => {
return key[0] !== '_' && key[0] !== '$' && key !== 'configService';
});

return Promise.all(
modelNames.map((modelName) => {
return this[modelName as string].deleteMany();
}),
);
}
}
}

4.4 DatabaseService 구현

// libs/core/database/src/lib/database.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';

/**
* DatabaseService는 데이터베이스 연결 상태 확인 등
* Prisma 클라이언트를 직접 사용하는 것 이상의 추가적인 기능을 제공할 수 있습니다.
* 일반적인 리포지토리나 서비스에서는 PrismaService를 직접 주입하는 것을 권장하지만,
* DB 헬스 체크 등이 필요할 때 이 서비스를 사용할 수 있습니다.
*/
@Injectable()
export class DatabaseService {
constructor(private readonly prisma: PrismaService) {}

/**
* Prisma 클라이언트 인스턴스를 반환합니다.
* (참고: 보통 서비스/리포지토리에서는 PrismaService를 직접 주입합니다)
*/
getPrismaClient() {
return this.prisma;
}

/**
* 데이터베이스 헬스 체크를 수행합니다.
*/
async healthCheck(): Promise<boolean> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return true;
} catch (error) {
// 헬스 체크 실패 시 로깅 추가 고려
console.error('Database health check failed:', error);
return false;
}
}
}

4.5 트랜잭션 관리

// libs/core/database/src/lib/transaction/transaction.manager.ts
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma.service';

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

/**
* 트랜잭션 내에서 콜백 함수를 실행합니다.
*/
async runInTransaction<T>(
callback: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
return this.prisma.$transaction(async (tx) => {
return callback(tx);
});
}

/**
* 트랜잭션 내에서 여러 작업을 배치로 처리합니다.
*/
async runInBatchTransaction<T>(
operations: Prisma.PrismaPromise<any>[],
): Promise<any[]> {
return this.prisma.$transaction(operations);
}
}

// libs/core/database/src/lib/transaction/transaction.decorator.ts
import { applyDecorators, SetMetadata } from '@nestjs/common';

export const TRANSACTION_KEY = 'TRANSACTION';

export function Transaction() {
return applyDecorators(SetMetadata(TRANSACTION_KEY, true));
}

4.6 마이그레이션 서비스

// libs/core/database/src/lib/migration/migration.service.ts
import { Injectable } from '@nestjs/common';
import { exec } from 'child_process';
import { promisify } from 'util';
import { ConfigService } from '@core/config';

const execAsync = promisify(exec);

@Injectable()
export class MigrationService {
constructor(private readonly configService: ConfigService) {}

/**
* 마이그레이션을 실행합니다.
*/
async runMigrations(): Promise<string> {
try {
const { stdout } = await execAsync('npx prisma migrate deploy');
return stdout;
} catch (error) {
throw new Error(`마이그레이션 실행 중 오류 발생: ${error.message}`);
}
}

/**
* 새 마이그레이션을 생성합니다.
*/
async createMigration(name: string): Promise<string> {
if (this.configService.isProduction()) {
throw new Error('프로덕션 환경에서는 마이그레이션을 생성할 수 없습니다.');
}

try {
const { stdout } = await execAsync(
`npx prisma migrate dev --name ${name} --create-only`,
);
return stdout;
} catch (error) {
throw new Error(`마이그레이션 생성 중 오류 발생: ${error.message}`);
}
}

/**
* 마이그레이션 이력을 조회합니다.
*/
async getMigrationHistory(): Promise<string> {
try {
const { stdout } = await execAsync('npx prisma migrate status');
return stdout;
} catch (error) {
throw new Error(`마이그레이션 이력 조회 중 오류 발생: ${error.message}`);
}
}
}

4.7 모듈 구현

// libs/core/database/src/lib/database.module.ts
import { DynamicModule, Module, Global } from '@nestjs/common';
import { ConfigModule } from '@core/config';
import { PrismaService } from './prisma.service';
import { DatabaseService } from './database.service';
import { TransactionManager } from './transaction/transaction.manager';
import { MigrationService } from './migration/migration.service';

@Global()
@Module({})
export class CoreDatabaseModule {
static forRoot(): DynamicModule {
return {
module: CoreDatabaseModule,
imports: [ConfigModule],
providers: [
PrismaService,
DatabaseService,
TransactionManager,
MigrationService,
],
exports: [
PrismaService,
DatabaseService,
TransactionManager,
MigrationService,
],
};
}
}

5. 사용 예시

5.1 모듈에서 사용

// apps/dta-wide-api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { CoreConfigModule } from '@core/config';
import { CoreDatabaseModule } from '@core/database';

@Module({
imports: [
CoreConfigModule.forRoot(),
CoreDatabaseModule.forRoot(),
// 다른 모듈 임포트...
],
})
export class AppModule {}

5.2 서비스에서 사용

서비스나 리포지토리에서는 일반적으로 PrismaService를 직접 주입하여 사용합니다. 트랜잭션 관리가 필요한 경우, TransactionManager를 추가로 주입하여 사용할 수 있습니다.

// libs/feature/user/src/lib/user.service.ts
import { Injectable } from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import { PrismaService, TransactionManager } from '@core/database';

@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly transactionManager: TransactionManager,
) {}

async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: { id },
});
}

async createUser(data: Prisma.UserCreateInput): Promise<User> {
return this.transactionManager.runInTransaction(async (tx) => {
const user = await tx.user.create({
data,
});

await tx.profile.create({
data: {
userId: user.id,
},
});

return user;
});
}

async createMultipleUsers(users: Prisma.UserCreateInput[]): Promise<User[]> {
const operations = users.map((userData) =>
this.prisma.user.create({ data: userData })
);

return this.transactionManager.runInBatchTransaction(operations);
}
}

6. 마이그레이션 관리

마이그레이션 관리에 대한 상세 내용은 prisma-migration-guide.md 문서를 참조하세요. 이 문서에는 아래 내용이 포함되어 있습니다:

  • 마이그레이션 명령어 사용법
  • 개발 및 프로덕션 환경에서의 마이그레이션 전략
  • 자동화된 마이그레이션 실행 방법
  • 마이그레이션 문제 해결 가이드

7. 테스트 전략

7.1 테스트 설정

// libs/core/database/test/test-database.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@core/config';
import { PrismaService } from '../src/lib/prisma.service';
import { DatabaseService } from '../src/lib/database.service';
import { TransactionManager } from '../src/lib/transaction/transaction.manager';

@Module({
imports: [
ConfigModule.forRoot({
envFilePath: '.env.test',
}),
],
providers: [PrismaService, DatabaseService, TransactionManager],
exports: [PrismaService, DatabaseService, TransactionManager],
})
export class TestDatabaseModule {}

7.2 단위 테스트

// libs/core/database/test/database.service.spec.ts
import { Test } from '@nestjs/testing';
import { DatabaseService } from '../src/lib/database.service';
import { PrismaService } from '../src/lib/prisma.service';
import { ConfigService } from '@core/config';

describe('DatabaseService', () => {
let service: DatabaseService;
let prismaService: PrismaService;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
DatabaseService,
{
provide: PrismaService,
useValue: {
$queryRaw: jest.fn().mockResolvedValue([{ result: 1 }]),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn(),
getRequired: jest.fn(),
isDevelopment: jest.fn().mockReturnValue(false),
isTest: jest.fn().mockReturnValue(true),
},
},
],
}).compile();

service = moduleRef.get<DatabaseService>(DatabaseService);
prismaService = moduleRef.get<PrismaService>(PrismaService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

it('should perform health check', async () => {
const result = await service.healthCheck();
expect(result).toBe(true);
expect(prismaService.$queryRaw).toHaveBeenCalled();
});
});

7.3 통합 테스트

// libs/feature/user/test/user.service.spec.ts
import { Test } from '@nestjs/testing';
import { UserService } from '../src/lib/user.service';
import { TestDatabaseModule } from '@core/database/test';
import { DatabaseService, TransactionManager } from '@core/database';

describe('UserService (Integration)', () => {
let service: UserService;
let databaseService: DatabaseService;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [TestDatabaseModule],
providers: [UserService],
}).compile();

service = moduleRef.get<UserService>(UserService);
databaseService = moduleRef.get<DatabaseService>(DatabaseService);

// 테스트 전 데이터베이스 초기화
await databaseService.getPrismaClient().cleanDatabase();
});

afterAll(async () => {
// 모든 테스트 완료 후 연결 종료
await databaseService.getPrismaClient().$disconnect();
});

it('should create a user with profile', async () => {
const user = await service.createUser({
email: 'test@example.com',
name: 'Test User',
});

expect(user).toBeDefined();
expect(user.email).toBe('test@example.com');

// 연관된 프로필 확인
const profile = await databaseService.getPrismaClient().profile.findUnique({
where: { userId: user.id },
});

expect(profile).toBeDefined();
expect(profile.userId).toBe(user.id);
});
});

8. 모범 사례

8.1 Prisma 모델 정의 가이드라인

  • 모든 테이블에 id, createdAt, updatedAt 필드를 포함합니다.
  • 관계는 명시적으로 정의하고 외래 키 제약 조건을 설정합니다.
  • 인덱스를 적절히 활용하여 성능을 최적화합니다.
  • 열거형은 가능한 Prisma 스키마 내에서 정의합니다.
// 모범 사례 예시
model User {
id String @id @default(uuid())
email String @unique
name String
role Role @default(USER)
posts Post[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([email, name])
}

model Profile {
id String @id @default(uuid())
bio String?
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

enum Role {
USER
ADMIN
EDITOR
}

8.2 성능 최적화 팁

  1. 필요한 필드만 선택: select 절을 사용하여 필요한 필드만 가져옵니다.
  2. 페이지네이션 활용: 대량의 데이터는 takeskip을 사용하여 페이지네이션 처리합니다.
  3. 관계 데이터 필요시에만 로드: include를 필요한 경우에만 사용합니다.
  4. 인덱스 활용: 자주 검색하는 필드에 인덱스를 추가합니다.
  5. 데이터베이스 연결 풀 관리: 적절한 연결 풀 크기를 설정합니다.

9. 변경 이력

버전날짜작성자변경 내용
0.1.02025-03-22bok@weltcorp.com최초 작성