CRA 없이 React 프로젝트 구성하기: 웹팩과 바벨 설정부터 SSR 준비까지

해당 글은 CRA를 사용하지 않고 CSR 방식의 React 프로젝트를 구성하는 방법을 설명합니다.

업로드 날짜: 2025년 2월 2일


❗️

해당 내용은 개인적으로 공부한 내용을 기록한 글입니다. 정확하지 않는 내용이 있을 수 있으며, 피드백은 언제나 환영입니다!

들어가며

cra

Create React App(CRA)은 React 프로젝트를 빠르게 시작할 수 있게 해주는 편리한 도구입니다. 하지만 이러한 편리함에 속아서 CRA에 의존하게 되면, CRA의 추상화된 설정들을 이해하지 못하고 사용하게될 수 있고 이러한 추상화된 설정들을 이해하지 못하면, 프로젝트에 필요한 커스터마이징이나 최적화 작업을 수행하기 어려울 수 있습니다. 따라서 이러한 추상화된 설정들을 이해하고, 필요에 따라 커스터마이징할 수 있도록 React 프로젝트를 처음부터 설정하는 방법을 알아보고자 하였습니다..!

더 나아가서 이러한 설정을 통해 서버 사이드 렌더링(SSR)을 적용하는 방법에 대해서도 알아보고자 합니다.. 해당 내용은 그 다음 글에서 다루도록 하겠습니다.

과정

1. 프로젝트 생성

먼저 새로운 Node.js 프로젝트를 시작합니다 🔥

npm init -y
2. 의존성 설치
React 관련 패키지

리액트를 사용하기 위해서 필수적으로 설치해야하는 패키지를 설치합니다!

npm install react react-dom
  • react: React의 핵심 라이브러리로, 컴포넌트를 정의하고 관리하는 기능을 제공
  • react-dom: React 컴포넌트를 실제 DOM에 렌더링하는 기능을 담당
TypeScript 관련 패키지 (devDependencies)

TypeScript를 사용하면 개발 단계에서 타입 안정성을 확보할 수 있습니다.

npm install -D typescript @types/react @types/react-dom
  • typescript: TypeScript 컴파일러. 실행시 ts 파일을 js 파일로 변환합니다.
  • @types/react, @types/react-dom: React, ReactDOM의 타입 정의
Babel 관련 패키지 (devDependencies)

Babel은 최신 JavaScript 코드 혹은 JSX를 브라우저가 이해할 수 있는 코드로 변환(트랜스파일링)해주는 도구입니다. React의 JSX 문법이나 TypeScript 코드를 JavaScript 코드로 변환하는 데 사용할 예정입니다.

npm install -D @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript babel-loader
  • @babel/core: Babel의 핵심 라이브러리
  • @babel/preset-env: ES6+ 코드를 ES5로 변환하는 Babel 프리셋
  • @babel/preset-react: JSX 코드를 React.createElement 호출로 변환하는 Babel 프리셋
  • @babel/preset-typescript: TypeScript 코드를 JavaScript 코드로 변환하는 Babel 프리셋
  • babel-loader: Webpack에서 Babel을 사용하기 위한 로더

babel은 어떤 원리로 변환하는걸까? 소스 코드를 변환하기 쉽게, 혹은 문맥을 이해할 수 있도록 AST라는 자료구조로 변환(Parsing)하는 과정을 거칩니다. 이후 문맥을 파악하면서 사전에 설정했던 플러그인 혹은 프리셋이 동작하며 목표로 하는 코드로 변환(Transformation)을 하게 됩니다. 이렇게 변환된 AST트리를 다시 코드로 변경(Generation)하여 최종적인 코드가 나오게 됩니다.

babel은 js로 만들어진 변환 도구입니다. 아무래도 js로 만들어지다보니 성능적으로 좋지는 않습니다. 이에 문제점을 느낀 천재적인 개발자들은 babel보다 더 빠른 변환 툴(트랜스파일러)을 만들게 되었습니다! 대표적으로 자랑스러운 한국 개발자분이 만든 SWC 가 있습니다.

Webpack 관련 패키지 (devDependencies)

Webpack은 모든 리소스를 모듈로 다루며, 이들을 하나로 묶어주는 번들러입니다.

npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin
  • webpack: 코어 모듈 번들러
  • webpack-cli: 커맨드 라인에서 웹팩 사용
  • webpack-dev-server: (선택) 번들 결과물이 아닌 메모리 내에서 동작할 수 있도록 하는 개발용 서버 제공
  • html-webpack-plugin: (선택) HTML 파일을 빌드 결과물에 자동으로 포함시키는 플러그인
3. 환경 설정
TypeScript 설정 파일 생성
//tsconfig.json { "compilerOptions": { // ECMAScript 목표 버전 설정 "target": "es5", // 사용할 라이브러리 정의 "lib": [ "dom", // Window, Document 등 브라우저 DOM API "dom.iterable", // DOM의 Iterator 관련 타입들 (forEach, entries 등) "esnext" // 최신 JavaScript 문법 (async/await, generator 등) ], // JavaScript 파일도 컴파일 허용 "allowJs": true, // JavaScript 파일의 타입 체크 스킵 "skipLibCheck": true, // import/export 와 require/module.exports 호환 "esModuleInterop": true, // default import 호환성 개선 "allowSyntheticDefaultImports": true, // 엄격한 타입 체킹 옵션 활성화 "strict": true, // 파일 이름의 대소문자 일관성 확인 "forceConsistentCasingInFileNames": true, // switch문에서 break 누락 시 에러 발생 "noFallthroughCasesInSwitch": true, // 모듈 시스템 설정 "module": "esnext", "moduleResolution": "node", // JSON import 활성화 "resolveJsonModule": true, // 각 파일을 독립적인 모듈로 처리 "isolatedModules": true, // TypeScript의 타입 체크만 수행하고 컴파일은 Babel이 담당 "noEmit": true, // JSX 처리 방식 설정 "jsx": "react-jsx" }, // 컴파일 대상 파일 위치 "include": ["src"] }

noEmit 옵션?

TypeScript는 컴파일 결과로 JavaScript코드를 뱉어내게됩니다. 이때 이 작업을 TypeScript-compiler가 담당할 수도 있고, Babel의 preset-typescript가 담당할 수도 있습니다. noEmit 옵션을 true로 설정하면 TypeScript-compiler가 컴파일 결과를 생성하지 않고, 타입 체크만 수행하게 됩니다. 이렇게 함으로써 Babel이 컴파일 결과를 생성하게 하여, TypeScript와 Babel을 함께 사용할 수 있습니다.

tsc vs preset-typescript

TypeScript-compiler(tsc)와 Babel의 preset-typescript은 TypeScript 코드를 JavaScript 코드로 변환하는 역할을 한다. 그렇다면 둘 중 어떤 것을 사용해야 할까요?

tsc는 TypeScript의 공식 컴파일러이기 때문에 가장 최신의 TypeScript 문법을 지원하고, 타입 체크를 가장 정확하게 수행할 수 있다는 특징이 있습니다.

Babel의 preset-typescript를 사용하는 이유는 Babel과 함께 사용할 때 편리하기 때문이다. Babel은 다양한 플러그인과 프리셋을 제공하기 때문에, TypeScript와 함께 사용할 때도 다양한 플러그인과 프리셋을 사용할 수 있다. 또한 Babel은 TypeScript의 타입 체크를 수행하지 않기 때문에, 컴파일 속도가 빠르다는 장점이 있습니다.

컴파일 측면에서 보면 tsc를 사용하면 타입 체크로 인해 컴파일 에러를 잡아낼 수 있으며, Babel을 사용하면 컴파일 단계에서 발생하는 에러가 있는 코드 또한 빌드가 가능하게 됩니다(타입을 제거하는 방식으로 동작). 이로 인해서 빌드 시간이 단축되지만, 런타임에 타입 에러가 발생할 수 있는 안정성의 문제가 존재하게됩니다. 따라서 Babel의 preset을 사용하고자 한다면, 빌드시 tsc --noEmit 을 번들 전에 사용하여 타입 체크를 수행하거나, CI/CD 파이프라인에서 타입 체크를 수행하는 방식을 통해서 안정성을 챙길 수 있습니다!

