All Articles

Webpack

이번 글은 2020년 1월 15일에 서울대 SKT연구소에서 T아카데미 주관으로 진행된 <모던 프론트엔드 개발 환경의 이해>(강사: 김정환 님)라는 세미나의 필기 내용이다. 작성을 위해 김정환 님의 블로그 내용도 참고했으나, 세미나 때 언급된 내용 위주로 정리하여 블로그와 내용이 동일하진 않다.

Webpack을 반드시 써야 하나?

웹팩이 여러 파일(모듈)을 하나의 파일로 합쳐주는 번들러(bundler)라는 것은 다들 알 것이다. 근데 꼭 하나로 합쳐야 할까? 모듈시스템으로 module.exportsrequire를 통해 여러 파일들이 서로 엮여있는 채로 써도 될 것 같은데 말이다. 하지만, 모든 브라우저에서 모듈시스템을 지원하는 건 아니다. 그러므로 우리가 작성한 코드(모듈시스템이 포함된)를 제대로 작동하게 하려면 웹팩으로 빌드를 할 수밖에 없는 것이다.

웹팩을 사용하면 js뿐만 아니라 이미지 파일 등도 모두 모듈로 인식해서 require로 불러올 수 있게 된다.

Webpack 간단히 알아보기

Webpack 설치

npm install -D webpack webpack-cli로 설치하자.

주요 개념들

참고로 웹팩에서의 주요 개념은 다음과 같다. 뭔가 실질적인 기능을 하는 녀석들은 loader와 plugin인데, 참고로 나는 둘 다 hook스러운 기능이라고 느껴졌다.

  • Entry : 진입점
  • Output : 결과물
  • Loaders : 번들링되기 전 파일 단위로 뭔가를 처리하기 위한 것
  • Plugins : 번들링 된 결과물에 뭔가를 처리하기 위한 것
  • Mode : “development”, “production”, “none” 중 선택

간단하게 빌드해보기

node_modules/.bin/webpack --mode development --entry ./src/app.js --output dist/main.js와 같이 명령어를 쓴다면, “‘개발 모드’로, ‘./src/app.js’를 엔트리 포인트인 모든 모듈들을 찾아서 하나의 js파일 ‘dist/main.js’로 만들어라.”라는 뜻이다.

번들을 했으면 html파일의 script부분의 경로를 수정해주면 된다.

Webpack 설정 파일 작성하기

webpack.config.js이라는 파일을 루트 디렉토리에 생성하고 아래와 같이 작성하면 위 ‘간단하게 빌드해보기’와 같은 결과를 얻을 수 있다.

const path = require('path');

module.exports = {
    mode: 'development',
    entry: {
        main: './src/app.js'
    },
    output: {
        filename: '[name].js',
        path: path.resolve('./dist'),
    },
}
  • 참고로 여기서 main이라는 키에 이름을 넣으면 그 main이라는 게 [name]에 들어간다. 물론 [name].js 대신 main.js라고 써도 되지만 이렇게 쓰는 이유는, 다중 엔트리포인트를 쓰는 경우 유용해지기 때문이다. output의 filename을 한 줄만 써줘도 알아서 2개의 output이 만들어진다.
  • path는 절대경로를 넣어야 하는데, 그러다 보니 path.resolve()를 쓰게 된 것이다.
  • 보통 package.json의 scripts에서 "build": "./node_modules/.bin/webpack"와 같이 build 부분에 웹팩 실행 명령어를 넣어두고 npm run build와 같이 간단하게 빌드를 진행한다.

Loader

웹팩을 쓰면, css파일까지도 js로 관리할 수 있다. 만일 아무런 설정없이 임포트된 css를 가진 채 빌드하면, 에러가 난다. 이는 css-loader를 통해 해결 가능하다. 웹팩을 이용해 빌드한 output의 js파일 내에는 css코드까지 포함되어 있게 된다. js파일이 아닌 무언가를 모듈로 인식할 수 있게끔 만들어주는 게 loader의 주 역할이다. 내가 느끼기에 로더는, 번들을 하기 전에 파일마다 뭔가를 할 수 있게 만들어주는 전처리기의 느낌이 난다.

