본문으로 건너뛰기

자바스크립트의 상위호환성과 하위호환성 (Feat. polyfill, TC39)

· 약 10분

ECMAScript 2022에서는 at() 이라는 기능이 추가되었는데요. 이제 더이상 배열의 마지막 요소를 찾기 위해 arr[arr.length-1]과 같이 작성하지 않아도 됩니다. arr.at(-1)과 같이 작성하면 마지막 요소를 찾을 수 있어요! 그런데, 이런 자바스크립트의 최신 문법은 어떻게, 어떤 기준으로 추가되는 것일까요?

ECMAScript의 표준을 관리하는 TC39

자바스크립트는 TC39에서 JS의 공식 명세서를 관리하는데요. TC39는 자바스크립트의 표준 사양을 만드는 기술 위원회로써, 애플, 구글, 삼성을 포함한 다양한 회사의 브라우저 및 엔진 개발자들이 모여 자바스크립트의 명세 변경 안건을 투표하고, 합의된 변경 사항을 국제 표준화 기구인 ECMA에 제출하는 일을 합니다. 우리가 흔히 알고 있는 ECMAScript(ES6, ES7 ...)도 TC39에서 개발한 자바스크립트 표준 사양입니다.

이러한 일을 하는 이유는 각기 다른 브라우저 엔진끼리 호환되는 자바스크립트를 사용하기 위해서인데요. 자바스크립트 엔진은 크롬은 V8, 애플은 JavaScriptCore, 파이어폭스는 SpiderMonkey 와 같이 제조사나 브라우저별로 각기 다른 엔진을 사용하고 있습니다. 뿐만 아니라, 자바스크립트는 서버나 키오스크, 심지어 전구에서도 실행되는 언어인데요. 각자의 명세를 바탕으로 개발한다면 아이폰에서는 볼 수 있는 웹페이지가 갤럭시에서는 읽을 수 없게 될지도 몰라요. 때문에 표준화 기구를 통해 모든 영역에서 하나의 자바스크립트를 사용할 수 있도록 하는 것입니다. 하나의 명세서에 맞춰 개발을 하기 때문에 모두 동일한 코드를 사용하고 읽을 수 있는 것이죠.


자바스크립트의 하위 호환성과 상위 호환성

이렇게 발전해온 자바스크립트는 하위 호환성을 강하게 지켜왔는데요. 자바스크립트의 하위 호환성을 말씀드리기 앞서, 하위 호환성과 상위 호환성이라는 용어가 낯설 수 있기 때문에 해당 용어에 대해 먼저 소개해 드리도록 하겠습니다.

하위 호환성과 상위 호환성

하위 호환성은 오래전에 만든 기술현재 환경에서 동작할 수 있도록 하는 것이고, 상위 호환성은 오래된 환경에서도 현재 만든 기술을 동작할 수 있도록 하는 것입니다. 쉽게 말하자면, 새로운 기술을 만들 때 과거를 지원하는 것은 하위 호환성, 미래를 대비하는 것은 상위 호환성입니다.

상위 호환성과 하위 호환성이라는 명칭 때문에 두 개념이 공존할 수 없는 것이라 생각할 수 있지만, 둘 다 지원할 수도, 지원하지 않을 수도 있습니다.

iOS 18.1과 인텔리전스의 상위 호환성

예를 들어 소개해드리겠습니다. 애플 인텔리전스는 아이폰 16의 출시와 함께 나왔지만, 아이폰 15pro에서도 사용할 수 있습니다. 이는 애플 인텔리전스가 구형기기인 아이폰 15 pro에 대해 상위 호환성을 유지한다고 볼 수 있습니다.

하지만 아이폰 15와 이전 모델에서는 애플 인텔리전스를 사용할 수 없기 때문에 상위 호환성을 보장하지 않는다고 볼 수 있습니다. 다만 애플 인텔리전스가 포함된 iOS 18.1은 아이폰 11에서도 설치할 수 있기 때문에 아이폰 11에 대한 하위 호환성을 보장합니다.

이처럼, 미래를 대비하는 상위 호환성보다는 하위 호환성을 보장하는 것이 상대적으로 쉽기 때문에 상위 호환성보다는 하위 호환성을 지원하는 사례가 훨씬 많습니다.


하위 호환성을 보장하는 자바스크립트

그럼 다시 자바스크립트로 돌아가서, TC39의 명세를 기반한 자바스크립트는 하위 호환성을 강하게 지키고 있는 언어인데요. 그 이유는 하위 호환성을 지키지 않으면 오래된 웹사이트를 최신 브라우저에서 읽지 못하는 문제가 발생할 수 있기 때문입니다. 덕분에 1995년에 만들어진 코드를 지금도 동작할 수 있는 것인데요. IT 분야에서 이 정도 하위 호환성을 지키면서 무언가를 유지 보수해온 사례는 거의 없습니다. 물론 TC39에서도 하위 호환성을 깨는 결정을 하기도 했는데요. 이로 인한 부정적인 부수효과가 작다고 판단될 때 예외적으로 하위 호환성을 깨기도 했습니다.

하위 호환성 보장으로 인해 발생하는 예외적인 상황들

한번 명세서에 작성된 내용을 변경하면 기존 프로그램들이 동작하지 않을 수 있습니다. 때문에 하위 호환성을 위해 여전히 발생하고 있는 자바스크립트 버그들이 있는데요. 당장 typeof null 를 실행해보면 null 이 아닌 undefined가 출력됩니다.

이는 오래전에 발생한 자바스크립트 버그 때문인데요. 자바스크립트가 처음 만들어질 때는(1995년) 내부적으로 데이터 타입을 구분할 때 비트 패턴을 기반으로 구분하였습니다. 이때 null의 데이터 타입을 표현하는 비트 패턴이 객체를 나타내는 비트 패턴과 같아 객체처럼 인식되는 버그가 발생했습니다. 하지만 이를 수정하게 되면 기존의 프로그램에서 문제가 발생할 수 있기 때문에 지금까지 이 버그를 수정하지 못한 것입니다.

최근에 수정된 사항에도 비슷한 점이 있습니다.

if(false) {
function ask() {
console.log(HERE)
}
}
ask()

위와 같은 코드를 실행하면 어떤 현상이 벌어질까요? if 문의 ask()를 참조하지 못해 Reference Error가 발생하는 것이 일반적으로 기대하는 동작입니다. 하지만 Reference Error가 아닌 Type Error 가 발생하는데요. 이는 ES6 이전에서는 블록 스코프라는 개념이 없었고, 모두 함수 스코프였기 때문이에요. 따라서 ES6 이전과 이후의 동작이 달라졌지만, 하위 호환성을 위해 Type Error가 발생하도록 둔 것입니다.


상위 호환성을 보장하지 않는 자바스크립트

하위 호환성을 보장하는 것과 달리, 자바스크립트는 상위 호환성을 보장하고 있지 않은데요. 오랜 시간 동안 브라우저의 발전에는 너무나 다양하고 많은 발전이 있어왔고, 이를 충족시키는 상위 호환성을 지키기에는 어려움이 많기 때문입니다. 그럼 최신 문법을 사용했을 때 오래된 환경(브라우저)에서는 실행하지 못할까요? 우리는 본능적으로 그렇지 않다라는 것을 알고 있습니다.

폴리필을 통한 상위 호환성 보장

