컴퓨터 공학(Computer Science & Engineering))/디자인 패턴

[객체지향원칙] DIP 의존성 역전 - nestjs를 이용하여 todo 구현

멍개. 2022. 8. 28. 16:22

 

앞의 영상은 제가 즐겨보는 채널입니다. 해당 채널을 보다보면 최근들어 DI(Dependency Inversion)에 관련해서 언급을 많이하고 있습니다.

그렇다면 DI가 머길래 열정적으로 DI를 언급하는것일까요? 이번포스트에서는 DI가 무엇인지 살펴보고 DI가 잘 적용된 nestjs 프레임워크를 살펴보면서 왜 DI를 사용해야 하는지 알아보겠습니다. 추가적으로 리액트에서 DI를 넣어보도록 하겠습니다.

먼저, 우리는 선행되어야 하는 지식이 있습니다. 이 포스트의 제목에 등작한 DIP부터 시작하여 DI, IoC입니다.

 

● 의존성 역전 원칙이란?

의존성 역전 원칙(Dependency Inversion Principle)은 DIP라고도 부릅니다. 의존성 역전 원칙의 사전적 의미는 다음과 같습니다.

객체 지향 프로그래밍에서 의존관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 이 원칙은 다음과 같은 내용을 담고 있다.[1]

첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.

둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

이 원칙은 '상위와 하위 객체 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공한다.[2]

내용이 상당히 어려워보이죠? 앞의 내용을 한 문장으로 해석하면 인터페이스를 활용하라는 것 입니다. 여기서 DI(방법)와 IoC(개념)가 나오게 됩니다.

DIP는 원칙이며 DI는 DIP를 위해 사용되는 방법이고 IoC는 DI 했을때 발생하는 현상입니다.

자 우선 우리는 DIP에서 D(Dependency)I(Inversion)가 무엇을 의미하는지 살펴보겠습니다.

· 의존성 주입이란?

우리가 코드를 작성할 때 가장 중요한 점 중 하나로 의존성을 낮추는 것을 의미합니다. 그럼 의존성이 머냐? 의존성이 높다는 코드는 new를 통해 객체를 만드는 행위입니다. 간단한 예시 코드를 살펴보겠습니다. 의존성을 낮추기 위해 의존성을 외부에서 주입을 합니다.

class MacProByIntel {

}

class Mung {
  mac: MacProByIntel;
  
  buyIntel() {
    this.mac = new MacProByIntel();
  }
}

Mung 클래스는 MacProByIntel을 받을 수 있습니다. 여기서 M1 CPU와 M2 CPU를 구매하는 메서드를 추가해보겠습니다.

class MacProByIntel {

}
class MacProByM1 {

}
class MacProByM2 {

}

class Mung {
  mac: MacProByIntel;
  
  buyIntel() {
    this.mac = new MacProByIntel();
  }
  
  buyM1() {
    this.mac = new MacProByM1();
  }
  
  buyM2() {
    this.mac = new MacProByM2();
  }
}

Mung 클래스에서 MacProByIntel, MacProByM1, MacProByM2를 new하여 직접 객체를 만드는 것을 의존성이 높다고 표현합니다.

 

여기서 Mung이 상위 모듈이며 Mac이 하위 모듈입니다.

class MacProByIntel {

}
class MacProByM1 {

}
class MacProByM2 {

}

class Mung {
  mac: MacProByIntel;
  
  buyIntel(mac: MacProByIntel) {
    this.mac = mac;
  }
  
  buyM1(mac: MacProByM1) {
    this.mac = mac;
  }
  
  buyM2(mac: MacProByM2) {
    this.mac = mac;
  }
}

객체를 직접 생성하지 않고 객체를 전달받는 형태로 구성이 되면 의존성이 낮아졌다고 합니다. 이런 형태를 의존성 주입이라고 합니다.

· 추상화

DIP의 정의를 살펴보면 가장 많이 나오는 단어가 추상화입니다. 추상화란 인터페이스를 의미합니다. 즉 객체를 의존하지 말고 추상화를 의존하라는 것을 의미합니다. 여기서 IoC가 발생하게 됩니다.

앞에서 DI는 방법, IoC는 개념이라고 했는데 DI를 통해 IoC가 발생합니다.

class Mac {

}

