🛠️

[디버깅 사례 공유] 제 컴퓨터에서는 됐었는데요...

태그
경험공유프론트엔드디버깅
최종 편집
Dec 30, 2022 11:21 PM
발행일
September 23, 2021
제 컴퓨터에서는 됐었는데 시연하려고 하니 안 되네요. 😭
https://flic.kr/p/DW2bXp

"데모의 법칙"은 개발자들에게는 유명한 농담이자 현실이다. 운영 환경에서만 발생하는 버그, 또는 환경에 따라 달라져서 재현하기 어려운 버그는 참 우리를 환장하게 만들지만, 어느정도 규모 있는 프로덕을 운영하다 보면 또 의외로 자주 맞닥뜨리기도 한다.

최근 몇 개월간 캐시노트 프론트엔드 팀도 이런 문제를 3번이나 겪었다. 신기하게도 셋 모두 create-react-app 에서 CSS module을 사용하면서, 개발 환경에서는 경험하지 못했던 문제가 운영 환경에서 발견된 것이었다. 기존에는 이런 문제들도 원인을 파악해서 해결하고 나면 너무 자명하게 느껴저서 기록을 남기지 않는 경우가 많았다. 이제부터라도 '아 이거 겪어본 문젠데... 어떻게 풀었더라?' 를 피하기 위해, 문제 발견과 해결 과정을 의식적으로 기록하여 미래의 나에게 도움을 주고자 한다.

문제 1: 특정 페이지에 진입한 뒤부터 모든 페이지에서 이미지가 일그러지는 현상

발견 및 재현

  • 캐시노트 운영 환경에서 이미지가 일그러진 채로 렌더링된다는 제보를 받았다.
image
  • 프론트엔드 팀원들이 각자 본인의 환경에서 해당 페이지에 들어가보니 모두 정상이었다.
  • 계속 실험하다 보니, 새로고침 후 처음에는 괜찮다가, 여러 페이지를 들락날락하다 보면 어느 순간 이미지가 일그러지기 시작하는 걸 확인했다. 페이지 진입 순서에 따라 이미지 스타일이 달라지는 것.
  • 최근에 추가한 기능의 페이지들부터 하나씩 찾다 보니, 특정 페이지에서 img 태그에 적용한 CSS 스타일이 문제의 원인임을 파악했다.

원인 분석

  • 위 페이지를 서빙하는, 캐시노트의 merlin 저장소에서는 CSS module을 써서 React 컴포넌트를 스타일링한다. CSS module은 빌드 타임에 CSS 클래스명을 해시로 바꿔서, 해당 클래스명이 다른 클래스명과 충돌하지 않게 해준다. 예를 들어 뉴스Item.module.css.thumbnailBox > img 는 이렇게 변환된다.
image
  • 그런데 문제가 된 페이지에서는 클래스명이 아닌 엘리먼트의 태그를 직접 이용해서 스타일링을 했다. 당연하게도, img 태그 자체는 해싱이 될 수 없으므로(태그를 해싱해버리면 스타일링 대상을 어떻게 찾겠는가?) 스타일 정의가 그대로 남는다.
image
  • merlin 은 모든 페이지 컴포넌트를 분할하여 지연 로드한다. module.css 파일도 페이지와 함께 번들링되어 있어서, 페이지가 로드될 때 CSS 스타일도 <head /> 에 주입된다. 페이지 컴포넌트가 unmount되더라도 이미 주입된 스타일이 사라지는 건 아니므로, 다시 뉴스 페이지로 가보면 두 스타일이 다 적용되면서 height 40, width 400으로 일그러진 이미지가 보이게 된다.
image

해결 및 환경 개선

  • 우선은 원인이 된 페이지에서는 클래스명으로 스타일링하도록 변경하여 다시 배포했다.
  • 그리고 기존 코드 및 CSS module을 사용하는 모든 저장소에서 엘리먼트 태그를 최상단 셀렉터로(i.e., 해싱할 수 없는 형태로) 사용하여 스타일링하는 부분이 없는지 전수조사했고, 다행히 없었다.
  • 마지막으로 환경 개선. 바람직한 버그 수정이라면, 비슷한 유형의 버그가 애초에 생기기 어렵게 환경을 바꿔주어야 한다. merlinnode-git-hookslint-staged 를 써서, pre-commit 단계에서 커밋 대상 파일에 대해 eslint, stylelint, 그리고 jest 테스트를 수행한다. 하나라도 실패하면 커밋할 수 없다. 따라서 stylelint 단계에서 rule을 추가하여 잘못된 CSS 코드가 커밋되지 않게 하면 충분하리라 판단했다.
  • 원인을 명확하게 파악했으니 rule을 정의하기도 쉬웠다. "module.css에서 엘리먼트 태그를 1차 셀렉터로 사용하지 못하게 하는" rule이 적절하리라 생각했고, 다행히 적당한 플러그인이 있었다. 없으면 만들려고 했는데 시간을 아꼈다.
