Skip to main content

실행 컨텍스트 in React

React 와 실행 컨텍스트에 대해 공부해봤습니다.

Published 12 min readMarkdown
Part of seriesReact
실행 컨텍스트 in React

🤚🏻 들어가며

React 를 주로 사용해서 개발을 하고 있다고 이야기를 하고 있으면서도, 내가 매일 써내려가고 있는 코드들의 실행 순서와 상탯값의 변화에 대해 100% 인지하고 작성하고 있다는 자신이 생기지 않았다. 그래서, 이 부분에 대해 공부를 해야했는데 마침 예니저님이 공부했던 예시 코드가 있어서 해당 코드를 토대로 해서 공부를 한 번 해봤다.

👩🏻‍🌾 먼저 하고자 하는 메인 스터디에 들어가기 전 선행 스터디로 실행 컨텍스트와 클로저에 대해 알아보자.


🧮 실행 컨텍스트

실행할 코드에 제공할 환경 정보들을 모아놓은 객체로, javaScript 의 동적 언어로서의 성격을 가장 잘 파악할 수 있는 개념이다. 실행 컨텍스트는 스택과 큐로 구성되어 있다. 스택은 출입구가 하나 뿐인 우물 같은 데이터 구조라면, 큐는 양 쪽이 모두 열려있는 파이프 같은 구조를 생각하면 좋다.

실행 컨텍스트는 동일한 환경에 있는 코드들을 실행할 때 필요한 환경 정보들을 모아서 컨텍스트를 구성하며, 이를 콜 스택에 쌓아올렸다가 가장 위에 쌓여있는 컨텍스트와 관련 있는 코드들을 실행하는 방식으로 전체 코드의 환경과 순서를 보장한다.

🤔 동일한 환경이란?

하나의 실행 컨텍스트를 구성할 수 있는 방법으로 전역 공간, eval() 함수, 함수 등이 있다. eval() 을 제외한다면 우리가 흔히 실행 컨텍스트를 구성하는 방법은 함수를 실행하는 것 뿐이다.


🌍 환경정보

1️⃣ VariableEnvironment

실행 컨텍스트를 생성할 때 VariableEnvironment 에 정보를 먼저 담은 다음, 이를 그대로 복사해서 LexicalEnvironment 를 만들고, 이후에는 LexicalEnvironment 를 주로 활용한다. VariableEnvironment 와 LexicalEnvironment 의 내부는 현재 컨텍스트 내부의 식별자 정보인 environmentRecord와 외부 환경을 참조하는 정보인 outerEnvironmentReference로 구성되어 있다.

VariableEnvironment 는 식별자 정보를 수집하는 것으로, 컨텍스트의 내부 코드를 실행하는 동안 변화가 생기더라도 변경되지 않는다.

2️⃣ LexicalEnvironment

VariableEnvironment 와 LexicalEnvironment 의 내부는 environmentRecord와 outerEnvironmentReference로 구성되어 있다. 실행 컨텍스트를 구성하는 환경 정보들을 모아 하나의 사전처럼 구성한 객체라고 생각하면 된다. LexicalEnvironment 는 각 식별자의 데이터를 추적하며, 컨텍스트 내부 코드를 실행하는 동안 변화가 생기면 변경된다.

3️⃣ environmentRecord

현재 컨텍스트와 관련된 코드의 식별자 정보들이 저장되는 것으로, 매개 변수의 이름, 함수 선언, 변수명 등이 담긴다.

4️⃣ Scope

scope 는 식별자에 대한 유효범위를 의미하며, 어떤 경계 A 의 외부에서 선언된 변수는 A의 외부 뿐 아니라 A의 내부에서도 접근이 가능하다. 하지만 그에 반해 A의 내부에서 선언된 변수는 오직 A 내부에서만 접근이 가능하다. 이러한 식별자 유효범위를 안에서 밖으로 차례차례 검색해나가는 것을 scope-chain 이라고 하며, 이를 가능케 하는 것이 바로 outerEnvironmentReference 이다.

5️⃣ Scope-chain

