본문 바로가기

Backend/NestJS

NestJS - 대충 서비스 만들어보기 (18)

이번 내용은 NestJS 라고 하기보단 클린 아키텍처 같은걸로 타이틀을 해야할 것 같긴 합니다만..

지금까지 만들었던 광고 서버의 구조를 좀 바꿔보려고 합니다.

 

 

현재까지 만든 디렉토리 구조는 위와 같습니다. 뭐좀 하다보니 이상한 위치에 있는 것처럼 보이는 것도 있습니다만..

아무래도 시작할 때 nest-cli 를 통해 하나의 모듈 단위로 컴퍼넌트를 만들었다 보니 광고 관련된 건 ads / 인증 관련된건 auth

그 외에도 config, slack, redis 등으로 나뉘어져 있죠.

가장 메인이라고 할 수 있는 광고쪽을 보면 흔히 알고 있는 layered architecture 라고 할 수 있습니다.

 

- Layered Architecture

 

 

각 계층의 이름은 경우에 따라 Presentation, Web, Business, Persistence 등으로 불리기도 합니다만 현재 작업해놓은 구조상으론

위와 같죠. 그럼 이걸 왜 바꾸려고 하는거지? 계층형 아키텍처가 잘못된건가? 라고 하실 수 있는데 꼭 그렇진 않습니다.

이분법적인 사고로 계층형 아키텍처는 쓰레기야 라고 하는건 아닐뿐더러 오히려 지금 정도 사이즈에선 굳이 구조를 바꾸는게

오바이기도 합니다만 포스팅을 위해 적어보자면.. 

 

우선 의존성은 아래를 향하고, 그 끝에는 영속성을 관리하는 레이어가 위치하게 되는데 그러다보니 개발에선 항상 Repository 를 먼저, 그 후에 Service 및 Controller 순서로 구현하게 됩니다. 가장 중요한건 도메인을 포함해 비즈니스 로직이어야 하는데 Repository 가 가장

중요시 다뤄지다보니 우선 순위에도 밀리고 ORM(DB) 과 강하게 엮이면서 Entity 는 순수한 비즈니스 로직을 가진 도메인 객체라고

바라보기 어려워집니다.(물론 지금까지 만든 Entity 가 뭘 들고 있지도 않긴 합니다만)

이런것들로 인해 Domain Driven 이 아닌 Database Driven 으로 가게되죠.

 

또한 이런 의존성 방향으로 인해 DB infra 를 교체하기도 꽤나 어렵습니다. 저수준의 모듈이 고수준의 모듈에 의존해야 한다는 (DIP)
내용은 다들 잘 알고 계실텐데, 오히려 반대로 도메인 영역(고수준)이 DB infra(저수준)를 의존하고 있으니 말이죠.

물론 이건 적은것처럼 구조적인 문제라기보단 DIP 를 적용해서 해결할 수 있지만 저런 의존성 방향을 가진 구조를 계층형 구조라고 하니

수정하는 순간 계층형 구조에선 벗어나는 것이지 않을까 싶군요.

 

그리고 어찌됐건 계층을 나눴다는 것은 계층 별로 책임을 분리하고 방향을 무시한 접근을 제한한다는건데 뭐이건 개발자마다 좀 다를 수

있습니다만 팀에서 작업하는 것들을 보면 그런 제한이 지켜지지 않는것을 꽤 자주 볼 수 있었습니다.

너무도 단순한 로직만 있는 부분의 경우엔 Controller 에서 Repository 에 바로 접근해 데이터를 조회하거나 저장하는 등의 방식을요.

계층을 나눈 의미도 퇴색되고, 테스트하기도 어려워집니다.

 

이런 것들때문에 Onion Architecture, 여기서 좀 더 발전한 Clean Architecture, Hexagonal Architecture 등의 구조들이 나오게 된 배경으로 보입니다. 여기서 이번엔 Clean Architecture 를 적용해볼까 합니다.

 

- Clean Architecture

 

저는 처음에 이 용어 자체가 추상적으로 깔끔한 아키텍처를 추구하는 어떠한 방법론들을 말하는건가 라고 생각했습니다만..

비교적 최근에 클린 아키텍처 라는 책을 읽으면서 뭔지 알게 되었습니다.

 

 

클린 아키텍처는 위와 같이 생겼고, 밖에 있는 원이 안에 있는 원에 의존성을 가지도록 합니다. 안에 있는 원이 밖에 있는 원에 의존성을

