본문 바로가기
Javascript

Lerna와 yarn-workspace를 활용한 Mono Repo (Typescript & Jest) 환경 구성하기

by 창천향로 창천향로 2021. 7. 13.
반응형

보통 하나의 도메인 시스템을 운영할때 프로젝트 하나만으로는 해결이 안될 때가 많습니다.

이를테면 다음과 같은 경우인데요.

intro

어떤 분은 이걸 보고 MSA 라고 하시기도 하는데요.
API / Admin / Batch 가 분리된걸로 절대로 MSA 라고 하진 않습니다.

하나의 주문시스템을 위해 3개의 하위 프로젝트가 있는 상태입니다.

  • 주문 어드민
  • 주문 API
  • 주문 배치

이 3개는 주문시스템을 이루는 프로젝트들입니다.
배포되는 서버도 다르고 다른 코드 베이스를 가지고 있는데요.
하지만 이들이 공통적으로 필요로 하는 Order Entity는 어떻게 관리해야할까요?

  • npm 저장소에 올려서 사용하기에는 실시간성이 떨어집니다
  • 모든 하위 프로젝트들이 동일한 파일을 복사해서 관리하는건 유지보수가 굉장히 힘듭니다.

그래서 이런 문제를 해결하기 위해 여러 생태계에서 방법들을 고민하는데요.
대표적인 사례로 Mono Repo (Multiple Packages 혹은 Multi Module) 가 있습니다.

  • Mono Repo는 우리 회사 프로젝트 전체를 하나의 저장소로 올리는 것을 의미하진 않습니다.
    • 도메인별로 Mono Repo를 유지해야 함을 의미합니다.
  • 사내 전체에서 사용되는 config / 유틸 JS 파일등은 당연히 별도의 저장소를 사용해야 합니다.

NodeJS에서 Mono Repo를 관리하는 가장 대표적인 방법으로 Lerna & Yarn Workspace 조합이 있습니다.

lerna
  • lerna는 각 패키지들을 배포하고 버전 관리하는 역할을 합니다.
  • yarn은 각 패키지간의 의존성 관리 하는 역할을 합니다.

lerna로도 패키지간 의존성을 관리할 수 있지만, lerna로 패키지 의존성을 관리할때 이슈가 있기 때문에 각 도구가 서로 잘하는 역할만 하도록 설정을 합니다.

패키지 의존성 관리는 lerna보다 yarn이 좋은 상세한 이유는 lerna? yarn workspace? 크게 개념만 잡아보기 을 보시면 좋습니다.

자 그럼 한번 이제 간단한 프로젝트를 구성하면서 lernayarn workspace 조합을 배워보겠습니다.

1. 설치

CLI를 사용하기 위해 전역으로 lerna를 설치하겠습니다.

npm install -g lerna

그리고 신규 디렉토리를 하나 만드신 뒤, 아래 명령어로 디렉토리를 lerna로 구성합니다.

lerna init -i

그럼 아래와 같이 디렉토리가 구성되는데요.

  • lerna.json
    • 프로젝트 lerna 설정
  • 최상위 package.json
    • 하위 프로젝트들이 공통으로 사용할 dependencies 선언
    • 프로젝트 전체를 대상으로 하는 script
  • packages
    • 하위 프로젝트들이 담길 상위 디렉토리
    • 수동 / lerna create 패키지명 으로 자동 생성 모두 가능

각각에 대해서 소개드리겠습니다.

1-1. 설치 요소

root/lerna.json

기본 생성된 lerna.json을 아래와 같이 수정합니다.

{
  "packages": [
    "packages/*"
  ],
  "version": "independent",
  "npmClient": "yarn",
  "useWorkspaces": true
}
  • version: "independent"
    • 패키지 배포와 관련된 버전을 패키지별로 독립적으로 가져가기 위한 설정
  • npmClient: "yarn"
    • npm 대신에 yarn을 사용하기 위해 선언
  • useWorkspaces: true
    • 맨 위에서 언급한것처럼 yarn workspace 를 사용하기 위해서 true로 선언합니다.

root/package.json

{
  "name": "root",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "lerna": "^4.0.0"
  }
}
  • private: true
    • 루트 프로젝트가 NPM Repository로 배포되는 것을 금지합니다.
    • NPM 패키지를 배포하기 위한 용도가 아니라면 불필요한 옵션입니다.
  • workspaces: []
    • workspace에 담길 패키지를 지정합니다.
    • lerna.jsonpackages의 경로와 일치