class MacProByIntel implements Mac{

}
class MacProByM1 implements Mac{

}
class MacProByM2 implements Mac{

}

class Mung {
  mac: Mac;
  
  buy (mac: Mac) {
    this.mac = mac
  }
}

이를 우리는 추상화에 의존했다고 표현합니다. 하위모듈인 MacProByIntel, MacProByM1, MacProByM2는 인터페이스를 통해 구현되어집니다. 또한 Mung은 각각의 하위모듈이 아닌 추상화 된 Mac 인터페이스를 받도록 합니다.

 

좌측에서 우측 처럼 화살표의 방향이 바뀌었습니다. 여기서 역전의 의미가 나오게 됩니다. 즉, IoC가 발생한 것 입니다.

DIP를 정리하면 DI를 이용하여 의존성을 낮추고 인터페이스를 이용하여 IoC 현상이 발생하는 것을 의미합니다.

DIP 원칙을 따르면 OOP 원칙 중 OCP(Open Close Principle)인 개방 폐쇠 원칙을 지킬 수 있습니다. 만약, 애플이 M3, M4, M5를 계속 만들어 낸다면 우리는 Mac 클래스를 상속받은 M3, M4, M5만 만들어 주면 됩니다. 수정에 대해서 기존 코드를 건드리지 않아도 됩니다. 확장은 자유롭고 수정은 폐쇠되어지게 됩니다.

여기서 멍개는 인터페이스(abstract)를 통해 그 구현체(implementation)에 대한 의존성을 가지게 된다고 표현합니다.

● DI(Dependency Injection)을 통한 개발은 어떻게 진행되나?

우리가 사용하는 다양한 라이브러리 및 프레임워크는 DI를 위해 컨테이너를 제공합니다.

https://blog.naver.com/pjt3591oo/222386896479

 

[typescript] typedi를 이용하여 Dependency Injection(DI) 이해하기

안녕하세요 멍개입니다. 타입스크립트의 typedi를 활용하여 DI(Dependency Injection)를 알아보도록 하...

blog.naver.com

 

또한 스프링, Nestjs, Angular, FastAPI 등이 DI를 지원하는 프레임워크입니다. 해당 프레임워크의 특징은 객체를 관리하는 컨테이너를 제공합니다.

nestjs를 이용하여 Todo앱을 만들어 보며 DI가 어떤 이점이 있는지 살펴보겠습니다.

· 데이터베이스 생성

$ docker run --name mysql-container -e MYSQL_ROOT_PASSWORD=password -d -p 3306:3306 mysql:5.7

다음으로 데이터베이스를 생성합니다.

$ docker exec -it mysql-container /bin/bash

$ mysql -u root -ppassword

mysql>
mysql> CREATE DATABASE todo;

· nestjs 프로젝트 생성

$ nest new todo
$ tree . -I node_modules
.
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json

2 directories, 13 files

nestjs는 module를 컨테이너라고 부릅니다. module을 통해 객체를 등록하게 됩니다. nestjs는 module에 등록된 객체를 컨트롤러나 서비스에게 주입하여 줍니다. 즉, 우리가 new를 직접하지 않소 nestjs가 new된 결과를 주입하게 됩니다.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

controllers에 providers에 명시된 항목을 주입합니다.

// src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

nestjs는 AppController에게 AppService를 주입합니다.

· 필요 라이브러리 설치

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

· 데이터베이스 모듈 만들기

nestjs는 필요한 코드를 providers로 생성 후 module을 통해 export 하는 구조로 모듈을 생성합니다.

// database/database.providers.ts

import { DataSource } from 'typeorm';

export const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        type: 'mysql',
        host: 'localhost',
        port: 3306,
        username: 'root',
        password: 'password',
        database: 'todo',
        entities: [
            __dirname + '/../**/*.entity{.ts,.js}',
        ],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

이제 모듈을 만들어 줍니다.

// database/database.module.ts

import { Module } from '@nestjs/common';
import { databaseProviders } from './database.providers';

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders],
})
export class DatabaseModule {}

· todo 앱 생성

$ nest g module todo
CREATE src/todo/todo.module.ts (81 bytes)
UPDATE src/app.module.ts (308 bytes)

$ nest g service todo
CREATE src/todo/todo.service.spec.ts (446 bytes)
CREATE src/todo/todo.service.ts (88 bytes)
UPDATE src/todo/todo.module.ts (155 bytes)

