[Project] #1 아키텍처 설계는 흥미로운 경험이었다.

DatQueue·2024년 1월 15일
9
post-thumbnail

아키텍처 설계 배경 (NestJS)

아래에서 보여지겠지만, 현재 프로젝트의 베이스가 되는 아키텍처및 디자인 패턴 설계는 처음부터 마음먹고 진행된 것이 전혀 아니었고 일련의 배경이 존재하였다. 그 얘기를 먼저 진행해보고자 한다.

어떤 패턴을 사용하든, 어떤 패턴을 구축하든 일단 가장 먼저 이해하고 인지해야할 것은 바로 언어와 프레임워크라 생각한다. (그 중에서도 프레임워크가 될 수 있지 않을까 싶다.)

여태 NestJS를 어떻게 사용해 왔는가

NestJS를 통해 서버 개발에 입문하는 사람(취준생, 초입자 등등...)이 일반적으로 마주하게 되는 구조(Structure)는 아마 아래와 같지 않을까 싶다.

프로젝트를 진행하기 전, 주로 공식문서 베이스와 유데미에서 찾게 된 온라인 강의(2020년 기준의 강의였다...)하나를 통해 NestJS를 공부해왔던 나로서는 위의 아키텍처와 패턴이 항상 당연하게 여겨졌다.

HTTP request를 다루는 Controller, 그리고 Controller에서 담기엔 복잡한 태스크를 @Injectable()을 통한 위임으로 구현한 Service layer, 그리고 이를 이들의 구조를 정의하는 Module. 더하여, Service 클래스와 같이 @Injectable()을 통해 프로바이더로써 동작하게 되는 Guard, Pipe, Filter와 같은 Middleware 까지.

미들웨어 계층의 Enhancer들을 제외하면 각 도메인 별, 실 비즈니스 로직을 담고 있는 클래스는 결국 Controller와 Service가 될 것이다.

나 역시 프로젝트를 위와 같이 NestJS를 마주하면 바로 접해볼 수 있는 위와같은 패턴의 아키텍처로 시작하였다.


기존 아키텍처의 문제점 _ #1 NestJS 관점

혹시나 논란을 불러일으키지 않을까 해서 잠시 언급하자면, 소제목에서 언급한 "문제점(Problem)"은 개발을 마주하는 "나", 그리고 "팀"에 있어서의 문제이지 범용적인, 혹은 절대적인 "단점"을 말하는 것이 아님을 말한다.


하나,

"Circular Dependency(순환 종속성)"를 피할 수 없다.

위의 그림에서 볼 수 있듯이 기존 아키텍처에선 root AppModule을 여러 도메인의 모듈들이 바라보고 있고, 각 모듈에서 선언된 provider(Service class혹은 미들웨어)를 서로 다른 모듈에 종속된 provider 내부에서 생성자로 사용하고자 할 시, 일반적으로 모듈 자체를 다른 모듈에 imports 해주게끔 하였다.

@Injectable() 데코레이터를 통한 Singleton dependency로 정의된 Service 클래스는 또 다른 Singleton dependency한 Service 클래스를 생성자로써 불러왔다.

이는 피할 수 없는 상황이였을 것이다.

모든 로직을 전부 Service 클래스에 위임해 진행하는 위의 경우, 한 도메인 클래스 내부에 무조건 다른 도메인의 로직을 불러올 상황이 일어나는 것은 당연했기 때문이다.

아래의 간단한 예시를 보자.

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
  ) {}
 }


@Injectable()
export class UserService {
  constructor(
    // ...
  ) {}
 }

@Injectable() 프로바이더로 정의된 AuthService에서 UserService 클래스를 생성자로써 주입받아 사용하려 한다.

이를 가능케 하기 위해 UserModule 측에선 UserService를 provider로 등록함과 동시에 exports 해주어야 했고, AuthModule에서 UserModule 자체를 imports 해주었다. 위의 그림과 같이 말이다.

@Module({
  // ...
  providers: [UserService],
  exports: [UserService]
})
export class UserModule {}


@Module({
  imports: [
    UserModule
  ],
  providers: [AuthService],
  // ...
})
export class AuthModule {}

하지만 이대로 실행하면 어떤 결과를 불러올까?

다들 아시다시피 "Circular Dependency"가 발생하게 된다. 클래스 A에서 클래스 B가 필요하고, 클래스 B에서 클래스 A가 필요한 그 상황으로 인해 해당 에러가 발생한 것이다.

NestJS에서는 보통 아래의 3가지 상황에서 순환 종속성 에러를 마주하게 된다.


  • circular file imports - where a.ts import b.ts which imports a.ts
  • circular module imports - where AuthModule imports UserModule import AuthModule, like the above error
  • circular constructors - where AuthService injects UserService which injects AuthService

그리고 NestJS에선 이러한 순환 종속성을 해결(? 사실 해결이라 할 순 없다...)하기 위해 Forward Reference(전달 참조)를 제시한다.

forwardRef()를 사용한 해당 방법은 모듈이나 서비스 등을 참조하는 순간까지 해당 참조를 늦추는(lazy evaluation) 방식으로 순환 종속성 문제를 해결할 수 있다.

@Module({
  imports: [
    forwardRef(() => UserModule)  // forward reference
  ],
  providers: [AuthService]
})
export class AuthModule {}

그러나, Nest는 위의 방법을 제시하기 전 하나의 코멘트를 붙인다.

"While circular dependencies should be avoided where possible, you can't always do so."

가능하면 되도록 순환 종속성을 "피해(avoid)"란 것이다.

하지만 기존의 아키텍처와 같이, Service 클래스에 집중적으로 많은 태스크가 다뤄지는 상황에서 도메인이 점점 늘어날수록 자연스래 외부 모듈의 종속된 서비스 클래스를 불러와야하는 상황이 발생할 수 밖에 없다.


둘,

개발 단계중, 복잡한 의존성 에러를 빈번히 마주할 수 있다.

흔히, 볼 수 있는 에러이다. 의존성 주입과 관련되어 일련의 클래스가 주입되지 못하였거나 혹은 잘못되었을 경우 Nest에서 이를 해결하지 못해 발생하게 되는 경우다.

아주 간단한 수준의, 혹은 토이 프로젝트 정도의 규모에선 (다루게 되는 모듈이 많지 않을 경우) 크게 문제가 되지 않을 것이다. 설령, 개발 단계에서 위의 에러가 발생하였다 하더라도 디버깅 하는데는 문제가 없을 것이다.