Loader 설치 및 사용

  1. Loader들도 npm을 통해 설치해줘야 쓸 수 있다. css-loader라면 npm install -D css-loader
  2. webpack.confing.js파일에서 로더를 설정한다.

    // webpack.config.js
    module.exports = {
        module: {
            rules: [{
                test: /\.css$/, // .css 확장자로 끝나는 모든 파일을 대상으로
                use: ['css-loader'], // css-loader를 적용하기
            }]
        }
    }
    • 참고로, use를 사용하여 배열을 넣어줘도 되고, loader를 사용하여 문자열 형태로 하나만 써줘도 된다.
  3. 그럼, 웹팩은 entry point부터 시작해서 모듈을 검색하다가 CSS 파일을 찾으면 css-loader로 처리하게 된다.

자주 사용하는 로더

  1. css-loader : css파일을 js에서 임포트 가능하게 해준다.(위 예시에서 보여줬듯이)
  2. style-loader : css-loader만으로는 자바스크립트 코드로만 변경시켜줄 뿐 DOM에 적용되지 않는다. style-loader는 자바스크립트로 변경된 스타일시트를 동적으로 DOM에 추가하는 로더이다. 따라서, CSS를 번들링하기 위해서는 css-loader와 style-loader를 함께 사용한다.

    • 둘을 동시에 사용하려면, 아래와 같이 배열에 둘 다 넣는다. 참고로 로더의 실행순서는 역순이니까 주의하라!
    // webpack.config.js
    module.exports = {
        module: {
            rules: [{
                test: /\.css$/,
                use: ['style-loader', 'css-loader'], // style-loader를 앞에 추가한다 
            }]
        }
    }
  3. file-loader : file을 모듈로 가져오게 해준다.

    // webpack.config.js
    module.exports = {
        module: {
            rules: [{
                test: /\.png$/, // .png 확장자로 마치는 모든 파일
                loader: 'file-loader',
                options: {
                    publicPath: './dist/', // prefix를 아웃풋 경로로 지정 
                    name: '[name].[ext]?[hash]', // 파일명 형식 
                }
            }]
        }
    }
    • 참고로, 이렇게 하면 이미지의 파일명이 해시코드로 변경되어 dist 디렉토리 하위에 복사된다.
    • 해시코드가 쓰이는 이유는 캐싱을 방지하기 위함으로 보인다.
    • dist 하위로 이동했으므로 이미지 로딩에 실패할 것인데, 옵션을 조정해서 경로를 바로잡아주기 위해 options를 추가한 것이다.
  4. url-loader : Data URI Scheme(이미지를 base64로 인코딩하여 문자열 형태로 소스코드에 넣는 형식)이 더 성능에 좋을 때가 있는데, 이러한 처리를 자동화해준다.

    // webpack.config.js
    module.exports = {
        module: {
            rules: [{
                test: /\.png$/,
                use: {
                    loader: 'url-loader', // url 로더를 설정한다
                    options: {
                        publicPath: './dist/', // file-loader와 동일
                        name: '[name].[ext]?[hash]', // file-loader와 동일
                        limit: 5000 // 5kb 미만 파일만 data url로 처리 
                    }
                }
            }]
        }
    }
    • 빌드 결과를 보면 작은 이미지 파일들이 문자열로 변경되어 있는 것을 확인할 수 있다. 반면 5kb 이상인 이미지 파일들은 여전히 파일 형태로 존재하게 된다.
    • 아이콘처럼 용량이 작거나 사용 빈도가 높은 이미지는 파일을 그대로 사용하기 보다는 Data URI Scheme을 적용하기 위해 url-loader를 사용하면 좋다고 하니 참고하자! 이미지파일을 로딩하는 횟수가 너무 많아지는 걸 방지하자는 것이다.

Plugin

