React Deep Dive 1강

RookieAND·2024년 3월 9일
10

React Deep Dive

목록 보기
1/8
post-thumbnail

✒️ 리액트 개발을 위해 꼭 알아야 할 자바스크립트

✏️ JS 의 동등성 비교

  • useEffect 를 사용할 때, 첫번째 인자로 넣은 setup 함수 내에 존재하는 Reactive 한 값은 반드시 deps 에 넣어야 한다.
    • Reactive 하지 않은 값은 컴포넌트 내부에서 리렌더링이 되어도 절대 변하지 않는 값 이다.
    • 반대로 Reactive 한 값은 매 렌더링마다 값이 달라질 수 있음을 의미한다.
  • 이때 각 배열에 존재하는 값들을 순회하면서 동등 비교를 시행하고 만약 값이 변경되었다면 setup 함수가 실행된다.
  • React 의 props 는 리렌더링을 유발시키는 요소이며, 이전과 현재의 props 를 비교하는 과정도 동등 비교가 담당한다.

React 에서는 동등 비교를 어떤 식으로 진행하는가?

  • 기본적으로 React 에서는 Object.is 메서드로 두 값의 동등 비교를 시행하며, 참조형 타입의 경우 같은 메모리 주소를 참조한다면 같다고 판단한다.
  • 이렇게 두 객체의 내부 데이터를 상세히 조회하지 않고 참조된 메모리 주소만을 판별하는 동등 비교 방식이 "얕은 비교" 이다.

✒️ JS 의 데이터 타입

  • JS 의 데이터 타입은 크게 원시 타입과 객체 타입으로 나뉜다.

  • 원시 타입은 객체 타입이 아닌 나머지 타입을 의미한다.

  • boolean, string, number, null, undefined, BigInt, Symbol 로 이루어져 있다.

typeof null === 'object'

  • JS 개발을 하다보면 흔히 마주할 수 있는 궁금증이 바로 해당 비교문이다.
  • 이는 JS 가 초기 설계되었을 때 생긴 결함으로, 이전 코드와의 호환성 문제로 아직까지 고쳐지지 못했다.
  • undefined 는 아직 값이 할당되지 않은 상태이며, null 은 명시적으로 값이 비었음을 의미한다. (값은 값이다.)

Number vs BigInt

  • Number 는 일반적으로 -2^53 - 1 ~ 2^53 - 1 사이를 표현할 수 있다.
  • 그 이상 숫자가 커질 경우 값이 달라도 true 를 반환한다.
  • 하지만 BigInt 의 경우 number 가 표현할 수 있었던 한계를 넘어설 수 있다.

✏️ 값을 저장하는 방식의 차이

  • 원시 타입의 경우 값을 복사하더라도 이를 새로운 메모리 주소를 할당 받아 채우기 때문에 주소는 다르다.
  • 하지만 참조 타입 (Object) 의 경우 메모리 주소를 참조하는 형식으로 값을 넘기기 때문에 차이가 있다.

✏️ Object.is

  • Object.is 는 ES6 에서 새롭게 정의된 동등 비교 연산이며, 기존의 비교 연산자와의 차이점은 아래와 같다.

  • ==Object.is 의 차이

    • == 의 경우 양쪽이 동등한 타입이 아니라면 이를 캐스팅한다.
    • 하지만 Object.is 의 경우 타입이 다르면 false 를 반환한다.
  • ===Object.is 의 차이

    • Object.is+0-0 을 다르다고 판단하지만 === 는 그렇지 않다.
    • Object.isNumber.NaN (혹은 그 외 NaN 이 나올 만한 케이스) 과 NaN 을 같다고 판단하지만 === 는 그렇지 않다. (NaN === NaN 은 무조건 false 다.)
  • 참조형 타입의 경우 두 객체가 같은 메모리 주소를 참조한다면 true 를 반환한다.

Object.is 조금 더 자세히 살펴보기

