23년 1월 원티드 프리온보딩 백엔드 2일차 TIL (실무 함수형 프로그래밍 빌드업) (Wanted Pre Onboarding BackEnd)

thumbnail

오늘의 강의는..

2일차 부터는 실제 Nest.Js에 대해서 알아보고, 포트 앤 어뎁터 아키텍쳐(Ports and Adapters Architecture)에 대해 설명을 하는 시간을 가졌다.
강의를 들으면서 내용을 정리한 부분이다.

옆에서 강의 틀고 노션 및 수기로(개판 필기체를 해석 하는데 힘들었다…) 작성한 것을 다시 곱씹고 하면서 강의 내용을 정리했다.
이렇게 라이브를 직접 옮기면서 작성한 것을 정리한 것이라 약간 엉성할 수 있다.
이 부분은 감안하고 봐주시길 바란다.


강사님의 이야기 시간

강사님의 개발자 일대기 이야기를 해주셨다.
재직한 회사에서 다양한 경험을 쌓으셨고 매 순간 열심히 하신 것 같다.

어떤이는 운도 중요하여, 열심히 해도 운이 안좋으면 취업, 이직이 어렵다 라고 이야기 하는 사람도 있다.
하지만 개인적으로 거꾸로 아닌가 싶다.

열심히 노력하고 준비를 하고 있어여 기회가 온 운을 잡을 수 있는게 아닌가? 라는게 나의 생각이다.

불확실성은 곧 희망이다.

강사님의 이 멘트가 개인적으로 많이 와닿았다.


실무 함수형 프로그래밍 빌드업

오늘의 강의 내용은 아래와 같았다.

  1. Nest.Js 주요 컨셉
  2. Ports and Adapters Architecture
  3. Live Coding1 : Nest.js 로 프로젝트 아키텍처 구성하기
  4. Live Coding2 : 사전과제 로직 함께 리팩토링 하기

초반에 많은 질의가 있었다.
간단하게 정리하면…

  • Express를 안쓰는 것은 아니다.
  • Express를 좀 더 추상화 한 개념이 Nest.js
  • Nest.Js는 Express 기반이고, Angular에서 많은 영감을 받아서 개발하였다.
  • Layerd Architecture는 기업에서 많이 채택하는 아키택쳐
  • 쉽게 프로젝트를 구성할 수 있는 아키텍쳐
  • Ports and Adapters Architecture 는 여기서 한 단계 더 진화된 아키텍처
  • 훨씬 더 직관적인 비즈니스 로직을 작성할 수 있는 아키텍쳐

Nest.js 주요 컨셉

Nest.Js 개발자는 고양이를 좋아한다.(?)


Controllers

컨트롤러에 대한 정의

  • 컨트롤러는 어플리케이션을 향한 요청을 받는 첫 번째 스탭
  • 라우팅 역할
  • 외부세계로 부터 들어온 요청이 어느 곳으로 가야하는지 안내하는 역할
  • 클라이언트로부터 요청이 들어오는 부분
  • 컨트롤러는 NestAPI 뿐만이 아닌 GraphQL이나 이런 것도 컨트롤러할
  • 요청이 들어오는 것이 컨트롤러이며, 요청이 되는 주체를 의미한다.
  • 외부 세계로 부터 들어온 요청이 어느 곳으로 가는게 맞냐 라는 역할
  • 사용자의 요청을 수행할 수 있는 비즈니스 로직에 요청을 보내는 주체
  • 하지만 컨트롤러는 순수함수가 아님을 유의한다. (외부에서 오는 요청 때문에…)

Nest.Js의 컨트롤러 생김새는 다음과 같다.

import {Controller, Get} from "@nestjs/common";

@Controller("cats")
export class CatsController {

    @Get()
    async findAll(): Promise<string> {
        return "This action returns all cats
    }
}
  • Nest.Js 컨트롤러는 Typescrit의 데코레이터 문법을 사용해 추상화를 했다.
  • Nest.Js의 데코레이터 형식은 자바의 스프링과 모습이 비슷하다.

Providers

  • NestJs의 근본이며 중요한 컨셉
  • Nest.Js의 대다수의 클래스들이 provider로 구성
  • provider는 디펜던시로 주입될 수 있다.
  • 컨트롤러는 HTTP 요청을 핸들링하고 더 복잡한 일들(디비 처리, 요청에 대한 작업 등)을 provider에게 위임 해야한다.
  • provider에게 중요한 비즈니스 로직을 가둔다.
  • provider는 그저 javascript의 class일 뿐이다.
  • 프레임워크에 종속되지 않은 클래스이고, javascript에서 클래스는 결국 프로토타입 함수다.
  • provider는 순수함수가 될 수 있다.
@Injectable()
export class CatsService {
  private readonly cats: Cat[] = []

