공공기관 산하 사이트에 새 기능을 개발하는 일을 맡았습니다. 공공기관 프로젝트는 이해관계자가 매우 많은 경우가 허다합니다. 이 프로젝트에서는 발주처, 운영사, 개발사, 외주 업체(신기능 개발을 맡은 우리 팀)이 있었고요. 아무튼 모든 일은 아래 두 가지 때문에 벌어지게 되는데요…
운영사와 원 사이트 개발사가 다르고, 운영사도 여러 개
이 사업에는 여러 외주 업체가 여러 기능을 개발해 삽입하고, 해당 기능을 유지보수하는 것도 맡는다.
즉 의사결정이 한 곳에서 이루어질 수 없는 구조여서 간단한 안건 하나도 4-5일 이상 여러 메일을 통해 표류하다가 결정되는 환경이었습니다.
처음에는 별도 서비스로 제공할 예정이었다가, 운영사의 사이트 안에 해당 사이트를 삽입하는 것으로 개발 말미에 요구사항이 바뀌었습니다.
프로젝트를 향후 유지보수할 때 매우 난감할 수 있겠다고 생각되었고, 제가 사용하던 개발 프로세스를 통해 작업할 수 있기를 희망했기 때문에 iframe을 제안했습니다. 처음에는 승인이 떨어졌고요, 그 방법으로 개발해서 개발 서버에 업로드했습니다.
내부망에 있는 백엔드 api를 클라이언트가 직접 호출하면 안 된다는 요구사항이 있어, Java Controller를 만들어 클라이언트에서는 프록시된 요청을 하도록 만들었습니다. iframe의 postMessage를 통해 요청과 응답을 주고받았습니다. 문제는 3일 뒤에 벌어지는데요,
iframe의 보안 정책 변화 등으로 장애의 소지가 될 수 있을 듯하니 톰캣 서버에서 기동하도록 수정해달라는 요청이 왔습니다. 괜찮다고 한 사람과 다른 사람이 지적했는데… 합당한 지적이었는지는 둘째치고 문제는 이게 프로젝트 제일 처음에 고려되었어야 하는 이야기였다는 점입니다. 이해당사자가 많은 프로젝트에서는 이런 일이 흔할 것 같다는 생각도 들었습니다.
이미 React로 기능 개발이 끝난 상황이었기 때문에 jsp 기반으로 모든 것을 다시 구현하는 것은 무리라고 생각했고, React app의 html이 <div id="root" /> 안에 js 번들로 조작을 가하는 식으로 빌드가 된다는 점에서 착안해 이 구조를 유지하면서 그대로 JSP에 앱을 이식하는 방향으로 결정했습니다.
그래서 이런 JSP 코드가 작성되게 되는데요…
<c:set var="ctx" value="${...contextPath}" />
...
<div id="root"></div>
<script src="${ctx}/.......js"></script> <!-- React 번들 -->React 앱이 아니라, JSP 페이지의 기존 내비게이션이 작동을 멈췄습니다. 다른 페이지에 들어가 작동하는 방식을 눈으로 살펴보니 내비게이션이 가장 나중에(?!) 로드되는 모습을 보였는데요, React App이 로드되다가 에러가 발생하면 그 뒤 코드가 실행되지 않아 생기는 문제로 보였습니다. Iframe으로 개발되었던 당시에는 런타임이 완전히 다르기 때문에 벌어지지 않는 문제였어요.
Uncaught TypeError: Cannot read properties of undefined (reading 'css')
at hasOwnProperty.call(props, 'css')
at jsx (xxxxxxx.js:...)
at HTMLElement.dispatch (jquery-2.1.1.js:3:6404)
at HTMLElement.handle (jquery-2.1.1.js:3:3179)이런 문제였는데, 에러 메시지를 읽어보면 근본 원인은 컨텍스트 오염이라고 판단할 수 있습니다. 아래 상황을 통해 쉽게 추론할 수 있는데요,
뜬금없이 jsx 에러가 발생
jsx는 앱 전체에 영향을 미치는 함수
갑자기 스택에 우리 프로젝트와 상관없는 jquery가 등장
표면적인 문제는 css 라이브러리인 emotion의 jsx가 jquery의 DOM 조작 함수를 끊은 것으로 보입니다. 다른 증거를 찾기 위해 우선 emotion의 css를 모두 제거해 봤더니, 새로운 에러 메시지가 발견됐습니다.
Uncaught TypeError: $(...).closest is not a function
at HTMLElement.<anonymous> (...)
at HTMLElement.handle (jquery-2.1.1.js:3:10107)
...JSP 사이트의 거의 모든 JS는 ES6 이전 코드(var)로 작성되어 있었습니다. 게다가 전역 스코프에 모든 코드가 노출되어서 컨텍스트 오염이 일어나게 됐습니다. 특히 빌드된 React 번들이 $ 라는 이름의 변수를 사용하게 되면서 앞선 전역에 있는 jQuery와 서로 같은 이름의 변수를 사용하게 되는 문제가 발생했던 것입니다. 실제로 빌드된 번들을 열어보면 minify가 일어나면서 $에 변수를 할당하고 있습니다. 즉 jquery가 참조하려고 했던 $가 Jquery가 아니게 된 것입니다. 위의 에러 메시지는 그 방증이 됩니다.
즉 React App의 스코프 분리가 절실한 상황이었고 해결책을 찾던 중 예전에 공부했던 JS Module의 IIFE 빌드가 생각났습니다.
빌드 결과물을 IIFE에 가둡니다. 아래처럼요. 이전에 JS 모듈에 대해서 공부했던 적이 있었기 때문에 레거시 프로젝트들에서 이런 식의 빌드를 많이 했다는 것을 알고 있었습니다.
var e=Object.create,... //before
(function(){var e=Object.create,...})(); //after이렇게 되면 모든 변수가 함수 스코프에 갇히기 때문에 전역에 선언되지 않고, 외부에서 이 스코프 안의 변수를 건드릴 일이 없게 됩니다. 즉 jQuery가 React 내부의 변수들을 참조하는 현상이 사라지게 될 것이라고 생각해볼 수 있습니다.
//vite.config.ts
build: {
rollupOptions: {
output: {
format: 'iife',
},
},
}우습게도 이 한 줄로 모든 상황이 깔끔하게 해결됐는데요, 가장 기초적인 지식 중 하나인 스코프가 이런 문제를 일으켰고(레거시 프로젝트에서는 흔한 상황이었을 것 같지만), 고민했던 시간과 프로젝트 규모에 비해 쉽게 해결할 수 있었던 경험이었습니다.
빌드 결과물 전체를 하나의 함수 안에 가두기 때문에 코드 스플리팅이 난해하다는 부작용이 있습니다. 예상 외로 클로저 관련 부작용은 거의 없고, 오히려 번들 크기 최적화 관점에서 문제가 있을 수는 있습니다.
const HeavyChartComponent = lazy(() => import('./HeavyChart'))React.lazy를 사용하면 메인 번들에서 이렇게 무거운 컴포넌트 번들을 분리해 필요할 때만 import할 수 있는데요, 이렇게 lazy loadiing을 하는 경우에도 IIFE로 빌드하는 경우 결국 하나의 function 안에 들어가야 하고, 번들을 쪼갤 수 없게 됩니다. React.lazy는 나눠진 번들을 필요한 시점에 fetch하는 식으로 작동하는데, iife로 빌드할 때 결국 하나의 함수로 합쳐야 한다는 점과 양립할 수 없기 때문입니다.
애시당초 IIFE를 도입한 이유가 각기 다른 jsp 컴포넌트가 합쳐져 여러 스크립트 파일이 전역 오염을 일으켜 런타임 에러를 유발했기 때문인데, 이 상황에서 번들을 쪼개는 대신 하나로 합쳐 안정성을 꾀한 것이라고 할 수 있겠습니다.
최근 레거시 환경에서 프론트엔드를 개발할 때 겪은 시행착오를 정리해 봤습니다. JSP와 React가 공존하게 된 경위를 한번 정리해 봤는데요, 프론트엔드 개발자가 되면서 레거시 프로젝트에 진입할 일이 없을 것이라고 생각했었는데 좋은 경험을 하게 됐습니다. 아무튼 이 복잡한 상황에서 정말 기초적인 지식 하나로 해결이 된 케이스라서 다양하게 호기심을 가지고 이것저것 뜯어봤던 경험이 헛된 것이 아니었음을 다시 한 번 깨닫습니다.
다소 외람되지만… 또 하나의 교훈은 Next.js 같은 큰 프레임워크를 처음부터 쓰지 말라는 것이었습니다. Vite + React로 최대한 가볍게 시작한 것이 빌드 설정을 간단히 바꾸어 가볍게 문제를 해결할 수 있었던 원인이었기 때문입니다. (처음부터 SSR 프레임워크를 썼다면 빌드 포맷을 IIFE로 바꾸는 식의 우회 자체가 불가능했을 것입니다.)