function is(a, b) {
    return (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
}
  • Object.is 는 ES6 스펙이기 때문에 ES5 이전 버전을 지원하기 위해 Polyfill 된 유틸 함수를 React 내부에서 사용한다.
  • 이를 조금 더 자세히 뜯어보면 아래와 같은 로직을 거친다.
  1. x === y 가 성립한다면, x !== 0 || 1 / x === 1 / y 식도 성립해야 한다.
    1-1. x !== 0 인 경우
    - x 가 0이 아니라면 y 도 0이 아니며, x === y 이므로 x 와 y 가 NaN 인 경우 외에는 항상 참이다.
    1-2. x === 0 인 경우 1 / x === 1 / y 이어야 한다.
    - 만약 x 가 +0 이라면 1 / x 는 Infinity 이고 -0 라면 -Infinity 이다. 따라서 x 와 y 의 부호가 다르면 false 를 반환한다.
    - 엄격한 비교의 경우 두 케이스를 같다고 보기 때문에 이를 구별하기 위한 비교식이다.
  2. x !== x && y !== y 인 경우
    • 엄격한 비교의 경우 x !== x 를 성립하는 조건은 x 가 NaN 인 경우 외에 없다.
    • 따라서 xy 가 둘 다 NaN 일 경우 true 를 반환하고, 그 외에는 false 를 반환한다.

✏️ ShallowEqual in React

  • React 에서는 동등 비교가 필요한 경우 Object.is 에 더해 별도의 추가 작업을 더하여 정의한 shallowEqual 유틸 함수를 사용한다.

  • shallowEqual 의 경우 object 를 비교할 때 1 Depth 만 체크하므로 복잡한 구조의 Object 를 Props 로 넘길 경우 메모이제이션이 정상적으로 동작하지 않는다.

  • Source : https://github.com/facebook/react/blob/master/packages/shared/shallowEqual.js

function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}
  • 가장 먼저 Object.is 를 사용하여 두 값이 동등한지를 체크한다.
  • 이후 a 와 b 가 object 가 아닌 경우 (null 제외) 에는 전부 false 를 반환한다.
  • 만약 a 와 b 가 object 라면, a 가 가진 키가 b 에도 있으며 두 value 가 같은지를 비교한다. (1 Depth 만 비교)

✒️ 함수

✏️ 함수의 정의

  • 함수란 작업을 수행하거나 값을 계산하는 과정을 표현하고, 이를 하나의 "블록" 으로 감싸 실행 단위로 만든 것이다.

✏️ 함수의 정의 방식

  1. 함수 선언문 : function 키워드를 사용하여 함수를 정의한다.
  2. 함수 표현식 : function 키워드를 사용하여 정의한 함수를 식별자에 할당한다.
// 함수 선언문
function add(a, b) {
  return a + b;
}

// 함수 표현식
const add = function (a, b) {
  return a + b;
};
  • 두 표현 모두 함수를 정의하는데 쓰이지만, 가장 큰 차이는 hoisting 의 여부이다.
  • 함수 선언문의 경우 코드 실행 전에 해당 함수의 정보가 사전에 메모리에 등록되기 때문에 동일한 레벨의 컨텍스트 내부에서는 어디서든 사용이 가능하다.
  • 하지만 함수 표현식은 생성된 함수를 변수에 할당하였고, 변수의 경우 값이 할당되기 이전에 사용될 경우 var 는 undefined 를, const 와 let 은 ReferenceError 를 유발시킨다.
  1. Function 생성자 : new Function() 생성자를 기반으로 함수를 생성하는 방식
  2. Arrow Function : => 키워드를 사용한 익명 함수를 생성하고 이를 변수에 할당하는 방식.
// Function 생성자
const add = new Function("a", "b", "return a + b");

const subFuncBody = "return a - b";
const sub = new Function("a", "b", subFuncBody); // 런타임 환경에서 Body 를 할당받아 실행이 가능하다.

// Arrow Function
const add = (a, b) => a + b;
  • Function 생성자의 경우 가장 마지막 인자로 실행할 함수 본문을 받고, 이전 인자들은 모두 매개변수로 받는다.
  • Function 생성자의 가장 큰 특이점은 런타임 환경에서 인계 받은 문자열을 사용하여 함수를 만들 수 있다는 점이다. (코드 실행 전이 아니라, 런타임 환경이라는 게 중요하다)
  • Arrow Function 의 경우 함수 표현식과 선언문 방식으로 생성된 함수와는 달리 arguments 가 없으며 생성자 기반으로 제작이 불가능하다.
  • 또한 this 바인딩이 특이한데, 화살표 함수는 함수 자체의 바인딩을 가지지 않기 때문에 자신을 호출한 스코프를 기준으로 상위 스코프를 가리킨다.

✏️ 그 외 자주 쓰이는 함수 사용 패턴

  1. IIFE (즉시 실행 함수) : 함수를 정의하는 순간 실행되는 함수
  2. HOC (고차 함수) : 함수를 인자로 받거나 새로운 함수를 반환하는 함수
// IIFE 
async (() => {
    const slackClient = await slackApp.bootstrap();
    slackClient.init();
})

// HOC
const Component = () => (<div> {...} </div>)
const intlComponent = withIntl(Component);
  • IIFE 의 경우 함수가 정의되는 즉시 실행되기에 1회성으로 동작한다. 따라서 재사용이 불가능하다.
  • HOC 의 경우 함수형 컴포넌트 또한 함수이기 때문에 특정 컴포넌트를 인자로 받아 로직을 부착시켜 반환하는 용도로 많이 쓰인다.

