(번역)클린 아키텍처로 TypeScript 웹 앱을 구현하는 방법

강엽이·2022년 9월 5일
41
post-thumbnail

원문 : https://betterprogramming.pub/how-to-implement-a-typescript-web-app-with-clean-architecture-27c7eb745ab4

여러분들의 웹 애플리케이션 구조에 대한 자세한 가이드

목차

이 가이드에서는 이전 글에서 만든 클린 아키텍처 템플릿을 이용하여 애플리케이션을 작성하는 방법을 보여드리겠습니다.

왜 클린 아키텍처일까요? 여러분은 코딩을 하면서 아무것도 잘못되지 않기를 바라지만 앱의 규모가 커질수록 그러기 어렵습니다. 클린 아키텍처만이 여러분들이 원하는 것을 이루어줄 수 있습니다.

"빨리 가는 유일한 방법은 잘 하는 것이다." - Bob Martin

소개

저는 우리가 애플리케이션을 만들 때 웹 프레임워크에 너무 많이 의존한다고 생각합니다. 프레임워크는 많은 지루한 일들을 처리하지만, 우리의 애플리케이션에 대한 제어권을 빼앗아 버립니다.

그래서 저는 애플리케이션을 여러 계층의 패키지로 분리하여 프레임워크로부터 제어권을 빼앗는 프로젝트 템플릿을 만들었습니다.

이 글을 통해 최종적으로 우리가 Angular가 전혀 필요하지 않았을 수 있고, Angular가 아닌 다른 프레임워크로 쉽게 교체할 수 있다는 것을 알게 될 것입니다. 이것이 바로 클린 아키텍처의 요점입니다.

이 아키텍처의 장점

  • 잘 정의된 계층들의 경계
  • 캐싱을 통한 빌드 및 테스트 실행 시간 단축
  • 느슨한 결합으로 인한 테스트 작성 시간 단축
  • 웹 프레임워크, 데이터베이스 등과 같은 세부 사항에 대한 의존성 제로
  • 코드 재사용성 증가

단점

  • 일부 보일러 플레이트 코드
  • 경험 필요 (이 글에서 설명할 예정이니 걱정하지 않아도 됩니다!)

시작

다음과 같은 섹션으로 구성됩니다.

글 하단에 완료된 구현에 대한 링크가 있습니다.

아키텍처 계층

애플리케이션을 세 가지 주요 계층으로 나눕니다.

  • 코어: 엔티티, 유즈케이스 및 리퍼지토리 인터페이스를 포함합니다. 포함되는 내용들은 애플리케이션의 코어입니다. 따라서 이 계층의 이름은 코어입니다.
  • 데이터: 로컬 및 원격 스토리지에서 데이터를 검색하기 위한 리퍼지토리 구현을 포함합니다. 이 구현은 데이터를 얻거나 저장하는 방법입니다.
  • 프레젠테이션: 이 계층은 사용자가 애플리케이션을 보고 상호 작용하는 부분입니다. Angular 또는 React 코드가 포함되어 있습니다.

DI(의존성 주입)라고 불리는 네 번째 보조 계층도 있습니다. 이 계층의 역할은 프레젠테이션과 데이터 간의 직접적인 의존성을 방지함과 동시에 프레젠테이션이 코어를 통해 데이터를 사용할 수 있도록 하는 것 입니다.

코어 계층은 애플리케이션 로직이 포함되어 있으며 데이터 계층이 구현하는 리퍼지토리에 대한 인터페이스를 정의합니다. 리퍼지토리는 유즈케이스에서 데이터에 대한 작업을 수행하기 위해 사용되지만, 코어 계층은 데이터의 출처나 저장 방법에 상관하지 않습니다. 코어 계층은 데이터 계층에게 로컬 캐시 또는 원격 API 중 어디서 데이터를 가져오는지 결정하는 책임(우려 사항)을 위임합니다.

다음으로 프레젠테이션 계층은 코어 계층의 유즈케이스를 사용하여 사용자가 애플리케이션과 상호 작용할 수 있도록 합니다. 프레젠테이션은 데이터의 출처를 신경 쓰지 않기 때문에 프레젠테이션 계층은 데이터 계층과 상호 작용하지 않습니다. 코어는 애플리케이션의 계층들을 연결해 줍니다.

