프로젝트 빌드에 대해
프로젝트 빌드란 뭘까요? 번들링과 트랜스파일링, CI/CD 등이 왜 필요한지 이해해봅니다

프로젝트 빌드
프로젝트 빌드는 번들링, 트랜스파일, 최적화 등을 하는 과정을 말합니다. 왜 이러한 과정이 필요할까요? 옛날 이야기를 하나 보죠.

롤러코스터 타이쿤2는 어셈블리어로 개발된 게임입니다. 어셈블리어는 기계어에 매우 가까운 저수준 언어입니다.

그 시절에도 어셈블리어로 게임을 제작하는 일은 드물었습니다. 성능은 좋지만 사람이 읽고 쓰기엔 어렵기 때문입니다. 롤러코스터 타이쿤2 개발자가 대단하다는 말을 듣는 이유입니다.
고수준일수록 사람에 친화적이고 저수준일수록 기계 친화적입니다. 즉, 사람이 읽기 쉬울수록 기계는 어려워합니다.
번들링
우리가 보는 코드는 사람 친화적 코드입니다. 의미있는 변수 이름, 가독성을 위한 띄어쓰기, 세세한 폴더 분류, 클린 코드...중요합니다. 하지만 브라우저한텐 어떨까요?

브라우저 입장에서 위 구조는 그저 난잡합니다. 의존성이 여기저기 엮여있어 불러오는 비용이 낭비될 뿐입니다. 그래서 브라우저가 읽기 편하도록 재분류하는 번들링 작업이 필요합니다.

이런 구조는 어떨까요? 코드량과 복잡도가 늘어 개발자는 읽기 어렵겠지만 브라우저에겐 편합니다.
그림 1을 그림 2로 재분류하는 작업이 번들링이며, 정리된 결과물 단위를 번들이라고 부릅니다.
대부분의 번들링 도구는 번들링, 최적화, 경량화, 트랜스파일링, 코드 스플리팅 등의 작업을 같이 수행합니다.
코드 스플리팅
그렇다면 번들은 몇 개로 만들어야 좋을까요? 적을수록 브라우저가 읽기 쉬우니 아래 그림 오른쪽처럼 한 개면 될까요?