✏️ 함수 제작 시 주의 사항

  1. 함수의 부수 효과를 최대한 억제하라
  • 함수의 부수효과란 함수 내부의 작동으로 함수 외부에 영향을 끼치는 현상을 의미한다.
  • 함수는 기본적으로 동일한 인자를 받으면 항상 동일한 결과를 반환해야 하며, 함수의 실행이 외부에 영향을 미쳐서는 안된다.
  • 물론 부수 효과가 아예 없는 상황은 없으나 코드를 더 쉽게 이해하도록 하며 디버깅을 용이하게 하는 순수 함수를 채용하려 노력해야 한다.
  1. 함수를 작게 만들어라.
  • 하나의 함수에 여러 동작을 넣지 말고 최대한 단일 기능을 하도록 함수를 설계하자.
  • 함수에 여러 기능이 추가되면 각 기능들이 동작하는 과정에서 예상치 못한 에러를 맞이할 확률이 올라간다.
  1. 함수 명을 명료하게 짓자.

✒️ 클래스

✏️ 클래스란?

  • 주로 특정한 목적을 가진 객체를 반복적으로 생성하기 위해 사용된다.
  • ES6 스펙에서 추가된 문법이며, 이전 버전의 경우 주로 prototype 기반의 객체 모델링을 진행했다. (같은 객체를 반환하는 함수 포함)

✏️ constructor

  • 객체 (클래스 인스턴스) 를 생성하기 위해 사용되는 특수 메서드다.

✏️ property

  • 클래스 내부에서 정의할 수 있는 속성 값을 의미한다.
  • Typescript 의 경우 protected, private 와 같이 속성 접근 제한자를 사용할 수 있고, JS 에서도 # 을 사용하여 특정 속성을 private 하게 지정할 수 있다.

✏️ getter, setter

  • 클래스 내부에서 특정한 값을 가져올 때 쓰이는 패턴이다.
  • getter 함수의 경우 앞에 get 을, setter 함수의 경우 set 을 붙인다.

✏️ 인스턴스 메서드

  • 클래스 내부에서 선언한 메서드를 인스턴스 메서드라고 한다.
  • 인스턴스 메서드의 경우 코드 상으로는 Class 내부에 정의되나, 런타임 환경에서는 prototype 에 선언되어 prototype 메서드라고 불린다.
  • JS 는 기본적으로 프로토타입 언어이기 때문에, 객체 내부의 메서드나 속성들이 전부 prototype 을 기반으로 정의된 것을 볼 수 있다.
class Car {
    constructor(name) {
        this.name = name;
    }

    getName() {
        return this.name
    }
}

const myCar = new Car('레이');
console.log(Object.getPrototypeOf(myCar)) // { constructor: f, getName: ƒ }

Prototype Chaining

  • 해당 메서드를 객체에서 선언하지 않았으나 프로토타입 내부에 존재하는 메서드를 찾아 실행하는 방식을 Prototype Chaining 이라 한다.
  • 모든 객체는 Object 를 최상위 객체로 가지기 때문에, 별도의 정의 없이도 Object 내부 prototype 에 정의된 메서드들을 기본적으로 사용할 수 있다.
  • toString() 메서드의 경우에도 별도의 정의 없이 어느 객체에서나 사용할 수 있다.

✏️ static (정적) 메서드

  • 별도의 클래스 인스턴스 없이도 클래스 명을 기반으로 호출할 수 있는 메서드
  • 정적 메서드 내부의 this 는 클래스 인스턴스가 아닌 클래스 자신을 가리키기 때문에 유의해야 한다.

✏️ 상속

  • 기존의 클래스를 상속 받아 자식 클래스에 이를 확장시키는 문법
  • 클래스를 상속받은 대상은 부모 클래스에서 정의된 메서드와 클래스 속성을 모두 사용할 수 있다.

✒️ 클래스와 함수의 관계

  • ES6 이전에는 prototype 기반으로 Class 의 역할을 대신해왔기 때문에, Class 코드를 ES5로 트랜스파일링 할 경우 아래와 같이 반환된 코드가 나온다.
'use strict';

function _defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
        var descriptor = props[i];
        descriptor.enumerable = descriptor.enumerable || false;
        descriptor.configurable = true;
        if ('value' in descriptor) descriptor.writable = true;
        Object.defineProperty(
            target,
            _toPropertyKey(descriptor.key),
            descriptor,
        );
    }
}

function _createClass(Constructor, protoProps, staticProps) {
    if (protoProps) _defineProperties(Constructor.prototype, protoProps);
    if (staticProps) _defineProperties(Constructor, staticProps);
    Object.defineProperty(Constructor, 'prototype', { writable: false });
    return Constructor;
}