  create(cat: Cat) {
    this.cats.push(cat)
  }
}

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto)
  }
}
  • 위 코드에서 보면 컨트롤러는 서비스 객체에게 위임한다.
  • 위임을 표시하는 것이 컨트롤러의 생성자 주입(의존성 주입 형태 중 하나)이 그 표현이다.
  • 즉 데코레이터 모두 빼면 함수이고, 함수형 프로그래밍 페러다임으로 적용이 가능하다.
  • 참고 Provider 공식문서

Modules

  • 일종의 패키지 역할을 하는 것
  • 같은 도메인에 속한 것들을 응집화 할 수 있도록 도와주는 역할
  • 앱의 사이즈가 커질수록, 경계를 설정해서 복잡도를 매니징할 수 있다.
  • 경계(도메인)를 설정해서 각 모듈 간의 의존도를 제어할 수 있게 해준다.
  • MSA도 모듈화의 방법 중 하나
@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}
  • 위 코드에서 데코레이터를 선언하고 controllers 배열에 controller 역할을 하는 것을 전달하고, providers도 동일하다.
  • provider에서 @Injectable() 데코레이터만 붙이기만 하면 안되고, 모듈의 provider 배열에 전달해야 nest.js에 위임한다.

Dependency Injection

  • Inversion of control (IoC)
  • Nest.js 런타임 시스템에 dependency 인스턴스화를 위임한다.
  • 실제 객체를 인스턴스화 시키는 작업을 프레임워크에 위임하는 것
@Injectable()
export class CatsService {

}

@Controller("cats")
export class CatsController {
    constructor(private catsService: CatsService) {}
}

@Module({
  controllers: [CatsController],
  providers: [{provide : CatsService, useClass : CatsService}],
})
  • 위와같은 코드가 있을 때 Nest.js에서 처음에 부트스트랩을 할 때 인스턴스를 계산해서 필요한 클래스를 생성하고, 런타임 시점에 해당 컨트롤러나 프로바이더에 주입한다.
  • 개발자가 의존성에 대해 직접 인스턴스 생성 및 관리를 할 필요가 없다.
  • 위 코드를 기준으로 흐름을 보면 다음과 같다.

  1. CatsController를 인스턴스화 할 때, dependency(의존성)를 확인한다.
  2. CatsService 토큰은 CatsService 클래스를 리턴한다.

    • 여기서 나온 토큰의 의미는 식별자를 의미한다.
    • 하단의 @Module을 보면 providers 배열 안에 provide가 토큰(식별자 또는 구분자)를 가리킨다.
    • 저 토큰은 클래스도 되지만 일반 문자열(string)도 가능하다
    • useClass에는 직접 구현한 클래스를 넣는다.
    • 이 코드 어디서도 new 등으로 인스턴스 할당하는 로직을 볼 수 없다.
    • providers에는 저렇게 토큰 명시를 해서 구현하기도 하고, 그냥 CatsService만 명시해서 쓰기도 한다.
  3. Singleton 스코프(default)로 CatsService를 인스턴스화 시킨다.

    • 그래서 Nest.js에서 부트스트랩 시 providers를 보고 식별자와 구현 클래스를 확인 후 CatsService 인스턴스를 싱글톤(기본값)으로 인스턴스화 한다.
  4. 메모리 내에 캐시를 하고 재사용이 가능하게끔 만든다.

    • Nest.js에는 컨테이너를 운영하는데 그 안에 CatsService 객체가 생성되어 있다.
    • 그래서 요청 시 미리 메모리에 올려 있는 것을 가져와서 쓴다.
  5. 이 모든 과정은 Bottom up으로 dependency가 정확한 순서로 관리된다.

이것이 Nest.js의 핵심 내용이다.

스프링과 같다라고 하는 의견이 많았다.
나 또한 같은 것을 느꼈고, 어떤 이는 라이트 스프링이라고 이야기 했는데 정말 그런것 같다.

과거 스프링을 4년 정도 개발했던 나로써 Nest.js 및 Typescript 언어에 대한 거부감이 없었고, 학습도 쉬웠다.
오히려 난 Javascript가 좀 난해했다. (타입이 없었고 너무 자유분방한 것 같아서 그랬었던 것 같다.)


Architectures

먼저 Layered Architecture에 대해 설명 및 한계에 대한 설명을 했다.

Layered Architecture의 한계

img02 이미지 출처 - java-vault

레이어드 아키텍쳐를 사용하다 보면 배치에 대해 고민이 생기게 된다.

예를 들어 고객이 생성되었을 때, 메일을 보내는 기능을 추가해주고 싶은데 이 경우 메일 서비스를 만들어주면 되긴 한다.
근데 메일 서비스를 만들 때, 메일 송신을 담당하는 인프라 코드(구글, 네이버 등 메일을 보내게 해주는 인프라)는 어느 곳에 둘 것인지에 대해 고민이 있었다.

즉 메일 전신을 만들어주는 기능이 있고, 해당 메일을 보내는 기능도 있으며, 메일 보냈을 때 기록을 남기는 기능도 있을 것이다.
이렇게 일일히 구분해서 하다보면 혼란이 온다.

