새소식

반응형
Back-end/NestJS

[NestJS] TypeORM으로 MySQL 데이터베이스 연결하기(TypeORM 기능 다 알아보기)

2024.02.13
  • -
반응형

Nest는 데이터베이스에 구애받지 않기 때문에 모든 SQL 혹은 NoSQL 데이터베이스들과 쉽게 통합할 수 있습니다. 개인 선호도에 따라 다양한 선택지를 선택할 수 있지만 저는 이 포스팅에서 MySQL을 Nest에서 어떻게 구성하는지에 대해서 글을 써보도록 할 것입니다.

 

가장 일반적인 경우에 Nest를 데이터베이스와 연결시키기 위해선 Express나 Fastify를 사용할 때와 마찬가지로 단순히 그 데이터베이스에 적합한 Node.js 드라이버를 불러와야 합니다.

 

혹은 더 높은 수준의 추상화로 동작하기 위해서 MikroORM, Sequalize, Knex.s, TypeORM, Prisma 등의 범용 Node.js 통합 라이브러리나 ORM을 직접 사용할 수도 있는데 이 중에서 TypeORM을 사용해볼 것입니다.

 

1. TypeORM

SQL과 NoSQL 데이터베이스를 통합하기 위해서, Nest는 @nestjs/typeorm 패키지를 제공합니다. TypeORM은 TypeScript에서 사용할 수 있는 가장 성숙한 ORM(Object Relational Mapper)이라고 할 수 있습니다. TypeScript로 쓰여졌다보니 Nest 프레임워크와도 굉장히 잘 통합되어 있습니다.

 

그럼 사용 방법에 대해서 알아볼까요?

 

먼저 필요한 dependencies를 설치해주어야 합니다. 저는 MySQL을 사용하지만 TypeORM에서는 PostgreSQL, Oracle, Microsoft SQL Server, SQLite, MongoDB 등 다양한 데이터베이스에 대한 지원을 제공합니다. 또한 앞으로 진행하게 될 절차는 TypeORM 이외의 다른 어떠한 데이터베이스에 대해서도 동일하게 적용될 것이므로 다른 데이터베이스를 사용하더라도 똑같이 따라가셔도 됩니다. 

 

1-1. 설치

그렇다면 선택한 데이터베이스에 맞는 관련 클라이언트 API 라이브러리를 설치하는 것부터 진행해봅시다:

$ npm install --save @nestjs/typeorm typeorm mysql2

 

설치가 완료되면, TypeOrmModule을 루트 AppModule에서 import 할 수 있습니다.

 

// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'test',
      entities: [],
      synchronize: true,
    }),
  ],
})
export class AppModule {}
synchronize를 true로 설정하는 것은 운영 단계에서 데이터를 잃는 등 악영향을 끼칠 수 있는 요소가 많기 때문에 프로덕션 레벨에서는 false로 설정하는 것이 더 권장됩니다.

 

TypeOrmModule의 forRoot() 메서드는 TypeORM 자체 패키지의 DataSource 생성자가 제공하는 모든 configuration 속성들을 지원합니다. 게다가, 아래와 같은 추가적인 속성들을 부여할 수도 있습니다.

  • retryAttempts: 데이터베이스에 연결하려는 재시도의 횟수(default: 10)
  • retryDelay: 연결 재시도 할 때 딜레이(ms) - (default: 3000)
  • autoLoadEntities: true로 설정하면 엔티티가 자동으로 불러와진다. (default: false)

만약 모든 TypeORM의 기본 DataSource 옵션에 대해 궁금하신 분들은 여기를 참고해주세요.

 

또한 TypeORM DataSource와 EntityManager 객체를 다른 모듈에서 import하지 않고도 전체 프로젝트에 주입할 수도 있습니다. 아래는 그 예시입니다.

import { DataSource } from 'typeorm';

@Module({
  imports: [TypeOrmModule.forRoot(), UsersModule],
})
export class AppModule {
  constructor(private dataSource: DataSource) {}
}

 

1-2. Repository 패턴

TypeORM은 repository 디자인 패턴을 지원하기 때문에 각 엔티티는 자기 자신의 repository를 갖습니다. 이런 repository들은 데이터베이스 data source로부터 얻어올 수 있습니다.

Entity와 관련된 것을 알고싶다면 공식문서를 참고해주세요.

 

먼저 User 엔티티 클래스를 만들어줍니다:

// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column({ default: true })
  isActive: boolean;
}

 

User 엔티티 파일은 users 디렉터리에 위치하게 됩니다. 이 디렉터리에는 UserModule과 관련된 모든 파일들을 포함합니다. 우리가 만들 프로젝트의 model(entity) 파일들을 어디에 위치시킬지 결정하는 것은 자유이지만, 해당 모듈 디렉터리 안에, 즉 도메인과 가깝도록 만드는 것이 권장됩니다.

 

// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users/user.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'test',
      entities: [User],
      synchronize: true,
    }),
  ],
})
export class AppModule {}

 

User 엔티티를 사용하기 위해선, 해당 엔티티를 forRoot() 메서드 옵션의 entities 배열에 삽입하여 TypeORM이 그 사실을 알도록 해야합니다.

 

그리고 다음과 같이 UserModule을 작성합니다.

