Yarn berry workspace를 활용한 프론트엔드 모노레포 구축기

Apr.13.2022 강동혁, 고우혁, 박지성

Web Frontend

안녕하세요 우아한형제들 딜리버리프로덕트실 DH로컬라이징스쿼드 입니다. 저희 조직에서는 백오피스 애플리케이션, 크로스플랫폼 모바일 애플리케이션 등 다양한 프론트엔드 프로덕트들을 개발, 운영하고 있습니다.

이 글을 통해 저희 조직에서 프론트엔드 개발 환경을 개선하기 위해 고민했던 모노레포 기술구축했던 경험을 공유하려고 합니다. 모노레포 도입을 고민할 수밖에 없었던 열악했던 상황을 먼저 이야기하고, 모노레포 구축에서 느낀 어려운 점과 해결방안을 소개하겠습니다. 작업을 하고 나서 변화한 ‘일하는 방식’에 대해서도 나눠보려고 합니다.

시작하기 전에

이미 꾸려진 개발 조직에 여러분이 첫 번째 FE 엔지니어로 합류했다고 상상해 볼까요? 좋은 점들도 많지만 안타깝게도 상황이 녹록지 않은데요.

  • 백오피스 FE 애플리케이션은 BE 엔지니어의 유산으로 각 서버 repository에서 관리되고 있습니다.
  • BE 엔지니어는 필요에 따라 FE 애플리케이션 소스를 수정하지만 이것을 인지하기 어렵습니다.
  • 각 백오피스 FE 애플리케이션의 기술 스택은 서로 다릅니다.
  • 이미 deprecated된 기술도 여전히 사용하고 있으며 전사 표준 기술 스택과도 동떨어져 있습니다.
  • 코드 컨벤션 또한 찾아보기 어렵습니다.
  • FE 애플리케이션 소스의 코드 정적검사 및 테스팅이 충분하지 않습니다.

뛰어난 역량을 가진 여러분은 어려운 조건에서도 레포지토리를 쉼 없이 넘나들고 천차만별인 기술 스택을 초월하여 하루하루 개발합니다. 그러나 시간이 지날수록 업무효율이 높아지지 않는 것을 보며 무엇인가 잘못되었다는 것을 알아차립니다.


실제로 여러분 앞에 펼쳐진 상황이라면… 아찔하시죠? (아이고 두야…)

불행하게도(?) 이것은 상상이 아니라 불과 약 1년 전 저희가 맞닥뜨린 현실(!)이었습니다. 그래서 어떻게 했냐고요? 도망가진 않았냐고요? 지금부터 그 이야기들을 하나씩 풀어보겠습니다.

프론트엔드라는 불모지 개척을 위한 노력

척박한 프론트엔드 개발 환경을 가꾸어 나가기 위해 작은 부분부터 개선해나가기 시작했습니다. 어떤 문제든 단번에 해결하기를 기대하기는 어려우니까요.

가장 먼저 백오피스 FE 애플리케이션에 공통으로 사용되는 UI 컴포넌트들을 모은 라이브러리를 개발했습니다. 코드의 재사용성을 확보한 뒤에는 뒤떨어진 기술 스택을 전사 표준에 맞출 수 있도록 React, Typescript로 포팅하는 작업을 진행했습니다.

이를 통해 코드 베이스는 더 건강해졌고 통일된 기술 스택으로 개발 환경 또한 부분적으로 개선되었습니다. 그러나 백오피스 FE 애플리케이션 소스코드가 서로 다른 repository에 파편화되어 있고, FE 소스 변경을 일일이 확인하기 어려운 상황은 여전히 숙제로 남아있었습니다.

이 어려움을 격파하기 위해 모노레포라는 카드를 꺼내듭니다. (두둥)

왜 모노레포일까?

모노레포란 같은 레포지토리에서 서로 다른 프로젝트들을 관리하는 소프트웨어 개발 전략을 말합니다. (출처: Wikipedia) 각 프로젝트를 서로 다른 레포지토리에서 관리하는 전략과 비교했을 때 코드 재사용, 외부 의존성 관리, 모든 프로젝트를 아우르는 리팩토링 측면에서 장점이 있습니다.

저희 조직은 소수의 FE 엔지니어가 다수의 FE 프로젝트를 개발 및 운영해야 하는 상황에 놓여있었습니다. 모노레포의 장점을 통해 이런 상황에서 업무의 효율을 향상시킬 수 있을 것이라고 판단하였습니다.