outerEnvironmentReference 는 현재 호출된 함수가 선언될 당시의 LexicalEnvironment 를 참조한다. 여러 scope 에서 동일한 식별자를 선언한 경우에는 무조건 스코프 체인 상에서 가장 먼저 발견된 식별자에서만 접근이 가능하다.


🤚🏻 간단 정리

  • 실행 컨텍스트 생성 → variableEnv 에 정보 담기 → 이것을 복사해서 LexicalEnv 생성 및 활용

  • 실행 컨텍스트

    • v.e

      • environmentRecord

      • outerEnvironmentReference

    • l.e

      • environmentRecord

      • outerEnvironmentReference

example

  • 실행 컨텍스트 : 전역 context → outer() → inner()

    • 전역 컨텍스트

      • e : leetrue, outer()
    • outer()

      • environmentRecord : inner()

      • outerEnvironmentReference : global L.E

    • inner()

      • environmentRecord : leetrue

      • outerEnvironmentReference : outer L.E

var leetrue = 'pokemon newbie';

function outer() {
    function inner() {
        console.log(leetrue); // undefined
        var leetrue = 'pokemon master';
    };
    inner();
    console.log(leetrue); // pokemon newbie
};

outer();
console.log(leetrue); // pokemon newbie

👻 클로저

사실 이번에 실행 컨텍스트 공부만 하면 될 것이라 생각했는데, 아래…에서 다뤘던 부분에서 클로저 개념이 사용이 되어 공부가 필요했다. (^0^)

클로저는 반환된 내부 함수가 자신이 되었을 때의 렉시컬 환경인 스코프를 기억해 자신이 선언되었을 때의 환경 밖에서 호출이 된다고 하더라도 그 환경에 접근할 수 있는 함수를 의미한다.

🍑 실행컨텍스트 in React

React 에서 위와 같은 코드가 있다고 했을 때, 어떤 순서로 콘솔이 찍히게 될 지 생각해보자. 여기서 부터 한동안은 내가 생각하고 풀어갔던 과정이기 때문에.. 답을 확인하고 싶다면 아래로 쭉 내리면 되고 나는 공부를 해야하니 지금부터 주절거려볼 것이다.

👉🏻 화면이 첫 렌더링이 완료되었을 때,

🤔 내가 생각한 결과.

컴포넌트가 호출이 되면, num 과 func() 함수는 environmentRecord 에 수집되어 담겨질 것이고.. 컴포넌트의 최상단부터 차례대로 javaScript 함수가 호출 될 것이기 때문에, 화면이 그려지기 전까지는 아래와 같이 콘솔이 찍힐 것 같다. 이 때 useEffect 내부의 effect 들은 정의되어있는 상태일 것이다. 이후에는 화면이 렌더링될 것이고, 화면 렌더링 확인을 위해 심어둔(?ㅋㅋ) 콘솔까지 바로 호출이 되어서 같이 콘솔에 찍힐 것.

'App #Top state is 1'
'App #Bottom state is 1'
'Button Rendered, state is 1'

화면이 렌더링이 되고 나면 useEffect 의 effect 들이 호출되어 최상단의 useEffect 부터 차례로 찍히고 마무리가 될 것 같다. useEffect 의 effect 는 side effect 이기 때문에, 렌더링 이전에 처리되지 않는다. (리액트 성능이 떨어질 수 있기 때문) React 는 이런 성능적인 문제를 생각해서 가상돔을 렌더링할 때 side effect 를 제외한 순수 컴포넌트만을 사용해 렌더링 하며, 이후에 effect 를 호출해서 비동기적으로 side effect 를 처리한다.

'App #Top state is 1'
'App #Bottom state is 1'
'Button Rendered, state is 1'
================= 렌더링 후
"effect#0 state is 1"
"effect#1 state is 1"
"effect#2 state is 1"

🔎 실제로 찍힌 값.

'App #Top state is 1'
'App #Bottom state is 1'
'Button Rendered, state is 1'
"effect#0 state is 1"
"effect#1 state is 1"
"effect#2 state is 1"

👉🏻 버튼 클릭해서 상태 변경을 만들었을 때,

🤔 내가 생각한 결과.

버튼을 클릭하면 버튼의 클릭 이벤트에 적용한 콜백 함수가 호출되며 상단부터 차례로 진행될 것이다. 이 때, Num 변수는 호출 당시의 값을 기억하기 때문에 모두 1 이 찍힐 것.

