Framework/React & RN

[React] Creact React App을 분석해보자

Joonfluence 2022. 4. 26.

서론

오늘은 리액트 환경설정을 자동화해주는 툴인 CRA에 관해서 알아보도록 하겠습니다.

본론

 

CRA란

CRA란 별다른 설정 없이 react 개발환경을 구축해주는 boilerplate 입니다. 이를 활용하면, 쉽게 환경설정을 할 수 있다는 장점이 있습니다. 구체적으로 CRA가 해주는 역할은 대표적으로 다음과 같습니다.

  • public 디렉토리에 index.html과 logo, favicon 등의 static asset 및 기본적인 리액트 코드(index.js, app.js) 설정
  • webpack, babel, eslint, jest 등 리액트 개발에 필요한 각종 환경설정
  • react, react-dom, react-scripts 등 각종 라이브러리 설치
  • react-scripts를 사용하여 package.json에 npm command 정의
    ...

그 중에서도 중심적인 역할을 하는 webpack 설정과 react-scripts 내용을 중심으로 프로젝트 코드를 분석해보겠습니다. 먼저, 아래처럼 프로젝트를 시작해줍니다.

npx create-react-app my-react-app

npx 혹은 npm 모두 사용 가능하지만, CRA의 경우에는 버젼 변경이 잦은 편이고 글로벌에서 관리해주는게 좋으므로 npx로 최신버젼을 실행해줍니다. 그 둘의 차이점은 해당 링크를 통해 설명드렸습니다.

npm init react-app my-react-app
yarn create react-app my-react-app

위 경우처럼, 로컬에 설치해줘도 상관은 없습니다.

 

CRA Eject

분석을 위해, 바로 eject를 해주겠습니다. eject를 해주는 까닭은 CRA 환경에선 웹팩과 바벨 등 개발과 배포를 위한 각종 설정에 직접 접근할 수 없기 때문입니다. eject를 하면 전이 의존성을 갖는 모듈(웹팩, 바벨, 린트 등)을 포함한 모든 모듈을 복사해 package.json의 종속성에 포함시킵니다. 링크 참조

한가지 주의하실 점이 있는데, 한 번 eject한 프로젝트는 다시 되돌릴 수 없습니다. 따라서 이미 프로젝트가 꽤 진행됐고, 직접 개발 환경을 설정하실 분이 아니라면, eject하지 않는 것을 권해드립니다.

npm run eject

 

CRA의 경로 설정 분석하기

먼저, CRA 설정에 기본이 되는 경로설정부터 확인해보겠습니다. 경로설정에는 두가지 방식이 존재하는데요, resolveApp, resolveModule 입니다. 먼저 resolveApp부터 살펴보겠습니다.

const appDirectory = fs.realpathSync(process.cwd()); // /Users/joonholee/testRepo/my-react-app
const resolveApp = (relativePath: string) => path.resolve(appDirectory, relativePath); // /Users/joonholee/testRepo/my-react-app/[relativePath]

먼저 process.cwd를 통해, 현재 디렉토리 경로를 확인해주는데요. process는 현재 실행되고 있는 노드 프로세스에 대한 정보를 담고 있는 노드의 내장객체이며, cwd란 current working directory의 줄임말입니다. 현재 경로를 나타내주죠. 그 뒤에, 상대경로에는 파일명을 입력하면, 정확한 절대경로를 나타낼 수 있게 됩니다. 디렉토리가 오는 경우도 있는데, 그럴 경우엔 해당 디렉토리 경로만 표시됩니다.

module.exports = {
  dotenv: resolveApp(".env"),
  appPath: resolveApp("."),
  appBuild: resolveApp(buildPath),
  appPublic: resolveApp("public"),
  appHtml: resolveApp("public/index.html"),
  appPackageJson: resolveApp("package.json"),
  appSrc: resolveApp("src"), // src
  appTsConfig: resolveApp("tsconfig.json"),
  appJsConfig: resolveApp("jsconfig.json"),
  yarnLockFile: resolveApp("yarn.lock"),
  proxySetup: resolveApp("src/setupProxy.js"),
  appNodeModules: resolveApp("node_modules"),
  appWebpackCache: resolveApp("node_modules/.cache"),
  appTsBuildInfoFile: resolveApp("node_modules/.cache/tsconfig.tsbuildinfo"),
  swSrc: resolveModule(resolveApp, "src/service-worker"), // src/service-worker
  publicUrlOrPath,
};