// .stylelintrc.json 파일
{
  "extends": "stylelint-config-standard",
  "plugins": [
    "stylelint-selector-tag-no-without-class"
  ],
  "rules": {
    "plugin/selector-tag-no-without-class": "/.+/" // 모든 태그를 허용하지 않음
  }
}
/* index.css 파일. module.css가 아닌 곳에서는 허용해준다. */

/* stylelint-disable plugin/selector-tag-no-without-class */

html {
...

문제 2: 개발 환경과 운영 환경에서 CSS 스타일이 다르게 나오는 현상

발견 및 재현

  • 특정 페이지에서, 개발 환경과 운영 환경의 padding이 다르게 적용되는 것을 발견했다. 운영 환경에서는 훨씬 넓은 padding이 적용된 채 렌더링되었다.
개발 환경에서만 패딩이 적절하게 보인다.
개발 환경에서만 패딩이 적절하게 보인다.
image
  • 제보한 개발자 외에 다른 개발자들의 환경에서도, 개발환경에서는 괜찮고 운영환경에서는 패딩이 커지는 현상이 재현되었다.

원인 분석

  • 캐시노트에서는 공통 UI 컴포넌트를 feather 라는 디자인 시스템으로 구현하여, 각 프로덕에서 import해서 사용한다. 문제가 된 merlin 페이지에서 사용한 feather 컴포넌트는 <Cell /> 로, 목록의 item을 렌더링할 때 주로 썼다.
https://feather.kcd.co.kr/?path=/story/docs-usage-cell--cell-usage
  • merlin 페이지에서는 이 <Cell /> 의 아래 부분에 에러 메시지를 보여줄 수 있어야 했기 때문에, 한 번 wrapping하여 <CredentialCell /> 컴포넌트를 만들었다. (스타일 정의에는 tailwindcss를 사용한다)
// CredentialCell.tsx
<div className={cx([styles.wrap, className])}>
      <Cell className={styles.cell}>
...
/* feather > Cell.module.css */
.cell {
  @apply flex items-center py-16 bg-card;
}

/* merlin > CredentialCell.module.css */
.wrap {
  @apply py-16;
}

.cell {
  @apply py-0;
}
  • 확인해보니 개발환경에서는 CredentialCell.module.css 에서 정의한 스타일이 <head /> 에서 더 뒤에 위치해 있었지만, 운영환경에서는 Cell.module.css 의 스타일이 더 뒤에 위치해 있었다. 동일한 셀렉터에 대해 스타일링할 때 CSS는 specificity가 같다면 나중에 정의된 스타일을 우선해서 적용하므로, Cell은 py-0 이 아닌 py-16 을 가지게 됐고, 따라서 운영환경에서만 패딩이 더 넓어진 것이다.
  • 그러면 왜 두 환경에서 import 순서가 달랐을까? merlincreate-react-app(CRA)로 만들고 있었으므로, 환경의 차이로 인해 스타일 주입이 어떻게 달라지는지 CRA의 Webpack 설정 소스코드를 살펴봤다.
    • CRA는 개발 환경에서는 style-loader로 CSS를 주입한다. style-loader로 주입된 CSS는 기본적으로 <head />의 가장 아래로 가서, 주입된 CSS가 최상위 우선순위를 가지게 한다. (https://webpack.js.org/loaders/style-loader/#insert)
    • 운영 환경에서는, mini-css-extract-plugin 을 통해 각 컴포넌트에 적용되는 스타일이 CSS Chunk로 만들어진다. 이 CSS Chunk는 컴포넌트의 (렌더링 시점이 아닌) 로드 시점에 <head />에 주입된다.
    • 그리고 merlin 에서 feather의 UI 컴포넌트를 렌더링하는 시점에는 featherindex.js 가 필요한 스타일을 동적으로 <head /> 에 주입해준다.
    • 종합해보면, 개발 환경에서는 <CredentialCell /> 에서 <Cell /> 을 사용하는 시점에 Cell.module.css 가 주입되고, 그 다음 style-loader에 의해 CredentialCell.module.css 가 주입되면서 스타일이 오버라이드된다. 그러나 운영 환경에서는 CredentialCell.module.css 가 (CSS Chunk로) 먼저 로드되고, 그 다음 Cell.module.css 가 주입되기 때문에 오버라이드가 안 된다.

해결 및 환경 개선

  • 결과적으로 적용한 해결책은 간단했으나, 그 과정에서 나온 의견들과 그것이 왜 기각되었는지 기록해두는 것도 의미있으리라 생각하여 함께 적어둔다.
    • 의견 1. 모든 feather 컴포넌트의 CSS를 미리 import해두자
      • 번들 사이즈가 너무 커져서 하고 싶지 않다. 기각.
    • 의견 2. 전체를 미리 import할 수 없다면, 개별 feather 의 컴포넌트가 사용되기 전에 프리로드하여 미리 CSS가 주입되게 하자
      • 현재의 feather 는 컴포넌트별 import(e.g., import feather/button )를 할 수 없는 구조이다. 구조 변경에도 공수가 많이 들 뿐 아니라 개발자가 프리로드를 하나하나 챙기는 게 굉장히 까다로울 것이다. 기각.
    • 의견 3. 개발 환경과 운영 환경이 일치하도록 Webpack 설정을 수정해보자. 그런데 CRA는 기본적을 Webpack 설정 수정을 허용하지 않으므로...
      • eject 한다 → 너무 지저분하고 어렵다. 기각.
      • react-scripts 를 fork 떠서 수정한다 → 원본 react-scripts가 업데이트될마다 따라가야 하는데, 버전 관리하기 어려워서 하기 싫다. 기각.
      • CRACO로 eject하지 않고 수정해본다 → 채택
  • 개발 환경과 운영 환경을 일치시키는 방법이 두 개 있었다. 개발 환경에서 mini-css-extract-plugin 을 쓰거나, 운영 환경에서 style-loader 를 쓰거나.
    • 개발 환경을 바꾼다 → 운영 환경에서 진입경로에 따라 다른 스타일이 적용되는 문제가 그대로 존재하므로, 오히려 개발 환경에서 문제를 인지하기 어려워진다. 게다가 “문제 인지”는 될지언정, 해결은 !important 따위를 써서 merlin의 CSS가 specificity가 강해지도록 만들어줘야 하는데, 이를 챙기기 쉽지 않을 것이다.
    • 운영 환경을 바꾼다 → CRACO를 이용해서 시도해 보았고, 실제로 문제도 해결되는 걸 확인했다.
    • // craco.config.js
      webpack: {
      	configure: (webpackConfig, { env }) => {
      	  if (env === 'production') {
      	    removeLoaders(webpackConfig, loaderByName('mini-css-extract-plugin'));
      	    addBeforeLoaders(webpackConfig, loaderByName('css-loader'), {
      	      loader: 'style-loader',
      	    });
      	  }
      	  return webpackConfig;
      	},
      }
  • 일단 해결 방법 하나는 찾았으니, 더 나은 방법이 있는지 찾고 싶었다. 사실 '운영 환경과 개발 환경의 차이'라고는 하지만 이 차이는 성능을 위해 의도된 것인데, 일부의 문제를 위해 운영 환경에서의 성능을 희생하는 게 아주 찝찝했기 때문이다. 결국 근본적인 문제는 CSS import 순서이니, feather 가 항상 위쪽에 import되게 하거나, merlin 이 항상 아래쪽에 import되게 하는 다른 방법은 없을까?
  • 방법이 있었다! featherrollup으로 번들링하고, rollup-plugin-postcss 로 CSS를 처리하는데, 여기에 CSS를 <head /> 상단에 주입하게 하는 옵션이 있었다. 이걸 적용하니 개발자가 이후 개발할 때 추가로 신경써야 할 것도 없고, 성능 문제도 없이 아주 깔끔하게 해결됐다.
// feather > rollup.config.js
postcss({
  minimize: isProduction,
  inject: { insertAt: 'top' },
}), 

문제 3: 개발 환경에서 실행할 때는 문제가 없는데 프로덕션 빌드는 실패하는 현상

발견 및 재현

  • 특정 커밋 이후부터 github action을 통한 배포 파이프라인에서 빌드가 실패하여 배포되지 않는 현상이 발견되었다.
chunk 3 [mini-css-extract-plugin]
Conflicting order. Following module has been added:
 * css ./node_modules/react-scripts/node_modules/css-loader/dist/cjs.js??ref--5-oneOf-5-1!./node_modules/react-scripts/node_modules/postcss-loader/src??postcss!./src/forms/InlineSubmitForm.module.css
despite it was not able to fulfill desired ordering with these modules:
 * css ./node_modules/react-scripts/node_modules/css-loader/dist/cjs.js??ref--5-oneOf-5-1!./node_modules/react-scripts/node_modules/postcss-loader/src??postcss!./src/components/ItemList.module.css
   - couldn't fulfill desired order of chunk group(s) , 
   - while fulfilling desired order of chunk group(s) , , , , , , ,
  • 개발 환경에서는 해당 커밋 이후에도 실행(i.e., yarn start)이 잘 되고 있었는데, 빌드를 실행해보니 빌드 자체는 성공하지만 똑같은 경고가 떴다.

원인 분석 및 해결

  • 일단 빌드할 때, 즉 운영 환경에서만 문제가 되는 이유는 문제 2에서 봤던 대로 mini-css-extract-plugin 가 운영 환경에서만 작동하기 때문이었다.
  • "Conflicting order"라는 키워드로 검색해보니 CRA의 이슈로도 제보되어 있고 stackoverflow에도 질문이 올라와 있었다. stackoverflow 답변이 아주 자세해서 이해하는 데 큰 도움이 되었는데, 요약하면 이 문제는 CSS Chunk를 만들어내는 과정에서 두 CSS 파일이 서로 다른 순서로 import되고 있어서, 두 청크에서 서로 다른 스타일 결과물이 나올 수 있다는 경고였다.
  • 제시된 해결책 중 하나는 eslint 플러그인 등을 이용하여 JS에서 파일의 import 순서를 항상 일정하게 유지하는 것이었다. 그러나 우리는 이미 simple-import-sort 플러그인을 쓰고 있었기 때문에 파일 import 순서는 항상 일정한 상태였으며, 청크가 어떻게 구성되느냐에 따라 한 파일 내에서의 import 순서를 일정하게 유지하더라도 conflicting order 문제는 언제나 생길 수 있었다.
    • 실제로, 위 경고에서는 InlineSubmitForm.module.cssItemList.module.css 에서 order conflict이 생겼다고 했는데 정작 문제가 된 커밋에서는 두 CSS 파일을 변경하지 않았다. 이 커밋에서 새로 추가한 파일 A가 SearchBar.tsx 를 import하고, SearchBar.tsx에서 InlineSubmitForm.tsx 를 import했을 뿐인데 경고가 생긴 것이다.
  • 이런 문제를 개발자가 직접 인지하고 고치는 것은 불가능하다고 판단했기에, 우리는 "경고를 무시한다"는 간단한 해결책을 사용했다. melrin 은 CSS module을 쓰고 있으니, 각 CSS 파일들은 모두 독립적으로 작동한다. 즉 order conflict가 서로 다른 스타일 결과물을 만들어내지 않기 때문에, 이 경고를 무시해도 괜찮았다.
  • 문제 2에서 봤듯 CRA에서 Webpack 설정의 직접 수정을 허용하지 않으므로 CRACO를 이용해 수정했다.
// craco.config.js
webpack: {
  configure: (webpackConfig, { env }) => {
    if (env === "production") {
      const instanceOfMiniCssExtractPlugin = webpackConfig.plugins.find(
        (plugin) => plugin.constructor.name === "MiniCssExtractPlugin",
      );
      instanceOfMiniCssExtractPlugin.options.ignoreOrder = true;
    }
    return webpackConfig;
  },
},

Next Step

문제 1은 문제 해결뿐 아니라, stylelint plugin을 추가하여 유사한 문제가 발생할 수 없도록 개발 환경을 개선했다. 반면 문제 2와 문제 3은, 해결까지는 됐으나 근본적인 환경 개선까지는 아직 가지 못했다.

문제 2와 3은 표면적 현상은 달랐지만, “ create-react-app 이라는 프론트엔드 프레임워크가 운영 환경에서 동작하는 방식에 대한 이해 부족”에서 비롯된 문제라는 점에서는 비슷하다. 이를 근본적으로 개선하기 위해, 캐시노트 프론트엔드 팀 안에서는 CRA와 Webpack에 대한 이해를 높이고, 더 나아가 merlin 에서 CRA를 걷어내기 위한 준비를 하고 있다. 이러한 과정도 잘 정리해서 다음 블로그 글로 발행해보고 싶다.