$ nest g controller todo
CREATE src/todo/todo.controller.spec.ts (478 bytes)
CREATE src/todo/todo.controller.ts (97 bytes)
UPDATE src/todo/todo.module.ts (240 bytes)
$ tree . -I node_modules
.
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   ├── database
│   │   ├── database.module.ts
│   │   └── database.providers.ts
│   ├── main.ts
│   └── todo
│       ├── todo.controller.spec.ts
│       ├── todo.controller.ts
│       ├── todo.module.ts
│       ├── todo.service.spec.ts
│       └── todo.service.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json

4 directories, 20 files

· todo entity 생성

src/todo/entity/todo.entity.ts를 생성하여 다음과 같이 정의합니다.

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column({ length: 500 })
  name: string;

  @Column('text')
  description: string;

  @Column('int')
  isComplete: number;
}

이제 todo 모듈에 등록된 컨트롤러와 프로바이더에서 데이터베이스를 사용할 수 있도록 하겠습니다

src/todo/todo.module.ts를 다음과 같이 수정합니다.

import { Module } from '@nestjs/common';
import { TodoService } from './todo.service';
import { TodoController } from './todo.controller';

import { DataSource } from 'typeorm';
import { Todo } from './entity/todo.entity';
import { DatabaseModule } from 'src/database/database.module';

@Module({
  imports: [DatabaseModule],
  providers: [
    {
      provide: 'TODO_REPOSITORY',
      useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
      inject: ['DATA_SOURCE'],
    },
    TodoService
  ],
  controllers: [TodoController]
})
export class TodoModule { }

· 서비스 생성

서비스는 데이터베이스와 연결된 Todo 레포지토리 객체를 전달받을 수 있습니다. src/todo/todo.service.ts를 다음과 같이 작성합니다.

import { Injectable, Inject } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Todo } from './entity/todo.entity';

@Injectable()
export class TodoService {
  constructor(
    @Inject('TODO_REPOSITORY')
    private todoRepository: Repository<Todo>,
  ) {}

  async findAll(): Promise<Todo[]> {
    return this.todoRepository.find();
  }

  async find(id): Promise<Todo> {
    return this.todoRepository.findOne({where: id});
  }

  async create(todo: Todo): Promise<Todo> {
    return this.todoRepository.save(todo);
  }

  async update(id, todo: Todo): Promise<Todo> {
    await this.todoRepository.update(id, todo);
    return this.todoRepository.findOne({where: id});
  }

  async delete(id): Promise<Todo> {
    const todo = await this.todoRepository.findOne({where: id});
    await this.todoRepository.remove(todo);
    return todo;
  }
}

자 이제 우리가 해야할 일은? 해당 서비스가 정상적으로 동작하는지 테스트를 해봐야겠죠? 여기서 의존성 주입이 빛을 보게 됩니다.

여기서 Entity 타입으로 처리하지면 DTO를 만들어 처리하는것을 권장합니다 (본 글에서는 Entity 타입을 그대로 이용합니다).

· todo 서비스 테스트 하기

src/todo/todo.service.spec.ts에 todoService 테스트 코드를 작성합니다.

import { Test, TestingModule } from '@nestjs/testing';
import { TodoService } from './todo.service';

describe('TodoService', () => {
  let service: TodoService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [TodoService],
    }).compile();

    service = module.get<TodoService>(TodoService);
  });

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

여기서 우리는 import로 전달하는 데이터베이스 소스를 원래 연결된 디비가 아닌 모킹된 객체를 만들어 넘길 수 있습니다. 하지만 우리는 sqlite로 연결시키겠습니다.

import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Module } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { TodoService } from './todo.service';

import { Todo } from './entity/todo.entity';

const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/example.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders]
})
export class DatabaseModule {}

describe('TodoService', () => {
  let service: TodoService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule],
      providers: [
        {
          provide: 'TODO_REPOSITORY',
          useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
          inject: ['DATA_SOURCE'],
        },
        TodoService
      ],
    }).compile();

    service = module.get<TodoService>(TodoService);
  });

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

sqlite 사용을 위해 필요한 라이브러리를 다운받습니다.

$ npm install -D sqlite3

그리고 package.json의 scripts의 test를 다음과 같이 수정합니다.

