(번역)타입스크립트 프로젝트를 위한 궁극적인 클린 아키텍처 템플릿

강엽이·2022년 8월 13일
69
post-thumbnail

원문 : https://betterprogramming.pub/the-ultimate-clean-architecture-template-for-typescript-projects-e53936269bb9

클린 아키텍처 원칙에 따른 레이어 기반의 타입스크립트 프로젝트 템플릿 생성 가이드

목차

소개

이것이 바로 여러분들이 평생 동안 찾아다닌 그것입니다. 뭘까요? 지난날의 코드를 디버깅하는 고통, 불행 그 많은 시간들은 이제 끝입니다. 앱이 3시간 안에 완성될 것이라고 생각하며 작성했던, 혹은 노력할만한 가치가 없는 데모 앱을 만든다고 생각하며 작성했던, 또는 여러분들이 더 잘 모르며 작성했던 그 코드들이요.

이 가이드에서는 클린 아키텍처 원칙을 구현하는 타입스크립트 프로젝트 템플릿을 만드는 방법을 설명합니다. 이것은 Anglar, React, Vue 또는 React Native 또는 Electron과 같은 다른 프레임워크가 있는 SPA(싱글 페이지 어플리케이션)를 포함하여 생각할 수 있는 모든 유형의 애플리케이션에 사용할 수 있습니다. 기본적으로 타입스크립트를 사용하면 적용할 수 있습니다.

클린 아키텍처는 앱을 작성할 때, 덜 고통스럽게 하기 위해 몇 가지 규칙을 따르기만 하는 것이 아닙니다. 적절한 아키텍처 및 종속성 규칙을 적용할 수 있도록 작업 환경을 혁신하는 것입니다. 여러분들은 아마도 미래의 어느 시점에서 게으름을 피우고 가능한 빨리 기능을 개발하기 위해 규칙을 완전히 무시하는 것을 허락하지 않을 것입니다. 왜냐하면 그것은 여러분들에게 악영향으로 되돌아 올 것이기 때문입니다.

클린 아키텍처란 무엇인가

재미있는 부분으로 스킵하기

Uncle Bob's의 클린 아키텍처(한 번 꼭 읽어 보는 것을 추천드립니다.) 이면에 있는 아이디어는 관심사를 분리하는 것입니다. 이를 통해 코드가 프레임워크, 데이터베이스 또는 UI와 독립적일 수 있습니다. 또한 코드를 보다 쉽게 테스트할 수 있습니다.

클린 아키텍처의 종속성 시각화(출처: Uncle bob)

이 다이어그램은 클린 아키텍처의 핵심 아이디어인 종속성 규칙을 시각화 하고 있습니다. 모든 화살표가 엔티티를 향해 안쪽을 가리킵니다. 이것은 엔티티는 무엇에도 의존하지 않지만 모든 것이 엔티티를 의존한다는 것을 알 수 있습니다. 이는 엔티티가 비지니스 규칙을 나타내며 사용자가 Angular, React, Vue 등 무엇을 사용하든 상관하지 않기 때문입니다.

즉, 엔터프라이즈 비지니스 규칙은 엔터프라이즈/애플리케이션의 핵심 규칙을 정의하는 클래스 또는 모델을 구현하는 계층입니다.

여러분들은 엔티티와 상호작용하기 위해 엔티티를 둘러 싸고 있는 유즈 케이스를 계층을 사용합니다. 이 계층은 엔티티로 들어오고 나가는 데이터 흐름을 지시하고, 아래 계층들이 엔티티들과 상호 작용할 수 있도록 합니다.

그 다음, 다이어그램의 오른쪽 하단 섹션에서 볼 수 있듯이 컨트롤러는 프레젠터에 사용되는 출력을 생성하기 위해 유즈케이스와 상호 작용합니다. Angular, React 컴포넌트들이 이곳에 위치 되며, 프레젠터와 컨트롤러 역할을 모두 맡게 됩니다.

