Module, Controller, Service, Repository

kakasoo·2022년 4월 23일
6

NestJS

목록 보기
5/7

차근차근 Nest에 대해서 설명하고자 한다. 이 글에서는 Controller와 Service, Repository를 어떻게 구분하면 좋을지를 설명한다. 당연히 각 역할대로 구분하면 된다는, 간단한 소개가 되겠지만, 그 각 역할이 뭔지 이야기 해보자.

Module


@Module({
  imports: [OtherModule],
  controllers: [SomeController],
  providers: [SomeService],
  exports : [SomeService],
})
export class SomeModule {}

Express 유저라면 처음 Nest를 접하고 나서 이해되지 않는 내부 동작에 놀랄 것이다. 너무 친절한 나머지 개발자가 이해해야 할 부분까지 다 알아서 해버리니 말이다. 그래서 아무렇게나 시작하게 되고, 버그를 잡는 데에 한 세월이 걸리고... 그러다가 결국 포기하고 마는 것이 Nest이다. 개인적으로 이런 부분은 아쉽다고 생각한다. 하지만 Node.js를 써본 사람이라면, 이를 프로토타입에 빗대서 이해할 수 있겠다. 아니, Node.js 유저가 아니더라도, 객체지향적이라고 생각하면 더 이해가 쉬울 것이다.

Module은 각 기능들에 의존성 주입을 해줘서, 하나의 '완성된 기능 단위'를 형성한다. 따라서 Module은 imports 배열 안에 다른 Module을 import 해서 쓸 수 있다. 이 경우, import된 대상 Module이 export한 기능을 가져다 쓸 수 있게 된다.

즉, 위 코드로 얘기하면, SomeModule은 OtherModule에서 export한 어떤 기능, 아마도 Service를 자유롭게 가져다가 쓸 수 있다는 뜻이다. 이건 클래스의 상속과 매우 유사한 이야기이다. Module도 클래스가 메서드를 찾는 과정과 같이, 자신이 찾고 있는 Service나 또 다른 기능 대상이 보이지 않을 경우, 자신에게 import된 Module에서 찾아오기 때문이다. 모듈에 대한 설명을 간단하게 마치고, 다른 코드를 통해 다음 이야기를 해보자.

ProductsModule

import { Module } from '@nestjs/common';
import { ProductsService } from '../providers/products.service';
import { ProductsController } from '../controllers/products.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from '../models/tables/product';

@Module({
  imports: [TypeOrmModule.forFeature([Product])],
  controllers: [ProductsController],
  providers: [ProductsService],
})
export class ProductsModule {}

상품을 다루는 모듈이 있다고 가정해보자. 이 모듈은 TypeORM으로 Product Entity를 가져다가 쓰고 있을 것이다. 이 또한 다른 Module의 기능을 가져다 쓰고 있다고 볼 수 있을 것이다. 그도 그럴 게, 앞으로의 코드에서는 Product라고 하는, 미리 정의된 Entity를 사용하고 있기 때문이다. Entity는 DB table을 클래스로 구현한 매핑 클래스다. 이 mapping class를 TypeORM을 이용해 사용할 것이기 때문에 일단 ProductsModule은 TypeOrmModule이라는 라이브러리 코드를 import하고 있다.

다음으로는 Controller와 Service를 보자. 간단하게 상품에 대한 Controller와 Service를 사용하고 있을 것이다. Module은 Module 내부의 Controller와 Service에 의존성 주입을 해주는 존재이다. 그래서, 이 안의 대부분의 기능들이 한 개의 instance가 되게끔 도움을 준다. 추후 볼 코드에서는 ProductsController가 ProductsService를 생성자로 받는 걸 볼 텐데, 이런 경우 Module은 ProductsService를 자동으로 ProductsController의 생성자 parameter로 제공한다. 이런 것을, 의존성 주입 (DI, Dependency Injection) 이라고 한다. 의존성 주입이라는 주제 하나만으로도 글을 쓸 수 있을 테니, 이에 대한 설명은 일단 미루고, 지금은 간단하게, 가장 작은 사이즈로 프로그램을 유지할 수 있게 만들어주는 마법으로만 이해하면 되겠다.

ProductsController

import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { ProductsService } from '../providers/products.service';