// user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { User } from './user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  providers: [UsersService],
  controllers: [UsersController],
})
export class UsersModule {}

 

이 모듈에서 forFeature() 메서드를 사용하여 현재 모듈의 범위에서 사용 중인 repository들을 명시하여 import 합니다.

 

이렇게 하면 해당 모듈 범위 내에서 @InjectRepository() 데코레이터를 사용하여 UserRepository를 UsersService에 주입할 수 있게 됩니다.

 

// users.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>, // UserRepository 주입
  ) {}

  findAll(): Promise<User[]> {
    return this.usersRepository.find();
  }

  findOne(id: number): Promise<User | null> {
    return this.usersRepository.findOneBy({ id });
  }

  async remove(id: number): Promise<void> {
    await this.usersRepository.delete(id);
  }
}

 

물론 루트 AppModule에 UsersModule을 import 하는 것도 추가해줍니다.

 

만약 TypeOrmModule.forFeature를 import하는 모듈말고도, 다른 외부의 모듈에서 앞서 만든 repository를 사용하고 싶다면, 그렇게 만들어진 provider(TypeOrmModule)를 다시 명시적으로 export 해주면 됩니다.

 

다음과 같이 전체 모듈을 export 합니다:

// user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  exports: [TypeOrmModule]
})
export class UsersModule {}

 

이제, 예를 들어 UserHttpModule에서 UsersModule을 import 하면, UserHttpModule의 provider에서도 자동으로 @InjectRepository(User)를 사용할 수 있게 됩니다.

// user-http.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users.module';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [UsersModule],
  providers: [UsersService],
  controllers: [UsersController]
})
export class UserHttpModule {}

 

1-3. Relations (관계)

relation은 둘 이상의 테이블 간에 설정된 연결을 의미합니다. 이는 각 테이블의 공통된 필드를 기반으로 만들어지며, 간혹 해당 relation에 기본키와 외래키가 연관될 수도 있습니다.

 

아래는 3가지 relation에 대한 설명입니다.

  • One-to-one: primary(기본) 테이블의 각 행들은 모두 foreign(외래) 테이블의 한 행이랑만 연결되어 있습니다. @OneToOne() 데코레이터로 정의합니다.
  • One-to-many | Many-to-one: 기본 테이블의 각 행들은 모두 외래 테이블의 하나 이상의 행과 연결되어 있습니다. @OneToMany()와 @ManyToOne() 데코레이터로 정의합니다.
  • Many-to-Many: 기본 테이블의 각 행들은 모두 외래 테이블과 다수의 연결을 가지고 있고, 외래 테이블의 각 행들 역시 기본 테이블과 다수의 연결을 갖습니다. @ManyToMany() 데코레이터로 정의합니다.

 

엔티티에서 relations을 정의하기 위해 상응하는 데코레이터를 사용합니다. 예를 들어, 각 User는 여러 Photo를 가진다고 하면 아래와 같이 @OneToMany() 데코레이터를 사용할 수 있습니다.

import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
import { Photo } from '../photos/photo.entity';

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

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column({ default: true })
  isActive: boolean;

  @OneToMany(type => Photo, photo => photo.user)
  photos: Photo[];
}

 

이러한 relation을 나타내는 행은 두 테이블 간의 연결관계를 나타내는 것으로 실제 데이터베이스에는 필요한쪽에 해당하는 엔티티에 해당 열을 생성하는 것이 일반적입니다.

  • 위의 예에서도 Photo 엔티티에 각 레코드는 어떤 유저의 사진인지 알기 위해 User 엔티티의 id를 외래키로 사용할 것이므로 Photo 엔티티에는 외래키가 있어야 하지만 User 엔티티에는 Photo와 관련된 어떠한 열을 갖지 않습니다.

 

relation에 대해서 더 자세히 알고싶은 분은 공식문서를 참고해주세요.

 

 

다음으로 relation을 정의할 때 자주 사용하는 두 가지 옵션에 대해서만 설명을 하도록 하겠습니다.

 

@JoinColunm 옵션

@JoinColumn은 어느 쪽의 relation이 외래키를 가진 join 컬럼을 포함할지를 결정하는 역할을 하며, 이를 통해 join 컬럼의 이름과 참조된 컬럼의 이름을 원하는 대로 지어줄 수 있습니다.

 

@JoinColumn을 설정할 때, propertyName + referencedColumnName의 이름으로 데이터베이스 상에 컬럼을 자동으로 생성합니다.

@ManyToOne(type => Category)
@JoinColumn() // this decorator is optional for @ManyToOne, but required for @OneToOne
category: Category;

 

이 코드에서는, 데이터베이스 상에 categoryId 컬럼을 만들어냅니다. 

 

@ManyToOne과 @OneToMany 관계에서는 @JoinColumn을 생략해줄 수도 있습니다.
또한 @OneToMany는 지정하지 않고 @ManyToOne만 지정하는 것도 가능합니다.

 

 

해당 컬럼 이름을 바꾸고 싶다면 다음과 같이 지정해줄 수 있습니다.

@ManyToOne(type => Category)
@JoinColumn({ name: "cat_id" })
category: Category;

 

 

Join 컬럼은 항상 다른 열에 대한 참조(외래 키를 사용한)입니다.

 

