[모던 리액트 Deep Dive] 리액트 역사, 리액트와 자바스크립트

변진상·2024년 7월 9일
0

학습 기록

목록 보기
28/31

왜 리액트인가?

리액트의 특징

  • 명시적인 단방향 상태 변경: 리액트는 단방향 바인딩만 지원한다. 양방향 바인딩(Angular)은 뷰와 컴포넌트가 서로 영향을 줄 수 있다. 단방향 바인딩이 줄 수 없는 편리함을 주지만, 프로젝트 규모가 커진다면 상태의 변화 원인을 파악하기 어려워진다.
    - 단방향 바인딩도 단점이 있는데, 항상 변화를 감지하고 업데이트하는 코드를 작성해야한다. 코드의 규모가 커지는 단점이 있다.
  • JSX(Javascript XML): 기존 Javascript 문법에 HTML을 약간 가미한 문법으로 몇 가지 특징만 이해하면 JSX 코드를 구현할 수 있다. Angular는 문자열 템플릿을 사용하며 Angular 디렉티브라고 해서 ngIf 처럼 전용 문법을 익혀야했다.
  • 배우기 쉽고 간결함
  • 강력한 커뮤니티, 메타: 큰 사용자 커뮤니티를 이루고 있다. 오픈소스 프로젝트는 재정이 안정적이어야 성장이 가능한데 메타가 주 스폰서로 두고 성장한다.
  • 프레임워크를 지향하지 않음: Angular와 Vue와 다르게 프레임워크를 지향하지 않아 함께 사용할 수 있는 라이브러리가 다양하다.
    - 상태관리: Redux, Zustand, Recoil, Jotai
    - 서버 사이드 렌더링: Next.js, Remis, Hydrogen
    - 애니메이션: Framer Motion, readct-spring, React Move
    - 차트: Recharts, visx, nivo
    - 폼: React Hook Form, Formik, Reactr Final Form

리액트의 역사

  • 2000년대 LAMP 스택 유행
    - Linux, Apache Web Server, MySQL, PHP를 활용한 웹개발이 주를 이뤘다.
    - DB에서 데이터를 불러와 웹 서버에서 HTML 페이지를 만들어 클라이언트에 제공.
    - 웹 브라우저는 단순히 이 페이지를 다운로드 받고, JS는 폼 처리와 같은 부수적인 역할만 했다.
  • 2010년대
    - 자바스크립트를 편리하게 사용하기 위한 JQuery가 자바스크립트 비공식 표준으로 자리잡게 되었다.
    - 인터넷 익스플로러8의 Local Storage API
    - 2011년 공식 표준화 된 웹 소켓, Canvas, SVG, Geolocation(사용자 위치 정보 제공 API)
    - ES5가 표준 스펙으로 자리잡았다.

이러한 변화 덕에 JS가 적극적으로 DOM 조작, Ajax를 이용한 클라이언트와 서버의 통신을 하기 시작했다.

BoltJS의 등장과 한계

  • CreatgeClass로 내부에 객체를 선언해 컴포넌트를 만드는 방식이 리액트에서도 이어받게된다. 하지만 BoltJS는 아키텍처가 복잡해져 고도화 전 소스코드가 돌연 삭제되었다.
  • 이 당시 프레임 워크는 양방향 바인딩 구조를 채택한다. 모델이 뷰를 변경하는 단방향 방식이 처음 제안되었는데, 모델의 데이터가 변경되면 DOM을 버리고 새롭게 렌더링하는 방식이다. 당시에는 DOM의 변경을 최소한으로 하는 것이 성능을 위한 최선의 방식이라고 여겨 이 방식에 대한 의구심이 많았다고 함.
  • 페이스북 FE 개발자들이 느끼는 큰 어려움은 DOM을 업데이트 하는 것이었다. '좋아요' 버튼의 클릭 이벤트 리스너를 등록하고 이를 제거하고 다시 찾고 다시 속성을 변경하는 작업이 복잡하며, 버그의 주요 원인이었고 개발자도 이 흐름을 파악하기 어려웠다고 한다. → 성능은 둘째 치고 다시 새롭게 랜더링하는 방식을 채택 → 리액트의 시작.

1장. 리액트 개발을 위해 꼭 알아야 할 자바스크립트

1.1 자바스크립트의 동등 비교

  • 리액트의 가상 DOM과의 실제 DOM과의 비교, 리액트 컴포넌트가 렌더링할지를 판단하는 방법, 변수나 함수의 메모이제이션 등 모든 작업은 자바스크립트의 동등 비교를 기반으로 한다.