자바스크립트는 상위 호환성을 지키지 않지만, 개발 과정에서 폴리필(polyfill)이나 트랜스파일러(transpiler)인 Babel을 통해 최신 문법으로 작성된 코드를 오래된 브라우저에서도 동작할 수 있도록 이전 버전의 코드로 변환시켜줍니다. 물론 그럼에도 놓칠 수 있는 부분이 있기 때문에 지원해야 하는 범위에 따라 개발자의 주의가 필요합니다.

폴리필 사용 시 주의할 점

예를 들어 바벨을 통해 0.1%의 사용률을 가지는 구형 브라우저까지 지원하도록 설정하더라도, 갤럭시 S8에서는 Object.fromEntires를 사용할 수 없습니다. 갤럭시 S8은 2019년 이후 삼성 브라우저 업데이트를 지원받지 못하고, 2019년까지의 삼성 브라우저는 사용률이 0.09% 이기 때문이죠.


HTML과 CSS의 상위 호환성

상위 호환성을 보장하지 않는 자바스크립트와 달리, HTML과 CSS는 상위 호환성을 보장하고 있는데요. 어떻게 가능한 것일까요? HTML과 CSS는 최신 문법과 같이 읽지 못하는 속성이나 값을 발견하게 되면 에러를 발생시키는 것이 아니라, 건너뛰고 계속해서 읽는 방식으로 코드를 읽습니다. 때문에 오래된 브라우저에서 현대 문법의 코드를 실행할 수 있는 것인데요. 하지만 실행할 수 있는 것이지, 현대 문법을 동작할 수 있는 것은 아닙니다.

예를들어 flexbox는 비교적 최근(2020년경)부터 지원되는 속성인데요. 때문에 오래된 브라우저에서 gap을 읽지 못하는 현상이 있어서 오래된 브라우저에서는 gap이 아닌 margin을 쓰는 것이 안전합니다.



정리

오래된 브라우저를 지원하기 위해 폴리필을 한다는 개념은 많이들 알고 계셨을텐데요. 그럼에도 생소한 상위 호환성과 하위 호환성을 설명하려다 보니 글이 길어졌네요. 정리하자면, 자바스크립트는 TC39와 ECMAScript와 같은 표준화 기구로 인해 하위 호환성을 강하게 지키고 있다고 말씀드리고 싶습니다. 하지만 상위 호환성은 보장하지 않기 때문에 상위 호환성을 위해서 폴리필(polyfill)이나 트랜스파일러(transpiler)인 Babel을 사용해야 합니다.

이 글 덕분에 상위 호환성과 하위 호환성, 그리고 ECMA Script와 TS39에 대해 조금 친해질 수 있었다면 좋겠네요 😊



출처: You Don’t Know JS Yet, 하위 호환성과 상위 호환성 헷갈리지 말아요

클로저와 자바스크립트에서의 클로저 (feat. 일급객체, 렉시컬 스코프)

· 약 10분

클로저란 외부 함수가 종료된 후에도, 내부 함수가 외부 함수의 지역 변수에 접근할 수 있는 함수를 의미한다. 함수가 실행되어 종료된 이후에도 종료된 외부 함수의 변수를 사용할 수 있기 때문에 상태유지, 데이터 은닉, 비동기 처리 등을 목적으로 사용할 수 있어, 많은 프로그래밍 언어에서 사용하고 있다.


상태유지와 데이터 은닉

function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

위 예제에서는 createCounter는 실행과 동시에 종료되었으며, 반환된 함수는 counter라는 변수명에 저장되었다. 때문에 count에는 임의로 접근할 수 없으며, counter 함수의 클로저를 통해서만 수정할 수 있기 때문에 데이터 수정을 제한할 수 있다. 또한, count라는 변수를 전역에서 선언하지 않고도 상태를 유지할 수 있는 장점이 있다.


비동기 처리에서의 클로저

for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(`Counter: ${i}`);
}, i * 1000);
}

자바스크립트의비동기 처리를 묻기 위해 자주 사용되는 예제이다. 간단하게 코드의 진행방식을 살펴보면,

  1. for문이 동작하면서 3번 setTimeout 함수를 콜스택에서 실행하며, 비동기 함수인 setTimeout은 이벤트루프에 의해 Web API에서 처리된다.
  2. setTimeout이 비동기처리 되는 동안 i는 3번의 for문을 거쳐 4가 된다.
  3. Web API에서 처리된 setTimeout은 이벤트루프에 의해 매크로 테스크큐를 거쳐 콜스택에서 실행된다.
  4. 이때의 i는 var로 선언되어 함수레벨 스코프를 가지기 때문에 setTimeout의 콜백함수 내부에서 참조하는 i는 var로 선언한 i 이며, 이미 i가 4가 되었기 때문에 Counter: 4 가 출력된다.

뭔가 이상한 이 코드의 해결방법은 콜백함수 별로 개별적인 i를 가지도록 하여 해결할 수 있다. 첫번째 방법은 블록레벨 스코프를 사용하여 for문이 실행될 때 마다 개별적인 스코프를 가지는 i를 선언하는 방법, 그리고 두번째 방법은 즉시실행함수와 클로저를 이용하여 렉시컬 스코프에 개별적인 i 를 저장하는 방법이 있다.

본 포스팅은 클로저를 중심으로 다루기 때문에, 클로저를 통해 해결하는 방법을 알아보자면 다음과 같다.

for (var i = 1; i <= 3; i++) {
(argument => {
setTimeout(() => {
console.log(`Counter: ${argument}`);
}, argument * 1000);
})(i);
}

앞서 설명한 비동기 처리 과정을 제외하고 이 코드의 진행방식을 살펴보면,

  1. for 루프가 실행될 때마다 즉시 실행 함수 (argument) => { ... }가 호출되며, i 값이 argument에 전달된다.
  2. 즉시 실행 함수가 실행되면서 setTimeout의 콜백함수가 생성되고, 이때 콜백함수는 상위함수인 즉시실행함수의 argument를 참조하는 렉시컬 환경을 가진다.
  3. 이후 setTimeout의 콜백함수가 실행되면서 argument를 참조하는데, 즉시실행함수의 변수 argmunet는 이미 종료되었지만 렉시컬 환경에서 참조하고 있기 때문에 접근할 수 있다. (클로저를 통한 접근)
  4. console.log()가 실행될 때 실행하는 argument는 4가 된 var = i를 참조하는 것이 아니라 콜백함수 (()=> console.log(...))의 렉시컬 환경인 argument 값을 참조하기 때문에 Counter: 1, 2, 3이 출력된다.

즉, setTimeout의 콜백함수가 실행될 때 참조하는 argument는 이미 종료된 즉시실행함수 ((argument) => { ...})(i)의 i를 참조하기 때문에 각각 개별적인 i를 갖는 것이고, 4가 된 i 변수와는 무관한 것이다.


리액트 setState에서의 클로저?

블로그를 보다보면 흔히들 React에서 useState를 사용할 때 클로저를 사용한다고 한다. 리액트 컴포넌트가 다시 실행될 때 기존 값은 사라지게 되는데, 클로저가 이를 기억하고 가져올 수 있다고 한다. 내 생각에는 이 부분은 useState를 자바스크립트로 간단하게 구현할 때 클로저를 사용할 수 있다는 것에서 발생한 오해라고 생각한다.