@ApiTags('상품 / Products')
@Controller('api/products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  @ApiOperation({ summary: 'MVP : 상품의 상세 내역을 조회' })
  @ApiParam({ name: 'id', description: 'productId' })
  @Get('id')
  async getDetail(@Param('id', ParseIntPipe) productId: number) {
    return await this.productsService.getDetail(productId);
  }
}

Contrller는 Service를 주입하여서 사용하고 있을 것이다. 여기서 ProductsController는 ProductsService를 주입해서 사용하고 있을 것이다. 이런 게 가능한 이유는 앞서 말했듯이 Module이 알아서 ProductsController를 생성하는 과정에서, ProductsService를 파라미터로 주기 때문이다. NestJS에 익숙하다면, 사실 이 모든 의존성 주입 과정이 각 클래스에 대한 'name'을 식별해서 넣어주고 있음을 알 것이다. 간단히 말해서 우리가 저 Controller와 Service를 ProductsController, ProductsService라고 부르듯이, Module도 두 클래스의 이름을 인지하고 있기 때문에 가능하다는 뜻이 된다. 이를 위해서 서비스 쪽에는 특별한 decorator가 추가될 필요가 있다. 바로, 의존성 주입의 대상이 된다는 decorator이다.

ProductsService

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from '../models/tables/product';
import { Repository } from 'typeorm';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productsRepository: Repository<Product>,
  ) {}

  async getDetail(productId: number) {
    return await this.productsRepository
      .createQueryBuilder('product')
      .withDeleted()
      .leftJoinAndMapMany('prouct.headers', 'product.headers', 'header')
      .leftJoinAndMapMany('product.bodies', 'product.bodies', 'body')
      .leftJoinAndMapMany(
        'product.categories',
        'product.categories',
        'category',
        'category.deletedAt IS NULL',
      )
      .leftJoinAndMapMany(
        'product.options',
        'product.options',
        'option',
        'option.deletedAt IS NULL AND option.isSale = :isSale',
        { isSale: true },
      )
      .where('product.id = :productId', { productId })
      .andWhere('product.isSale = :isSale', { isSale: true })
      .getOne();
  }
}

간단한 쿼리문과 함께, Service에 대한 정의를 해보았다. 이 Service는 상품의 상세 내역을 가져오는 method를 가진 Service이다. 이 Service Class의 상단을 보면 @Injectable() 이라는 데코레이터를 가지고 있는 걸 볼 수 있다. 이 decorator가 바로, 의존성 주입의 대상이 된다는 뜻을 지칭하는 decorator이다. 이런 클래스를 Module의 Provider로 넣어주면, Module은 이 클래스를 요구하는 기능들을 발견할 때마다 알아서 제공하게 된다. 따라서, 각 기능들은 각각의 서비스들을 생성할 필요없이, 하나의 서비스를 공유하는, 최적화된 모듈을 만들 수 있게 된다. 여기서 기능이라고 말한 까닭은, 반드시 이 기능을 요구하는 코드가 Controller이 필요는 없기 때문이다.

Controller와 Service의 구분

Controller는 클라이언트의 Request를 받아들이는 역할을 한다. 따라서 Controller는 요청에 대한 Params, Body, Query, Header... 등 다양한 요소들을 검증하는 관문 역할을 수행한다. Service는 요청이 유효한 경우, 그 요청이 요구하는 실질적인 비즈니스 로직을 담당한다. 따라서 Controller는 Request에 부응하기 위한 각종 Service 들을 주입받아야 한다.

Repository

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from '../models/tables/product';
import { Repository } from 'typeorm';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productsRepository: Repository<Product>,
  ) {}
}

Repository는 사실 TypeORM이 제공해주는 패턴 중 하나이다. TypeORM은 Active Record 라고 불리는 패턴을 위해 BaseEntity라는 클래스를 제공해주기도 하는데, Repository는 이와 다른, 또 다른 패턴으로, 개인적인 생각으로는 Active Record보다 더 유용한 패턴이다. 안타깝게도 아직은 완전하지 못하지만 추후 typeorm 0.3.x 에서는 나아질 것이다! Repository는 Entity의 속성들을 토대로, 간단한 쿼리 정도는 method를 이용해 바로 호출할 수 있게 도와준다. 그게 안 될 경우에는, 사용자가 직접 작성해주어야 하지만, 그러다보면 백엔드 특성 상 비즈니스 로직의 대부분이 DB와의 관계를 나타내는 데에만 쓰인다는 점을 볼 수 있다. 앞서 보여준 ProductsService에서도, 코드가 대부분 쿼리로만 이루어져 있었다. 개인적으로, Service는 비즈니스의 논리만 있어야 한다고 믿는다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Product } from '../models/tables/product';
import { Repository } from 'typeorm';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(Product)
    private readonly productsRepository: Repository<Product>,
  ) {}

  async getDetail(productId: number) {
    const product = this.repository.getProduct(productId);
	if (!product) {
      throw Error('There is not product!');
    }
    return product;
}