FE 프로젝트 소스를 한곳에서 관리함으로써 소스의 변경사항을 보다 쉽게 파악할 수 있고, 신규 입사자가 합류했을 때 개발 환경을 세팅하는 데 비용을 줄일 수 있을 것으로 예상했습니다. 또한, 이미 개발해 둔 공통 UI 컴포넌트 라이브러리와 같이 모듈화된 코드를 여러 프로젝트에서 재사용할 수 있을 것을 기대했습니다. 마지막으로 코드 정적 분석 및 테스트 도구를 공통으로 적용함으로써 코드의 최소 품질을 유지할 수 있을 것이라고 생각했습니다.

한 번도 가보지 않은 길이었기 때문에 기술적인 도전이 필요하다는 점, 현재 운영하고 있는 프로젝트들의 배포 파이프라인 등 구성도 변경해야 한다는 점 등 우려되는 부분도 있었습니다. 그러나 절실한 동기와 불모지를 개척하려는 의지, 무엇보다 공감하고 함께 도전해 보자는 동료들이 있었기 때문에 모노레포라는 열차에 탑승하게 됩니다.

그럼, 모노레포 열차 출발합니다

(위에서 걱정했던 대로) 모노레포는 저희 모두에게 가보지 않은 길이었고 앞으로 생길 어려움도 예측할 수 없었기에, 저희는 신중하게 하나씩 난관을 헤쳐나가기로 했습니다. 물론 최악의 케이스도 염두에 두어야만 했죠.


그때_우려했던_모노레포_절망편.jpg

도구 정하기

먼저 모노레포를 구축하기 위한 ‘도구’가 필요했습니다. 먼저 각자 하나씩 도구 후보를 선택하고, 현재 프론트엔드 프로젝트들의 조건을 가지고 간단히 모노레포를 구성해 보고 서로 장단점을 비교해 보았습니다.

가장 먼저 떠오른 건 yarn (v1) + lerna였습니다. lerna에 대한 사전 지식이 필요하다는 진입장벽은 있었지만, 레퍼런스를 많이 찾을 수도 있었고, 각 패키지를 라이브러리 형태로 배포할 때 버전 관리가 용이하다는 장점이 있었습니다.

npm workspace는 npm을 패키지 매니저로 사용하는 경우 추가적인 라이브러리 사용 없이 모노레포를 구축할 수 있다는 장점이 있었습니다. 다만 npm 7.0 버전이 릴리스된 지 얼마 되지 않은 상태였기 때문인지 참고할 만한 레퍼런스가 많지 않았습니다.

하지만 저희가 선택한 건 yarn berry(v2+) workspace였습니다. 아직 널리 사용한다고 하기에는 무리가 있는 yarn berry에 이보다도 사용자가 적은 workspace까지 사용한다는 건 가시밭길이 눈앞에 보이는 선택지였지만 기꺼이 가시밭길을 가기로 한 이유가 몇 가지 있었는데요.

첫 번째 이유는 yarn berry 자체였습니다. 이전에 백오피스 애플리케이션을 React + Typescript로 포팅 할 때 yarn berry를 도입한 상태였는데, 이때 yarn berry의 장점을 많이 체감했습니다. 특히 Zero-install을 모노레포에서도 그대로 활용할 수 있었던 건 무시하기 어려운 yarn berry만의 장점이었습니다.

workspace 또한 별개의 기능이 아니라 yarn berry에 내장되어 있기 때문에 러닝 커브가 낮았던 것도 이유 중 하나였습니다. 구체적인 케이스에 대한 레퍼런스를 찾기는 쉽지 않았지만 공식 문서가 어느 정도 잘 정리되어 있었기에 익히기 그리 어렵지 않았고, 간단한 프로토타이핑을 통해 실제 적용도 충분히 가능하겠다는 확신을 얻을 수 있었습니다.

yarn(v1) + lerna npm workspace yarn berry workspace
장점 – 충분히 많은 레퍼런스
– 프로젝트 별 배포 시 버전 관리에 용이
– (npm을 쓴다면) 추가 라이브러리 필요 없음 – yarn berry의 장점을 그대로 (특히 Zero-install)
– 추가 라이브러리 필요 없음
단점 – lerna에 대한 진입장벽 – 적은 레퍼런스 – 마이너한 패키지 관리자의 더 마이너한 기능
– 참고할 레퍼런스가 거의 없음

구조 잡기