Babel 설정 파일 생성
{ "presets": [ "@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic" }], // runtime 옵션을 추가하면 React 17 이상에서 필요한 import 'reacct' 구분을 자동으로 추가 "@babel/preset-typescript" ] }
Webpack 설정 파일 생성
const path = require("path"); module.exports = { // 진입점 설정 entry: "./src/index.tsx", // 출력 설정 output: { path: path.resolve(__dirname, "dist"), filename: "bundle.js", }, // 모듈 해석 방식 설정 resolve: { extensions: [".tsx", ".ts", ".js"], }, // 로더 설정 module: { rules: [ { test: /\\.(ts|tsx)$/, exclude: /node_modules/, use: "babel-loader", }, ], }, };

추가적으로 편한 개발 환경을 만들기 위해서 webpack-dev-server를 사용할 수 있습니다. (필수적인 부분은 아닙니다!) 일반적으로 작성한 파일이 실제로 실행되기까지, 번들링을 수행하여 정적 파일을 만들고, 이를 서버에서 서빙을 해주고, 이를 브라우저에서 실행하는 과정을 거치게 됩니다. 이로 인해서 파일 변경이 일어나면, 이를 적용하기 위해서 전체 파일을 다시 번들링해 정적 파일을 만드는 과정이 필요합니다. 하지만 webpack-dev-server를 사용하면 번들링된 파일을 메모리에 저장하고, 이를 서빙해주기 때문에 파일을 변경하여도 번들링 과정을 수행하지 않아도 됩니다. dev server 사용 중 만약 파일이 변경되었다면, 이를 자동으로 감지하고 리빌드를 수행하여 변경된 모듈만을 교체하는 방식으로 동작합니다. 이 덕분에 매번 전체 파일을 다시 빌드하지 않아도 되어 개발 환경에서 빠르게 변경사항을 확인하고, 생산력을 높일 수 있습니다! 번들된 파일은 메모리에만 저장해두고, 이를 내장 서버로 서빙해주는 방식으로 구현이 됩니다. (실제로 CRA에서도 webpack-dev-server를 사용하고 있답니다!)

사용하고자 한다면 아래와 같이 설정을 추가해주면 됩니다.

npm install -D webpack-dev-server
{ // 기존 webpack 설정들... "devServer": { "port": 3000, "hot": true, "open": true } }
HTML 템플릿 파일 생성

SPA 구조의 CSR이 동작하는 방식은 아래와 같습니다.

  1. 사용자가 페이지에 접속한다.
  2. 서버는 비어있는 html 파일을 응답한다.
  3. 브라우저는 받은 html파일을 렌더링 한다. 그 과정에서 위에서부터 아래로 읽어가며 css, js와 같은 필요한 리소스를 요청한다.
  4. 서버는 요청받은 리소스를 응답한다.
  5. 브라우저는 받은 리소스를 렌더링한다. 렌더링이 완료되면 사용자가 페이지를 볼 수 있다.

이 때, 2번에서 서버가 응답하는 html 파일은 비어있는 상태입니다. 이는 사용자가 페이지에 접속했을 때, 서버에서 미리 렌더링된 페이지를 보여주는 것이 아니라, 빈 페이지를 보여주고, 이후에 js 파일을 통해 페이지를 렌더링하는 방식이기 때문입니다. 여기서는 브라우저가 받는 html 파일은 아래와 같이 구성하였습니다.

<!DOCTYPE html> <html lang="kr"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <div id="root"></div> </body> </html>

그리고, 우리가 작성한 리액트 코드(정확히는 작성한 코드들이 브라우저가 이해할 수 있는 js 파일로 변환된 코드들)리소스는 3번 절차에서 받아오게 됩니다. 이 때, 브라우저가 리소스를 받아올 때는 script 태그의 src를 통해서 파일을 다운로드 받습니다.

이 script태그를 직접 정적으로 작성하여도 좋지만, 이 경우 번들링된 파일의 위치나 이름, 번들 파일 개수등이 변경되었을 때, 매번 html 파일을 수정해주어야 하는 불편함이 있을 수 있습니다. 또한 실수를 범할 수도 있겠죠. 이를 해결하기 위해 html-webpack-plugin을 사용할 수 있습니다. 이는 번들링된 파일의 scripts 태그를 자동으로 추가해줍니다.

npm install -D html-webpack-plugin
const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { // 기존 webpack 설정들... plugins: [ new HtmlWebpackPlugin({ template: "./public/index.html", // html 템플릿 파일 경로 }), ], };
source-map 설정

개발 환경에서는 디버깅을 위해 source-map을 설정해주어도 좋습니다! 이는 빌드된 파일이 아닌 원본 파일을 디버깅할 수 있도록 도와주는 옵션입니다. 실제로 브라우저 개발자도구에서 코드를 볼 때 원래는 번들링된 코드를 보게 되는데, source-map을 설정해주면 번들링되기 전의 코드로 코드를 확인할 수 있습니다.

사용한다면 webpack 설정 파일에 아래의 값을 추가해주면 됩니다.

module.exports = { // 기존 webpack 설정들... devtool: "source-map", };
scripts 설정

마지막으로 package.json 파일에 스크립트를 통해서 번들링을 수행할 수 있도록 설정해주면됩니다.

"scripts": { "build": "webpack --mode production", "start": "webpack serve --mode development" },
4. React 코드 작성

간단히 예시 코드를 작성해보겠습니다. 전체 코드는 깃허브를 통해서 확인 가능합니다.

index.tsx 파일 생성

index.tsx 파일은 React 컴포넌트를 렌더링하는 역할만 하도록 하였습니다. React에는 ReactDOM.render 함수를 통해 컴포넌트를 렌더링할 수 있는데, 이 함수는 첫 번째 인자로 렌더링할 컴포넌트를, 두 번째 인자로 컴포넌트를 렌더링할 DOM 요소를 받습니다.

import React from "react"; ReactDOM.render(<렌더링할 컴포넌트 />, document.getElementById("root"));

여기서 단순히 ReactDOM.render를 사용해도 괜찮지만, react-dom/client의 createRoot 함수를 사용하면 Concurrent Mode(동시성 모드) 를 활성화할 수 있습니다.

참고로 CRA에서는 ReactDOM.render를 사용하고 있다.

동시성 모드 동시성 모드는 React 18에서 새롭게 도입된 기능으로, 렌더링 작업을 더 세분화하고 렌더 단계를 비동기로 병렬처리하여 성능을 향상시키는 기술이다.

// index.tsx import React from "react"; // import ReactDOM from "react-dom"; import { createRoot } from "react-dom/client"; const container = document.getElementById("root"); function main() { // ReactDOM.render(<div>Hello World</div>, root); if (!container) return; const root = createRoot(container); root.render(<div>Hello World</div>); } main();

코드를 작성한 이후 npm start를 해주게 되면 dev-server가 실행되고, 브라우저에서 http://localhost:3000 으로 접속하면 Hello World가 출력되는 것을 확인할 수 있습니다!

5. 번들링 및 실행

실제 서버에 리액트로 개발한 코드를 배포하는 경우에는 webpack-dev-server를 사용하지 않고, 번들링된 파일을 생성하고 서버에 해당 파일을 서빙하는 방식으로 동작합니다.

npm run build

만약 사용자가 우리의 서비스에 들어오게 되면 서버로부터 html 파일을 받게 되고, html 파일을 렌더링하면 번들링된 파일을 받아오게 됩니다. 이 지식을 바탕으로 실제 배포되는 환경을 구성해보겠습니다.

서버 코드는 아래처럼 작성이 가능합니다.

const express = require("express"); const fs = require("fs"); const app = express(); const html = fs.readFileSync("dist/index.html", "utf-8"); const bundle = fs.readFileSync("dist/bundle.js", "utf-8"); app.get("/", (req, res) => { res.send(html); }); app.get("/bundle.js", (req, res) => { res.send(bundle); }); app.listen(3000, () => { console.log("Server is listening on port 3000"); });

서버는 단순히 서비스에 접속시 dist/index.html 파일을 응답하고, dist/bundle.js 파일을 응답하도록 구현해두었습니다. 이제 서버를 실행하고, http://localhost:3000으로 접속하면 Hello World가 출력되는 것을 확인할 수 있습니다!!

node ./server/index.js

짱이죠?

다음 단계: SSR 구현하기

이번 포스트에서는 CSR 방식의 React 프로젝트 구성을 해보면서 간단히 React 프로젝트의 동작 방식에 대해 알아보았습니다. CSR이 가지는 여러가지 고질적인 문제들이 존재하는데, 이를 개선하기 위해서 SSR(Server Side Rendering)을 적용하기도 합니다.

이후에 React를 사용하여 이러한 SSR을 적용하는 방법에 대하여 글을 작성해보겠습니다!