답이 명쾌하지 않기 때문에 혼란이 가중화 된다.
그래서 레이어드 아키텍쳐는 다양한 비즈니스 로직을 충족시키기에는 한계가 있는 구조다.


Layered Architecture의 진화 (Port and Adapter Architecture)

img03 이미지 출처 - getoutsidedoor

외부에서 접근은 Rest Api가 될 수 있고, 개발자들끼리 CLI로도 올 수 있고 다양한 접근이 가능하다. 그리고 내부에서 처리는 각 요구사항에 맞는 부분들의 도착지가 있다.

이렇게 될 경우 모든 로직은 Business Logic이라는 부분으로 외부 내부의 시선이 모이게 된다.
위 그림에서처럼 왼쪽은 요청이 들어오는 영역이고, 오른쪽은 인프라에 영향을 끼치는 영역이다.

오른쪽은 개발자가 크게 신경을 쓸 부분은 아니다.
어찌보면 각 인프라나 디비의 경우 구현체가 있고 거기까지 흐름을 가져다 주는게 개발자의 역할일 뿐이다.(강사님은 농담 뉘앙스로 내 알바 아님이라 표현하심 ㅋ)

포인트는 우리가 작성하는 비즈니스 로직은 내부에 가두고, 들어오는 요청을 받는 부분의 문을 열어준다.(포트를 열어준다.)

위 그림에서 왼쪽 영역의 경우…
외부에서 요청한 것은 포트로 받고, 내가 작성한 비즈니스 로직을 사용하려면 포트로만 접근하여 사용할 수 있게끔 해주는 것.

오른쪽 영역의 경우…
약속된 포트를 통해서만 구현체에게 명령을 준다라는 것.

이것이 포트 앤 어뎁터 아키텍쳐(Port and Adapter Architecture)이다.

이것을 다시 정리하면…

  • 인터페이스를 통해서 해당 포트로 접근이 가능하게끔 한다는 것이 포인트.
  • 내가 미리 약속한 인터페이스를 통해서만 포트에 접근이 가능하고, 포트를 통해서만 비즈니스 로직에 접근이 가능하다.
  • 그리고 비즈니스 로직에서 처리한 결과가 포트를 통해 외부세계(인프라나 디비 영역)에 영향을 준다.
  • 레이어드 아키텍쳐에서 특정 로직을 담당하는 코드를 어디에 위치해야 할 지를 고민하던 것들을 해결하기 위해 나왔다.
  • 무조건 포트를 통해서만 서로 통신을 한다.

개념적인 이야기는 이게 끝이다.

내부 세계(우리가 작성하는, 또는 작성해야 할 영역)는 각종 버그와 위험(잘못짠 코드..)이 도사리고 있다.
이런 위기를 구원해주는 패러다임이 있다.
패러다임은 프로그래밍 룰이 필요하고, 마인드셋(어떠한 사고로 프로그래밍을 할 것)이 필요하며, 우리가 작성한 어플리케이션 코드를 바라보는 철학이 필요하다.

저번 시간에는 함수형 프로그래밍 룰을 통해 사이드 이팩트를 줄이고, 고차함수를 통해 함수를 합성하는 것을 배웠다.
이번에는 인터페이스라는 룰을 배울 것이다.


Interface

img04 이미지 출처 - SK(주) C&C’s TECH BLOG

컨트롤러에서 들어오는 영역을 인바운드 어뎁터라 하고, 컨트롤러가 내부 비즈니스 로직을 처리하기 위한 문(포트)를 인바운드 포트라 한다.
내가 작성한 서비스 로직에서 처리 결과나 외부 세계로 작업 결과물을 내보내는 문(포트)를 아웃바운드 포트라 하고, 그 문과 연결되어 처리되는 부분을 아웃바운드 어뎁터라 한다.


Ports and Adapters Architecture

내용을 정리하면 아래와 같다.

  • inbound-adapter : controller, GraphQL, REST, gRPC, CLI 등
  • inbound-port : 서비스 로직으로 향하는 interface
  • service : inbound-port 구현체
  • outbound-port : 외부 세계로 향하는 interface
  • outboud-adapter : outbound-port 구현체 (DB, Search Engine, Notification, Mail 등)

수업 중간 질문

실제 라이브 코딩 전 잠깐 질의 응답 시간이 있었는데 이를 간략하게 정리했다.
Q 는 수강생이 질문한 것이고, A는 강사님이 답변하신 부분이다.

Q : 기존 레이어드 아키텍쳐에 비해 좋은 게 뭐가 있는가?
A : 지금 당장은 생각이 나지 않는다.

Q : In-Out port 사이에 있는 service가 거대해 지는 문제점이 있지 않나?
A : 그렇게 하지 않기 위해 개발자가 노력해야 한다.
그래서 서비스를 최대한 최소 단위로 나누고, 그 안을 함수형 프로그래밍 페러다임을 적용하면 좋다(라고 개인적인 의견이라 말씀하심)