마지막으로 가장 바깥쪽 계층에 데이터베이스와 같은 세부 정보가 있습니다. 즉, 안쪽의 원을 변경하지 않고도 가장 바깥쪽 레이어의 세부 정보를 변경할 수 있습니다. 이해가 가시나요? 웹 프레임워크는 당신이 MongoDB, SQL, 심지어 블록체인을 사용하든 상관하지 않습니다. 그리고 물론 유즈케이스나 엔티티는 그러한 도구들에서 멀리 떨어져 있습니다.

자세한 구현 가이드

시작하기 전에 몇 가지 언급할 것이 있습니다.

먼저 애플리케이션 템플릿을 코어, 데이터, 의존성 주입(DI), 프레젠테이션과 같이 4개의 레이어로 구분했습니다.

  • 코어: 엔티티, 유즈케이스 및 리퍼지토리 인터페이스를 포함합니다. 이름에서 알 수 있듯이 이것이 애플리케이션의 핵심입니다.
  • 데이터: 로컬 및 원격 스토리지에서 데이터를 검색하기 위한 핵심 저장소 구현을 포함합니다. 우리가 데이터를 저장하는 방법입니다.
  • DI: 코어와 데이터를 결합하여 프레젠테이션이 코어에 의존할 수 있도록 해줍니다.
  • 프레젠테이션: 이 계층은 사용자가 우리의 애플리케이션을 보고 상호 작용하는 계층입니다. 예를 들어, 이 계층에는 Angular 또는 React 코드가 포함됩니다.

두 번째로, 저는 여기서 Lerna를 사용하는 것을 선호했습니다. 왜냐하면 그것이 저에게 가장 적은 두통을 주었기 때문입니다. Yarn 또는 Npm Workspaces 와 같은 대안이 있지만 Lerna 만큼 쉽지 않았습니다.

마지막으로, 일부 섹션에서 제 접근 방식에 동의하지 않을 수 있지만, 이 아키텍처의 장점은 앱의 코어가 변경되지 않고 앱의 종속성이 자체에만 있다면 무엇이든 바꿀 수 있다는 것입니다.

말이 나왔으니 시작해보겠습니다.

프로젝트 디렉토리 및 루트 생성

  • 프로젝트에 사용할 폴더를 만듭니다. 저는 그냥 root(루트)라고 부르겠습니다.
  • 이 폴더 안에 패키지라는 폴더를 만듭니다. 그 안에 core, data, di라는 세가지 폴더를 생성합니다.

여러분들은 이제 다음과 같은 구조를 가지고 있습니다.
초기 폴더 구조

다음으로, 우리는 이것들을 모두 초기화해야 합니다. root, core, data, di에서 다음 명령을 수행합니다.

  • npm init -y를 실행합니다.
  • tsconfig.json 파일을 생성합니다. 아래 제공된 구성을 사용하세요.

루트의 tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "sourceMap": true,
    "allowSyntheticDefaultImports": true,

    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,

    "noImplicitAny": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "forceConsistentCasingInFileNames": true
  }
}

core, datatsconfig.json

{
  "extends": "../../tsconfig.json",

  "include": ["src/**/*.ts"],

  "exclude": ["node_modules"],

  "compilerOptions": {
    "baseUrl": ".",
    "rootDir": ".",

    "module": "ES2020",
    "target": "ES2020",
    "moduleResolution": "node",

    "outDir": "build/",

    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    "plugins": [
      {
        "name": "@rollup/plugin-typescript"
      }
    ]
  }
}

ditsconfig.json

{
  "extends": "../../tsconfig.json",

  "include": ["src/**/*.ts"],

  "exclude": ["node_modules"],

  "compilerOptions": {
    "baseUrl": ".",
    "rootDir": ".",

    "module": "ES2020",
    "target": "ES2020",
    "moduleResolution": "node",

    "lib": ["ES6", "DOM"],

    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,

    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

    "outDir": "build/",

    "plugins": [
      {
        "name": "@rollup/plugin-typescript"
      }
    ]
  }
}

마지막으로 프레젠테이션을 만듭니다. 여러분들은 이것을 여러분이 원하는 대로 할 수 있습니다. 저는 Angular를 선호하기 때문에 Angular CLI를 사용하여 생성합니다. Angular CLI를 사용하여 프로젝트를 초기화하려면 패키지 폴더에서 ng new 명령어를 실행하고 취향에 따라 단계를 수행합니다.

