자바스크립트에서 globalThis의 소름끼치는 폴리필


원문: https://mathiasbynens.be/notes/globalthis

globalThis 제안은 모든 자바스크립트 환경에서 전역 this에 접근할 수 있는 메커니즘을 도입한다. globalThis의 폴리필은 매우 간단할 것 같지만, 정확하게 만들기는 매우 어렵다. Toon이 창조적인 해결책으로 나를 깜짝놀래키기 전까지 나는 그것이 가능하다고 생각조차 못했다.

이 글은 globalThis의 적절한 폴리필 작성이 어렵다는 것을 설명한다. 폴리필은 아래의 요구사항을 따라야한다.

  • 브라우저, 브라우저의 워커, 브라우저의 확장프로그램, Node.js 그리고 standalone(이하 독립형) 자바스크립트 엔진 바이너리에서 모두 동작해야 한다.
  • sloppy mode(이하 느슨한 모드), strict mode(이하 엄격한 모드) 그리고 자바스크립트 모듈을 모두 지원해야 한다.
  • 코드가 실행되는 컨텍스트에 관계없이 동작해야 한다. (즉, 빌드할 때 폴리필이 엄격한 모드인 함수로 래핑 된 경우에도 정확하게 동작해야 한다.)

용어

먼저 용어에 대해서 정리하겠다. globalThis는 전역 스코프의 this 값을 제공한다. 이것은 복잡한 이유로 브라우저의 전역 객체와는 다르다.

자바스크립트 모듈에서 모듈 스코프는 전역 스코프와 당신의 코드 사이에 개입되어 있다는 점에 유의해야 한다. 모듈 스코프는 글로벌 스코프의 this를 숨기기 때문에 모듈 스코프의 최상단에서 thisundefined이다.

즉, globalThis는 "전역 객체"가 아니며, 단지 전역 스코프의 this를 의미한다. 이 중요한 뉘앙스를 이해시켜준 Domenic에게 감사한다.

globalThis의 대안

브라우저에서 globalThis와 동일한 것은 window이다.

globalThis === window;
// → true

frames도 동일하게 동작한다.

globalThis === frames;
// → true

하지만, windowframes는 (웹 워커와 서비스 워커와 같은) 워커 컨텍스트 안에서 undefined이다. 운 좋게도 self는 모든 브라우저 컨텍스트에서 동작하므로 더 강력한 대안이다.

globalThis === self;
// → true

Node.js에서는 window, frames, self을 사용할 수 없지만, global을 사용할 수 있다.

globalThis === global;
// → true

jsvu으로 설치하는 것과 같은 독립형 자바스크립트 엔진 쉘에서는 위에서 설명한 모든 것(window, frames, self, global)을 사용할 수 없다. 여기서는 전역 this에 접근할 수 있다.

globalThis === this;
// → true

게다가 느슨한 모드 함수는 그들의 this를 전역 this로 설정할 수 있기 때문에, 코드를 전역 스코프에서 실행할 수 없더라도 전역 this에 접근할 수 있다.

globalThis === (function() {
	return this;
})();
// → true

하지만, 자바스크립트 모듈에서 최상단의 thisundefined이고, 엄격한 모드의 함수안에서 thisundefined이다. 그래서 이 접근방법은 여기서 동작하지 않는다.

일단 엄격한 모드의 컨텍스트 안에서 임시로 엄격한 모드를 깨고 나올 수 있는 방법은 오직 하나다. Function 생성자는 느슨한 함수를 생성한다.

globalThis === Function('return this')();
// → true

음, eval도 같은 효과를 내기 때문에 두 가지 방법이 있다.

globalThis === (0, eval)('this');
// → true

브라우저에서 Function 생성자와 eval을 사용하는 것은 컨텐츠 보안 정책(Content Security Policy (CSP))때문에 종종 허락되지 않는다. 웹사이트는 종종 이런 정책을 옵션으로 가져가지만, 예를 들어 크롬 확장프로그램 안에서는 시행하고 있다. 불행하게도, 이것은 적절한 폴리필이 Function 생성자와 eval에 의존할 수 없다는 것을 의미한다.

