모던 리액트 딥다이브 Week2 - Chapter1

지코·2025년 9월 9일

FE STUDY

목록 보기
2/7
post-thumbnail
본 포스팅 시리즈는 📚'모던 리액트 Deep Dive'를 주간 별로 1장씩 공부하며
* 새롭게 알게 된 것들
* 평소 알고 있다고 생각했지만 이번에 제대로 알게 된 것들
* 궁금한 부분에 대해 딥다이브한 것들
등을 기재하기 위해 시작되었다.

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


호출 스택과 이벤트 루프 (p.72-75)

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

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

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

function foo() {
  console.log('foo')
  setTimeout(bar(), 0)
  baz()
}

foo()

위 예제 코드는 다음과 같은 순서로 실행된다.

  1. foo() 가 호출 스택에 먼저 들어간다.
  2. foo() 내부에 console.log가 존재하므로 호출 스택에 들어간다.
  3. console.log의 실행 후 다음 코드로 넘어간다.
    ➡️ 스택에 foo()가 존재한다.
  4. setTimeout(bar(), 0)이 호출 스택에 들어간다.
  5. 타이머 이벤트가 실행되며 bar() 가 태스크 큐로 들어가고, 스택에서는 제거된다.
  6. baz() 가 호출 스택에 들어간다.
  7. baz() 내부에 console.log가 존재하므로 호출 스택에 들어간다.
  8. console.log의 실행 후 다음 코드로 넘어간다.
    ➡️ 스택에 foo(), baz() 가 존재한다.
  9. baz() 에 남은 코드가 없으므로 호출 스택에서 제거된다.
    ➡️ 스택에 foo()가 존재한다.
  10. foo() 에 남은 코드가 없으므로 호출 스택에서 제거된다.
    ➡️ 이제 호출 스택이 완전히 비워졌다❗️
  11. 이벤트 루프가 호출 스택이 비워져 있다는 것을 확인했다. 그리고 태스크 큐를 확인하니 bar() 가 있으므로, 호출 스택에 들여보낸다.
  12. bar() 내부에 console.log가 존재하므로 호출 스택에 들어간다.
  13. console.log의 실행 후 다음 코드로 넘어간다.
    ➡️ 스택에 bar() 이 존재한다.
  14. bar() 에 남은 것이 없으므로 호출 스택에서 제거된다.

여기서 이벤트 루프의 역할은 호출 스택에 실행 중인 코드가 있는지, 태스크 큐에 대기 중인 함수가 있는지를 반복적으로 확인하는 역할을 한다. 호출 스택이 비었다면 태스크 큐에 대기 중인 작업이 있는지 확인하고, 이 작업을 실행 가능한 오래된 것부터 순차적으로 꺼내와서 실행하게 된다.

비동기 함수는 모두 자바스크립트 코드가 동기식으로 실행되는 메인 스레드가 아닌, 태스크 큐가 할당되는 별도의 스레드에서 수행된다. 이 별도의 스레드에서 태스크 큐에 작업을 할당해 처리하는 것은 브라우저나 Node.js의 역할이다.
만약 이러한 작업들까지도 모두 메인 스레드에서 이루어진다면, 절대로 비동기 작업을 수행할 수 없을 것이다.

태스크 큐와 마이크로 태스크 큐 (p.76)

태스크 큐와 다르게 마이크로 태스크 큐라는 것도 있다.
이벤트 루프는 하나의 마이크로 태스크 큐를 가지며, 마이크로 태스크 큐에 들어간 작업들은 태스크 큐에 들어간 작업들보다 먼저 실행된다.

  • 태스크 큐: setTimeout, setInterval, setImmediate
  • 마이크로 태스크 큐: process.nextTick, Promises, queueMicroTask, MutationObserver

예시 코드를 보며 차이를 확인해보자.

1️⃣ 동기

for (let i = 0; i <= 100000; i++) {
  counterEl.textContent = i;
}

호출 스택에 for 루프가 쌓여있으니 이벤트 루프가 돌 기회가 없다. 렌더링은 이벤트 루프 한 사이클 끝날 때만 할 수 있는데, 루프가 끝날 때까지 한 번도 이벤트 루프가 돌지 않으며, 결국 마지막 값 100000만 한 번에 보이게 된다.

2️⃣ 태스크 큐

function stepTimeout() {
  if (i <= 100000) {
    counterEl.textContent = i++;
    setTimeout(stepTimeout, 0);
  }
}

setTimeout 콜백 함수는 마이크로 태스크 큐에 들어간다.

보통의 이벤트 루프라면 stepTimeout() 함수 실행 → macrotask 실행 → microtask 실행 → 렌더링 기회 → 다음 macrotask(stepTimeout) 실행 → ... 와 같이 진행되지만, 이 코드에서는 마이크로 태스크 큐를 추가하지 않았기 때문에 매크로 태스크 큐 실행 후 바로 렌더링되는 것으로 보인다.

결국 이벤트 루프가 한 사이클 돌 때마다 매크로 태스크 큐에서 하나씩 실행하기 때문에, 1부터 10만까지 숫자가 순차적으로 보이게 된다.

3️⃣ 마이크로 태스크 큐

function stepMicro() {
  if (j <= 100000) {
    counterEl.textContent = j++;
    Promise.resolve().then(stepMicro);
  }
}

Promise.then은 마이크로 태스크 큐에 들어간다.

이벤트 루프는 stepMicro() 함수 실행 → microtask 큐 비우기(stepMicro 또 실행) → 또 microtask → 또 실행... 와 같이 진행시킨다. 태스크 큐와 다르게, 마이크로 태스크 큐에 계속 쌓이면서 렌더링 기회를 주기 전에 마이크로 태스크를 다 처리해버린다.

