Back-end/nest.js

Nest.js : Ports and Adapters Architecture 구현하기 (1)

hsloth 2023. 2. 3. 18:07

이 글은 작성자(개발초보)의 뇌피셜일 뿐이며 참고만 하시되, 잘못된 부분이 있다면 지적해 주시기 바랍니다.

 

Nest.js를 배우면서 우연히 ports and adapter architecture에 대해서 알게 되었다.

 

ports and adapter architecture란, 비즈니스 로직과 외부 요소들(예를들어, DB)을 port와 adapter를 이용해서 구분하는 아키텍쳐이다.

 

우리가 일반적으로 사용하는(나만 이렇게 사용했을 지도 모르지만) service 만을 이용해서 코드를 작성시, service 안에 온갖 코드가 다 들어갈 것이다.

예를들면, DB를 건드리는 코드도 들어갈 수 있고, 검색엔진, email 혹은 외부 라이브러리 등 많은 것들에 관한 코드가 들어갈 수 있을 것이다.

하지만 서비스 로직(비즈니스 로직) 안에 이렇게 많은 것들이 들어가면, 너무 많은 외부 요소들이 얽혀있기 때문에 훗날 서비스 로직에 대해 관리 및 유지보수를 하기가 어려워 질 것이다.

또한, DB와 관련된 로직에관한 수정 혹은 에러 때문에 service 로직 전체를 뜯어 고치고있는 황당한 일을 겪게 될 것이다. (즉, 외부 요소 자체의 문제를 비즈니스 로직까지 끌고온다는 문제가 있다)

 

Ports and Adapters Architecture를 사용하면, 이런 DB 같은 외부 요소들을 비즈니스 로직으로부터 분리할 수 있다.

그리고 이런 외부 요소들이 분리됨으로써, 외부 요소의 구체적인 구현체를 몰라도 쉽고 빠르게 테스트를 할 수 있고

테스트 코드 작성시 외부 요소에 영향을 받거나 주지 않고 테스트 코드를 작성할 수 있다는 장점이 있다.

 

다음은 Ports and Adapters Architecture의 그림이다.

원티드 온보딩에서 가져온 그림. 문제시 삭제하겠습니다... 어디까지나 이해를 돕기 위해!

  • 흐름을 보면, inbound-adapter(Controller)에서 inbound-port(interface)를 통하여 service로 들어가고, 다시 service에서 outbound-port(interface)를 통하여 outbound-adapter(구현체)로 나간다.

port - 콘센트(outlet)

adapter - 플러그(plug)

라고 생각해보자.

 

전기(service)를 이용하기 위해서는 콘센트(port)가 있어야 하고, 사용하고 싶은 전자기기(외부 요소 / ex.DB)에는 콘센트(port)와 연결할 수 있는 플러그(adapter / ex.typeorm 모듈을 이용해 쿼리를 DB로 날릴 수 있게 한 파일 = repository)가 있다.

이 요소들은 각각 분리되어 있으며, 서로 아무런 영향을 끼치지 않는다.

예를들면 플러그가 망가졌다고 콘센트가 망가지지는 않고, 플러그가 망가지면 전자기기에 달려있는 플러그만 고치면 되고, 전기가 끊긴다고 해도 콘센트나 플러그, 전자기기가 망가지는 것은 아니다.

 

필자가 생각하는 구현 방법

  • 어떻게 구현을 해야할지 정말 많은 생각을 했다.
  • port를 어떤식으로 만들어야할지, 몇 개를 만들어야할지, 서비스의 개수는 몇개가 되어야 할지를 생각해 보았다.
  • 그 결과 다음과 같은 결론이 났다.
  • 하나의 리소스에 대한 한 개의 서비스와 한 개의 inbound, outbound-port를 가진 구조
  • 하나의 리소스에 대한 한 개의 서비스와 여러 개의 inbound, outbound-port를 가진 구조
  • 하나의 리소스에 대한 한 개의 서비스와 하나의 inbound-port, 여러 개의 outbound-port를 가진 구조
  • 하나의 리소스에 대한 기능 별 서비스들과 기능 별 inbound, outbound-port를 가진 구조

 

1. 하나의 리소스에 대한 한 개의 서비스와 한 개의 inbound, outbound-port를 가진 구조

  • 하나의 리소스에 대해 한 개의 서비스와 한 개의 inbound, outbound port를 만들고, 해당 inbound, outbound port에 서비스에 대한 함수들을 한 번에 정의한다.
  • 예를들어, 리소스를 user라고 해보자.
  • 리소스(도메인) 단위로 폴더를 만든다고 하면, 구조는 아래와 같을 것이다.
src
|
- user -
	|
	- user.inbound-port.ts
	- user.outbound-port.ts
	- user.service.ts
	- user.controller.ts
	- user.module.ts
	- user.repository.ts 같은 adapters 등등
  • 그리고 user.service.ts에 login함수, register함수가 있다고 가정하면 port의 코드는 다음과 같을 것이다.
