🍳 React 개발 환경 구축하며 알게된 것들

요즈음엔 외대 종강시계의 새로운 버전을 만들고 있습니다. Vue로 개발했던 프로젝트를 React+Typescript로 새롭게 만드는 프로젝트를 진행하고 있는데요. 기존의 보일러플레이트를 사용하지 않고 yarn init부터 시작해서 chrome extension 보일러플레이트를 처음부터 만들었습니다.

chrome extension으로 서빙할 수 있는 번들을 만들 수 있도록 webpack과 babel, tsconfig 설정을 모두 처음부터 작성했었는데요. 그 과정에서 알게 된 사실들과 비교해봤던 것들을 정리해봤습니다.

알게된 것들

1. Webpack Loader 적용 순서

webpack loader는 특정 확장자를 명시하고 이에 대해 필요한 로더를 붙이는 방식으로 작성합니다. 이때 적용 순서에 따라 다른 방식으로 로더를 적용시킬 수 있는데요.

2개 이상의 로더를 적용하는 설정을 작성할 때는 다음과 같은 방식으로 작성할 수 있습니다.

// 1
{
    test: /\.ts|tsx$/,
    loaders: ['babel-loader'],
},
{
    test: /\.ts|tsx$/,
    loaders: ['ts-loader'],
},
// 2
{
    test: /\.ts|tsx$/,
    loaders: ['babel-loader', 'ts-loader'],
},

두 가지 경우에 결과가 같습니다. 수직으로 로더 전체 배열 안에 나열하면 아래 오는게 먼저 적용되고, 수평으로 test와 함께 있는 loaders 배열에 적용하면 뒤에 오는게 먼저 적용됩니다.

함수를 연달아 감싼다고 이해하면 쉽습니다. ts-loader(babel-loder(bundle)) 이런 식으로요. 그래서 SCSS로 스타일링을 할 경우 로더로 처리할 때 css-loader sass-loader 이 순서로 둡니다.

두번째 방법이 더 깔끔하다는 생각이 들었습니다. 프로젝트에 어떤 자원을 어떻게 처리할 것인지를 한 객체 안에 더 잘 표현하는 방식인 것 같기도 하고요.

2. babel Loader vs babelrc

루트 디렉토리에 babelrc를 두는 것과 Webpack Babel Loader의 설정 객체에 config를 작성하는 것이 어떤 차이인지 궁금했습니다.

// webpack.config.js

{
  test: /\.(tsx?)$/,
  use: {
    loader: 'babel-loader',
    options: {
      presets: [
        ['@babel/preset-env', {
          modules: false,
        }],
        '@babel/preset-react',
        ['@babel/preset-typescript', {
          isTSX: true,
          allExtensions: true,
        }],
      ],
      plugins: [
        '@babel/proposal-class-properties',
        '@babel/plugin-syntax-dynamic-import',
      ],
    },
  },
}
// babelrc

{
  "plugins": [
    '@babel/proposal-class-properties',
    '@babel/plugin-syntax-dynamic-import',
  ],
  "presets": [
    [
      '@babel/preset-env', {
        modules: false,
      }
    ],
    '@babel/preset-react',
    [
      '@babel/preset-typescript', {
        isTSX: true,
        allExtensions: true,
      }
    ],
  ]
}

결과의 차이는 없습니다. 기본적으로 루트에 babelrc를 두면 babel-loader가 찾아서 적용합니다. 두 개를 다 만들 필요는 당연히 없습니다.

그런데 babelrc를 루트 디렉토리에 두면 더 확장성 있는 설정이 가능합니다. Storybook은 빌드시에 루트 디렉토리의 babelrc를 찾아 적용합니다. 웹팩만을 위한 설정으로 둘 것인지, Storybook과 같은 다른 런타임과 공유하는 설정으로 사용할 것인지 선택할 수 있습니다.

3. babel preset, plugin 적용 순서

babel config를 작성할 때 필요한 플러그인과 프리셋들을 배열 안에 나열하게 되는데, 이것들이 어떤 순서로 적용되는지 궁금했습니다.

바벨 독스에 따르면 플러그인들은 프리셋 이전에 먼저 실행됩니다. 이때 플러그인들은 배열의 앞에서부터 뒤로 실행됩니다. 프리셋은 배열의 맨 뒤 인덱스부터 앞으로 실행됩니다. 위에서 보여드렸던 babel config 예제에 순서를 표시하면 다음과 같습니다.

{
  "plugins": [
    '@babel/proposal-class-properties', // 1번째
    '@babel/plugin-syntax-dynamic-import', // 2번째
  ],
  "presets": [
    [
      '@babel/preset-env', { // 5번째
        modules: false,
      }
    ],
    '@babel/preset-react', // 4번째
    [
      '@babel/preset-typescript', { // 3번째
        isTSX: true,
        allExtensions: true,
      }
    ],
  ]
}