Q :Service 부분을 MSA로 구성하고 port 부분만 gateway로 구상할 수 있는가?
A : 맞다. 포트 부분만 열어놨기 때문에 그 부분만 때서 처리하기 용이하다.
게이트웨이는 MSA 포워딩 해주는 서비스라는 것이라고 질문자가 응답함

Q : 초기 어플리케이션 개발에 핵사고날(포트 앤 어뎁터) 아키텍쳐를 적용하는 경우가 있는가?
A : 있다.

Q : Nest.js + fsts를 사용한 참조하기 괜찮은 레포지토리가 있는가?
A : 내가 만들지 않아서 없다.

Q : 여러 DB를 분산한 경우 데이터를 모을 때 어떤 방법을 사용하였는가?
A : 다양한 방식으로 해결할 수 있다.
중앙 코어(데이터팀)에서 각 디비를 찔러서(호출) 한번에 모든 데이터를 수집해서 빅데이터 등으로 모은 뒤 필요한 데이터를 가공하여 api로 제공함
다양한 방법을 사용하기도 한다.
정답은 없고 각자 방법이 다 다르다.


번외로 강사님이 재직했던 곳에서는 MSA 구성이 각 서버가 각자의 디비를 가지고 있는 형태였고,
데이터가 분산되어 있기에, 서로 소통하며 데이터를 주고받을 수 있는 이벤트 드리븐(도메인 드리븐) 아키텍처가 적용되어 있었다고 하심.
하지만 설계에 정답은 없다.

긴 설명 이후 잠시 휴식 후 이제는 라이브 코딩으로…


Live Coding1 : Nest.js 로 프로젝트 아키텍처 구성하기

Nest.Js 공식문서를 참고하며 진행하였다.
먼저 공식 레포를 참고해서 프로젝트 셋업을 진행하였다.

npm install 하여 설치하며 기다리는 중 깜짝질문이 나왔다.

package.json.lock은 왜 있는 것인가?

나도 이 부분을 몰랐다 -0-;;
대충 찾아보니 조금 더 명확한 버전을 제공하기 위해서다.
쉽게 고정된 버전을 동일하게 쓰기 위함이다.

Nest.js에 대해 깊게 파지는 않고, 아키텍쳐에 대해서 알아볼 예정이다.
이번 라이브코딩 코드는 lecture-2를 참고하면 된다.

오늘의 목표는 Member의 리스트를 조회하는 API 작성이다.
이번 라이브 코딩에서 디비를 직접 다루진 않고 메모리 샘플 데이터로 진행한다.


메모리 디비

아래 코드가 메모리 디비로 쓸 샘플코드다.

export type Member = {
  id: bigint
  name: string
  email: string
  phone: string
}

export const MemoryDatabase = (() => {
  const members = [
    {
      id: 1n,
      name: 'J',
      email: 'j@gmail.com',
      phone: '010-3333-4444',
    },
  ] as Member[]

  return {
    findMembers: () => Promise.resolve(members),
  }
})()

여기서 bigint가 등장하는데 javascript에서는 큰 수를 다룰 때 쓰는 것이라고 한다.(나도 처음봤다.)
근데 개념은 이해하는게 Java에서도 유저 값 고유 id는 uuid나 정수형 쓸 때 Long을 쓰긴 해서 javascript에는 number만 쓰는줄 알았었다.

근데 이거를 실제로 쓰려면 미들웨어를 붙여서 컨버팅하고 해야 해서 이번 실습에서는 number 타입을 쓰는 것으로 했다.

findMembers 클로저 함수는 Promise를 반환하는데 member배열을 반환한다.


Controller

다음은 컨트롤러 영역이다.
강사님 회사의 코드 전략 중 하나는 파일에 하나의 클래스에 하나의 함수만 가지는게 원칙이 있는데 이를 적용했다.

그래서 이번에 처럼 조회하는 함수를 만들면 다음과 같은 코드가 만들어진다.
파일명은 get-members.controller.ts를 사용하였다.

나는 그냥 get.members.controller.ts 이런 유형인데 하이픈도 같이 쓰기도 하나보다…

import { Controller, Get, Inject } from '@nestjs/common'
import {
  FIND_MEMBERS_INBOUND_PORT,
  FindMembersInboundPort,
} from '../inbound-port/find-members.inbound-port'
import { MemoryDatabase } from 'src/lib/memory-database'

@Controller()
export class GetMembersController {
  constructor(
    @Inject(FIND_MEMBERS_INBOUND_PORT)
    private readonly findMembersInboundPort: FindMembersInboundPort
  ) {}

  @Get('/members')
  async handle() {
    return MemoryDatabase.findMembers()
  }
}

이렇게 하고 서버를 실행시키고 접근해본다.

http://localhost:3000/members

근데 404가 뜬다.

이유는 간단하다.
위에서도 언급했다시피 어노테이션(데코레이터)만 붙인다고 되는게 아니다.