하지만, 다루게 되는 기능에 따른 도메인이 점점 늘어날수록, 여러 갯수의 모듈들이 서로가 서로를 물게 되고, @Injectable()로 구현된 싱글톤 클래스 역시 서로가 서로에게 주입되며 개발단계에서 상당한 에러를 불러일으킬 것이다. 더불어, (나와같은 초보자 입장에선) 해당 종속성을 트랙킹하는 것에 있어서도 난항을 겪게 될 것이다.


기존 아키텍처의 문제점 _ #2 도메인 설계 관점

위는 데이터베이스 설계도 중 일부이다. 당연히 빙산의 일각에 불구하고, 초기 와이어프레임 당시에도 각 도메인별 테이블 수는 예상과 달리 매우 많았고, 진행을 이어가며 추가될 테이블 혹은 컬럼 또한 상당히 늘어났다.

이는 결국, 각 도메인별 책임져야 할 태스크가 (로직이) 늘어난다는 것을 의미하고 데이터 관리를 위한 CRUD가 추가로 요해지게 된다.

NestJS에서 일반적으로 제시하는 레포지터리 패턴(Repository Pattern)은 어떠한가?

위와 같이 (Typeorm 기준) Service 클래스에서 Typeorm의 Repository 클래스를 불러와 @Inject() 데코레이터를 이용해 주입시킨다. 물론, 모듈단에서든 혹은 별개의 provider 파일에서 사용자 지정 종속성 주입 작업이 필요할 것이다.

Typeorm이라면 사실 위의 경우보다 @InjectRepository()를 Service 클래스 생성자 내부에서 불러오는 것을 더 많이 보게 된다.


그렇다면 이 모든 CRUD 작업을 Service 클래스에 정의하는 것이 꽤 괜찮다 말할 수 있을까?

-Service-는 흔히 말하는 "Buisness Layer"다.

비즈니스 계층은 프로그램의 "핵심"을 담당하는 계층이다. 물론 단순한 CRUD 작업 자체가 비즈니스 로직이 될 수 있다. 충분히 그렇지만, 우리가 비즈니스 로직을 설계하고 아키텍처를 구상하는데 있어서 만큼은 지향점을 "CRUD를 넘어선, CRUD의 복합체로 이루어진 고수준의 UseCase에 맞추어야"하지 않을까 생각한다.

오로지 유저의 행위 자체에 초점을 맞춘, API를 정의한 라우트 핸들러 함수가 직접적으로 품고 있는 메서드(함수)만을 Service 클래스에 담는 것이다.

물론, @InjectRepository(Entity)를 Service 생성자 내부에서 사용하는 방법 또한 엄연히 DB Context를 분리시킨 Repository Pattern이라고 할 수는 있지만 로직의 분리를 위해선 별도의 계층 하나가 추가 될 필요가 있는 것이다.

자세한 건 아래에서 얘기를 나눠보도록 하겠다.



아키텍처 설계 과정, 그리고 도입

꼭 배경을 언급하고 싶어 서론이 길어졌고, 이제는 본격적인 설계과정 도입을 소개해보고자 한다.

전체 구조는 위와 같다. 보통의 케이스에 크게 벗어나진 않을 것이다.

  • apis - 도메인 모듈및 모든 내부 로직을 정의

  • common - 환경 설정 및(config 파일) 인프라 베이스, 공유 모듈등을 정의

  • middlewares - Enhancer(Guard, Pipe, Filter ...)들을 정의

  • models - orm수준의 entity 클래스를 정의 (별도로 분리하였다)

(commands, standalone.module.ts는 무시하셔도 좋습니다)


(아키텍처 소개인만큼 세세한 설정 파일 등을 까보진 않겠습니다, 도메인 설계 관점을 포인트로 진행하도록 하겠습니다.)


> port(포트) - Adaptor(어댑터) 패턴

와이어 프레임(디자인 및 데이터베이스 설계)을 토대로 위와 같이 도메인을 디렉토리 별로 분리하게 되었다. 각각의 영역에서 처리해야 할 로직들이 많을 거라 예상되었고, 나에게 있어 이는 별도의 도메인으로 나눌 이유가 되었다.

자, 그럼 하나의 도메인을 예시를 통해 분석해 보자.

orders 가 좋을 것 같다. orders 도메인을 열어보자.

참고로, payment(결제)와 orders(주문)을 별개의 모듈로 생성할 만큼 분리할 이유는 없었기에 하나의 모듈로 정의하였다.

아마, 위의 구조를 보자마자 어떠한 아키텍처가 떠오르는 분이 계실 것이다.

그렇다, 바로 "Hexagonal Architecture(헥사고날 아키텍처)"이다.

그리고 이는 아래와 같은 포트-어댑터 패턴을 적극적으로 사용해 설계되었다 볼 수 있을 것이다. (이미지 참조 __ Line engineering 지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기)

"포트와 어댑터를 통해 소프트웨어 환경에 쉽게 연결할 수 있는 느슨하게 결합된 응용 프로그램 구성 요소를 만드는 것을 목표로 합니다."

포트 어댑터 아키텍처는 위와 같이 언급한다.

조금 더 자세히 말하면, 인터페이스나 Infrastrucutre의 변경에 영향을 받지 않는 순수한 도메인을 구축하고 관리하는 것이라 할 수 있다.

도메인 클래스(Service Class)는 결국 어떠한 구현체(Adaptor)를 직접적으로 받는 것이 아닌, 추상적인 인터페이스를 바라보게 되는 것이다.

감이 오지 않을 수 있으니 직접 코드를 통해 확인해보자.


> presentation

흔히 우리가 Controller라 부르는 녀석이 정의될 곳이다.

클라이언트 입장에서 보면 API의 구현을 담은 Web Rest Controller도 하나의 어댑터(http rest가 아닌 socket에 대한 구현체가 그려질 수도 있음)이다.

컨트롤러는 클라이언트와의 소통을 통한 API 정의, 나아가 화면 정의들을 판단하여 유저 관점에서 분리하도록 진행하였다.

그리고 클라이언트란 계층과 데이터 교환을 위해 사용되는 DTO(Data Transfer Object)역시 presentation 레이어에 위치시킨다.

잠시 있다가 컨트롤러 코드에서 확인할 수 있겠지만, dto 객체를 그대로 도메인 영역으로 보내주지 않는다.
클라이언트로부터 넘어오는 dto 객체의 명세는 언제나 바뀔 가능성이 있다. 또한, 거의 일어나지 않을수도 있겠지만 클라이언트로부터 넘어오는 값과 영속성 계층(orm to db)에 저장해야할 값의 타입이 달라야 할 수도 있다.

마지막으로 필요한 부분은 매퍼(Mapper)이다.

