[Webpack] 웹팩 개발 서버, API 연동 그리고 최적화
Web Frontend Developer

[Webpack] 웹팩 개발 서버, API 연동 그리고 최적화

지난번에 웹팩에 대한 기본적인 내용을 포스팅했고, 이번에서는 웹팩을 좀 더 개발 및 배포하는 과정에서 효율적으로 쓰기 위한 기능들을 정리해 보려고 한다. 참고로 이 내용은 인프런에서 <프론트엔드 개발환경의 이해>라는 김정환님의 강의를 듣고 정리한 것들을 기반으로 작성한다.

 

웹팩 개발 서버 (webpack dev server)

우리가 기존에 클라이언트 작업을 하고 나면 그 파일을 브라우저에 직접 로딩해서 결과물을 확인했다. 만약에 인터넷에 웹사이트로 배포를 하기 위해서는 해당 파일을 서버에서 읽고 요청한 클라이언트에 제공해야 한다. 이러한 운영 환경(production)과 개발 환경(development)을 유사하게 가져가게 되면 배포 시에 잠재적으로 생길 수 있는 문제들을 미리 찾을 수 있다는 장점이 있다. 또한 ajax 방식의 api 연동도 cors 정책 때문에 서버가 반드시 필요하다. 이러한 이유로 프론트엔드 개발환경에서 개발용 서버를 사용하는데 웹팩에서는 webpack-dev-server로 지원한다.

npm i -D webpack-dev-server 로 패키지를 설치한 후, webpack-dev-server로 명령어를 실행하면 로컬 호스트의 8080 포트에 서버가 구동된다. 소스코드를 수정하면 웹팩 서버는 파일 변화를 감지하고 웹팩 빌드를 다시 수행하여 브라우저를 리프레시한다.

웹팩 개발 서버는 웹팩 config 파일에서 devServer 프로퍼티로 설정할 수 있다. 각각의 옵션은 다음과 같은 역할을 한다.

// webpack.config.js:
module.exports = {
  devServer: {
    contentBase: path.join(__dirname, "dist"),
    publicPath: "/",
    host: "dev.domain.com",
    overlay: true,
    port: 8081,
    stats: "errors-only",
    historyApiFallback: true,
  },
}
  • contentBase: 정적 파일을 제공할 경로이다. 절대경로를 사용할 것을 권장한다.
  • publicPath: 브라우저를 통해 접근하는 경로이다. 기본값은 '/' 스트링 값이다.
  • host: 개발환경에서 도메인을 맞춰야 하는 상황에서 사용한다. 예를 들면 쿠키 기반 인증은 인증 서버와 동일한 도메인으로 개발환경을 맞추어야 한다.
  • overlay: 빌드시 에러나 경고를 브라우저 화면에 표시한다.
  • port: 개발 서버 포트 번호를 설정한다. 기본값은 8080.
  • stats: 메시지 수준을 정할 수 있다. 'none', 'errors-only', 'minimal','normal', 'verbose' 등이 있다.
  • historyApiFallBack: 히스토리 API를 사용하는 SPA 개발 시 설정한다. 404가 발생하면 index.html로 리다이렉트한다.

이 외에도 다양한 옵션이 있는데 이는 공식 문서를 참고하자.

 

API 연동

프론트엔드에서는 서버와 데이터를 주고 받기 위해 ajax를 사용한다. 개발환경에서 api를 구성하는 방법에 대해 알아본다.

devServer.before 속성은 웹팩 서버에 기능을 추가할 수 있다. 예를 들면 다음과 같이 API를 목업으로 만들어서 프론트엔드에서 사용할 수가 있다.

// webpack.config.js
module.exports = {
  devServer: {
    before: (app, server, compiler) => {
      app.get("/api/keywords", (req, res) => {
        res.json([
          { keyword: "apple" },
          { keyword: "banana" },
          { keyword: "carrot" },
          { keyword: "grape" },
        ])
      })
    },
  },
}

// 서버 구동 후 요청을 보내면 응답을 받는다.
curl localhost:8080/api/keywords

[{"keyword":"apple"},{"keyword":"banana"},{"keyword":"carrot"},{"keyword":"grape"}]

만약에 목업 api 작업이 많을 경우 connect-api-mocker 패키지를 설치해서 api 연동을 할 수도 있다. 이 패키지는 특정 목업 폴더를 만들어서 api 응답을 담은 파일을 저장한 후, 이 폴더를 api로 제공해 주는 기능을 한다.

// webpack.config.js:
const apiMocker = require("connect-api-mocker")

