Framework/NestJS

NestJS 완전정복: MikroORM 사용하기

Joonfluence 2025. 5. 25.

MikroORM 소개

MikroORM은 Node.js와 TypeScript를 위한 강력한 ORM(Object-Relational Mapping) 라이브러리입니다. TypeORM이나 Sequelize와 같은 다른 ORM들과 비교했을 때 다음과 같은 특징을 가지고 있습니다.

  1. TypeScript First: TypeScript를 기본적으로 지원하며, 타입 안정성이 뛰어납니다.
  2. Unit of Work 패턴: 트랜잭션 관리가 용이하며, 변경사항을 효율적으로 추적합니다. 모든 변경사항을 em.flush() 호출 시 한번에 처리(트랜잭션 자동화) 합니다.
  3. Identity Map: 메모리 내 객체 캐싱을 통해 성능을 최적화합니다.
  4. Entity Manager: 엔티티의 생명주기를 관리하고 데이터베이스 작업을 추상화합니다.
  5. Query Builder: 강력한 쿼리 빌더를 제공하여 복잡한 쿼리도 쉽게 작성할 수 있습니다.

다른 ORM과의 비교

TypeORM vs MikroORM

  • TypeORM은 더 오래된 라이브러리로 커뮤니티가 크지만, TypeScript 지원이 MikroORM보다 제한적입니다.
  • MikroORM은 더 현대적인 아키텍처와 더 나은 타입 안정성을 제공합니다.

Sequelize vs MikroORM

  • Sequelize는 JavaScript 중심이지만, MikroORM은 TypeScript 중심입니다.
  • MikroORM은 더 나은 타입 추론과 데코레이터 기반의 엔티티 정의를 제공합니다.

NestJS에서 MikroORM 설정하기

1. 필요한 패키지 설치

npm install @mikro-orm/core @mikro-orm/nestjs @mikro-orm/postgresql
  • @mikro-orm/core: MikroORM 핵심 라이브러리
  • @mikro-orm/nestjs: NestJS 전용 어댑터
  • @mikro-orm/postgresql

2. MikroORM 모듈 설정

The forRoot() method accepts the same configuration object as init() from the MikroORM package.

// app.module.ts
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';

@Module({
  imports: [
    MikroOrmModule.forRoot({
      // 데이터베이스 연결 설정
      type: 'postgresql',
      host: 'localhost',
      port: 5432,
      user: 'postgres',
      password: 'password',
      dbName: 'my_database',
      entities: ['dist/**/*.entity.js'],
      entitiesTs: ['src/**/*.entity.ts'],
      debug: true,
    }),
  ],
})
export class AppModule {}

3. 엔티티 정의

// user.entity.ts
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';

@Entity()
export class User {
  @PrimaryKey()
  id!: number;

  @Property()
  name!: string;

  @Property()
  email!: string;

  @Property()
  createdAt: Date = new Date();

  @Property({ onUpdate: () => new Date() })
  updatedAt: Date = new Date();
}

4. Repository 구현

Repository를 구현 할 때, NestJS 프로젝트에서 아래처럼 Injectable 데코레이터 사용 방식을 권장합니다. NestJS의 DI 시스템과의 통합이 더 자연스럽기 때문입니다.

// src/user/user.module.ts
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { User } from '../entities/user.entity';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { UserRepository } from './user.repository';

@Module({
  imports: [
    MikroOrmModule.forFeature([User]),
  ],
  providers: [UserService, UserRepository],
  controllers: [UserController],
})
export class UserModule {}

모듈에 리포지토리 등록

// src/user/user.repository.ts
import { Injectable } from '@nestjs/common';
import { EntityRepository } from '@mikro-orm/core';
import { User } from '../entities/user.entity';
import { InjectRepository } from '@mikro-orm/nestjs';

export class UserRepository extends EntityRepository<User> {
  constructor(
    @InjectRepository(User)
    private readonly repository: EntityRepository<User>,
  ) {
    super(repository);
  }

  async findByCondition(condition: Partial<User>): Promise<User[]> {
    return this.repository.find(condition);
  }

  async findWithPagination(page: number, limit: number): Promise<[User[], number]> {
    return this.repository.findAndCount(
      {},
      {
        limit,
        offset: (page - 1) * limit,
        orderBy: { createdAt: 'DESC' },
      },
    );
  }

  async findOneWithPosts(id: number): Promise<User | null> {
    return this.repository.findOne(
      { id },
      {
        populate: ['posts'],
      },
    );
  }
}

혹은 Entity에 Repository 지정 방식으로도 사용 가능합니다. 해당 방식은 MikroORM의 기본 패턴에 더 가까운 방식입니다. 이렇게 사용하면, 더 나은 타입 안정성과 MikroORM의 기능을 최대한 활용 가능하다는 장점이 있습니다.