domain 파트에서 보겠지만 dto를 그대로 도메인 영역에 전달하는 것이 아닌 command란 객체로 변환해 전달하는 만큼, 이를 매핑해주는 클래스가 필요하다. 이는 정적인(static)클래스로 충분히 구현가능하며, 컨트롤러의 각 라우트 핸들러 함수 내부에서 호출된다.

// orders.mapper.ts

export class OrdersMapper {
  static mapToCommand(orderReqDto: OrderReqDto): OrderReqCommand {
    const cartMenus = orderReqDto.cartMenus.map((cartMenus) => CartMenusForOrdersMapper.mapToCommand(cartMenus));

    return new OrderReqCommand(
      orderReqDto.storeId,
      orderReqDto.userNick,
      
      // ... (생략)
      
      orderReqDto.userStoreCouponId,
      orderReqDto.orderRequest
    );
  }
}

export class OrdersCancelMapper {
  static mapToCommand(orderCancelReqDto: OrderCancelReqDto): OrderCancelReqCommand {
    return new OrderCancelReqCommand(
      orderCancelReqDto.orderId,
      orderCancelReqDto.storeId,
    );
  }
}

이러한 구성요소들을 바탕으로 주문을 진행하는 OrderProcessController를 정의할 수 있다.

// order-process.controller.ts
// swagger 명세 생략 

@UseGuards(JwtAccessAuthGuard)
@Controller('order')
export class OrderProcessController {
  private readonly cancelReason_case1 = UnCatchedMsg.UNCATCHED_MSG;
  private readonly cancelReason_case2 = UnCatchedMsg.CUSTOMER_CHANGE_OF_MIND;
  
  // 생성자 내부에선 UseCase 인터페이스를 받아오게 된다. ~Symbol 토큰을 provider로써 주입받고,
  // 추후 이는 애플리케이션>모듈 단에서 정의된다.
  constructor(
    @Inject(OrderPaymentUseCaseSymbol)
    private readonly orderPaymentUseCase: OrderPaymentUseCase,
    @Inject(OrderUseCaseSymbol)
    private readonly orderUseCase: OrderUseCase,
    @Inject(OrderCancelUseCaseSymbol)
    private readonly orderCancelUseCase: OrderCancelUseCase,
  ) {}
  
  // 주문 생성
  @Post('/create')
  @UseFilters(TossPaymentsCancelFilter)
  @UsePipes(new ValidateOrderIdPipe())
  async startOrderTransaction(
    @Req() request: ExtendedRequest,
    @Body() orderReqDto: OrderReqDto,
  ) {
    const userId = request.userId;
    
    // dto to command mapping (to transfer -> domain layer)
    const orderReqCommand = OrdersMapper.mapToCommand(orderReqDto); 

    try {
      await this.orderUseCase.processOrderTransaction(userId, orderReqCommand);
    } catch (err) {
      await this.orderPaymentUseCase.cancelPayments(orderReqCommand.paymentKey, this.cancelReason_case1);
      throw err;
    }
  }
  
  // 주문 취소
  @Post('/cancel')
  @UseFilters(TossPaymentsCancelFilter)
  async cancelOrders(
    @Req() request: ExtendedRequest,
    @Body() orderCancelReqDto: OrderCancelReqDto,
  ) {
    const userId = request.userId;
    const orderCancelReqCommand = OrdersCancelMapper.mapToCommand(orderCancelReqDto);
    const paymentKey = await this.orderCancelUseCase.getPaymentKey(orderCancelReqCommand.orderId);

    try {
      await this.orderCancelUseCase.cancelOrders(userId, orderCancelReqCommand);
      await this.orderPaymentUseCase.cancelPayments(paymentKey, this.cancelReason_case2);
    } catch (err) {
      throw err;
    }
  }
}

💢💢💢

핵심은 Controller는 직접적으로 "Service" 클래스를 불러오는 것이 아닌 "UseCase"란 인터페이스에 접근한다는 것이다. 그리고 해당 UseCase에서 선언된 메서드 내부 파라미터엔 dto 객체가 아닌 command 객체를 받게끔 한다.


> domain

핵심은 portservice 클래스이다. 위의 아키텍처에서 이 2가지에 대해 이해하고 자연스럽게 받아들이는 과정은 생각보다 쉽지 않았고 그만큼 중요하였다.


listeners

이벤트 핸들러를 정의하는 구간이다. 비즈니스 로직이므로 서비스 클래스에 정의할 수 도 있겠지만, 서비스 클래스엔 유저의 행위에 의한 큼지막한 유즈케이스에 초점을 두었다.

유저가 주문을 완료하면 레디스의 sorted-set을 활용하여 주문 수 카운트를 +1 시켜준다고 하자. 이는 주문 수 랭킹 기능을 만들기 위해서이다. 하지만 명확히 따지자면 주문을 생성하는 부분과는 다른 명세의 기능이다. 주문 시 일어날 수 있는 주문서 생성, 포인트 적립, 쿠폰 사용, 메뉴 차감 등과는 다르게 RDBMS에 접근하지 않는 별개의 행위이므로 "이벤트"라 간주하고 진행하였다.

위의 판단하에, 비즈니스 로직으로부터 이벤트 기능을 "Decoupling"시킬 필요가 있었다.

NestJS의 EventEmitter를 활용하였으며, 자세한 건 추후 소개하도록 하겠다.


models

도메인 계층에서 사용될 객체 모델이며, 이 역시 도메인의 순수성을 지키기 위한 장치이다.

// 가주문 테이블(temp_orders)을 조회했을 때 반환되는 값.
export class TempOrderChecksResModel {
  payment_orderId: string;
  totalAmount: number;
  pointAmount: number;
  couponAmount: number;
}

여태 나의 경우엔 find를 통한 조회 로직을 수행하는데 있어(일반적 NestJS layered 아키텍처의 경우라봐도 무방하다) 서비스 클래스 내부 메서드의 리턴 값으로 직접적 엔티티 객체를 불러왔다.

@Injectable()
export class OrderService {
  // Orders 엔티티를 리턴 타입으로 직접 받아옴.
  async findOrderDataBySth(sth: ~~dto): Promise<Orders> {} 
}

이것이 문제가 된 다는 것은 "절대" 아니다.

하지만, 포트-어댑터 패턴을 채택하기로 한 시점, 도메인 영역은 infrastructure 영역을 모르게 할 필요가 있었다. 이에 대해 TypeORM으로 구현된 엔티티 객체를 그대로 불러오지 않고, 별도의 Response 객체로 변환하는 작업을 수행하였다.

이는 [infrastructure layer --> domain layer]로 응답 객체를 전달하는데 핵심이 되는 과정이지 절대로 presentation 단에 응답하기 위해 생성한 Response Dto가 아니다...!

즉, Response Dto로 그대로 쓰일 수도 있지만 핵심은 Domain이다.