resolveModule은 파일경로들을 웹팩과 같은 순서대로 풀어주는 함수입니다.

const moduleFileExtensions = [
  "web.mjs",
  "mjs",
  "web.js",
  "js",
  "web.ts",
  "ts",
  "web.tsx",
  "tsx",
  "json",
  "web.jsx",
  "jsx",
];

const resolveModule = (resolveFn: (path: string) => void, filePath: string) => {
  const extension = moduleFileExtensions.find((extension) =>
    fs.existsSync(resolveFn(`${filePath}.${extension}`))
  );

  if (extension) {
    return resolveFn(`${filePath}.${extension}`);
  }

  return resolveFn(`${filePath}.js`);
};

appIndexJs: resolveModule(resolveApp, "src/index"), // /Users/joonholee/testRepo/my-react-app/src/index.js
testsSetup: resolveModule(resolveApp, "src/setupTests"), // /Users/joonholee/testRepo/my-react-app/src/setupTest.js
swSrc: resolveModule(resolveApp, "src/service-worker"), // /Users/joonholee/testRepo/my-react-app/src/service-worker.js

와 같이 나타나게 됩니다.

 

CRA의 웹팩 설정

Entry(엔트리)

엔트리(엔트리 포인트라고도 함)는 웹팩이 파일을 불러오는 시작점입니다. CRA에선 src/index.js 파일이 시작점이므로 해당 파일을 entry 파일로 사용합니다.

const paths = require('./paths');

module.exports = function (webpackEnv) {
  return {
    entry: paths.appIndexJs, // /Users/joonholee/testRepo/my-react-app/src/index.js
  }
}

Output(출력)

번들된 파일의 경로와 파일명을 지정해줍니다.

  • path : 번들된 파일 경로이 위치할 경로를 의미합니다.
  • pathinfo : 번들된 파일에 대한 부가적인 정보를 주석으로 제공합니다.
  • filename : 번들된 파일의 이름을 말합니다. 배포환경에선 static/js/[name].[contenthash:8].js, 개발환경에선 static/js/bundle.js로 지정해줍니다.
  • chunkFilename : 청크 파일의 이름을 정의합니다. 배포환경에선 static/js/[name].[contenthash:8].js, 개발환경에선 static/js/[name].chunk.js로 지정해줍니다.
  • assetModuleFilename : 에셋 파일의 이름을 지정해줍니다.
  • publicPath : 번들 파일이 위치할 디렉토리를 정해줍니다.
  • devtoolModuleFilenameTemplate : devtool이 모듈 이름을 필요로 할 때 사용되는 옵션입니다.
const paths = require('./paths');