{
  "script": {
    "test": "rm -rf data_sqlite && jest",
  }
}

전체 테스트 코드는 다음과 같습니다.

import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Module } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { TodoService } from './todo.service';

import { Todo } from './entity/todo.entity';

const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/example.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders]
})
export class DatabaseModule {}

describe('TodoService', () => {
  let service: TodoService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule],
      providers: [
        {
          provide: 'TODO_REPOSITORY',
          useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
          inject: ['DATA_SOURCE'],
        },
        TodoService
      ],
    }).compile();

    service = module.get<TodoService>(TodoService);
  });

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

  it('조회', async () => {
    const res = await service.findAll();
    expect(res.length).toBe(0);
  })
  
  it('생성', async () => {
    const todo = new Todo();
    todo.name = '생성';
    todo.description = '생성';
    todo.isComplete = 0;
    await service.create(todo);
    
    const res = await service.findAll();
    expect(res.length).toBe(1);
  })
  
  it('상태변경', async () => {
    let todo = await service.find(1);
    todo.isComplete = 1
    await service.update(1, todo);

    todo = await service.find(1);

    expect(todo.isComplete).toBe(1);
  })
  
  it('삭제', async () => {
    await service.delete(1);

    const res = await service.findAll();
    expect(res.length).toBe(0);
  })
});

서비스가 원하는 형태로 잘 동작합니다. 또한 테스트 환경과 API가 동작하는 환경에서 완전히 분리된 데이터베이스 소스를 가지기 때문에 테스트를 통해 데이터베이스가 더렵혀지지 않습니다.

여기서 한 가지 정보를 알려드리면 sqlite는 bool 타입을 지원하지 않기 때문에 앞에서 entity를 만들때 isComplete를 bool 타입이 아닌 int 타입으로 생성했던겁니다.

· 컨트롤러 만들기

자 이제 해당 서비스를 주입받아 사용자에게 요청을 받고 응답하는 컨트롤러를 만들어봅니다.

파일명: src/todo/todo.controller.ts

import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { Todo } from './entity/todo.entity';
import { TodoService } from './todo.service';

@Controller('todo')
export class TodoController {
  constructor(
    private readonly todoService: TodoService,
  ) {}

  @Get()
  findAll(): Promise<Todo[]> {
    return this.todoService.findAll();
  }

  @Get(':id')
  find(@Param('id') id): Promise<Todo> {
    return this.todoService.find(id);
  }

  @Post()
  create(@Body() todo: Todo): Promise<Todo> {
    return this.todoService.create(todo);
  }

  @Put(':id')
  update(@Param('id') id, todo: Todo): Promise<Todo> {
    return this.todoService.update(id, todo);
  }

  @Delete(':id')
  delete(@Param('id') id): Promise<Todo> {
    return this.todoService.delete(id);
  }
}

이제 해당 컨트롤러가 잘 동작하는지 살펴봐야 겠죠 같은 경로에 있는 todo.controller.spec.ts를 통해 단위 테스트가 가능하고 /test에서 e2e 테스트를 만들 수 있습니다.

 

· todo 컨트롤러 테스트

먼저 단위 테스트를 수행하겠습니다.

파일명: src/todo/todo.cotroller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TodoController } from './todo.controller';

import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Module } from '@nestjs/common';
import { TodoService } from './todo.service';

import { Todo } from './entity/todo.entity';

const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/controller.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders]
})
export class DatabaseModule {}

describe('TodoController', () => {
  let controller: TodoController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule],
      providers: [
        {
          provide: 'TODO_REPOSITORY',
          useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
          inject: ['DATA_SOURCE'],
        },
        TodoService
      ],
      controllers: [TodoController],
    }).compile();

    controller = module.get<TodoController>(TodoController);
  });

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

  it('조회', async() => {
    const res = await controller.findAll();
    expect(res.length).toBe(0);
  })

  it('생성', async () => {
    const todo = new Todo();
    todo.name = '생성';
    todo.description = '생성';
    todo.isComplete = 0;
    await controller.create(todo);
    
    const res = await controller.findAll();
    expect(res.length).toBe(1);
  })
  
  it('상태변경', async () => {
    let todo = await controller.find(1);
    todo.isComplete = 1
    await controller.update(1, todo);

    todo = await controller.find(1);

    expect(todo.isComplete).toBe(1);
  })
  
  it('삭제', async () => {
    await controller.delete(1);

    const res = await controller.findAll();
    expect(res.length).toBe(0);
  })
});