주의: setTimeout('globalThis = this', 0)는 같은 이유로 제외되었다. 게다가 일반적으로 CSP에 의해서 막힌다. 또한 setTimeout을 폴리필에 사용하는 것에 반대하는 이유가 두 가지 더 있다. 첫 번째는 ECMAScript의 스펙이 아니고 모든 자바스크립트 환경에서 사용할 수 없다. 두 번째는 비동기적이어서 setTimeout이 모든곳에서 지원된다고 하더라도, 다른 코드에 의존하는 폴리필을 사용하는 것은 고통스러울 것이다.

간단한 폴리필

위의 기법들을 결합하여 아래와 같이 하나의 폴리필을 만들 수 있다.

// 네이티브 globalThis 보강판. 이것은 사용하지 마세요!
const getGlobal = () => {
	if (typeof globalThis !== 'undefined') return globalThis;
	if (typeof self !== 'undefined') return self;
	if (typeof window !== 'undefined') return window;
	if (typeof global !== 'undefined') return global;
	if (typeof this !== 'undefined') return this;
	throw new Error('전역 객체를 찾을 수 없습니다');
};
// 주의: 전역 스코프에서 실행될 때 `globalThis`를 전역 객체로 만들기 위해 
// (어휘적(lexical) 범위의 최상단 변수와는 대조적으로)
// `const` 대신에 `var`를 사용했다.
var globalThis = getGlobal();

하지만 이 코드는 엄격한 모드의 함수나 브라우저가 아닌 환경의 자바스크립트 모듈 안에서는 동작하지 않는다. (globalThis를 지원하는 경우는 제외). 게다가 getGlobal은 잘못된 결과를 반환할 수 있다. 왜냐하면 this에 의지하는데, this는 컨텍스트에 의존적이고 번들러에 의해서 변경될 수 있기 때문이다.

강력한 폴리필

globalThis의 강력한 폴리필을 작성할 수 있을까? 다음과 같은 환경이 있다고 가정해보자.

  • globalThis, window, self, global, this에 의존할 수 없다.
  • Function 생성자와 eval을 사용할 수 없다.
  • 하지만 다른 자바스크립트 내장 기능의 무결성에 의존할 있다.

해결책은 찾았지만 방식이 아름답지는 않다. 이것에 대해서 잠시 생각해보자.

전역 this에 직접 접근하는 방법을 모른채 전역 this에 어떻게 접근할 수 있을까? 만약 globalThis에 함수 프로퍼티를 어떻게든 지정하고, 그것을 globalThis의 함수로써 호출한다면, 그 함수 안에서 this를 접근할 수 있다.

globalThis.foo = function() {
	return this;
};
var globalThisPolyfilled = globalThis.foo();

globalThisglobalThis를 참조하는 바인딩 없이 어떻게 그것이 가능한가? 다음 코드는 실행 할 수 없다.

function foo() {
	return this;
}
var globalThisPolyfilled = foo();

foo()는 더 이상 함수로 호출되어질 수 없고, 또한 위에서 언급했듯이 엄격한 모드나 자바스크립트 모듈에서 이 함수 안의 thisundefined이다. 엄격한 모드의 함수는 그들의 thisundefined로 설정한다. 하지만 gettersetter의 경우는 아니다.

Object.defineProperty(globalThis, '__magic__', {
	get: function() {
		return this;
	},
	configurable: true // 이것은 나중에 getter를 `delete` 가능하게 한다.
});
// 주의: 전역 스코프에서 실행될 때 `globalThis`를 전역 객체로 만들기 위해 
// (어휘적(lexical) 범위의 최상단 변수와 대조적으로)
// `const` 대신에 `var`가 사용되어진다.
var globalThisPolyfilled = __magic__;
delete globalThis.__magic__;

위의 코드에서 globalThis에 getter를 설정하고 getter를 통해서 globalThis의 참조를 가저온 다음, 더이상 필요없는 getter는 삭제한다. 이 기법은 원하는 모든 상황에서 globalThis에 접근할 수 있지만, 첫 번째 라인에서 여전히 (여기서는 globalThis라고 부르는) 전역 this 참조에 의존하고 있다. 이 의존성을 피할 수 있을 까? globalThis에 직접 접근하지 않고 getter를 전역적으로 설정 할 수 있을 까?