기본적으로 relation은 항상 관련된 엔티티의 primary 컬럼을 참조합니다. 만약 primary 컬럼이 아닌 관련 엔티티의 다른 열과 relation을 만들고 싶다면 @JoinColumn에 해당 열을 지정해야 합니다.

@ManyToOne(type => Category)
@JoinColumn({ referencedColumnName: "name" })
category: Category;

 

이 relation은 이제 Catetory 엔티티의 id 대신 name을 참조합니다.

 

또한 다중 컬럼을 join할 수도 있습니다. 하지만 기본값으로 연관된 엔티티의 기본 컬럼(primary column)을 참조하진 않는다는 것을 알아야 합니다. 이 경우 반드시 referenced column name을 제공해주셔야 합니다.

@ManyToOne(type => Category)
@JoinColumn([
    { name: "category_id", referencedColumnName: "id" },
    { name: "locale_id", referencedColumnName: "locale_id" }
])
category: Category;

 

@JoinTable 옵션

@JoinTablemany-to-many relation에서 사용되고 "junction" 테이블의 join 컬럼을 묘사합니다. junction 테이블이란 관련 엔티티를 참조하는 열을 사용하여 TypeORM에서 자동으로 생성되는 특수한 별도의 테이블을 의미합니다 (중개 테이블이라고도 합니다.). 조인 테이블 내부의 컬럼 이름과 그들이 참조되는 컬럼은 @JoinColumn을 통해 변경할 수 있습니다.

 

즉, 생성된 junction 테이블의 이름을 원하는 대로 변경할 수도 있습니다.

@ManyToMany(type => Category)
@JoinTable({
    name: "question_categories", // table name for the junction table of this relation
    joinColumn: {
        name: "question",
        referencedColumnName: "id"
    },
    inverseJoinColumn: {
        name: "category",
        referencedColumnName: "id"
    }
})
categories: Category[];

 

대상 테이블에 복합 기본 키(composite primary key)가 있는 경우에는 속성 배열(propety array)을 @JoinTable로 보내야 합니다.

 

1-4. Auto-load entities

일일히 직접 data source 옵션의 entities 배열에 엔티티들을 추가하는 것은 매우 귀찮을 수 있는 작업입니다. 게다가, 루트 모듈로부터 엔티티를 참조하는 것은 애플리케이션 도메인 영역을 깨뜨리는 일이고 구현 세부사항을 애플리케이션의 다른 부분에게 노출시키는 일일 수도 있습니다.

 

이러한 문제를 해결하기 위해서, configuration 객체의 autoLoadEntities 속성을 true로 설정할 수 있습니다.

// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      ...
      autoLoadEntities: true,
    }),
  ],
})
export class AppModule {}

 

옵션을 명시하여 forFeature() 메서드를 통해 등록된 모든 엔티티가 configuration 객체 entities 배열에 자동으로 추가되게 됩니다.

 

forFeature() 메서드를 통해 등록되지 않은 엔티티들은 relation을 통해 엔티티로부터 참조될 뿐, autoLoadEntities 설정 방식으로는 포함되지 않을 것임을 인지하셔야 합니다.

 

1-5. TypeORM Transaction(트랜잭션)

데이터베이스 트랜잭션은 DBMS 내에서 데이터베이스에 대해 수행되는 작업 단위를 의미하며, 다른 트랜잭션과 독립적으로 일관되고 신뢰할 수 있는 방식으로 처리되는 기술입니다. 트랜잭션은 일반적으로 데이터베이스 상의 모든 변화를 나타냅니다.

 

TypeORM 트랜잭션을 다루는 데 매우 다양한 방식이 존재합니다. Nest에서는 QueryRunner 클래스를 사용할 것을 권장하고 있는데 그 이유는 QueryRunner가 트랜잭션 전반을 완전히 통제할 수 있는 기능들을 갖고 있기 때문이라고 합니다.

 

먼저, 일반적인 방식으로 클래스에 DataSource 객체를  주입해야 합니다.

@Injectable()
export class UsersService {
  constructor(private dataSource: DataSource) {}
}

 

이제 이 객체를 트랜잭션을 생성하는 데 사용할 수 있습니다.

async createMany(users: User[]) {
  const queryRunner = this.dataSource.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(users[0]);
    await queryRunner.manager.save(users[1]);

    await queryRunner.commitTransaction();
  } catch (err) {
    // since we have errors lets rollback the changes we made
    await queryRunner.rollbackTransaction();
  } finally {
    // you need to release a queryRunner which was manually instantiated
    await queryRunner.release();
  }
}

 

dataSource는 QueryRunner를 생성하는 데만 사용된다는 사실을 아셔야 합니다. 하지만 이 클래스를 테스트하려면 여러 메서드를 제공하는 전체 DataSource 객체를 mocking해야 합니다. 따라서 helper factory 클래스(예: QueryRunnerFactory)를 사용하고 트랜잭션을 유지하는 데 필요한 제한적인 메서드 집합으로 인터페이스를 정의하는 것이 좋습니다. 이 기법을 사용하면 이러한 메서드를 매우 간단하게 mocking 할 수 있습니다.

 

위와 같은 방식말고도 DataSource 객체의 transaction() 메서드를 사용한 callback-style의 접근을 사용할 수도 있습니다.