그리고 루트와 모든 패키지에 .gitignore 파일을 추가하였습니다. /node_modules/build를 추가하여 node_modules와 빌드된 내용을 무시하도록 설정하였습니다.

여기까지 진행하면 다음과 같은 모습이 됩니다.

코어와 데이터 및 DI 구성

다음으로 core, data, di를 설정할 것입니다. 모두 동일한 초기 설정을 따릅니다. 따라서 모든 항목에 대해 다음 단계를 따릅니다. (또는 코어에서 이 작업을 수행한 다음 모든 항목을 복사하여 데이터 및 di에 붙여넣습니다.)

먼저 src 폴더를 생성하고 index.ts라는 파일을 추가합니다. 이 파일은 우리가 우리의 패키지에서 내보내고자 하는 모든 것을 표현합니다. src 내부에 tests라는 폴더를 만듭니다. 여기에는 이 패키지에 대한 유닛 테스트가 포함됩니다.

package.json에 추가해야할 몇 가지 스크립트와 devDependencies가 있습니다. 다음을 복사하여 붙여넣을 수 있습니다. (데이터 및 di 페키지에서는 이름을 변경해 주세요)

{
  "name": "core",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "build": "tsc --project tsconfig.build.json && npm run rollup",
    "build:watch": "nodemon -e ts --exec \"npm run build\" --ignore build/** --ignore tests/**",
    "rollup": "rollup -c",
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@rollup/plugin-typescript": "^8.3.3",
    "@types/jest": "^28.1.6",
    "@types/node": "^18.0.0",
    "jest": "^28.1.3",
    "nodemon": "^2.0.19",
    "rollup": "^2.77.0",
    "rollup-plugin-dts": "^4.2.2",
    "ts-jest": "^28.0.7",
    "typescript": "^4.7.4",
    "tslib": "^2.4.0"
  },
  "main": "build/src/index.js",
  "types": "build/src/index.d.ts"
}

파일 마지막에 있는 types 속성을 한번 알아봅시다. 이 패키지를 다른 패키지에서 가져오고 사용할 수 있도록 하려면 이 속성이 필요합니다.

각 스크립트에 대한 설명은 다음과 같습니다.

  • build: 프로젝트를 빌드하면 이 패키지를 다른 패키지에서 가져올 수 있는 index.d.ts가 포함된 빌드 폴더가 생성됩니다.
  • build:watch: 코드가 변경될 때마다 프로젝트가 재빌드 됩니다.
  • rollup: 빌드 후 프로젝트 타입을 번들합니다. 올바른 선언 및 내보내기를 포함하는 index.d.ts를 생성하려면 이 작업이 필요합니다.
  • test: 패키지의 모든 단위 테스트를 실행합니다.
  • test:watch: 모든 관련 변경 사항에 대해 테스트를 다시 시작합니다. (자세한 내용은 jest 문서 참조)

devDependencies에 대한 설명은 다음과 같습니다.

  • rollup, rollup-plugin-dts, @rollup/plugin-typescript: index.d.ts 파일을 유즈케이스에 더 유용하도록 수정하는 데 사용되는 모듈 번들러입니다.
  • jest, ts-jest: 테스트 작성 및 단위 테스트용
  • nodemon: 코드가 변경될 때마다 명령을 실행할 수 있습니다. build:watch 스크립트를 참조하세요.
  • 나머지는 타입스크립트 종속성입니다.

rollup과 jest를 위해 몇 가지 설정 파일을 추가해야 합니다. 이 설정 파일들은 package.json과 동일한 수준입니다. 다음과 같습니다.

import typescript from "@rollup/plugin-typescript";
import dts from "rollup-plugin-dts";

import tsConfig from "./tsconfig.json";

const config = [
  {
    input: "src/index.ts",
    output: [{ file: "build/src/index.js", sourcemap: true }],
    plugins: [
      typescript({
        sourceMap: tsConfig.compilerOptions.sourceMap,
      }),
    ],
  },
  {
    input: "build/src/index.d.ts",
    output: [{ file: "build/src/index.d.ts", format: "es" }],
    plugins: [
      dts({
        compilerOptions: {
          baseUrl: tsConfig.compilerOptions.baseUrl,
        },
      }),
    ],
  },
];

