들어가며
현재 웹 메모 프로젝트는 모노레포를 사용하고 있다. shared, ui같은 공통 패키지가 있고 실제로 서비스를 개발하는 web 패키지가 있다. web패키지에서는 공통 패키지를 가져다가 사용한다. 모노레포를 운용하며 마주한 문제점과 이를 해결한 과정의 기록이다.
문제점 : 공통 패키지를 수정하면, web에 반영되기까지 너무 오래 걸린다.
web패키지를 개발하던 중, 공통 패키지를 수정하면 이를 web패키지에 적용하는 데까지 너무 오래 걸리는거다.
시간을 측정해보니 16초가 걸렸다.
수정 후 적용하는데 까지의 과정을 살펴보면
- ui패키지(공통 패키지)를 수정한다.
- ui패키지를 빌드한다.
- web패키지를 리로드한다.
- 변경된 ui패키지가 적용된다.
ui패키지 빌드와 web패키지 리로드라는 두 가지의 적용 과정이 포함되어있다. 또한 web패키지 내의 코드를 수정했을 때 HMR(Hot Module Replacement)을 적용하는 것과 달리 새로고침하는 것을 확인할 수 있다. 빌드된 파일이 수정되어서 Next.js에서 변경된 것을 파악하지 못해 아얘 새로고침을 하는 것으로 추정이 된다.
Compiled Packages 방식의 한계
현재 방식을 살펴보자. 현재 방식은 TurboRepo에서 이야기하는 Compiled Packages 방식이다. 이 방식은 컴파일된(빌드된) 패키지를 가져다가 사용하기 때문에 TypeScript패키지가 아니라 JavaScript패키지에서도 사용이 가능하다는 장점이 있다. 하지만 위와 같이 파일을 변경할 경우 패키지를 빌드하고, 사용하는 측에서도 리로드를 한다는 문제점이 존재했다.
그럼 조금 다른 접근은 어떨까? TurboRepo 공식 문서를 더 살펴보다 보니 Just In Time Packages 방식이라는 게 있더라. 이 방식은 패키지를 따로 빌드하지 않고, 이 패키지를 사용하는 측에서 알아서 컴파일을 하는 방식이다. 즉, web패키지 안에서 패키지를 관리하는 것과 크게 다를바가 없다. 파일의 위치가 다른 패키지 내에 있을 뿐인 것이다. 지금 문제점이 두 번의 빌드(해당 패키지에서 빌드, 웹에서 HMR)가 일어난다는 점이니까, Just In Time Packages방식이라면 이 문제를 해결해줄 수 있지 않을까?
내가 생각한 방식이 맞는지 체크를 해보기 위해 검색해본 결과, 나와 동일한 현상을 겪은 분의 아티클을 발견했다. 이 글은 Compiled Packages에서 Just In Time Packages로 마이그레이션을 했으며 효과가 좋았다는 내용의 글이었다. 이 글을 읽고 조금 더 확신을 가지고 행동으로 옮길 수 있었다.
해결 과정
Just In Time Package 방식은 패키지를 사용하는 측에서 컴파일을 하는 방식이다. 이를 위해서 빌드 과정을 제거했다.
- 빌드 파일을 제거하고
- 빌드 스크립트도 제거했다.
- 그리고 패키지를 사용하는 측에서 바라보는 파일의 경로를 변경했다. 기존에는 빌드된 폴더인 dist를 바라봤더라면, 변경된 방식은 빌드되지 않은 파일인 src폴더를 바라보도록 했다.
global.css를 기존에는 빌드 과정에서 복사한 dist폴더 내에 global.css를 가져다 사용했는데, 이제는 빌드 과정과 dist폴더가 없어졌으니 exports필드에서 분기하여 따로 명시해주었다.
결과
Compiled Packages 방식에서 Just In Time Packages로 마이그레이션한 이후의 영상이다.
그 결과 변경된 파일이 반영되는 시간이 기존 16초에서 4초로 단축시킬 수 있었다. 4배나 빨라진 셈이다. 이제 ui 컴포넌트를 수정하고 결과를 확인하는 게 훨씬 쾌적해졌다.
문제점 : 공통 패키지에서 사용하는 환경 변수를 사용할 수 없다.
그런데 Just In Time Packages 방식으로 전환하고 나니 예상치 못한 문제가 하나 생겼다. 바로 환경 변수다.
기존에는 shared 공통 패키지에서 빌드를 할 때 실제 환경 변수를 주입해주었다. 즉 shared 패키지 내의 .env파일의 환경 변수를 빌드 과정에서 주입해주었다. 그럼 shared 공통 패키지를 사용하는 측에서는 이미 환경 변수가 주입이 되었기 때문에 사용하는데에 문제가 없었다. 그런데 빌드를 하지 않고 직접 가져다가 사용하기 때문에 환경 변수가 process.env.xx 그대로 전달이 되어 문제가 생긴 것이다.
처음에는 "그냥 web 패키지에서 환경 변수를 전달해주면 되지 않나?" 싶었는데, 그렇게 하면 각 패키지마다 같은 환경 변수를 중복으로 관리해야 한다. 이건 유지보수 측면에서 좋지 않다고 판단했다.
고민 끝에 내린 결론은 환경 변수만을 담당하는 패키지를 별도로 만드는 것이었다. 이 패키지는 Compiled Packages 방식으로 빌드해서 환경 변수를 주입한 뒤, 다른 패키지에서 이를 가져다 쓰는 구조다.
해결 과정
빌드 도구로는 간단한 설정으로 TS패키지를 빌드할 수 있는 tsup을 활용했다. tsup은 간단한 설정과 더불어 esbuild기반으로 만들어졌기에 빌드 속도가 빠르다.
- 패키지를 만든다.
// package.json
{
"name": "@extension/env",
"version": "1.0.0",
"description": "",
"keywords": [],
"author": "",
"license": "ISC",
}
- 빌드를 위해 tsup을 설치한다.
pnpm add tsup --filter @extension/env
- 빌드할 파일을 구성한다.
// src/index.ts
export * from "./config";
// src/config.ts
export const ENV = {
webUrl: getSafeConfig("WEB_URL", process.env.WEB_URL),
supabaseUrl: getSafeConfig("SUPABASE_URL", process.env.SUPABASE_URL),
// ..
};
여기서 getSafeConfig는 환경 변수가 없을 때 에러를 던지거나 기본값을 반환하는 헬퍼 함수다. 런타임에 환경 변수가 없어서 생기는 문제를 빌드 타임에 미리 잡아낼 수 있어서 유용하다.
- tsup을 설정한다.
// tsup.config.ts
import dotenv from "dotenv";
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
sourcemap: true,
clean: true,
env: dotenv.config().parsed,
dts: true,
format: ['esm', 'cjs'],
})
환경 변수 주입을 위해 dotenv를 import하고, 파싱된 결과를 env에 전달한다. 그럼 환경 변수 파일 .env에 있는 정보는 빌드 과정에서 객체 형태로 tsup에 전달이 될 것이다. 이렇게 하면 빌드된 파일에는 process.env.WEB_URL 같은 코드 대신 실제 값이 하드코딩되어 들어간다.
- 이 패키지를 사용하는 측을 위해 exports필드를 명시한다.
// package.json
{
"exports":{
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require":"./dist/index.js"
}
},
}
tsup 설정에서 dts옵션을 활성화해주어서 index.d.ts에 선언 파일이 자동으로 생성이 될 것이다. types에는 이 경로를 명시해주고, tsup 설정에서 format 옵션에 esm과 cjs모두 명시해주었기 때문에 .mjs(esm), .js(cjs) 파일이 모두 생성될 것이며 이를 각각 import와 require에 명시한다.
- 이 패키지를 각 패키지에서 설치해 사용한다.
// package.json
{
// ..
"dependencies":{
// ..
"@extension/env": "workspace:*"
}
}
import { CONFIG } from "@extension/env";
이렇게 하니까 환경 변수 관리가 한곳에서 이뤄지고, 다른 패키지에서는 그냥 가져다 쓰기만 하면 된다. 환경 변수를 추가하거나 수정할 때도 한 군데만 고치면 된다.
맺으며
16초가 4초가 됐다. 숫자로는 75%지만 체감은 그 이상이다. 이제 버튼 색 하나 바꾸고 16초를 기다리는 일이 없다. 환경 변수 문제도 별도 패키지 분리로 해결했다.
남은 아쉬움이 하나 있다면, Just In Time Packages 방식은 개발 경험은 향상시켰지만, TypeScript 기반 환경에서만 사용할 수 있다는 제약이 있다. 만약 향후 JavaScript 기반 프로젝트에서 공통 패키지를 사용해야 한다면 다시 Compiled Packages 방식을 고려해야 할 수도 있을 것이다. 혹은 모노레포 내에서는 지금처럼 Just In Time Packages 방식을 사용하고, package.json의 publishConfig 설정을 통해 모노레포 외부에서 사용하면 Compiled Package 방식을 사용할 수도 있을 것이다.
환경 변수 패키지를 별도로 분리하면서 패키지 간 의존성이 하나 더 생겼다. 이는 복잡성 증가로 이어질 수 있지만, 환경 변수 관리가 명확해지고 각 패키지의 역할이 더 분명해졌다는 장점이 더 크다고 판단했다. 실제로 개발하면서 "이 환경 변수 어디서 관리하지?"라는 고민을 할 필요가 없어졌다.
이번 건은 카카오엔터 글 하나가 확신을 준 케이스였다. 막연히 '이거 아닐까' 싶을 때 같은 문제를 겪은 사람의 글을 찾아 검증하고 움직이는 것, 이 습관은 계속 가져가야겠다.
tsup, 진짜 편하더라
tsup을 사용하며 느낀 점은 "와.. 진짜 편하고 빠르다"였다. 이전에 사용하던 RollupJS는 ESM, CJS 각각 파일 위치를 설정하고 TypeScript Loader, Terser 등 필요한 각종 플러그인을 설치하는 번거로운 과정을 거친 후에야 빌드할 수 있었다. 설정 파일도 길고 복잡했다.
그에 반해 tsup은 원하는 설정에 true를 표시하면 알아서 빌드를 해준다. esbuild 기반이라 속도도 놀라울 정도로 빠르다. 이번 env 패키지처럼 간단하게 TypeScript를 빌드해야 한다면 tsup은 안성맞춤이라는 생각이 들었다. 다만 프로젝트 규모가 커지고 복잡한 설정이 필요한 경우에도 tsup이 적합할지는 아직 확신이 없다. 커스터마이징이 필요한 경우에는 여전히 Rollup이나 Webpack 같은 도구가 더 나을 수도 있겠다는 생각이 든다.
결국 도구는 많이 알아두고 상황 봐서 고르면 되는 거였다.