가져서는 안됩니다. 각 레이어를 그림에 써있는 대로 DB / Controller / UseCase / Entity 라고 하기도 하고, 혹은

Infra / Interface / Application / Domain 과 같은 이름을 붙이기도 합니다.

 

이렇게만 두면 의존성 방향을 지키지 못하는 상황이 여전히 발생할 수 있는데요.

그림에는 없지만 클린 아키텍처에선 의존성 규칙을 지키기 위해 포트와 어댑터 라는 것도 존재합니다.

일단 간단한 설명으로 보자면 유즈케이스 레이어에선 엔티티에 대한 의존성만 가질 수 있는데, 작업하다보면 영속성이나 외부 API 등이

필요할 때가 있습니다. 그냥 외부 인프라에 붙으면 의존성 규칙에 위배되므로 이럴때 포트 & 어댑터를 적용해 외부 요소가 무엇으로

구성되어있는지 알 필요 없이 외부 요소를 사용할 수 있도록 합니다.

근데 이건 적고보니 hexagonal architecture 랑 뭐가 다른건지 모르겠네요. 뭐 이건 나중에 따로 보도록 하겠습니다.

 

- 실제 구현

 

src/ads 광고 부분에 대해 클린 아키텍처를 적용해보도록 하겠습니다.

ads 하위에 infra / interface / application / domain 이렇게 4개의 디렉토리를 생성합니다.

구현은 중요한 순서대로 domain -> application -> interface -> infra 부터 가볼 생각입니다.

 

광고가 생성되었을 때 일전에 만들었던 광고가 생성되었다 라는 이벤트를 발송해야하고 이는 Ad 도메인 객체가 가지고 가는게 맞겠죠.

근데 Ad 객체는 new 키워드로 생성해야 하기 때문에 EventBus 를 주입받을 수 없습니다.

이런 경우를 해결하기 위해선 팩터리를 이용합니다.

 

import { CountryCode } from '../../types/country';

export class Ad {
  constructor(
    private id: number,
    private adType: string,
    private title: string,
    private thumbnail: string,
    private content: string,
    private country: CountryCode,
  ) {}
}

 

먼저 Ad 도메인 클래스를 domain/ad.ts 에 작성합니다. 간단합니다.

 

import { Injectable } from '@nestjs/common';
import { EventBus } from '@nestjs/cqrs';
import { AdCreatedEvent } from './ad-created.event';
import { Ad } from './ad';
import { CountryCode } from '../../types/country';

@Injectable()
export class AdFactory {
  constructor(private eventBus: EventBus) {}

  create(
    id: number,
    adType: string,
    title: string,
    thumbnail: string,
    content: string,
    country: CountryCode,
  ) {
    const ad = new Ad(id, adType, title, thumbnail, content, country);
    this.eventBus.publish(new AdCreatedEvent(adType, title, country));
    return ad;
  }
}

 

위에서 말했듯이 Ad 는 EventBus 를 주입받을 수 없으므로, 위와 같이 AdFactory 를 이용합니다.

Factory 가 Ad 객체를 생성하고 생성되었다는 이벤트를 게시합니다. AdFactory 또한 domain/ad.factory.ts 에 위치했구요.

이미 코드에 나와있는 것처럼 자연스럽게 AdCreatedEvent 또한 domain 디렉토리로 위치를 옮겨줍니다.

 

이제 domain 하위에는 ad.ts / ad.factory.ts / ad-created.event.ts 파일이 위치합니다.

 

import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { SlackService } from '../../slack/slack.service';
import { AdCreatedEvent } from '../domain/ad-created.event';

@EventsHandler(AdCreatedEvent)
export class AdEventsHandler implements IEventHandler<AdCreatedEvent> {
  constructor(private slackService: SlackService) {}

  async handle(event: AdCreatedEvent) {
    const { adType, title, country } = <AdCreatedEvent>event;
    await this.slackService.send(adType, title, country);
  }
}

 

AdEventHandler 는 AdCreatedEvent 경로 맞춘거말곤 크게 수정사항이 없지만, 위치를 application 하위로 옮겨줍니다.

얘가 들고 있는 로직은 핵심 도메인 로직은 아니지만 어플리케이션의 비즈니스 로직이기 떄문이죠.

커맨드 핸들러와 쿼리 핸들러도 비즈니스 로직을 들고 있으므로 application 에 이동시킵니다. 근데 이거 옮기려다보니 application

내에서 event / command / query 를 나눠주는게 좀 더 용이하겠네요.

 