export default config;
module.exports = {
  transform: { "^.+\\.ts?$": "ts-jest" },
  testEnvironment: "node",
  testRegex: ".*\\.(test)?\\.(ts)$",
  moduleFileExtensions: ["ts", "js"],
};

마지막으로 한 가지: 빌드에 테스트가 포함되어 있지 않길 바라므로 tsconfig.json 옆에 tsconfig.build.json라는 파일을 새로 만듭니다. 이 파일은 package.json 안의 빌드 스크립트를 사용합니다. 내용은 다음과 같습니다.

{
  "extends": "./tsconfig.json",

  "exclude": ["node_modules", "src/tests"]
}

다 됐습니다!
이제 각 패키지의 모양은 다음과 같습니다.

이제 패키지를 테스트 빌드 할 수 있습니다. 빌드에서 어떤 작업을 수행하려면 패키지의 index.ts 파일에 내보내기를 추가하면 됩니다. 예: export const Foo = 1;

이제 npm install && npm run build 명령어를 각 코어, 데이터, di에서 실행합니다.

정상적으로 빌드가 진행 되었다면, build/src/index.ts, build/src/index.d.ts 파일에서 빌드 결과를 확인할 수 있습니다.

이제 프로젝트 패키지들은 준비 되었습니다.

모노레포 설정

현재로서는 서로 관련 없는 5개의 패키지(root, core, data, di, presentation)만 있습니다. 우리는 다른 패키지의 컨테이너 역할을 하기 위해 root가 필요합니다. 그러기 위해 저희는 Lerna를 사용할 것입니다.

먼저 다음 설정을 복사하여 루트의 package.json에 붙여넣습니다.

{
  "name": "root",
  "license": "ISC",
  "scripts": {
    "prestart": "npm install && lerna bootstrap",
    "start": "lerna run start",
    "test": "lerna run test",
    "build": "lerna run build",
    "graph": "nx graph"
  },
  "devDependencies": {
    "lerna": "^5.1.8",
    "nx": "^14.4.3",
    "tslib": "^2.4.0"
  }
}

다음은 스크립트에 대한 설명입니다.

  • prestart: 이 스크립트는 npm start를 실행하면 자동으로 실행됩니다. 모든 패키지에 대한 모든 종속성이 있고 올바르게 구성되었는지 확인합니다.
  • start: packages 디렉토리의 모든 패키지에 대해 시작 스크립트를 실행합니다.
  • test, build: start와 동일합니다.
  • graph: 프로젝트 종속성을 시각화할 수 있도록 종속성 그래프를 만듭니다.

Lerna 및 NX에 대한 종속성은 스크립트에서 사용하므로 필요합니다.

다음으로 Lerna와 NX의 설정 파일(각각 lerna.jsonnx.json)이 있습니다.

lerna.json

{
  "packages": ["packages/*"],
  "useNx": true,
  "version": "0.0.0"
}

nx.json

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "test"]
      }
    }
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"]
    },
    "start": {
      "dependsOn": ["^build"]
    }
  }
}

nx.json에서 cacheableOperations라는 속성을 볼 수 있습니다. 이 속성은 변경된 패키지만 리빌드/리테스트됩니다. 프로젝트가 커질수록 이 기능에 더욱 감사하게 될 것입니다.

또한 targetDefaults에는 start 항목과 build 항목, 이 두 가지 항목이 포함되어 있습니다. 여기서의 설정은 패키지 A가 패키지 B에 종속된 경우 패키지 A의 시작은 패키지 B의 빌드 후에만 실행되도록 합니다. 지금 당장 명확하지 않다면 다음 섹션에서 이 작업이 왜 필요한지 알 수 있습니다.

패키지 간의 종속성 설정

마지막 단계만 진행하면 이제 끝입니다. 우리는 data, presentation, dicore에 의존한다고 아직 명시하지 않았습니다. 다음 단계는 이것을 할 것입니다.