아래 다이어그램은 계층 내부, 계층 간의 의존성을 설명합니다. 결국 모든 것이 코어 계층을 향하고 있다는 점에 주목하세요. 우리는 곧 이와 관련된 섹션을 함께 볼 것입니다.

데이터의 흐름은 사용자가 버튼을 클릭하거나 양식을 제출할 때 프레젠테이션에서 모두 시작됩니다. 프레젠테이션에서 유즈케이스를 호출하고, 데이터를 검색/저장하는 리퍼지토리의 메서드를 호출합니다. 이 데이터는 로컬 데이터, 원격 데이터 혹은 둘 다에서 검색됩니다. 리퍼지토리는 호출 결과를 유즈케이스로 반환하고, 이를 프레젠테이션으로 반환합니다.

우리는 데이터 계층에 구현되어 있는 리퍼지토리 인터페이스를 코어 계층에 주입함으로써 이 데이터의 흐름을 구현할 것입니다. 이러한 방식을 통해, 코어 계층이 제어권을 유지하기 때문에 의존성 역전을 만족합니다. 데이터가 코어에 리퍼지토리로 정의된 것을 구현하고 있어서 만족된 것입니다.

일반적인 파일 구조

메인 프로젝트에는 packages 폴더가 있으며, 이 폴더에는 애플리케이션 각 계층에 대한 폴더가 있습니다. 우리는 코어에 몇 가지 파일을 만드는 것으로 시작할 것입니다.

예제 애플리케이션 정의

앱에 대한 다음과 같은 요구 사항을 받았다고 가정해 보겠습니다.

  • 사용자에게 카운터를 표시하는 앱 만들기
  • 사용자가 카운터를 생성/삭제 가능
  • 사용자가 버튼을 눌러 카운터를 증가/감소 가능
  • 사용자가 카운터의 증가/감소량을 변경 가능
  • 사용자가 카운터에 라벨 할당 가능
  • 사용자가 라벨을 기준으로 카운터 필터링 가능
  • 애플리케이션을 닫았다가 다시 열어도 사용자의 카운터가 저장되어 있어야 함

이와 같은 요구사항에서, 우리는 다음과 같이 말할 수 있습니다.

  • 우리의 메인 엔티티는 Counter입니다.
  • 우리의 유즈케이스는 모든 카운터를 가져오기, 라벨로 필터링된 카운터 가져오기, 증가, 감소, 라벨 할당, 카운터 생성과 삭제입니다.
  • 우리는 데이터를 로컬에 저장하는 것이 필요합니다. 즉, 로컬 데이터 소스가 필요합니다.

첫 번째 엔티티와 유즈케이스 작성하기

이제 재미있는 부분들로 넘어가고 있습니다. 애플리케이션의 단일 엔티티인 카운터를 정의하는 것부터 시작합니다.

core/src 디렉토리 아래에 counter 디렉토리를 생성합니다. 그리고 그 안에 entities 디렉토리를 생성합니다. 그리고 그 안에 한번 더 counter.entity.ts 파일을 생성합니다.

export class Counter {
  id: string;
  label: string;
  currentCount: number = 0;
  incrementAmount: number = 1;
  decrementAmount: number = 1;
}

다음으로, 유즈케이스를 구현합니다. 먼저 유즈케이스 및 각 유즈케이스의 의존성과 상호 작용하는 표준 방식을 정의합니다.

이제 core/src/base 아래에 usecase.interface.ts 유즈케이스 인터페이스를 생성해 보겠습니다.

export abstract class Usecase<T> {
  abstract execute(...args: any[]): T;
}

이제 새로운 유즈케이스를 생성할 때마다 반환 유형을 정의해야 하는 Usecase를 구현합니다. 따라서 우리는 유즈케이스 결과에 신중해야 합니다.

먼저 CreateCounterUsecase를 생성하겠습니다.

코어의 src/counter 폴더에 usecases 폴더를 생성하고, create-counter.ts를 생성합니다.