Module에도 등록을 해줘야 한다.

//member.module.ts

@Module({
  controllers: [GetMembersController],
  providers: [],
})
export class MemberModule {}

근데 이렇게 등록을 해도 여전히 404가 뜬다.
이것도 위에서 본바와 같이 app.module.ts에서 등록을 해줘야 한다.

//app.module.ts

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

여기서 의존관계를 추적해보면 main.ts부터 시작해본다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  await app.listen(3000)
}
bootstrap()

저기서 AppMododule로 가져와서 App을 생성하는데,
저 AppModule을 타면 importsMemberModule을 가져오고,
거기서 controllers를 보니 GetMembersController 컨트롤러를 선언한 것을 확인할 수 있다.

이제는 위의 코드를 Port And Adapter Architecture로 바꿔보겠다.


아키텍쳐 적용해보기 (Inbound-Port interface)

이제는 메모리 디비의 값을 가져오는 것을 해보려 한다.
위 이론에서 언급한 내용처럼 inbound-port를 만들기 위해 인터페이스를 하나 만든다.

export interface FindMembersInboundPort {
  execute()
}

이 인터페이스는 하나의 역할만 하는 것을 목표로 하는게 이 아키텍쳐의 목표다.
위 인터페이스의 이름처럼 멤버를 찾는 역할을 하는 목표만 가진다.

이제 이 인터페이스를 채워보겠다.

//find-members.inbound-port.ts
import { Member } from '../../lib/memory-database'

export type FindMembersInboundPortInputDto = void

export type FindMembersInboundPortOutputDto = Array<Member>

export const FIND_MEMBERS_INBOUND_PORT = 'FIND_MEMBERS_INBOUND_PORT' as const //이 부분은 아래 서비스쪽에서 다룹니다.

export interface FindMembersInboundPort {
  execute(
    params: FindMembersInboundPortInputDto
  ): Promise<FindMembersInboundPortOutputDto>
}

코드를 설명 전 이 코드에 함정이 있다.
다른 분들도 못맞췄고, 나도 못맞췄다.

정답은 아래 코드다.

export type FindMembersInboundPortOutputDto = Array<Member>

여기서 Member는 inbound쪽 영역이 아닌 디비 영역에…즉 인프라 코드에 있는 것이다.
이렇게 되면 의존성이 깨져버린다.

그래서 서비스 로직을 작성할 때 요점은 처음부터 디비나 각종 의존성을 제거하고 작성하는 것이다.

그럼 이 로직을 어떻게 바꾸냐 하면…

//find-members.inbound-port.ts
export type FindMembersInboundPortInputDto = void

export type FindMembersInboundPortOutputDto = Array<{
  name: string
  email: string
  phone: string
}>

export const FIND_MEMBERS_INBOUND_PORT = 'FIND_MEMBERS_INBOUND_PORT' as const

export interface FindMembersInboundPort {
  execute(
    params: FindMembersInboundPortInputDto
  ): Promise<FindMembersInboundPortOutputDto>
}

이렇게 타이핑으로 직접 처리를 하면 된다.
이게 단점이 될 수 있다.

물론 중복된 코드가 많아질 수 있지만 이것을 감수해야 하는 게 이 아키텍쳐다.
이렇게 되면 엔티티와 별개로 DTO가 필요해진다.

이 룰은 강한 룰이지만, 이렇게 하는 것이 필수는 아니며 교육 차원 및 이런 습관을 들이는 것이 중요하다.
나도 이 부분은 공감했고, 내 프로젝트에 이 룰을 적용하여 리팩토링을 진행해 봐야겠다고 생각이 들었다.

//DB Entity
export type Member = {
  id: bigint
  name: string
  email: string
  phone: string
}

//inbound port dto
export type FindMembersInboundPortOutputDto = Array<{
  name: string
  email: string
  phone: string
}>

이와 같이 코드가 분리될 경우 의존성은 없어지고, 완전한 분리가 되어 서로의 간섭이 없어지며 유지보수가 용이해진다.
이 부분은 트레이드 오프가 있는데 얻을 수 있는 것은 의존성 제거다.

이렇게 함으로써 저 inboud-port 인터페이스는 의존성이 없게 되었다.

다른 분이 좋은 의견을 주셨는데 이런 타입들을 외부에서 별도 모듈을 통해서 관리하고,
이 모듈을 임포트해서 저 port에서 가져와서 쓰는 것도 된다고 하셨다.

근데 이렇게 될 경우 해당 모듈에 대한 의존성이 생기는 것이므로,
해당 모듈도 관리 포인트가 되고 의존성을 신경써 줘야 한다.(결국 또 등가교환이 이뤄진다.)

개인적으론 그냥 강사님이 보여주신 의존성 제로 방식이 더 좋은 것 같다.
이제는 서비스 로직으로 들어간다.


아키텍쳐 적용해보기 (Service)

서비스는 아까 작성한 inbound-port를 구현해야 한다.