async createMany(users: User[]) {
  await this.dataSource.transaction(async manager => {
    await manager.save(users[0]);
    await manager.save(users[1]);
  });
}

 

1-7. Migrations

Migration은 데이터베이스의 기존 데이터를 보존하면서 애플리케이션의 데이터 모델과 동기화되도록 데이터베이스 스키마를 점진적으로 업데이트 할 수 있는 방법을 제공합니다.

 

migration의 generate, execute, revert를 위해선 TypeORM에서 제공하는 전용 CLI를 사용할 수 있습니다.

 

migration 클래스는 Nest 애플리케이션 코드와 분리되어 있습니다. 그렇기 때문에 그들의 생애주기는 TypeORM CLI에 의해 유지됩니다. 따라서 마이그레이션을 통해 의존성 주입 및 기타 Nest 관련 기능을 활용할 수 없습니다.

 

마이그레이션에 대해 자세히 알아보려면 TypeORM 공식문서를 참고해 주세요.

 

 

1-8. Testing

애플리케이션을 단위 테스트 할 때, 일반적으로 데이터베이스 연결을 피하고 테스트 suite를 독립적으로 유지하며, 실행 프로세스가 최대한 빠르게 진행되고자 합니다. 하지만 테스트 클래스는 결국 data source 인스턴스에서 가져온 repository에 의존할 것입니다.

 

그렇다면 이를 어떻게 처리할 수 있을까요? 

 

해결 방법은 mock repository를 만드는 것입니다. 이를 위해 custom provider를 설정할 수 있는데요. 등록된 각 repository는 자동으로 <EntityName>Repository 토큰으로 표시되며, 여기서 EntityName은 엔티티 클래스의 이름에 해당합니다.

 

@nestjs/typeorm 패키지는 주어진 엔티티에 대한 준비된 토큰을 반환하는 getRepositoryToken() 함수를 제공합니다.

@Module({
  providers: [
    UsersService,
    {
      provide: getRepositoryToken(User),
      useValue: mockRepository,
    },
  ],
})
export class UsersModule {}

 

이를 통해, mockRepository가 UsersRepository를 대체하여 사용될 수 있습니다. 어떤 클래스에서 @InjectRepository() 데코레이터를 사용하여 UsersRepository를 요청할 때마다 Nest는 등록된 mockRepository 객체를 반환하는 것입니다.

 

2. Async configuration

repository 모듈 옵션을 정적이 아닌 비동기적으로 전달하고 싶은 경우에는 어떻게 할까요?

 

그러한 경우 비동기 설정을 다루기 위해 몇 가지 방식을 제공하는 forRootAsync() 메서드를 사용할 수 있습니다.

 

한 방식은 factory 함수를 사용하는 것입니다:

TypeOrmModule.forRootAsync({
  useFactory: () => ({
    type: 'mysql',
    host: 'localhost',
    port: 3306,
    username: 'root',
    password: 'root',
    database: 'test',
    entities: [],
    synchronize: true,
  }),
});

 

이 factory는 다른 비동기 provider들과 똑같이 동작합니다. 예를 들어, async가 가능하고 inject 속성을 통한 의존성 주입도 가능합니다.

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: (configService: ConfigService) => ({
    type: 'mysql',
    host: configService.get('HOST'),
    port: +configService.get('PORT'),
    username: configService.get('USERNAME'),
    password: configService.get('PASSWORD'),
    database: configService.get('DATABASE'),
    entities: [],
    synchronize: true,
  }),
  inject: [ConfigService],
});

 

 

또 다른 방식은 useClass 구문을 사용하는 것입니다.

TypeOrmModule.forRootAsync({
  useClass: TypeOrmConfigService,
});

 

이는 TypeOrmModule 내부에서 외부의 TypeOrmConfigService 클래스를 인스턴스화 하고 해당 인스턴스의createTypeOrmOptions() 메서드를 호출하여 옵션 객체를 제공해주게 됩니다.

 

그리고 여기서 만들 TypeOrmConfigService는 createTypeOrmOptions() 메서드를 호출하는 것을 보니 다른 인터페이스를 구현해야 한다는 의미로 해석할 수 있겠죠?.

 

이 경우 TypeOrmOptionsFactory 인터페이스를 구현하면 됩니다.

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  createTypeOrmOptions(): TypeOrmModuleOptions {
    return {
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'root',
      database: 'test',
      entities: [],
      synchronize: true,
    };
  }
}

 

TypeOrmModule 내에서 TypeOrmConfigService 인스턴스가 생성되는 것을 막고 다른 모듈로부터 import되는 provider의 사용을 막기 위해서 useExisting 구문을 사용할 수 있습니다.

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  useExisting: ConfigService,
});

 

useExisting은 useClass와 똑같이 동작하지만 중요한 한 가지가 다릅니다.

  • TypeOrmModule이 import한 모듈을 조회해서 새로운 ConfigService를 인스턴스화하는 대신 기존 ConfigService를 재사용합니다.

 

name 속성이 useFactory, useClass, useValue와 동일한 level에 정의되어 있는지 확인해야 합니다. 그래야 Nest가 적절히 주입 토큰 아래에서 data source를 올바르게 등록할 수 있습니다.

 

3. Custom DataSource Factory