import { Usecase } from "../../base/usecase.interface";

import { Counter } from "../entities/counter.entity";

export abstract class CreateCounterUsecase implements Usecase<Counter> {
  abstract execute(...args: any[]): Counter;
}

export class CreateCounterUsecaseImpl implements CreateCounterUsecase {
  constructor() {}

  execute(...args: any[]): Counter {
    throw new Error("Method not implemented.");
  }
}

유즈케이스 인터페이스와 그 바로 아래에 해당 인터페이스에 대한 구현이 있습니다. 이러한 방식으로 작업하면 유즈케이스로 들어오고 나가는 데이터의 흐름을 정의하는 데 도움이 되고 의존성 주입을 쉽게 할 수 있습니다.

이 유즈케이스에서는 사용자가 페이지를 새로고침 하더라도 카운터를 잃지 않도록 어디서든 계속 사용할 수 있는 카운터를 만드는 방법이 필요합니다. 이를 위해 src/counter/counter-repository.interface.ts 아래에 리퍼지토리 인터페이스를 만듭니다.

import { Counter } from "./entities/counter.entity";

export abstract class CounterRepository {
  abstract createCounter(counterInfo: Counter): Counter;
}

이제 이 리퍼지토리를 create-counter 유즈케이스의 의존성에 추가하고 새롭게 추가한 이 메서드를 호출합니다. 의존성 주입을 수행할 때 쉽게 의존성을 제공할 수 있기 때문에 저는 생성자에서 의존성을 정의하는 것을 좋아합니다.

import { Usecase } from "../../base/usecase.interface";
import { CounterRepository } from "../counter-repository.interface";

import { Counter } from "../entities/counter.entity";

export abstract class CreateCounterUsecase implements Usecase<Counter> {
  abstract execute(): Counter;
}

export class CreateCounterUsecaseImpl implements CreateCounterUsecase {
  constructor(private counterRepository: CounterRepository) {}

  execute(): Counter {
    return this.counterRepository.createCounter({
      id: Math.random().toString().substring(2),
      currentCount: 0,
      decrementAmount: 1,
      incrementAmount: 1,
      label: "New Counter",
    });
  }
}

축하합니다! 방금 첫 번째 엔티티, 유즈케이스 및 리퍼지토리 인터페이스를 작성했습니다.

마지막으로 해야 할 일은 코어 패키지에서 엔티티, 유즈케이스 및 리퍼지토리를 내보내는 것입니다. 저는 index.ts 파일을 사용하여 이 작업을 수행하는 것을 선호하는데요. 함께 살펴보겠습니다.

core/src/counter 아래에 index.ts 파일을 생성합니다. 이 파일은 export 문을 사용하여 매우 간단한 가져오기 선언을 통해 카운터 디렉토리 내의 모든 항목을 사용할 수 있도록 합니다.

export * from "./entities/counter.entity";

export * from "./usecases/create-counter";

export * from "./counter-repository.interface";

counter에 새 파일을 추가하고 내보낼 때마다 이 파일에 export 문을 추가합니다.

export * from "./counter";

counter 옆에 다른 모듈을 추가하지 않으면 이 파일을 다시 업데이트할 필요가 없습니다.

다음 명령(npx lerna run build && npx lerna bootstrap)을 실행하여 코어 패키지를 빌드하고 이에 의존하는 모든 패키지를 배포합니다. 템플릿을 처음 사용하는 경우에만 부트스트랩 명령을 실행하면 됩니다.

이제 다음 단계를 실행할 준비가 되었습니다.

데이터 소스 생성

이제 코어에서 정의한 리퍼지토리 인터페이스를 구현해야 합니다. 저는 이 작업을 데이터라는 패키지에서 수행하기로 했습니다. 이를 통해 core 패키지에서 비지니스 규칙을 분리하고 이를 지원하는 데이터 소스를 다른 패키지에서 분리합니다.

packages/data/src 아래에 counter 폴더를 생성하고 그 안에 counter-repository.impl.ts 파일을 생성합니다. 파일 확장자명은 완전히 선택 사항입니다. 저는 이 확장자를 사용하여 파일 내부를 좀 더 명확하게 만드는 것을 좋아합니다. 또한 이러한 확장자는 파일을 찾는 것을 좀 더 쉽게 만듭니다.