🤞 port

포트-어댑터(or hexagonal)패턴은 외부 계층에서 도메인 및 유즈케이스로의 통신은 오로지 "port"를 통해서 이루어지도록 한다.

즉, 해당 포트(Interface)를 통해 내부 비즈니스 영역을 외부로 노출시키는 것이다.

포트는 Inbound portOutbound port로 나뉜다.

  • Inbound port: 도메인 영역 사용을 위해 노출된 인터페이스

  • Outbound port: 도메인에서 인프라 영역을 사용하기 위해(인프라를 호출시키기 위해) 노출된 인터페이스

In port와 Out port는 아키텍처 측면에서 내부 영역(domain), 외부 영역(infrastructure)을 기준으로 제시된 개념이다. 도메인으로부터 "들어오고(In)", "나가고(out)"로 오해하지 말도록 하자.


먼저 Inbound port는 아래와 같이 구성하였다.

도메인 영역을 정의하는 만큼, 앞서 보았던 프리젠테이션 영역의 컨트롤러 내부에 불러왔던 command객체와 usecase를 볼 수 있다.

Dto와 Command 객체는 Static 클래스의 매퍼를 통해 매핑해준다는 것을 확인하였고, 이 둘은 같을 수도 있고 달라질 수도 있다.

아래는 예시로, 토스 페이먼츠 결제 진행 시 도메인에서 불러올 command 객체이다.

// tossPayments_request.command.ts

export class TossPaymentReqCommand {
  constructor(
    public paymentKey: string,
    public orderId: string,
    public amount: number,
  ) {}
}

다음은 컨트롤러에서 불러오고, Service Class를 구현체(Implements)로 가지는 UseCase이다.

Typescript의 Interface를 통해 나타낼 수 있다.

컴파일 타임에서 추상 클래스의 역할만 온전히 해주면 되므로 런타임에 터지는 것은 문제가 되지 않는다. (어짜피 모듈단에서 종속성 주입만 잘해주면 된다)

아래는 결제 시 진행하게 되는 유저의 행위를 추상화한 UseCase이다.

export const OrderPaymentUseCaseSymbol = Symbol('Order_PaymentUseCase_Token');

export interface OrderPaymentUseCase {
  getUserInfoForPayment(userId: number): Promise<RequiredUserInfoResModel>;
  createTemporaryOrderTable(tempOrderReqCommand: TempOrderReqCommand): Promise<RegisteredOrderIdResModel>;
  requestTossPayment(tossPaymentReqCommand: TossPaymentReqCommand): Promise<TossPaymentsConfirmResModel>;
  cancelPayments(paymentKey: string, cancelReason: string): Promise<void>;
}

컨트롤러에 생성자 주입을 가능케 하기 위한 Custom Token을 정의한뒤, interface를 통한 유즈케이스를 선언한다.

결제 화면에 필요한 유저 정보를 불러오기 위한 행위, 가주문을 생성하는 행위, 토스페이먼츠에 결제를 요청하는 행위, 결제를 취소하는 행위.

이렇게 행위에 집중한 고수준의 메서드를 생성한다.

또한, 앞서 알아본 것과 같이 철저히 외부 종속성을 없애기 위해 반환 타입으로 별도의 생성 모델을 가지고 매개변수로는 command를 불러온다.


Outbound port는 인프라 영역(Mysql, Redis, Cloud, MQ, ...)을 도메인에서 사용하게 하기 위한 부분이라 하였다.

조금 더 직관적인 db 접근 함수가 제시될 것이다. 물론, 쿼리 문 자체가 비즈니스 로직이 되는 경우도 있다. 이러한 간단한 케이스에선 레이어의 분리는 일어나지만 같은 메서드 구현체를 가져갈 수도 있다.

아래는 결제에 사용되는 out port 이다.

export interface PaymentDrivenPort {
  insertToTemporaryOrders(tempOrderReqCommand: TempOrderReqCommand): Promise<RegisteredOrderIdResModel>;
  getTempOrdersData(orderId: string): Promise<TempOrderChecksResModel>;
}

insertToTemporaryOrders() 메서드는 사실 상 유즈케이스의 createTemporaryOrderTable()와 일맥상통하기 때문에 와닿지 않겠지만, getTempOrdersData()의 경우는 다르다.

이는 가주문 테이블에 저장된 데이터와 실 결제 시 pg사로 부터 리턴된 데이터의 정합성을 체킹하기 위해 비즈니스 로직 내부에서 호출된다.

생성된 가주문 데이터를 불러오는 메서드는 오로지 주문 아이디(orderId)를 통해 가주문 테이블을 뒤지는 행위에 불과하고, 이는 곧 비즈니스 로직이 아닌 레포지터리 단, 즉 outbound로 빠지게 되는 것이다.


Service 🤞

도메인 계층의 마지막으로 살펴 볼 영역은 "Service" 클래스이다.

위에선 Payment 로직을 예시로 들었지만, 이 아키텍처의 "Service"를 설명하기엔 OrderService가 매우 적절하지 않나 싶다.


// order.usecase.ts
export const OrderUseCaseSymbol = Symbol('Order_UseCase_Token');

export interface OrderUseCase {
  processOrderTransaction(userId: number, orderReqCommand: OrderReqCommand): Promise<void>;
}

// order.usecase.ts
export class OrderService implements OrderUseCase {

  constructor(
    private readonly orderRepository: OrderDrivenPort,
    private readonly menuStockHandleRepository: MenuStockHandlerDrivenPort,
    private readonly orderCouponsHandleRepository: OrderCouponsHandlerDrivenPort,
    private readonly orderPointsHandleRepository: OrderPointsHandleDrivenPort,
    private readonly emptyCartMenusRepository: EmptyCartMenusDrivenPort,  
    private readonly tempOrdersRepository: PaymentDrivenPort,
    private readonly userPointsRepository: UserPointDrivenPort,
    private readonly storeRepository: StoreDrivenPort,
    private readonly notificationRepository: NotificationDrivenPort,
    private readonly fcmSendRepository: FCMSendDrivenPort,
  ) {}