프리셋이나 플러그인의 적용 순서가 중요한 경우가 있습니다. emotion CSS props를 스타일링에 사용할 경우 바벨 플러그인(@emotion/babel-preset-css-prop)을 사용해 셋업이 가능한데요. 독스에서는 @babel/preset-react@babel/preset-typescript를 함께 쓰는 경우 이후에 배치해달라고 이야기합니다. 더 빨리 적용되어야 한다는 것입니다.

해당 플러그인은 emotion의 css props를 JSX에 적용시키기 위해 원래대로라면 React.createElement로 변화해야 할 JSX를 emotion/react 라이브러리의 jsx 함수로 변화시킵니다. JSX를 React.createElement로 변화시키는 @babel/preset-react가 먼저 적용되어있다면 플러그인이 제대로 작동하지 않겠죠.

사실 프리셋이 플러그인의 집합이라는 점에서, 플러그인과 프리셋의 의미는 거의 동등합니다. 그렇기 때문에 어떤 상황에서는, babel이 제시하는 프리셋과 플러그인의 실행 순서를 따르면 프리셋과 플러그인 사이에서 플러그인들의 호출 순서가 꼬일 가능성이 충분히 존재한다고 생각합니다.

관련해서 babel의 플러그인 호출 순서를 커스텀할 수 있는 논의와 PR이 진행중이었습니다. 아직 PR이 닫히지는 않은 것 같은데 최근에는 활동이 없군요.

4. Tree Shaking에 적합한 세팅

사용하지 않는 라이브러리들의 코드와 export를 판단해 Dead Code를 없애는 트리 쉐이킹을 위해 개발 환경 단에서 몇가지 설정이 필요합니다. Webpack의 트리 쉐이킹이 궁금하시다면 이 글을 참조하세요!

webpack은 빌드 과정에서 필요없는 코드와 export를 분류해 위치를 표시하고, 번들링의 마지막 단계에서 코드 압축을 돕는 도구인 terser가 필요없는 코드들을 제거합니다.

트리 쉐이킹은 기본적으로 모듈의 정적 분석이 가능한 ESM 모듈 환경에서만 가능합니다. 따라서 ESM으로 작성된 상태를, webpack에서의 terser가 dead code를 제거하고 minify를 하기 전까지 최대한 유지해야 합니다.

일단 tsconfig에서 module 속성 값의 설정을 ESNext나 ES6로 맞추면 ESM 모듈이 유지된 채로 타입스크립트 트랜스파일이 됩니다.

{
  "compilerOptions" : {
    "module": "ESNext",
    ...
  }
}

이후 babel 설정에서, @babel/preset-envmodules 설정을 false로 하면 ESM 모듈이 cjs로 바뀌는 것을 방지할 수 있습니다.

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {"chrome": "58"},
        "modules": false,
         ...
      }
    ]
  ]
}

처음에 이런 정보를 접했을 때, ESM을 계속 유지하면 최종 번들 결과물이 ESM으로 나와서 브라우저 호환성을 해치는 것이 아닌가? 라는 단순한 생각이 들었지만 이 과정은 호환성 문제를 일으키지 않습니다. 결국 webpack이 코드를 범용적으로 사용할 수 있는 형태로 변환하기 때문입니다.

선택의 여지

babel, tsc, webpack 등으로 개발 환경을 구성하다 보면, 같은 일을 두 도구에서 모두 수행할 수 있는 상황이 있습니다. 이때 무엇을 선택해야 할지 간단히 비교해보았습니다.

TypeScript : tsc vs @babel/preset-typescript

TypeScript 트랜스파일에 tsc를 쓸 것인지, 혹은 babel을 쓸 것인지 선택할 수 있습니다. 꽤나 잘 알려져있는 선택의 여지입니다. 저는 이 글을 접하면서 babel을 typescript 트랜스파일에 쓸 수 있다는 것을 알게 되었는데요.

이번에 보일러플레이트를 만들면서 두 개의 컴파일러를 엮어서 사용하는게 쉬운 일이 아니라는, 위 포스팅의 언급에 동감했습니다. 순서도 헷갈리고 신경써야할 부분이 많았습니다. TypeScript 트랜스파일까지 babel에서 진행한다면 babel만을 가지고 번들링시 필요한 거의 모든 것을 변환할 수 있게 됩니다.

babel을 사용하는 것의 장점이라면, 더 빠르다는 것입니다. tsc는 node_modules 내부의 모든 라이브러리들의 d.ts를 스캔해 올바르게 동작하는지 확인하고, 현재 프로젝트의 타입 선언 파일(index.d.ts)를 만들며, 빌드할때마다 타입 채킹을 하니 더 느립니다. 반면 babel은 TypeScript 문법을 그냥 제거해버리는 식으로 컴파일을 하니 더 빠릅니다.

다만 이런 특징 때문에 타입 선언 파일을 같이 만들어야할 필요가 있는 경우(라이브러리 개발 등)에는 사용할 수 없습니다.