useFactory, useClass, useExisting을 사용하는 비동기 configuration과 함께 선택적으로 dataSourceFactory 함수를 지정하여 TypeOrmModule이 data source를 생성하도록 허용하는 대신 자체 TypeOrm data source를 제공할 수 있습니다.

 

dataSourceFactory는 useFactory, useClass, useExisting를 사용하여 비동기 구성 중에 구성된 TypeORM data source 옵션을 받고 TypeORM data source를 resolve하는 Promise를 반환합니다.

TypeOrmModule.forRootAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  // Use useFactory, useClass, or useExisting
  // to configure the DataSourceOptions.
  useFactory: (configService: ConfigService) => ({
    type: 'mysql',
    host: configService.get('HOST'),
    port: +configService.get('PORT'),
    username: configService.get('USERNAME'),
    password: configService.get('PASSWORD'),
    database: configService.get('DATABASE'),
    entities: [],
    synchronize: true,
  }),
  // dataSource receives the configured DataSourceOptions
  // and returns a Promise<DataSource>.
  dataSourceFactory: async (options) => {
    const dataSource = await new DataSource(options).initialize();
    return dataSource;
  },
});

 

 

4. EntityManager

EntityManager를 사용하여 등록된 엔티티를 관리할 수 있습니다. (insert, update, delete, load, 등등) -> CRUD 작업

 

EntityManager는 단일 공간에 모든 엔티티 repositories의 모음집 같은 것이라고 생각하시면 됩니다. DataSource를 통해 entity manager에 접근할 수 있습니다.

import { DataSource } from "typeorm"
import { User } from "./entity/User"

const myDataSource = new DataSource(/*...*/)
const user = await myDataSource.manager.findOneBy(User, {
    id: 1,
})
user.name = "Umed"
await myDataSource.manager.save(user)

 

5. Repostiory

Repository는 EntityManager와 유사하지만 CRUD 작업을 딱 특정 엔티티에게만 제한되어 작동하도록 하는 객체입니다.

data source를 통해 repository에 접근할 수 있습니다.

import { User } from "./entity/User"

const userRepository = dataSource.getRepository(User)
const user = await userRepository.findOneBy({
    id: 1,
})
user.name = "Umed"
await userRepository.save(user)

 

6. Find 옵션

6-1. 기본 옵션

모든 repository와 manager의 .find* 메서드는 QueryBuilder를 사용하지 않고도 필요한 데이터를 쿼리하는 데 사용할 수 있는 특수 옵션을 허용합니다. 

 

select - 메인 객체의 어느 속성을 선택해야 하는지를 나타냅니다.(아래 예시에서 메인 객체는 User)

userRepository.find({
    select: {
        firstName: true,
        lastName: true,
    },
})

 

그러면 이는 아래와 같은 쿼리는 실행할 것입니다.

SELECT "firstName", "lastName" FROM "user"

 

relations - relation은 main 엔티티로 불러와져야 합니다. (하위 관계도 load 가능)

userRepository.find({
    relations: {
        profile: true,
        photos: true,
        videos: true,
    },
})
userRepository.find({
    relations: {
        profile: true,
        photos: true,
        videos: {
            videoAttributes: true,
        },
    },
})

joinleftJoinAndSelect의 약어로 보시면 됩니다.

 

이는 아래와 같은 쿼리를 실행시킬 것입니다.

SELECT * FROM "user"
LEFT JOIN "profile" ON "profile"."id" = "user"."profileId"
LEFT JOIN "photos" ON "photos"."id" = "user"."photoId"
LEFT JOIN "videos" ON "videos"."id" = "user"."videoId"

SELECT * FROM "user"
LEFT JOIN "profile" ON "profile"."id" = "user"."profileId"
LEFT JOIN "photos" ON "photos"."id" = "user"."photoId"
LEFT JOIN "videos" ON "videos"."id" = "user"."videoId"
LEFT JOIN "video_attributes" ON "video_attributes"."id" = "videos"."video_attributesId"

 

where - 엔티티가 쿼리될 때 사용되는 단순한 조건

userRepository.find({
    where: {
        firstName: "Timber",
        lastName: "Saw",
    },
})

 

이는 아래와 같은 쿼리를 실행시킵니다.

SELECT * FROM "user"
WHERE "firstName" = 'Timber' AND "lastName" = 'Saw'

 


고정된 엔티티로부터 컬럼을 쿼리하려면 해당 컬럼이 정의된 계층 구조와 관련하여 수행되어야 합니다. 아래는 그 예시입니다.

userRepository.find({
    relations: {
        project: true,
    },
    where: {
        project: {
            name: "TypeORM",
            initials: "TORM",
        },
    },
})

 

그리고 이는 아래와 같은 쿼리를 실행시킵니다.

SELECT * FROM "user"
LEFT JOIN "project" ON "project"."id" = "user"."projectId"
WHERE "project"."name" = 'TypeORM' AND "project"."initials" = 'TORM'

 


OR 연산으로 쿼리하는 것은 다음과 같습니다.

userRepository.find({
    where: [
        { firstName: "Timber", lastName: "Saw" },
        { firstName: "Stan", lastName: "Lee" },
    ],
})

 

이는 다음과 같은 쿼리를 실행시킵니다.