리액트에서 setState와 같은 매커니즘을 통해 UI를 변경하게 되면, 가상 DOM의 변경사항에 맞춰 실제 DOM을 업데이트하는 재조정(reconciliation)과정을 거쳐 반영된다. 따라서 상태가 변경될 때 이전 값이 사라지는 것이 아니라 상태가 변경된 이후에 이전 값이 사라지는 것이며, 리액트는 클로저를 통해 사라진 이전 값을 불러올 필요가 없다.


클로저는 자바스크립트만의 개념일까?

이처럼 활용도가 높은 클로저는 자바스크립트만의 고유 개념이 아니며, 현재 대부분의 언어에서 사용된다. 클로저를 사용하는 언어이자, 사용할 수 있는 언어는 다음과 같은 특징을 가진다.

  1. 렉시컬 스코프를 사용한다.
  2. 함수가 일급 객체이다.

렉시컬 스코프

렉시컬 스코프(정적 스코프)는 함수가 어디서 "선언"되었는지에 따라 변수의 유효 범위가 결정되는 방식을 말한다. 렉시컬 스코프와 반대되는 개념인, 다이나믹 스코프(동적 스코프)는 함수가 어디서 "호출"되었는지에 따라 변수의 유효범위가 결정된다. 그리고 현재 대부분의 언어는 예측 가능성과 성능 최적화 등의 이유로 렉시컬 스코프를 채택하고 있다.

클로저와 렉시컬 스코프에 대해 이해했다면 왜 클로저를 사용하려면 렉시컬 스코프를 사용해야 하는지 알 수 있다. 더 쉽게 말하자면, 다이나믹 스코프에서는 클로저가 필요하지 않는 이유를 알 수 있다.

렉시컬 스코프는 선언된 위치에서 변수의 유효 범위가 결정되기 때문에 자신이 선언된 환경을 저장하며(렉시컬 환경), 이 개념을 기반으로 클로저가 사용된다. 반면에 다이나믹 스코프는 호출될 때 변수의 유효범위가 결정되므로 렉시컬 환경이 필요 없는 것이고, 자연스럽게 클로저도 사용할 필요가 없는 것이다.


일급 객체

프로그래밍 언어를 공부했다면 일급 객체라는 말을 무조건 들어봤을 것이다. 처음 일급 객체라는 단어를 봤을 때, 진짜 1급 객체를 말하는 것인가? 애니메이션 속 레벨을 지칭하는 것과 같은 유치한 네이밍은 어디서 왔을까 라는 의구심이 들었다.

결론으로 말하자면 일급 객체의 일은 1이 맞으며, 영문표현은 First-Class이다. 일급 객체는 1966년의 프로그래밍 언어의 설계에서 중요한 개념을 정의하며 사용된 단어로써, 특정 요소에 따라 1급, 2급, 3급으로 나누었다. 이때 할당, 전달, 반환이 모두 가능한 요소를 일급 객체라고 칭하였으며, 일부만 가능한 것은 이급, 매우 제한된 요소는 삼급으로 나누었다.

그럼 다시 클로저로 돌아가서, 왜 함수가 일급 객체여야 클로저를 사용할 수 있을까?

함수가 일급객체인 자바스크립트

function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

앞서 소개한 전형적인 클로저의 예시이다. 클로저의 정의를 다시 살펴보면, 클로저란 외부 함수가 종료된 후에도, 내부 함수가 외부 함수의 지역 변수에 접근할 수 있는 함수를 의미한다.

위 예시에서는 createCounter 라는 함수를 counter 라는 변수에 저장했으며, createCounter(외부함수)는 종료되었지만 createCounter함수에서 반환된 함수(내부함수) function(){ ... return count; }가 createCounter의 변수인 count(외부함수의 지역 변수)를 참조하고 있기 때문에 count가 유지되며 접근할 수 있는 것이다.

이처럼 함수가 클로저가 되려면, 함수가 반환된 이후에도 렉시컬 환경을 유지할 수 있어야 한다. 이를 위해 함수를 변수에 저장하거나 반환할 수 있어야 하며, 이는 함수가 일급 객체일 때 가능하다. 따라서 함수가 일급 객체여야만 클로저가 성립할 수 있다.

함수가 일급객체가 아닌 C언어

#include <stdio.h>

void outer() {
int count = 0;

void inner() { // C는 함수가 일급 객체가 아니므로 내부 함수를 반환할 수 없음
count++;
printf("%d\n", count);
}

inner();
}

int main() {
outer();
outer(); // 새로 호출될 때마다 count 값이 초기화됨
return 0;
}

자바스크립트는 사용자 입력, 네트워크 요청과 같이 실행 중 동적으로 변화하는 환경(브라우저)에서 동작해야 했기 때문에, 함수를 런타임에서 생성하거나 반환할 수 있도록 설계되었으며, 함수의 메모리 저장 방식도 동적으로 변경될 수 있도록 힙(Heap) 메모리에 할당되도록 설계되었다.

반면, C언어는 하드웨어와 친화적인 정적환경에서 실행되도록 설계된 언어이기 때문에, 런타임에서의 동적 처리를 최소화하여 최적화하였다. 따라서, 실행 중에 새로운 함수를 동적으로 생성하거나 반환할 필요가 없었으며, 모든 함수는 컴파일 시점에 고정된 메모리 주소에 저장되었다.

때문에 C언어의 예시에서는 자바스크립트의 예시처럼 클로저를 사용한 상태유지가 불가능한 것이다.



출처: You Don’t Know JS Yet, https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js

Virtual DOM이 무조건 빠른 것은 아닙니다.

· 약 3분

리액트의 Virtual DOM 은 무엇인가요?

https://old.million.dev/blog/virtual-dom

사용자가 클릭과 같은 이벤트를 발생해서 UI가 바뀌었다면, 바뀐 UI를 보여주기 위해 DOM이 리렌더링되어야 합니다. 이때 브라우저는 변경된 부분의 DOM 트리를 수정하고 리페인팅과 리플로우 과정을 거쳐 리렌더링 하게 됩니다. 이러한 과정은 많은 비용이 소모되기 때문에 DOM 변경을 최소화하여야 하는데요. 리액트는 Virtual DOM이라는 개념을 사용하여 이러한 과정을 최적화합니다.

리액트는 변경된 DOM이 반영된 자바스크립트로 된 Virtual DOM을 만들고 기존 DOM과 차이점을 비교하여 실제로 바뀐 부분만 교체합니다. 이때 만약 여러 번 DOM을 수정해야 한다면 React Fiber 엔진을 사용해서 한 번에 처리할 수 있도록 최적화를 진행합니다.


SolidJS나 스벨트는 Vitrual DOM을 안 쓰는데 더 빠른데요?

Virtual DOM이 무조건 빠른 것은 아닙니다.

"리액트는 Virtual DOM을 사용하기 때문에 빠르다" 라는 말은 맞기도 하지만 틀리기도 합니다. Virtual DOM을 생성하고 비교하는 과정을 거치기 때문에 직접 DOM을 수정하는 것보다 빠를 수는 없습니다. 다만 Virtual DOM이 빠른 이유는 최적화를 통해 DOM 접근을 최소화하며, 필요한 부분만 DOM을 수정하기 때문입니다.

실제로 DOM 구조가 단순하고 변경 사항이 적은 경우, Virtual DOM의 Diffing 과정이 오히려 성능 부담이 될 수 있습니다. 브라우저 기본 렌더링만으로 충분히 효율적입니다.

