왜 갑자기 리팩토링?
기존에 expo-go 기반으로 개발한 termterm의 규모가 커지면서, 가설 검증의 플로우를 빠르게 하기 위한 새로운 OKR로 "웹뷰 기반의 하이브리드 앱으로 리팩토링하기"를 설정하였습니다.
소규모 팀인 termterm 팀에서는 환경의 문제로 인해 적용하지 못했던 각종 문제(푸시, 결제, GA 등)를 해결하고 파일럿 프로젝트에 빠르게 도전하여 실 사용자의 반응을 확인하기 위한 수단이 필요한 상황이었습니다.
때문에 오로지 React-Native 코드로만 관리하던 서비스를 최소 단위의 micro-frontend로 분리하고, 핵심 기능과 컨트롤러만 React Native CLI로 관리하자는 계획을 세우게 되었습니다.
본 시리즈의 목표는 한 개의 모노레포 속에서 다양한 JS 기반 서비스를 배포할 수 있는 worktree를 구축하는 것입니다.
유틸함수와 hook, ui를 분리하여, 최종적으로 모바일 앱 뿐 아니라, 웹, 익스텐션, 데스크탑 앱까지 개발할 수 있는 환경을 구축하고자 합니다.
root
├── common //공통 모듈
│ ├── libs //유틸
│ ├── hooks //react hook
│ ├── ui //pure ui
│ └── shared //scss token
└── packages
└── ... //각종 프로젝트들
이 본문이 도움이 될거예요
- 하이브리드 앱 환경 구축
- pnpm을 이용한 모노레포 환경 구축
- typescript, rollup.js를 이용한 유틸 함수 모듈 구축
- react, typescript, rollup.js를 이용한 React hook 모듈 구축
- react, typescript, rollup.js, scss(saas), storybook을 이용한 React UI 모듈 구축
- 모노레포에서 워크스페이스 간 모듈 참조
- 기본적인 rollup.config.js 작성법
pnpm?
사실 yarn berry와 lerna, turborepo까지 시도해보며, termterm에서는 pnpm workspace 아키텍쳐가 가장 적합할 것이라 판단하게 되었습니다.
yarn berry의 경우 pnp가 가장 강력한 기능인데, 모노레포 도입 과정에서 pnp를 포기하게 되는 상황이 생각보다 자주 발생한다는 것을 알 수 있었습니다.
위 채널톡의 기술 블로그 인상깊게 읽었는데, pnp를 포기하게 된 이유를 보고... 어쨌든 규모가 커져서 분리하는 것이 아닌 가설 검증을 위해 분리하는 termterm에서 굳이 yarn berry를 적용할 필요가 없다 느꼈습니다.
당장 node_modules를 이용하는 react native와 발생하는 심볼릭 링크 관련 충돌을 직접 겪으며 yarn berry 포기를 확신했습니다.
결국 pnp가 아니면서, workspace 기능이 훌륭한 pnpm이 채택되었습니다.
(lerna와 turborepo, nx의 경우 프레임워크의 성격이 짙다 느껴, 규모가 크지 않은 termterm에서는 배제하게 되었습니다.)
모노레포 환경 구축
루트 디렉토리를 생성하고 경로 이동 후 아래 명령어를 실행하여 퓨어한 package.json을 생성합니다.
pnpm init
기본적인 정보는 자유롭게 설정하되, 직접 수정해야하는 부분이 있습니다.
{
"name": "termterm-monorepo",
"version": "1.0.0",
"description": "termterm hybrid web monorepo",
"keywords": [],
"author": ""
}
모노레포에서 이용할 워크스페이스를 구분하기 위해 우선 pnpm-workspace.yaml 파일을 생성하여, 워크스페이스를 등록합니다.
packages:
- "common/*"
- "packages/*"
termterm의 경우 common의 하위와 packages 하위에 여러 모듈 & 프로젝트가 위치되기 때문에 위와 같이 설정했습니다.
이후, 아키텍쳐를 구상한 대로 디렉토리를 생성합니다.
각 디렉토리 하위에서도 pnpm init을 통해 기본 package.json을 설정합니다.
이제 워크스페이스 별 name을 통해 루트에서 해당 모듈에 편하게 접근할 수 있도록 설정할 것입니다.
가령 common/libs의 경우 package.json을 아래와 같이 수정합니다.
{
"name": "@termterm/libs",
...
}
루트의 package.json에서 아래와 같이 스크립트를 작성합니다.
...
"scripts": {
"libs": "pnpm -F @termterm/libs",
...
},
...
루트에서 각 워크스페이스에 접근하여 cli를 입력하기 위해서는 pnpm -F 워크스페이스 이름 + cli 까지 입력해야해서 상당히 번거롭습니다. cd 명령어로 경로를 이동해서 작업할 수 있지만, 여러 워크스페이스를 동시에 작업해야하는 모노레포 특성 상 매번 경로를 움직이는 것은 상당히 귀찮을 수 있습니다.
때문에 위와 같이 해당 워크스페이스에 대한 접근 명령어를 스크립트로 작성해둡니다.
이후 루트에서 pnpm libs + cli 를 통해 @termterm/libs 워크스페이스에서의 작업을 모두 진행할 수 있습니다.
공통 typescript, eslint 설정
이 부분은 선택할 수 있지만, 본 프로젝트에서는 모든 워크스페이스에서 typescript가 사용되기도 하고 (공통으로 설정해야할 속성도 있고), 저 혼자 개발하는 것이 아니기 때문에 공용으로 사용되는 eslint를 설정했습니다.
eslint의 경우 상위 경로에서 설정한 것이 더 높은 우선순위를 갖기 때문에, 루트에서 설정하면 하위 워크스페이스에서는 설정할 필요가 없습니다.
pnpm add -w -D typescript
pnpm add -w -D eslint eslint-config-airbnb-typescript eslint-config-airbnb
eslint는 airbnb의 설정을 가져와 사용하였습니다.
tsc --init
이후 생성되는 tsconfig.js의 이름을 tsconfig.base.js로 변경하여 공용이라는 것을 명시합니다.
해당 파일 내부에 다른 속성은 크게 상관 없고, preserveSymlinks만 true로 수정합니다.
이는 pnpm에서 공용 모듈 간 자동 import 문제를 해결하기 위함으로, 공용 모듈을 사용하는 워크스페이스에서 해당 속성이 false라면 자동 import가 되지 않습니다.
루트에 .eslintrc.js를 생성하고 아래와 같이 입력하여 airbnb의 세팅을 사용합니다.
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
sourceType: "module",
},
plugins: ["@typescript-eslint/eslint-plugin"],
extends: [
"airbnb-base",
"airbnb-typescript/base",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js"],
};
공용 유틸 함수 모듈 환경 구축
typescript로 유틸함수를 작성하고, rollup.js로 번들링합니다.
pnpm libs add -D typescript tslib rollup @rollup/plugin-typescript
루트에서 위 명령어를 입력하여 @termterm/libs 워크스페이스에 필요한 모듈을 설치했습니다.
다음으로 rollup.config.js를 생성해서 번들링 옵션을 설정합니다.
const typescript = require('@rollup/plugin-typescript')
const fs = require('fs')
const path = require('path')
const options = {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs'
},
{
file: 'dist/index.es.js',
format: 'es'
}
],
plugins: [
{
name: 'Erase Dist',
buildStart() {
fs.rmSync(path.resolve('dist'), { recursive: true, force: true })
}
},
typescript({
module: 'esnext',
declaration: true,
declarationDir: './dist'
})
]
}
module.exports = options
index.ts에서 내보낸 함수를 dist 디렉토리 하위에 commonjs와 es module 형태로 내보냅니다.
이 과정에서 직전에 빌드한 기록을 지우기 위해 파일 시스템의 rmSync를 이용해 dist를 한 번 지워냅니다.
rollup 설정이 완료되었다면, 빌드 스크립트 작성을 위해 package.json을 수정합니다.
{
"name": "@termterm/libs",
"main": "dist/index.js",
"module": "dist/index.es.js",
"declaration": "dist/index.d.ts",
"scripts": {
"start": "rollup -c rollup.config.js -w",
"build": "rollup -c rollup.config.js"
},
"devDependencies": {
"@rollup/plugin-typescript": "^11.1.6",
"rollup": "^4.9.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
}
}
tsconfig.js 설정은 아래와 같이합니다.
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": false,
"skipLibCheck": true
}
}
번들
간단한 유틸함수를 작성하여 src/index.ts로 가져옵니다.
export { default as add } from "./calculate/add";
export { default as minus } from "./calculate/minus";
이후 pnpm build로 작성한 유틸함수를 번들링합니다.
번들링된 모듈을 같은 모노레포의 다른 워크스페이스에서 참고하는 방법은 포스팅 최 하단에서 확인할 수 있습니다.
공용 React Hook 모듈 환경 구축
typescript와 React로 Custom Hook을 작성하고, rollup.js로 번들링합니다.
pnpm hooks add react react-dom
pnpm hooks add -D @babel/core @babel/preset-env @babel/preset-react
pnpm hooks add -D @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-typescript
pnpm hooks add -D @types/react rollup rollup-plugin-peer-deps-external tslib typescript
루트에서 위 명령어를 입력하여 @termterm/hooks 워크스페이스에 필요한 모듈을 설치했습니다.
다음으로 rollup.config.js를 생성해서 번들링 옵션을 설정합니다.
const typescript = require('@rollup/plugin-typescript')
const commonjs = require('@rollup/plugin-commonjs')
const nodeResolve = require('@rollup/plugin-node-resolve')
const external = require('rollup-plugin-peer-deps-external')
const babel = require('@rollup/plugin-babel').default
const fs = require('fs')
const path = require('path')
const options = {
input: 'src/index.ts',
output: [
{
file: 'dist/index.js',
format: 'cjs'
},
{
file: 'dist/index.es.js',
format: 'es'
}
],
plugins: [
external(),
{
name: 'Erase Dist',
buildStart() {
fs.rmSync(path.resolve('dist'), { recursive: true, force: true })
}
},
nodeResolve(),
commonjs(),
babel({ babelHelpers: 'bundled' }),
typescript({
module: 'esnext',
declaration: true,
declarationDir: './dist'
})
],
external: ['react', 'react-dom']
}
module.exports = options
아무래도 유틸 함수보다는 조금 길어졌습니다.
rollup 설정이 완료되었다면, 마찬가지로 빌드 스크립트 작성을 위해 package.json을 수정합니다.
{
"name": "@termterm/hooks",
"main": "dist/index.js",
"module": "dist/index.es.js",
"declaration": "dist/index.d.ts",
"scripts": {
"start": "rollup -c rollup.config.js -w",
"build": "rollup -c rollup.config.js"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"@babel/preset-react": "^7.23.3",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"@types/react": "^18.2.55",
"rollup": "^4.9.6",
"rollup-plugin-peer-deps-external": "^2.2.4",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
}
}
tsconfig.js 설정은 유틸함수 워크스페이스와 동일합니다.
번들
작성한 커스텀 훅을 전부 src/index.ts로 가져와 내보냅니다.
테스트를 위해 임시로 작성한 커스텀 훅입니다.
import React from "react";
import { useState } from "react";
const useCount = () => {
const [count, setCount] = useState(0);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
return { count, increment, decrement };
};
export default useCount;
import React, { ChangeEvent } from "react";
import { useState } from "react";
const useInput = () => {
const [value, setValue] = useState("");
const handleValue = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
return { value, handleValue };
};
export default useInput;
export { default as useCounter } from "./input/useCounter";
export { default as useInput } from "./input/useInput";
이후 pnpm build로 작성한 커스텀 훅을 번들링합니다.
공용 UI 모듈 환경 구축
typescript와 React, SCSS로 공통 디자인 시스템을 작성하고, Storybook(webpack5)으로 UI 테스트 후 rollup.js로 번들링합니다.
pnpm ui install react react-dom style-inject classnames
pnpm ui install -D css-loader postcss rollup rollup-plugin-peer-deps-external rollup-plugin-postcss sass sass-loader style-loader tslib typescript
pnpm ui install -D @babel/core @babel/preset-env @babel/preset-react
pnpm ui install -D @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-typescript
pnpm ui install -D @types/react
루트에서 위 명령어로 typescript, react, rollup 개발 환경을 구성했습니다.
storybook은 storybookjs에서 지원하는 템플릿으로 빠르게 구성합니다.
cd common/ui
npx sb init //명령어 입력 후, webpack5 선택
storybook에서 scss를 사용하기 위해서는 추가적인 모듈이 필요합니다.
//경로가 루트라면 -F 필요함
pnpm add -D @storybook/preset-scss
이후, typescript와 rollup, babel, storybook에 대한 추가 설정 작업이 필요합니다.
tsconfig.js
{
"compilerOptions": {
"target": "es5",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
"sourceMap": false,
"outDir": "./dist",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"removeComments": true
},
"exclude": [
"./dist",
"./node_modules",
"./src/**/*.test.tsx",
"./src/**/*.stories.tsx",
"./src/**/*.test.ts",
"./src/**/*.stories.ts"
]
}
rollup.config.js
import babel from "@rollup/plugin-babel";
import commonjs from "@rollup/plugin-commonjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import peerDepsExternal from "rollup-plugin-peer-deps-external";
import postcss from "rollup-plugin-postcss";
import pkg from "./package.json" assert { type: "json" };
import { rmSync } from "fs";
import { resolve } from "path";
const extensions = ["js", "jsx", "ts", "tsx", "mjs"];
const config = [
{
external: [/node_modules/],
input: "src/index.ts",
output: [
{
dir: "dist",
format: "cjs",
preserveModules: true,
preserveModulesRoot: "src",
},
{
file: pkg.module,
format: "es",
},
{
name: pkg.name,
file: pkg.browser,
format: "umd",
},
],
plugins: [
{
name: "Erase Dist",
buildStart() {
rmSync(resolve("dist"), { recursive: true, force: true });
},
},
nodeResolve({ extensions }),
babel({
exclude: "node_modules/**",
extensions,
include: ["src/**/*"],
}),
commonjs({ include: "node_modules/**" }),
peerDepsExternal(),
typescript({
module: "esnext",
declaration: true,
declarationDir: "./dist",
}),
postcss({
extract: false,
inject: (cssVariableName) =>
`import styleInject from 'style-inject';\nstyleInject(${cssVariableName});`,
modules: false,
sourceMap: false,
use: ["sass"],
}),
],
},
];
export default config;
.babelrc
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
.stroybook/main.ts
import type { StorybookConfig } from "@storybook/react-webpack5";
import { join, dirname } from "path";
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], //경로 변경
addons: [
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/preset-scss"), //추가
],
framework: {
name: getAbsolutePath("@storybook/react-webpack5"),
options: {
builder: {
useSWC: true,
},
},
},
docs: {
autodocs: "tag",
},
};
export default config;
경로를 src 하위의 index.ts로 바꾸었으므로, 아래와 같이 아키텍처를 구성합니다.
위 이미지의 Button은 테스트를 위해 임시로 제작한 컴포넌트로, src 하위의 아키텍처는 자유롭게 구성해도됩니다.
제작한 컴포넌트는 아래와 같이 index.ts에서 내보내야 번들링이 가능합니다.
export { default as Button } from "./components/Button";
이렇게 제작한 디자인 시스템은, pnpm storybook으로 테스트하고 pnpm build를 통해 번들링할 수 있습니다.
common 모듈 공유
위 방식으로 세팅한 모든 공통 모듈을 packages 하위의 프로젝트에서 공유하여 사용할 수 있어야합니다.
우선 packages 하위에 모듈을 테스트할 prototype 프로젝트를 생성했습니다.
아래 프로젝트의 경우, 원하는 스택으로 생성하면 됩니다. 본 포스팅에서는 vite와 react typescript 조합의 템플릿을 이용했습니다.
cd pacakges
pnpm create vite prototype
마찬가지로, package.json에서 프로젝트의 이름을 적절하게 수정하고 루트에 -F 과정을 생략할 스크립트를 추가합니다.
prototype/pacakge.json
{
"name": "@termterm/prototype",
...
}
root/package.json
{
"name": "termterm-monorepo",
"version": "1.0.0",
"description": "termterm hybrid web monorepo",
"scripts": {
"libs": "pnpm -F @termterm/libs",
"hooks": "pnpm -F @termterm/hooks",
"ui": "pnpm -F @termterm/ui",
"prototype": "pnpm -F @termterm/prototype" // 추가
},
"keywords": [],
"author": "",
"devDependencies": {
"eslint": "^8.56.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.1.0",
"typescript": "^5.3.3"
}
}
이후 prototype 프로젝트에 공통 모듈을 설치하여 테스트를 진행합니다.
pnpm prototype add @termterm/libs @termterm/hooks @termterm/ui
위 cli는, @termterm/prototype에 @termterm/libs @termterm/hooks @termterm/ui를 설치하겠다는 것을 의미합니다.
설치가 완료되면 pacakge.json에 아래와 같은 형식으로 모듈이 추가됩니다.
{
...
"dependencies": {
"@termterm/hooks": "workspace:^",
"@termterm/libs": "workspace:^",
"@termterm/ui": "workspace:^",
..
},
...
}
node_modules에도 공통모듈이 올바르게 설치되었다는 것을 확인할 수 있습니다.
설치가 완료된 모듈은 이제 자유롭게 import하여 사용할 수 있습니다.
만약 자동으로 import되지 않는 문제가 발생한다면, 프로젝트의 tsconfig.js를 아래와 같이 수정하여 루트의 tsconfig를 상속받도록 수정합니다.
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"extends": "./../../tsconfig.base.json", //추가
"references": [{ "path": "./tsconfig.node.json" }]
}
루트의 tsconfig.base.json을 설정할 때 봤던 preserveSymlinks를 상속받아 해당 문제를 해결합니다.
(번외) 공통 스타일 토큰 사용하기
scss의 강점이라고 하면, 빼놓을 수 없는 것이 바로 mixin과 변수입니다.
모든 프로젝트에서 하나의 스타일 토큰을 import하여 사용하기 위해 common 하위에 shared 디렉토리를 구성했습니다.
별도의 설정은 필요하지 않습니다.
common/shared/var.scss
...
$temp1: aqua;
$temp2: crimson;
이후 각 프로젝트의 scss 최상단에서 해당 토큰을 import합니다.
vite의 경우 config를 이용해서 해당 과정을 자동화할 수 있습니다. 이 과정은 다음 글에서 보여드리겠습니다.
@import "../../../common/shared/var.scss";
.temp-1 {
background-color: $temp1;
color: $temp2;
}
이렇게 하나의 IP를 공유하는 서비스 내부에서, 다양한 가설을 세우고 도입하며 사용자의 반응을 검증할 수 있는 환경 세팅이 마무리 되었습니다.
모노레포를 자주 사용했음에도, RN과 같은 툴이 끼면서 꽤 오랜 시간 고민하게 되었습니다.
최근 PS만하면서 개발과 조금 멀어졌다는 느낌이 있었는데, 확실히 성장이 멈췄다고 느낄 때에는 도전만큼 좋은게 없는 것 같습니다.
당분간 리팩토링 과정에서 관련 아티클이 자주 업로드될 예정입니다.
👉 텀텀(termterm) 구글 플레이스토어 베타 테스터 신청
'개발 > frontend' 카테고리의 다른 글
[EAS-iOS Build] expo 빌드 + 제출 과정에서 발생하는 다양한 에러 대응 방법 (0) | 2024.05.05 |
---|---|
[에러 해결] Next.js에서 새로고침 시 404: This page could not be found 발생 원인과 해결 방법 (2) | 2024.01.04 |