// User.entity.ts
import { Entity, Property } from '@mikro-orm/core';
import { EntityRepositoryType } from '@mikro-orm/core';
import { UserRepository } from './user.repository';

@Entity({ repository: () => UserRepository })
export class User {
  @Property()
  name!: string;

  // 타입 추론을 위한 선언
  [EntityRepositoryType]?: UserRepository;
}

// User.repository.ts
import { EntityRepository } from '@mikro-orm/core';
import { User } from './User.entity';

export class UserRepository extends EntityRepository<User> {
  // 커스텀 메서드 구현
  async findByName(name: string): Promise<User | null> {
    return this.findOne({ name });
  }
}

6. 서비스 구현

// src/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { User } from '../entities/user.entity';
import { UserRepository } from './user.repository';

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  // 사용자 생성
  async createUser(data: { name: string; email: string }): Promise<User> {
    return this.userRepository.create(data);
  }

  // 사용자 조회
  async findUser(id: number): Promise<User | null> {
    return this.userRepository.findOne(id);
  }

  // 모든 사용자 조회
  async findAllUsers(): Promise<User[]> {
    return this.userRepository.findAll();
  }

  // 사용자 업데이트
  async updateUser(id: number, data: Partial<User>): Promise<User | null> {
    return this.userRepository.update(id, data);
  }

  // 사용자 삭제
  async deleteUser(id: number): Promise<boolean> {
    return this.userRepository.delete(id);
  }

  // 조건부 사용자 검색
  async findUsersByCondition(condition: Partial<User>): Promise<User[]> {
    return this.userRepository.findByCondition(condition);
  }

  // 페이지네이션을 사용한 사용자 조회
  async findUsersWithPagination(page: number, limit: number): Promise<[User[], number]> {
    return this.userRepository.findWithPagination(page, limit);
  }

  // 관계가 있는 엔티티와 함께 사용자 조회
  async findUserWithPosts(id: number): Promise<User | null> {
    return this.userRepository.findOneWithPosts(id);
  }
}

7. 컨트롤러 구현

// user.controller.ts
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { UserService } from './user.service';
import { User } from './user.entity';

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async createUser(@Body() data: { name: string; email: string }): Promise<User> {
    return this.userService.createUser(data);
  }

  @Get(':id')
  async getUser(@Param('id') id: number): Promise<User | null> {
    return this.userService.findUser(id);
  }

  @Get()
  async getAllUsers(): Promise<User[]> {
    return this.userService.findAllUsers();
  }

  @Put(':id')
  async updateUser(
    @Param('id') id: number,
    @Body() data: Partial<User>,
  ): Promise<User | null> {
    return this.userService.updateUser(id, data);
  }

  @Delete(':id')
  async deleteUser(@Param('id') id: number): Promise<boolean> {
    return this.userService.deleteUser(id);
  }
}

MikroORM의 주요 기능

1. 관계 설정

// post.entity.ts
import { Entity, PrimaryKey, Property, ManyToOne } from '@mikro-orm/core';
import { User } from './user.entity';

@Entity()
export class Post {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;

  @Property()
  content!: string;

  @ManyToOne(() => User)
  user!: User;

  @Property()
  createdAt: Date = new Date();
}

2. 쿼리 빌더 사용

// user.service.ts
async findUsersWithPosts(): Promise<User[]> {
  return this.em.createQueryBuilder(User, 'u')
    .select('*')
    .leftJoinAndSelect('u.posts', 'p')
    .where({ 'p.title': { $like: '%NestJS%' } })
    .getResult();
}

3. 트랜잭션 처리 (롤백 관리)

MikroORM의 em.transactional() 메서드를 사용하면, 콜백 내에서 예외가 발생할 경우 해당 트랜잭션 전체가 자동으로 롤백됩니다.

await em.transactional(async (em) => {
  // 여러 데이터베이스 작업
  // 예외 발생 시 전체 작업이 롤백됨
  await em.persistAndFlush(entity1);
  await em.persistAndFlush(entity2);
  // throw new Error('Rollback!'); // 예외 발생 시 전체 롤백
});

더 세밀하게 제어하고 싶다면, 아래와 같이 수동 롤백을 할 수도 있습닌다.

await em.begin();
try {
  // 여러 작업
  await em.persist(entity1);
  await em.persist(entity2);
  await em.flush();
  await em.commit(); // 모든 작업 성공 시 커밋
} catch (e) {
  await em.rollback(); // 예외 발생 시 명시적으로 롤백
  throw e;
}

성능 최적화

1. Eager Loading

// user.service.ts
async findUserWithPosts(id: number): Promise<User | null> {
  return this.em.findOne(User, { id }, {
    populate: ['posts'],
  });
}

2. 배치 처리

// user.service.ts
async createManyUsers(users: { name: string; email: string }[]): Promise<void> {
  const entities = users.map(user => this.em.create(User, user));
  await this.em.persistAndFlush(entities);
}