"handler#1 state is 1"
"handler#2 state is 1"

여기까지 콘솔이 찍히고 나면, func() 함수가 호출되므로 func() 함수의 컨텍스트가 스택에 쌓인다. 그렇기 때문에 func() 함수 내부의 javaScript 함수가 순차적으로 호출된다. 이 때 func() 내부의 비동기 함수인 setTimeout 과 promise 의 경우에는 큐에 저장된…다? 그렇기 때문에 아직 로그는 안 찍힐 것… 그리고 Num 이 모두 1 로 찍히는 이유는 호출 당시의 상태값이 1 이었기 때문에 아무리 내부에서 setter 함수를 호출한다고 한들 모두 1 이 찍힌다.

"handler#1 state is 1"
"handler#2 state is 1"
======= func() 호출 =======
"func start state is 1"
"func mid state is 1"
"func finish state is 1"

func() 가 모두 실행이 되고 실행 컨텍스트에서 빠지면 버튼의 콜백함수 컨텍스트가 이어서 실행되며 아래와 같이 콘솔이 찍히며 마무리되고 콜백함수 또한 스택에서 빠져나오게 된다.

"handler#1 state is 1"
"handler#2 state is 1"
======= func() 호출 =======
"func start state is 1"
"func mid state is 1"
"func finish state is 1"
======= func() 끝 =======
"handler#3 state is 1"

콜백 함수가 스택에서 빠져나오게 되면, setter 함수에 의해 상태가 변경이 되고 컴포넌트의 리렌더링이 일어날 것이다. 그렇기 때문에 다시 Home 의 최상단부터 코드가 다시 실행된다. 다만 여기서 잘 모르고 암기만 해서 알고 있는 부분이, setter 함수가 호출되면 상태가 비동기적으로 변경이 된다는 것인데 이것에 대한 자세한 과정을 알아봐야할 것 같다. Anyway, 나머지는 상태가 변경이 된 것이 반영되면서 첫 렌더링 때와 같이 반복되어 로그가 찍힐 것이다.

"handler#1 state is 1"
"handler#2 state is 1"
======= func() 호출 =======
"func start state is 1"
"func mid state is 1"
"func finish state is 1"
======= func() 끝 =======
"handler#3 state is 1"
======= state 변경 =======
'App #Top state is 2'
'App #Bottom state is 2'
'Button Rendered, state is 2'

화면 리렌더링이 일어나고 나면, effect 가 실행된다. 최상단 useEffect 는 deps 가 없기 때문에 재호출되지 않고, 나머지 num 이 deps 인 useEffect 의 effect 들은 모두 호출이 되어 로그가 찍힌다.

클린업 함수들은 선언 당시의 상태를 가지고 렌더링이 될 것이고, 나머지 effect 들은 이것들이 속해있는 상태 값을 바라보고 있기 때문에 업데이트된 상태로 반영이 되어 로그가 찍힌다.

"handler#1 state is 1"
"handler#2 state is 1"
======= func() 호출 =======
"func start state is 1"
"func mid state is 1"
"func finish state is 1"
======= func() 끝 =======
"handler#3 state is 1"
======= state 변경 =======
'App #Top state is 2'
'App #Bottom state is 2'
'Button Rendered, state is 2'
======= effect ==========
"CU#1 state is 1"
"CU#1 state is 1"
"effect#1 state is 2"
"effect#2 state is 2"

마지막으로 비동기 함수가 실행되는데, 위에서 부터 순서대로 찍히지 않을까 싶다. 그러니, 아래와 같이 찍히고 마무리 될 것 같다.

"handler#1 state is 1"
"handler#2 state is 1"
======= func() 호출 =======
"func start state is 1"
"func mid state is 1"
"func finish state is 1"
======= func() 끝 =======
"handler#3 state is 1"
======= state 변경 =======
'App #Top state is 2'
'App #Bottom state is 2'
'Button Rendered, state is 2'
======= effect ==========
"CU#1 state is 1"
"CU#1 state is 1"
"effect#1 state is 2"
"effect#2 state is 2"
======= 비동기 ============
"timer state is 1"
"promise 프로미스 state is 1"

