kakasoo

Clean Architecture (NestJS) 본문

프로그래밍/NestJS

Clean Architecture (NestJS)

카카수(kakasoo) 2023. 6. 25. 19:13
반응형

어떤 기능을 구현하다보면 외부 요소에 대한 의존성을 강하게 가지게 되는 경우가 있다.

가장 간단한 예시로, 우리는 어떤 데이터베이스를 사용하느냐에 따라 내부 로직이 변경될 것이다.

Postgres나 MySQL을 쓰는 경우에, 그 둘의 내부 함수 명이 다르기 때문에 영향을 받게 될 것이고,

만약 ORM을 사용한다고 하더라도 MongoDB와 같은 NoSQL을 사용하면 또 영향을 받게 된다.

데이터베이스 말고도 유저가 보내는 요청들이 무엇인지에 따라서도 서비스는 영향을 받게 될 것이다.

요청은 우리가 제어하는 것 아니냐 반문할 수 있겠지만, 모바일인지 웹인지에 따라서 처리 방식이 다르다.

우리의 핵심 비즈니스 로직들을 내부 라고 정의할 때, 이런 영향을 주는 요소들을 전부 외부 라고 불러보자.

Clean Architectrue란 이런 문제를 해결하기 위한 설계다.

하지만 데이터베이스와 같이 의존 관계를 가지는 경우는 반드시 발생할 텐데 이를 어떻게 해결하는 걸까?

Clean Architecture가 말하는 해결 방법은 이 의존 관계를 역전하여 영향을 받지 않게 설계하는 것이다.

쉽게 말하면, 내부의 구현체들은 외부의 구현체가 어떻게 구현되어 있는지를 전혀 모르게끔 설계한다.

 

NestJS 개발자들은 Entities들에 익숙할 것이다.

이 Entities들에 대해서 일단 UseCases라는 계층을 정의하는데, 이 계층은 사용 사례를 의미한다.

만약 Cat 이라는 Entity가 있다면 UseCases는 책의 CRUD를 의미하는 각종 사례들이 될 것이다.

그리고 컨트롤러는 그 CRUD에 맞게 유저가 요청을 보낼 수 있는 진입점이 될 것이고,

External Interface는 web, mobile, 그리고 DB와 같이 서버 바깥에 존재하는 유저나 인프라가 된다.

인프라와 유저가 둘 다 외부 구조가 되는 것은 조금 이상하다고 느껴지는가?

이건 레이어드 아키텍처에서도 사실 비슷한 구조였을 텐데, 이렇게 놓고 보면 더 이해가 쉬울 것이다.

Clean Architecture가 말하는 해결 방법은 이 의존 관계를 역전하여 영향을 받지 않게 설계하는 것

어떤 요청이 들어오고 나갈 때, 그 비즈니스 로직을 처리하는 구간은 요청과 응답 사이에 존재하게 된다.

따라서 여기를 기준으로 다시 재정의를 한다면, 컨트롤러는 DB와 서비스를 주입받는다고 이해하면 되고,

그 DB와 서비스가 각기 어떻게 구체화되어 있는지를 전혀 모르게 설계하면 되는 것이다.

Controllers

import { Controller, Get, Inject, Post, Put } from '@nestjs/common';
import { CatFactoryService } from 'src/use-cases/cat/cat-factory.service';
import { CAT_FACTORY_SERVICE, CAT_USE_CASES } from 'src/use-cases/cat/cat-use-cases.module';
import { CatUseCases } from 'src/use-cases/cat/cat.use-case';

@Controller('api/cats')
export class CatsController {
  constructor(
    @Inject(CAT_USE_CASES) private readonly catUseCases: CatUseCases,
    @Inject(CAT_FACTORY_SERVICE) private readonly catFactoryService: CatFactoryService,
  ) {}

  @Put(':id')
  async updateCat() {}

  @Get(':id')
  async getOne() {}

  @Get()
  async getAll() {}

  @Post()
  async createCat() {}
}

위 코드는 CatController를 정의한 모습이다.

내부의 코드는 아직 작성되지 않았지만, 일단 주입되어 있는 두 UseCases, FactoryService를 보자.

NestJS에 익숙하다면 컨트롤러에 주입된 두 개의 객체가 서비스인 것을 바로 알 수 있을 것이다.

( 단, 여기서 말하는 서비스는 레이어 개념에서의 서비스인데, 나중에는 외부 인터페이스를 지칭할 것이다. )

UseCases는 이제 실질적인 비즈니스 로직으로, 우리가 흔히 말하는 CRUD 로직을 담당할 것이다.

FactoryService는 해당 엔티티 객체를 메모리 상에서 생성, 수정하는 로직만을 담당할 것이다.

두 객체는 보다시피 컨트롤러에 주입되어 있지만, 여기서는 어떻게 구현되었는지 알 수 없다.

FactoryService가 이해하기 어렵다면 TypeORM의 BaseEntity 정도로 생각해도 된다.

TypeORM을 잘 모른다면, 여기서만 등장하는 특이한 형태의 서비스 정도로만 알고 넘어가자.