  public async processOrderTransaction(userId: number, orderReqCommand: OrderReqCommand): Promise<void> {

    // get temp-orders data for validation check!
    const tempOrdersData = await this.tempOrdersRepository.getTempOrdersData(orderReqCommand.orderNumber);

    const { couponAmount, pointAmount } = tempOrdersData;

    // coupon amount valid check!
    if (couponAmount !== orderReqCommand.useCouponAmount) {
      throw new CouponAmountMissMatchException();
    }

    // point amount valid check!
    if (pointAmount !== orderReqCommand.usePointAmount) {
      throw new PointAmountMissMatchException();
    }

    const queryRunner = dataSource.createQueryRunner();
  
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 주문 생성 (주문, 주문상세, 주문메뉴, 주문메뉴옵션 엔티티 생성)
      await this.orderRepository.createOrders(userId, orderReqCommand, queryRunner);
      
      // 메뉴 수량 차감
      await this.menuStockHandleRepository.decreaseMenuStock(orderReqCommand, queryRunner);

      // 회원 쿠폰 삭제
      await this.orderCouponsHandleRepository.deleteUserCoupons(orderReqCommand, queryRunner);

      // 쿠폰 사용 내역 생성 (사용한 쿠폰이 하나라도 존재할 경우)
      if (orderReqCommand.userMereCouponId || orderReqCommand.userStoreCouponId) {
        await this.orderCouponsHandleRepository.insertUseCouponsLog(
          orderReqCommand.userMereCouponId,
          orderReqCommand.userStoreCouponId,
          orderReqCommand.orderNumber,
          queryRunner
        );
      }

      // 회원 포인트 차감
      if (orderReqCommand.usePointAmount > 0) {
        await this.orderPointsHandleRepository.decreaseUserPoints(userId, orderReqCommand, queryRunner);
      }
      
      // 회원 포인트 내역 생성 (포인트 금액이 0이상일 경우)
      if (orderReqCommand.usePointAmount > 0) {
        const { remainPoint} = await this.userPointsRepository.getUserRemainPoint(userId);
        await this.orderPointsHandleRepository.insertUserPointsLog(userId, orderReqCommand.storeId, orderReqCommand.usePointAmount, 1, remainPoint, queryRunner);
      }
      
      // 장바구니 비우기
      await this.emptyCartMenusRepository.emptyCartMenus(orderReqCommand, queryRunner);

      // notification
      const storeName = await this.storeRepository.findStoreName(orderReqCommand.storeId);
      const notificationTitle = "주문";
      const notificationBody = `[${storeName}] 결제가 완료되었어요. 사장님이 주문을 곧 접수할 예정이에요.`;
      
      const notificationTokenEntity = await this.notificationRepository.getExistsDeviceToken(userId);

      // send-notification
      if (notificationTokenEntity && notificationTokenEntity.notificationToken) {
        // save-notification
        await this.notificationRepository.saveNotification(notificationTokenEntity.notificationTokenId, notificationTitle, notificationBody, queryRunner);

        await this.fcmSendRepository.sendToFirebase(
          notificationTokenEntity.notificationToken,
          notificationTitle,
          notificationBody,
          'ic_order',
        );
      }

      await queryRunner.commitTransaction();
    } catch (err) {
      await queryRunner.rollbackTransaction();      
      throw err;
    } finally {
      await queryRunner.release();
    }
  }
}

정말 재밌는 경우이다.

"주문 생성 api"에서 유저의 행위는 무엇일까? 그렇다, 그냥 주문 전체를 진행하는 것이다. 하지만, 이러한 "주문 전체" 는 굉장히 많은 부가적 처리를 동반한다.

추후, 주문 프로세스를 다루는 포스팅에서 더 자세히 얘기하겠지만 단순 주문서를 생성하는 것을 넘어 "메뉴 차감", "쿠폰 사용 처리", "포인트 차감", "장바구니 비우기", "푸시 알림 처리" 등등... 많은 추가적 로직을 요구하게 된다.

그리고 데이터 일치성및 정합성을 위해 이는 무조건 "하나의" 논리적인 단위(Transaction)에서 일어나야 하며, 서비스 클래스에서 트랜잭션을 구축하게 된다.

서비스와 서비스 끼리 통신하는 경우는 없어야 하며 OrderService 내부 생성자에서 볼 수 있는 것과 같이, 하나의 서비스는 여러개의 포트를 불러올 수 있고, 이는 Order 도메인 뿐만 아니라 외부의 도메인에서 정의한 포트 또한 상관없이 불러올 수 있게 하였다.

💢도메인 서비스 클래스에서의 트랜잭션 사용 및 외부 도메인 모듈의 함수 호출에 대해선 의문이 생길 수도 있을 것이다. 이는 조금 있다가 다시 얘기해보고자 한다.💢


마지막으로 눈여겨 볼 부분은 "@Injectable()"부재이다.

사실 부재?는 아니고 내가 제거한 것이다. 물론, 이는 굉장히 민감한 주제일 수 있다고 본다. 지금부터 전달할 내용은 절대 옳고 그름을 말하는 것이 아닌, 이러한 설계를 하게 된 그 당시와 지금의 "나의 생각"임을 미리 말씀드린다.

"도메인에서 NestJS 자체의 종속을 없애자는 제스쳐가 아니다"

DDD(Domain Driven Design)혹은 헥사고날 관점에서 항상 제시되는 내용중 하나로 "서비스 클래스는 어떠한 외부 모듈 혹은 프레임워크를 알아서 안된다" 란 내용을 본 적이 있을 것이다.

이는 서버에 어떤 외부 종속성이 개입되더라도 순수한 비즈니스 도메인 계층을 오로지 도메인 관점에서 지키자는 것으로 해석할 수 있다.

하지만 사실 상 (사실 상이라기 보다 나의 개발 능력의 현재 한계라 봐도 무방할 것이다...) 외부 종속성을 완벽히 걷어내는 것은 불가능 하였다.

이런 상황에서 내가 굳이 NestJS에서 제공하는 클래스의 DI 상태를 가능케 하는@Injectable()을 걷어낸 이유는 무엇이였을까?

솔직히 말해서 일부는 아키텍처의 모습을 갖추기 위한 제스처임을 부정할 수 없을 거 같다. 하지만, 이렇게 서비스 클래스를 "Plain of Typescript Class"로 만듦으로써 클래스의 복잡한 의존성을 온전히 Module단에 맡길 수 있었다.

물론, 이렇게 될 시 모듈단에서 추가적인 클래스의 의존성 주입 처리를 해줘야하기에 부담이 될 수 있다.

그러나 이는 돌이켜보면 "막노동(좋은 말로 수작업)?"이 요구되는 것 이외엔 모두 "이점"으로 다가왔다. 어짜피, 시스템 아키텍처의 흐름 상 서비스 끼리 서로가 서로를 불러오는 상황은 없으므로 순환 의존성 에러는 발생하지 않고, 직접 커스텀 프로바이더를 생성하는 수작업이 오히려 애플리케이션의 정확성을 증가시키는 행위가 되었다.

글이 길어진다. 조금 빨리 진행해보자.


> infrastructure

포트-어댑터 중 "어댑터"가 선언 될 영역이다. 즉, port의 구현체(implements)라고 할 수 있다.