자바스크립트의 Object.is를 이용한 비교

  • Object.is는 두 인수를 받아 인수 두 개가 동일한지 확인하고 반환하는 메서드다.
    	> "\=\=", "\=\=\=" 와 Object.is의 차이점
    	> - "\=\="는 비교 전 양쪽이 같은 타입이 아니라면 비교할 수 있도록 강제 형변환을 한 후 비교한다.
    	> - "\=\=\="과도 차이가 있는데, Object.is가 개발작가 기대하는 방식으로 비교한다. 
-0 === +0 // true
Object.is(-0, +0) // false

Number.NaN === NaN // false
Object.is(Number.NaN, NaN) // true

NaN === 0 / 0 // false
Object.is(NaN, 0 / 0) // true

하지만, 객체간의 비교의 경우 "\=\=", "\=\=\="와 같이 동작한다.

Object.is({}, {}) // false

const a = {
	hello = 'hi',
}
const b = a;

Object.is(a, b) // true
a === b // true

리액트에서의 동등 비교

Object.is는 ES6에서 제공하는 기능이기 때문에 리액트에서는 이를 구현한 폴리필을 함께 사용한다.

import is from "./objectIs";
  
// 다음 코드는 Object.prototype.hasOwnProperty다.
// 객체에 특정 프로퍼티가 있는지 확인하는 메서드다.
import hasOwnProperty from "./hasOwnProperty";

/*
주어진 객체의 키를 순회하면서 두 값이 엄격한 동등성을 가지는지 보고 다른 값이 있다면 false 반환.
두 객체 간의 모든 키와 값이 동일하면 true 반환.
*/

// 단순히 Object.is를 수행하는 것 뿐만 아니라 객체간의 비교도 추가.

// 원래는 flow언어로 작성되어있어 mixed라는 자료형을 가지지만, ts로 작성하기 위한 pollyfill이다.
// mixed는 모든 타입들을 포함하는 타입이나, 몇 연산자의 사용이 타입 체킹 없이는 제한된다.
type mixed = any;

function shallowEqual(objA: mixed, objB: mixed): boolean {

  if (is(objA, objB)) {
    // 같은 참조 주소를 가지는 객체의 경우 true 반환
    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);

  // 배열의 길이가 다르다면 false
  if (keysA.length !== keysB.length) {
      return false;
  }

  // A를기준으로, B에 같은 키가 있는지, 그리고 그 값이 확인한다.
  for (let i = 0; i < keysA.length; i++) {
    const currentKey = keysA[i];

    if (
    !hasOwnProperty.call(objB, currentKey) ||
    !is(objA[currentKey], objB[currentKey])
    ) {
      return false;
    }
  }
  return true;
}

리액트에서는 이 동등비교를 이용해 shallowEqual이라는 함수를 만들어 사용한다. 이 함수를 이용해 의존성 비교 등 리액트의 동등비교가 필요한 다양한 곳에서 사용된다.

// Object.is는 참조가 다른 객체에 대해 비교가 불가하다.
Object.is({a: 1}, {a: 1}) // false

// 리액트 팀에서 구현한 shallowEqual은 객체의 1depth까지 비교 가능하다.
shallowEqual({a: 1}, {a: 1}) // true

// 2 depth는 비교하지 못한다.
shallowEqual({b: {a: 1}}, {b: {a: 1}}) // false

얕은 비교까지만 구현한 이유는 무엇일까?

리액트에서 사용하는 JSX props는 객체이고, 일차적으로만 비교하면 되기 때문이다. 이러한 특성 때문에 props로 또다른 객체를 넘겨준다면 리액트 렌더링이 예상치 못하게 작동한다.

type DeeperProps = {
	counter: {
		counter: number;
	};
};

  

const DeeperComponent = memo((props: DeeperProps) => {
	useEffect(() => 
		console.log("Deeper Component has been rendered!");
	});
	
	return <h1>{props.counter.counter}</h1>;
});

위와 같이 props가 깊어지는 경우, React.memo는 컴포넌트에 실제로 변경된 값이 없음에도 불구하고 메모이제이션된 컴포넌트를 반환하지 못한다. 상위 컴포넌트에서 강제로 렌더링을 일으킬 경우, shallowEqual을 이용하는 Component 함수는 위 로직에 따라 객체간 비교를 수행해 렌더링을 방지해주지만 DeeperComponent 함수는 제대로 비교하지 못해 memo가 작동하지 않는다.