위 코드는 동작할 수 없는, 가상의 코드이다.

하지만 나는, 이와 같은 형식으로 코드가 존재해야 한다고 믿는다. 서비스는, DB 쿼리를 일일히 추적하지 않고서도 이해할 수 있는 상태여야 한다. 위 코드는, 상품을 id를 이용해서 가져오고 없을 경우에는 에러를 뱉도록 한다. 있을 때는 그 상품을 그대로 반환해서 Controller에게 돌려준다. 이렇게 코드를 작성하는 편이 훨씬 더 직관적임을 알 수 있다. 따라서 query 로직만을 다른 곳으로 이전할 수 있는데, 이를 위해 Repository를 조금 확장해서 사용하는 것이다. 이와 같은 방식을 Custom Repository라고 한다.

Custom Repository

import { EntityRepository, Repository } from 'typeorm';
import { Product } from '../tables/product';

@EntityRepository(Product)
export class ProductsRepository extends Repository<Product> {
  async getProduct(productId: number) {
    return await this.createQueryBuilder('product')
      .withDeleted()
      .leftJoinAndMapMany('prouct.headers', 'product.headers', 'header')
      .leftJoinAndMapMany('product.bodies', 'product.bodies', 'body')
      .leftJoinAndMapMany(
        'product.categories',
        'product.categories',
        'category',
        'category.deletedAt IS NULL',
      )
      .leftJoinAndMapMany(
        'product.options',
        'product.options',
        'option',
        'option.deletedAt IS NULL AND option.isSale = :isSale',
        { isSale: true },
      )
      .where('product.id = :productId', { productId })
      .andWhere('product.isSale = :isSale', { isSale: true })
      .getOne();
  }
}

Custom Repository는 이와 같이, Repository를 상속받아서, 그 안에 제너릭으로 Entity를 줘서 만든다. 서비스 쪽에서도 이제 코드를 조금 바꾸어줘야 한다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ProductsRepository } from 'src/models/repositories/products.repository';

@Injectable()
export class ProductsService {
  constructor(
    @InjectRepository(ProductsRepository)
    private readonly productsRepository: ProductsRepository,
  ) {}

  async getDetail(productId: number) {
    const product = await this.productsRepository.getProduct(productId);
    if (!product) {
      throw new Error('there is not product!');
    }
    return product;
  }
}

바뀐 점은, @InjectRepository decorator의 대상이 Product Entity가 아니라 ProductsRepository가 되었다는 점과, 타입이 Repository이 아니라 ProductsRepository가 되었다는 점이다. 이렇게 명시하면 우리가 명시한 Custom Repository method를 자유롭게 쓸 수 있다. 다음으로 Module 코드를 변경해보자.

import { Module } from '@nestjs/common';
import { ProductsService } from '../providers/products.service';
import { ProductsController } from '../controllers/products.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductsRepository } from 'src/models/repositories/products.repository';

@Module({
  imports: [TypeOrmModule.forFeature([ProductsRepository])],
  controllers: [ProductsController],
  providers: [ProductsService],
})
export class ProductsModule {}

마찬가지로 Module도 Product 대신 ProductsRepository를 사용하도록 변경되었다.

결론

Controller는 Request와 Response 사이의 검증을 담당한다. 실질적인 비즈니스 로직의 수행은 Service들이 담당한다. Controller는 여러 개의 서비스를 가질 수도 있다. Service 내부의 쿼리 로직은 비즈니스 로직이 어떻게 동작하는가를 담고 있지 않다. 따라서 이 쿼리 로직들은 Custom Repository로 모두 뺀다. 이렇게 할 경우 쿼리 중복을 제거하여 코드를 더 가볍게 만들 수 있다는 장점도 있다.

profile
자바스크립트를 좋아하는 "백엔드" 개발자

0개의 댓글