globalThis에 getter를 설정하는 것 대신에, 전역 this 객체를 Object.prototype으로 상속받아서 getter를 설정할 수 있다.

Object.defineProperty(Object.prototype, '__magic__', {
	get: function() {
		return this;
	},
	configurable: true // 이것은 나중에 getter를 `delete` 가능하게 한다.
});
// 주의: 전역 스코프에서 실행될 때 `globalThis`를 전역 객체로 만들기 위해 
// (어휘적(lexical) 범위의 최상단 변수와 대조적으로)
// `const` 대신에 `var`가 사용되어진다.
var globalThis = __magic__;
delete Object.prototype.__magic__;

주의: 사실 ECMAScript 스펙에 전역 thisObject.prototype을 상속 받는다는 내용은 없고, 단지 객체여야 한다고 명시되어 있다. Object.create(null)Object.prototype을 상속 받지 않은 객체를 만든다. 자바스크립트 엔진은 Object.prototype를 상속받지 않는 객체를 this로 사용하더라도 스펙에 위배되지 않으며, 이 경우 위와 같은 코드는 여전히 동작하지 않을 것이다. (실제로 IE 7은 그렇게 한다.) 운 좋게, 좀 더 최신의 자바스크립트 엔진은 전역 thisObject.prototype을 prototype 체인으로 가져야만 한다고 동의한 것으로 보인다.

globalThis를 이미 사용할 수 있는 최신의 환경에서는 Object.prototype가 변화(mutating)하는 것을 막기 위해서 폴리필을 아래와 같이 수정할 수 있다.

(function() {
	if (typeof globalThis === 'object') return;
	Object.defineProperty(Object.prototype, '__magic__', {
		get: function() {
			return this;
		},
		configurable: true // 이것은 나중에 getter를 `delete` 가능하게 한다.
	});
	__magic__.globalThis = __magic__; // lolwat
	delete Object.prototype.__magic__;
}());

// 이제 코드에서 `globalThis`를 사용할 수 있다.
console.log(globalThis);

혹은 __defineGetter__를 사용할 수 있다.

function() {
	if (typeof globalThis === 'object') return;
	Object.prototype.__defineGetter__('__magic__', function() {
		return this;
	});
	__magic__.globalThis = __magic__; // lolwat
	delete Object.prototype.__magic__;
}());

// 이제 코드에서 `globalThis`를 사용할 수 있다.
console.log(globalThis);

이것은 지금까지 본 폴리필 중 가장 끔찍한 것이다. 소유하지 않은 객체(object)는 변경하지 않는 것이 관행인데, 이 폴리필은 이런 관행을 완전히 무시한다. 자바스크립트 엔진의 기본 원리인 프로토타입 최적화에서 설명하듯이 내장된 프로토타입을 변경하는 것은 일반적으로 나쁜 생각이다.

반면에 이 폴리필을 깨뜨릴 유일한 방법은 폴리필이 실행되기 전에 누군가가 Object 또는 Object.defineProperty (또는 Object.prototype.__defineGetter__)를 변경하는 것이다. 나는 이보다 더 강력한 폴리필을 생각해낼 수 없다. 여러분은 어떠한가?

폴리필 테스트

이 폴리필은 범용적인 자바스크립트의 흥미로운 예이다. 폴리필은 순수한 자바스크립트 코드로 호스트 빌트인 기능에 의존하지 않고 ECMAScript가 구현된 어느 환경에서나 실행될 수 있다. 이것은 애초에 폴리필의 목표 중 하나였다. 실제로 이것이 동작하는지 확인해보자.

여기에 이 폴리필을 위한 HTML 데모 페이지가 있다. 이 데모에서는 고전적인 스크립트인 globalthis.js와 동일한 코드로 모듈방식인 globalthis.mjs를 사용하여 globalThis를 출력한다. 이 데모는 폴리필이 브라우저에서 동작하는지 확인하는 데 사용될 수 있다. globalThis는 기본적으로 Chrome 71 / V8 v7.1, Firefox 65, Safari 12.1, iOS Safari 12.2에서 지원한다. 폴리필의 흥미로운 부분을 테스트하기 위해서는 이전 버전의 브라우저에서 데모페이지를 열어야한다.