흔히 우리가 레포지터리 계층으로 보는 부분이 정의된다 생각하면 편하다.

하지만, 나의 경우는 이를 좀 더 구체화 시켰다.

인프라스트럭쳐 계층에선 redis, mysql(typeorm...), aws (s3...), firebase, MQ(Rabbitmq...) 등의 인프라 영역을 다룬다. 흔히, 해당 영역에서 제공하는 api 함수를 적극 사용해 C, R, U, D의 작업을 수행하게 된다.

현재 진행중인 서버 to 서버간의 통신을 위한 Messsaging Queue를 제외한 위의 나머지 4가지를 인프라 영역으로 사용하였다. 그 중에서도 사실 상 가장 많이 사용된 부분은 RDBMS (Mysql)과의 데이터 소통을 위한 orm(typeorm)기반의 구현체 작성이었고, 아주 간단히 알아보도록 하자.

먼저 구현체를 작성하기 전, interfacerepositories를 잠깐 살펴보자.

src > common > infrastructure에서 정의한 베이스 인터페이스, 베이스 클래스를 impl 및 extends 받은 인터페이스와 레포지터리 클래스라 보면 된다.


// typeorm.repository.interface.ts
import { DeepPartial, DeleteResult, FindManyOptions, FindOneOptions, FindOptionsWhere, InsertResult, ObjectId, QueryRunner, SelectQueryBuilder, UpdateResult } from "typeorm";
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';

export interface BaseInterfaceRepository<T> {
  create(data: DeepPartial<T>): T;
  createMany(data: DeepPartial<T>[]): T[];
  save(data: DeepPartial<T>): Promise<T>;
  
  // 생략 ...
  
  createQueryBuilder(alias: string, queryRunner?: QueryRunner): SelectQueryBuilder<T>;
}

// typeorm.abstract.repository.ts
export abstract class BaseAbstractRepository<T> implements BaseInterfaceRepository<T> {
  private entity: Repository<T>;

  protected constructor(
    entity: Repository<T>
  ) {
    this.entity = entity;
  }

  readonly manager: EntityManager;

  public async save(data: DeepPartial<T>, options?: SaveOptions): Promise<T> {
    return await this.entity.save(data, options);
  }

  // 생략 ...

  public createQueryBuilder(alias?: string, queryRunner?: QueryRunner): SelectQueryBuilder<T> {
    return this.entity.createQueryBuilder(alias, queryRunner);
  }

}

interfaces

export interface OrdersRepositoryInterface extends BaseInterfaceRepository<Orders> {}

repositories (Repository interface를 구현체로 가진다)

export const TypeOrmOrdersRepositorySymbol = Symbol('Typeorm_Orders_Token');

export class TypeOrmOrdersRepository extends BaseAbstractRepository<Orders> implements OrdersRepositoryInterface {
  constructor(
    @InjectRepository(Orders)
    private readonly ordersRepository: Repository<Orders>
  ) {
    super(ordersRepository);
  }
}

레포지터리 클래스에서는 별도의 메서드를 생성하지 않도록 한다. 그리고 UseCase와 같이 추후 커스텀한 클래스 의존성을 가능케 하기 위해 토큰을 함께 생성한다.


🤞 adaptor

이제 어댑터이다. Outbound port의 어댑터라 할 수 있으며, 특정 db에 데이터를 저장 및 관리하는 직접 적 쿼리 함수가 정의된다.

(로직의 내용은 지금 중요하지 않으니 구조 위주로만 봐주셔도 됩니다, 대부분의 과정을 생략하였습니다)

// orders_driven.adaptor.ts

export class OrderMySqlAdaptor implements OrderDrivenPort {
  constructor(
    @Inject(TypeOrmOrdersRepositorySymbol)
    private readonly ordersRepository: TypeOrmOrdersRepository,
	
    // 생략 ... 
     
    @Inject(TypeOrmCartMenuOptionsRepositorySymbol)
    private readonly cartMenuOptionsRepository: TypeOrmCartMenuOptionsRepository,
    @Inject(TypeOrmPaymentLogsRepositorySymbol)
    private readonly paymentLogsRepository: TypeOrmPaymentLogsRepository,
    private eventEmitter: EventEmitter2,
  ) {}

  public async getOrderById(orderId: number): Promise<OrderDataByChecking> {
    const order = await this.ordersRepository
      .createQueryBuilder('order')
      .select([
        'order.orderId'
      ])
      .where('order.orderId = :orderId', { orderId })
      .getOne();

    return order;
  }

  public async createOrders(userId: number, orderReqCommand: OrderReqCommand, queryRunner: QueryRunner): Promise<void> {
    try {
      const newOrder = await this.createOrder(userId, orderReqCommand, queryRunner);
      const { orderId } = newOrder;

      await this.createOrderDetails(orderId, orderReqCommand, queryRunner);

      // 생략 ...

      const store = await this.getStoreData(orderReqCommand.storeId);
      
      this.eventEmitter.emit('order.completed', store);

      await this.changePaymentStatusToCompleted(orderReqCommand.orderNumber);
    } catch (err) {
      throw err;
    }
  } 

  // 생략 ...

  private async createOrderMenuOption(orderMenuId: number, cartMenuOptionEntity: any, queryRunner: QueryRunner): Promise<void> {
    const { detailOptionId, ...cartMenuOptionData } = cartMenuOptionEntity;
    
    await this.orderMenuOptionsRepository
      .createQueryBuilder('orderMenuOption', queryRunner)
      .insert()
      .values({
        ...cartMenuOptionData,
        orderMenu: { orderMenuId },
        detailOption: { detailOptionId },
      })
      .execute();
  }
}

주문 생성을 위해 데이터베이스에게 일련의 명령을 전달하는 구현체(어댑터)이다.

주문을 생성하는 과정에선 주문 테이블, 주문 상세 테이블, 주문 메뉴 테이블, 주문 메뉴 옵션 테이블, 장바구니 테이블 등 여러 테이블로의 쿼리 함수가 실행될 필요가 있었고 이러한 작업을 위의 OrderMySqlAdaptor에서 정의하게 되었다.

이 역시 Service 클래스와 마찬가지로, @Injectable() 데코레이터를 주입하지 않으며 모듈단에 해당 의존성 관련 책임을 전가 시키게끔 하였다.


> application (Module)

모듈단은 생각보다 중요하고, 동시에 지옥이 펼쳐질 수도 있다.

하지만 이는 내가 지향하고 설계한 아키텍처를 위해 감당해야할 trade-off 이며 지금에서는 크게 어려움이 없지 않나 싶다.

