프론트엔드 프로젝트 최신화(2)

모노레포를 적용하고 있는 프로젝트에 대해 소개합니다

Dugi 🎈

  • 테크 인사이트

안녕하세요, 채널톡 웹팀 엔지니어 두기입니다 👋

저번 글에 이어, 이 글에서는 저희 팀에서 모노레포를 적용하고 있는 프로젝트에 대해 다룹니다.

들어가며

채널 제품팀에서는 여러 어플리케이션을 관리하고 있습니다. 9만여 개의 고객사 홈페이지에서 고객사와 사용자를 이어주는 채널 “프론트" 플러그인, 사용자가 문의한 내용을 빠르게 응대할 수 있도록 돕고 팀 메신저, 고객 데이터 관리, CRM 마케팅 등을 관리할 수 있는 채널 “데스크" 가 있습니다. 제품을 만드는 기초가 되는 디자인 시스템과, 채널팀의 홈페이지, 팀 내부적으로 사용하는 인하우스 어플리케이션도 엔지니어가 관리하는 영역입니다.

이 프로젝트들은 어플리케이션마다 별도의 git repository를 사용하여 관리되어 왔습니다. 그러나 제품이 복잡해지며 문제가 발생했습니다.

  • 여러 어플리케이션에서 공유하는 도메인 모델과, 이에 관한 비즈니스 로직을 각 어플리케이션마다 중복으로 작성하게 됩니다.

  • 모델 뿐만 아니라, view 레이어에서도 중복이 발생합니다. 한 예로, “데스크”에서는 “프론트" 플러그인을 설정하기 위한 form을 제공합니다. 이 form에서는 설정에 따라 플러그인이 어떻게 노출될지 미리보기를 제공해야 하는데, 이 과정에서 여러 컴포넌트를 두 어플리케이션에서 중복 구현하게 되는 문제점이 생겼습니다.

이것을 해결하기 위해, 공통된 로직과 컴포넌트를 모듈로 묶어 공유하자는 방향이 세워졌습니다. 이렇게 shared-components, recipe-components 등의 패키지가 만들어졌고, 여러 어플리케이션에서 구현이 중복되는 문제를 해결했습니다.

그러나 쪼개진 패키지를 별도의 git repository에서 관리하면서 새로운 문제점과 마주쳤습니다.

  • 관리하는 repository가 많아지면서 일관성 있게 관리하기가 어려워졌습니다. 팀 구성원들은 대부분 어플리케이션의 repository에서 많은 시간을 보냅니다. 자연스럽게 나눠진 repository는 시간이 흐름에 따라 이슈 트래킹, 코드 리뷰, dependency 최신화, branch protection rule 등 여러 부분에서 어플리케이션 repository보다 뒤쳐졌습니다.

패키지마다 CI/CD도 일관성 있게 구성되어 있지 않았기 때문에, 문서를 꼼꼼히 작성하지 않는다면 package를 배포하는 방법을 잊어버릴 수도 있었습니다.

어플리케이션과 라이브러리를 관리하면서 겪은 어려움으로 인해, 모노레포를 도입하고자 하는 이유가 갖춰졌습니다.

Why monorepo:

Point 1. 편의성

  • 같은 git repository에 있으면, dependency, branch rule과 convention, CI 구성을 공유할 수 있습니다. 팀에서 컨벤션을 수정하기도 간편합니다.

  • GitHub 등 repository 기반의 issue tracking을 하고 있다면, 여러 어플리케이션과 패키지를 한 repository에서 관리하는 것이 더 편리합니다.

Point 2. 낮은 결합도

  • 패키지를 만들고 배포하는 비용이 크게 감소합니다.

    각 패키지가 추상화 수준이나 기능에 따라 단일 책임을 가지게 되어 하나하나가 불필요한 dependency 없이 간결해집니다. 👍

  • 거대한 monolithic application의 경우, 소스 파일 간의 의존성이 거미줄처럼 복잡해져 한 코드를 이해하기 위해 수백 개의 파일을 읽어보아야 하는 경우가 자주 발생합니다.

  • 작은 package들로 구성된 프로젝트의 경우, 어떠한 package를 참조하고자 할 때 interface와 documentation을 참조하는 것으로 충분합니다. (보통 우리가 npm에서 설치한 라이브러리를 사용할 때, 그 라이브러리의 소스 코드까지 뜯어보는 경우는 별로 없으니까요 😁) 작업자는 본인이 maintaining하는 package와 그보다 추상화 수준이 낮은 package에 대해서만 아는 것으로 충분합니다.

  • 규모가 있는 프로젝트라면, 어떤 한 사람이 코드를 업데이트 했을 때 프로젝트의 다른 부분이 breaking change로 인해 깨져 conflict 해소로 인해 많은 시간을 소비하게 됩니다. Semantic versioning을 지키며 package를 업데이트할 수 있다면, 한 package의 업데이트로 인해 프로젝트의 나머지 부분에 영향이 미치는 부분을 격리할 수 있습니다. Public API에 breaking change를 만드는 것에도 신중하게 됩니다.