이와 마찬가지로 API를 직접 호출하는 e2e 테스트에서도 별도의 데이터베이스 소스를 생성하여 테스트를 진행할 수 있습니다.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, Module } from '@nestjs/common';
import * as request from 'supertest';
import { DataSource } from 'typeorm';
import { Todo } from '../src/todo/entity/todo.entity';
import { TodoService } from '../src/todo/todo.service';
import { TodoController } from '../src/todo/todo.controller';

const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/e2e.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders]
})
export class DatabaseModule {}

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule],
      providers: [
        {
          provide: 'TODO_REPOSITORY',
          useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
          inject: ['DATA_SOURCE'],
        },
        TodoService
      ],
      controllers: [TodoController],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/todo (GET)', () => {
    return request(app.getHttpServer())
      .get('/todo')
      .expect(200)
      .expect([]);
  });

  it('/todo (POST)', () => {
    return request(app.getHttpServer())
      .post('/todo')
      .send({
        name: '생성',
        description: '생성',
        isComplete: 0,
      })
      .expect(201)
      .expect({
        name: '생성',
        description: '생성',
        isComplete: 0,
      });
  })
});

package.json의 script에서 test:e2e를 다음과 같이 수정합니다.

{
  "scripts" :{
    "test:e2e": "rm -rf data_sqlite && jest --config ./test/jest-e2e.json"
  }
}

추가적으로 컨트롤러나 서비스는 또 사이드 이펙트가 존재하는 프로바이더를 주입받을 수 있습니다. 이 또한 테스트를 진행할 때 사이드 이펙트가 모킹되거나 스파이를 통해 테스트를 진행할 수 있습니다.

여기서 핵심은 DI를 하게되면 코드의 확장성도 좋아지지만 테스트 코드를 작성하기가 매우 쉬워집니다.

여기까지 보면 DIP는 추상화된 인터페이스를 이용하도록 되어있는데 여기서는 그렇게 진행하진 않았습니다.

· 사이드 이펙트가 존재하는 서비스

일반적으로 사이드 이펙트의 대표적인 예시가 네트워크 통신입니다. 여기서는 랜덤값을 반환하는 함수로 대체하여 진행합니다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class SideEffectService {
  constructor(
  ) {}

  async getRandom(): Promise<number> {
    return Math.random();
  }
}
// src/sideEffect/sideEffect.module.ts
import { Module } from '@nestjs/common';
import { SideEffectService } from './sideEffect.service';

@Module({
  providers: [{
    provide: 'SIDE_EFFECT_SERVICE',
    useFactory: () => {
      return SideEffectService;
    }
  }],
  exports: [{
    provide: 'SIDE_EFFECT_SERVICE',
    useFactory: () => {
      return SideEffectService;
    }
  }],
})
export class SideEffectModule {}

이제 이를 todo에서 사용가능하도록 하겠습니다.

todo.module.ts에서 해당 모듈을 imports에 추가하면 됩니다.

import { Module } from '@nestjs/common';
import { TodoService } from './todo.service';
import { TodoController } from './todo.controller';

import { DataSource } from 'typeorm';
import { Todo } from './entity/todo.entity';
import { DatabaseModule } from 'src/database/database.module';
import { SideEffectModule } from 'src/sideEffect/sideEffect.module';

@Module({
  imports: [DatabaseModule, SideEffectModule],
  providers: [
    {
      provide: 'TODO_REPOSITORY',
      useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
      inject: ['DATA_SOURCE'],
    },
    TodoService
  ],
  controllers: [TodoController]
})
export class TodoModule { }

todo.controller.ts에서 SideEffectService를 주입받고 Post() 요청 핸들러인 create()에서 desciption을 앞에서 만든 랜덤값을 추가하여 저장하도록 합니다.

import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
import { SideEffectService } from '../sideEffect/sideEffect.service';
import { Todo } from './entity/todo.entity';
import { TodoService } from './todo.service';

@Controller('todo')
export class TodoController {
  constructor(
    private readonly todoService: TodoService,
    @Inject('SIDE_EFFECT_SERVICE') private readonly sideEffectService: SideEffectService,
  ) {}

