[4] ORM으로 생산성 높이고 첫 API 완성하기

Kim TaeHyeong·2일 전

UMC

목록 보기
10/10

gpt가 써줌

NestJS + TypeORM 구조 이해하기

NestJS와 TypeORM을 처음 사용하면 Module, Controller, Service, Entity, Repository, DTO가 한꺼번에 등장해서 구조가 헷갈릴 수 있다.

책(Book) API를 기준으로 전체적인 흐름을 보면 다음과 같다.

Client
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
TypeORM
  ↓
Database

DTO는 요청 데이터를 검증하거나 응답 데이터를 정의하는 데 사용한다.


1. Module

Module은 관련된 Controller, Service, Repository 등을 하나의 기능 단위로 묶고 NestJS의 의존성을 관리하는 역할을 한다.

@Module({
  imports: [
    TypeOrmModule.forFeature([BookEntity]),
  ],
  controllers: [BookController],
  providers: [BookService],
})
export class BookModule {}

BookModule에서는 BookController와 BookService를 사용하고, BookEntity의 Repository를 주입받을 수 있도록 설정한다.

forRoot()와 forFeature()

TypeOrmModule.forRoot(...)

애플리케이션의 TypeORM DataSource와 DB 연결을 설정한다.

TypeOrmModule.forFeature([BookEntity])

해당 Module의 DI 컨테이너에서 BookEntity에 대한 Repository를 주입받을 수 있도록 등록한다.


2. Controller

Controller는 HTTP 요청을 받아 적절한 Service를 호출하고 결과를 응답하는 역할을 한다.

@Controller('books')
export class BookController {
  constructor(
    private readonly bookService: BookService,
  ) {}

  @Get()
  findAll() {
    return this.bookService.findAll();
  }
}

따라서

GET /books
    ↓
BookController
    ↓
BookService

와 같은 흐름이 만들어진다.

Controller에 DB 조회 로직이나 복잡한 비즈니스 로직을 직접 작성하기보다는 Service에 맡기는 것이 일반적이다.


3. Service

Service는 비즈니스 로직을 처리하는 곳이다.

@Injectable()
export class BookService {
  constructor(
    @InjectRepository(BookEntity)
    private readonly bookRepository: Repository<BookEntity>,
  ) {}

  async findAll(): Promise<BookResponseDto[]> {
    const books = await this.bookRepository.find({
      relations: {
        category: true,
      },
      order: {
        bookId: 'DESC',
      },
    });

    return books.map(BookResponseDto.from);
  }
}

@InjectRepository(BookEntity)를 사용하면 NestJS가 BookEntity에 연결된 Repository를 주입해준다.

여기서 중요한 점은 Repository를 직접 구현한 것이 아니라 TypeORM이 제공하는 Repository를 NestJS의 DI를 통해 주입받는 것이다.


4. Entity

Entity는 TypeORM이 데이터베이스 테이블과 매핑하기 위해 사용하는 클래스다.

@Entity('book')
export class BookEntity {
  @PrimaryGeneratedColumn({
    name: 'book_id',
    type: 'bigint',
  })
  bookId!: string;

  @Column({
    type: 'varchar',
    length: 100,
  })
  title!: string;

  @Column({
    type: 'text',
    nullable: true,
  })
  description!: string | null;
}

여기서

@Entity('book')

은 이 Entity가 book 테이블에 매핑된다는 의미이고,

@Column({ name: 'book_id' })
bookId!: string;

처럼 설정하면 bookId 프로퍼티와 book_id 컬럼이 매핑된다.

또한 Entity에는 테이블 간 관계도 표현할 수 있다.

@ManyToOne(() => CategoryEntity, (category) => category.books)
@JoinColumn({
  name: 'category_id',
  referencedColumnName: 'categoryId',
})
category!: Relation<CategoryEntity>;

이 경우 BookEntity와 CategoryEntity 사이의 ManyToOne 관계를 TypeORM에 알려준다.


5. Repository

Repository는 특정 Entity를 대상으로 데이터베이스 작업을 수행하기 위한 TypeORM의 객체다.