결국 루프 끝날 때까지 렌더링이 한 번도 일어나지 않아, 100000이 한 번에 보이게 된다.

구조 분해 할당에서 기본값 사용이 가능하다. (p.81)

리액트에서 자주 사용하는 useState 훅을 사용하는 코드를 보면 배열 구조 분해 할당을 이용한 것을 알 수 있다.

const [state, setState] = useState(0)

이러한 구조 분해 할당에서 기본값을 설정하는 것도 가능한데, 이때 주의점은 반드시 undefined일 때만 기본값을 사용한다는 것이다.

const [a = 1, b = 1, c = 1, d = 1, e = 1] = [undefined, null, 0, '']

console.log(a) // 1
console.log(b) // null
console.log(c) // 0
console.log(d) // 
console.log(e) // 1

위 예제 코드를 보면 변수 a ~ e 모두 기본값을 1로 설정했지만, undefined인 ae 만 1을 할당받은 것을 확인할 수 있다.

🤔 const로 선언한 변수는 트랜스파일 후 왜 var가 될까? (p.89)

책에 나와 있는 예제 코드는 다음과 같다.

//트랜스파일하기 전
const arr1 = ['a', 'b']
const arr2 = [...arr1, 'c' 'd', 'e']

// 트랜스파일된 결과
var arr1 = ['a', 'b']
var arr2 = [].concat(arr1, ['c' 'd', 'e'])

바벨(Babel)자바스크립트의 최신 문법을 다양한 브라우저에서도 일관적으로 지원할 수 있도록 코드를 트랜스파일해주는 도구이다.

바벨은 트랜스파일링을 통해 왜 const를 var로 바꾸는 걸까❓

우선적으로 ES5 이전에는 let / const 가 없었다.
옛날 브라우저에서는 var 밖에 없었기 때문에, let / const 를 지원하지 않는 환경에서 코드를 실행하려면 var 로 바꾸는 수밖에 없다.

이때, const의 "재할당 금지" 특성은 런타임에서 체크해야 한다. 바벨은 정적 타입체커가 아니라 코드를 옛날 자바스크립트 문법으로 바꾸는 툴이기 때문에 재할당 금지 같은 동작까지 강제하지는 않고, 문법적으로만 var 로 바꿔서 실행되도록 한다.
실제로는 개발 중에 ESLint 같은 툴로 재할당 금지를 잡는 것이 일반적이다.

Array 프로토타입의 메서드: map, filter, reduce와 forEach의 차이점 (p.93)

map, filter, reduce 는 모두 배열과 관련된 메서드이다. 기존 배열의 값을 건드리지 않고 새로운 값을 만들어 내기 때문에, 기존 값이 변경될 염려 없이 안전하게 사용할 수 있다.

forEach 는 세 함수와는 다르게, 콜백 함수를 인수로 받아 배열을 순회하면서 단순히 그 콜백 함수를 실행하기만 하는 메서드이다.

이 때 두 가지 주의점이 있다.

  1. forEach 는 아무런 반환값이 없다. forEach 의 반환값은 undefined로 의미 없다는 것이다.
  2. forEach 는 실행되는 순간부터, 에러를 던지거나 프로세스를 종료하지 않는 이상 이를 멈출 수 없다.

다음 예제 코드를 보자.

function run() {
  const arr = [1, 2, 3]
  arr.forEach((item) => {
    console.log(item)
    if (item === 1) {
      console.log("finished!")
      return
    }
  })
}

run()

// 1
// finished!
// 2
// 3

중간에 return이 존재해 함수 실행이 끝났음에도 불구하고 계속해서 콜백이 실행되는 것을 볼 수 있다. 이는 return이 함수의 return이 아니라 콜백 함수의 return으로 간주되기 때문이다.

forEach 내부의 콜백 함수는 무조건 O(n) 만큼 실행된다는 것을 기억하자.

any 대신 unknown을 사용하자 (p.101)

이는 타입스크립트에 관한 이야기이다.

anyunknown 의 차이를 알고 있는가? (스스로에게 하는 질문이다.)
이는 타입스크립트를 처음 배울 때 기본적으로 배우는 내용이며, 한 차례 포스팅으로도 정리한 적 있다.
➡️ [TIL] 241023_Typescript: any와 unknown의 차이는?

불가피하게 아직 타입을 단정할 수 없는 경우에 unknown 을 사용하는 것이 좋으며, 이때 any 를 사용하는 것은 타입스크립트의 이점을 버리는 것이나 다름 없다.
any 는 어떤 타입의 값이든 모두 받을 수 있는데, 그 코드가 문제가 되는 것은 빌드 타임이 아닌 런타임이다. 따라서 타입을 사용해서 빌드 타임에서 미리 코드 상의 버그를 찾을 수 있는 타입스크립트를 사용하는 의미가 사라지게 된다.

function doSomething(callback: unknown) {
  if (typeof callback === 'function') {
    callback()
    return
  }
  
  throw new Error('callback은 함수여야 합니다.')
}

위 예제 코드를 보자. 함수를 작성할 때는 매개변수 callback 의 타입을 단정할 수 없기 때문에 unknown 으로 선언하고, 함수 내부에서 타입 좁히기(type narrowing)를 활용해 해당 unknown 값이 우리가 원하는 타입일 때만 의도대로 작동하도록 작성할 수 있다.

Reference

📚 모던 리액트 Deep Dive

profile
꾸준함이 무기

0개의 댓글