Point 3. 오너십

  • 팀에서 거대한 어플리케이션 프로젝트를 관리하며 느낀 점은: 한 팀원이 어떤 설계 의도를 가지고 코드를 작성하더라도, 이후 시간이 흐름에 따라 기획과 코드가 수정되면서 최초의 설계 의도가 흐려지기 쉽다는 것입니다.

  • Package 간의 경계는 파일과 디렉토리의 경계보다 훨씬 견고한 경계이기 때문에, package는 한 maintainer가 책임지고 관리하기 좋은 단위입니다. Maintainer는 package가 설계 의도를 보존하면서 업데이트될 수 있도록 더 효과적으로 방어할 수 있습니다.


Monorepo를 통해 관리하는 프로젝트 중 하나인 @prosemirror-toolkit 을 예시로, 몇 가지 활용 패턴에 대해서 소개해드리고자 합니다.

채널톡은 팀 메신저와 고객과의 상담을 기본 기능으로 가지고 있는데, @prosemirror-toolkit채널톡의 메시지 에디터를 구현하기 위한 프로젝트입니다. 기반 라이브러리인 prosemirror를 어플리케이션에서 사용하기 더 간단한 형태로 추상화하고, 저희 제품 도메인과 연관성이 있는 부분과의 연결을 제공해야 합니다.

Repository는 여러 패키지를 monorepo 형태로 구성하고 있습니다:

  • core, React-world와의 연결을 제공하는 react-core 등 추상화 레벨이 낮아 다른 패키지에 코어 API를 제공하는 패키지

  • bold-extension, emoji-extension 등 feature 를 구현하는 패키지

  • util, util-fx 등 다른 패키지에서 필요한 utility를 제공하는 패키지

  • storybook 등 프로젝트의 사용처와는 관계 없지만, 테스트 등 부가 기능을 지원하는 패키지

현재 lerna, turborepo, nx 등 모노레포 관리를 돕는 여러 도구가 생태계에 존재하나, prosemirror-toolkit 프로젝트에서는 모노레포 관리를 위해 yarn workspace를 사용하고 부가적인 도구는 사용하지 않고 있습니다.


의존성

Consistent dependency 만들기

Monorepo 전체에 대해 어떤 dependency의 버전을 모두 동일하게 유지하고 싶은데, 모든 패키지의 package.json을 돌아다니며 버전을 확인하고 일관성을 직접 관리하는 것은 어려운 일입니다.

prosemirror-toolkit은 yarn workspace를 활용하여 모노레포를 구성했기 때문에, yarn up 커맨드를 사용하여 패키지의 버전을 올릴 수 있습니다. 한 패키지의 dependency를 업데이트하는 yarn add 와 달리, 이 커맨드는 workspace 전체에 걸쳐 어떤 dependency의 버전을 업그레이드합니다.