//find-members.service.ts
export class FindMembersService implements FindMembersInboundPort {
  async execute(
    params: FindMembersInboundPortInputDto
  ): Promise<FindMembersInboundPortOutputDto> {
    return MemoryDatabase.findMembers()
  }
}

아직 완성되지는 않았지만 대략 이런 형태로 구현이 될 것이다.
근데 이 코드에서도 함정이 있는데 바로 타입스크립트의 함정이다.

(물론 이 코드는 완성된 결과물이 아니지만…)
이 코드에서 MemoryDatabase.findMembers() 함수가 리턴하는 값은 디비의 Member타입이고, 여기엔 id가 있다.
하지만 지금 반환을 보면 OutputDto고 여기엔 id값이 없다.

이 부분은 Typescript덕 타이핑(Duck Typing)덕분에 에러가 안나고 넘어간다.
덕 타이핑은 간단하게 설명하면 일종의 동적 타이핑 종류이고, 구조적으로 타입이 맞기만 하면 에러 없이 허용을 한다.
이거는 나중에 따로 포스팅을 하면서 공부해봐야겠다.


이제 프로젝트를 구동하기 위해 위 서비스를 주입해야 하는데
서비스 자체를 주입하는건가? 포트를 주입하는 건가를 햇갈릴 수 있다.

물론 정답은 포트다.
포트를 통해서만 호출하기 때문이다.

의존성 주입을 해야 하는데 Nest.js에서는 생성자 주입을 많이 사용한다.
컨트롤러에 서비스 로직을 주입할 때는 아래 코드와 같이 작성된다.

@Controller()
export class GetMembersController {
  constructor(
    @Inject(FIND_MEMBERS_INBOUND_PORT)
    private readonly findMembersInboundPort: FindMembersInboundPort
  ) {}

  @Get('/members')
  async handle() {}
}

여기서 생성자를 보면 @Inject()라는 데코레이터가 사용되었는데 이게 의존성 주입 역할을 해준다.
저 데코레이터에 전달된 값이 아까 이론에서 언급했던 토큰이다.

이제 위의 서비스 설명 중 포트 인터페이스 코드가 어디에 어떤 역할인이 알 수 있다.

export const FIND_MEMBERS_INBOUND_PORT = 'FIND_MEMBERS_INBOUND_PORT' as const

그럼 여기서 의문이 들 것이다.
아까 Module설명을 할 때 Provider에서 토큰과 클래스가 있었는데 어떻게 적용해야 하는가?

아래 코드를 보면서 설명한다.

//Member Module

import { FIND_MEMBERS_INBOUND_PORT } from './inbound-port/find-members.inbound-port';
import { FindMembersService } from './service/find-members.service';

@Module({
  controllers: [],
  providers: [{
      provide: FIND_MEMBERS_INBOUND_PORT,
      useClass: FindMembersService,
    }],
})

여기서 중요한 것은 useClass에는 Port interface가 아닌 해당 Port를 구현한 구현체가 작성된 점이다.
Nest.js에게 이런 토큰 (FINDMEMBERSINBOUND_PORT)을 찾으면 해당 구현체는 FindMembersService 라고 말해주는 것이다.

그리고 의존성 주입을 할 때는 아래와 같이 토큰만 주입을 받는 것이다.

constructor(
    @Inject(FIND_MEMBERS_INBOUND_PORT)
    private readonly findMembersInboundPort: FindMembersInboundPort,
  ) {}

이렇게 되면 Nest.js는 토큰을 본 다음에 실제 구현체인 FindMembersService를 가져와서 주입해준다.
토큰을 쓰게 되면 추상화 된 개체들(인터페이스 구현체 및 기타 구현체)을 쓸 때 식별자로 사용이 가능해진다.

근데…스프링은 이걸 아주 옛날에 이런 개념을 적용했는데…
역시 Spring은 정말 잘 만들어진 프레임워크인것 같다는 생각이 다시금 들었다.
특히 위 방식도 @Qualifier()에서 이미 사용한 방식이다.

//Java sample
public class MyData {
    @Autowired
    @Qualifier("userData")
    private UserData userData;
}

Java spring에서는 이런식으로 가끔 사용했었다.

다시 원점으로 돌아와서…
Nest.js에게 이 토큰으로 식별하여 찾을 경우 useClass가 가리키는 객체를 인스턴스화 해서 사용해 달라는 것이다.


다시 돌아와서…
실제 컨트롤러의 호출단은 다음과 같이 바꿀 수 있게 된다.

// import { MemoryDatabase } from 'src/lib/memory-database'

@Get('/members')
  async handle() {
    return this.findMembersInboundPort.execute();
    //return MemoryDatabase.findMembers()
  }

이렇게 되면 컨트롤러에서 메모리 디비에 대한 의존성도 사라진다.
여기까지 코드와 내용을 보면 각 영역마다의 의존도가 없다는 것을 알 수 있다.

컨트롤러 코드를 다시 보자.

