여러분, 오늘은 빈말 없이, 제가 최근에 겪은 큰 함정과 그 해결 방법에 대해 이야기해보려 합니다. 상황은 이렇습니다. 저희 운영 환경에 오래된 프로젝트가 하나 있는데, 계속 잘 돌아가다가 최근에 사용자들이 가끔 페이지가 하얗게 뜬다고 피드백을 주기 시작했습니다. 오류율이 높진 않았지만, 기본 사용자 수가 많다 보니 매일 수많은 error 로그가 제 이메일로 쏟아졌습니다. 처음에는 백엔드 API 문제인 줄 알고 한참을 찾았는데 이상이 없었고, 나중에 프런트엔드 모니터링을 보니 그제서야 상황을 파악했습니다. 전부 JS 오류였고, 압축된 bundle 파일에 집중되어 있었습니다.
아시다시피, 프로덕션 환경의 JS는 모두 압축되어 있어서 한 줄에 수만 자가 들어가고, 오류 메시지는 도저히 읽을 수가 없습니다. 저는 그 'Unexpected token'을 한참 동안 쳐다보며, 마치 저를 비웃는 것 같다고 느꼈습니다. 결국 방법이 없어서 sourcemap을 켜서 위치를 추적했더니, 오류 발생 지점이 다양했지만 공통점이 하나 있었습니다. 바로 조건문 안에 작성된 코드이거나 ES6+의 새로운 문법을 사용한 코드였습니다.
이때서야 저는 문제가 압축 설정에 있을 수 있다는 것을 깨달았습니다. 저희 프로젝트에 사용된 압축 도구가 비교적 오래된 것이었는데, 당시에 편하게 하려고 기본 설정을 그냥 사용했고 자세히 살펴보지 않았습니다. 기본 설정에는 'drop_debugger', 'compress' 같은 몇 가지 최적화 옵션이 있는데, 오래된 코드에는 재앙이나 다름없었습니다.
가장 전형적인 예를 하나 들겠습니다. 압축 도구는 기본적으로 '항상 거짓'이라고 판단하는 조건 분기를 삭제해버립니다. 예를 들어 코드에 if (typeof window !== 'undefined') 같은 판단을 작성했다고 합시다. 목적은 SSR을 지원하거나 특수 환경에서 오류가 나는 것을 방지하기 위해서입니다. 하지만 압축기 입장에서는 이 판단이 불필요하다고 생각합니다. 컨텍스트를 분석해 window가 반드시 존재한다고 판단하고, if 블록 전체를 통째로 제거해버립니다. 결과는? 실제 기기, 일부 WebView 환경에서는 window의 특정 속성에 접근할 수 없어서 코드가 바로 크래시가 납니다.
더 심각한 문제도 있습니다. 압축기가 함수 내 변수 이름을 극도로 짧게 바꿔버립니다. 예를 들어 userName을 a로, orderList를 b로 바꿉니다. 대부분의 경우 문제없지만, 코드에서 eval이나 new Function을 사용하고 그 안에서 외부 변수 이름을 참조한다면, 압축 후 변수 이름이 맞지 않아 바로 ReferenceError가 발생합니다. 저희 운영 환경에도 이런 오래된 모듈이 몇 개 있는데, 평소에는 문제없다가 압축하면 망가집니다.
그래서 최근 이틀 동안 마음을 다잡고, 일부러 오후 내내 이 압축 설정을 연구했습니다. 바꾸고 나서 어떻게 됐게요? 운영 환경 오류가 80%나 줄었습니다! 정말 미신이 아니라, 설정이 제대로 되어 있지 않았던 것입니다.
제가 구체적으로 몇 가지를 변경했는데, 메모해 두시면 언젠가 도움이 될지도 모릅니다.
첫째, compress 옵션에서 conditionals와 dead_code를 꺼버렸습니다. 압축기가 멋대로 코드 분기를 삭제하지 못하게 하는 것입니다. 이렇게 하면 압축률이 약간 떨어져서 몇 KB 정도 늘어날 수 있지만, 안정성을 얻는 대가로 충분히 가치가 있습니다. 특히 오래되고 다양한 호환성 판단으로 가득한 레거시 프로젝트에서는 이 두 옵션이 시한폭탄입니다.
둘째, mangle의 eval 매개변수를 true로 설정했습니다. 이는 코드에서 eval이나 new Function이 감지되면 그 안의 변수 이름을 변경하지 않거나, 해당 함수 내 변수를 줄이지 않고 유지하라는 의미입니다. 이렇게 하면 압축 효과가 다소 줄어들지만, 운영 환경에서 크래시가 나는 것보다는 훨씬 낫습니다.
셋째, compress의 unused도 껐습니다. 이 옵션은 '정의되었지만 사용되지 않은' 매개변수를 삭제합니다. 하지만 때로는 일부러 매개변수 하나를 자리 표시자로 남겨두는 경우가 있습니다. 예를 들어 콜백 함수에서 세 번째 매개변수가 실제로 유용하고, 앞의 두 개는 고정된 시그니처인 경우입니다. 압축기는 이를 모르고, 앞의 두 개가 사용되지 않는다고 판단해 바로 삭제해버립니다. 그 결과 콜백 매개변수가 어긋나서 호출 시 undefined를 받게 되어 로직이 완전히 꼬여버립니다.
넷째, 가장 쉽게 간과되는 부분인 output의 ascii_only 설정입니다. 코드에 중국어나 이모지 문자열이 있다면 이 설정을 false로 두는 것이 좋습니다. 그렇지 않으면 모두 \uXXXX 같은 이스케이프 시퀀스로 변환됩니다. 기능은 같지만 문자열 길이가 폭발적으로 늘어나고, 일부 오래된 브라우저는 매우 긴 문자열을 파싱할 때 문제가 생겨 멈추거나 오류가 발생할 수 있습니다.
어쨌든 지금 제 설정은 '불필요한 간섭 금지' 모드입니다. 압축의 목적은 원래 용량을 줄이는 것이지만, 용량을 줄이려다 기능을 망가뜨린다면, 절약한 트래픽은 야근 비용도 충당하지 못합니다. 계산해 보니 설정을 바꾼 후 번들 파일 크기는 약 15% 정도만 증가했지만, 운영 환경 오류율은 하루 수백 건에서 하루 한 자릿수로 줄었습니다. 그 정도 용량은 아무것도 아닙니다.
마지막으로 한 가지 당부드리자면, 압축 설정을 변경한 후에는 반드시 전체 회귀 테스트를 실행해야 합니다. 특히 라우트 지연 로딩이나 동적 import와 관련된 부분은 더욱 그렇습니다. 제가 왜 아는지 묻지 마세요. 말하면 눈물만 납니다. 어쨌든 지금은 운영 환경이 안정적이라 잘 잘 수 있습니다. 여러분도 비슷한 문제가 있다면 코드 로직을 의심하지 말고, 먼저 압축 설정이 '역효과'를 내고 있는지 확인해 보세요.