‘도구’ 다음은 ‘계획’, 즉 어떤 모습으로 모노레포를 구성할지를 결정해야 했습니다. 먼저 모노레포를 구축하기 이전까지의 프론트 개발 환경은 대략 아래와 같았습니다.

처음에는 공통 컴포넌트 정도를 떼어 공통화하는 게 목표였지만, 컴포넌트 외에도 백오피스 애플리케이션에서 사용하던 커스텀 훅, 유틸 코드, 타입 정의 등의 중복도 많았기 때문에 이들 또한 각각의 프로젝트로 분리해 코드의 중복을 더 줄일 수 있겠다는 판단을 했습니다. 최종적으로 계획 후 구성한 모노레포의 구조는 아래와 같습니다.

기초 세우기

다음으로 모노레포의 장점을 살리는 기본적인 개발 환경을 구성했습니다. 코드뿐만 아니라 eslint, prettier 같은 개발 환경 설정도 중복된 부분이 많았고 이를 공통화해 향후 새로운 프로젝트의 초기설정에 드는 노력을 줄이고자 했는데요, 결론적으로 아래와 같이 개발 환경을 구성할 수 있었습니다.

예제 레포지토리 미리보기

prettier

prettier의 경우 프로젝트별로 스타일 컨벤션을 굳이 다르게 가져가야 하는 경우가 아니라면 루트에 설정 파일을 공통으로 두고 활용해도 별문제가 없었습니다.

eslint

eslint의 경우도 동일하고, 필요하다면 경로별 override가 가능하기 때문에 루트에 설정 파일을 하나만 놓고 관리하는 것이 가능했습니다. 다만 typescript 프로젝트들을 개발하는 경우 플러그인의 조합에서 문제가 발생하는 경우가 있었습니다.

타입스크립트 프로젝트에서 alias 설정을 통해 import 경로를 단축해 사용하고자 하는 경우 보통 tsconfig.json의 compilerOptions → paths 옵션을 설정하게 됩니다. 저희는 eslint에서 import 관련 린팅을 담당해 주는 플러그인인 eslint-plugin-import에서 typescript의 resolve 로직을 활용할 수 있도록 eslint-import-resolver-typescript 플러그인을 함께 사용했는데, 단독 프로젝트의 경우 resolver에 아래 설정을 더해주면 편하게 사용이 가능했습니다.

"settings": {
    "import/resolver": {
        "typescript": {
            // 경우에 따라 설정해도 되지만, 설정하지 않아도 루트경로의 tsconfig를 찾아줍니다.
            "project": "........./tsconfig.json"
        }
    }
}

위의 project 옵션은 array 형태 혹은 glob 패턴을 사용한 문자열도 넣을 수 있기 때문에 처음에 모노레포를 구축할 때도 아래와 같은 형식으로 넣으면 될 거라고 생각했습니다.

"typescript": {
    "project": "./packages/**/tsconfig.json"
}

여기서 해결이 딱! 하고 되었으면 좋았을 텐데, 막상 설정을 하고 보니 한 프로젝트에서는 설정한 resolver를 잘 따라서 린팅이 이루어지는 반면 다른 프로젝트에서는 resolver를 찾지 못하고 오류를 내뿜는 현상이 있었습니다. 에디터에서 ts 오류가 나지는 않는 것으로 보아 타입스크립트 자체, 혹은 tsconfig 설정의 문제는 아니었죠.

많은 재시도 끝에 찾은 답은 eslint 설정을 분리해야 한다는 것이었습니다. 처음 위의 glob 패턴으로 tsconfig의 경로를 설정해 주면 파일에 따라 각 프로젝트에 맞는 tsconfig를 따라 줄 것이라고 기대했지만, 실제로는 모든 tsconfig가 모든 프로젝트와 파일의 resolver로 작동하고, 따라서 alias도 특정한 하나의 tsconfig만을 따르고 있었습니다. 따라서 각 프로젝트 별로 resolver 설정을 override 해주어야 오류를 내지 않고 정상적으로 린팅이 이루어졌습니다.

"overrides": [
  {
    "files": ["packages/package-a/**/*.ts?(x)"],
    "settings": {
      "import/resolver": {
        "typescript": {
          "project": "./packages/package-a/tsconfig.json"
        }
      }
    }
  },
  {
    "files": ["packages/package-b/**/*.ts?(x)"],
    "settings": {
      "import/resolver": {
        "typescript": {
          "project": "./packages/package-b/tsconfig.json"
        }
      }
    }
  },
  ...
]