SELECT * FROM "user" WHERE ("firstName" = 'Timber' AND "lastName" = 'Saw') 
	OR ("firstName" = 'Stan' AND "lastName" = 'Lee')

 

order - selection의 순서를 지정합니다.

userRepository.find({
    order: {
        name: "ASC",
        id: "DESC",
    },
})

 

이는 다음과 같은 쿼리를 실행시킵니다.

SELECT * FROM "user"
ORDER BY "name" ASC, "id" DESC

 

withDeleted - softDelete나 softRemove로 soft-deleted 엔티티를 포함합니다.(e.g., @DeleteDateColumn 열이 설정된 엔티티)

기본값으로는 soft deleted 엔티티가 포함되지 않습니다.

userRepository.find({
    withDeleted: true,
})

 


다수의 엔티티를 반환하는 find* 메서드(find, findBy, findAndCount, findAndCountBy)는 다음 옵션도 사용할 수 있습니다.

 

skip - 엔티티를 가져올 곳의 offset (paginated)

userRepository.find({
    skip: 5,
})
SELECT * FROM "user"
OFFSET 5

 

 

take - limit (paginated) - 가져올 엔티티의 최대 갯수

userRepository.find({
    take: 10,
})
SELECT * FROM "user"
LIMIT 10

 

 

위의 skip과 take은 같이 사용해야 합니다.

userRepository.find({
    order: {
        columnName: "ASC",
    },
    skip: 0,
    take: 10,
})
SELECT * FROM "user"
ORDER BY "columnName" ASC
LIMIT 10 OFFSET 0

 

cache - 쿼리 결과를 캐싱할지 말지를 설정합니다.

userRepository.find({
    cache: true,
})

 

쿼리 캐싱에 대한 정보는 typeorm 공식문서를 참고하세요.

 

lock - 쿼리의 locking 메커니즘을 활성화합니다.

이는 findOne과 findOneBy 메서드에서만 사용가능합니다. lock은 다음과 같이 정의될 수 있는 객체입니다.

{ mode: "optimistic", version: number | Date }

혹은

{
    mode: "pessimistic_read" |
        "pessimistic_write" |
        "dirty_read" |
        /*
            "pessimistic_partial_write" and "pessimistic_write_or_fail" are deprecated and
            will be removed in a future version.

            Use onLocked instead.
         */
        "pessimistic_partial_write" |
        "pessimistic_write_or_fail" |
        "for_no_key_update" |
        "for_key_share",

    tables: string[],
    onLocked: "nowait" | "skip_locked"
}

 

이를 사용하는 예시는 다음과 같습니다.

userRepository.findOne({
    where: {
        id: 1,
    },
    lock: { mode: "optimistic", version: 1 },
})

 

lock에 대한 기능은 이곳을 참고하세요.

 

find 옵션 사용 예시

userRepository.find({
    select: {
        firstName: true,
        lastName: true,
    },
    relations: {
        profile: true,
        photos: true,
        videos: true,
    },
    where: {
        firstName: "Timber",
        lastName: "Saw",
        profile: {
            userName: "tshaw",
        },
    },
    order: {
        name: "ASC",
        id: "DESC",
    },
    skip: 5,
    take: 10,
    cache: true,
})

 


그리고 만약 아무런 인자도 넘겨주지 않는다면:

userRepository.find()

 

아래와 같은 쿼리를 실행시킵니다.

SELECT * FROM "user"

 

 

6-2. 그 외의 옵션

  • Not
  • LessThan
  • LessThanOrEqual
  • MoreThan
  • MoreThanOrEqual
  • Equal
  • Like
  • ILike
  • Between
  • In
  • Any
  • IsNull
  • ArrayContains
  • ArrayContainBy
  • ArrayOverlap
  • Raw

 

7. Custom repositories

데이터베이스와 동작하는 메서드를 포함해야하는 custom repository를 만들 수가 있습니다.

 

예를 들어, findByName(firstName: string, lastName: string)의 시그니처를 갖는 메서드를 갖고 싶다고 해봅시다.

  • 이 메서드는 주어진 first name과 last name으로 유저를 검색하는 기능을 합니다.

 

이 메서드에게 가장 최고의 장소는 Repository이기 때문에 우리는 이 메서드를 userRepository.findByName(...)과 같이 호출할 수 있을 것입니다. 이를 위해 custom repository를 사용해봅시다.

 

custom repository가 만들어지는 몇 가지 방식이 존재합니다.

 

7-1. custom repository 만드는 법

이 방식은 repository 인스턴스를 전역적으로 export된 변수에 할당하는 가장 자주 사용되는 방식이고 애플리케이션에 걸쳐 해당 변수를 사용합니다.

 

// user.repository.ts
export const UserRepository = dataSource.getRepository(User)

// user.controller.ts
export class UserController {
    users() {
        return UserRepository.find()
    }
}

 

Repository 클래스의 .extend 메서드를 사용하여 UserRepository 기능을 확장시키기 위해 아래와 같이 코드를 작성합니다.

// user.repository.ts
export const UserRepository = dataSource.getRepository(User).extend({
    findByName(firstName: string, lastName: string) {
        return this.createQueryBuilder("user")
            .where("user.firstName = :firstName", { firstName })
            .andWhere("user.lastName = :lastName", { lastName })
            .getMany()
    },
})