로더가 파일 단위로 처리하는 반면 플러그인은 번들된 결과물을 처리한다. 번들된 자바스크립트를 난독화 한다거나 특정 텍스트를 추가해주는 용도로 사용한다. 내가 느끼기에 플러그인은, 빌드를 한 후에 결과물에 뭔가를 할 수 있게 만들어주는 후처리기의 느낌이 난다.

Plugin 설치 및 사용

플러그인은 로더와 다르게, 클래스로 구현되어 있어 생성자함수를 사용한다.

  1. BannerPlugin : 결과물에 빌드 정보나 커밋 버전 등 남기고 싶은 글을 추가할 수 있다.

    // webpack.config.js
    const webpack = require('webpack');
    
    module.exports = {
        plugins: [
            new webpack.BannerPlugin({
                banner: () => `빌드 날짜: ${new Date().toLocaleString()}` // 여기에 함수가 아닌, 문자열을 전달해도 됨
            })
        ]
  2. DefinePlugin : 개발환경/운영환경에 따로 배포할 필요성이 있는데, 배포 때마다 소스코드를 수정하자니 번거롭다. 환경 정보를 얻을 수 있게 해주는 플러그인이다.

    • 파라미터로 빈 객체를 넘겨주어도 노드 환경정보인 process.env.NODE_ENV를 디폴트로 넘겨주게 되어있으며, 웹팩 설정의 mode에 설정한 값이 여기에 들어간다.(development, production 등)
    • 파라미터로 빈 객체가 아니라 아래와 같이 뭔가를 넣어주면, 이들이 전역상수 문자열이 되어 접근 가능해진다.
    // webpack.config.js
    const webpack = require('webpack');
    
    export default {
        plugins: [
            new webpack.DefinePlugin({
                VERSION: JSON.stringify('v.1.2.3')
                PRODUCTION: JSON.stringify(false),
                MAX_COUNT: JSON.stringify(999),
                'api.domain': JSON.stringify('https://dev.api.domain.com'),
            })
        ]
    }
  3. HtmlWebpackPlugin : HTML파일 내에 ejs 문법으로 코드를 동적으로 삽입해주며 빌드 결과물을 로딩하는 코드를 자동으로 삽입해주는 기능을 한다. 그리고, 코드를 어느 정도 압축할 수도 있다!

    • 기본적인 쓰임새 중 하나인, 코드의 동적 삽입은 아래와 같은 방식으로 설정한다.

      // webpack.config.js
      const HtmlWebpackPlugin = require('html-webpack-plugin');
      
      module.exports {
          plugins: [
              new HtmlWebpackPlugin({
                  template: './src/index.html', // 템플릿 경로를 지정 
                  templateParameters: { // 템플릿에 주입할 파라미터 변수 지정
                      env: process.env.NODE_ENV === 'development' ? '(개발용)' : '', 
                  },
              })
          ]
      }
      • env가 들어있는 걸 보고, ”HtmlWebpackPluginDefinePlugin의 상위호환인가?”라고 생각이 잠시 들었지만, 아마 HtmlWebpackPlugin은 ‘코드의 동적 삽입’이 목적이고 DefinePlugin은 ‘전역상수의 활용’이 목적이라 둘이 쓰임새가 다른 것 같다.
    • 개발(development) 환경과 달리, 운영(production) 환경에서는 파일을 압축하고 불필요한 주석을 제거하는 것이 좋다. 아래와 같이 환경변수에 따라 minify 옵션을 켤 수 있다. 이제, NODE_ENV=production npm run build로 빌드하면 코드 공백과 주석이 제거된다.

      // webpack.config.js
      new HtmlWebpackPlugin({
          template: './src/index.html', // 템플릿 경로를 지정 
          minify: process.env.NODE_ENV === 'production' ? { 
              collapseWhitespace: true, // 빈칸 제거 
              removeComments: true, // 주석 제거 
          } : false,
      }
    • 정적파일을 배포하면 즉각 브라우져에 반영되지 않는 경우가 있다. 브라우져 캐시가 원인일 경우가 있는데 이를 위한 예방 옵션도 있다.

      // webpack.config.js
      new HtmlWebpackPlugin({
          hash: true, // 정적 파일을 불러올 때 쿼리문자열에 웹팩 해시값을 추가한다
      })
  4. CleanWebpackPlugin : 빌드 이전 결과물을 제거(청소)해주는 플러그인이다. 이전 빌드 결과물을 모두 덮어쓰면 상관없겠지만, 그렇지 않은 경우가 있어 청소가 필요하다.

    // webpack.config.js
    const { CleanWebpackPlugin } = require('clean-webpack-plugin');
    
    module.exports = {
        plugins: [
            new CleanWebpackPlugin(),
        ]
    }
  5. MiniCssExtractPlugin : 빌드된 결과물 내에서 스타일시트 코드만 뽑아 별도의 CSS 파일로 만들어서 역할에 따라 파일을 분리할 수 있게 해준다. 브라우져에서 큰 파일 하나를 내려받는 것 보다, 여러 개의 작은 파일을 동시에 다운로드하는 것이 더 빠르기 때문에 성능 측면에서 쓸 만하다.

    // webpack.config.js
    const MiniCssExtractPlugin = require('mini-css-extract-plugin');
    
    module.exports = {
        plugins: [
            ...(
                process.env.NODE_ENV === 'production' 
                ? [ new MiniCssExtractPlugin({filename: `[name].css`}) ]
                : []
            ),
        ],
    }

    만일 개발환경일 때에는 style-loadercss-loader를 운영환경일 때에는 MiniCssExtractPlugin 사용을 하고 싶다면, 플러그인 대신 MiniCssExtractPlugin.loader이라는 로더를 쓰면 된다.

    // webpack.config.js
    const MiniCssExtractPlugin = require('mini-css-extract-plugin');
    
    module.exports = {
        module: {
        rules: [{
            test: /\.css$/,
            use: [
            process.env.NODE_ENV === 'production' 
            ? MiniCssExtractPlugin.loader  // 프로덕션 환경
            : 'style-loader',  // 개발 환경
            'css-loader'
            ],
        }]
        }
    }

Webpack 심화

Webpack 개발 서버

open ./src/index.html 이런식으로 브라우저에 직접 로딩하는 게 아니라, 서버를 띄워 확인할 수 있도록 해주는게 webpack-dev-server다. 운영환경과 비슷하게 테스트해보려면 서버를 띄워서 하는 편이 낫다. 참고로 CRA에서는 이게 기본적으로 셋팅되어있다.

webpack-dev-server 설치

npm i -D webpack-dev-server로 설치하자.

webpack-dev-server 사용

pacakage.json의 scripts에 "start": "webpack-dev-server"를 추가해서 npm start로 이용하면 편하다. webpack-dev-server를 실행시키면 localhost의 8080 port에 server가 구동된다. 코드를 수정할 때마다 브라우저를 갱신할 필요없이, 코드 수정 후 저장을 하면 웹팩 개발 서버가 이를 watch하고 있다가 브라우저를 리프레시해준다.

webpack-dev-server로 목업API 서버 구동

웹팩 설정파일의 devServer.before 옵션을 작성하면 목업API서버로 웹팩서버의 기능을 확장할 수 있다. 목업API서버를 만들었으니, 어느 정도 API서버개발이 늦어지더라도 프론트엔드개발자가 정해진 인터페이스에 맞게 미리 작업을 시작할 수 있게 되겠다.

// webpack.config.js
module.exports = {
    devServer: {
        before: (app, server, compiler) => {
        app.get('/api/keywords', (req, res) => {
            res.json([
            { keyword: '이탈리아' },
            { keyword: '세프의요리' }, 
            { keyword: '제철' }, 
            { keyword: '홈파티'}
            ])
        })
        }
    }
}

실제 API 연동에서 CORS 해결

실제 서버와 연동하면 CORS문제가 생기는데, 이는 웹팩 개발 서버에서 API 서버로 프록싱을 하면 해결할 수 있다.

// webpack.config.js
module.exports = {
    devServer: {
        proxy: {
        '/api': 'http://localhost:8081', // 프록시
        }
    }
}

참고로, 위와 같이 프론트엔드 측에서 해결해도 되지만, 서버측에서 아래와 같이 응답 헤더를 추가해도 해결 가능하다.(이는 이번 주제인 웹팩과 직접적 관련이 있다고 보긴 어려우니 가볍게 참고만 하자.)

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

HMR(Hot Module Replacement)

webpack-dev-server가 코드의 변화를 감지하면 전체 화면을 갱신해준다는 것을 앞에서 다루었다. 하지만 오히려 이게 불편할 때도 있다! input태그에 뭔가를 써두고 테스트를 하고 있을 때, 전체 화면이 리프레시 되면 그게 모두 지워지고 말 것이다. 따라서 HMR이 필요하게 된다. HMR은 ‘변경된 모듈만’을 갱신해준다.

우선, hot을 true로 둠으로써 HMR을 켜주자.

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

아래와 같이, module.hot 객체의 accept() 메소드는 감시할 모듈과 콜백 함수를 인자로 받는다. 해당 모듈의 코드가 변경되면 콜백함수가 실행된다.

// src/controller.js

// 중략
export default controller;

if (module.hot) {
    console.log("핫모듈 켜짐");

    module.hot.accept("./view", () => {
        console.log("view 모듈 변경됨");
    })
}

빌드 최적화

예컨대 어드민툴은 최적화가 잘 되지 않아도 상관없다. 하지만 사용자들이 실제 접하는 서비스들은 초기 렌더링 성능을 어떻게 높이느냐가 중요하다.

사실, 빌드를 할 때 "build": "NODE_ENV=production webpack --progress"로 설정하여 npm run build를 실행하는 것만으로도 어느 정도 최적화가 이루어진다. 운영모드에 적합한 플러그인들을 자동으로 추가해주기 때문이다(콘솔로그 제거 등).

코드 스플리팅(Code Splitting)

빌드 결과물을 여러 개로 쪼개는 것을 의미한다. 큰 파일 하나를 다운로드하는 것보다 작은 파일 여러 개를 동시에 다운로드하는 게 더 빠르기 때문에 유리하다. 코드스플리팅을 위해 가장 쉬운 방법은 멀티엔트리를 쓰는 것이다(엔트리를 여러 개로 나누어 output을 여러 개로 만들기).

externals

axios 등등 많이 쓰는 라이브러리들은 모두 이미 최적화가 되어있으므로 굳이 웹팩에 태울 필요가 없다. 이들을 빌드 과정에서 externals로 분리하면 용량도 감소하고 빌드시간도 줄어든다.

// webpack.config.js
module.exports = {
    externals: {
        axios: 'axios',
    },
}

externals로 분리했기 때문에 우리가 빌드한 후의 결과물에 해당 모듈(위 예시에선 axios)은 없으니, index.html에 별도로 로딩을 해줘야 할 것이다. 별도 로딩을 위해, 일단 node_modules 안에 있는 axios 코드를 복사해야한다. 이 과정에서 쓰이는 플러그인이 copy-webpack-plugin이다.

const CopyPlugin = require('copy-webpack-plugin');

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

그밖에

  • React.js를 쓰는 사람도 CRA를 eject하게 될 일은 언젠간 온다. webpack을 잘 알아야 커스터마이징이 가능하다.
  • 조건문을 써서 개발용인지 운영용인지 환경변수에 따라 HMR에 대한 코드를 제거해주면 되겠다.

[참고자료]
김정환, 모던 프론트엔드 개발 환경의 이해, 68차 토크ON세미나(2020. 1. 15.), T아카데미(SK Planet).
http://jeonghwan-kim.github.io/series/2020/01/02/frontend-dev-env-webpack-intermediate.html