왜 재귀적으로 동등비교를 하지 않는가?

  • 성능.

자바스크립트의 동등 비교 정리

  • 자바스크립트에서 객체 비교의 불완전성은 스칼라나 하스켈 등의 다른 함수형 언어에서는 볼 수 없는 특징이다.
  • 자바스크립트를 기반으로 한 리액트의 함수형 프로그래밍 모델에서도 이러한 언어적 한계가 존재해 얕은 비교만을 사용해 필요한 기능을 구한하고 있다.
  • 이런 특징을 잘 숙지하면 함수형 컴포넌트에서 사용되는 훅의 의존성 배열 비교, 렌더링 방지를 넘어선 useMemo, useCallback의 필요성, 렌더링 최적화를 위한 React.memo의 올바른 사용을 위한 방법을 쉽게 이해할 수 있을 것이다.

1.2 함수

함수를 선언하는 4가지 방법

  1. 함수 선언문
function add(a, b){
	return a + b;
}
  1. 함수 표현식
    자바스크립트에서 함수는 '일급객체'이다. 일급객체란, 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 의미한다. 자바스크립트에서의 함수는 다른 함수의 매개변수, 반환값, 할당도 가능하다. 함수는 일급객체이기 때문에 함수를 변수에 할당하는 것도 가능하다.
const sum = function(a, b){
	return a + b;
}

sum(10, 24) // 34

함수 표현식과 선언 식의 차이

  • 호이스팅 여부
    - 표현식 X: 표현식은 먼저 변수가 메모리에 등록되어 undefined로 초기화된다. 런타임 시점에 함수가 할당된다.
    - 선언식 O: 함수 자체가 메모리에 등록되어 하단에 선언되어도 코드 상단에서 실행 가능
  1. Function 생성자
const add = new Function('a', 'b', 'return a + b');

add(10, 20) // 20

→ 함수의 몸통을 모두 문자열로 작성, 클로저가 생성되지 않는다. eval 만큼이나 실제로 사용이 되지 않는 방법.

  1. 화살표 함수
    ES6에서 추가된 방식.
const add = (a, b) => {
	return a + b;
}

add(10, 20) // 20
  • 화살표 함수를 생성자 함수로 사용할 수 없다.
const Car = (name) => {
	this.name = name;
}

const myCar = new Car('하이') // TypeError: Car is not a constuctor
  • arguments가 존재하지 않는다.
const hi = () => {
	console.log(arguments)
}

hi(1, 2, 3) // ReferenceError: arguments is not defined
  • this가 상위 스코프의 this를 가리킨다.(다른 함수 선언 방식들은 전역 객체를 가리킨다.)

다양한 함수 살펴보기

  1. 즉시 실행 함수(Immediately Invoekd Function Expression, IIFE)
    • 함수를 정의 후 즉시 실행, 단 한 번만 실행 후 다시 호출할 수 없다.
    • 글로벌 스코프를 오염시키지 않는다.
    • 리팩터링에 도움이 된다.
(function(a, b){
	return a + b;
})(10, 24); //34

((a, b) => {
	return a + b;
})(10, 24); //34
  1. 고차함수(Higher Order Function)
    • 일급 객체의 특성을 이용한 함수.
const add = function(a){
	return function(b){
		return a + b;
	}
}	

add(1)(2);

함수를 만들 때 주의 사항

  1. 함수의 부수효과를 최대한 억제해야한다.
    • 부수 효과: 함수 외부에 영향을 끼치는 것.
    • 순수 함수: 부수 효과가 없는 함수.
  • 항상 순수 함수를 작성해야하는가?
    - 컴포넌트에서 API 호출한 경우 → Http request → 부수효과
    - console.log() 출력 → 브라우저 콘솔창에 영향 → 부수효과
    - HTML title 변경 → DOM 조작 → 부수효과
  • 리액트 적 관점으로는 useEffect의 작동을 최소화하는 것
  1. 함수를 작게 만들어라.

  2. 누구나 이해 가능한 이름을 붙여라

    • 이해할 수 있게, 한글을 사용해도 좋다.
    • 네이밍이 너무 길어지는 경우를 걱정한다면 이를 축소해주는 라이브러리를 사용할 수 있다(Terser).
    • 콜백 함수에도 이름을 붙여주는 것이 좋다. 밖에서는 접근 불가하고 콜백이 무슨 일을 하는지 알 수 있다.
      	```js
      	useEffect(function apiRequest(){
      		// do something
      	}, [])

## 1.3 클래스
- 리액트 16.8 버전이 나오기 전까지 모든 컴포넌트가 클래스로 작성되어있었다.
- 클래스형 컴포넌트에 대한 이해는 클래스, 프로토타입, this에 달려있다.
- 바벨로 ES6 → ES5 문법으로 변환
```js
"use strict";