주의: 이 폴리필은 인터넷 익스플로러 10 이하의 버전에서는 동작하지 않는다. 이 브라우저들에서는 __magic__이 전역 this의 참조로 동작함에도 불구하고, __magic__.globalThis = __magic__으로 어떻게든지 globalThis가 전역으로 사용 가능하게 만들어주지 않는다. 즉 __magic__window가 모두 [object Window]이지만 __magic__ !== window 라는 사실이다. 이것은 이 브라우저들이 전역 객체와 전역 this 사이의 차이에 대해 혼돈될 수 있음을 나타낸다. 이전의 대안들 중 하나로 폴리필을 수정하면 IE10과 IE 9에서 동작한다. IE 8을 지원하기 위해서는 Object.defineProperty 호출하는 부분을 try-catch문으로 감싸고 catch 블록에서 이전의 대안들 중 하나를 사용하면 된다. (이렇게 하는 것은 전역 thisObject.prototype을 상속 받지 않는 IE 7 이슈 또한 피할 수 있다. IE의 이전버전에서 이 데모를 실행시켜 보아라)

Node.js와 독립형 자바스크립트 엔진에서 테스트하기 위해서는 동일한 자바스크립트 파일을 다운로드 한다.

# 폴리필과 데모 코드를 모듈로 다운로드 해라
curl https://mathiasbynens.be/demo/globalthis.mjs > globalthis.mjs
# 이 파일을 고전적인 자바스크립트 파일로 사용할 수 있도록 복사(즉, symlink) 해라
ln -s globalthis.mjs globalthis.js

이제 node에서 테스트할 수 있다.

$ node --experimental-modules --no-warnings globalthis.mjs
Testing the polyfill in a module
[object global]

$ node globalthis.js
Testing the polyfill in a classic script
[object global]

독립형 자바스크립트 엔진 쉘에서 테스트하기 위해서는 jsvu를 사용해서 원하는 엔진을 설치하고 스크립트를 바로 실행할 수 있다. 예를 들어 (globalThis를 지원하지 않는) V8의 7.0버전과 (globalThis를 지원하는) 7.1버전에서 테스트하기 위해서는 아래와 같이 하면된다.

$ jsvu v8@7.0 # Install the `v8-7.0.276` binary.

$ v8-7.0.276 globalthis.mjs
Testing the polyfill in a module
[object global]

$ v8-7.0.276 globalthis.js
Testing the polyfill in a classic script
[object global]

$ jsvu v8@7.1 # Install the `v8-7.1.302` binary.

$ v8-7.1.302 globalthis.js
Testing the polyfill in a classic script
[object global]

$ v8-7.1.302 globalthis.mjs
Testing the polyfill in a module
[object global]

이와 같은 기술로 JavaScriptCore, SpiderMonkey, Chakra 그리고 XS와 같은 다른 자바스크립트 엔진에서 테스트할 수 있다. 여기 JavaScriptCore를 사용한 예제가 있다.

$ jsvu # Install the `javascriptcore` binary.

$ javascriptcore globalthis.mjs
Testing the polyfill in a module
[object global]

$ javascriptcore globalthis.js
Testing the polyfill in a classic script
[object global]

결론

자바스크립트를 사용하는 것은 까다롭고, 종종 독창적인 방법을 요구한다. globalThis는 전역 this에 쉽게 접근할 수 있게 해준다. 정확하게 동작하는 globalThis 폴리필은 보기보단 좀 더 도전적이지만, 동작하는 해결책이 있다.

정말로 필요할 때만 이 폴리필을 사용해라. 자바스크립트 모듈은 전역 상태를 변경하지 않고 기능을 쉽게 가져오거나 내보낼 수 있다. 대부분의 최신 자바스크립트 코드는 전역 this에 접근을 필요로 하지 않는다. globalThis는 라이브러리와 폴리필에서만 유용하다.

npm에 있는 globalThis 폴리필

이 글을 게시한 이후에 다음과 같은 npm 패키지가 이 기술을 이용해서 globalThis 폴리필을 제공하기 시작했다.

참고: 나는 이 패키지들 중 어떠한 것의 저자도 관리자도 아니다.


이소희, FE Development Lab2019.05.03Back to list