module.exports = {
  devServer: {
    before: (app, server, compiler) => {
      app.use(apiMocker("/api", "mocks/api"))
    },
  },
}

// mocks/api/keywords/GET.json
[
  { "keyword": "apple" },
  { "keyword": "banana" },
  { "keyword": "carrot" },
  { "keyword": "grape" }
]

app.use() 메서드는 익스프레스 객체에서 미들웨어 추가를 위한 범용 메서드이다. 이렇게 목업 미들웨어를 추가한 후 첫 번째 인자로 라우팅 경로를, 두 번째 인자로 응답으로 제공할 목업 파일 경로를 전달한다. 

 

지금까지는 목업 API를 연동했고, 이번에는 실제 API를 연동하는 과정을 살펴보자. 만약 서버를 구성하고 직접 api로 서버에 요청하면 에러가 나면서 CORS(Cross Origin Resouces Policy) 정책 때문이라는 메시지가 뜰 것이다. 브라우저는 보안상의 목적으로 브라우저가 최초로 접속한 서버가 아닌 다른 서버에서 자원을 요청하지 못하도록 하는 정책을 가지고 있다. 웹팩 개발 서버와 API 요청을 보내는 서버가 다른 서버이기 때문에 CORS에 의해 다른 서버에서 데이터를 가져오지 못하는 것이다.

이를 해결하기 위한 방법은 서버쪽, 클라이언트쪽 두가지이다. 먼저 서버쪽 해결방법은 해당 API 응답 헤더에 "Access-Control-Allow-Origin" : "*" 헤더를 추가하는 것이다. 그러면 브라우저에서 응답 데이터를 받을 수 있다.

// server/index.js
app.get("/api/keywords", (req, res) => {
  res.header("Access-Control-Allow-Origin", "*") // 헤더를 추가한다
  res.json(keywords)
})

두 번째로 클라이언트쪽 해결 방법은 웹팩 개발 서버에서 api 서버로 프록싱하는 것이다. 웹팩 개발 서버는 proxy 속성으로 이를 지원한다. 아래의 예제를 보면, 개발 서버로 들어온 모든 http 요청 중 /api 로 시작되는 것은 server.domain.com 으로 요청하도록 설정하였다. 그리고 나서 확인해 보면 정상적으로 동작함을 알 수 있다.

// webpack.config.js
module.exports = {
  devServer: {
    proxy: {
      "/api": "server.domain.com", // 프록시
    },
  },
}

 

핫 모듈

웹팩 개발 서버는 앞서 설명한 것 처럼 코드의 변화를 감지해서 전체 화면을 갱신한다. 하지만 어떤 상황에서는 전체 화면을 갱신하는 것이 좀 불편한 경우도 발생한다. 예를 들면 SPA에서는 브라우저가 데이터를 들고 있는데, 리프레시를 하면 모든 데이터가 초기화 되어버리기 때문에 번거로운 작업을 반복해야 할 수도 있다. 이러한 불편함을 해결하기 위해 전체 화면을 갱신하지 않고 변경한 모듈만 바꿔치기할 수 있는 기능이 핫 모듈 리플레이스먼트이다.

설정은 간단하게 devServer.hot 설정을 true로 하면 된다. 그리고 특정 모듈만 변화가 생겼을 때 감지하여 해당 모듈만 바꾸어 주고 다른 모듈은 그대로 두게 하려면 module.hot 객체를 생성하여 accept() 메서드를 사용하면 된다.

// webpack.config.js:
module.exports = {
  devServer = {
    hot: true,
  },
}

// src/controller.js
if (module.hot) {
  module.hot.accept("./view", async () => {
    view.render(await model.get(), controller.el) // 변경된 모듈로 교체
  })
}

위의 예제에서는 view 모듈이 변경되었을 때 해당 모듈만 교체하는 코드이다. 이렇게 될 경우 view 가 변경되었을 때 브라우저가 갱신되지 않고 view만 바뀌게 된다. 핫 로더는 style-loader, react-hot-loader, file-loader는 핫 모듈 리플레이스먼트를 지원한다.

 

최적화

코드가 많아질 경우 파일의 크기가 커지고, 브라우저 성능에 영향을 주게 된다. 따라서 웹팩에서 최적화 작업을 통해서 성능을 향상시키는 작업은 운영 모드에서는 필수적이다.

어플리케이션 전역변수인 process.env.NODE_ENV는 "development"로 개발 시에는 설정되어 있는데, 배포 시에는 이 변수값을 "production"으로 바꾸어 주게 될 때 자바스크립트 결과물을 최소화하기 위해 여러가지 플러그인이 자동으로 적용된다. 따라서 다음과 같이 웹팩 설정 파일과 명령어를 설정하여 개발 모드와 운영 모드에 맞게 변수값을 바꾸어 줄 수 있다.