아래는 OrderModule 클래스이다. 길어보여도 거의 대부분이 생략된 코드이다. 어쩔 수 없다. 각 도메인마다 모듈은 하나로 두게 되고, 결제 및 주문 생성, 취소 등 이 모든 작업에 대한 provider 주입을 이 내부에서 취해주어햐 하기 때문이다.

// order.module.ts

----------------------------------------------------
export const tempOrdersToSqlAdaptorProviders = [
  {
    provide: TypeOrmTempOrdersRepositorySymbol,
    useClass: TypeOrmTempOrdersRepository,
  }
];

export const ordersToSqlAdaptorProviders = [
  {
    provide: TypeOrmOrdersRepositorySymbol,
    useClass: TypeOrmOrdersRepository,
  }
];

// 생략 ...
----------------------------------------------------

export const orderProcessorToDomainProviders = [
  OrderMySqlAdaptor,
  MenuStockHandlerMySqlAdaptor,
  OrderCouponsHandlerMysqlAdaptor,
  OrderPointsHandlerMysqlAdaptor,
  EmptyCartMenusMySqlAdaptor,
  UserPointsMySqlAdapter,
  {
    provide: OrderUseCaseSymbol,
    useFactory: (
      orderRepository: OrderDrivenPort,
      menuStockHandleRepository: MenuStockHandlerDrivenPort,
      orderCouponAndPointHandleRepository: OrderCouponsHandlerDrivenPort,
      orderPointsHandleRepository: OrderPointsHandleDrivenPort,
      emptyCartMenusRepository: EmptyCartMenusDrivenPort, 
      tempOrdersRepository: PaymentDrivenPort,
      userPointsRepository: UserPointDrivenPort,
      storeRepository: StoreDrivenPort,
      notificationRepository: NotificationDrivenPort,
      fcmSendRepository: FCMSendDrivenPort,
    ) => {
      return new OrderService(
        orderRepository,
        menuStockHandleRepository,
        orderCouponAndPointHandleRepository,
        orderPointsHandleRepository,
        emptyCartMenusRepository,
        tempOrdersRepository,
        userPointsRepository,
        storeRepository,
        notificationRepository,
        fcmSendRepository,
      );
    },
    inject: [
      OrderMySqlAdaptor, 
      MenuStockHandlerMySqlAdaptor, 
      OrderCouponsHandlerMysqlAdaptor, 
      OrderPointsHandlerMysqlAdaptor,
      EmptyCartMenusMySqlAdaptor,
      PaymentMySqlAdaptor,
      UserPointsMySqlAdapter,
      StoreMySqlAdaptor,
      NotificationMySqlAdaptor,
      FCMSendAdaptor,
    ]
  }
];

// 생략 ...
----------------------------------------------------

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Users,
      TempOrders,
      Orders,
      
      // 생략...
      
      UserNotificationTokens,
      UserNotifications,
    ]),
    SharedModule,
    EventEmitterModule.forRoot(),
  ],
  controllers: [
    PaymentProcessorController,
    OrderProcessController,
    
    // 생략 ...
  ],
  providers: [
    ...userToSqlAdaptorProviders,
    ...storeToSqlAdaptorProviders,
    ...tempOrdersToSqlAdaptorProviders,
    ...ordersToSqlAdaptorProviders,
	
    // 생략 ...
    
    ...orderProcessorToDomainProviders,
  ],
})
export class OrderModule {}

클래스 단위의 @Injectable()을 거둬낸 만큼 standard한 방식이든, custom한 방식이든 직접 모듈내에서 sql 수준의 어댑터에 대한 프로바이더와 도메인 클래스에 대한 프로바이더를 정의해야 했다.

특히 서비스에 대한 프로바이더를 정의할 땐, useFactory를 통한 동적 프로바이더 생성이 필요하였고, 이는 상당히 귀찮은 작업이었지만 개발단계에서 해당 클래스가 사용될 경우에만 동적으로 주입해줌에 따라 오히려 쉽게 Service를 적용시킬 수 있었다.

앞서 outbound port를 소개할 때, 외부 모듈의 out port를 서비스 클래스에서 받아옴에 따라 외부 모듈에서 정의한 infra 구현체의 함수를 사용할 수 있다고 하였다.

위의 동작을 가능케 하기 위해, 결국 사용할 모듈에서도 외부 모듈에서 정의한 sqlAdaptor에 대한 프로바이더를 생성해줄 필요가 있는데, 이는 외부 모듈에서 선언한 것을 그대로 불러옴을 허용함에 따라 코드의 반복을 막도록 하였다.

즉, OrderModule에서 아래의 adaptor를 사용한다고 하면

export const userToSqlAdaptorProviders = [
  {
    provide: TypeOrmUserRepositorySymbol,
    useClass: TypeOrmUserRepository,
  }
];

이것을 한번 더 만들어주는 것이 아닌 UserModule에 이미 선언되어 있으므로 그대로 불러오면 된다.


좋은 설계를 한 것이 맞을까?

글의 진행 상 아키텍처를 구상하면서 겪은 많은 과정들을 생략하게 되어 아쉽다. 글을 마무리 하기 전에 몇 가지 키워드를 통해 생각을 말해보고자 한다.


> 폴더와 파일이 굉장히 많아졌다.

부정할 수 없는 사실이다. 도메인을 하나 생성할때마다, 포트를 하나 뜷을때마다, 부가적인 파일들이 항상 따라다니며 추가되었고 이는 단순 시간 측면에서의 비용을 늘게 했다해도 과언이 아니다.

하지만 대부분의 로직을 마무리 한 이 시점에서의 생각은 만족스러운 trade-off 였다고 본다. 도메인이 늘어나고, 다루는 로직이 점점 늘어날 수록 부담스러울 정도로 분리된 이 구조가 로직을 tracking 하는데 굉장히 이점을 주었다.

작성한지 오래된 로직을 찾아야 하거나 혹은 수정해야 할 경우, 특정 메서드의 자취를 잘 짜여놓은 아키텍처의 흐름에 맞게 찾아갈 수 있게 되어 매우 좋았다.

프리젠테이션부터 도메인을 거쳐 인프라스트럭처 계층까지. 또는 그 반대로 인프라스트럭처부터 도메인을 거쳐 프리젠테이션 계층까지.

모든 케이스에서 데이터 흐름을 전달하는 메서드의 흐름은 딱 저 두 가지였고, 중구남방 여러 도메인의 서비스 계층을 왔다갔다하는 이전의 아키텍처에 비해 훨씬 좋은 추적을 가능케 하였다.


> 모놀리식 구조이다. MSA의 플로우에 신경쓸 필요는 없다.