스벨트나 SolidJS가 빠른 이유는 Virtual DOM을 사용하지 않는 대신에 컴파일 단계에서 최적화를 하기 때문입니다. 컴파일 단계에서 각 컴포넌트를 분석하여 정적 분석을 수행하고, 필요한 DOM 조작만 수행하도록 최적화해서 리액트보다 빠른 성능을 보여줄 수 있는 것이에요.

Vue.js도 Virtual DOM을 사용하고 있지만, 컴파일 과정에서 Virtual DOM의 최적화를 진행하는 하이브리드 접근 방식(Compiler-Informed Virtual DOM)을 사용하여 더 빠른 성능을 이끌어 냅니다.


그럼 리액트도 컴파일 단계에서 최적화를 하면 되는 거 아닌가요?

컴파일 단계에서 최적화하는 방식에도 단점이 있습니다. 컴파일 단계에서 코드가 변경되기 때문에 코드를 예측하기 힘들 뿐 아니라, 런타임에서 실행되는 코드에 대해서는 최적화를 할 수 없다는 단점이 있습니다. 또한 대부분의 라이브러리가 리액트를 포함한 Virtual DOM을 사용하는 주류 생태계에 맞춰져 있기 때문에 상대적으로 컴파일 단계의 최적화는 라이브러리 호환성이 떨어지게 됩니다.


자료 출처
https://yozm.wishket.com/magazine/detail/1176/
https://svelte.dev/blog/virtual-dom-is-pure-overhead
https://old.million.dev/blog/virtual-dom
https://ui.toast.com/posts/ko_20220331
https://itchallenger.tistory.com/822 \

ES Module과 모듈 시스템의 역사

· 약 6분

자바스크립트 개발을 하다 보면 가끔씩 require문을 사용하는 코드들을 볼 수 있습니다. 프론트엔드가 아닌 백엔드를 개발하시는 분이라면 어쩌면 import보다 require에 익숙하실 수도 있습니다. 웹개발을 시작한지 오래되지 않은 프론트엔드 개발자로서, ESMdoules이 다른 파일의 코드를 가져오는 당연한 방식이라고 생각할 때도 있었는데요. ES Module은 어떻게 등장하게 되었는지, 왜 옛 코드에는 아직도 require가 남아있고, 왜 아직도 ES Module이 아닌 CommonJS를 쓰려 하는지에 대해 포스팅하였습니다.

ES Module과 모듈 시스템의 역사

먼저 ES Modules이란 무엇인가에 대해 설명드려야 할 것 같습니다. ES Modules은 2015년에 ECMAScript6에서 자바스크립트 모듈 시스템으로 정식으로 추가된 기능인데요. ES Modules가 등장하기 전, CommonJS, AMD 등 브라우저 환경에서 혼재되어 사용되던 모듈시스템을 통합하기 위해 표준으로 지정되었습니다. ES Modules은 이제는 너무나 익숙한 방식이지만 사실 타입스크립트는 4.7버전, NextJS는 12버전부터 ES Module을 지원하기 시작했을만큼 얼마되지 않은 모듈 시스템입니다.

그런데 왜 지금도 많이 사용되는 CommonJS를 표준으로 지정하지 않고 새로운 모듈 시스템을 만든걸까요?

CommonJS의 등장과 브라우저에서의 단점

초창기 자바스크립트의 모듈시스템 문제를 해결하기 위해 등장한 CommonJS는 사실 ServerJS라는 이름의 프로젝트를 시작했을 만큼 서버를 위한 모듈시스템이었습니다. (서버 사이드 자바스크립트에 필요한 것)

ES Module과 CommonJS의 가장 큰 차이점은 비동기적으로 동작하는 ES Module과는 달리, CommonJS는 동기적으로 동작한다는 점인데요. 서버에서는 파일시스템으로 빠르게 모듈을 불러올 수 있었기 때문에 안정성을 위해 실행 전에 모든 의존성을 불러오고 있었습니다. 때문에 동기적으로 동작하는 CommonJS는 서버에서는 문제가 없었지만 브라우저에서 사용할 때 문제점이 발생하게 됩니다. 로컬 디스크에 모든 디펜더시를 가지고 파일시스템 I/O를 통해 빠르게 필요한 모듈을 호출하는 서버와는 달리, 브라우저는 네트워크를 통해 필요한 모듈을 다운로드 하고 나서야 사용할 수 있었습니다. 그리고 이때 발생하는 동기적인 로딩이 성능과 사용자의 경험에 치명적인 영향을 끼치게 됩니다.

브라우저의 동기 문제를 해결하기 위한 AMD 등장

이 문제를 해결하기 위해 브라우저 환경을 위한 AMD(Asynchronous Module Definition) 라는 모듈 시스템이 등장했는데요. 비동기적 모듈 선언이란 뜻처럼, 비동기 방식을 사용하는 모듈 시스템입니다. 모듈과 의존성을 정의하고 콜백함수를 통해 필요한 모듈을 병렬적으로 불러오는 방식이었습니다. 덕분에 비동기 문제를 해결하고 더 쉬운 디버깅 환경을 만드는 등의 장점이 있었습니다. 하지만 AMD는 CommonJS와 완벽히 호환되지 않았고, 이런 환경 속에서 두 모듈을 호환할 수 있는 UMD 패턴이 등장하기도 하였습니다.

ES Module의 등장과 ES Module의 장점

그리고 이런 여러가지 모듈 시스템이 혼재되어 있는 상황을 해결하기 위해, ESCMAScript 6에서 ES Module이 표준 모듈 시스템으로 명세되었습니다. ES Module은 import/export라는 직관적인 구문과 정적구조를 통해 사용성과 성능 향상을 이끌어냈는데요.

모듈이 런타임 시점에서 동적으로 할당되는 CommonJS와 달리, ES Module은 정적 분석을 통해 런타임 이전에 의존성 그래프를 만들어주었습니다. 덕분에 별도로 모듈의 의존성이나 불러오는 순서를 정의하지 않아도 되었을 뿐만 아니라, 이 시점에서 사용되지 않는 코드를 정리해주는 트리쉐이킹이나 코드스플리팅을 진행할 수 있다는 장점이 있었습니다.


그럼 왜 아직 CommonJS를 사용하는 건가요?

이미 만들어진 CommonJS 기반의 모듈들

기존에 사용되던 대부분의 라이브러리가 CommonJS를 기반으로 만들어졌습니다. 프로젝트에서 중요한 역할을 하는 모듈을 포함하여 npm에 게시된 수백만 개의 모듈이 이미 CommonJS를 사용하고 있습니다. ES Module을 지원하는 라이브러리가 많아지고 있지만, 이 많은 패키지가 모두 그렇게 될 수는 없습니다.

서버 환경에 특화된 CommonJS

ES Module은 브라우저를 위해 비동기로 구현된 반면, 앞서 이야기했던 것 처럼 CommonJS는 서버 환경을 중점적으로 설계되었습니다. ES Module은 정적 분석 과정을 거치며 import를 export에 바인딩되는데요. 브라우저 환경에서는 트리쉐이킹 등으로 인해 네트워크로 불러와야 할 용량이 줄어들어 높은 성능 향상을 이끌어내지만, 파일 입출력으로 코드를 불러오는 서버사이드 환경에서는 상대적으로 성능 향상이 크지 않습니다. 오히려 이 과정으로 인해 설계상 CommonJS보다 느릴 수 밖에 없기 때문에, 대규모 애플리케이션 환경에서는 더 느린 성능을 보여주게 됩니다.



