CRA에서 VITE로
2024-03-20

3년전 CRA로 시작한 우리 프로젝트는 현재까지도 CI/CD가 구축되지 않았다. TDD를 하지 않기때문에 기본적으로 테스트코드가 존재하지 않았고, 무엇보다 바라만봐도 마음아픈 미니타워 사이즈의 개발서버가 CI/CD를 구축했을시 잘 버텨줄 수 있을지 의문이었기 때문이다. (실제로 배포만이라도 자동화 해보자는 마음으로 구축후 빌드해봤으나 빌드만으로 RAM 사용률이 45%를 넘어서면서 개발서버가 먹통이되는 현상이 나타났다..)

다가오는 출시압박에 요구사항이 커짐에 따라 기능개발에만 급급한 프로젝트 빌드시간은 평균 90s를 넘고야 말았고, CD없이 개발-스테이징-운영서버의 3중배포는 나를 피곤하게 하기에 충분했고 미뤄왔던 번들러 교체를 진행하게된 계기가됐다.


esbuild-loader & vite 선택의 순간

오로지 빌드타임을 줄이는 것이 목표였기때문에 현재 상황에서 esbuild-loader를 적용해 간편한 세팅으로 빠른 적용을 하고싶었다. 하지만 프로젝트가 CRA + craco + typescript-path설정만 되어있었기 때문에, CRA 기본 세팅이 필요로 하는 것 보다 무겁게 설정되어있지는 않을까 하는 마음에 eject를 진행해서 확인해본 결과, 생각보다 많은 세팅이 들어가있었고 이는 불필요하다는 판단 하에 vite를 선택하게 됐다.


설치 및 세팅

기존설정을 동일하게 가져가기 위한 몇가지 플러그인을 설치후 react-scripts와 craco는 제거후 package.json의 scripts를 변경해줬다.

pnpm add -D vite@latest vite-tsconfig-paths vite-plugin-svgr
pnpm remove react-scripts craco

"start" : "vite"
"build" : "vite build"
  • vite-tsconfig-path: Typescript path mapping으로 모듈을 가져오기 위한 플러그인
  • vite-plugin-svgr: svg를 react-component로 변환하기 위한 플러그인

현재 프로젝트는 로컬서버에서도 개발서버와 동일한 환경을 위해 host 설정 및 https + 443포트를 사용중이다 ^^b 그렇기에 별도의 설정이 필요했다.

// vite.config.ts
import * as fs from 'fs';
import tsconfigPaths from 'vite-tsconfig-paths';
import svgr from 'vite-plugin-svgr';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';

export default defineConfig(({ mode }) => {
    const env = loadEnv(mode, process.cwd(), '');

    return {
        server: {
            port: 443,
            strictPort: true,
            host: env.HOST,
            https: {
                key: fs.readFileSync(env.SSL_KEY_FILE),
                cert: fs.readFileSync(env.SSL_CRT_FILE),
            },
        },
        plugins: [
            react({
                jsxImportSource: '@emotion/react',
                babel: {
                    plugins: ['@emotion/babel-plugin'],
                },
            }),
            tsconfigPaths(),
            svgr(),
        ],
        envPrefix: 'REACT_APP_',
        build: {
            outDir: 'build',
        },
    };
});

https를 쓰기위한 인증서 설정 + 별도의 plugin설정까지 추가했다. vite는 클라이언트에서 환경변수에 접근하기 위한 prefix가 VITE_지만 개발,스테이징,운영 3가지 서버의 .env를 바꾸기 귀찮아 일단 prefix를 기존과 맞춰서 바꿔주었다. 추가로 svgr 플러그인은 사용하려면 import 하는 경로 마지막에 ?react suffix를 붙여야한다.

import SvgCallIn from '@assets/assets-svg/interaction_call_in.svg?react';

process.env -> import.meta

기존에 환경변수 참조 코드에 문제가 생겨서 확인해보니 vite는 import.meta.env로 환경변수를 참조한다고 한다. 그래서 process.env 에서 import.meta로 변경해 주었다


assets파일 핸들링

로컬서버에서는 문제가 없었는데 production에서 문제가 생겼다. 기존 css 내부에 background: url()로 인라이닝한 스타일이 적용이 안되는 것이었다. 해당부분이 문제가 되는건 아래에 나오지만 vite는 로컬서버는 번들링하지 않는다. 그렇기때문에 production과 assets-path가 예상과 다를 수 있다는 것이다.

url 변수에 Double-quote("")를 붙여줘야 적용된다고 한다. 하지만 prettier설정의 Single-quote('')가 켜져있었기때문에 .styles.ts 모듈에는 Double-quote("")가 적용되도록 추가 수정해주었다.

// .styles.ts
import Svg from "@assets/svgs/svg";

const Container = css`
    background-image: url("${Svg}");
`
// .prettierrc.js
module.export = {
    overrides: [
        {
            files: '*.styles.ts',
            options: {
                singleQuote: false,
            },
        },
    ],
}

Native ESM의 자동 strict mode

vite는 라이브러리와 같이 그 내용이 바뀌지 않을 소스코드는 esbuild를 이용해 사전번들링해 빠른 속도를 취하고, source-code와 같이 수정이 잦은 코드는 Native ESM을 이용해 본질적으로 브라우저에게도 번들링 작업 일부를 위임한다. Native ESM을 사용하는 것 자체가 문제가 아니다. 프로젝트 내부에서 sheetjs-style라는 오래된 엑셀 라이브러리를 사용중이 었는데. 그 라이브러리가 제공하는 코드에 문제가 있었다.