  @Get()
  findAll(): Promise<Todo[]> {
    return this.todoService.findAll();
  }

  @Get(':id')
  find(@Param('id') id): Promise<Todo> {
    return this.todoService.find(id);
  }

  @Post()
  async create(@Body() todo: Todo): Promise<Todo> {
    const random = await this.sideEffectService.getRandom();
    todo.description = `${todo.description} ${random}`;
    return this.todoService.create(todo);
  }

  @Put(':id')
  update(@Param('id') id, todo: Todo): Promise<Todo> {
    return this.todoService.update(id, todo);
  }

  @Delete(':id')
  delete(@Param('id') id): Promise<Todo> {
    return this.todoService.delete(id);
  }
}

이 친구를 우리는 어떻게 테스트할 수 있을까요? 자 우선 서버를 구동한 후 API 요청을 해보겠습니다

> SELECT * FROM todo;
+----+------+-------------------------+------------+
| id | name | description             | isComplete |
+----+------+-------------------------+------------+
|  1 | name | test 0.9163431432608733 |          0 |
+----+------+-------------------------+------------+
1 row in set (0.00 sec)

· 사이드 이펙트 테스트

자 이제 우리는 e2e를 통해 SideEffect를 호출해서 우리가 원하는대로 description에 추가해서 저장되는지 테스트를 수행해보겠습니다.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, Injectable, Module, Query } from '@nestjs/common';
import * as request from 'supertest';
import { DataSource } from 'typeorm';
import { Todo } from '../src/todo/entity/todo.entity';
import { TodoService } from '../src/todo/todo.service';
import { TodoController } from '../src/todo/todo.controller';

const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/e2e.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders]
})
class DatabaseModule {}

@Injectable()
class SideEffectService {
  constructor() {}

  async getRandom(): Promise<number> {
    return 11234;
  }
}

@Module({
  providers: [{
    provide: 'SIDE_EFFECT_SERVICE',
    useClass: SideEffectService,
  }],
  exports: [{
    provide: 'SIDE_EFFECT_SERVICE',
    useClass: SideEffectService,
  }],
})
class SideEffectModule{}

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule, SideEffectModule],
      providers: [
        {
          provide: 'TODO_REPOSITORY',
          useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
          inject: ['DATA_SOURCE'],
        },
        TodoService, 
      ],
      controllers: [TodoController],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/todo (GET)', () => {
    return request(app.getHttpServer())
      .get('/todo')
      .expect(200)
      .expect([]);
  });

  it('/todo (POST)', () => {
    return request(app.getHttpServer())
      .post('/todo')
      .type('application/json')
      .send({
        name: '생성',
        description: '생성',
        isComplete: 0,
      })
      .expect(201)
      .expect({
        name: '생성',
        description: '생성 11234',
        isComplete: 0,
        id: 1
      });
  })
});

기존의 모듈을 만들었던 부분에서 고정된 랜덤값을 반환하는 SideEffectService()를 주입합니다.

@Injectable()
class SideEffectService {
  constructor() {}

  async getRandom(): Promise<number> {
    return 11234;
  }
}

TodoController는 주입된 서비스를 정상적으로 동작하여 description이 수정되어 저장되는 모습을 확인할 수 있습니다.

만약 네트워크 통신을 하는 부분이라면 존재하는 에러 코드를 반환하도록 모킹한 후 해당 에러코드에 대해서 원하는 방향으로 동작하는지 테스트가 가능합니다.

todo.controller.spec도 테스트 코드 수정이 필요합니다.

import { Test, TestingModule } from '@nestjs/testing';
import { TodoController } from './todo.controller';

import { DataSource } from 'typeorm';
import { Module } from '@nestjs/common';
import { TodoService } from './todo.service';

import { Todo } from './entity/todo.entity';

const databaseProviders = [
  {
    provide: 'DATA_SOURCE',
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/controller.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders]
})
export class DatabaseModule {}

@Module({
  providers: [{
    provide: 'SIDE_EFFECT_SERVICE',
    useClass: SideEffectService,
  }],
  exports: [{
    provide: 'SIDE_EFFECT_SERVICE',
    useClass: SideEffectService,
  }],
})
class SideEffectModule{}