1-2. yarn workspace

yarn workspace를 간단하게 소개하면 다음과 같습니다.

workspaces

(출처: https://classic.yarnpkg.com/)

현재 프로젝트는 다음과 같이 조합합니다.

  • lerna는 각 패키지들을 배포하고 버전 관리하는 역할을 합니다.
  • yarn은 각 패키지간의 의존성 관리 하는 역할을 합니다.

여기서 yarn workspaces 만으로 구성하지 않는 이유는, 여러 개의 패키지를 용이하게 관리 할 수 있는 CLI 명령어 (publish, version 등)는 Lerna에서 많이 제공 되고 있기 때문입니다.

물론 yarn workspaces 도 일부 command를 제공하긴 합니다.

이런 CLI 명령어들이 결국은 CI를 통해 진행되는 배포 자동화에서 발생할 수 있는 여러 이슈들에 대해서 좀 더 편하게 대응할 수 있기 때문입니다.

반면에 패키지 관리는 yarn으로 하는 이유는 다음과 같습니다.

  • npm은 모노레포를 지원하지 않음
    • yarnyarn workspaces 를 추가적인 라이브러리 설치 없이 쉬운 방법으로 제공
  • yarn workspaces 가 불필요하게 lerna bootstrap 등의 명령을 실행하지 않으면서 더 안전하고 깔끔하게 패키지를 관리

자 그럼 실제로 간단하게 2개의 하위 패키지를 추가해보겠습니다.

2. 패키지 생성 및 의존성 추가

아래 명령어로 2개의 하위 패키지를 구성합니다.

lerna create order-log
lerna create order-base

그럼 packages 하위에 order-baseorder-log 2개의 프로젝트가 추가됩니다.

lerna-module

그럼 이제 의존성 추가하는 방법을 하나씩 해보겠습니다.

2-1. 공통 의존성 설치

공통 의존성이라 하면 root 에 등록될 패키지들을 의미합니다.

테스트로 한번 적용해보겠습니다.
프로젝트 전체에 TS 스택이 사용될 예정이니 TS 스택을 아래와 같이 실행합니다.
실행 위치는 root 입니다.

root

yarn add @types/jest jest @types/node ts-jest ts-node typescript --dev --ignore-workspace-root-check
  • yarn workspace 를 사용하게 되면 기본적으로 패키지안에 모듈을 설치하는 것으로 간주하므로 workspace-root-check 를 무시하고 설치해줍니다.

이렇게할 경우 root/package.json에는 아래와 같이 의존성이 추가됩니다.

{
  "name": "root",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "@types/jest": "^26.0.24",
    "@types/node": "^16.0.1",
    "jest": "^27.0.6",
    "lerna": "^4.0.0",
    "ts-jest": "^27.0.3",
    "ts-node": "^10.0.0",
    "typescript": "^4.3.5"
  }
}

2-2. 개별 의존성 설치

만약 개별 모듈에서 각각 외부 패키지 등록이 필요하다면 다음과 같이 등록할 수 있습니다.

yarn workspace 모듈명 add 패키지명 [--dev]

이를테면 order-log 모듈에 chalk 패키지를 dev로 등록한다면 다음과 같습니다.

yarn workspace order-log add chalk --dev 
yarn workspace order-log add chalk --dev 

2-3. 로컬 패키지 등록

(제가 못찾아서 그렇겠지만) 내부 (로컬) 패키지를 의존성을 등록하는 방법은 바로 package.json 에 추가하면 됩니다.

order-log/package.json

"dependencies": {
  "order-base": "1.0.0",
}

2-4. 패키지 의존성 상속

Gradle Mutli Module을 사용하시는 분들이라면 의존성 상속에 대해서 당연하게 생각하실텐데요.
(제가 검색해본 내에서는) Lerna / yarn workspace 에서는 의존성 상속이 안됩니다.

즉, Entity 모듈 (상위) 이 TypeORM을 갖고 있다고 해서 이를 의존 있는 하위 모듈 (web / api / admin 등) 들이 자동으로 TypeORM을 가지고 있을 순 없다는 것입니다.

예를 들어, order-basedayjs를 의존성을 추가했다고 해서

dependency1

하위 모듈인 order-logdayjs를 사용할 수는 없다는 것이죠.

dependency2

개인적으로는 모노레포가 활성화된 Gradle 환경에서 하다보면 좀 더 세밀하게 의존성 관리를 해야하는데 그러지 못하다는 점이 굉장히 아쉽습니다.
(All or One 느낌이랄까요?)