data/package.json 안에 devDependenciesdependencies: { "core": "*" } 속성을 추가합니다.

di/package.json 안에 devDependenciesdependencies: { "core": "*", "data": "*" } 속성을 추가합니다.

presentation/package.json 안에 devDependenciesdependencies: { "core": "*", "di": "*" } 속성을 추가합니다.

Nx와 Lerna는 presentation이 다른 패키지들에 의존하고 있으므로 presentation을 시작하기 전에 core, data, di를 빌드할 수 있을 만큼 똑똑합니다.

공식적으로 끝이 났습니다! 이제 루트에서 npm start를 실행하고 어떤 마법 같은 일이 일어나는지 지켜보세요.

만약 오류가 발생한다면 모든 폴더에서 package-lock.json, node_modules를 제거하고 다시 npm start를 실행하세요.

위의 모든 구현은 여기에 있습니다.

이 걸작 모노레포를 사용하는 방법

이 템플릿을 사용하는 방법을 단계별로 설명하는 제 글이 있습니다. 링크는 여기에서 찾을 수 있습니다.

이렇게 작업한 이유

저는 한동안 클린 아키텍처로 프로젝트를 구성하기 위해 좋은 방법을 찾으려고 노력했습니다. 저는 모든 것을 하나의 패키지에 넣고 규칙을 준수할 수 있다고 믿는 가장 빠른 접근 방식을 고려했습니다. 왜냐하면 저는 훌륭한 프로그래머이기 때문입니다. (그렇게 믿고 싶었습니다.) 하지만 이 방법은 저에게 충분하지 않았습니다.

저는 규칙을 어기지 않을 무언가가 필요했습니다. 여기서 말하는 규칙은 의존성 규칙입니다. 엔티티가 데이터베이스 혹은 UI 코드에 종속되도록 허용할 수 없습니다. 잘못된 관계에 의존성이 생기면 오류를 발생시키는 스크립트를 포함 하는( dependency-cruiser 사용) 방법을 통해 해결하려고 했지만 까다로웠습니다. 그리고 너무 늦게 오류를 보여주었습니다.

그래서 각 애플리케이션 계층 자체로 패키지를 나누기로 했습니다. 자체 package.json, tsconfig.json 및 설정된 번들로 완성하였습니다. 그리고 Lerna와 Nx를 통해 각 패키지를 통합하였습니다. 이러한 방식으로는 package.json에 의존성을 명시하지 않는 한 각 패키지들은 "우연히" 혹은 어떤 방법으로든 의존할 수 없습니다. 제 입으로 말하자면 이것은 예술적으로 동작할 것입니다.

추가

방금 우리가 만든 것과 같이 모노레포를 이용한다면 VS Code 워크스페이스를 이용하는 것을 추천드립니다. VS Code 워크스페이스를 사용할 경우 각 패키지 별로 구문 강조 표시 및 워크스페이스별 확장 기능을 완전하게 사용할 수 있습니다. VS Code는 프로젝트의 루트에 있는 tsconfig.json만 고려합니다. 즉, 각 패키지에 있는 tsconfig.json을 완전히 무시하여 잘못된 문제에 대해서도 긍정을 표시할 수 있습니다. VS Code 워크스페이스는 이런 문제로 부터 여러분들을 도와줍니다.

읽어주셔서 감사합니다.

참고 자료 및 링크

profile
FE Engineer

2개의 댓글

comment-user-thumbnail
2022년 8월 13일

클린 코드, 클린 아키텍쳐 등등 여러 유명한 서적 읽어도 실제 구현에서 적용하긴 막막하죠. 고전은 시대를 뛰어넘어야 고전인데 코드는 시대에 따라 바뀌는 운명이니까요. 이 글은 고전은 아니겠지만 걸작인 해설서네요. 곧 복잡한 프로젝트를 시작해야하는데 적용해보고 싶네요. 항상 좋은 글 번역해주셔서 감사합니다!

답글 달기
comment-user-thumbnail
2022년 8월 23일

I used to only look for popular or cool stuff, but reading this post makes me feel a lot.. Thanks! I hope to have more good articles in the future free games unblocked

답글 달기