//get-members.controller.ts
import { Controller, Get, Inject } from '@nestjs/common'
import {
  FIND_MEMBERS_INBOUND_PORT,
  FindMembersInboundPort,
} from '../inbound-port/find-members.inbound-port'

@Controller()
export class GetMembersController {
  constructor(
    @Inject(FIND_MEMBERS_INBOUND_PORT)
    private readonly findMembersInboundPort: FindMembersInboundPort
  ) {}

  @Get('/members')
  async handle() {
    return this.findMembersInboundPort.execute()
  }
}

여기서 서비스 로직은 찾아볼 수 없다.
다 각자 분리가 되어 있음을 알 수 있다.

그런데 어떤 분이 의견을 주셨는데 이 구조를 사용하면 파일 갯수가 엄청 늘어나지 않는가? 였다.
강사님이 좋은 말씀을 주셨다.

맞는 말이다.
그런데 코드 하나에 엄청난 라인을 자랑하는 방식을 사용할 것인지…아니면 짧은 라인의 코드를 가진 복수의 파일 방식을 사용할 것인지?

난 당연 후자였다.
무튼 실행을 하면 데이터가 잘 나오는데 덕타이핑 덕분에 id도 그대로 노출이 된다.

이제는 서비스 로직을 보고 개선해나갈 차례다.
아래가 서비스 로직이다.

//find-members.service.ts
export class FindMembersService implements FindMembersInboundPort {
  async execute(
    params: FindMembersInboundPortInputDto
  ): Promise<FindMembersInboundPortOutputDto> {
    return MemoryDatabase.findMembers()
  }
}

여기도 보면 MemoryDatabase.findMembers()로 직접 호출을 하는데 이 의존도를 끊어보겠다.
이제는 outboud-port와 adapter를 만들어준다.

outbound-port도 동일하게 작성을 한다.

//find-members.outbound.port.ts

export type FindMembersOutboundPortInputDto = void

export type FindMembersOutboundPortOutputDto = Array<{
  name: string
  email: string
  phone: string
}>

export const FIND_MEMBERS_OUTBOUND_PORT = 'FIND_MEMBERS_OUTBOUND_PORT' as const

export interface FindMembersOutboundPort {
  execute(
    params: FindMembersOutboundPortInputDto
  ): Promise<FindMembersOutboundPortOutputDto>
}

그리고 아까 컨트롤러에 주입했던 것처럼 서비스에도 outbound-port를 주입해준다.

//find-members.service.ts

import {
  FindMembersInboundPort,
  FindMembersInboundPortInputDto,
  FindMembersInboundPortOutputDto,
} from '../inbound-port/find-members.inbound-port'
import { Inject } from '@nestjs/common'
import {
  FIND_MEMBERS_OUTBOUND_PORT,
  FindMembersOutboundPort,
} from '../outbound-port/find-members.outbound-port'

export class FindMembersService implements FindMembersInboundPort {
  constructor(
    @Inject(FIND_MEMBERS_OUTBOUND_PORT)
    private readonly findMembersOutboundPort: FindMembersOutboundPort
  ) {}

  async execute(
    params: FindMembersInboundPortInputDto
  ): Promise<FindMembersInboundPortOutputDto> {
    return this.findMembersOutboundPort.execute()
  }
}

이렇게 되면 메모리 쪽의 의존도를 끊을 수 있게 되었다.
하지만 아직 FindMembersOutboundPort의 구현체가 없기 때문에 이를 만들어준다.

//find-members.repository.ts

import {
  FindMembersOutboundPort,
  FindMembersOutboundPortInputDto,
  FindMembersOutboundPortOutputDto,
} from '../outbound-port/find-members.outbound-port'
import { MemoryDatabase } from '../../lib/memory-database'

export class FindMembersRepository implements FindMembersOutboundPort {
  async execute(
    params: FindMembersOutboundPortInputDto
  ): Promise<FindMembersOutboundPortOutputDto> {
    return MemoryDatabase.findMembers()
  }
}

당연한 이야기겠지만 해당 구현체에서는 메모리 의존을 해도 된다.
근데 outbound-port에서 dto에는 id가 없기에 타입에 맞춰서 반환 값을 바꿔준다.

async execute(
    params: FindMembersOutboundPortInputDto,
  ): Promise<FindMembersOutboundPortOutputDto> {

    const members = await MemoryDatabase.findMembers();

    return members.map((member) => {
      return {
        name: member.name,
        email: member.email,
        phone: member.phone,
      };
    });
  }

그리고 module에 선언을 해줘야 한다.

//member-module.ts

import { Module } from '@nestjs/common'
import { GetMembersController } from './controller/get-members.controller'
import { FIND_MEMBERS_INBOUND_PORT } from './inbound-port/find-members.inbound-port'
import { FindMembersService } from './service/find-members.service'
import { FIND_MEMBERS_OUTBOUND_PORT } from './outbound-port/find-members.outbound-port'
import { FindMembersRepository } from './outbound-adapter/find-members.repository'