import * as core from "core";

export class CounterRepositoryImpl implements core.CounterRepository {
  createCounter(counterInfo: core.Counter): core.Counter {
    throw new Error("Method not implemented.");
  }
}

코어 안에 있는 모든 것을 core라는 키워드로 가져왔습니다. 이것 또한 개인적인 선호입니다. 디스트럭처링된 가져오기를 사용하여 core에서 원하는 것을 가져올 수 있지만, 저는 좀 더 명확하게 하는 것이 더 좋다고 생각합니다.

어쨋거나, 이 리퍼지토리를 어떻게 구현해야 할까요? 사용자가 세션을 유지할 수 있는 방법이 필요합니다. "오, 나 알아!", "브라우저에 내장된 로컬 스토리지를 사용하면 됩니다!". 여러분들이 열정적으로 말하는 것을 들었습니다. 그것은 요점을 전달하기 위한 좋은 해결책이지만, 작은 문제가 있습니다.

data 패키지는 처음부터 브라우저를 인식하지 못하기 때문에 브라우저의 스토리지 API에 접근할 수 없습니다. 사실, 우리는 data가 이렇게 동작하기를 원했습니다. 그렇지 않으면 데이터가 실행 중인 플랫폼과 같은 세부적인 것에 의존하게 됩니다.

대신 우리의 레포에 local storage 구현을 제공합니다. local storage는 우리가 데이터에서 정의하는 인터페이스와 원하는 모든 곳에서 문자 그대로 정의할 수 있는 구현과 의존성입니다. local storage 의존성은 레포 구현에서 주입됩니다. 우리는 곧 이와 관련된 섹션을 함께 볼 것입니다.

이 인터페이스를 data/src/common 아래에 local-storage-service.interface.ts로 생성하기로 했습니다. 이를 이용하면 다른 리퍼지토리에서도 사용할 수 있습니다. local storage 의존성에 대한 인터페이스는 다음과 같습니다.

export abstract class LocalStorageService {
  abstract get(key: string): string;

  abstract set(key: string, value: string): string;
}

이제 레포 구현에 의존성을 추가하고 createCounter 메서드를 구현합니다.

import * as core from "core";

import { LocalStorageService } from "../common/local-storage-service.interface";

export class CounterRepositoryImpl implements core.CounterRepository {
  constructor(private localStorageService: LocalStorageService) {}

  createCounter(counterInfo: core.Counter): core.Counter {
    this.localStorageService.set(counterInfo.id, JSON.stringify(counterInfo));

    return counterInfo;
  }
}

저는 지금 이 방법을 최대한 간단하게 구현하고 있습니다. 멋진 점은 프레젠테이션이나 코어를 변경하지 않고도 미래에 원하는 대로 만들 수 있다는 것입니다.

축하합니다! data에 필요한 모든 것을 구현했습니다. 이제 우리는 내보내야 합니다. 다시 한 번 인덱스 파일을 사용해 보겠습니다.

data/src/counterindex.ts 파일을 생성합니다.

export * from "./counter-repository.impl";

그리고 data/src/common 아래에도 동일하게 생성합니다.

export * from "./local-storage-service.interface";

마지막으로 이 두가지를 모두 data/src 아래의 인덱스 파일로 내보냅니다.

export * from "./common";

export * from "./counter";

브라우저의 스토리지 API에 접근할 수 있는 위치(프레젠테이션)에 구현할 것이기 때문에 로컬 스토리지 서비스 인터페이스를 내보냅니다.

하지만 그렇게 되면 데이터프레젠테이션에 의존하게 되어 의존성 그래프가 엉망이 되지 않을까요? 사실, 우리가 제어의 역전을 구현하고 있기 때문에 그렇지 않을 것입니다. 즉, 프로젠테이션은 간접적으로 데이터를 의존하는 것을 의미합니다. 다음 섹션에서는 이 기능의 작동 방식을 확인할 수 있습니다.