// user.controller.ts
export class UserController {
    users() {
        return UserRepository.findByName("Timber", "Saw")
    }

 

7-2. 트랜잭션에서 custom repository 사용하기

트랜잭션은 자기 자신의 실행 범위를 갖습니다. 그들만의 query runner와 entity manager, repository manager를 갖는 것이죠.

그래서 이것이 전역적인 data source의 entity manager와 repository가 트랜잭션에서는 동작하지 않는 이유입니다.

 

트랜잭션 범위 안에서 쿼리들을 적절히 실행시키려면 제공된 entity mangerentity manger의 getRepository() 메서드를 사용해야만 합니다.

 

그리고 트랜잭션 내에서 custom repository를 사용하기 위해, 제공된 entity manger 인스턴스의 withRepository 메서드를 사용해야 합니다.

await connection.transaction(async (manager) => {
    // 트랜잭션 내에서 제공되는 manager 인스턴스를 반드시 사용해야 하고
    // global entity manager나 repository를 사용할 수는 없습니다.
    // 왜냐하면 이러한 manager는 포괄적이고 transactional하기 때문입니다.

    const userRepository = manager.withRepository(UserRepository)
    await userRepository.createAndSave("Timber", "Saw")
    const timber = await userRepository.findByName("Timber", "Saw")
})

 

8. EntityManger API

EntityManger를 통해 여러 기능을 사용할 수 있습니다.

 

dataSource - EntityManger에 의해 사용되는 DataSource

const dataSource = manager.dataSource

 

queryRunner - EntityManger에 의해 사용되는 query runner. EntityManager의 트랜잭션 인스턴스에서만 사용됨.

const queryRunner = manager.queryRunner

 

transaction - multiple 데이터베이스 요청이 단일 데이터베이스 트랜잭션에서 실행 될 트랜잭션을 제공.

await manager.transaction(async (manager) => {
    // NOTE: you must perform all database operations using the given manager instance
    // it's a special instance of EntityManager working with this transaction
    // and don't forget to await things here
})

 

query - raw SQL 쿼리를 실행.

const rawData = await manager.query(`SELECT * FROM USERS`)

 

createQueryBuilder - SQL 쿼리를 작성하는 데 사용하는 query builder를 생성.

const users = await manager
    .createQueryBuilder()
    .select()
    .from(User, "user")
    .where("user.name = :name", { name: "John" })
    .getMany()

 

hasId - 전달된 엔티티가 정의된 primary 컬럼 속성을 갖는지 체크.(primary key인지)

if (manager.hasId(user)) {
    // ... do something
}

 

getId - 전달된 엔티티의 primary 컬럼 속성 값을 얻어옴. 만약 엔티티가 복합 기본 키(composite primary key)를 갖는다면 반환된 값은 primary 컬럼의 이름과 값으로 된 객체임.

const userId = manager.getId(user) // userId === 1

 

create - User의 새 인스턴스를 생성. 선택적으로 새로 생성된 user 객체에 작성된 user 속성을 갖는 object literal을 받음.

const user = manager.create(User) // same as const user = new User();
const user = manager.create(User, {
    id: 1,
    firstName: "Timber",
    lastName: "Saw",
}) // same as const user = new User(); user.firstName = "Timber"; user.lastName = "Saw";

 

merge - multiple 엔티티를 single 엔티티로 병합.

const user = new User()
manager.merge(User, user, { firstName: "Timber" }, { lastName: "Saw" }) 
// same as user.firstName = "Timber"; user.lastName = "Saw";

 

preload - 전달된 plain JavaScript 객체에서 새 엔티티를 생성. 엔티티가 데이터베이스에 이미 존재하면, 그 엔티티와 관련된 모든 값을 불러오고 모든 값을 지정된 객체의 새 값으로 대체한 다음 새 엔티티를 반환. 새 엔티티는 실제 데이터베이스에서 불러와지며 모든 속성이 새 객체에 대체됨.

const partialUser = {
    id: 1,
    firstName: "Rizzrak",
    profile: {
        id: 1,
    },
}
const user = await manager.preload(User, partialUser)
// user will contain all missing data from partialUser with partialUser property values:
// { id: 1, firstName: "Rizzrak", lastName: "Saw", profile: { id: 1, ... } }

 

save - 지정된 엔티티 또는 엔티티 배열을 저장. 엔티티가 데이터베이스에 아직 존재하지 않으면 삽입. 지정된 모든 엔티티를 단일 트랜잭션에서 저장함(entity manager의 경우 트랜잭션이 아님). 정의되지 않은 속성은 모두 건너뛰기 때문에 부분 업데이터도 지원. 값을 NULL로 만드려면 속성을 수동으로 null과 같도록 설정해야 함.

await manager.save(user)
await manager.save([category1, category2, category3])

 

remove - 지정된 엔티티 또는 엔티티 배열을 제거. 지정된 모든 엔티티를 단일 트랜잭션에서 제거함.

await manager.remove(user)
await manager.remove([category1, category2, category3])

 

insert - 새 엔티티 또는 엔티티 배열을 삽입.

await manager.insert(User, {
    firstName: "Timber",
    lastName: "Timber",
})

await manager.insert(User, [
    {
        firstName: "Foo",
        lastName: "Bar",
    },
    {
        firstName: "Rizz",
        lastName: "Rak",
    },
])

 

update - 전달된 업데이트 옵션  혹은 엔티티 id에 따라 엔티티를 부분적으로 업데이트함.

await manager.update(User, { age: 18 }, { category: "ADULT" })
// executes UPDATE user SET category = ADULT WHERE age = 18

await manager.update(User, 1, { firstName: "Rizzrak" })
// executes UPDATE user SET firstName = Rizzrak WHERE id = 1

 

upsert - 이미 존재하는 엔티티가 아니라면 새 엔티티 또는 엔티티 배열을 삽입하며, 이 경우 대신 업데이트됨.(AuroraDataApi, Cockroach, Mysql, Postgres, sqlite 데이터베이스 드라이버들에서 지원됩니다.)

await manager.upsert(
    User,
    [
        { externalId: "abc123", firstName: "Rizzrak" },
        { externalId: "bca321", firstName: "Karzzir" },
    ],
    ["externalId"],
)
/** executes
 *  INSERT INTO user
 *  VALUES
 *      (externalId = abc123, firstName = Rizzrak),
 *      (externalId = cba321, firstName = Karzzir),
 *  ON CONFLICT (externalId) DO UPDATE firstName = EXCLUDED.firstName
 **/

 

delete - 엔티티 id, ids,혹은 주어진 조건에 맞게 엔티티를 삭제.

await manager.delete(User, 1)
await manager.delete(User, [1, 2, 3])
await manager.delete(User, { firstName: "Timber" })

 

increment - 주어진 옵션을 만족하는 제공된 엔티티의 값에 의해 특정 컬럼을 증가.

await manager.increment(User, { firstName: "Timber" }, "age", 3)

 

decrement - 주어진 옵션을 만족하는 제공된 엔티티의 값에 의해 특정 컬럼을 감소.

await manager.decrement(User, { firstName: "Timber" }, "age", 3)

 

exist - 모든 엔티티가 FindOptions와 일치하는 엔티티가 있는지 확인

const exists = await manager.exists(User, {
    where: {
        firstName: "Timber",
    },
})

 

existBy - FindOptionWhere와 일치하는 엔티티가 있는지 확인.

const exists = await manager.existsBy(User, { firstName: "Timber" })

 

count - FindOptions와 일치하는 엔티티의 갯수. pagination 시 유용.

const count = await manager.count(User, {
    where: {
        firstName: "Timber",
    },
})

 

countBy - FindOptionWhere과 일치하는 엔티티의 갯수. pagination 시 유용.

const count = await manager.countBy(User, { firstName: "Timber" })

 

find - 전달된 FindOptions와 일치하는 엔티티를 찾음.

const timbers = await manager.find(User, {
    where: {
        firstName: "Timber",
    },
})

 

findBy - 전달된 FindWhereOptions와 일치하는 엔티티를 찾음.

const timbers = await manager.findBy(User, {
    firstName: "Timber",
})

 

findAndCount - 전달된 FindOptions와 일치하는 엔티티를 찾음. 또한 주어진 조건과 일치하는 모든 엔티티를 계산하지만 pagination 설정은 무시함.

const [timbers, timbersCount] = await manager.findAndCount(User, {
    where: {
        firstName: "Timber",
    },
})

 

findAndCountBy - 전달된 FindOptionsWhere와 일치하는 엔티티를 찾음. 또한 주어진 조건과 일치하는 모든 엔티티를 계산하지만 pagination은 무시함.(from과 take 옵션)

const [timbers, timbersCount] = await manager.findAndCountBy(User, {
    firstName: "Timber",
})

 