// 클래스가 함수처럼 호출되는 것을 방지
function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

// 프로퍼티를 할당하는 코드
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, descriptor.key, descriptor);
  }
}

// 프로토타입 메서드와 정적 메서드를 선언하는 코드
function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps);
  if (staticProps) _defineProperties(Constructor, staticProps);
  return Constructor;
}

var Car = /*#__PURE__*/ (function () {
  function Car(name) {
    _classCallCheck(this, Car);

    this.name = name;
  }

  _createClass(
    Car,
    [
      {
        key: "honk",
        value: function honk() {
          console.log(
            "".concat(
              this.name,
              "\uC774 \uACBD\uC801\uC744 \uC6B8\uB9BD\uB2C8\uB2E4!"
            )
          );
        },
      },
      {
        key: "age",
        set: function set(value) {
          this.carAge = value;
        },
        get: function get() {
          return this.carAge;
        },
      },
    ],
    [
      {
        key: "hello",
        value: function hello() {
          console.log("저는 자동차입니다.");
        },
      },
    ]
  );

  return Car;
})();

1.4 클로저

  • 함수형 컴포넌트에 대한 이해는 클로저에 달려있다.
  • 함수형 컴포넌트의 구조, 작동 방식, 훅의 원리, 의존성 배열 등 함수형 컴포넌트의 기술들이 클로저에 의존하고 있기 때문이다.

클로저의 정의

  • MDN → "클로저는 함수와 함수가 선언된 어휘적 환경의 조합"...

스코프

  • 자바스크립트는 기본적으로 함수 레벨 스코프
  • var로 선언한 변수의 경우 글로벌하게 바인딩 → for문에서 사용하고 외부에서도 접근 가능하다.
    - 이를 해결하기 위해서는 let을 사용하자. let은 블록레벨 스코프를 가진다.
{
  var a = 100;
  let b = 200;
  const c = 300;
}

console.log(a); // 100
console.log(b); // b is not defined
console.log(c); // c is not defined

리액트에서의 클로저

  • 클로저의 원리를 사용하고 있는 것 중 대표적인 것은 useState다.
function Component() {
  const [state, seState] = useState();

  function handleClick() {
    // useState 호출은 위에서 끝났지만,
    // setState는 계속 내부의 최신값(prev)를 알고있다.
    // 이는 클로저를 활용했기 때문에 가능하다.
    setState((prev) => prev + 1);
  }
}

외부 함수(useState)가 반환한 내부함수(setState)는 외부함수의 호출이 끝났음에도 자신이 선언된 외부함수가 선언된 환경을 기억해 계속해 state를 사용할 수 있다. → 클로저 발생

1.5 이벤트 루프와 비동신 통신의 이해

  • 자바스크립트는 싱글 스레드에서 작동한다. 한 번에 하나의 작업만 동기 방식으로만 처리할 수 있다(이런 특성을 Run-to-completion이라고 한다.).
  • 동기(synchronous): 직렬 방식으로 작업을 처리하는 것을 의미한다. 작업이 시작된 이후에는 무조건 완료 후에 다른 작업을 처리할 수 있다.
  • 비동기(asynchronous): 병렬 방식으로 작업을 처리하는 것을 의미한다.
  • 리액트도 과거 렌더링 스택을 비우는 방식으로 구현됐던 동기식의 렌더링이 16버전에서 비동기식으로 작동하는 방법도 소개됐다.

이벤트 루프란?

V8, Spider monkey와 같은 자바스크립트 런타임 엔진에서 비동기 실행을 위한 장치를 가지고 있다.

호출 스택과 이벤트 루프

호출 스택은 자바스크립트에서 수행해야할 코드나 함수를 순차적으로 담아두는 스택이다.

function bar() {
  console.log('bar');
}

function baz() {
  console.log('baz');
}