function _toPropertyKey(arg) {
    var key = _toPrimitive(arg, 'string');
    return _typeof(key) === 'symbol' ? key : String(key);
}

function _toPrimitive(input, hint) {
    if (_typeof(input) !== 'object' || input === null) return input;
    var prim = input[Symbol.toPrimitive];
    if (prim !== undefined) {
        var res = prim.call(input, hint || 'default');
        if (_typeof(res) !== 'object') return res;
        throw new TypeError('@@toPrimitive must return a primitive value.');
    }
    return (hint === 'string' ? String : Number)(input);
}

function _classCallCheck(instance, Constructor) {
    if (!(instance instanceof Constructor)) {
        throw new TypeError('Cannot call a class as a function');
    }
}
var Cat = /*#__PURE__*/ _createClass(function Cat(name) {
    _classCallCheck(this, Cat);
    this.name = name;
});

트랜스파일링 된 코드들이 각각 어떤 역할을 하는지 알아보자.

  1. _createClass 함수
function _createClass(Constructor, protoProps, staticProps) {
    if (protoProps) _defineProperties(Constructor.prototype, protoProps);
    if (staticProps) _defineProperties(Constructor, staticProps);
    Object.defineProperty(Constructor, 'prototype', { writable: false });
    return Constructor;
}
  • _createClass 함수는 첫 번째 인자로 Constructor (생성자) 함수를 받는다.
  • 이후 protoProps 와 staticProps 를 받는데 각각 생성자 호출로 반환된 객체의 prototype 혹은 생성자 내부에 새로운 속성을 추가하고 싶을 때 쓰인다.
  • Constructor 함수의 prototype 속성 중 writable flag 를 false 로 수정함으로서 수정이 불가하도록 한다. (configurable 은 true 이기에 완전한 수정 불가는 아니다)
  • 수정이 완료된 함수가 반환되고, 이후 해당 함수를 new 키워드로 생성자를 호출하여 prototype 에 적재된 메서드들이 포함된 객체를 반환한다.
  1. _classCallCheck 함수
var Cat = /*#__PURE__*/ _createClass(function Cat(name) {
    _classCallCheck(this, Cat);
    this.name = name;
});

function _classCallCheck(instance, Constructor) {
    // 만약 Cat 함수가 new Cat() 이 아닌 Cat() 으로 호출되었다면 에러 발생.
    if (!(instance instanceof Constructor)) {
        throw new TypeError('Cannot call a class as a function');
    }
}
  • 함수는 일반 호출과 new 키워드를 기반으로 한 생성자 호출로 나뉘는데, 여기서는 생성자를 호출해야 하므로 이를 검사하기 위해 추가된 함수다.
  • 일반적으로 함수를 그냥 실행할 경우 this 바인딩이 전역 객체로 이어지기 때문에, instance instanceof Constructor 조건문을 통과할 수 없다.
  1. _defineProperties 함수
function _defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
        var descriptor = props[i];
        descriptor.enumerable = descriptor.enumerable || false;
        descriptor.configurable = true;
        if ('value' in descriptor) descriptor.writable = true;
        Object.defineProperty(
            target,
            _toPropertyKey(descriptor.key),
            descriptor,
        );
    }
}
  • ES5 스펙의 Object.defineProperties 함수를 래핑한 함수다.
  • value 가 존재하는 property 인 경우 수정이 가능하도록 writable flag 를 true 로 설정한다.
  • configurable flag 는 true 이며, enumerable flag 의 경우 property 에 정의된 값을 따라간다. (default 는 false)

✒️ 클로저

✏️ 클로저의 정의

  • MDN 에서 정의한 클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합이다.
  • 즉, 함수가 생성되는 위치를 기준으로 주변의 Lexical Environment (어휘적 환경) 을 참조하고, 해당 환경 내부에 정의된 모든 식별자가 클로저의 대상이 된다.
