기존 환경
문제가 발생한 환경을 설명하려면 오래전으로 거슬러 올라가야 한다. (옛날옛적에🧓…까지는 아니지만)
작업하고 있는 웹사이트는 오래된 프로젝트로, 초기에는 TypeScript의 namespace 패턴을 사용해 모듈처럼 구조화한 파일들을 실행해 작동 되었다. (JS로 컴파일해 로드하니, 편의상 이를 레거시 JS 파일이라고 부르겠다.)
이후 React.js를 도입하면서 신규 기능들을 개발하게 되었고, 신규 기능들은 웹팩(Webpack)으로 번들링하여 entry.js 파일을 통해 제공했다.
이 entry.js를 실행하면 전역변수 entryPoint를 선언하고, 그 속성에 외부에서 사용할 기능들을 할당했다. HTML 내 다른 스크립트에서는 entryPoint를 통해 React로 개발된 기능을 호출하고 렌더링할 수 있었다.
// entry.js
var entryPoint = {
someFeatureLoader: () => {...},
common: {
someFunc: () => {...},
},
};
<script src="/out/myApp/entry.js" type="text/javascript"></script>
<script>
window.entryPoint.someFeatureLoader();
</script>
이렇게 기존 레거시 JS 파일로 개발된 기능들을 점차 대체했지만, 일부는 여전히 남아 있어 레거시 JS 파일과 신규 모듈이 공존하는 형태가 되었다. 자연스럽게 레거시 JS 파일에서 신규 모듈의 기능을 사용하는 경우가 생겼고, 레거시 코드가 entryPoint에 의존하게 되었다.
// legacyFeature.ts
namespace LegacyFeature {
export async function init() {
const result = window.entryPoint.common.someFunc();
...
}
}
HTML에서는 <script> 태그를 통해 파일을 로드하는데, 스크립트가 로드된 순서대로 실행되기 때문에 코드 순서만 잘 조정하면 문제없이 동작했다.
<script src="/out/myApp/entry.js" type="text/javascript"></script>
<script src="./js/legacyFeature.js" type="text/javascript"></script>
<script>
// 실행 시점에는 entryPoint가 이미 선언되어 있음 → 정상 동작
window.LegacyFeature.init();
</script>
문제 발생 상황
성능 최적화 및 트리 셰이킹(Tree Shaking) 개선을 위해 기존 모듈을 ESM(ECMAScript Module) 방식으로 변경하면서 문제가 발생했다.
ESM 방식에서는 <script type="module">을 사용해야 하며, 모듈이 비동기적으로 로드되기 때문에 entry.js에서 entryPoint가 등록되기 전에 레거시 스크립트가 실행될 수 있다.
<script src="/out/myApp/entry.mjs" type="module"></script>
<script src="./js/legacyFeature.js" type="text/javascript"></script>
<script>
// ❌ 오류 발생: entryPoint가 아직 선언되지 않음
window.LegacyFeature.init();
</script>
그럼 나중에 실행하면 되지 않나?
처음에는 단순히 스크립트 실행 순서를 조정해주면 다 해결될 것 같았다. entry 모듈 실행 후에 entryPoint에 접근하는 레거시 JS 파일을 실행하면 되겠다고 생각했다. 하지만 레거시 JS 파일에 선언된 전역변수에 의존하는 또 다른 레거시 파일이 있고, 그 파일에 의존하는 또 다른 파일이 있고... 줄줄이 변경해주어야 했다. 의존성을 파악하는 것도 쉽지 않았고, 이를 모두 수정하는 것은 현실적으로 불가능하다고 판단했다.
Proxy를 활용한 해결
문제 해결 방법을 고민하던 중, 리더님이 Proxy 객체를 활용을 조언해 주셨다.
핵심 아이디어는 전역변수 entryPoint를 Proxy로 감싸서, 아직 정의되지 않은 속성에 접근하더라도 동적으로 필요한 모듈을 로드하도록 만드는 것이었다. 즉, entryPoint.common.someFunc()처럼 기존 코드에서 전역변수를 참조하더라도, 해당 속성이 존재하지 않을 경우 모듈을 불러올 때까지 지연시켰다가 실행되도록 하는 것이다.
entryPoint 접근 시 Proxy에서 Promise를 반환하도록 처리하기 때문에 해당 객체를 사용하는 코드에 비동기 처리가 필요했지만, 적용해야 할 곳이 한정적이었고 많지 않았기 때문에 이 방법을 채택했다.
JavaScript Proxy란
Proxy는 ES6(ES2015)에서 도입된 기능으로, 특정 객체를 감싸서 기본 동작(속성 조회, 할당, 함수 호출 등)을 가로채고 원하는 대로 동작을 변경할 수 있게 해주는 객체다.
기본 문법은 다음과 같다.
const proxy = new Proxy(target, handler);
- target → Proxy로 감싸게 될 대상 객체
- handler → Proxy의 동작을 정의하는 객체 (트랩(trap) 메서드를 포함)
- handler에서 대상 객체에 대한 동작을 가로채서 어떻게 변경할지 정의할 수 있다. 아래는 handler에 get, set 트랩을 정의한 예제이다.
const user = {
name: "홍길동",
age: 5
};
const userProxy = new Proxy(user, {
// get 트랩: 속성을 읽을 때 호출됨
get: function(target, property) {
console.log(`${property} 속성 읽기`);
return target[property];
},
// set 트랩: 속성을 설정할 때 호출됨
set: function(target, property, value) {
console.log(`${property} 속성을 ${value}로 설정`);
target[property] = value;
return true;
}
});
// 동작 테스트
console.log(userProxy.name);// "name 속성 읽기" 출력 후 "홍길동" 출력
userProxy.age = 25;// "age 속성을 25로 설정" 출력
Proxy의 장점
- 객체의 기본 동작을 수정할 수 있어 유연성이 높다.
- 원본 객체를 수정하지 않고도 추가 기능을 구현할 수 있다.
- 접근 제어, 유효성 검사, 로깅 등 다양한 용도로 활용 가능하다.
더 자세한 Proxy에 대한 설명과 사용 방법은 다음 문서를 읽어보는 것을 추천한다.
https://ko.javascript.info/proxy
Proxy와 Reflect
ko.javascript.info
Proxy 적용 방법
ESM 환경에서는 모듈이 비동기로 로드되므로, 기존처럼 전역변수(entryPoint)에 즉시 접근하는 방식이 동작하지 않는다. 이를 해결하기 위해 Proxy를 활용해 전역변수를 감싸고, 필요한 경우 동적으로 모듈을 로드하는 방식을 적용했다.
- 전역변수 entryPoint를 Proxy로 감싼다.
- 기존 전역변수를 직접 할당하는 대신 Proxy를 사용해 속성 접근을 감지한다.
- Proxy 핸들러 정의
- get 트랩을 활용해 속성 접근을 감지하여 새 Proxy 생성해 반환하여 트리 구조를 만든다.
- 예: entryPoint.common.someFunc()처럼 계층적으로 접근할 경우, Proxy를 중첩 생성해 트리 구조를 만든다.
- apply 트랩을 활용해 함수 호출을 가로채고, 필요한 모듈이 아직 로드되지 않았다면 동적으로 로드한 후에 실행한다.
- get 트랩을 활용해 속성 접근을 감지하여 새 Proxy 생성해 반환하여 트리 구조를 만든다.
- 레거시 코드 내 entryPoint 접근 코드에 async/await을 적용해 비동기 처리
마무리하며
Proxy를 활용하여 레거시 JS 코드의 대규모 수정 없이도 ESM 환경에서 안정적으로 동작하도록 전환할 수 있었다. 레거시 JS 파일까지 ESM으로 전환하진 못했지만, 레거시 코드는 점차 제거될 예정이니 공수를 따졌을 때는 가장 괜찮았던 해결 방법이라 생각한다. 스스로 찾아낸 해결방안은 아니지만 Proxy에 대해 알게 되고 활용 방법을 익힐 수 있었던 유익한 경험이었다.