// webpack.config.js:
const mode = process.env.NODE_ENV || "development" // 기본값을 development로 설정

module.exports = {
  mode,
}

// package.json
{
  "scripts": {
    "start": "webpack-dev-server --progress",
    "build": "NODE_ENV=production webpack --progress"
  }
}

 

또한 빌드 과정을 최적화 할 수 있는 방법을 웹팩에서는 제공하는데 바로 optimization 속성이다. 여러가지 플러그인이 있는데 CSS 파일의 빈칸을 없애는 압축 플러그인 optimize-css-assets-webpack-plugin과 자바스크립트를 난독화하고 debugger 구문을 제거하는 terser-webpack-plugin 두 개를 통해 최적화 작업을 한 웹팩 설정파일은 아래와 같다.

// webpack.config.js:
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimizer: mode === "production" 
      ? [
        new OptimizeCSSAssetsPlugin(), 
        new TerserPlugin({
          terserOptions: {
            compress: {
              drop_console: true, // 콘솔 로그를 제거한다
            },
          },
        })
      ] 
    : [],
  },
}

 

코드를 압축하는 것 이외에도 결과물을 여러개로 쪼개는 것도 브라우저 다운로드 속도를 높일 수 있다. 왜냐하면 큰 파일 하나를 다운로드 하는 것 보다 작은 파일 여러개를 동시에 다운로드하는 것이 더 빠를 수 있기 때문이다.

단순히 코드를 분리하는 것 보다, 중복되는 코드가 중복해서 쓰이지 않게 분리하면 좀 더 최적화 작업을 잘 수행할 수 있다. 예를 들면 여러 파일에서 중복해서 사용하는 모듈이 있다면 하나만 쓰이고 나머지는 쓰이지 않게 해줄 수 있는데, 이 때 사용되는 플러그인이 SplitChunkPlugin이다.

// webpack.config.js:
module.exports = {
  optimization: {
    splitChunks: {
      chunks: "all",
    },
  },
}

이러한 방법은 엔트리 포인트를 적절하게 분리해야 하기 때문에 번거로울 때도 있다. 이를 해결하기 위해 자동으로 변경해 주는 방식도 있는데 이를 다이나믹 임포트(dynamic import)라고 한다. 기존의 방법대로라면 import 로 모듈을 불러와서 사용하지만, 다이나믹 임포트를 적용하면 getController() 함수에서 컨트롤러 모듈을 웹팩이 처리하는 청크 형태로 처리한 후 프로미스 타입으로 반환하며 이 함수를 가져다가 사용한다. 이 경우 SplitChunksPlugin 옵션을 제거한다.

// 동적 임포트 적용 전
import controller from "./controller"

document.addEventListener("DOMContentLoaded", () => {
  controller.init(document.querySelector("#app"))
})

// 동적 임포트 적용 후
function getController() {
  return import(/* webpackChunkName: "controller" */ "./controller").then(m => {
    return m.default
  })
}

document.addEventListener("DOMContentLoaded", () => {
  getController().then(controller => {
    controller.init(document.querySelector("#app"))
  })
})

 

마지막 최적화 방법으로 이미 빌드 과정을 거친 패키지를 빌드에서 제외하는 externals 속성이 있다. axios와 같은 써드파티 라이브러리는 중복해서 빌드해 줄 필요가 없기 때문에 CopyWebpackPlugin을 사용하여 axios 파일을 node_modules에서 웹팩 아웃풋 폴더로 옮기고 index.html에서 로딩하는 코드를 추가해 준다.

const CopyPlugin = require("copy-webpack-plugin")

module.exports = {
  externals: {
    axios: "axios",
  },
  plugins: [
    new CopyPlugin([
      {
        from: "./node_modules/axios/dist/axios.min.js",
        to: "./axios.min.js", // 목적지 파일에 들어간다
      },
    ]),
  ],
}

<!-- src/index.html -->
  <script type="text/javascript" src="axios.min.js"></script>
</body>
</html>

 

웹팩을 통해 프론트엔드 개발 환경을 좀 더 편리하게 가져갈 수 있는 방법들에 대해서 이번 포스팅에서 알아보았다. 웹팩에 대해서는 앞으로도 계속해서 공부하고 또 실무에서 자주 써보면서 익숙해 져야 할 필요성이 느껴지는 시간이었다.

 

참고자료