import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { AdsRepository } from '../../ads.repository';

@CommandHandler(CreateAdCommand)
export class CreateAdHandler implements ICommandHandler<CreateAdCommand> {
  constructor(
    private readonly adsRepository: AdsRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateAdCommand): Promise<any> {
    await this.adsRepository.createUser(command);
  }
}

 

변경된 create-ad.handler.ts 입니다. 원래는 얘가 ad 를 생성하고 생성되었다는 이벤트를 직접 게시했으나

생성 이벤트는 이제 도메인 객체가 책임을 가져가므로 해당 부분만 날려주었습니다. create-ad.command.ts 와 함께

application/command 하위로 이동시켰습니다.

query 는 안에 파일 통째로 application/query 로 이동시키고 경로만 맞춰줍니다.

 

다음인 interface 는 현재 상황에선 dto 와 controller 를 옮겨줍니다.

아직은 그외에 딱히 할건 없습니다.

 

이제 infra 레이어에 DB 와 slack 관련된 것들을 다 집어 넣어야 합니다.

infra 에 일단 db 디렉토리를 생성하고, 그 하위에 entities / repository 를 만든 후 기존에 있던 ad.entity.ts 와 ads.repository.ts 를

옮겨줍니다. 경로 안맞는걸 수정하다보면 application 의 핸들러들에서 수정을 필요로 하는데요.

우리는 지금 의존성 방향을 생각하면서 클린 아키텍처를 적용중인데 현재 application 이 infra 레이어를 의존하게 되어있네요.

여기서 이제 DIP 를 적용해야합니다. 먼저 UserRepository 인터페이스를 domain 레이어에 만들어줍니다.

 

import { CountryCode } from '../../../types/country';
import { Ad } from '../ad';

export interface IAdsRepository {
  createUser: (
    adType: string,
    title: string,
    thumbnail: string,
    content: string,
    country: CountryCode,
  ) => Promise<number>;
  findAds: () => Promise<Array<Ad>>;
  findAdById: (id: number) => Promise<Ad>;
}

 

domain/repository 하위에 iads.repository.ts 를 위와 같이 작성합니다.

이제 infra 에 있는 ads.repository 가 이 interface 를 구현하도록 만들어줘야합니다.

 

import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Ad as AdEntity } from '../entities/ad.entity';
import { IAdsRepository } from '../../../domain/repository/iads.repository';
import { Ad } from '../../../domain/ad';
import { AdFactory } from '../../../domain/ad.factory';
import { CountryCode } from '../../../../types/country';

export class AdsRepository
  extends Repository<AdEntity>
  implements IAdsRepository
{
  constructor(
    @InjectRepository(AdEntity)
    private readonly repository: Repository<AdEntity>,
    private adFactory: AdFactory,
  ) {
    super(repository.target, repository.manager, repository.queryRunner);
  }

  async createUser(
    adType: string,
    title: string,
    thumbnail: string,
    content: string,
    country: CountryCode,
  ): Promise<number> {
    const ad = new AdEntity();
    ad.adType = adType;
    ad.title = title;
    ad.country = country;
    ad.thumbnail = thumbnail;
    ad.content = content;

    await this.repository.save(ad);

    return ad.id;
  }

  async findAds(): Promise<Array<Ad>> {
    const adEntityList = await this.repository.find();

    return adEntityList.map((adEntity) => {
      const { id, adType, title, thumbnail, content, country } = adEntity;

      return this.adFactory.reconstitute(
        id,
        adType,
        title,
        thumbnail,
        content,
        country as CountryCode,
      );
    });
  }

  async findAdById(id: number): Promise<Ad | null> {
    const adEntity = await this.repository.findOne({
      where: { id },
    });

    if (!adEntity) {
      return null;
    }

    const { adType, title, thumbnail, content, country } = adEntity;

    return this.adFactory.reconstitute(
      id,
      adType,
      title,
      thumbnail,
      content,
      country as CountryCode,
    );
  }
}

 

만들다보니 AdFactory 에 reconstitute 라는 메서드를 추가하게 됐습니다.

원래있는 메서드를 재활용하지 못한 이유는 Ad 객체 생성시 이벤트 발송이 같이 묶여있는데 find 는 새로 생성되었다는 이벤트는 아니므로

별도 메소드로 분리하게 됐습니다.

 

이제 이 인터페이스를 의존하도록 일부 핸들러를 수정해줍니다.

 