module.exports = function (webpackEnv) {
  const isEnvDevelopment = webpackEnv === 'development';
  const isEnvProduction = webpackEnv === 'production';

  return {
    ...,
    output: {
      path: paths.appBuild, // /Users/joonholee/testRepo/my-react-app/[process.env.BUILD_PATH]
      pathinfo: isEnvDevelopment, 
      filename: isEnvProduction
        ? 'static/js/[name].[contenthash:8].js'
        : isEnvDevelopment && 'static/js/bundle.js',
      chunkFilename: isEnvProduction
        ? 'static/js/[name].[contenthash:8].chunk.js'
        : isEnvDevelopment && 'static/js/[name].chunk.js',
      assetModuleFilename: 'static/media/[name].[hash][ext]',
      publicPath: paths.publicUrlOrPath,
      devtoolModuleFilenameTemplate: isEnvProduction
        ? (info) =>
            path
              .relative(paths.appSrc, info.absoluteResourcePath)
              .replace(/\\/g, '/')
        : isEnvDevelopment &&
          ((info) =>
            path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
    },
  }
}

Mode(모드)

개발 환경에선 development, 배포 환경에선 production으로 설정해줍니다.

module.exports = function (webpackEnv) {
  const isEnvDevelopment = webpackEnv === 'development';
  const isEnvProduction = webpackEnv === 'production';

  return {
    ...,
    mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
  }
}

Loaders(로더)

웹팩은 설정 값이 없는 초기 상태에선 js 파일과 json 파일만 로드할 수 있는데요, 따라서 그 외 파일들을 불러오려면 추가로 웹팩에서 로더 설정을 해줘야 합니다. CRA에는 source-map-loader, babel-loader, style-loader, css-loader, postcss-loader, sass-loader. 총 6가지 로더가 존재합니다. 참 많죠..? 차근차근 순서대로 살펴보도록 하겠습니다.

module.exports = function (webpackEnv) {
  const isEnvDevelopment = webpackEnv === 'development';
  const isEnvProduction = webpackEnv === 'production';
  const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';

  return {
    ...,
    module: {
      strictExportPresence: true,
      rules: [
        // Handle node_modules packages that contain sourcemaps
        shouldUseSourceMap && {
          enforce: 'pre',
          exclude: /@babel(?:\/|\\{1,2})runtime/,
          test: /\.(js|mjs|jsx|ts|tsx|css)$/,
          loader: require.resolve('source-map-loader'),
        },
      ].filter(Boolean),
    },
  }
}

1) source-map-loader

source-map-loader란 자바스크립트 파일에서 현재 존재하는 source maps을 추출하는 로더입니다. source map이란 개발하는 코드와 번들링된 코드 사이의 관계를 표현하는 데이터로, 배포 환경에서 디버깅할 때 주로 사용됩니다. 웹팩에서 번들된 파일은 기본적으로 난독화됐기 때문에, 배포 환경에서 에러가 나더라도 확인하기 어렵습니다. source-map 라이브러리를 사용하면, 디버깅이 쉬워진다고 합니다. 단, 소스맵은 자원이 많이 소모되어 메모리 이슈를 가져올 수 있으므로 꼭 필요한 경우에 사용하는 것이 좋습니다.

2) babel-loader

babel-loader는 JSX 문법과 함께 사용된 리액트 코드를 자바스크립트 파일로 변환하기 위해, babel-preset-react-app 프리셋을 옵션으로 사용합니다.