function foo() {
  console.log('foo');// (2) 콜스택: c.l() - foo
  // (3) c.l() 함수 실행 끝, 콜스택: foo
  // c.l() 함수는 이후에 생략
  bar();  // (4) 콜스택: bar - foo
  // (5) bar 함수 실행 끝, 콜스택: foo
  baz(); // (6) 콜스택: baz - foo
  // (7) baz 함수 실행 끝, 콜스택: foo
}

foo() // (1) 콜스택: foo
// (8) foo 함수 실행 끝, 콜스택: (empty)
  • 이벤트 루프는 이 호출 스택이 비어있는지 여부를 확인하는 것이다.
  • 단일 스레드 내부에서 이 호출 스택 내부에 수행해야할 작업이 있는지 확인하고, 수행해야할 코드가 있다면 자바스크립트 엔진을 이용해 실행한다.
function bar() {
  console.log('bar');
}

function baz() {
  console.log('baz');
}

function foo() {
  console.log('foo');  // (2) 콜스택: c.l() - foo
  // (3) c.l() 함수 실행 끝, 콜스택: foo
  // c.l() 함수는 이후에 생략
  
  setTimeout(bar, 0);  // (4) 콜스택: setTimeout - foo
  // (5) setTimeout 함수 실행 끝, 콜스택: foo
  // (6) 이벤트 타이머가 실행되며 bar가 태스크 큐로 들어간다. 태스크 큐: bar
  
  baz(); // (7) 콜스택: baz - foo / 태스크 큐: bar
  // (8) baz 함수 실행 끝, 콜스택: foo / 태스크 큐: bar
}

foo();  // (1) 콜스택: foo
// (9) foo 함수 실행 끝, 콜스택: (empty) / 태스크 큐: bar
// 이벤트 루프가 콜스택이 빈 것을 확인해 태스크 큐의 요소 중 실행 가능한 가장 오래된 태스크를 가져온다.
  • 이래서 setTimeout이 정확한 시간에 실행되는 것을 보장하지 못하는 이유를 이해할 수 있다.

태스크 큐란?

  • 태스크 큐는 실행해야할 태스크(비동기 함수의 콜백함수, 이벤트 핸들러 등...)의 집합을 의미한다.
  • 이벤트 루프는 태스크 큐를 한 개 이상 가지고 있다.
  • 이름과는 다르게 Queue가 아닌 Set 형태를 지니고 있다. 선택된 큐 중에서 실행 가능한 가장 오래된 태스크를 가져와야하기 때문이다.
  • 이벤트 루프는 호출 스택이 비었는가 여부 체크, 태스크 큐에 대기 중인 함수가 있는지 여부 체크, 호출 스택이 비었다면 태스크큐가 빌때까지 실행 가능한 오래된 작업부터 순차적으로 꺼내와 실행한다.

그럼 비동기함수는 누가 수행하냐?

  • n초 뒤 setTimeout의 작업 처리, fetch 기반으로 실행되는 네트워크 요청은 누가 보내고 응답을 받는가?
    - 자바스크립트가 동기식으로 실행되는 메인스레드가 아닌 태스크 큐가 할당되는 별도의 스레드에서 실행.
    - Node.js나 브라우저의 역할. 이런 Web API들은 자바스크립트 코드가 외부에서 실행된다.

태스크 큐와 마이크로 태스크 큐

  • 이벤트 루프는 태스크 큐와 하나의 마이크로 태스크 큐를 갖는다.
  • 마이크로 태스크 큐: 태스크 큐 보다 우선순위가 높은 태스크를 처리한다.(ex. Promise, process.nextTick, quieueMicroTask, MutationObserver)
  • 태스크 큐(ex. setTimeout, setInterval, setImmediate)
  • 명세에 따르면 마이크로 태스크 큐가 빌때까지는 기존 태스크 큐의 실행이 미뤄진다.
	function foo() {
  console.log("foo");
}

function bar() {
  console.log("bar");
}

function baz() {
  console.log("baz");
}

setTimeout(foo, 0);

Promise.resolve().then(bar).then(baz);
// bar
// baz
// foo
  • 렌더링의 경우 마이크로 태스크 큐를 실행한 뒤에 렌더링이 일어난다.
console.log("a");

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

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

window.requestAnimationFrame(() => {
  // requestAnimationFrame 메서드는 브라우저 리페인트 전에 실행된다.
  console.log("리페인팅 전입니다");
});

// a → c → 리페인팅 전입니다 → b
profile
자신을 개발하는 개발자!

0개의 댓글