출처: 자바스크립트의 표준 정의 : CommonJS vs ES Module, JavaScript 번들러로 본 조선시대 붕당의 이해, 코어자바스크립트 모듈, commonjs vs esm, JavaScript Module System(2) - UMD와 ESM, TOAST UI 의존성 관리, [번역] CommonJS는 사라지지 않습니다, [번역] CommonJS가 자바스크립트를 해치고 있습니다, JavaScript 표준을 위한 움직임: CommonJS와 AMD, 번들러 파헤치기 1 - 모듈 시스템의 발전과 역사 (commonJS, AMD, UMD, ESM-ES Module), CommonJS와 ESM에 모두 대응하는 라이브러리 개발하기: exports field, Why AMD?, why CommonJS (and its async incarnation, AMD) were not adopted by browsers?, CommonJS에서 ESM으로 전환하기

NextJS의 렌더링 과정_클라이언트 컴포넌트와 서버 컴포넌트를 중심으로

· 약 6분

기본적인 웹사이트 렌더링 과정

기본적인 웹사이트의 렌더링 과정은 다음과 같다.

  1. 클라이언트에서 HTTP를 통해 서버로 데이터를 요청한다.
  2. 서버는 요청에 따라 데이터를 처리하여 HTTP로 응답한다.
  3. 클라이언트는 응답받은 리소스를 파싱하여 인터페이스를 렌더링한다.

하지만 NextJS는 서버사이드렌더링에 좀 더 최적화된 렌더링을 가지고 있는데, NextJS에서는 클라이언트 컴포넌트와 서버 컴포넌트의 경계를 나누어 각각 다른 렌더링 과정을 거친다. 이번 포스팅에서는 서버 컴포넌트와 클라이언트 컴포넌트의 렌더링 과정에 대해 공식문서를 바탕으로 정리하였다.


⚙️ NextJS의 렌더링 과정

🏢 서버 컴포넌트 렌더링 과정

Server Componets는 경로(route)와 서스펜스 Boundary에 따라 청크로 분할되는데, 분할된 각각의 청크는 다음의 두가지 단계를 거쳐 렌더링된다.

  1. 리액트에서 Server Components를 RSC 으로 렌더링한다.
  2. Next.js에서 RSC와 클라이언트 컴포넌트의 JavaScript를 통해 서버에서 HTML을 렌더링한다.

✅ RSC(React Sever Components Payload)란? 렌더링된 React Server Components 트리의 이진표현으로 다음과 같은 정보가 담겨있다.

  1. 서버 컴포넌트의 렌더링 결과
  2. 클라이언트 컴포넌트가 렌더링 될 곳의 Placeholder와 자바스크립트 파일의 참조
  3. 서버 컴포넌트에서 클라이언트 컴포넌트로 전달할 모든 데이터

이후 클라이언트 환경에서 다음과 같은 작업을 거친다.

  1. 초기 페이지를 불러오는 상황(Full page load)이라면 상호작용할 수 없는 미리보기를 즉시 보여준다. (React가 직접 제어하지는 않는 HTML)
  2. RSC로 클라이언트와 서버 컴포넌트 트리를 조정하고 DOM을 업데이트 하여 보여준다.
  3. 이후 JavaScript를 사용하여 hydrate를 통해 앱(Application)을 상호작용할 수 있도록 만든다.

서버 컴포넌트 렌더링 전략

서버 컴포넌트를 렌더링 할 때에는 다음과 같은 전략을 선택할 수 있다.

정적 렌더링(Default)

정적 렌더링은 빌드될 때 혹은 revalidation 시 백그라운드에서 실행된다. 이는 블로그나 제품 페이지 같이 공통적인 데이터를 보여주고 자주 변경되지 않는 페이지에서 유용하다.

동적 렌더링

동적 렌더링은 각 사용자별로 요청할 때 마다 렌더링된다. 따라서 사용자 맞춤형 데이터나 검색결과 같이 요청에 맞춰 데이터를 보여줘야 할 때 유용하다.

웹사이트는 한가지 전략만을 사용하기보다는 대부분 두가지를 동시에 사용한다. 동적인 정보가 필요한 곳에는 동적 렌더링을, 그렇지 않은 곳은 정적 렌더링을 적절히 혼합하여 사용한다.

동적 API(Dynamic APIs)

요청 시에만 알 수 있는 정보에 의존하는 API들을 사용하면 전체 경로를 동적 렌더링으로 전환할 수 있다. 동적 API 로는 cookies, headers, connection, draftMode, searchParams props, unstable_noStore가 있다.

스트리밍(Streaming)

스트리밍은 기본적으로 Next.js App router에 내장되어 있으며, 스트리밍을 사용하면 전체 콘텐츠가 렌더링되기 전에 일부를 보여줄 수 있다.


🖥️ 클라이언트 컴포넌트 렌더링 과정

클라이언트 컴포넌트는 첫 방문이거나 새로고침으로 전체 페이지를 로드한 것인지(Full page load), 아니면 방문한 페이지에서 탐색을 한 것인지(Subsequent Navigations)에 따라 다르게 렌더링된다.

Full page load(전체 페이지 로드)

NextJS는 초기 페이지 로드 최적화를 위해 React의 API를 사용하여 클라이언트 컴포넌트와 서버 컴포넌트 모두를 서버에서 정적 HTML 미리보기(Static HTML preview)로 렌더링한다. 따라서 첫 방문시에는 정적 HTML 미리보기(Static HTML preview)를 사용해 즉시 페이지를 볼 수 있다.

Subsequent Navigations(후속 네비게이션)

만약 Full page load가 아니라면 클라이언트 컴포넌트는 전적으로 클라이언트에서 렌더링된다. 즉, 클라이언트 컴포넌트의 자바스크립트 번들이 다운되고 준비된 이후, RSC를 사용하여 클라이언트와 서버 컴포넌트 트리를 조정하고 DOM을 업데이트하는 과정을 거친다.



(번외)어떻게 작성된 HTML 콘텐츠에 리액트 컴포넌트를 렌더링할 수 있을까?

리액트 18버전 기준으로, 리액트는 Client React DOM APIs를 사용하여 클라이언트(브라우저)에서 React 컴포넌트를 렌더링 한다. 이때 일반적인 CSR 방식으로 리액트를 실행하면 createRoot()를 사용하여 React 루트를 생성하고, root.render를 호출하여 React 컴포넌트를 표시한다. 그런데 root.render를 처음 호출한다면 React 루트 내부의 모든 HTML 콘텐츠를 지우고 클라이언트에서 렌더링 된 컴포넌트로 교체하는데, 서버에서 렌더링한 HTML 콘텐츠가 있다면 이 과정에서 사라지게 된다. 그럼 어떻게 리액트에서 SSR을 구현할 수 있는 것일까?

그 이유는 HTML 콘텐츠가 담긴 DOM을 바탕으로 리액트를 사용한다면 createRoot()가 아닌 hydrateRoot()를 사용하여 렌더링하기 때문이다. hydrateRoot()를 사용하여 Hydrate를 완료한 후에 root.render를 호출하게 되는데, 그렇다면 기존 HTML을 없애지 않고 HTML 콘텐츠가 담긴 DOM 노드 안에 React 컴포넌트를 넣을 수 있게 된다. 물론 이 작업들은 NextJS에서 호출하고 있기 때문에 별도로 사용하지 않아도 된다.