UseCases

import { Injectable } from '@nestjs/common';
import { IDataServices } from 'src/core/abstracts/data-services.abstract';
import { Cat } from 'src/core/entities/cat.entity';

@Injectable()
export class CatUseCases {
  constructor(private readonly dataServices: IDataServices) {}

  async getAllCats(): Promise<Cat[]> {
    const cats = await this.dataServices.cats.getAll();
    return cats;
  }

  async getCatById(id: string): Promise<Cat> {
    const cat = await this.dataServices.cats.get(id);
    return cat;
  }

  async createCat(cat: Cat): Promise<Cat> {
    try {
      const createdCat = await this.dataServices.cats.create(cat);
      return createdCat;
    } catch (e) {
      throw e;
    }
  }

  async updateCat(catId: string, cat: Cat): Promise<boolean> {
    try {
      const isSucess = await this.dataServices.cats.update(catId, cat);
      return isSucess;
    } catch (e) {
      return false;
    }
  }
}

바로 위에서 사용한 CatUseCases도 마찬가지로, 주입받은 데이터베이스가 무엇인지 알 수 없다.

각 인터페이스 ( = 메서드 )를 통해 무엇을 할 수 있는지만 알 뿐, 어떻게 구현되어 있는지 알 수 없다.

심지어 MongoDB인지 MySQL인지도 알 수 없게끔 구현되어있다.

타입으로 명시된 IDataServices를 보면 알 수 있을까?

 

import { Cat } from '../entities/cat.entity';
import { Owner } from '../entities/owner.entity';
import { IGenericRepository } from './generic-repository.abstact';

export abstract class IDataServices {
  abstract cats: IGenericRepository<Cat>;
  abstract owners: IGenericRepository<Owner>;
}

여기서 사용한 IDataServices는 추상 클래스로 정의된 타입일 뿐, 실질적인 구현체를 가지고 있지 않다.

따라서 타입을 봐도 내부 동작을 알 수 없다.

각 레이어는 이처럼 서로 어떻게 구현되어 있는지를 알 수 없게, 그리고 알 필요없게 작성된다.

 

타입만 같고, 실제 구현은 다른

// cat-use-cases.module.ts
import { Module } from '@nestjs/common';
import { CatFactoryService } from './cat-factory.service';
import { CatUseCases } from './cat.use-case';
import { DataServicesModule } from 'src/services/data-services/data-services.module';

export const CAT_FACTORY_SERVICE = Symbol('CAT_FACTORY_SERVICE');
export const CAT_USE_CASES = Symbol('CAT_USE_CASES');

@Module({
  imports: [DataServicesModule],
  controllers: [],
  providers: [
    { provide: CAT_FACTORY_SERVICE, useClass: CatFactoryService },
    { provide: CAT_USE_CASES, useClass: CatUseCases },
  ],
  exports: [CAT_FACTORY_SERVICE, CAT_USE_CASES],
})
export class CatModule {}
// data-services.module.ts
import { Module } from '@nestjs/common';
import { MongoDataServicesModule } from 'src/frameworks/mongo/mongo-data-services.module';

@Module({
  imports: [MongoDataServicesModule],
  exports: [MongoDataServicesModule],
})
export class DataServicesModule {}
// mongo-data-services.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { IDataServices } from 'src/core/abstracts/data-services.abstract';
import { Cat, CatSchema } from './model/cat.model';
import { Owner, OwnerSchema } from './model/owner.model';
import { DATA_BASE_CONFIGURATION } from 'src/configuration';
import { MongoDataServices } from './mongo-data-services.service';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: Cat.name, schema: CatSchema },
      { name: Owner.name, schema: OwnerSchema },
    ]),
    MongooseModule.forRoot(DATA_BASE_CONFIGURATION.mongoConnectionString),
  ],
  providers: [
    {
      provide: IDataServices,
      useClass: MongoDataServices,
    },
  ],
  exports: [IDataServices],
})
export class MongoDataServicesModule {}

cat-use-cases module을 보면 어떤 데이터베이스를 가져오는지는 결국 데이터베이스 서비스 모듈이다.

그리고 이 데이터베이스 서비스 모듈은 다시 mongo를 가져온다는 것을 알 수 있는데,

이는 MongoDataServicesModule은 IDataServices provide에 MongoDataServices를 주입한다.

즉, IDataServices로만 이해하고 있었을 모든 데이터 서비스는 사실 몽고였음을 알 수 있다.

여기서 클린 코드로 구성한 서버의 장점을 알 수 있다.

만약 mongo에서 다른 데이터베이스로 옮겨야 한다면 이제 새로운 데이터 서비스 모듈을 정의하면 된다.

그리고 그 모듈이 내보낼 구체적인 서비스 로직 역시 IDataServices의 조건에 맞게 구현하면 끝이다.

외부에 대한 의존성을 제거했기 때문에 새로운 외부 요소로 대체하는 것 역시 간단해진 것이고,

따라서 테스트하기 좋은 코드의 요건에도 부합한다.

 

반응형