// user.inbound-port.ts

/* 함수의 input과 output의 타입 정의 */
export type UserLogInInboundPortInputDto;
export type UserLogInInboundPortOutputDto;

export type UserRegisterInboundPortInputDto;
export type UserRegisterInboundPortOutputDto;

/* inject시 사용할 token 정의 */
export const USER_INBOUND_PORT = 'USER_INBOUND_PORT' as const;

/* interface 정의 */
export interface UserInboundPort {
	logIn(params: UserLogInInboundPortInputDto): 
    	Promise<UserLogInInboundPortOutputDto>;
	
    /* register 함수도 동일하게 작성 */
}

---

// user.outbound-port.ts

/* 함수의 input과 output의 타입 정의 */
export type UserLogInOutboundPortInputDto;
export type UserLogInOutboundPortOutputDto;

export type UserRegisterOutboundPortInputDto;
export type UserRegisterOutboundPortOutputDto;

/* inject시 사용할 token 정의 */
export const USER_OUTBOUND_PORT = 'USER_OUTBOUND_PORT' as const;

/* interface 정의 */
export interface UserOutboundPort {
	getUser(params: UserLogInOutboundPortInputDto): 
    	Promise<UserLogInOutboundPortOutputDto>;
	
    /* createUser 함수도 동일하게 작성 */
}
  • DTO를 따로 만들지 않고, 직접 값을 정의함으로써 아무런 외부 요소의 개입, 의존 없이 port를 만들 수 있다.
  • 이 방법의 문제점은 하나의 inbound-port, outbound-port를 사용하기 때문에, inbound-port, outbound-port에 대한 코드가 상당히 길어지고 복잡해질 수 있다는 점이다.

 

2. 하나의 리소스에 대한 한 개의 서비스와 여러 개의 inbound, outbound-port를 가진 구조

  • 하나의 리소스에 대해 한 개의 서비스와 기능 별 inbound, outbount port를 만들어 함수를 따로따로 정의한다.
  • 생각을 해봤는데 하나의 서비스에 inbound-port는 여러 개 만드는게... 말이 되나 싶었다. 서비스는 UserService하나 뿐인데, 거기에 대한 토큰을 여러개 생성하면, 생성자에 @Inject를 이용해서 같은 UserService를 여러번 넣는 꼴이다.
  • 말은 되지만, 이럴거면 차라리 기능 별 서비스마다 기능 별 inbound, outbound-port를 가진 구조를 사용하는게 나을 것 같다.

 

3. 하나의 리소스에 대한 한 개의 서비스와 하나의 inbound-port, 여러 개의 outbound-port를 가진 구조

  • 이 구조에서 inbound port는 하나인데, 왜 outbound port를 여러 개로 구분하느냐?
  • 그냥 지극히 개인적인 의견으로, inbound-port에 대한 adapter는 현재 필자의 능력상(필자는 아직 controller외에 graphQL같은 것들을 잘 모른다) Controller 하나 밖에 없기 때문에 inbound-port를 하나만 정의하고, outbound-port에 대한 adapter는 DB, email, search engine 등 다양하기 때문에 outbound-port를 여러 개 정의하는게 맞지 않나 하는 개념적인 이유 때문이다.
  • 필자는 일단 이 구조로 ports and adapter architecture를 구현하기로 결정했다.

 

4. 하나의 리소스에 대한 기능 별 서비스들과 기능 별 inbound, outbound-port를 가진 구조

  • 원티드 온보딩에서 배웠던 방법이다.
  • 개인적으로 가장 깔끔한 방법이라고 생각한다. 하지만, 그만큼 작성해야하는 서비스와 port의 개수도 늘어나기 떄문에 파일의 개수가 많아진다.
  • 예를들면, user라는 리소스가 있고, 그에 따른 로그인, 회원가입 기능이 있을 경우, 구조는 아래와 같을 것이다.
src
|
- user
    |
    - inbound-port
    |	|
    |	- logIn.inbound-port.ts
    |	- register.inbound-port.ts
    |
    - outbound-port
    |	|
    |	- logIn.outbound-port.ts
    |	- register.outbound-port.ts
    |
    - outbound-adapter
    |	|
    |	- user.repository.ts
    |
    - controller
    |	|
    |	- user.controller.ts
    |
    - service -
    |	|
    |	- logIn.service.ts
    |	- register.service.ts
    |
    user.module.ts
  • 필자가 이 방법으로 아키텍쳐를 설계하기에는, 내가 평소에 사용하던 프로젝트 구조와는 조금 다른 부분이 있어 나중에 기회가 되면 설계해볼 예정이다.

 

+ 추가적으로 든 생각

3번의 방법으로 구현할 때, outbound-port를 기능 별로 만드는게 맞을까? 아니면, 구현체(외부 요소) 별로 만드는게 맞을까? 생각의 차이겠지만, 나는 일단 구현체 별로 구현을 해볼까 한다.