지금은 데이터 패키지를 다시 빌드해 봅시다. npx lerna run build를 다시 실행합니다.

의존성 주입 (Angular의 약간의 도움을 받아서)

우리는 coredata를 함께 가지고 있습니다. 우리는 유즈케이스 및 리퍼지토리의 구현을 코어와 데이터의 인터페이스와 연결하고자 합니다.

예를 들어, Factory와 같이 의존성이 지정된 객체를 생성하는 클래스를 사용하여 이 작업을 진행합니다.

di/src 아래에 counter 폴더를 만들고 그 안에 counter.factory.ts 파일을 만듭니다.

import * as core from "core";
import * as data from "data";

export class CounterFactory {
  private counterRepository: core.CounterRepository;

  constructor(private localStorageService: data.LocalStorageService) {
    this.counterRepository = new data.CounterRepositoryImpl(
      this.localStorageService
    );
  }

  getCreateCounterUsecase(): core.CreateCounterUsecase {
    return new core.CreateCounterUsecaseImpl(this.counterRepository);
  }
}

CounterFactory 클래스는 리퍼지토리 및 유즈케이스를 인스턴스화하는 데 필요한 모든 의존성들을 인스턴스화 합니다. 리퍼지토리는 노출하지 않고 필요한 인터페이스만 노출합니다.

다음과 같이 di/src/counter 아래에 index.ts 파일을 생성하여 이 팩토리와 필요한 로컬스토리지 서비스 인터페이스를 내보냅니다.

import * as data from "data";

export * from "./counter.factory";

export type LocalStorageService = data.LocalStorageService;

이 파일을 di/src 아래에 index.ts 파일을 통해 내보냅니다.

export * from "./counter";

다음은 di의 프로젝트 디렉토리입니다.

npx lerna run build를 실행하여 패키지를 빌드합니다. Lerna가 코어 및 데이터를 다시 빌드하지 않고 이전 빌드된 캐시를 사용하는 것을 주목하세요. 변경사항이 없습니다. 멋지지 않나요?

이제 Presentation으로 넘어갈 준비가 되었습니다.

우리는 방금 만든 내용을 프레젠테이션에서 쉽게 접근할 수 있도록 만들어야 합니다. 이를 위해 Angular의 훌륭한 의존성 주입을 사용합니다. 다음은 제가 작업한 방법입니다.

Angular를 사용하면 app.module 파일 내에서 직접 이 작업을 수행할 수 있지만, presentation/src/di에 폴더를 만들고 그 안에서 모든 작업을 수행하고, 그 안에 counter.ioc.ts 파일을 생성함으로써 보다 깔끔하게 만들 것입니다.

import * as core from 'core';
import * as di from 'di';

import { Provider } from '@angular/core';

import { LocalStorageServiceImpl } from '../services/local-storage-service';

const localStorageServiceImpl = new LocalStorageServiceImpl();

const counterFactory = new di.CounterFactory(localStorageServiceImpl);

export const CORE_IOC: Provider[] = [
    {
        provide: core.CreateCounterUsecase,
        useFactory: () => counterFactory.getCreateCounterUsecase(),
    },
];

이 파일은 CounterFactory를 인스턴스화하고 필요한 모든 의존성을 제공합니다. 그런 다음 Angular의 Provider 타입을 사용하여 Provider[]을 생성하고 Angular 애플리케이션에서 일반적으로 하듯이 의존성을 주입합니다.

여러분들이 당황하기 전에, /presentation/src/services 혹은 여러분들이 적절하다고 생각되는 곳에 LocalStorageServiceImpl 파일을 생성합니다.


import * as di from 'di';

export class LocalStorageServiceImpl implements di.LocalStorageService {
    get(key: string): string {
        const item = localStorage.getItem(key);

        if (item == null) throw new Error(`Could not find item with key ${key}`);

        return item;
    }

    set(key: string, value: string): void {
        localStorage.setItem(key, value);
    }
}

마지막으로 한가지 남았습니다. 우리의 모든 애플리케이션에서 이것을 사용할 수 있도록 app.module에 있는 CORE_IOC 프로바이더 배열을 포함해야 합니다.

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';