진행중인 서비스가 웹 서버와 앱 서버로 분리가 되어있고, 이 두 서버가 통신을 한다는 측면에서 MSA의 흐름을 고려해야하나?라 생각할 뻔하였지만 특정 기능을 제외한 (주문 통신) 나머지에 있어선 온전히 모놀리식 구조였고 도메인 간의 일체 이동을 금할 필요는 없었다.

도메인 간의 이동이라 해서 서비스 계층간의 이동이란 뜻은 절대 아니고, 앞서 outbound port 영역에서 살펴본 것과 같이 인프라 영역은 철저히 여러 모듈의 도메인에서 공통적으로 사용할 수 있게 끔 허용한다는 것이다.

즉, UserMySqlAdaptor에서 정의한 findByUserId()란 메서드를 UserDrivenPort를 통해 OrderService에서 사용가능하게끔 한다는 것이다.

과유불급이라 하였다.

모놀리식 구조로써 가져갈 수 있는 아키텍처에 최선을 다해도 충분하다 본다.


> Sinkhole Anti Pattern(싱크홀 안티패턴)

싱크홀 안티패턴이란 말을 들어보았는가?

요청이 한 계층에서 다른 계층으로 이동할 때 특정 계층이 아무런 비즈니스 로직도 처리하지 않은 채 그냥 다음으로 전달을 하는 경우를 말한다.

내 코드에서도 간헐적으로, 아니 그 이상으로 일어나는 상황이다.

// likeStore.service.ts

export class LikeStoreService implements LikeStoreUseCase {
  constructor(
    private readonly likeStoreRepository: LikeStoreDrivenPort,
  ) {}

  public async getLikeStoresByPaginate(cursorPageOptionsCommand: CursorPageOptionsCommand, userId: number):  Promise<CursorPageResModel<LikeStoreResponseModel>> {
    return await this.likeStoreRepository.getLikeStoresByPaginate(cursorPageOptionsCommand, userId);
  }

  public async insertToLikeStores(storeId: number, userId: number): Promise<void> {
    await this.likeStoreRepository.addStoreLikeCount(storeId);
    await this.likeStoreRepository.insertStoresToUser(storeId, userId);
  }

  public async deleteFromLikeStores(storeId: number, userId: number): Promise<void> {
    await this.likeStoreRepository.subStoreLikeCount(storeId);
    await this.likeStoreRepository.deleteStoreFromUser(storeId, userId);
  }

  public async deleteStoreList(deleteLikeStoresReqCommand: DeleteLikeStoresReqCommand, userId: number): Promise<void> {
    return await this.likeStoreRepository.deleteStoresList(deleteLikeStoresReqCommand, userId);
  }
}

"가게 좋아요(찜)" 기능에 대한 비즈니스 로직을 구성하는 서비스 클래스이다.

하지만 가만히 보면 별다른 로직을 수행하지 않은 채 그냥 out port에서 정의한 메서드를 그대로 호출하고 있다.

이러한 케이스를 "지양"하라고 하지만 동시에 완전히 피하는 것은 불가능하다 말하기도 한다.

실제로 비즈니스 로직이지만 단순한 sql 쿼리문 정도가 끝인 경우가 있고, 필요에 따라 infra adaptor에서 단순 crud를 넘어 일련의 기능을 처리해야할 경우도 존재한다. 굳이 서비스에서 복잡하게 불러와 처리하기 힘든 상황을 어댑터에 맡기는 것이 깔끔할 수 있기 때문이다.

뭐, 지금에서야 말하지만 "아키텍처 숙련도" 또한 이러한 싱크홀 안티패턴의 퍼센티지를 좌우한다고 본다.

나의 경우도, 진행이 될수록, 아키텍쳐가 온전히 나에게 들어올 수록 어댑터와 서비스 계층에서 나눠야 할 책임을 유연하게 판단할 수 있었고 꽤 괜찮은 형태를 만들어 낼 수 있었다.

너무 민감해하며, 아키텍처에 잡아먹히면 절대 안되지만 해당 안티패턴을 지양하기 위해 항상 고려는 해야한다 생각한다.


> 예외적 상황에 너무 머리를 싸매지 말자.

나는 어떤 아키텍처를 구축한 것일까?

누군가 "너는 잘못된 헥사고날 아키텍쳐를 구축했어!" 라고 하면 그건 당당히 "아니다"라고 말할 수 있을거 같다.

왜냐면 난 애초에 헥사고날 아키텍처를 구축한게 "아니기" 때문이다.

어떠한 아키텍처를 구축했어! 라고 말하는 것은 굉장히 민감한 것 같다. 그 아키텍처의 모든 규약에 대해 지켜야 할 필요가 생기고 이것이 오히려 개발 생산성의 저하로 불러올 수 있기 때문이다.

나는 포트 어댑터 패턴을 토대로 한 확장및 분리된 레이어드 아키텍처를 설계했다고 말할 수 있을거 같다. 그 과정에 있어 헥사고날, 그리고 DDD(Domain Driven Design)아키텍처를 참고한 것은 사실이다.

이번 글에선 전체 흐름을 위주로 말하였지만 추후 진행되는 기능 구현및 로직 설계의 과정에서 보면

"어, 이거 그 아키텍처 위배한 거 아니야..?"

라고 생각할 만한 부분들이 나타날 것이다. 하지만, 명확한 규제의 아키텍처를 따라가기엔 나조차도 너무 부족하고 현실적 차원에서 좋은 코드를 설계하는것 그 이상으로 중요한 것들이 존재하기 때문이다.

그 적절한 줄다리기를 하는 것은 생각보다 어렵고, "서비스"를 개발하는 "엔지니어"로써 단순 코드 한 줄, 클래스 하나를 바라보는게 전부는 아니란 생각이다.


사실 이 글을 누가 읽어주실지는 모르겠다.

매우 길게 작성했지만, 동시에 적고 싶은 내용의 40%는 적지 못한 글인 것 같기도 하다.

가독성을 위해 두 파트로 나눌까 생각하였지만 글의 일관성을 위해 하나로 이어쓴 점에 대해선 읽으실 분들께 심심한 사죄를 드린다...

마지막으로, 프로젝트 전반에 걸친 해당 아키텍처를 설계할 수 있게 끔 도와주시고 이 글을 작성할 수 있는 바탕이 되어주신 백엔드 개발자 "최재형"님께 감사의 말씀을 드리고 싶다.

"최재형"님 개인 블로그

그럼...

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

6개의 댓글

ㅋㅋㅋㅋㅋ 잘 읽다가 제 이름이 나와서 당황했습니다.. 샤라웃(?) 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 1월 19일

훌륭한 고찰 공유 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 4월 4일

nest js로 프로젝트를 진행하기 위해서 아키텍처 조사 중 이 글을 읽은게 너무 큰 도움이 되었습니다 감사합니다!!!

1개의 답글