var APOS = "'"; QUOTE = '"'

이유는 Native ESM의 모듈은 내부에서 자동적으로 strict mode를 사용하기 때문에 문제가됐다. 라이브러리 자체에서 수정되면 참 고맙겠지만 (확인해보니 라이브러리 이슈에도 올라와 있더라..) 업데이트도 없고 이슈pr도 무시되는 상황이라 라이브러리를 exceljs로 교체해줌으로써 문제를 해결했다.


개발서버는 번들링되지 않는다

위의 설정까지 마친후 처음 로컬서버를 실행했을때 당황스러웠다. 초기 로딩이 느려도 너~무 느렸다. 확인해 보니 엄청난 수의 모듈을 request하고 있었고 좀만 찾아보니 vite는 개발서버를 번들링하지 않는다는 것을 알 수 있었다.

모듈이 너무 많다.. 어쩌면 CRA가 개발서버를 번들한탓에 이러한 문제에 대해 생각하지 않았던게 아닐까 싶다. 하지만 지금 수많은 모듈을 개발한 사람은 남아있지도않고, 곧 출시를 앞두고 모듈 정리를하기엔 리스크가 크기에 당장 생각해낸 해결방안은 다음과 같았다.

  • 배럴파일 정리
  • 동적분할로 인해 필요할때만 모듈 불러오기

정말 사용해야 할 곳에서만 배럴파일을 사용하고, 순환참조가 일어나는 부분을 모두 제거한 후 동적분할을 통해 분리해주면 어느정도 잡힐 것이라 판단했다. 가장 큰 원인이 됐던 모듈은 메인 라우터 모듈과 전역 상태를 import해 메인 reducer를 생성하는 모듈이었다. 그도 그럴것이 메인라우터는 모든 페이지를 불러오기 때문에 모든 페이지에 관한 로직을 import할 것이고, 전역상태 역시 마찬가지기 때문이다.

특히 라우터 모듈은 동적분할이 돼있지 않아 메인 로직이 담겨있지 않은 페이지에서도 메인에 관한 모듈을 불러오는 쓸데없는 시간을 소비하고 있었다.

WoW! 여전히 모듈은 많지만 눈에띄는 성과가 나왔고 덤으로 메인로직을 사용하지 않는 페이지의 초기 로딩속도 역시 눈에띄게 좋아진 것을 발견했다.

load가 1.53s -> 201ms로 나름의 성과를 거뒀다. 하지만 기뻐하는것도 잠시였다. 동적분할을 적용하니 Suspense로 인한 fallback뷰가 라우팅마다 발생하는 꺼림직한 문제가 발생했다.


라우터 Suspense의 fallback뷰가 UX를 떨어트린다..

모듈을 불러오는 동안 fallback뷰가 라우팅마다 보이는건 무시할 수 없는 문제였다.. startTransition을 사용하면 fallback뷰가 보이는걸 막을 수 있지만 사용하고 있는 react-router-dom에서 해당 기능을 지원하냐가 관건이었다.

공식문서에서 찾기는힘들었지만 분명 있을거라 생각해 모든 changelog를 뒤져본 결과 실험적인 기능으로 개발되어 있었고 적용 후 테스트해보니 원하는 결과를 봐서 다행이었다.

<HistoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: false }}>
    <Routes>
        // 라우터들
    </Routes>
</HistoryRouter>

배포시 route를 동적 import했을때 문제 feat.React.lazy

현재 프로젝트는 haproxy를 이용한 무중단 배포를 진행하고 있다. 하지만 배포환경에서 동적import에 문제가생겼고 이는 꽤 여러사람에게 문제가 되고있는듯 했다.

build시 분리된 chunk가 생성되고 이 chunk에 코드 내용과 관련된 hash값이 붙는다. 즉 소스코드가 변하지 않는 모듈은 hash값이 동일하기 때문에 이는 캐싱에 유리한데, 이 바뀐 hash가 약간의 문제를 일으킨다. 예를들어 /home 경로가 Home.hash.js를 load할때, 소스코드 변경후 build시 hash값이 변경되고, 더이상 Home.hash.js는 없지만 이를 import하기 때문에 문제가 생긴다.

error이벤트에 강제로 새로고침해주는 로직으로 임시로 해결할 수 있다고는 하지만, 프로젝트 특성상 그럴 수 없기때문에 모든 chunk에서 hash를 빼주기로 결정했다. 정적파일과 entry모듈은 hash를 유지했지만 entry의 hash가 바뀌는경우도 생겨서 안전하게 모든 chunk에 hash를 제거해줬다.

export default defineConfig(({ mode }) => {
    return {
        envPrefix: 'REACT_APP_',
        build: {
            rollupOptions: {
                output: {
                    entryFileNames: '[name].js',
                    chunkFileNames: '[name].js',
                    assetFileNames: 'assets/[name][extname]',
                },
            },
        },
    };
});

결과

chunk에 hash를 제거하는 찝찝함은 남아있다...

하지만 빌드시간은 3배가량 단축되었고 기존 CRA는 로컬서버도 번들링되기 때문에 HRM과 서버실행속도가 느렸는데 (특히 node_modules를 지우고 새로 시작했을때는 정~~말 느렸었다) 지금은 거의 즉시 reload된다!

아직 멀었지만 그래도 조금이나마 DX가 좋아져서 다행이다 🥲