 findOne - 전달된 FindOptions와 일치하는 첫 번째 엔티티를 찾음.

const timber = await manager.findOne(User, {
    where: {
        firstName: "Timber",
    },
})

 

findOneBy - FindOptionsWhere와 일치하는 첫 번째 엔티티를 찾음.

const timber = await manager.findOneBy(User, { firstName: "Timber" })

 

findOneOrFail - 특정 id나 find options와 일치하는 첫 번째 엔티티를 찾음. 만약 일치하는 것이 없으면 반환된 promise를 reject

const timber = await manager.findOneOrFail(User, {
    where: {
        firstName: "Timber",
    },
})

 

findOneByOrfail = 전달된 FindOptions와 일치하는 첫 번째 엔티티를 찾음. 만약 일치하는 것이 없으면 반환된 promise를 reject

const timber = await manager.findOneByOrFail(User, { firstName: "Timber" })

 

clear - 전달된 테이블의 모든 데이터를 비움.(truncates/drop)

await manager.clear(User)

 

getRepository - 특정 엔티티에 대한 작업을 수행하는 Repository를 가져옴.

const categoryRepository = manager.getTreeRepository(Category)

 

getTreeRepository - 특정 엔티티에 대한 작업을 수행하는 TreeRepository를 가져옴.

const userRepository = manager.getMongoRepository(User)

 

withRepository - 한 트랜잭션에서 사용되는 custom repository를 가져옴.

const myUserRepository = manager.withRepository(UserRepository)

 

release - entity manger의 query runner를 release. query runner가 수동으로 생성되고 관리될 때만 사용됨.

await manager.release()

 

 

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.