typescript, @rollup/*, @babel/* 등 workspace 전체에 걸쳐서 사용되는 dependency를 업데이트할 때 유용합니다.

yarn up은 dependency를 업데이트할 때 사용하는 도구이기 때문에, 모든 패키지가 어떤 dependency의 같은 버전을 사용하고 있다는 보장을 해주는 것은 아닙니다. prosemirror-toolkit에는 반드시 모든 package에 걸쳐 동일 버전을 사용해야 하는 핵심적인 dependency들이 있습니다. prosemirror-model, prosemirror-state와 같이 toolkit의 기반이 되는 prosemirror 관련 dependency나, react 가 이러한 dependency 가 될 수 있습니다.

위 다이어그램처럼 monorepo의 패키지들이 동일한 의존성을 가져야 하는데, 의존성을 업데이트하며 아래 그림처럼 실수가 생길 수 있습니다. 일부 package만 prosemirror-view의 다른 버전에 의존하는 모습입니다.

Inconsistency가 생길 수 있는 문제점을 해결하기 위해, 중간에 외부 의존성의 wrapper 역할을 하는 패키지를 하나 더 두었습니다. 모노레포 안의 다른 패키지에서는 외부 의존성을 사용할 때 wrapper 패키지에 대신 의존성을 두어 사용합니다.

TypeScript

@prosemirror-toolkit/prosemirror-pm을 사용하는 경우, 아래와 같은 형태로 의존성 그래프가 변하게 됩니다. 모노레포의 모든 패키지가 같은 버전의 외부 의존성을 사용하는 것을 쉽게 보장할 수 있고, 공통적으로 사용하는 의존성을 쉽게 변경하거나 업데이트할 수 있습니다.

패키지 간 참조

모노레포 내의 패키지는 외부 패키지에 의존하는 것과 더불어, 모노레포의 다른 패키지를 참조할 수도 있습니다. 모노레포 안의 패키지를 참조하는 것은 외부 의존성과 달리 yarn add 로 패키지를 추가하지 않고도 가능합니다.

yarn workspace를 사용하는 경우, node_modules/ 디렉토리 내에 workspace 내의 패키지들에 대한 symlink가 만들어집니다. 마치 "some_package": "../path-to-package" 처럼 local filesystem을 가리키는 의존성을 설치하는 것과 마찬가지입니다. 하지만, package.json에 의존성을 추가하는 과정을 거치지 않고도 모노레포 내부 패키지를 resolve할 수 있습니다.

Circular dependency 예방하기

모노레포 내부 패키지 간 의존성이 생기면, circular dependency가 생기지 않도록 하는 것에 유의해야 합니다. prosemirror-toolkit 프로젝트에서는 타입스크립트 컴파일러의 Project References 기능을 통해 circular dependency가 생기지 않도록 보장하고 있습니다.

Documentation - Project References

Project references 기능을 사용할 때, 타입스크립트 컴파일러는 여러 패키지를 동시에 컴파일할 수 있습니다. 이 때, tsconfig.jsonreferences 필드에 유의해야 합니다. package A에서 package B를 사용한다면, 아래처럼 package A의 references 필드에는 package B의 tsconfig path를 적어놓아야 합니다.

JSON

타입스크립트 컴파일러는 references 필드를 통해 패키지 간의 의존성 그래프를 만든 후, 의존성이 없는 패키지부터 차례대로 컴파일해 나갑니다. (다른 말로, 의존성 그래프를 위상 정렬한 순서대로 패키지를 컴파일 해 나갑니다.) 의존성 그래프에 circular dependency가 존재하면 컴파일 에러가 발생합니다.

예시 상황으로, react-core 패키지가 core 를 references 에 추가하고, 반대로 corereact-core 패키지를 references에 추가하여 circular dependency가 있는 상황을 구성해 보았습니다. (실제로는 corereact-core 패키지에 의존하지는 않습니다.)

JSON

위와 같이 circular dependency가 존재한다는 것을 지적하며 컴파일 에러가 발생합니다.

Incremental build

이외에도, typescript compiler는 project references를 사용하여 구성된 소스를 컴파일할 때 incremental build를 수행합니다. 즉, 어떤 패키지를 컴파일한 적이 있고 그 후로 소스가 변경되지 않았다면, 그 패키지를 건너뛰고 컴파일합니다. 시간이 지나 모노레포를 구성하는 패키지가 수백 개가 되더라도, incremental build 기능 덕분에 로컬 개발 환경이나 cache를 적절하게 활용하는 CI 환경에서 프로젝트를 컴파일하는 시간은 크게 증가하지 않습니다. 거대한 어플리케이션 프로젝트에서 컴파일이나 번들링이 수 분 ~ 수십 분 걸려 개발에 어려움을 주는 상황과 비교하면 큰 장점이라고 할 수 있습니다.

Typescript compiler가 out-of-the-box로 incremental build feature를 제공한다는 것은, nx나 turborepo와 같은 별도의 workspace 관리 도구를 사용하지 않기로 결정한 이유 중 하나였습니다. 하지만, 각 패키지가 번들링되거나 별도의 빌드 스크립트에 따라 구성되어야 하는 경우, 더 많은 feature나 configuration option을 제공하는 도구를 사용하는 것을 고려해볼 수 있습니다.


생산성 높이기

패키지 생성기

새 프로젝트를 setup하는 것은 고된 일입니다. Linter, 단위 테스트, transpiler, bundler, … 등 각각에 대한 설정 파일과 script를 지정하다 보면 반나절은 걸립니다.

Monorepo를 운영하기 위해 가장 먼저 갖추어져야 할 조건 중 하나는, 패키지를 만드는 비용을 없애는 것이라고 생각했습니다. 작업 환경이 갖추어져 바로 코드를 작성할 수 있는 공간을 3초 만에 만들 수 있어야 합니다.

@prosemirror-toolkit 프로젝트에 repository 내에 새로운 패키지를 만들기 위한 template을 추가했습니다.

CLI prompt를 통해 메타데이터를 입력하면, monorepo에 새로운 package를 간단히 추가할 수 있는 스크립트도 작성했습니다.

이후 시간이 지남에 따라 template이 업데이트되거나 repository 전체적으로 마이그레이션이 필요한 상황이 있을 수 있습니다. 모든 패키지가 template으로부터 만들어졌기 때문에, 같은 모양을 가지고 있다는 것은 큰 장점입니다. 마이그레이션 스크립트를 쉽게 작성할 수 있기 때문에, 스크립트를 통해 monorepo 전체의 설정을 일관성 있게 관리하는 것이 자동화되었습니다.

의존성 참조 체크하기

앞서 local package 간의 circular dependency를 예방하는 것은 typescript project references가 담당하고 있다고 소개했습니다. 즉, 만약 어떤 package A에서 package B를 사용하고 있다면,

TypeScript

package A의 tsconfig.jsonreferences 필드에는 package B를 명시해야 합니다. 이 reference에 대한 listing이 누락되지 않아야 프로젝트가 circular dependency를 가지고 있지 않음을 보장할 수 있습니다.

JSON

이것을 사람이 보장하는 것은 어렵기 때문에, tsconfig.json 의 references 필드와 해당 패키지에서 실제로 Import하는 내부 package들의 목록이 일치하는지 확인하는 과정을 스크립트를 통해 자동화했습니다.

Plaintext
#!/bin/bash

find_references() {  
  find packages/prosemirror-toolkit__$1/src -type f \
    | xargs cat \
    | sed -n "s/^.*from '@prosemirror-toolkit\/\(.*\)'$/\1/p" \
    | awk -F/ '{print $1}' \
    | sort \
    | uniq
}

read_references() {  
  cat packages/prosemirror-toolkit__$1/tsconfig.json \
    | sed -n "s/^.*\"path\": \"..\/prosemirror-toolkit__\(.*\)\".*$/\1/p"
}

...

yarn plugins

yarn workspace는 turborepo나 lerna와 같은 tool에서 제공하는 것처럼, workspace 전체를 관리할 수 있는 여러 도구를 제공합니다. 이러한 도구는 대부분 yarn berry의 플러그인을 통해 제공됩니다. 예를 들어, workspace 전체에 대해 동일한 스크립트를 실행하는 것은 @yarnpkg/plugin-workspace-tools 에서 제공하는 foreach 커맨드를 사용하면 됩니다.

# yarn workspaces foreach <command> yarn workspaces foreach jest # Run unit test suites in all packages


마무리

프로젝트를 yarn workspace 등 여러 도구를 이용하여 모노레포 형태로 관리하면서 느낀 소결론들을 정리하면서 글을 마치도록 하겠습니다.

  • 모노레포는 한 팀 이상의 사람들이 같은 코드베이스에서 작업하기 유리한 형태라고 생각했습니다.

  • 이 때, 각 작업자나 소규모 팀은 패키지의 maintainer로서 역할을 수행해야 합니다.

  • yarn workspace와 typescript project references 기능을 통해 여러 가지 의존성 이슈를 해결할 수 있었습니다.

  • 모노레포 안의 패키지들이 일정한 규칙을 따르도록 컨벤션을 잘 정하면, 그만큼 작업을 자동화하기 위한 도구를 만들기도 쉬웠습니다.


[이런 글도 추천드려요]

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요