import { CORE_IOC } from 'src/di/counter.ioc';

@NgModule({
    declarations: [AppComponent],

    imports: [BrowserModule],

    providers: [...CORE_IOC],

    bootstrap: [AppComponent],
})
export class AppModule {}

이제 공식적으로 끝이 났습니다. 여러분들이 여기까지 올 줄 알았습니다!

이전 단계에서 수행한 작업의 대부분은 한 번만 수행할 작업입니다. 유즈케이스를 추가하면 알 수 있습니다.

이제 UI 코드를 작성할 수 있습니다.

유즈케이스와 상호 작용하는 UI 컴포넌트 생성

다음은 일반적인 Angular 코딩 절차입니다. presentation/src/app 아래에 counter라는 새로운 컴포넌트를 만들 것입니다.

UI 코드를 건너뛰고 컨트롤러와 유즈케이스를 보여드리겠습니다. 관심이 있으시다면 여기서 코드를 보실 수 있습니다.

app.component에서 Angular가 생성한 코드를 모두 제거하고 제 코드를 추가할 것입니다. 우리는 지금 카운터를 만들기 위한 버튼과 이러한 것들을 표시하기 위한 구조가 필요합니다. 스크롤 가능한 리스트로 구성하겠습니다. UI는 다음과 같습니다.

app.component.ts의 컴포넌트 컨트롤러에 있는 메서드에 파란색 버튼을 연결하겠습니다.


import { Component } from '@angular/core';

import * as core from 'core';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss'],
})
export class AppComponent {
    counters: core.Counter[] = [];

    constructor(private createCounterUsecase: core.CreateCounterUsecase) {}

    createCounter(): void {
        const newCounter = this.createCounterUsecase.execute();

        this.counters.push(newCounter);
    }
}

모든 카운터를 저장하는 목록과 유즈케이스에서 호출되어 목록에 새로운 카운터를 추가하는 메서드도 가지고 있습니다. 우리는 Angular의 뛰어난 의존성 주입을 사용하여 생성자에 유즈케이스를 주입합니다. 꽤 깔끔하죠?

이제 우리는 add-counter 버튼을 누르면 몇 가지 항목이 목록에 뜨는 것을 볼 수 있습니다. (다시 말하지만, 실제 HTML과 CSS는 관련이 없기 때문에 생략합니다.)

이제 새로 고침 버튼을 누르면 모든 것이 사라집니다. 페이지가 로드될 때 모든 카운터를 검색하는 메서드를 컨트롤러에 추가해야 하기 때문입니다.

이를 위해, 우리는 유즈케이스가 필요합니다. 자 시작해 봅시다.

우리는 get-all-counters.ts 유즈케이스를 core/counter/usecases 아래에 생성합니다.

import { Usecase } from "../../base/usecase.interface";
import { CounterRepository } from "../counter-repository.interface";

import { Counter } from "../entities/counter.entity";

export abstract class GetAllCountersUsecase implements Usecase<Counter[]> {
    abstract execute(): Counter[];
}

export class GetAllCountersUsecaseImpl implements GetAllCountersUsecase {
    constructor(private counterRepository: CounterRepository) {}

    execute(): Counter[] {
        return this.counterRepository.getAllCounters();
    }
}

레포 인터페이스에 모든 카운터를 가져오는 메서드를 추가합니다.

import { Counter } from "./entities/counter.entity";

export abstract class CounterRepository {
    abstract createCounter(counterInfo: Counter): Counter;

    abstract getAllCounters(): Counter[];
}

npx lerna run buildcore를 빌드한 후 data의 레포 구현에서 이 메서드를 구현합니다.

import * as core from "core";

import { LocalStorageService } from "../common/local-storage-service.interface";

export class CounterRepositoryImpl implements core.CounterRepository {
    get counterIds(): string[] {
        const counterIds = JSON.parse(this.localStorageService.get("counter-ids"));

        /** for app being used for first time */
        if (counterIds == null) [];

        return counterIds.ids;
    }

    set counterIds(newIds: string[]) {
        this.localStorageService.set("counter-ids", JSON.stringify({ ids: newIds }));
    }