import { Inject } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateAdCommand } from './create-ad.command';
import { IAdsRepository } from '../../domain/repository/iads.repository';
import { AdFactory } from '../../domain/ad.factory';

@CommandHandler(CreateAdCommand)
export class CreateAdHandler implements ICommandHandler<CreateAdCommand> {
  constructor(
    @Inject('AdsRepository') private readonly adsRepository: IAdsRepository,
    private readonly adFactory: AdFactory,
  ) {}

  async execute(command: CreateAdCommand): Promise<any> {
    const { adType, title, thumbnail, content, country } = command;
    const id = await this.adsRepository.createUser(
      adType,
      title,
      thumbnail,
      content,
      country,
    );

    this.adFactory.create(id, adType, title, thumbnail, content, country);
  }
}

 

application/command 하위의 create-ad.handler.ts 입니다.

다른 부분은 거의 동일하지만 이제 이 비즈니스 로직에서 팩토리를 통해 광고가 생성되었다는 것 까지 해줘야하므로

repository 를 통해 user 생성시 id 를 받고, 그걸 이용해 팩토리를 통한 광고 생성 메서드를 호출합니다.

 

import { Inject } from '@nestjs/common';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IAdsRepository } from '../../domain/repository/iads.repository';
import { GetAdInfoQuery, GetAdsInfoQuery } from './get-ad-info.query';

@QueryHandler(GetAdInfoQuery)
export class GetAdInfoHandler implements IQueryHandler<GetAdInfoQuery> {
  constructor(
    @Inject('AdsRepository') private readonly adsRepository: IAdsRepository
  ) {}

  execute(query: GetAdInfoQuery): Promise<any> {
    const { adId } = query;
    return this.adsRepository.findAdById(adId);
  }
}

@QueryHandler(GetAdsInfoQuery)
export class GetAdsInfoHandler implements IQueryHandler<GetAdsInfoHandler> {
  constructor(private readonly adsRepository: IAdsRepository) {}

  execute(): Promise<any> {
    return this.adsRepository.findAds();
  }
}

 

application/query 하위의 get-ad-info.handler.ts 입니다. 여기는 뭐 특별히 코멘트 할건 없네요.

두 핸들러에서 주입받는 repository 대상을 지정해줬는데, 아래에서 관련 코드와 함께 설명을 덧붙이겠습니다.

 

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CqrsModule } from '@nestjs/cqrs';
import { AdsController } from './interface/ads.controller';
import { SlackModule } from '../slack/slack.module';
import { Ad } from './infra/db/entities/ad.entity';
import { AdsRepository } from './infra/db/repository/ads.repository';
import { AuthModule } from '../auth/auth.module';
import { CreateAdHandler } from './application/command/create-ad.handler';
import {
  GetAdInfoHandler,
  GetAdsInfoHandler,
} from './application/query/get-ad-info.handler';
import { AdEventsHandler } from './application/event/ad-events.handler';
import { AdFactory } from './domain/ad.factory';

@Module({
  imports: [
    SlackModule,
    TypeOrmModule.forFeature([Ad]),
    AuthModule,
    CqrsModule,
  ],
  controllers: [AdsController],
  providers: [
    CreateAdHandler,
    GetAdInfoHandler,
    GetAdsInfoHandler,
    { provide: 'AdsRepository', useClass: AdsRepository },
    AdEventsHandler,
    AdFactory,
  ],
})
export class AdsModule {}

 

변경된 AdsModule 입니다. 일단 크게 바뀐점은 필요한 곳에서 주입받아 사용할 수 있도록 domain/ad.factory 를 provider 로 

제공해주었습니다.

그리고 두번째는 AdsRepository 를 커스텀 프로바이더 형식으로 provider 에 제공한다는 점입니다.

우리가 DIP 를 위해 domain 레이어에 생성했던 IAdsRepository 는 인터페이스라서 클래스가 아니므로 의존성 클래스로 주입받을 수

없습니다. 따라서 Inject 데코레이터를 통해 AdsRepository 토큰으로 주입받을 수 있도록 커스텀 프로바이더를 사용하는 것입니다.

 

여기까지 하고 서버를 실행한 후 원래의 API 들을 그대로 요청해보면, 잘 동작하는 것을 확인할 수 있습니다.

 

 

src/ads 부분 변경 후 바뀐 디렉토리 구조입니다. aop 가 좀 뻘쭘하게 있는 것과 slack 모듈이 ads 모듈과 강결합되어 있는 부분도

다음 포스팅에서 클린 아키텍처를 적용해보도록 하겠습니다.