🔎 실제로 찍힌 값.

실제로 찍힌 내용은 내가 생각한 것과 조금 다른 부분이 있었다. 🤔 마지막 setTimeout 과 promise 에 대한 순서가 생각과는 다르게 찍혔다. 이 부분에 대해서 추가적인 공부가 필요했다. 그리고 setter 함수의 동작에 대한 부분과 effect 에 대해서도 같이 공부하면 좋을 것 같다.

"handler#1 state is 1"
"handler#2 state is 1"
"func start state is 1"
"func mid state is 1"
"func finish state is 1"
"handler#3 state is 1"
'App #Top state is 2'
'App #Bottom state is 2'
'Button Rendered, state is 2'
"CU#1 state is 1"
"CU#1 state is 1"
"effect#1 state is 2"
"effect#2 state is 2"
"promise 프로미스 state is 1"
"timer state is 1"

🗒️ Study.

이 코드를 보며 내가 추론해나가던 과정에서 추가적으로 공부해야할 부분들이 있어서 공부를 해봤다. setter 함수에 대한 부분과 useEffect, 그리고 태스크 큐에 대해 조금 더 깊이 알아볼 필요가 있었다.

왜 promise 가 timer 함수보다 먼저 실행이 되었는지.. timer 함수가 promise 보다 먼저 태스크 큐에 담겼을 것일텐데! 내가 모르는 부분이 있던 것인지 알아봐야했다. 그리고 setter 함수의 경우 언제 실질적으로 state 가 최신화 되고 리렌더링이 일어나는지에 대한 원리를 알고 싶었다. 그리고 effect 의 경우 어느 시점에 effect 가 등록되고 실행되는지에 대해 명확하게 알고 가려 한다.

1️⃣ event loop

복습하고 가자. 복습하지 않으면.. 분명 머릿 속에서 또 희미해질 것이기 때문에… 🥲

JavaScript engine.

JavaScript 엔진은 Memory Heap 과 Call Stack 으로 구성이 된다. Memory Heap 에는 참조형 데이터가 할당이 되며 콜 스택에는 원시타입 데이터와 렉시컬 환경과 전역 실행 컨텍스트가 구성된다. 함수가 호출이 되면, 콜 스택에 실행 컨텍스트가 생성이 된다.

Web API 는 웹 브라우저에 구현됨 API로 DOM event, ajax, Timers(setTimeout) 등이 있다.

이벤트 루프는 콜 스택이 빈 것이 확인되면 태스크 큐에 대기하고 있는 콜백 함수들을 처리하며 이 때 태스크 큐란, 태스크의 Set 이다. 이벤트 루프는 큐에 대기하는 첫 번째 태스크를 가져오는 것이 아닌, 태스크 큐에서 실행이 가능한 첫 번 째 태스크를 가져오는 방식이다. 태스크 중에서는 가장 오래된 태스크를 가져온다.

Event Loop

이벤트루프는 콜 스택이 비었다면 태스크 큐에 대기하고 있는 콜백함수들을 처리한다고 했다. 그럼 이 부분에서 마이크로 태스크 큐와 매크로 태스크 큐 두 가지가 있는데, 어떤 것이 먼저 처리가 될까?

이벤트 루프는 마이크로 태스크 큐의 모든 태스크들을 처리를 하고, 태스크 큐의 태스크를 처리한다. 예를 들어 아래와 같은 코드가 있다고 하면,

console.log('Call Stack');
setTimeout(() => console.log('MacroTask Queue'), 0);
Promise.resolve.the(() => console.log('MicroTask Queue'));

이렇게 콘솔이 찍힌다.

Call Stack
MicroTask Queue
MacroTask Queue

MicroTask

  • promises callback

  • async functions

  • queueMicrotask

  • process.nextTick()

MacroTask

  • timers (setTimeout(), setInterval(), setImmediate())

  • UI rendering

  • requestAnimationFrame

  • I/O

MicroTask & MacroTask

마이크로 태스크는 실행하면서 새로운 마이크로 태스크를 큐에 추가할 수 있다. 새롭게 추가된 마이크로 태스크들도 큐가 빌 때 까지 계속해서 실행이 된다.