번들 수가 적을수록 번들 당 용량은 커집니다. 브라우저에게 있어 자바스크립트는 렌더링 방해 자원입니다. 번들 용량이 클수록 초기 로딩 속도는 느려집니다.
따라서 필요한 것을 필요할 때에 불러야 효과적입니다. 번들링은 파일을 줄이되, 적절히 여러 개로 나눌 필요가 있습니다.
예를 들면 쇼핑몰에서 "거래 완료 페이지"와 "메인 페이지"는 코드를 같이 둘 필요 없으니 독립된 번들로 나눕니다. 그림 왼쪽과 같이 번들을 여러 개로 나누는 처리를 코드 스플리팅이라고 합니다.
코드 스플리팅 방법
자바스크립트 프레임워크들은 자동으로 코드 스플리팅을 합니다. 예를 들면 Next.js는 pages 폴더(14버전 이후는 app 폴더)에 작성한 코드들을 자동으로 스플리팅합니다. pages의 파일들은 각각의 URI로 라우팅되기 때문에 같은 번들에 묶을 필요가 없기 때문입니다.
위에 든 예시처럼 거래 완료 페이지와 메인 페이지는 서로 독립적인 페이지이므로 코드 스플리팅을 자동 처리해주는 게 더 편리합니다.
코드 스플리팅은 수동으로 지정할 수도 있습니다. 예를 들면 동적 import는 코드 스플리팅 대상입니다.
또한 터미널에서 Next.js 코드를 실행하면 코드 스플리팅을 볼 수 있습니다.
- ready started server on 0.0.0.0:3000, url: http://localhost:3000
- info Loaded env from /Users/projects/beermap-client/.env.development
- info Disabled SWC as replacement for Babel because of custom Babel configuration ".babelrc.json" https://nextjs.org/docs/messages/swc-disabled
- event compiled client and server successfully in 2.5s (26 modules)
- wait compiling...
- event compiled client and server successfully in 857 ms (20 modules)
- wait compiling /page (client and server)...
- info Using external babel configuration from /Users/projects/beermap-client/.babelrc.json
- event compiled client and server successfully in 11.7s (494 modules)
- 로컬 url로 서버가 열리고 3000번 포트로 수신합니다.
- .env.development에서 개발 환경 변수를 읽어옵니다.
- .babelrc.json 파일이 있으므로 기본 SWC 대신 바벨로 컴파일합니다.
- 번들이 복수이므로 여러 차례에 나눠 컴파일을 수행합니다. 이 때 작업에 연관되는 모듈의 수는 제각각이며 작업 시간도 각자 다릅니다. (코드 스플리팅)
트랜스파일링
브라우저는 타입스크립트나 리액트가 아닌, 오로지 HTML, CSS, JS만 실행하므로 프레임워크, 라이브러리로 만든 코드는 HTML, CSS, JS로 파일 변환해야 합니다. 이것이 트랜스파일링입니다. 보통은 빌드 때 번들링과 트랜스파일링이 같이 처리됩니다.
최신 문법은 브라우저마다 지원 현황이 다르기 때문에 모두와 호환되는 이전 버전의 문법으로 변환할(필요하다면 폴리필을 만들) 필요도 있습니다. 이러한 것도 트랜스파일링 과정에서 수행됩니다.
타입스크립트의 트랜스파일링
tsconfig 파일에선 컴파일 옵션을 지정할 수 있습니다. 이 때 컴파일은 타입스크립트를 자바스크립트로 바꾸는 트랜스파일링입니다.
"compilerOptions": {
"target": "es2016", // 결과물로 만들 자바스크립트의 ECMAScript 버전.
"module": "CommonJS", // 모듈 시스템 지정.
"strict": true, // 모든 타입 체킹 옵션 활성화.
"esModuleInterop": true, // ESM과의 호환성을 높이는 옵션. CommonJS 모듈을 ESM처럼 사용 가능합니다.
"forceConsistentCasingInFileNames": true, // 파일명의 대소문자가 일치하지 않으면 오류를 발생시킵니다.
"outDir": "dist", // 컴파일 결과물 저장 폴더. dist는 distribution(배포)의 줄임말입니다.
"declaration": true, // 컴파일 후 d.ts(타입 정보를 담은 파일)을 생성합니다.
"declarationDir": "types", // d.ts를 저장할 폴더 이름.
"rootDir": "."
}
보통 dist폴더에 컴파일 결과를 저장합니다. 만약 위 옵션을 가지고 npx tsc
를 실행하면 dist 폴더와 types 폴더가 생성됩니다.
개발자는 타입스크립트로 개발하고, 브라우저는 배포 파일의 dist에서 자바스크립트를 사용합니다. 개발할 땐 dist라는 폴더에서 import한 게 없으므로 package.json에서 실행 파일을 지정합니다.
// package.json
{
"name": "project",
"version": "1.0.0",
"main": "dist/index.js",
"types": "types/index.d.ts"
}
자바스크립트의 트랜스파일링
타입스크립트 -> 자바스크립트 컴파일은 평범한 자바스크립트로 바꾸지 않습니다. 예를 들면 아래 코드는 코드는 사람이 읽기 쉬운 자바스크립트입니다.
window.addEventListener("DOMContentLoaded", Selection);
window.addEventListener("load", () => {
const settingButton = document.querySelector(".mode__setting");
const themeButton = document.querySelector(".mode__theme");
const muteButton = document.querySelector(".mode__mute");
이대로 배포할 경우 다른 사람도 읽기 쉽고, 보안에 취약해집니다. 또한 사람이 읽기 편한 구조이므로 브라우저가 읽기엔 불편합니다. 그래서 실제 트랜스파일링을 하면 아래와 같이 변환됩니다.
(()=>{"use strict";var t,e,n=function(t){t.classList.add("focus"),t.focus()},r=function(t){t.classList.remove("focus"),t.blur()},o=document.getElementById("project"),i=(t=o,e=window.innerHeight,t.offsetTop-(e-t.offsetHeight)/2);const a=function(t,e){switch(t){case"about":scrollTo(0,0);break;case"project":scrollTo(0,i),e&&function(t){n(t.querySelector(".focusable"))}(o)}};function c(t){return function(e){for(var n=arguments.length,r=new Array(n>1?n-1:0),o=1;o<n;o++)r[o-1]=arguments[o];t.addEventListener("click",(function(t){t.preventDefault(),e.apply(void 0,r)})),t.addEventListener("keydown",(function(t){"Enter"===t.key&&(t.preventDefault(),e.apply(void 0,r))}))}}var s=!0,u=function(t){f(t),t.muted=s}
브라우저가 읽는 것에만 집중하기 때문에 개발자가 읽기 어렵습니다. 띄어쓰기도 없고 변수명도 축소, 난수화하며 ECMAScript 문법도 개발자가 설정한 버전에 맞춰 바꿉니다.
번들링 도구
흔히 웹팩이 쓰였습니다만 근래에 Vite가 주목을 받습니다. 빌드 속도가 웹팩보다 압도적으로 빠르기 때문에 개발 비용을 유의미하게 개선하기 때문입니다. 결과물이 비슷하다면 훨씬 빠른 쪽을 쓰는 게 이득이니까요.
CI/CD
빌드에 대해 간략하게 살펴봤으니 CI/CD도 살펴보겠습니다.
CI/CD는 연속되는 통합/연속되는 배포(Continuous Integration/Continuous Deployment)라는 단어입니다.
먼 옛날에는 변경 사항이 일어날 때마다 개발자가 직접 빌드, 테스트, 배포했습니다. 배포 중에 오류라도 나면 서비스를 정지하고 롤백해야 하는 수도 있습니다. 이는 서비스 공급자와 소비자 모두의 사용 경험을 저하시켰습니다.
CI/CD는 변경 사항을 통합/배포하는 과정에서 서비스 제공이 연속적이란 얘기입니다. 성공하면 배포하고, 실패하면 기존 버전으로 유지합니다. 개발자는 실패에 대한 배포 리스크를 지지 않아도 됩니다.
이러한 CI/CD는 도구를 통해 설정과 파이프라인을 만들기만 하면 됩니다. 변경 사항을 등록하면 자동으로 빌드, 테스트, 배포, 에러 처리를 하기 때문에 개발자가 수동으로 처리하던 비용이 획기적으로 감축됩니다.
name: Node.js CI
on:
push:
branches: ["main", "develop"]
pull_request:
branches: ["main", "develop"]
jobs:
build-and-test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [18.x]
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- if: steps.npm-cache.outputs.cache-hit == 'true'
run: echo 'npm cache hit!'
- if: steps.npm-cache.outputs.cache-hit != 'true'
run: echo 'npm cache missed!'
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Update NPM
run: npm install -g npm@latest
- run: npm ci
- run: npm run build --if-present
- run: npm run test:ci
- name: Publish to Chromatic
uses: chromaui/action-next@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
위와 같은 yaml 파일을 프로젝트에 설정하고 작업 변경을 시행하면 버전 관리 도구는 주어진 환경에 맞춰서 빌드, 테스트한 다음에 모두 성공할 때에만 변경 사항을 서비스에 적용합니다.
위 예제는 GitHub Actions의 CI 설정입니다. 배포는 Vercel로 했기 때문에 CD 설정은 Vercel에서 할 수 있습니다.
결론
일반적인 개발자가 회사에 들어가서 CI/CD 파이프라인 조정, 빌드, 트랜스파일링 등에 대해 작업할 일은 별로 없겠습니다만, 저처럼 사이드 프로젝트나 개인 프로젝트를 진행할 땐 기본적인 내용 정도는 알고 한다면 좋습니다.