describe('TodoController', () => {
  let controller: TodoController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule, SideEffectModule],
      providers: [
        {
          provide: 'TODO_REPOSITORY',
          useFactory: (dataSource: DataSource) => dataSource.getRepository(Todo),
          inject: ['DATA_SOURCE'],
        },
        TodoService
      ],
      controllers: [TodoController],
    }).compile();

    controller = module.get<TodoController>(TodoController);
  });

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

  it('조회', async() => {
    const res = await controller.findAll();
    expect(res.length).toBe(0);
  })

  it('생성', async () => {
    const todo = new Todo();
    todo.name = '생성';
    todo.description = '생성';
    todo.isComplete = 0;
    await controller.create(todo);
    
    const res = await controller.findAll();
    expect(res.length).toBe(1);
  })
  
  it('상태변경', async () => {
    let todo = await controller.find(1);
    todo.isComplete = 1
    await controller.update(1, todo);

    todo = await controller.find(1);

    expect(todo.isComplete).toBe(1);
  })
  
  it('삭제', async () => {
    await controller.delete(1);

    const res = await controller.findAll();
    expect(res.length).toBe(0);
  })
});

· DIP 적용하기 - 방법1

nestjs에선 DIP를 아주 쉽게 적용할 수 있습니다. module에 전달하는 providers에서 provider를 문자열이 아니라 인터페이스를 넣어주면 됩니다.

 
@Module({
  providers: [{
    provide: SideEffectService,
    useClass: SideEffectService,
  }],
  exports: [{
    provide: SideEffectService,
    useClass: SideEffectService,
  }],
})
class SideEffectModule{}

해당 프로바이더를 주입받는 클래스는 다음과 같이 주입 inject를 사용합니다.

  constructor(
    private readonly todoService: TodoService,
    @Inject(SideEffectService) private readonly sideEffectService: SideEffectService,
  ) {}

하지만 서비스 자체를 넣는 방법보다는 인터페이스와 함께 상수를 정의하여 사용하는 방법을 권장합니다.

// src/sideEffect/ISideEffect.interface.ts
export const SIDE_EFFECT_TOKEN = "SIDE_EFFECT";

export interface ISideEffect {
  getRandom:() => Promise<number>;
};
// src/sideEffect/sideEffect.service.ts
import { Injectable } from '@nestjs/common';
import { ISideEffect } from './ISideEffect.interface';

@Injectable()
export class SideEffectService implements ISideEffect{
  constructor(
  ) {}

  async getRandom(): Promise<number> {
    return Math.random();
  }
}
// src/sideEffect/sideEffect.module.ts
import { Module } from '@nestjs/common';
import { SIDE_EFFECT_TOKEN } from './ISideEffect.interface';
import { SideEffectService } from './sideEffect.service';

@Module({
  providers: [{
    provide: SIDE_EFFECT_TOKEN,
    useFactory: () => {
      return SideEffectService;
    }
  }],
  exports: [{
    provide: SIDE_EFFECT_TOKEN,
    useFactory: () => {
      return SideEffectService;
    }
  }],
})
export class SideEffectModule {}

이에 따라서 SideEffectService를 주입받는 todo 컨트롤러의 생성자도 수정합니다.

... 중략

@Controller('todo')
export class TodoController {
  constructor(
    private readonly todoService: TodoService,
    @Inject(SIDE_EFFECT_TOKEN) private readonly sideEffectService: ISideEffect,
  ) {}
}

또한 테스트 코드에서도 주입 시 provide 이름을 변경해줍니다.

· DIP 적용 - 서비스 그대로 활용

앞의 방법으로 한다면 provide를 문자열로 해야하는 단점이 있습니다. 즉, 실수로 이미 사용중인 값을 사용할 수 있는데 이땐 서비스 자체를 provide로 넘기고 @Inject()에 넘겨주면 됩니다.

// src/sideEffect/sideEffect.module.ts

import { Module } from '@nestjs/common';
import { SideEffectService } from './sideEffect.service';

@Module({
  providers: [
    {
      provide: SideEffectService,
      useFactory: () => {
        return SideEffectService;
      },
    },
  ],
  exports: [
    {
      provide: SideEffectService,
      useFactory: () => {
        return SideEffectService;
      },
    },
  ],
})
export class SideEffectModule {}

provide가 바뀌었으니 controller에서 @Inject()를 수정합니다.