저는 빌드시 타입을 체크하지 않는다는 점 때문에 처음에는 쓰기가 망설여졌던 것 같습니다. 사람이 항상 꼼꼼할 수 없어서, 빌드시 타입 에러를 잡아주면 좀 더 튼튼한 프로덕트를 만들 수 있지 않을까 하는 생각도 있습니다. 하지만 타입 에러를 필요할 때마다 검사할 수 있는 환경을 추가적으로 구축하거나, eslint 등의 도움을 받으면 어느정도 보완이 가능한 지점인 것 같기도 합니다.

그렇지만 여전히 타입 체킹을 분리했을 때, 제가 과연 성실히 타입 검사를 돌릴까-를 생각하면 잘 모르겠습니다… CI/CD 단에서 tsc로 타입체킹 했을 때 에러가 나면 통합이 안된다거나… 하는 타입 준수를 강제할 수 있는 다른 도구를 생각해봐야 할 것 같습니다.

그리고 tsc보다 미지원 문법이 더 많은 것 같은데, 관련해서 플러그인을 설치하고 유지하기가 조금 귀찮을 듯 합니다.

일단 외대 종강시계 프로젝트에서는 tsc를 쓰고 있는데요, 한번 poc 느낌으로 @babel/preset-typescript를 한 번 적용해서 실험해볼까 싶습니다.

JSX : tsconfig vs @babel/preset-react

JSX 변환을 TypeScript 트랜스파일 단계에서 하느냐, @babel/preset-react를 사용해서 하느냐의 차이입니다. 역시 둘 중에 하나만 해도 됩니다. React 17부터 지원하는 New JSX Transform을 적용하면서 JSX 변환하는 것은 tsconfig, 혹은 babelrc에서의 설정으로 모두 가능합니다.

// babelrc
// babel에서 jsx 변환을 시킬 경우, tsconfig의 jsx 설정은 preserve여야 합니다.(JSX 보존)
{
  "presets": [
    ["@babel/preset-react", {
      "runtime": "automatic" 
    }]
  ]
}

// 혹은 tsconfig
// TS 4.1부터 New JSX Transform을 지원합니다.
{
  jsx: 'react-jsx',
  ...
}

저는 tsconfig 에서 설정하는게 더 좋다는 생각이 들었습니다. @babel/preset-react를 설치하고 사용할 필요가 없기 때문에 그렇습니다. 굳이 tsconfig에서 사용할 수 있는 옵션이 있는데 플러그인을 하나 더 설치할 필요는 없는 것 같습니다. 다시 말하면 JS는 몰라도 TS를 사용하는 React 프로젝트에서는 @babel/preset-react를 사용할 필요가 없는게 아닌가..? 하는 생각이 좀 들었습니다.

tsconfig를 사용하면 emotion 적용도 더 간편해져서, @emotion/babel-preset-css-prop을 사용할 필요 없이 tsconfig에 설정 하나만 더 해주면 됩니다. 그렇게 하면 emotion의 JSX 함수를 통해 JSX를 변환할 수 있습니다.

{
  "compilerOptions" : {
    "jsx": "react-jsx",
    "jsxImportSource": "@emotion/react",
    ...
  }
}

TypeScript는 여러가지 JSX 변환 옵션을 지원하고 있습니다. 여기서 살펴보실 수 있어요!

ES5 : 그냥 Babel

tsc의 tsconfig 옵션에서 target속성을 ES5로 설정하면, ES5로 트랜스파일이 진행됩니다.

하지만 웹 어플리케이션을 개발할 때 babel을 쓰지 않고 tsc만 쓰는 경우는 거의 없을 것입니다. @babel/preset-env를 이용하면 브라우저 호환성 옵션을 설정하거나, 트리 쉐이킹에 적합한 설정을 하는 등 웹 개발에 필요한 다양한 옵션을 사용할 수 있으니까요.

원래부터 ES5 변환은 babel의 고유한 일이었기 때문에, 웹 애플리케이션의 ES5 트랜스파일은 babel에게 맡기는게 옳아 보입니다.

맺는말

이렇게 프론트엔드 개발 환경 구축 관련해서 최근에 알게 된 것들을 이것저것 정리해 보았는데요.

구글링을 하면 “되는 방법”은 쉽게 찾을 수 있는 내용이기는 합니다. 하지만 좀 더 깊게 찾아보면서 어떤 절차로 빌드 과정이 진행되는지, 어떤 것을 선택하는 것이 좋은지, 각각의 도구가 정확히 무슨 역할을 하는지 더 잘 이해해볼 수 있었던 것 같습니다.

빌드 환경과 관련된 의사결정은 과연 이렇게 하는게 최선의 선택인지 의심이 너무 되서 아직은 꽤나 어려운 듯 합니다.

웹 화면을 만드는 것처럼 결과물의 차이가 눈으로 바로 드러나지 않기도 하고요. 일단은 개발하기 편한 환경 정도를 만드는 게 고작인 듯도 합니다. 좀 더 노하우를 쌓아서 다양한 상황에서 최선의 빌드 결과물을 만들 수 있는 개발자가 되고 싶습니다.

<< 글 목록으로 돌아가기(클릭!)