EntityRepository에서 제공하는 기본 메서드들

기본 CRUD 작업

  • findOne(where) : 조건에 맞는 단일 엔티티 조회. 없으면 null 반환
  • find(where) : 조건에 맞는 엔티티 리스트 조회
  • create(data) : 새 엔티티 인스턴스 생성(메모리상). DB에 반영하려면 persist 필요
  • persist(entity, flush?: boolean) : 현재 트랜잭션에 엔티티를 등록(INSERT 준비). 실제 DB 반영은 flush 시점에 수행, 기본 설정하에선 persist()만으로는 DB에 변동이 저장되지 않으니, 꼭 flush()나 persistAndFlush()를 호출해야 한다.
  • persistAndFlush(entity) : 등록과 동시에 즉시 DB에 반영
  • flush() : 지금까지 변경된 모든 엔티티를 DB에 동기화
  • remove(entity) : persist()처럼 삭제 예약만 하고, 실제 SQL DELETE는 flush() 시점에 발생한다.
  • removeAndFlush(entity) : 삭제 예약 후 즉시 DB에 반영한다.

조건부 쿼리

  • findOneOrFail(where) : 조건에 맞는 엔티티 조회, 없으면 예외(EntityNotFoundError) 발생
  • findAndCount(where, option?) : 조건 조회 + 전체 개수 동시 반환 [entities, count], 페이징 시 유용

관계 처리 (Populate)

  • populate(entities, populate) : 지연 로딩된 연관 관계를 명시적으로 불러온다. 배열 또는 단일 엔티티를 지원한다.

네이티브 쿼리

  • nativeInsert(data) : 직접 SQL INSERT 실행, 반환값은 새로 생성된 PK
  • nativeUpdate(where, data) : 직접 SQL UPDATE 실행. 변경된 행(row) 수 반환
  • nativeDelete(where) : 직접 SQL DELETE 실행. 삭제된 행 수 반환

기타 유틸리티

  • count(where) : 조건에 맞는 행 개수 계산
  • exists(where) : 조건에 맞는 엔티티 존재 여부 확인
  • assign(entity, data) : 엔티티 프로퍼티를 주어진 데이터로 덮어쓰기. 반환값은 수정된 엔티티
// EntityRepository에서 상속받는 주요 메서드들
class EntityRepository<T> {
  // 기본 CRUD 작업
  findOne(where: FilterQuery<T>): Promise<T | null>;
  find(where: FilterQuery<T>): Promise<T[]>;
  create(data: RequiredEntityData<T>): T;
  persist(entity: T): Promise<void>;
  persistAndFlush(entity: T): Promise<void>;
  flush(): Promise<void>;
  remove(entity: T): Promise<void>;
  removeAndFlush(entity: T): Promise<void>;

  // 조건부 쿼리
  findOneOrFail(where: FilterQuery<T>): Promise<T>;
  findAndCount(where: FilterQuery<T>, options?: FindOptions<T>): Promise<[T[], number]>;

  // 관계 처리
  populate(entities: T | T[], populate: PopulateOptions<T>): Promise<T | T[]>;

  // 네이티브 쿼리
  nativeInsert(data: RequiredEntityData<T>): Promise<Primary<T>>;
  nativeUpdate(where: FilterQuery<T>, data: RequiredEntityData<T>): Promise<number>;
  nativeDelete(where: FilterQuery<T>): Promise<number>;

  // 기타 유틸리티
  count(where: FilterQuery<T>): Promise<number>;
  exists(where: FilterQuery<T>): Promise<boolean>;
  assign(entity: T, data: RequiredEntityData<T>): T;
}

마이그레이션

1. 마이그레이션 생성

npx mikro-orm migration:create

2. 마이그레이션 실행

npx mikro-orm migration:up

결론

MikroORM은 NestJS 애플리케이션에서 데이터베이스 작업을 효율적으로 처리할 수 있는 강력한 ORM입니다. TypeScript의 타입 안정성과 현대적인 아키텍처를 제공하며, Unit of Work 패턴을 통한 효율적인 트랜잭션 관리가 가능합니다. 또한 강력한 쿼리 빌더와 관계 설정 기능을 통해 복잡한 데이터베이스 작업도 쉽게 구현할 수 있습니다.

MikroORM을 사용하면 다음과 같은 이점을 얻을 수 있습니다.

  1. 타입 안정성이 뛰어난 데이터베이스 작업
  2. 효율적인 트랜잭션 관리
  3. 직관적인 API
  4. 강력한 쿼리 빌더
  5. 쉬운 마이그레이션 관리

이러한 특징들로 인해 MikroORM은 대규모 NestJS 애플리케이션에서 특히 유용하게 사용될 수 있습니다.

반응형

댓글