매크로 태스크의 경우는 이벤트 루프가 매크로 태스크 큐에 있는 것을 실행시키기 시작한다면 매크로 태스크만 실행시킨다. 매크로 태스크가 추가한 매크로 태스크는 다음 이벤트 루프 실행까지 실행되지 않는다.

MicroTask Queue & MacroTask Queue

setTimeout 함수는 콜스택에 들어가서 실행되는 개념이 아닌, 태스크 큐에 들어가 대기를 하게 된다. 이 때, 대기는 태스크 큐가 모두 비워질 때 동안 대기하게 된다.

promise 로 호출한 코드는 마이크로 태스크 큐에 담기는데, 마이크로 태스크 큐는 가장 우선적으로 처리된다. 자바스크립트 엔진은 태스크 큐 하나를 처리할 때 마다 우선적으로 마이크로 태스크 큐에 쌓인 마이크로 태스크를 모두 처리한다.

2️⃣ useState

React.useState 의 setter 함수는 비동기적으로 동작하고 React 가 하나의 이벤트 핸들러 함수 내의 모든 로직을 읽을 때 까지 기다린 다음에 일괄 처리(Batch)해 한 번에 렌더링 한다. 이런 식으로 설계된 이유는 리렌더링을 효율적으로 수행하기 위함이다.

React 앱에는 수 많은 컴포넌트와 상태가 있을 것인데 이런 상황에서 하나의 상태가 변할 때 마다 관련 뷰를 매번 리렌더링하는 것은 비효율과 함께 성능상의 문제를 일으킬 수 있다.

렌더링 큐.

컴포넌트가 첫 렌더링이 완료된다면, 리액트에게 리렌더링을 큐에 등록하도록 지시를 할 수 있는 여러가지 방법이 있다.

  • 함수형 컴포넌트

    • useState setter

    • useReducer dispatch

  • 클래스형 컴포넌트

    • this.setState()

    • this.forceUpdate()

  • 기타

    • ReactDOM의 최상위인 render(<App>) 메서드를 다시 호출

    • 새로 추가된 useSyncExternalStore 훅에서 트리거된 업데이트

함수형 컴포넌트 useState 의 setter 함수를 사용하면 이벤트 루프의 마이크로 태스크 큐에 리렌더링이 등록된다.

3️⃣ useEffect

React 의 함수 컴포넌트는 클로저와 유사한 방식으로 동작한다. 즉 컴포넌트 안에 있는 모든 함수들(event handler, effect, effect clean-up, setTimeout, …)은 렌더링 될 때 정의된 props 와 state 값을 바라본다.

클린업 콜백은, 컴포넌트 리렌더링하기 전에 클린업이 되는 방식이 아닌, 리렌더링이 된 후에 클린업이 되고 그 다음 새로운 이펙트가 실행이 된다. useEffect 의 클린업 콜백 같은 경우는 이전 이펙트에 대한 클린업이기 때문에 state 가 갱신되었다고 하더라도 이전 렌더링 때의 state 를 바라본다.

react 의 렌더링 과정을 간단하게 정리하면,

[1단계]

  • react fiber node 생성단계 & effect list 에 useEffect 담는 과정

[2단계]

  • 작업 처리 과정

    • 컴포넌트 렌더링

    • 렌더링 이후에는 effect list 에 저장된 effect 들을 수행

👩🏻‍🌾 공부한 것들을 바탕으로 다시 정리해보자.

공부를 해서 부족한 부분을 채워넣었으니, 다시 코드를 보면서 전반적으로 굴러가는 순서를 정리해보자. 이번에는 조금 더 명확하게 추론해나갈 수 있기를 바라며..!

👉🏻 첫 렌더링 시.

  1. App 컴포넌트가 호출되고 실행 컨텍스트가 생성된다.

  2. 콜스택에 순서대로 함수가 호출이 되며 console.log 가 순서대로 찍힌다.

  3. 이 때, useEffect 의 effect 는 effect list 에 담긴다.

  4. 컴포넌트가 렌더링되면서 Button 의 함수가 즉시호출되어 로그가 찍힌다.

  5. 컴포넌트 렌더링이 끝나면 effect list 의 effect 가 순서대로 호출된다.

  6. effect 들은 렌더링 당시의 state 를 바라보기 때문이 1 이 찍히게 된다.