tsconfig

tsconfig의 경우는 각 프로젝트 별로 서로 다르게 가져가야 하는 설정이 많고 (리액트 관련 설정, path alias, include, exclude 등) 이를 루트에서 override 하기 어렵기 때문에 각 프로젝트 루트에 tsconfig.json을 별도로 두고 관리해야 했습니다. 다만 공통되는 설정이 있다면 루트 tsconfig.json에 정의해 주고, 이를 각 프로젝트에서 extend 한 후 특정 설정만 바꿔주는 방식을 쓰면 조금 더 효율적인 관리가 가능했습니다.

jest

jest 또한 각 패키지별로 jest.config.js를 두면 각 패키지의 babel 설정을 자동으로 따라가기 때문에 편하게 사용할 수 있습니다. 만약 루트에서 모든 프로젝트를 한 번에 테스트하고 싶다면 아래와 같이 yarn workspace 스크립트를 사용할 수 있습니다. (각 패키지에 ‘test’ 스크립트가 모두 존재한다는 것을 가정합니다)

# yarn workspaces foreach --parallel --include package-a --include package-b --include package-c ... run test

lint-staged

husky와 lint-staged를 활용한 pre-commit hook

husky와 lint-staged 설정은 루트에만 해주면 간편합니다. 각 프로젝트 별로 별도의 작업이 필요하다면 lint-staged 설정에서 (조금은 번거롭지만) 경로별로 다르게 추가해 주면 됩니다.

module.exports = {
    '**/*.+(ts|tsx|js|jsx)': ['eslint --fix', 'prettier --write'],
    'packages/package-a/**/*.+(ts|tsx)': [() => 'yarn tsc -p packages/package-a/tsconfig.json --noEmit'],
    //...
}

vscode

저희 프론트 개발자들은 모두 개발 에디터로 VSCode를 사용했는데요. 모노레포 개발을 하면서는 VSCode에 제공하는 workspace 기능을 사용해 조금 더 편하게 개발을 진행할 수 있었습니다. (단, 이 전에 VSCode에서 yarn berry에 맞춰 에디터 SDK 설정이 필요합니다)

{
  "folders": [
    {
      "path": "packages/package-a",
    },
    {
      "path": "packages/package-b",
    },
  ],
  "settings": {
    "eslint.nodePath": "../../.yarn/sdks",
    "typescript.tsdk": "../../.yarn/sdks/typescript/lib",
    "prettier.prettierPath": "../../.yarn/sdks/prettier/index.js"
  }
}

VSCode의 Jest extension을 사용하는 경우, 모노레포 프로젝트에서는 multi-root workspace를 활용하는 방식을 권장하고 있습니다. 플러그인과 관련된 설정 또한 워크스페이스 파일의 “settings”를 활용해 커스터마이징할 수 있었습니다.

{
  // ...
  "settings": {
    "jest.jestCommandLine": "yarn test",
    "jest.autoRun": {
      "watch": false,
      "onStartup": ["all-tests"],
      "onSave": "test-file"
    },
  }
}

yarn berry workspace의 망치를 제대로 휘두르시려면

위처럼 하나하나 모노레포를 구축하는 과정을 격파해나가는 데에 yarn berry, 그리고 workspace는 좋은 망치가 되어주었습니다. 다만 아쉽게도 모든 경우가 해피한 만능 도구까지는 아니었고, 모노레포를 격파하기 위한 몇 가지 제약조건이 있었습니다.


“자격이 있는 자, 모노레포의 힘을 쓸 수 있을지니”

Yarn Berry의 장점이 모노레포를 구축하는 데 단점이 되기도 합니다.

yarn berry의 장점을 하나 꼽으면, 패키지의 의도하지 않은 호이스팅을 허용하지 않는 것이라고 할 수 있는데요, 이 특성이 모노레포에서도 그대로 적용됩니다.

다른 방식으로 구축한 모노레포에서는 루트에 공통으로 쓸 패키지를 선언해 설치하고 각 프로젝트에서는 특별히 사용하는 패키지만 의존성에 추가하는 방식을 사용하는데, 이 방식은 node_modules가 패키지를 찾는 방식(= 호이스팅)에 기대고 있는 방식이라 yarn berry workspace로 구현한 모노레포에는 통하지 않습니다. 따라서 각 패키지에서 쓸 모듈은 루트에 패키지를 추가했는가와는 상관없이 무조건 하위 프로젝트의 의존성으로도 추가해 주어야 합니다.