function makeFunc() {
  // displayName 함수 내부에서 스코프 체이닝으로 인해 해당 변수를 참조하므로, makeFunc 함수가 종료되어 실행 컨텍스트에서 사라져도 변수는 사라지지 않는다.
  const name = "Mozilla";
  function displayName() {
    // name 은 displayName 의 외부 Lexical Environment 에 위치한 name 변수를 참조한다.
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

실행 컨텍스트 관점에서 해당 클로저가 어떻게 동작하는지를 살펴보자.

  • myFunc 식별자에 makeFunc 함수의 실행 결과가 할당된다. Call Stack 에 myFunc 함수와 관련한 실행 컨텍스트가 적재된다.
  • makeFunc 함수는 내부에서 displayName 함수를 선언한다. Call Stack 에 displayName 함수와 관련한 실행 컨텍스트가 적재된다.
  • 이때 displayName 함수가 선언된 환경 (makeFunc 함수) 을 보면 name 변수가 있다.
  • displayName 실행 컨텍스트 내부의 outerEnvironmentReference 포인터에 의해 외부 Lexical Environment 를 참조한다.
  • 외부의 Lexical Environment (makeFunc 함수) 내 Environment Record 내부에는 name 식별자가 있고, displayName 함수 내부에서는 이를 사용한다.
  • 이후 makeFunc 함수가 종료되고 Call Stack 에 쌓였던 실행 컨텍스트 또한 제거 된다.
  • 하지만 클로저에 의해 참조되는 변수 name 은 유효하여 사라지지 않고, 이후 myFunc 함수를 호출할 때도 스코프 체이닝으로 접근이 가능하다.

클로저에 의해 사라지지 않는 변수를 뭐라고 할까?

  • 함수는 자신이 선언되었을 당시의 Lexical Environment 를 참조할 수 있다.
  • 그 이유는 실행 컨텍스트 내부의 outerEnvironmentReference 포인터 때문이다. (스코프 체이닝에 쓰임)
  • 만약 외부의 실행 컨텍스트가 Call Stack 에서 제거되었더라도, 클로저로 인해 참조되는 변수는 사라지지 않는다.
  • 이러한 변수를 자유 변수 라고 한다. 해당 변수를 참조하는 함수에 더 이상 접근할 수 없지 않는 이상 계속해서 메모리에 잔존한다.

✏️ 전역 스코프

  • 전역 레벨 (Global Context) 에서 선언된 변수들의 유효 범위를 전역 스코프라고 정의한다.
  • Scope Chaining 에 의해 해당 변수는 코드 어디서든 접근할 수 있다.
  • 브라우저에서는 window 객체에, NodeJS 런타임 환경에서는 global 전역 객체에 전역 레벨에서 선언된 식별자들이 바인딩 된다.
var global = 'global'

function hello () {
    // hello 내부에서 사용되는 global 의 경우에도 Scope Chaining 을 기반으로 전역 컨텍스트에 정의된 global 변수를 참조한다.
    console.log(global) // global
}

console.log(global) // global

✏️ 함수 스코프

  • JS 에서는 타 언어와 다르게 기본적으로 함수 레벨의 스코프를 기준으로 나눈다.
  • 만약 함수 내부에 식별자가 정의되었다면, 해당 함수 내부가 아닌 다른 곳에서는 해당 식별자를 참조할 수 없다.
function a() {
    const x = '100';
    var y = '200';
    console.log(x) // 100

    function b() {
        var x = '1000';
        console.log(x) // 1000
        console.log(y) // 200
    }
}
  • 함수 a 내부에 선언된 x 변수에 할당된 값은 100 이고, 이는 함수 a 내부에서 유효하다.
  • 함수 b 내부에 선언된 x 변수에 할당된 값은 1000 이고, 이는 함수 b 내부에서 유효하다.
  • 함수 b 내부에서 쓰이는 y 변수는 스코프 체이닝으로 인해 a 함수 내부에 선언된 변수를 참조하고, 할당된 값은 200 이다.

블록 스코프

  • ES6 에서 추가된 const, let 의 경우 중괄호 ({}) 를 기준으로 스코프를 할당 받는다. 이를 블록 스코프라고 한다.
var a = 100;

{
    const a = 1000;
    console.log(a); // 1000
}

console.log(a); // 100

✏️ 클로저의 장점

  • 특정 값의 접근을 클로저를 활용하여 제한할 수 있고, 값의 수정과 삭제에 필요한 로직을 특정하여 넘겨줄 수 있다.
  • React 의 경우 State 를 클로저로 관리하며, 이를 수정할 수 있는 수단으로 setState 함수를 넘겨 클로저에 접근할 수 있도록 해준다.
  • 해당 state 를 호출한 함수 컴포넌트의 실행이 종료되더라도, state 의 값은 클로저로 인해 메모리에 저장된 상태이기 때문에 리렌더링 이후에도 state 값에 접근할 수 있다.

✏️ 클로저의 단점

  • 클로저에 의해 특정 값을 GC 에 의해 사라지는 것을 막고 이를 계속 메모리에 보관하는 것은 어느 정도의 비용을 수반한다.
  • 만약 불필요한 값이 클로저에 의해 보관되고 사라지지 않는다면 이는 메모리 누수의 원인이 되며, 불필요하게 큰 메모리를 잡아먹을 수 있다.
// Closure 를 사용하지 않은 경우
const aButton = document.getElementById('a');

function heavyJob() {
    const longArr = Array.from({length: 100_000_100}, (_, i) => i);
    console.log(longArr);
}

aButton.addEventListener('click', heavyJob)

// Closure 를 사용하는 경우
const bButton = document.getElementById('b');

function heavyJobWithClosure() {
    // longArr 배열은 heavyJobWithClosure 함수가 호출되어 내부 함수가 반환될 때 참조되며, 메모리에서 사라지지 않는다.
     const longArr = Array.from({length: 100_000_100}, (_, i) => i);

     return function () {
        // 여기서 longArr 를 사용하고 있기 때문에 longArr 변수는 메모리에서 사라지지 않는다.
        console.log(longArr);
     }
}

const innerFunc = heavyJobWithClosure();

bButton.addEventListener('click', function () {
    innerFunc();
})
  • 클로저를 사용하는 경우 이벤트가 발동되었을 때 배열이 생성되는 것이 아닌, heavyJobWithClosure 함수가 실행되어 innerFunc 에 반환된 함수가 적재될 때 생성된다.
  • 따라서 실제 해당 배열이 필요한 경우 (Click 이벤트가 발동된 순간) 가 아니라 항상 longArr 배열을 메모리에 적재해두기 때문에 클로저를 사용하는 것이 더 비효율적이다.

✒️ JS 는 싱글 스레드 기반의 Non - Blocking 언어다.

✏️ JS 는 싱글 스레드 언어다.

  • JS 가 개발될 당시에는 멀티 쓰레드의 개념이 크게 정착되지 않았을 때이기 때문에 하나의 프로세스가 단일 스레드를 기반으로 동작하는 싱글 스레드 방식을 채택했다.
  • 따라서 JS 코드 내 실행은 위에서 아래로 순차적으로 동작하며, 한 곳에서 실행이 오래 걸릴 경우 이후의 코드가 실행되지 않는 특징을 가진다.
  • 이렇게 동기적으로 코드가 실행되는 상황에서, 앞선 코드의 실행이 완료되지 않아 다음 실행을 막는 현상을 Blocking 이라 한다.

✏️ JS 는 Non - Blocking 언어다.

  • 하지만 비동기적으로 동작하는 JS 코드는 이후 코드의 실행을 막지 않는다. 즉 Non - Blocking 하다는 특징을 가진다.
  • 하지만 JS 는 싱글 스레드인데 어떻게 해당 작업을 병렬로 실행할 수 있는가? 이는 브라우저에서 지원하는 멀티 쓰레드 기반의 Web API 를 사용하기 때문이다.

비동기로 동작한다 VS Non Blocking 하다.

  • 비동기로 동작한다 : 앞선 코드와 이후 코드의 실행 순서가 보장되지 않음을 의미.
  • Non - Blocking 하다 : 앞선 코드의 실행이 완전히 종료되기 이전에 이후 코드가 바로 실행된다. 즉 두 작업을 병렬로 실행할 수 있다. (실제로는 완전히 같은 타이밍에 실행되지 않는다.)

✒️ 이벤트 루프

✏️ 이벤트 루프의 정의

책에서는 브라우저 (Web API) 기반의 이벤트 루프를 위주로 설명했기 때문에, 정리글 또한 whatwg Spec 을 기반으로 작성한다.

  • 비동기로 동작하는 태스크 (Call Stack 에 바로 적재되지 않는 작업) 들을 별도의 Queue 에 보관했다가, Call Stack 이 빌 경우 보관했던 태스크들을 순차적으로 처리하는 일련의 과정을 이벤트 루프라고 한다.

✏️ Call Stack

  • JS 엔진에 의해 코드가 실행되기 전에 필요한 코드나 식별자와 관련한 정보를 보관한 실행 컨텍스트를 적재하는 Stack 이다.
  • Call Stack 의 경우 먼저 생성된 컨텍스트부터 쌓이며, 이후에 쌓인 컨텍스트부터 사라지는 Stack 의 특징을 그대로 가진다.

✏️ Task Queue

  • 이벤트 루프는 결국 Call Stack 에 바로 적재되지 않고 비동기로 동작하는 작업들을 별도의 Queue 에 보관하고, 이후 Call Stack 이 비었을 경우 Queue 에 적재된 작업을 하나씩 처리하는 과정을 의미한다.
  • 이때 비동기로 동작하는 여러 작업들을 보관하는 Queue 를 바로 Task Queue 라고 한다.

이름이 Task Queue 라고 해서 자료 구조가 Queue 인 것은 아니다.

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.
  • whatwg 스펙에서는 Task Queue 의 구현체가 Queue 가 아닌 Set (Ordered Set) 임을 안내하고 있다.
  • 그 이유는 Task Queue 에서 작업을 가져올 때 실행 가능한 Task 중 하나를 꺼내야 하기 때문인데, 이것이 반드시 가장 "첫번째" 에 위치한 태스크를 꺼내는 것은 아니기 때문이다.
  • Link : https://html.spec.whatwg.org/multipage/webappapis.html#task-queue

✏️ 이벤트 루프의 구현체는 어디에?

  • 자바스크립트 코드를 실행하는 작업은 단일 스레드로 구성되지만, 비동기로 동작하는 작업의 경우에는 별도의 스레드에서 수행된다.
  • 별도의 스레드에서 이러한 작업을 실행하도록 돕는 장치는 Browser 의 Web API 나, NodeJS 의 경우 C++ 기반의 라이브러리인 libuv 를 기반으로 이벤트 루프를 구현한다.

✏️ 마이크로 태스크 큐

  • 비동기 작업을 처리하기 위해 ECMA 에서는 PromiseJobs 이라는 내부 Queue 를 명시하는데, V8 엔진에서는 이를 마이크로 태스크 큐라고 정의한다.
  • 마이크로 태스크 큐는 태스크 큐와 다르게 먼저 들어온 작업을 우선하여 실행한다. (FIFO : First In First Out)
  • 모든 Promise 작업의 경우 Microtask Queue 에 들어가 처리되기에 Promise 는 늘 비동기로
    작동한다.
  • 추가로 MutationObserver 가 변화를 관측할 시 실행하는 Callback 함수 또한 Microtask Queue 로 들어간다.

✏️ 마이크로 태스크 큐의 특이점

  • Microtask Queue 내부의 Task 는 Call Stack 이 비었을 때 가장 최우선으로 실행된다.
  • Microtask Queue 내부의 작업이 Call Stack 에 적재되어 실행될 때, 새로운 Microtask 를 생성할 수 있다.
  • 브라우저 렌더링의 경우 Microtask Queue 내부의 작업이 모두 실행되어 Queue 가 비었을 때 실행된다.
  • 따라서 한 사이클에 연속적으로 새로운 Microtask 를 생성하는 것은 브라우저 렌더링 및 Task Queue 내부의 작업을 지연시킨다.

✏️ 각 비동기 Task 의 실행 순서 정리

console.log('a');

setTimeout(() => {
    console.log('b')
}, 0);

Promise.resolve().then(() => {
    console.log('c');
})

window.requestAnimationFrame(() => {
    console.log('d')
})


1. console.log('a') 가 Call Stack 에 적재되고, 실행된다.
2. setTimeout 작업이 Task Queue 에 할당된다.
3. Promise.resolve() 작업이 수행되고, then 핸들러 내부의 callback 이 Micro Task Queue 에 할당된다.
4. requestAnimationFrame 가 인자로 받은 callback 을 화면이 렌더링 되는 과정에서 실행하도록 예약 (스케줄링) 한다.
5. Call Stack 이 비었으므로 Micro Task Queue 내부의 작업을 가져와 `console.log('c')` 를 실행한다.
6. Call Stack 과 Micro Task Queue 이 전부 비었으므로 브라우저 렌더링이 진행된다. 이때 rAF 가 인자로 받은 `console.log('d')` 이 실행된다.
7. Task Queue 에 있던 작업이 Call Stack 으로 적재되고, `console.log('b')` 가 실행된다.

✏️ [번외] rAF 의 동작 방식

  • requestAnimationFrame 이 인자로 받은 callback 은 Map of Animation Frame Callbacks 에 저장된다.
  • 이 말인 즉은 rAF 의 callback 은 별도의 Queue 가 아닌 브라우저 내부에서 관리하는 별도의 공간에 Map 으로 저장되어 있다는 의미다.
  • 구현체가 Map 이기 때문에 rAF 의 경우 cancelAnimationFrame(id) 메서드로 취소가 가능하다.

추후 시간이 남으면 살펴봐야 할 문서들

✒️ React 에서 자주 쓰이는 JS 문법

✏️ 구조 분해 할당, 전개 구문

  • 객체나 배열 내부의 요소를 별도의 선언문 없이 변수에 직접 선언 및 할당하고 싶을 때 쓰인다.
  • Default Parameter 문법도 사용할 수 있으나, 값이 undefined 인 경우에만 적용된다.
  • 추가로 객체 구조 분해 할당의 경우 ES5로 트랜스파일링 할 경우 번들의 사이즈가 커질 수 있다.
// 배열 구조 분해 할당
const [isOpen, setIsOpen] = useState(false);

// 객체 구조 분해 할당
const Component = ({ totalCount }: PropsType) => {
    return (...)
}
  • 전개 구문의 경우 Spread Operator (...) 를 사용하여 특정 객체를 새로운 객체 내부에 선언할 수 있고, 배열 또한 같은 원리로 사용이 가능하다.
const obj = { a: 1, b: 2 };
const newObj = { ...obj, c: 3, d: 4 };

객체 구조 분해 할당의 트랜스파일링 구조를 파헤쳐보자.

"use strict";

// source 객체에서 excluded 배열 내 key 를 제외한 나머지를 반환하는 함수
// _objectWithoutPropertiesLoose 와는 다르게 Symbol Key 가 있는 경우도 고려한다.
function _objectWithoutProperties(source, excluded) {
  if (source == null) return {};
  var target = _objectWithoutPropertiesLoose(source, excluded);
  var key, i;

  // Symbol 키가 있다면 getOwnPropertySymbols 로 목록을 받아 순회한다.
  if (Object.getOwnPropertySymbols) {
    var sourceSymbolKeys = Object.getOwnPropertySymbols(source);

    // 객체 내부의 key 를 순회하며 excluded 에 포함되지 않는 나머지를 target 에 추가한다.
    for (i = 0; i < sourceSymbolKeys.length; i++) {
      key = sourceSymbolKeys[i];
      if (excluded.indexOf(key) >= 0) continue; // 해당 key 가 excluded 배열에 있다면 continue.
      if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue;
      target[key] = source[key];
    }
  }
  return target;
}

// source 객체에서 excluded 배열 내 key 를 제외한 나머지를 반환하는 함수
function _objectWithoutPropertiesLoose(source, excluded) {
  if (source == null) return {};
  var target = {};
  var sourceKeys = Object.keys(source); // 여기서는 Symbol Key 를 반환하지 않는다.
  var key, i;

  // 객체 내부의 key 를 순회하며 excluded 에 포함되지 않는 나머지를 target 에 추가한다.
  for (i = 0; i < sourceKeys.length; i++) {
    key = sourceKeys[i];
    if (excluded.indexOf(key) >= 0) continue; // 해당 key 가 excluded 배열에 있다면 continue.
    target[key] = source[key];
  }
  return target;
}
var obj = {
  a: 1,
  b: 2,
  c: 3,
  d: 4,
};
var a = obj.a,
  b = obj.b,
  rest = _objectWithoutProperties(obj, ["a", "b"]);
  1. _objectWithoutPropertiesLoose 함수
  • source 인자는 객체이고, excluded 배열은 객체에서 제거할 key 값을 담은 배열이다.
  • source 객체의 key 를 추출한 후, 이를 순화하며 excluded 에 포함되는 key 를 제외한 나머지를 target 객체에 추가한다.
  • 이후 target 을 반환하는 것으로 함수를 종료한다.
  1. _objectWithoutProperties 함수
  • 기본 골자는 _objectWithoutPropertiesLoose 와 같으나 Object.keys() 메서드가 Symbol 형태의 key 를 찾지 못하는 예외를 처리하는 로직이 추가되었다.
  • 먼저 _objectWithoutPropertiesLoose 함수의 결과 (target) 를 인계 받는다.
  • 이후 source 객체 내 Symbol 타입의 key 배열을 Object.getOwnPropertySymbols 메서드로 받는다.
  • Symbol Key 배열을 순회하며 excluded 에 해당 key 가 있다면 이를 제외하고, 없다면 key 와 value 를 target 객체에 할당한다.

✏️ Array Map, filter, reduce

  • 기존 배열의 요소를 순회하여 인자로 받은 callback 의 결과를 기반으로 새로운 배열에 요소를 추가하는 메서드이다.
  • React 의 경우 배열형 state 의 변경으로 리렌더링을 유발시키려면 메모리 주소가 달라져야 한다.
  • 위 메서드는 새로운 배열을 생성하여 할당하기에 Object.is 기반의 동등성 검사에서 값이 달라짐을 유도한다.

✏️ 삼항 조건 연산자

  • 특정 값을 기반으로 다른 컴포넌트를 내부에서 렌더링 하도록 코드를 작성할 수 있다.
  • if - else 기반의 코드 블럭 없이 한 줄로 간결하게 조건문을 축약할 수 있다.

✒️ Typescript

책에서는 Typescript 를 다뤄야 하는 이유에 대해서 설명했기에 간결하게 내용만 정리하고자 한다.

  1. any 대신 unknown 을 사용하자.
  2. Type Guard, Type Narrow 를 잘 활용하자.
  3. Generic 사용으로 타입의 유연성을 높히자.
  4. Index Signature, Mapped Type 활용으로 참조형 타입의 사용을 쉽게 하자.
profile
항상 왜 이걸 써야하는지가 궁금한 사람

0개의 댓글