배경
새로운 프로젝트는 많은 사용자들이 모바일 환경에서 사용할 수 있도록 앱 형태를 택했다.
또, 플레이스토어/앱스토어에서의 업데이트 없이, 실험을 통해 앱을 지속적으로 빠르게 업데이트해보고자 그 중에서도 웹뷰를 선택하게 되었다.
사실 웹뷰는 토스/당근에서 다양한 실험/빠른 업데이트 때문에 사용한다는 이야기를 들은 뒤부터 사용자에게 정말 유효한 가치를 전달하려면 꼭 필요한 기술이라고 생각해서 꼭 한번 도전해보고 싶었다.
문제
웹뷰 화면 설계를 이야기 하지 않을 수가 없다.
웹뷰, 아니 앱의 문제점중 하나라고 한다면, 바로 스토어 리젝이다.
https://orangebrother.dev/blog/app_why_reject
여러가지 리젝 사유가 있지만, 가장 신경써야할 부분은 우리가 웹뷰 서비스라는 것이다.
웹뷰인 경우 리젝 확률이 기하급수적으로 상승한다.
왜 웹이 아니라 앱을 사용하냐에 대한 대답이 명쾌해야한다고 한다.
특히 푸시알림, 결제, 카메라 연동 등 네이티브 기능이 없이, 정말 웹화면과 동일한 앱이라면 거의 100% 리젝이 된다.
따라서 어떻게 하면 이러한 기능을 넣을지 고민했다.
결과적으로 네비게이팅, 푸시알림, 소셜 로그인 기능을 앱단에서 처리하고, 위 사진처럼 네비게이팅시 결과 화면을 웹뷰로 보여주는 형태로 네이티브 기술을 사용하면서, 동시에 웹뷰도 사용할 수 있도록 구현하기로 했다.
즉 둘을 적절하게 섞는 것이다.
그러면 각 네브바 하나마다 하나의 웹뷰 배포가 필요해진다.
그래서 우리 프로젝트는 위 예시 사진처럼 5개의 배포가 필요했다.
이렇게 배포 단위가 쪼개지면서, 자연스럽게 여러가지 고민거리들이 생기기 시작했다.
- 프로젝트 환경(ts, vite, jest, tailwind, eslint)을 5번 각각 구성해줘야함
- tailwind config에서 5번 스타일을 지정해줘야함
- 공통된 타입/유틸/훅/컴포넌트가 동일해도 두 번 만들어 써야함
- 배포 CICD도 각각 따로, 5회 구성해야함
해결책
이러한 고민들을 바로 해결해줄 수 있는게 모노레포였다.
모노레포
모노레포란, 다수의 프로젝트를 한 개의 레포지토리 내에서 관리하는 소프트웨어 개발전략이다.
공통으로 사용하는 것들을 묶어서 하나의 패키지로 만들고, 이를 다른 프로젝트에서 사용할 수 있게 된다.
이를 통해 위에서 생겼던 5가지 고민을 모두 해결할 수 있다.
문제 : 프로젝트 환경(ts, vite, jest, tailwind, eslint)을 5번 각각 구성해줘야함 / tailwind config에서 5번 스타일을 지정해줘야함
해결 : tsconfig/jest/tailwind/eslint/ 공통 패키지 파일을 하나만 만들어서 다른 프로젝트에서 참조한뒤, 상속시키면 단 한번의 구성으로 모든 내부 프로젝트가 동일한 구성을 갖게할 수 있음
문제 : 공통된 타입/유틸/훅/컴포넌트가 동일해도 두 번 만들어 써야함
해결 : 공통된 타입/유틸/훅/컴포넌트를 따로 패키지로 분리하여 각 프로젝트에서 참조하여 사용가능
문제: 배포 CICD도 각각 따로, 5회 구성해야함
해결:
- CI는 전체 레포에서 1회 테스트/빌드시키는 형태로 구성이 가능
- CD는 각 프로젝트 폴더별로 구성하여 배포브랜치를 통해 독립적인 자동화 배포를 구성할 수 있음
이렇게 바로 우리가 겪었던 문제를 해결할 수 있기에 모노레포를 도입하기로 했다.
어떤 도구를 사용할 것인가?
대표적인 모노레포 구성을 위한 도구로는
Yarn, Lerna, Nx, Turborepo 4가지가 존재했다.
이중에 하나를 골라야 했는데 우리 팀의 결정 기준은 다음과 같았다.
- 프로젝트 마감/런칭이 3월까지는 완료되어야함 -> 학습 비용이 적어야함
- Private repo를 사용하기에 github actions 제한시간이 2000분임 -> CI/CD 스크립트를 실행시키는데에 드는 시간을 감소시켜야만함
우선적으로 Yarn은 직접적으로 모노레포를 지원하지는 않는다. 직접 구성을 해야하는데, 학습 비용이 높으며, 따로 CI/CD과정에서 최적화도 직접 진행해야했다. 첫번째, 두번째 조건 모두 만족하지 않는다.
Lerna는 직접적으로 모노레포를 지원하지만, 캐시나 스크립트 병렬 실행/관리에 최적화되어있지는 않다. 즉 두번째 조건에서 탈락이다. (https://fromundefined.com/posts/2022-08-ultimate-monorepo/)
Nx vs Turborepo
마지막으로 남은게 Nx와 Turborepo인데, 이 둘 중에서 어떤것을 사용할지를 고민했다.
두 도구 모두 스크립트 병렬 실행을 지원해서, 병렬적으로 린트-테스트-빌드를 진행할 수 있다.
병렬 실행이 되지않으면 상단 좌측 이미지처럼 실행전 대기시작때문에 CI/CD에 소요되는 시간이 늘어나게 된다.
하지만 병렬실행이된다면? 상단 우측이미지처럼 소요되는 시간을 감소시킬 수 있다.
더불어 Turborepo와 Nx 둘다 클라우드 캐시를 지원하여, CI/CD환경에서 이미 빌드/테스트한 파일을 다시 빌드/테스트하지 않을 수 있어 CI/CD에 드는 시간을 더욱더 감소시킬 수 있었다.
실제로 사용하는 방법(https://d2.naver.com/helloworld/7553804#ch4)을 찾아보니 Turborepo가 설정하기에 더 간단하고, Vercel에 인수되었으며 간편하게 쓸수 있다고 판단하여 Turborepo를 선택하게 되었다.
구성하기
터보레포 설정은 공식문서(https://turbo.build/repo/docs/getting-started/create-new) 를 참고하기바란다.
여기서는 공식문서에 없는 내용들을 위주로 작성해보려고한다.
작업하게 되는 부분은 apps와 packages로 나뉜다.
apps는 프로젝트, packages는 공통으로 사용할 요소들로 구성해주었다.
공통 환경 구성하기
tailwind.config를 예시로 들겠다.
packages/tailwind-config/package.json에 이렇게 작성해주었다
{
"name": "@repo/tailwind-config",
"version": "0.0.0",
"private": "true",
"exports": {
".": "./tailwind.config.ts"
},
"devDependencies": {
"tailwindcss": "^3.4.1"
}
}
그리고 tailwind.config.ts는 아래와 같이 작성한다.
import type { Config } from 'tailwindcss';
//공통 tailwind.config.ts설정
const config: Omit<Config, 'content'> = {
theme: {
extend: {
...
},
};
export default config;
이렇게 하면 패키지 구성은 끝이다!
이걸 사용하는 apps에서 사용해보자.
apps/home/package.json이다.
{
"name": "home",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
...
},
"dependencies": {
...
},
"devDependencies": {
"@repo/tailwind-config": "*",
"@repo/typescript-config": "*",
...
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
}
}
그리고 이제 apps/home/tailwind.config.ts에서 공통파일을 import해서 상속해주면된다.
import type { Config } from 'tailwindcss';
import sharedConfig from '@repo/tailwind-config';
const config: Pick<Config, 'content' | 'presets'> = {
content: ['./src/**/*.tsx'],
presets: [sharedConfig],
};
export default config;
이러면 공통 tailwind.config만 바꿔도 home에서 변경된 custom 스타일을 사용할 수 있게 된다!
패키지 구성하기
이번에는 공통 패키지이다.
예시로 toast패키지를 들자면, packages/toast/package.json을 이렇게 구성해주었다.
{
"name": "@repo/toast", //import할때 이 이름으로 import하면 된다.
"version": "1.0.0",
"description": "toast package",
"main": "./index.tsx", //export 할 파일
"types": "./index.tsx",
"private": true,
"license": "ISC",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"scripts": {
"lint": "npx prettier --write src & eslint src",
"test": "jest"
},
"devDependencies": {
"@repo/eslint-config": "*",
"@repo/jest-config": "*",
"@repo/typescript-config": "*",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.11",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
그리고 index.tsx를 아래와 같이 구성했다.
import ToastProvider from './src/ToastProvider';
import useToast from './src/hooks/useToast';
export { ToastProvider, useToast };
사용할 apps의 package.json에서 아래와 같이 등록해주면 된다.
그러면 이제 @repo/toast를 사용할 수 있게된다. ToastProvider로 App을 감싸주었다.
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '@repo/toast';
import App from './App';
import './index.css';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } });
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<App />
</ToastProvider>
</QueryClientProvider>,
);
빌드/테스트
이렇게 등록한 패키지, 앱들을 실행시키거나, 빌드하거나, 테스트하려면 해당 패키지의 package.json에 script를 작성해주고, 그 커맨드를 turbo.json에 등록해주면된다.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": { //전체 빌드
"outputs": ["dist/**"]
},
"lint": { //전체 lint
"dependsOn": ["^lint"]
},
"test": {}, //전체 테스트
"dev": { //전체 개발 모드로 실행
"cache": false,
"persistent": true
},
"type-check": {} //tsc
}
}
캐시 처리
파일이 변경되지 않으면 캐시가 사용되어 606ms만에 빌드가, 542ms만에 테스트가 끝난 모습이다.
만약 파일이 변경되면, 해당 부분에 대해서만 재빌드/테스트를 하기때문에 굉장히 효율적이다.
클라우드 캐싱
그러나 위의 캐시처리는 로컬 환경에서 파일을 통해 캐시하기때문에 github actions에서 빌드/테스트를 하는 경우에는 이를 쓸 수 가 없다.
그래서 vercel에서는 클라우드 캐싱과 github actions에서의 캐싱을 지원한다.
우리의 경우 github actions에서 사용하므로 공식문서의 두번째를 따라 진행했다. 굉장히 간단하게 설정할 수 있었다.
https://turbo.build/repo/docs/ci/github-actions