    constructor(private localStorageService: LocalStorageService) {
        try {
            this.counterIds;
        } catch (e: unknown) {
            this.counterIds = [];
        }
    }

    createCounter(counterInfo: core.Counter): core.Counter {
        this.localStorageService.set(counterInfo.id, JSON.stringify(counterInfo));

        this.addCounterId(counterInfo.id);

        return counterInfo;
    }

    getAllCounters(): core.Counter[] {
        return this.counterIds.map((id) => this.getCounterById(id));
    }

    private addCounterId(counterId: string): void {
        this.counterIds = [...this.counterIds, counterId];
    }

    private getCounterById(counterId: string): core.Counter {
        return JSON.parse(this.localStorageService.get(counterId));
    }
}

이제 리포지토리 구현이 조금 복잡해졌습니다. 이것보다 더 좋은 구현이 있을 것입니다. (예시입니다;))

그럼에도 불구하고 data를 빌드하고 di로 이동하면 새로운 유즈케이스를 고려하여 카운터 팩토리를 업데이트할 수 있습니다.

import * as core from "core";
import * as data from "data";

export class CounterFactory {
    private counterRepository: core.CounterRepository;

    constructor(private localStorageService: data.LocalStorageService) {
        this.counterRepository = new data.CounterRepositoryImpl(this.localStorageService);
    }

    getCreateCounterUsecase(): core.CreateCounterUsecase {
        return new core.CreateCounterUsecaseImpl(this.counterRepository);
    }

    getGetAllCountersUsecase(): core.GetAllCountersUsecase {
        return new core.GetAllCountersUsecaseImpl(this.counterRepository);
    }
}

보일러 플레이트 코드가 이미 준비되어 있으니까 훨씬 간단합니다. 맞죠?

마지막으로 Angular di를 사용하여 유즈케이스를 주입합니다.

import * as core from 'core';
import * as di from 'di';

import { Provider } from '@angular/core';

import { LocalStorageServiceImpl } from '../services/local-storage-service';

const localStorageServiceImpl = new LocalStorageServiceImpl();

const counterFactory = new di.CounterFactory(localStorageServiceImpl);

export const CORE_IOC: Provider[] = [
    {
        provide: core.CreateCounterUsecase,
        useFactory: () => counterFactory.getCreateCounterUsecase(),
    },
    {
        provide: core.GetAllCountersUsecase,
        useFactory: () => counterFactory.getGetAllCountersUsecase(),
    },
];

이제 이 유즈케이스를 app.component에서 사용할 준비가 되었습니다.

import { Component, OnInit } from '@angular/core';

import * as core from 'core';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
    counters: core.Counter[] = [];

    constructor(
        private createCounterUsecase: core.CreateCounterUsecase,
        private getAllCountersUsecase: core.GetAllCountersUsecase
    ) {}

    ngOnInit() {
        this.loadCounters();
    }

    createCounter(): void {
        const newCounter = this.createCounterUsecase.execute();

        this.counters.push(newCounter);
    }

    private loadCounters() {
        this.counters = this.getAllCountersUsecase.execute();
    }
}

우리는 생성자에서 유즈케이스를 제공하여 ngOnInit으로 호출할 수 있도록 설정했습니다. 이제 버튼을 눌러 카운터를 추가하고 페이지를 새로고침 해봅시다. 최소한 브라우저 스토리지를 재설정하기 전까지 카운터는 유지될 것입니다!

요약하자면 다음과 같습니다.
1. core에서 유즈케이스를 생성합니다.
2. data의 유즈케이스에서 요구하는 레포 메서드를 구현합니다.
3. di에 의존성을 가진 팩토리를 생성하는 메서들 설정합니다.
4. Angular의 di를 이용하여 presentation에 프로젝트 전체의 유즈케이스를 제공합니다.
5. 유즈케이스를 호출합니다.

1, 2, 5단계는 우리에게 의미있는 단계입니다. 나머지는 결합을 위한 코드거나 좀 더 쉽게 해주는 해결책입니다.