출처: https://nextjs.org/docs/app/building-your-application/rendering, https://react.dev/reference/react-dom/client

Virtual DOM이 무조건 빠른 것은 아닙니다.

· 약 3분

리액트의 Virtual DOM 은 무엇인가요?

https://old.million.dev/blog/virtual-dom

사용자가 클릭과 같은 이벤트를 발생해서 UI가 바뀌었다면, 바뀐 UI를 보여주기 위해 DOM이 리렌더링되어야 합니다. 이때 브라우저는 변경된 부분의 DOM 트리를 수정하고 리페인팅과 리플로우 과정을 거쳐 리렌더링 하게 됩니다. 이러한 과정은 많은 비용이 소모되기 때문에 DOM 변경을 최소화하여야 하는데요. 리액트는 Virtual DOM이라는 개념을 사용하여 이러한 과정을 최적화합니다.

리액트는 변경된 DOM이 반영된 자바스크립트로 된 Virtual DOM을 만들고 기존 DOM과 차이점을 비교하여 실제로 바뀐 부분만 교체합니다. 이때 만약 여러 번 DOM을 수정해야 한다면 React Fiber 엔진을 사용해서 한 번에 처리할 수 있도록 최적화를 진행합니다.


SolidJS나 스벨트는 Vitrual DOM을 안 쓰는데 더 빠른데요?

Virtual DOM이 무조건 빠른 것은 아닙니다.

"리액트는 Virtual DOM을 사용하기 때문에 빠르다" 라는 말은 맞기도 하지만 틀리기도 합니다. Virtual DOM을 생성하고 비교하는 과정을 거치기 때문에 직접 DOM을 수정하는 것보다 빠를 수는 없습니다. 다만 Virtual DOM이 빠른 이유는 최적화를 통해 DOM 접근을 최소화하며, 필요한 부분만 DOM을 수정하기 때문입니다.

실제로 DOM 구조가 단순하고 변경 사항이 적은 경우, Virtual DOM의 Diffing 과정이 오히려 성능 부담이 될 수 있습니다. 브라우저 기본 렌더링만으로 충분히 효율적입니다.

스벨트나 SolidJS가 빠른 이유는 Virtual DOM을 사용하지 않는 대신에 컴파일 단계에서 최적화를 하기 때문입니다. 컴파일 단계에서 각 컴포넌트를 분석하여 정적 분석을 수행하고, 필요한 DOM 조작만 수행하도록 최적화해서 리액트보다 빠른 성능을 보여줄 수 있는 것이에요.

Vue.js도 Virtual DOM을 사용하고 있지만, 컴파일 과정에서 Virtual DOM의 최적화를 진행하는 하이브리드 접근 방식(Compiler-Informed Virtual DOM)을 사용하여 더 빠른 성능을 이끌어 냅니다.


그럼 리액트도 컴파일 단계에서 최적화를 하면 되는 거 아닌가요?

컴파일 단계에서 최적화하는 방식에도 단점이 있습니다. 컴파일 단계에서 코드가 변경되기 때문에 코드를 예측하기 힘들 뿐 아니라, 런타임에서 실행되는 코드에 대해서는 최적화를 할 수 없다는 단점이 있습니다. 또한 대부분의 라이브러리가 리액트를 포함한 Virtual DOM을 사용하는 주류 생태계에 맞춰져 있기 때문에 상대적으로 컴파일 단계의 최적화는 라이브러리 호환성이 떨어지게 됩니다.


자료 출처
https://yozm.wishket.com/magazine/detail/1176/
https://svelte.dev/blog/virtual-dom-is-pure-overhead
https://old.million.dev/blog/virtual-dom
https://ui.toast.com/posts/ko_20220331
https://itchallenger.tistory.com/822 \

Virtual DOM이 무조건 빠른 것은 아닙니다.

· 약 3분

리액트의 Virtual DOM 은 무엇인가요?

https://old.million.dev/blog/virtual-dom

사용자가 클릭과 같은 이벤트를 발생해서 UI가 바뀌었다면, 바뀐 UI를 보여주기 위해 DOM이 리렌더링되어야 합니다. 이때 브라우저는 변경된 부분의 DOM 트리를 수정하고 리페인팅과 리플로우 과정을 거쳐 리렌더링 하게 됩니다. 이러한 과정은 많은 비용이 소모되기 때문에 DOM 변경을 최소화하여야 하는데요. 리액트는 Virtual DOM이라는 개념을 사용하여 이러한 과정을 최적화합니다.

리액트는 변경된 DOM이 반영된 자바스크립트로 된 Virtual DOM을 만들고 기존 DOM과 차이점을 비교하여 실제로 바뀐 부분만 교체합니다. 이때 만약 여러 번 DOM을 수정해야 한다면 React Fiber 엔진을 사용해서 한 번에 처리할 수 있도록 최적화를 진행합니다.


SolidJS나 스벨트는 Vitrual DOM을 안 쓰는데 더 빠른데요?

Virtual DOM이 무조건 빠른 것은 아닙니다.

"리액트는 Virtual DOM을 사용하기 때문에 빠르다" 라는 말은 맞기도 하지만 틀리기도 합니다. Virtual DOM을 생성하고 비교하는 과정을 거치기 때문에 직접 DOM을 수정하는 것보다 빠를 수는 없습니다. 다만 Virtual DOM이 빠른 이유는 최적화를 통해 DOM 접근을 최소화하며, 필요한 부분만 DOM을 수정하기 때문입니다.

실제로 DOM 구조가 단순하고 변경 사항이 적은 경우, Virtual DOM의 Diffing 과정이 오히려 성능 부담이 될 수 있습니다. 브라우저 기본 렌더링만으로 충분히 효율적입니다.

스벨트나 SolidJS가 빠른 이유는 Virtual DOM을 사용하지 않는 대신에 컴파일 단계에서 최적화를 하기 때문입니다. 컴파일 단계에서 각 컴포넌트를 분석하여 정적 분석을 수행하고, 필요한 DOM 조작만 수행하도록 최적화해서 리액트보다 빠른 성능을 보여줄 수 있는 것이에요.

Vue.js도 Virtual DOM을 사용하고 있지만, 컴파일 과정에서 Virtual DOM의 최적화를 진행하는 하이브리드 접근 방식(Compiler-Informed Virtual DOM)을 사용하여 더 빠른 성능을 이끌어 냅니다.


그럼 리액트도 컴파일 단계에서 최적화를 하면 되는 거 아닌가요?

컴파일 단계에서 최적화하는 방식에도 단점이 있습니다. 컴파일 단계에서 코드가 변경되기 때문에 코드를 예측하기 힘들 뿐 아니라, 런타임에서 실행되는 코드에 대해서는 최적화를 할 수 없다는 단점이 있습니다. 또한 대부분의 라이브러리가 리액트를 포함한 Virtual DOM을 사용하는 주류 생태계에 맞춰져 있기 때문에 상대적으로 컴파일 단계의 최적화는 라이브러리 호환성이 떨어지게 됩니다.


자료 출처
https://yozm.wishket.com/magazine/detail/1176/
https://svelte.dev/blog/virtual-dom-is-pure-overhead
https://old.million.dev/blog/virtual-dom
https://ui.toast.com/posts/ko_20220331
https://itchallenger.tistory.com/822 \

외워서 푸는 알고리즘 테스트

· 약 4분