이와는 정 반대로 몇몇 패키지는 각 프로젝트에서 필요한 의존성을 추가했음에도 실제로는 사용하는 모듈을 찾지 못하는 문제가 있는데요, 이때는 오류가 발생하는 경우 모노레포 루트에 찾지 못하는 패키지를 추가하는 방식으로 해결했습니다.

또한 개발을 할 때 각 프로젝트마다 에디터를 따로 열어두고 쓰면 깔끔하고 좋겠지만, 호이스팅 불가의 영향을 받아 VSCode의 sdk 설정이 까다로워지는 이유도 있어 각 프로젝트별로 에디터를 열었을 때 에디터가 typescript나 eslint 등을 찾지 못하는 등의 문제를 일으키며 방황하게 됩니다. 그래서 항상 루트 모노레포를 열어 작업해야 했습니다.

여기에 혹시 Storybook을 얹으신다면

Storybook은 대부분의 경우 yarn berry를 잘 지원하고, 문제없이 동작합니다. 다만 storybook의 기본 번들러인 webpack4 대신 webpack5를 사용하려고 할 때는 hoisting 이슈를 해결하기 위해 package.json의 resolution을 추가/수정해 주어야 합니다. (참고: 스토리북 마이그레이션 가이드)

yarn berry workspace를 통해 구축한 모노레포에 storbook을 적용하는 경우는 한 가지 더 유의점이 있습니다. yarn berry workspace에서 resolution 설정은 루트의 package.json에서만 설정 가능하고, 각 프로젝트에서 별도로 설정할 수는 없습니다. 따라서 storybook의 번들러를 변경하고자 하는 경우 모든 레포지토리에 동일하게 적용하는 것을 전제로 구성해야 합니다.

마무리

yarn berry, workspace를 이용해 모노레포를 구축하고 eslint, typescript 등 설정을 더 해준 것은 하나의 시작에 불과했습니다. 새 보금자리를 마련했으니 그동안 이산가족처럼 서로 다른 레포지토리에 살고 있던 FE 프로젝트들을 새 보금자리로 이사시키기 시작했습니다. 그와 더불어 CI/CD 파이프라인을 다시 구성해 모노레포 위에서 안전하게 코드를 통합하고 배포할 수 있는 환경 또한 마련했습니다.

모노레포 다시보기

모노레포 열차가 달리기 시작한 지 몇 개월 되지 않았지만 감히 성공적인 도전이었다고 이야기하고 싶습니다. 이제 FE 엔지니어들은 하나의 레포지토리를 바라보면서 Merge Request(gitlab)로 코드 리뷰를 하고 코드를 통합해 나갈 수 있습니다. 전체 FE 프로젝트의 오너십을 온전히 가진 후 FE 코드 컨벤션을 확립할 수 있었습니다. 공통으로 적용한 코드 정적검사와 각 프로젝트의 테스트를 CI 파이프라인에서 실행함으로써 코드의 최소 품질도 보장할 수 있게 되었습니다.


캬… 칭찬해 👍🏼

이 글을 읽고 계신 여러분들이 모노레포 도입에 대해 깊게 고민하고 계실 거라고 감히 예상해 봅니다. 각자가 처한 상황의 차이는 있겠지만 어떤 불만족을 가지고 있으시겠죠. 기존의 업무 방식이나 환경에 문제가 있고 이것을 개선해야겠다는 생각이 들었을 때, 엔지니어라면 자연스럽게 그 상황을 어떻게 타개할 수 있을지 고민할 것이라고 생각합니다.

물론 멋진 솔루션을 도출해 내는 것도 중요합니다. (대부분의 경우 누군가 이미 겪은 어려움이고 그에 대한 대안도 제시되어 있을 것이라고 생각합니다.) 하지만 그 무엇보다 현재의 어려움과 개선방안에 대한 동료의 공감이 중요하다는 것을 이번 기회를 통해 느꼈습니다. 동료의 공감을 이끌어내지 못한다면 그 솔루션을 실행에 옮겨 변화를 이끌어내기는 어려울 테니까요. 이 글을 통해 모노레포 뽐뿌(?)를 받으신 여러분이 동료의 공감을 얻어 모노레포 열차에 무사히 탑승하시기를 바라며 이만 줄이겠습니다.

P.S. 이 열차를 함께 끌어가고자 하는 여러분이 있다면, 여기를 참고해주세요! 🙂