👉🏻 버튼으로 상태를 업데이트 했을 시.

  1. 버튼을 클릭하면 버튼의 콜백함수가 호출되어 실행 컨텍스트에 담긴다.

  2. 콜백함수의 최상단부터 함수가 호출되어 콘솔에 찍힌다.

  3. setter 함수는 호출이 되지만 일괄 처리(Batch)되기 때문에 대기한다.

  4. func() 함수가 호출되면 버튼의 콜백함수는 잠시 홀드되고 func() 가 실행 컨텍스트에 담겨 최상단부터 호출된다.

  5. console.log 는 순서대로 찍히고 setter 함수는 마찬가지로 배치를 돌려야 하므로 대기한다.

  6. promise 는 마이크로 태스크 큐에, setTimeout 은 매크로 태스크 큐에 담긴다.

  7. func() 함수가 종료되면 실행 컨텍스트에서 제거되고 다시 버튼의 실행 컨텍스트가 실행되어 마지막 콘솔을 호출한다.

  8. 모든 함수 호출이 끝나면 setter 함수가 호출되며, 변경된 상탯값이 등록되어 리렌더링이 마이크로 태스크 큐에 추가된다.

  9. 큐에 등록된 태스크들이 콜스택에 올라오는데 가장 먼저 리렌더링이 올라온다.

  10. 이벤트 루프에 의해 리렌더링이 일어난다.

  11. App 컴포넌트가 다시 호출되므로 동기적으로 실행되는 코드들이 콜 스택에 담긴다.

  12. 최상단부터 함수가 호출되어 새로운 상탯값 2 와 함께 콘솔이 찍히고 effect list도 등록된다.

  13. Button 콘솔이 찍혔다. 컴포넌트 렌더링이 끝났기 때문에 effect가 실행된다.

  14. 의존성 배열이 없는 useEffect 는 effect 가 호출되지 않는다.

  15. 상태가 업데이트 되었기 때문에 이전 이펙트에 대한 클린업 effect 가 호출되어 순서대로 콘솔에 찍힌다. 이 때, 상태값은 이전 렌더링 때의 상태값을 바라보기 때문에 1 이 로그에 찍힌다.

  16. 클린업 effect가 끝나면 새로운 effect 가 호출이 되어 현재의 상태값을 바라보고 로그가 찍힌다.

  17. effect 호출이 끝나면 이벤트 루프에 의해 태스크 큐에 등록된 태스크들이 호출된다.

  18. 마이크로 태스크 큐에 등록된 promise 가 먼저 콘솔에 찍히고, 매스로 태스크 큐의 timer 함수가 호출이 된다. 이 때 state 는 처음 렌더링 될 때의 state 를 바라보므로 2 이다.

정리


마무리

오늘도 또 묵은 숙제를 끝(?)냈다. 사실 오늘 주제도 쉽지는 않았다. 오늘 포스팅에 다 적지는 못했지만, 리액트 모듈의 근본 코드들도 후벼파면서 훅의 원리부터 심연의 영역까지 보며 야크 털깎기를 또 하기도 했다..ㅋ 다시 정신차리고 원래 노선으로 돌아왔는데.. 야크 털깎기를 하면서 느낀 점은 나는 리액트의 ‘ㄹ’도 잘 모르고 있다는 사실.. 리액트의 렌더링 과정에 대한 전반적인 이해를 제대로 해야겠다는 욕심이 1스택 추가되었다. 조만간 또 시간을 내어서 공부하려 한다.

어쨌든! 리액트에서의 코드들이 돌아가는 순서와 state 갱신 시점 및 언제 어떤 state 를 바라보게 되는지에 대한 이해를 할 수 있어서 너무 좋았다. 지금부터는 조금 더 나의 장황한 코드들에 대한 흐름이 눈에 잘 보일 것 같다. 뿌듯뿌듯!


참고

About the author

  • 직면하는 모든 문제에 유치한 것은 없으며, 의미 없는 삽질 또한 없다고 믿습니다.

No comments yet. Be the first to comment.