알고리즘 풀이 시 어느정도 외워두면 편한 패턴들이 있다. 코딩테스트 합격자 되기 도서를 참고하여 작성하였으며, 자바스크립트로 코딩테스트를 준비하는 분들이라면 해당 도서를 한번 쯤 읽어 봄직하다.

스택

  • 선입후출(FILO)
  • 최근에 삽입한 데이터를 대상으로 연산해야 한다면 스택을 떠올리는 것이 좋다.
  • 가장 가까운(최근)이라는 키워드를 보면 스택을 떠올려 봐야 한다.

  • 선입선출(FIFO)
  • 여러 이벤트가 발생했을 때 발생한 순서대로 처리할 때 큐가 활용된다.
  • 성능이 중요한 문제라면 큐를 직접 구현하거나 연결리스트를 직접 구현하여 푸는 것이 좋으나, 난이도가 높지 않다면 굳이 구현하지 않아도 통과할 수는 있다.
// 연결리스트
class Node {
constructor(data) {
this.data = data; // 요소의 값
this.next = null; // 다음 요소를 참조
}
}
class Queue {
constructor() {
this.head = null; // 첫 번째 요소 참조
this.tail = null; // 마지막 요소 참조
this.size = 0; // 큐의 길이
}
push(data) {
// 새로운 요소를 생성
const newNode = new Node(data);
if (!this.head) {
// 큐가 비어 있다면 head와 tail을 모두 새로 생성한 요소로 설정
this.head = newNode;
this.tail = newNode;
// 아니면 현재 tail의 next 속성을 새로운 요소로 설정 후 tail이 새로운 요소를 참조하도 록 변경
} else {
this.tail.next = newNode;
this.tail = newNode;
}
this.size++; // 큐 길이 증가
}
pop() {
// head가 null이라면 비어 있다는 뜻
if (!this.head) {
return null;
}
// 두 번째 요소를 head의 참조로 변경하면
// 자연스럽게 첫 번째 요소가 사라짐
const removeNode = this.head;
this.head = this.head.next;
// 만약 두 번째 요소가 없었다면
// 큐가 비어 있다는 뜻이니 tail도 null로 설정
if (!this.head) {
this.tail = null;
}
this.size--; // 큐 길이 감소
// 삭제된 요소의 값을 반환
return removeNode.data;
}

isEmpty() {
return this.size === 0;
}
}

해시

  • 해시 함수를 통해 키와 값으로 저장하여 빠른 데이터 탐색이 가능한 자료구조
  • 단방향으로 동작하기 때문에 키를 통해 값을 찾을 수는 있지만 값을 통해 키를 찾을 수는 없다.
  • 해시를 비교하는 함수도 O(n)이기 때문에 해시를 사용할 때라면 크게 문제가 되지 않는다.

이진 탐색

  • 정렬된 배열은 이진탐색을 통해 시간복잡도를 O(logN)으로 낮출 수 있다.
  • 배열의 절반을 나누어 중위 값이 목표값보다 크고 작은지를 비교하여 탐색한다.
function binarySearch(sortedArray, target) {
let left = 0;
let right = sortedArray.length - 1;

while (left <= right) {
let mid = Math.floor((left + right) / 2);

if (sortedArray[mid] === target) {
return mid; // 찾은 경우 해당 인덱스 반환
} else if (sortedArray[mid] < target) {
left = mid + 1; // 왼쪽 포인터를 오른쪽으로 이동
} else {
right = mid - 1; // 오른쪽 포인터를 왼쪽으로 이동
}
}

return -1; // 찾지 못한 경우 -1 반환
}

// 사용 예제
const sortedArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const target = 7;
const result = binarySearch(sortedArray, target);

if (result !== -1) {
console.log(`타겟 값 ${target}은(는) 인덱스 ${result}에 있습니다.`);
} else {
console.log('타겟 값을 찾지 못했습니다.');
}

그래프

DFS

깊이 탐색한 다음 되돌아오는 깊이 우선 탐색으로, 하나의 경로를 끝까지 시도해봐야 하는 문제에서 주로 사용된다. DFS에서 시간초과가 발생한다면 BFS를, BFS에서 메모리 초과가 발생한다면 DFS를 시도해보는 것이 좋다.

function solution(graph, start) {
const result = [];
const list = {};
const visited = new Set();
for (const [a, b] of graph) {
list[a] = list[a] ? list[a].concat(b) : [b];
}
function dfs(node, visited, result) {
visited.add(node);
result.push(node);
(list[node] || []).forEach(neighbor => {
if (!visited.has(neighbor)) {
dfs(neighbor, visited, result);
}
});
}
dfs(start, visited, result);
return result;
}

BFS

넓게 탐색하며 진행하는 너비 우선 탐색으로, 최단거리를 찾아야 하는 문제에서 주로 사용된다.

/** 넓이 우선탐색 */
function solution(graph, start) {
const result = [start];
const list = {};
const visited = new Set();
for (const [a, b] of graph) {
list[a] = list[a] ? list[a].concat(b) : [b];
}

const queue = [];
queue.push(start);
visited.add(start);
while (queue.length) {
const node = queue.shift();
for (const target of list[node] || []) {
if (!visited.has(target)) {
queue.push(target);
visited.add(target);
result.push(target);
}
}
}

console.log(result);
return result;
}

빌드시간 개선을 위한 CRA to Vite Migration

· 약 4분

현재 CRA를 기반으로 만들어진 패키지로 웹뷰 서비스를 배포하고 있는데요. 기존까지는 큰 문제가 없었지만, 클라우드 서버 내에서 빌드를 하게 되면서 빌드시간이 2~3배 증가하는 문제가 생겼습니다. 때문에 빌드시간 단축을 위해 CRA의 Webpack v5에서 Vite 로 Migration을 시도했는데요. CRA에서 Vite로 변경했을 때의 빌드시간과 함께 이때 발생한 문제점에 대해 공유드리려 합니다.

왜 Vite를 사용하나요? Vite는 사전빌드를 거친 후 Native ESM을 사용하여 배포하는데, 개발단계에서는 Go로 작성된 Esbuild를 사용해서 Webpack, Parcel과 같은 기존의 번들러 대비 10-100배 빠른 속도를 제공합니다. 다만, 확장성과 안정성을 위해 배포시에는 Rollup을 기반으로한 사전 빌드를 거칩니다. ⠀ CommonJS를 통하여 빌드할 뿐만 아니라 필요하지 않은 설정까지 포함된 CRA에 비해 Vite는 더 가볍고 빠르기 때문에 최근 CRA보다 Vite를 사용하는 추세입니다.


Vite를 사용하여 빌드한 결과

기존 CRA의 환경변수와 플러그인을 Vite로 변경하고, Vite Migration을 위해 몇가지 수정을 거친 후 테스트를 진행하였습니다.

그 결과 꽤 극적인 결과가 나타났습니다.

  • 기존 CRA의 Webpack을 사용한 빌드시간
    • 1 min 35 sec total from scheduled to completion.
  • Vite 변경 후 빌드시간 (기존 대비 약 67% 빠름) - 31 sec total from scheduled to completion. ⠀
  • 기존 CRA의 Webpack을 사용한 클라우드 서버 빌드시간
    • 4 min 8 sec total from scheduled to completion.
  • Vite 변경 후 클라우드 서버 빌드시간 (기존 대비 약 57% 빠름)
    • 1 min 47 sec total from scheduled to completion.

구형 브라우저 지원이 필수적인 웹뷰 환경