// src/todo/todo.contoller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Inject,
} from '@nestjs/common';

import { Todo } from './entity/todo.entity';
import { TodoService } from './todo.service';
import { SideEffectService } from '../sideEffect/sideEffect.service';

@Controller('todo')
export class TodoController {
  constructor(
    private readonly todoService: TodoService,
    @Inject(SideEffectService) // 변경
    private readonly sideEffectService: SideEffectService,
  ) {}

  @Get()
  findAll(): Promise<Todo[]> {
    return this.todoService.findAll();
  }

  @Get(':id')
  find(@Param('id') id): Promise<Todo> {
    return this.todoService.find(id);
  }

  @Post()
  async create(@Body() todo: Todo): Promise<Todo> {
    const random = await this.sideEffectService.getRandom();
    todo.description = `${todo.description} ${random}`;
    return this.todoService.create(todo);
  }

  @Put(':id')
  update(@Param('id') id, todo: Todo): Promise<Todo> {
    return this.todoService.update(id, todo);
  }

  @Delete(':id')
  delete(@Param('id') id): Promise<Todo> {
    return this.todoService.delete(id);
  }
}

또한 테스트 코드에서도 다음과 같이 수정합니다.

// src/todo/todo.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TodoController } from './todo.controller';

import { DataSource } from 'typeorm';
import { Module } from '@nestjs/common';
import { TodoService } from './todo.service';

import { Todo } from './entity/todo.entity';
import { SideEffectService } from '../sideEffect/sideEffect.service';

const databaseProviders = [
  {
    provide: DataSource,
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/controller.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders],
})
export class DatabaseModule {}

describe('TodoController', () => {
  let controller: TodoController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule],
      providers: [
        {
          provide: Todo,
          useFactory: (dataSource: DataSource) =>
            dataSource.getRepository(Todo),
          inject: [DataSource],
        },
        { provide: SideEffectService, useValue: { getRandom: () => 11234 } },
        TodoService,
      ],
      controllers: [TodoController],
    }).compile();

    controller = module.get<TodoController>(TodoController);
  });

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

  it('조회', async () => {
    const res = await controller.findAll();
    expect(res.length).toBe(0);
  });

  it('생성', async () => {
    const todo = new Todo();
    todo.name = '생성';
    todo.description = '생성';
    todo.isComplete = 0;
    await controller.create(todo);

    const res = await controller.findAll();
    expect(res.length).toBe(1);
  });

  it('상태변경', async () => {
    let todo = await controller.find(1);
    todo.isComplete = 1;
    await controller.update(1, todo);

    todo = await controller.find(1);

    expect(todo.isComplete).toBe(1);
  });

  it('삭제', async () => {
    await controller.delete(1);

    const res = await controller.findAll();
    expect(res.length).toBe(0);
  });
});

// test/todo.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, Module } from '@nestjs/common';
import * as request from 'supertest';
import { DataSource } from 'typeorm';
import { Todo } from '../src/todo/entity/todo.entity';
import { TodoService } from '../src/todo/todo.service';
import { TodoController } from '../src/todo/todo.controller';

import { SideEffectService } from '../src/sideEffect/sideEffect.service';

const databaseProviders = [
  {
    provide: DataSource,
    useFactory: async () => {
      const dataSource = new DataSource({
        name: 'todo',
        type: 'sqlite',
        database: './data_sqlite/e2e.sq3',
        entities: [Todo],
        synchronize: true,
      });

      return dataSource.initialize();
    },
  },
];

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders],
})
class DatabaseModule {}

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [DatabaseModule],
      providers: [
        {
          provide: Todo,
          useFactory: (dataSource: DataSource) =>
            dataSource.getRepository(Todo),
          inject: [DataSource],
        },
        { provide: SideEffectService, useValue: { getRandom: () => 11234 } },
        TodoService,
      ],
      controllers: [TodoController],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/todo (GET)', () => {
    return request(app.getHttpServer()).get('/todo').expect(200).expect([]);
  });

  it('/todo (POST)', () => {
    return request(app.getHttpServer())
      .post('/todo')
      .type('application/json')
      .send({
        name: '생성',
        description: '생성',
        isComplete: 0,
      })
      .expect(201)
      .expect({
        name: '생성',
        description: '생성 11234',
        isComplete: 0,
        id: 1,
      });
  });
});