이거 아시는 분 계시면 공유 부탁드립니다!

3. 테스트

이렇게 만들어진 order-baseorder-log 프로젝트들을 테스트해보겠습니다.

3-1. order-base 테스트

order-base 프로젝트는 Order Entity 객체를 하나 만들어둡니다.

order-base/src/Order.ts

export class Order {
    name: string;
    amount: number;
    payType: string;
    status: string;
    updatedAt: Date;

    constructor() {
    }

    static accept (name, amount, payType) {
        return Order.acceptWithNow(name, amount, payType, new Date());
    }

    static acceptWithNow (name, amount, payType, now) {
        const order = new Order();
        order.name = name;
        order.amount = amount;
        order.payType = payType;
        order.status = 'accept';
        order.updatedAt = now;

        return order;
    }

    createCancel() {
        return this.createCancelWithNow(new Date());
    }

    createCancelWithNow(now) {
        const cancelOrder = new Order();
        cancelOrder.name = this.name;
        cancelOrder.amount = this.amount * -1
        cancelOrder.payType = this.payType;
        cancelOrder.status = 'cancel';
        cancelOrder.updatedAt = now;

        return cancelOrder;
    }
}

그리고 이를 테스트할 테스트 코드를 작성해봅니다.

order-base/test/Order.test.ts

import {Order} from "../src/Order";

describe('Order', () => {
    it('생성시 updatedAt = 생성시간, status = accept로 할당된다', () => {
        const now = new Date ('2021-07-08');

        const order = Order.acceptWithNow('name', 1000, 'kakaopay', now);

        expect(order.updatedAt).toBe(now);
        expect(order.status).toBe('accept');
    });

    it('취소시 현재 금액이 반대로, status = cancel로 할당된다', () => {
        const now = new Date ('2021-07-08');
        const amount = 1000;
        const order = Order.acceptWithNow('name', amount, 'kakaopay', now);

        const cancel = order.createCancel();

        expect(cancel.amount).toBe(amount * -1);
        expect(cancel.status).toBe('cancel');
    });
});

테스트를 실행하기 위해 jest 명령어를 등록합니다.

order-base/package.json

"scripts": {
  "test": "jest"
},

그리고 위 테스트를 실행해보면?

order-base-test

테스트가 성공함을 볼 수 있습니다.

3-2. order-log 테스트

자 그럼 다음으로는 하위 모듈인 order-log를 테스트 해보겠습니다.

먼저 마찬가지로 package.jsonjest를 추가합니다.

order-log/package.json

"scripts": {
  "test": "jest"
},

테스트할 코드와 테스트 코드를 추가합니다.

order-log/src/Message.ts

import {Order} from "order-base/src/Order";

export class Message {

    static messageAccept (order: Order) {
        return `${order.name} (총: ${order.amount}원) 주문이 접수되었습니다.`;
    }

    static messageCancel (order: Order) {
        return `${order.name} 주문이 취소되었습니다.`;
    }
}

order-log/test/Message.test.ts

import {Order} from "order-base/src/Order";
import {Message} from "../src/Message";

describe('Message', () => {
    it('주문 접수 메세지', () => {
        const order = Order.accept('AXDA01', 1000, 'kakaopay');

        const message = Message.messageAccept(order);

        expect(message).toBe('AXDA01 (총: 1000원) 주문이 접수되었습니다.');
    });
});

그리고 이 테스트를 수행해보면?

order-log-test

상위 모듈을 의존하고 있는 하위 모듈도 정상 작동하는 것을 확인할 수 있습니다.

3-3. 전체 테스트

마지막으로 이렇게 작성된 테스트들을 전체 수행해보겠습니다.

이때는 lerna의 스크립트를 사용하면 편한데요.
우리는 lerna test를 사용해서 전체 테스트 수행을 하겠습니다.

root/package.json

"scripts": {
  "test": "lerna run test"
},

그리고 이제 yarn test로 전체 테스트를 수행해보면?

lerna-test

모든 테스트가 차례로 수행되어 성공함을 확인할 수 있습니다.

4. 마무리

이번 시간에는 간단하게 lerna + yarn workspace로 모노레포를 구성하는 방법을 배워보았는데요.
다음 시간에는 본격 실전에 맞게 expresstypeORM 등의 환경에서 모노레포 구성하는 방법을 배워보겠습니다.

참고

반응형