그러나 여기에는 한가지 간과한 부분이 있었습니다. Vite v5는 네이티브 ES 모듈네이티브 ESM의 동적 Import, 그리고 import.meta 을 지원하는 브라우저를 타깃으로 하고 있습니다. (Vite 브라우저 지원 현황)

하지만 웹뷰 특성상 여러 기기에서 사용하기 때문에 오래된 브라우저에 대한 지원이 필수적입니다. 실제로 간혹 Object.fromEntries 와 css gap 속성을 사용하지 못하는 기기로 인해 문제가 생기기도 했습니다.

때문에 @vitejs/plugin-legacy 를 사용하여 오래된 브라우저를 위해 폴리필 해줘야만 하는데요. last 2 versions and not dead, > 0.3% 으로 폴리필 한 결과 다음과 같은 결과가 나타났습니다.

  • Vite 구형 브라우저 폴리필 지원 후 빌드시간 (기존 대비 약 24% 빠름)
    • 1 min 12 sec total from scheduled to completion.
  • Vite 구형 브라우저 폴리필 지원 후 클라우드 서버 빌드시간 (기존 대비 약 79% 느림)
    • 7 min 24 sec total from scheduled to completion.

클라우드 서버에서 좋지 않은 결과를 보여주는 이유는?

Native ESM를 사용하는 Vite와는 달리, CRA의 Webpack은 CommonJS를 사용하고 있기 때문에 구형 브라우저에 대한 지원율이 높습니다. 하지만 Vite의 경우 더 많은 폴리필을 지원해야 하기 때문에 같은 커버리지를 지원하면서도 상대적으로 빌드 시간이 더 오래 걸리게 되는 것인데요.

그럼에도 로컬 빌드 시 조금 더 빠른 성능을 보여주는 이유는 Vite의 배포 빌드 시 사용하는 Rollup은 Rust로 쓰여진 SWC를 파서로 사용하고 있기 때문에(https://github.com/rollup/rollup/pull/5073)  성능이 좋은 멀티코어 환경에서는 조금 더 빠른 성능을 보여주지만, 성능이 좋지않은 클라우드 서버 환경에서는 매우 느린 결과를 보여주고 있다고 추측합니다.


결론

구형 브라우저까지 지원할 필요가 없는 환경이거나,  멀티코어 성능이 좋은 환경에서 빌드를 해야 한다면 Vite는 좋은 선택이 될 것 입니다. 다만 웹뷰와 같이 많은 사용자에 대한 지원이 필요한 환경이라면 빌드속도를 위해 Vite를 선택하는 것은 그리 효과적이지 않을 수 있습니다.

자바스크립트는 왜 프로토타입을 선택했을까

· 약 5분

자바스크립트의 프로토타입이란?

"프로토타입은 기존 객체를 복제하여 새로운 객체를 생성하는 디자인 패턴으로, 사전에 클래스를 정의해야 하는 클래스 디자인패턴에 비해 객체 생성과정을 단순화 할 수 있다는 장점이 있다." 이처럼 프로토타입 디자인패턴에 대한 설명으로 간단히 이야기할 수도 있다. 자바스크립트의 렉시컬스코프, 클로저, 호이스팅이 어떻게 생겨났는지를 프로토타입 철학을 기반으로 분석한 포스팅을 보고, 좀 더 쉽게 각색하여 서양철학부터 context까지를 간략히 정리하였다.

클래스와 인스턴스로 세상을 구분했던 19세기 이전 서양철학

객체지향 프로그래밍의 주요 개념인 클래스와 인스턴스의 최초의 구분은 플라톤에 의해 제시되었는데, 플라톤은 클래스과 인스턴스를 이분법적으로 명확히 구분했다. 눈앞에 실제로 존재하는 사물(인스턴스)이 있다면 반드시 그것의 본질(클래스)이 존재한다는 것이 플라톤의 주장이었다. 그리고 이러한 본질 세계를 이데아(Idea) 라고 칭하며 현실의 사물은 모두 이데아의 본질을 모방한 것이라 주장했다. 의자로 예를 들자면, 의자 라는 클래스가 있을 때 바퀴달린 의자는 의자 라는 클래스를 모방한(상속받은) 바퀴달린 의자라는 것이다.

그리고 이러한 철학을 물려받은 플라톤의 제자 아리스토텔레스는 세상에는 하나의 보편적인 분류가 있다고 말하며, 이를 통해 세상을 분류하려 하였다. 이때 속성을 가지고 세상을 분류하였는데, 이때문에 돌고래는 어류가 아닌 포유류로 분류되었다. 허파로 숨을 쉬고, 새끼를 낳아 기르기 때문이었다.

이분법적인 서양철학을 반박하는 비트겐슈타인과 prototype

하지만 우리의 일반적인 시각으로는 돌고래는 물에서 살고 헤엄을 치기 때문에 어류가 맞다고 생각할 수 있다. 이처럼 분류의 기준은 문맥에 따라서 달라질 수 있음을 주장하며 나온 것이 프로토타입의 기반이 되는 비트겐슈타인의 의미사용이론이다. 의미사용이론은 '벽돌!' 이라고 하는 말의 의미는 벽돌이 필요할 때에는 '벽돌을 달라!', 벽돌이 떨어질 때는 '벽돌을 조심해!' 와 같이 문맥에 따라 의미가 달라진다는 것이다.

또한 공통된 정의 특성이 없더라도 다양한 방식으로 닮아있을 수 있다는 “가족 유사성”이라는 개념을 정의했는데, 가족 구성원은 다양한 방식으로 서로 닮아 있는 것 처럼, 분류에는 '포유류는 젖을 먹고 허파로 숨을 쉬어야 한다.' 와 같은 공통된 속성이 필요하지 않다는 것이다.

이러한 비트겐슈타인의 의미사용이론, 가족 유사성은 1970년경 철학자 엘렌 로쉬 에 의해 프로토타입 이론(Prototype theory)으로 정리된다.

프로토타입 이론의 주요 특징 CONTEXT

엘렌 로쉬가 정리한 프로토타입 이론은

  • 같은 단어라도 어떤 상황(context)에서 접했냐에 따라 달라진다
  • 정의로 부터 분류되는 것이 아니라 가장 좋은 보기로부터 범주화 된다. 라는 특징을 가진다.

위 특징은 자바스크립트의 동작원리와 밀접한 관계를 가지는데, 자바스크립트의 특징인 스코프체인, 호이스팅, this, closure이 자바스크립트의 실행 컨텍스트(context)에서 비롯되기 때문이다.

즉, 자바스크립트가 이러한 실행컨텍스트를 가지는 이유는 문맥에서 비롯되는 객체의 의미를 정의하기 위해서이며, 프로토타입 기반의 언어인 자바스크립트가 가지는 특징이 되는 것이다.


3줄요약

  1. 프로토타입은 한가지의 보편적인 분류로 의미를 나누는 class와 반대되는 개념이다.
  2. 프로토타입 이론은 의미가 문맥(context)에 따라 달라질 수 있음을 주장하는 철학이다.
  3. 자바스크립트 프로토타입의 호이스팅, 클로저, 스코프체인 등은 이러한 프로토타입의 문맥(context)을 이해하기 위한 수단이다.


참고자료: 자바스크립트는 왜 프로토타입을 선택했을까 JavaScript : 프로토타입(prototype) 이해 ECMAScript 명세 이해, 1부 [JS] 📚 자바스크립트 실행 컨텍스트 원리