// 오늘 할 것: member 의 리스트를 조회하는 API 작성
@Module({
  controllers: [GetMembersController],
  providers: [
    // inbound-port
    {
      provide: FIND_MEMBERS_INBOUND_PORT,
      useClass: FindMembersService,
    },

    // outbound-port
    {
      provide: FIND_MEMBERS_OUTBOUND_PORT,
      useClass: FindMembersRepository,
    },
  ],
})
export class MemberModule {}

여기까지 하면 Port and adapter 아키텍쳐를 적용한 모습이다.

서비스 로직이나 디비를 가져오는 리파지토리 로직에 대해 테스트 코드 작성도 쉽고, 코드 자체도 간결에서 읽기 편하다.
여기까지 하고 나니 내가 짠 코드는 벌레같은 코드였구나 라는 생각이 들 정도였다.


테스트 코드 작성

이 부분은 강의를 듣다가 단순 코드를 작성하는 부분이 많아서 Github 코드 및 아래 테스트 코드로 대체한다.

import { FindMembersService } from './find-members.service'
import {
  FindMembersOutboundPort,
  FindMembersOutboundPortInputDto,
  FindMembersOutboundPortOutputDto,
} from '../outbound-port/find-members.outbound-port'

class MockFindMembersOutboundPort implements FindMembersOutboundPort {
  private readonly result: FindMembersOutboundPortOutputDto

  constructor(result: FindMembersOutboundPortOutputDto) {
    this.result = result
  }

  async execute(
    params: FindMembersOutboundPortInputDto
  ): Promise<FindMembersOutboundPortOutputDto> {
    return this.result
  }
}

describe('FindMembersService Spec', () => {
  test('멤버 리스트를 반환한다.', async () => {
    const member = [
      {
        name: 'A',
        email: 'A@gmail.com',
        phone: '0103123123',
      },
    ]

    const findMemberService = new FindMembersService(
      new MockFindMembersOutboundPort(member)
    )

    const res = await findMemberService.execute()

    expect(res).toStrictEqual([
      {
        name: 'A',
        email: 'A@gmail.com',
        phone: '0103123123',
      },
    ])
  })
})

아까 서비스 로직을 보면 @inject를 제외한 어느 곳에서도 Nest.js의 기능을 사용하지 않고, 순수 Typescript로 구성되어 있음을 알 수 있다.
이는 테스트 코드를 작성할 때 Nest.js에 대한 의존이 낮다는 것을 의미한다.

그리고 보통 단위 테스트 등을 할 때 디비에 대한 의존도가 있을 수 있는데, 이렇게 할 경우 독립적으로 테스트가 가능하다.


정리

마지막 항목인 4. Live Coding2 : 사전과제 로직 함께 리팩토링 하기는 시간이 없어서 다음 강의에 다루기로 하였다.

그리고 수강생들의 질의가 이것저것 많았는데 그 중 하나가 아래였다.

Q : 포트 아키텍쳐 단점

  1. 타이핑이 많아진다.
  2. 모듈간의 의존성 관리가 아렵다(모든 아키텍쳐의 공통점)

사실 타이핑이 많은게 나은 것 같다.

그리고 강사님의 정리 내용을 요약하자면…

  • 어떤 아키텍쳐가 정답이 없다.
  • 선택한 아키텍쳐에 대한 이해도와 필요성, 숙련도에 따라 적용하면 된다.
  • 핵사곤 아키텍쳐라고 하기도 하는데 이건 더 어렵기에 포트 앤 어뎁터로 준비했다.

강의는 3시간 밖에 안되었는데 내가 쓴 엉망 필기체 및 요약본…그리고 노션에 정리한 코드 및 url 등을 다 따서 복습을 해보니…
이거 또한 일인듯 했다…

안그래도 흑우집합소 개발에 매진해야 했지만, 아무래도 너무 시간을 지체했었고,
한번 적용해봐야지 라는 생각에 시작했는데 아무래도 다음 강의 자료는 금방 할 수 없을 듯 하다.

참고로 아직 함수형 프로그래밍은 나오지 않았고, 함수형을 위한 빌드업 과정이었다.
강의를 정리하면서 들으니 내용을 흘려 들은게 많아서 자세히 이해할 수 없었지만,
정리하면서 보니 이제는 그 흐름이나 맥락이 이해되었다.

사실 이번 백엔드 강의는 나에게 너무 유익했던 시간이고, 소중한 내용을 배울 수 있어서 좋았다.
지금 이펙티브 타입스크립트책 내용 정리 포스팅도 준비중이긴 한데 일단 이 강의 내용 정리부터 하고 또 해봐야겠다.

다음 강의 정리는 준비되는 대로 포스팅 하도록 하겠다.


Written by@MHLab
로또는 흑우집합소 🎲
와인관리, 시음노트, 셀러관리는 마와셀 🥂

🫥 My Service|  📜 Contact|  💻 GitHub