나머지 유즈케이스를 추가하는 것은 단순 반복 작업입니다. 이 레포에서 나머지 부분을 어떻게 구현했는지 확인할 수 있습니다.

테스팅

본 섹션에서는 data의 카운터 저장소 구현에 대한 단위 테스트를 작성하는 예시를 보여드리겠습니다.

counter-repository.test.ts 파일을 data/src/tests/counter 아래에 생성하여 작업을 수행합니다.

import { Counter, CounterRepository } from "core";

import { LocalStorageService } from "../../common";
import { CounterRepositoryImpl } from "../../counter";

class MockLocalStorageService implements LocalStorageService {
    private storage = {} as any;

    get(key: string): string {
        return this.storage[key];
    }
    set(key: string, value: string): void {
        this.storage[key] = value;
    }
}

describe("Counter Repository", () => {
    let localStorageService: LocalStorageService;
    let counterRepository: CounterRepository;

    beforeEach(() => {
        localStorageService = new MockLocalStorageService();
        counterRepository = new CounterRepositoryImpl(localStorageService);
    });

    test("Should create a new counter and retrieve it later", () => {
        const newCounter: Counter = {
            id: "1",
            currentCount: 0,
            decrementAmount: 1,
            incrementAmount: 1,
            label: "new counter",
        };

        counterRepository.createCounter(newCounter);

        expect(counterRepository.getAllCounters()).toHaveLength(1);
        expect(counterRepository.getAllCounters()[0]).toStrictEqual(newCounter);
    });
});

class 부분은 리퍼지토리 구현에 필요한 로컬 스토리지 서비스의 기본 모의 구현입니다. 우리는 describe 내부에 테스트 코드의 본문을 정의할 것입니다. 각 테스트 블록이 실행되기 전에 카운터 저장소와 그 의존성을 초기화 하여 항상 깨끗한 환경에서 유닛 테스트가 실행되도록 합니다.

저는 새 카운터를 만든 다음 모든 카운터를 검색하는 메서드를 호출하여 저장되었는지 확인하는 단일 테스트를 작성했습니다. 나머지는 여러분들에게 달렸습니다!

마치며

우리가 꽤 많은 것을 다루기도 했고, 처음하기에는 조금 벅찰수도 있습니다. 만약 여러분들이 처음 시도하면서 문제가 생긴다면, 다시 한번 천천히 시도해보세요. 각 계층이 어떤 역할을 하고 있는지 이해하는 것은 잘못된 부분이 제자리에 놓이게 하는 데 많은 도움이 될 것입니다.

분명 익숙한 것보다 느리게 시작하게 되겠지만, 일단 기본적인 단계를 진행하고 나면, 어떤 것이 어떤 역할을 하는지 어디에 위치해야 하는지 쉽게 알 수 있게 될 것입니다. 모든 것이 느슨하게 결합되어 있을 때 테스트가 더 쉬워지는 것은 말할 것도 없습니다.

마지막으로, 누군가 제가 한 것에 대한 피드백을 주시면 매우 기쁠 것입니다. 여러분들이 적용 했을 때 잘 동작하는지? 복잡성과 실용성 사이의 균형은 충분히 좋은지? 제가 놓치고 있는 큰 문제가 있는지? 에 대해서요. 저는 건설적인 비판에 완전히 개방적이니 피드백을 주세요!

모든 글을 읽어주셔서 감사합니다. 저는 이 글이 저에게 그랬던것 처럼 여러분들에게 매우 유용하고, 프로그래밍하는 동안 여러분들에게 즐거움을 주기를 원합니다.

참고 문서

The article where I explain how I made this template
The github repo with the finished implementation
Uncle Bob’s Clean Architecture
Lerna Docs
NX Docs

profile
FE Engineer

2개의 댓글

comment-user-thumbnail
2022년 9월 12일

This is one of the superb articles on this Website. I like to read this blog and all things you said in this it's a complete truth. I'll share this blog on my LinkedIn Profile Creator Uk Group. I request you keep writing more than more beautiful topics.

답글 달기
comment-user-thumbnail
2023년 2월 13일

좋은 글 포스팅 감사합니다. 🙏

답글 달기