Repository<BookEntity>

를 통해 다음과 같은 작업을 할 수 있다.

bookRepository.find();

bookRepository.findOne(...);

bookRepository.save(book);

bookRepository.delete(...);

예를 들어:

const books = await this.bookRepository.find({
  relations: {
    category: true,
  },
  order: {
    bookId: 'DESC',
  },
});

는 BookEntity를 기준으로 데이터를 조회하고, category 관계도 함께 가져오며, bookId를 내림차순으로 정렬하는 코드다.

처음에는 TypeORM이 제공하는 기본 Repository를 사용하는 것만으로 충분하다.


6. Relation

Entity 사이에 관계가 있다면 TypeORM의 Relation을 사용할 수 있다.

예를 들어 하나의 Category에 여러 Book이 존재한다면:

CategoryEntity
    │
    └── books ──→ BookEntity

Book에서는:

@ManyToOne(() => CategoryEntity, (category) => category.books)
category!: Relation<CategoryEntity>;

Category에서는:

@OneToMany(() => BookEntity, (book) => book.category)
books!: Relation<BookEntity[]>;

처럼 양쪽 관계를 정의할 수 있다.

그리고 실제 조회할 때:

const books = await bookRepository.find({
  relations: {
    category: true,
  },
});

처럼 작성하면 book.category 관계 데이터를 함께 조회할 수 있다.


7. DTO

DTO(Data Transfer Object)는 API를 통해 주고받는 데이터의 구조를 정의하기 위해 사용하는 객체다.

요청 DTO

export class CreateBookDto {
  @IsInt()
  @Type(() => Number)
  categoryId: number;

  @IsNotEmpty()
  @MaxLength(100)
  title: string;

  @IsOptional()
  @IsString()
  description?: string;
}

이 DTO는 책을 생성할 때 클라이언트가 보내는 데이터를 정의한다.

{
  "categoryId": 1,
  "title": "국부론",
  "description": "경제학 서적"
}

class-validator와 class-transformer를 사용하면 요청 데이터의 검증과 변환도 할 수 있다.

응답 DTO

Entity를 API 응답으로 그대로 반환하는 대신 필요한 데이터만 DTO로 변환할 수 있다.

export class BookResponseDto {
  bookId: number;
  title: string;
  description: string | null;
  categoryName: string;
  isAvailable: boolean;

  static from(book: BookEntity): BookResponseDto {
    return {
      bookId: +book.bookId,
      title: book.title,
      description: book.description,
      categoryName: book.category.name,
      isAvailable: book.isAvailable,
    };
  }
}

따라서:

BookEntity
    ↓
BookResponseDto.from()
    ↓
BookResponseDto
    ↓
HTTP Response

형태로 응답 데이터를 만들 수 있다.


8. 실제 요청의 전체 흐름

GET /books 요청이 들어왔다고 가정하면:

Client
  │
  │ GET /books
  ↓
BookController
  │
  │ findAll()
  ↓
BookService
  │
  │ bookRepository.find()
  ↓
TypeORM Repository
  │
  ↓
Database
  │
  ↓
BookEntity[]
  │
  │ BookResponseDto.from()
  ↓
BookResponseDto[]
  │
  ↓
HTTP Response

결국 각각의 역할은 다음과 같이 정리할 수 있다.

구성 요소역할
Module기능을 묶고 NestJS의 의존성을 구성
ControllerHTTP 요청을 받고 응답
Service비즈니스 로직 처리
RepositoryEntity를 대상으로 DB 작업 수행
EntityDB 테이블과 컬럼 및 관계를 TypeORM에 정의
DTOAPI 요청/응답 데이터의 구조 정의

핵심

NestJS + TypeORM에서는 Controller가 HTTP 요청을 받고, Service가 로직을 처리하며, Repository를 통해 Entity에 매핑된 데이터를 조회·저장하고, 필요한 경우 DTO로 변환해서 응답한다.

처음에는 아래 구조만 기억해도 충분하다.

HTTP
 ↓
Controller
 ↓
Service
 ↓
Repository
 ↓
Entity ↔ Database

0개의 댓글