module.exports = function (webpackEnv) {
  const isEnvDevelopment = webpackEnv === 'development';
  const isEnvProduction = webpackEnv === 'production';
  const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';

  return {
    ...,
    module: {
      strictExportPresence: true,
      rules: [
      ...
        {
          oneOf: [
            {
              test: /\.(js|mjs|jsx|ts|tsx)$/,
              include: paths.appSrc,
              loader: require.resolve('babel-loader'),
              options: {
                customize: require.resolve(
                  'babel-preset-react-app/webpack-overrides'
                ),
                presets: [
                  [
                    require.resolve('babel-preset-react-app'),
                    {
                      runtime: hasJsxRuntime ? 'automatic' : 'classic',
                    },
                  ],
                ],

                plugins: [
                  isEnvDevelopment &&
                    shouldUseReactRefresh &&
                    require.resolve('react-refresh/babel'),
                ].filter(Boolean),
                cacheDirectory: true,
                cacheCompression: false,
                compact: isEnvProduction,
              },
            },
            {
              test: /\.(js|mjs)$/,
              exclude: /@babel(?:\/|\\{1,2})runtime/,
              loader: require.resolve('babel-loader'),
              options: {
                babelrc: false,
                configFile: false,
                compact: false,
                presets: [
                  [
                    require.resolve('babel-preset-react-app/dependencies'),
                    { helpers: true },
                  ],
                ],
                cacheDirectory: true,
                cacheCompression: false,
                sourceMaps: shouldUseSourceMap,
                inputSourceMap: shouldUseSourceMap,
              },
            },
      ].filter(Boolean),
    },
  }
}

이제부터 "oneOf" 조건이 추가되는데요, 이 조건은 여러 로더 중 하나를 만족하는 로더를 찾고 해당 로더로 반환합니다. 만약 없다면, 로딩이 실패하고 파일 로더의 로더 리스트 맨 마지막으로 이동 된다고 합니다.

3) css, scss 로더

이번엔 css, scss 혹은 sass 파일을 처리해주는 구문입니다. 두 경우 모두 style-loader가 필요하며, css는 css-loader, sass는 sass-loder를 따로 불러와야 합니다. (참고로 postcss-loader도 처리됩니다) 한가지 특이한 점은 .css/.sass와 module.css/module.sass를 구분한다는 점인데요, 전자는 글로벌 영역의 스타일링을 담당하고 후자는 로컬 파일의 스타일링을 담당합니다. 후자는 해당 리액트 파일의 디렉토리와 동일하게 위치하도록 처리해주면 됩니다.

module.exports = function (webpackEnv) {
  return {
    ...,
    module: {
      strictExportPresence: true,
      rules: [
      ...
        {
          oneOf: [
            {
            ...,
             {
              test: cssRegex,
              exclude: cssModuleRegex,
              use: getStyleLoaders({
                importLoaders: 1,
                sourceMap: isEnvProduction
                  ? shouldUseSourceMap
                  : isEnvDevelopment,
                modules: {
                  mode: 'icss',
                },
              }),
              sideEffects: true,
            },
            {
              test: cssModuleRegex,
              use: getStyleLoaders({
                importLoaders: 1,
                sourceMap: isEnvProduction
                  ? shouldUseSourceMap
                  : isEnvDevelopment,
                modules: {
                  mode: 'local',
                  getLocalIdent: getCSSModuleLocalIdent,
                },
              }),
            },
            {
              test: sassRegex,
              exclude: sassModuleRegex,
              use: getStyleLoaders(
                {
                  importLoaders: 3,
                  sourceMap: isEnvProduction
                    ? shouldUseSourceMap
                    : isEnvDevelopment,
                  modules: {
                    mode: 'icss',
                  },
                },
                'sass-loader'
              ),
              sideEffects: true,
            },
            {
              test: sassModuleRegex,
              use: getStyleLoaders(
                {
                  importLoaders: 3,
                  sourceMap: isEnvProduction
                    ? shouldUseSourceMap
                    : isEnvDevelopment,
                  modules: {
                    mode: 'local',
                    getLocalIdent: getCSSModuleLocalIdent,
                  },
                },
                'sass-loader'
              ),
            },
         }
      }  
   }
}

4) 기타 이미지 로더

이미지 파일 같은 경우, avif. bmp. gif. jpeg. png 모두 지원됩니다. 또 type을 asset으로 지정해줘야 합니다. 또한 dataUrlContion 조건이 있으므로 모듈 소스 크기가 maxSize보다 작으면 모듈이 Base64 인코딩 문자열로 번들에 삽입되고, 그렇지 않으면 모듈 파일이 출력 디렉터리로 내보내집니다.

const imageInlineSizeLimit = parseInt(
  process.env.IMAGE_INLINE_SIZE_LIMIT || '10000'
);

module.exports = function (webpackEnv) {
  return {
    ...,
    module: {
      strictExportPresence: true,
      rules: [
      ...
        {
          oneOf: [
            {
            ...,
            {
              test: [/\.avif$/],
              type: 'asset',
              mimetype: 'image/avif',
              parser: {
                dataUrlCondition: {
                  maxSize: imageInlineSizeLimit,
                },
              },
            },
            {
              test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
              type: 'asset',
              parser: {
                dataUrlCondition: {
                  maxSize: imageInlineSizeLimit,
                },
              },
            },
            {
              test: /\.svg$/,
              use: [
                {
                  loader: require.resolve('@svgr/webpack'),
                  options: {
                    prettier: false,
                    svgo: false,
                    svgoConfig: {
                      plugins: [{ removeViewBox: false }],
                    },
                    titleProp: true,
                    ref: true,
                  },
                },
                {
                  loader: require.resolve('file-loader'),
                  options: {
                    name: 'static/media/[name].[hash].[ext]',
                  },
                },
              ],
              issuer: {
                and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
              },
          },
       }
    }
}

Plugins(플러그인)

CRA의 react-script 설정

1) npm run start

먼저, 웹팩의 complier 옵션을 설정해줍니다.

const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;

const useTypeScript = fs.existsSync(paths.appTsConfig);
const urls = prepareUrls(
  protocol,
  HOST,
  port,
  paths.publicUrlOrPath.slice(0, -1)
);
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
  appName,
  config,
  urls,
  useYarn,
  useTypeScript,
  webpack,
});

두번째로 웹팩 서버 설정을 해줍니다. 짚고 넘어갈 지점은 headers에 CORS 허용을 위해 'Access-Control-Allow-Origin': '','Access-Control-Allow-Methods': '', 'Access-Control-Allow-Headers': '*'로 처리된 것. 그리고 프로젝트의 public 디렉토리에서 정적 파일을 불러온다는 점입니다.

module.exports = function (proxy, allowedHost) {
  const disableFirewall =
    !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true';
  return {
    allowedHosts: disableFirewall ? 'all' : [allowedHost],
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': '*',
      'Access-Control-Allow-Headers': '*',
    },
    compress: true,
    static: {
      directory: paths.appPublic,
      publicPath: [paths.publicUrlOrPath],
      watch: {
        ignored: ignoredFiles(paths.appSrc),
      },
    },
    client: {
      webSocketURL: {
        hostname: sockHost,
        pathname: sockPath,
        port: sockPort,
      },
      overlay: {
        errors: true,
        warnings: false,
      },
    },
    devMiddleware: {
      publicPath: paths.publicUrlOrPath.slice(0, -1),
    },

    https: getHttpsConfig(),
    host,
    historyApiFallback: {
      disableDotRule: true,
      index: paths.publicUrlOrPath,
    },
    proxy,
    onBeforeSetupMiddleware(devServer) {
      devServer.app.use(evalSourceMapMiddleware(devServer));

      if (fs.existsSync(paths.proxySetup)) {
        require(paths.proxySetup)(devServer.app);
      }
    },
    onAfterSetupMiddleware(devServer) {
      devServer.app.use(redirectServedPath(paths.publicUrlOrPath));

      devServer.app.use(noopServiceWorkerMiddleware(paths.publicUrlOrPath));
    },
  };
};

마지막으로 두 옵션이 설정된 WebpackDevServer를 실행합니다. 그와 동시에, openBrowser 함수를 통해 browser에 번들링된 파일을 띄워줍니다.

const devServer = new WebpackDevServer(serverConfig, compiler);
devServer.startCallback(() => {
  if (isInteractive) {
    clearConsole();
  }

  if (env.raw.FAST_REFRESH && semver.lt(react.version, '16.10.0')) {
    console.log(
      chalk.yellow(
        `Fast Refresh requires React 16.10 or higher. You are using React ${react.version}.`
      )
    );
  }

  console.log(chalk.cyan('Starting the development server...\n'));
  openBrowser(urls.localUrlForBrowser);
});

2) npm run test

const jest = require('jest');
const execSync = require('child_process').execSync;
let argv = process.argv.slice(2);
...
jest.run(argv);

test.js에서는 argv 변수에 추가된 --watch, --env 등의 옵션을 조건에 따라 추가하여 jest를 실행해줍니다.

3) npm run build

build.js 스크립트에서는 start.js에서와 마찬가지로 번들링을 하고, 다른 점은 build/ (혹은 변경하려면, process.env.BUILD_PATH를 지정해줘야 합니다) 파일 디렉토리에 번들링한 결과를 저장합니다. 이것으로 배포할 수 있고 배포 스크립트는 따로 작성해야 합니다.

function build(previousFileSizes) {
  console.log('Creating an optimized production build...');
  const compiler = webpack(config);
  ...
}

마무리

이것으로 CRA에 관한 내용들을 분석해보았습니다. 부족한 부분이 있거나, 궁금한 것이 있으면 댓글로 남겨주세요. 읽어주셔서 감사합니다.

참고한 사이트

https://365kim.tistory.com/147
https://berkbach.com/create-react-app-%EC%9D%98-webpack-config-js-%EB%93%A4%EC%97%AC%EB%8B%A4%EB%B3%B4%EA%B8%B0-78e40bf37313
https://joshua1988.github.io/webpack-guide/devtools/source-map.html

반응형

댓글