JavaScript와 브라우저 기초 복습하기

Jessie·2026년 8월 18일

JavaScript 동작 원리

Call Stack과 Event Loop

Call Stack이란 자바스크립트 코드가 실행되는 동안 어떤 함수를 실행하고 있는지 순서를 기록하는 Data 구조이다. 후입선출(LIFO)의 구조를 가지기 때문에 함수를 실행하게 되면 그 함수의 기록이 Call Stack의 제일 위에 쌓이게 되고, 함수가 종료되면 제일 위의 기록이 제거된다.

자바스크립트는 단일 스레드 프로그래밍 언어로, 단일 Call Stack을 가지고 있다. 이 말은, 한 번에 하나의 작업만 처리할 수 있다는 뜻이고, API 요청이나 타이머 작업처럼 시간이 오래 걸리는 함수가 실행되면 후속 작업을 처리할 수 없게 된다.

이 문제를 해결하기 위해서 비동기 콜백과 Event Loop가 등장한다. 자바스크립트가 fetch, setTimeout 등의 작업을 만나면 이 작업이 완료될 때까지 기다리는 대신, 브라우저의 WepAPI에게 맡기고 다음 작업을 진행한다. Web API는 비동기 작업을 처리하고, 작업이 완료되면 이후 실행할 콜백을 작업의 종류에 따라 Task Queue 또는 Microtask Queue에서 대기하도록 한다. Event Loop는 Call Stack과 queue를 감시하다가, Call Stack이 비면 queue 안의 작업을 Call Stack으로 넣어주는 작업을 한다.

비동기 작업이 대기하는 Queue는 크게 다음과 같이 구분할 수 있다.

  • Macrotask Queue: setTimeout, setInterval
  • Microtask Queue: Promise 콜백 (.then, .catch, .finally)

Microtask Queue는 Macrotask Queue보다 우선순위를 가지므로, Call Stack이 비면 Microtack Queue 작업이 먼저 실행되고, 그 다음 Macrotack Queue 작업이 실행된다.

Execution Context과 Lexical Environment

실행 컨텍스트(Execution Context)란 자바스크립트 엔진이 코드를 실행할 때 필요한 실행 상태를 관리하기 위한 단위이다.

자바스크립트 코드가 실행되기 시작하면 먼저 전역 실행 컨텍스트(Global Execution Context)가 생성되고, 함수를 호출할 때마다 해당 함수를 위한 함수 실행 컨텍스트(Function Execution Context)가 새롭게 생성된다.

예를 들어 다음 코드가 있다고 해보자.

const globalValue = 10;

function foo() {
  const localValue = 20;
  console.log(globalValue + localValue);
}

foo();

코드가 처음 실행되면 전역 실행 컨텍스트가 생성된다. 이후 foo()가 호출되면 자바스크립트 엔진은 foo의 코드를 실행하기 위한 새로운 함수 실행 컨텍스트를 생성한다.

이 실행 컨텍스트에서는 현재 실행 중인 코드가 어떤 변수와 함수에 접근할 수 있는지, 외부 스코프는 어디인지, this는 무엇인지 등 코드 실행에 필요한 상태를 관리한다.

💡 Call Stack과 실행 컨텍스트의 관계
생성된 실행 컨텍스트는 Call Stack을 통해 관리된다. 함수가 호출되면 새로운 실행 컨텍스트가 생성되어 Call Stack에 쌓이고, 함수 실행이 끝나면 해당 실행 컨텍스트가 Stack에서 제거된다. 따라서 Call Stack의 가장 위에는 현재 실행 중인 실행 컨텍스트가 위치한다.
(Call Stack = 실행 컨텍스트 스택)

그렇다면 localValueglobalValue는 어디에 저장되고, 그 값이 어디에 있는지 어떻게 알 수 있을까? 실행 컨텍스트 안에서 변수와 함수 같은 식별자를 관리하고, 외부 스코프와의 연결 관계를 나타내는 데 사용되는 개념이 Lexical Environment이다.

💡 함수가 실행되면 함수 실행 컨텍스트가 생성되고, 그 다음에 함수 Lexical Environment를 생성하여 실행 컨텍스트에 바인딩한다. 실행 컨텍스트와 Lexical Environment는 같은 것이 아니다.

Lexical Environment는 크게 두 가지로 구성된다.

  • Environment Record: 현재 스코프에 선언된 변수와 함수 등의 식별자를 관리한다.
  • Outer Lexical Environment Reference: 외부 Lexical Environment를 참조한다.

localValue를 사용할 때는 현재 Lexical Environment의 Environment Record에서 바로 찾을 수 있다. 반면 globalValue는 foo의 Environment Record에 존재하지 않는다. 이 경우 Outer Reference를 따라 외부 Lexical Environment로 이동하여 globalValue를 찾는다. 외부에서도 찾지 못한다면 다시 그 외부 환경으로 이동하며 식별자를 탐색한다.

localValue 탐색

foo Environment
└─ localValue 발견 ✓


globalValue 탐색

foo Environment
└─ 없음
   ↓ Outer
Global Environment
└─ globalValue 발견 ✓

이렇게 Lexical Environment들이 외부 환경을 참조하며 연결되고, 현재 환경에서 시작해 외부 환경으로 식별자를 탐색할 수 있는 연결 관계를 Scope Chain이라고 한다.

💡 Lexical은 코드가 작성된 위치를 의미한다. 함수가 어디에서 호출되었는지가 아니라 어디에서 정의되었는지에 따라 외부 Lexical Environment가 결정된다.

Closure

클로저(Closure)란 함수가 자신이 정의된 위치의 Lexical Environment를 기억하여, 외부 함수의 실행이 끝난 이후에도 해당 환경의 변수에 접근할 수 있는 현상을 말한다.

function outer() {
  const value = 10;

  function inner() {
    console.log(value);
  }

  return inner;
}

const closure = outer();
closure(); // 10

inner 함수는 outer 내부에서 정의되었기 때문에 자신의 Outer Lexical Environment로 outer의 Lexical Environment를 참조한다.

outer()의 실행이 끝나면 outer의 실행 컨텍스트는 Call Stack에서 제거된다. 하지만 반환된 inner 함수가 여전히 outer의 Lexical Environment를 참조하고 있기 때문에 해당 환경은 사라지지 않고 유지된다.

따라서 나중에 closure()를 호출해도 inner는 자신의 Scope Chain을 따라 outer의 value를 찾아 사용할 수 있다.

💡 실행 컨텍스트가 사라져도 변수에 접근할 수 있는 이유
함수 실행이 끝나 Call Stack에서 실행 컨텍스트가 제거되었다고 하더라도, 해당 함수의 Lexical Environment가 즉시 소멸하는 것은 아니다. Lexical Environment는 실행 컨텍스트에 의해 참조되기는 하지만, 독립적인 객체이기 때문에 다른 함수가 해당 환경을 계속 참조하고 있다면 사라지지 않고 남아있게 된다. Lexical Environment는 누군가에 의해 참조되지 않을 때 가비지 컬렉터에 의해 소멸한다.

객체의 참조와 불변성

JavaScript에서 원시값과 객체는 값을 다루는 방식에 차이가 있다.

원시값은 변수에 값 자체가 저장되는 것처럼 동작하기 때문에 다른 변수에 할당한 뒤 값을 변경해도 서로 영향을 주지 않는다.

let a = 10;
let b = a;

b = 20;

console.log(a); // 10
console.log(b); // 20

반면 객체를 다른 변수에 할당하면 두 변수가 같은 객체를 참조하게 된다.

const a = { count: 10 };
const b = a;

b.count = 20;

console.log(a.count); // 20
console.log(b.count); // 20

console.log(a === b); // true

a와 b가 각각 별도의 객체를 가지고 있는 것이 아니라 동일한 객체를 참조하고 있기 때문에, b를 통해 객체를 변경하면 a에서도 변경된 값이 보인다.

반대로 내용이 동일하더라도 각각 새롭게 생성된 객체는 서로 다른 객체다.

const a = { count: 10 };
const b = { count: 10 };

console.log(a === b); // false

즉 객체를 비교할 때는 내부의 값이 같은지가 아니라 동일한 객체를 참조하고 있는지가 중요하다

불변성(Immutability)

불변성이란 기존 값을 직접 변경하지 않고 새로운 값을 만들어 변경을 표현하는 것을 의미한다.

객체는 기본적으로 변경 가능한(mutable) 값이므로 다음과 같이 직접 변경할 수 있다.

const user = {
  name: 'Amy',
  age: 20,
};

user.age = 21;

불변성을 유지한다면 기존 객체를 수정하는 대신 새로운 객체를 만든다.

const user = {
  name: 'Amy',
  age: 20,
};

const nextUser = {
  ...user,
  age: 21,
};

console.log(user === nextUser); // false

이렇게 하면 기존 user는 그대로 유지하면서 변경된 값을 가진 nextUser라는 새로운 참조의 객체를 만들 수 있다.

참고로, Spread Syntax로 객체를 복사하면 바깥 객체는 새롭게 생성되지만, 중첩된 객체까지 새롭게 복사되는 것은 아니다 (Shallow Copy). 그래서 중첩된 값을 불변성을 유지하면서 변경하려면, 변경되는 경로의 객체도 새롭게 만들어야 한다.

const nextUser = {
  ...user,
  address: {
    ...user.address,
    city: 'Busan',
  },
};

💡 React에서 불변성이 중요한 이유
React는 state가 변경되었는지를 판단할 때 객체 내부의 모든 값을 하나씩 비교하지 않고, 이전 값과 새로운 값의 동일성을 비교하는 방식을 활용한다. 따라서 기존 객체를 직접 변경하는 대신 새로운 객체를 만들어야 변경을 쉽게 감지할 수 있다.

브라우저 렌더링 과정

DOM과 CSSOM

브라우저는 서버로부터 전달받은 HTML과 CSS 파일을 그대로 화면에 그리는 것이 아니라, 브라우저가 이해하고 처리할 수 있는 구조로 변환한다. HTML은 DOM(Document Object Model)으로, CSS는 CSSOM(CSS Object Model)으로 변환된다.

DOM은 HTML 문서를 브라우저가 다룰 수 있도록 객체 기반의 트리 구조로 표현한 것이다.

예를 들어 다음과 같은 HTML이 있다면,

<html>
  <body>
    <div>
      <h1>Hello</h1>
      <p>World</p>
    </div>
  </body>
</html>

브라우저는 HTML을 위에서부터 파싱하면서 각각의 요소를 Node로 만들고, 요소 간의 부모-자식 관계를 표현하는 DOM Tree를 생성한다.

// 실제 DOM 객체를 이해하기 쉽게 단순화한 형태

const body = {
  nodeName: 'BODY',
  children: [
    {
      nodeName: 'DIV',
      children: [
        {
          nodeName: 'H1',
          textContent: 'Hello',
          children: [],
        },
        {
          nodeName: 'P',
          textContent: 'World',
          children: [],
        },
      ],
    },
  ],
};

DOM이 만들어지면 JavaScript를 통해 이 구조에 접근하거나 변경할 수 있다.

const h1 = document.querySelector('h1');

console.log(h1.nodeName);    // "H1"
console.log(h1.textContent); // "Hello"
console.log(h1.parentNode);  // <div>...</div>

즉 DOM은 단순히 HTML을 복사한 것이 아니라, HTML 문서의 구조와 내용을 JavaScript를 비롯한 프로그램이 다룰 수 있도록 표현한 객체 모델이다.

CSSOM은 스타일시트와 각각의 CSS 규칙을 객체 형태로 표현한 모델이다. 이를 통해 브라우저는 어떤 스타일 규칙이 존재하는지 파악하고, DOM 요소에 적용될 스타일을 계산할 수 있다.

예를 들어 다음 CSS가 있다면,

body {
  font-size: 16px;
}

h1 {
  color: blue;
}

브라우저는 CSS를 파싱하여 각 요소에 어떤 스타일 규칙이 적용되는지 계산할 수 있는 객체를 생성한다.

// 실제 CSSOM 객체를 이해하기 쉽게 단순화한 형태

CSSStyleSheet {
  cssRules: [
    CSSStyleRule {
      selectorText: 'body',
      style: {
        fontSize: '16px'
      }
    },

    CSSStyleRule {
      selectorText: 'h1',
      style: {
        color: 'blue'
      }
    }
  ]
}

다만 실제 CSSOM은 CSS 선택자를 단순히 DOM 같은 형태의 트리로 바꾼 것이라고 이해하면 정확하지 않다. CSS에는 상속과 Cascading이 있기 때문에 브라우저는 여러 CSS 규칙을 종합하여 각 요소에 적용될 스타일을 결정한다.

Render Tree

브라우저는 DOM과 CSSOM을 생성한 뒤, 이를 바탕으로 실제 화면을 렌더링하는 데 필요한 요소와 스타일 정보를 가진 Render Tree를 구성한다. (이 과정에서 핵심적으로 일어나는 작업이 Style Calculation라서 이 단계를 style 단계로 구분하기도 한다.)

DOM이 문서의 구조를 나타낸다면, Render Tree는 화면에 무엇을 그려야 하는지를 나타내는 구조라고 볼 수 있다.

// HTML
<body>
  <h1>Hello</h1>
  <p>World</p>
  <div style="display: none;">Hidden</div>
</body>

이 코드를 기반으로 파싱된 DOM에는 HTML에 존재하는 모든 요소가 포함된다. 하지만 divdisplay: none이 적용되어 화면에 표시되지 않으므로 Render Tree에서 제외된다. 즉, DOM과 Render Tree는 1:1로 대응하지 않는다.

Layout

Render Tree가 만들어지면 브라우저는 각 요소가 화면에서 어디에 위치하고, 어떤 크기를 가져야 하는지 계산한다. 이 과정을 Layout이라고 한다.

<div style="width: 50%; padding: 20px;">
  Hello
</div>

width: 50%만으로는 실제 너비가 몇 px인지 알 수 없다. 부모 요소의 크기나 viewport 크기 등 주변 요소와의 관계를 고려해야 하기 때문이다.

브라우저는 Layout 과정에서 이러한 값들을 계산하여 각 요소의 정확한 크기와 위치를 결정한다.

// Render Tree
div
├─ width: 50%
└─ padding: 20px

// Layout - 화면상의 실제 위치와 크기
div
├─ x: 0px
├─ y: 0px
├─ width: 640px
└─ height: 60px

Layout은 한 요소만 독립적으로 계산하는 것이 아니라 다른 요소와의 관계를 고려해서 계산한다. 따라서 이미 화면이 렌더링된 이후에도 요소의 크기나 위치에 영향을 주는 스타일이 변경되면 Layout이 다시 필요할 수 있다.

element.style.width = '500px';

이처럼 변경으로 인해 요소의 위치와 크기를 다시 계산하는 것을 흔히 Reflow라고 부른다.

💡 Render Tree와 Layout의 차이
Render Tree가 무엇을 그릴 것인지를 결정하기 위한 구조라면, Layout은 그 요소를 어디에, 어떤 크기로 그릴 것인지를 계산하는 과정이다.

Paint

Paint는 Layout에서 계산된 정보를 바탕으로 색상, 텍스트, 테두리, 그림자 등의 시각적 요소를 실제로 그리는 단계이다.

예를 들어 다음과 같은 요소가 있다고 해보자.

<div
  style="
    width: 200px;
    height: 100px;
    background: blue;
    border: 1px solid black;
  "
>
  Hello
</div>

Layout 단계에서는 주로 다음과 같은 기하학적 정보가 결정된다.

x: 0px
y: 0px
width: 200px
height: 100px

이후 Paint 단계에서는 이를 바탕으로 배경, 테두리, 텍스트 등의 시각적 요소를 그린다.

→ 배경을 blue로 그리기
→ 검은색 border 그리기
→ "Hello" 텍스트 그리기

이미 렌더링된 이후에도 요소의 시각적인 속성이 변경되면 다시 Paint가 필요할 수 있다.

element.style.backgroundColor = 'red';

이 경우 background-color는 일반적으로 요소의 위치나 크기를 변경하지 않기 때문에 Layout은 다시 하지 않고 Paint부터 다시 수행할 수 있다. 이러한 다시 그리기를 Repaint라고 부른다.

💡 Layout과 Paint의 차이
Layout은 어디에, 어떤 크기로 그릴 것인지를 계산하고, Paint는 어떤 색상과 모양으로 그릴 것인지를 결정한다.

Composite

Paint된 결과가 곧바로 최종 화면에 표시되는 것은 아니다. Paint 과정이 끝나면 브라우저는 그려진 결과를 바탕으로 여러 Layer를 적절한 위치와 순서로 합쳐 최종 화면을 만드는 과정을 수행한다. 이를 Composite이라고 한다.

브라우저는 렌더링 과정에서 필요에 따라 화면의 일부 요소를 별도의 Compositing Layer로 분리할 수 있다. 예를 들어 화면이 다음과 같은 Layer들로 나뉘었다고 생각해보자.

Layer 3    [ Modal ]
              ↓
Layer 2    [ Header ]
              ↓
Layer 1    [ Content ]

각 Layer의 내용이 준비되면 Composite 단계에서는 이들을 올바른 위치와 순서로 합성하여 사용자가 보는 최종 화면을 만든다.

Layer 3 ──┐
Layer 2 ──┼──→ Composite ──→ 최종 화면
Layer 1 ──┘

Composite가 중요한 이유는 경우에 따라 Layout과 Paint를 다시 수행하지 않고도 화면을 변경할 수 있기 때문이다. 대표적으로 transform이나 opacity를 이용한 변경은 해당 요소가 별도의 합성 레이어에서 처리되는 등의 조건이 갖춰지면, Layout이나 Paint 없이 Composite 단계에서 처리될 수 있다.

element.style.transform = 'translateX(100px)';

이러한 변경은 기존에 그려진 Layer를 새로운 위치에 합성하는 방식으로 처리할 수 있기 때문에 애니메이션 성능 최적화에서도 자주 활용된다.

브라우저 렌더링 과정 요약


참고자료

profile
주니어 프론트엔드 개발자입니다 😎

0개의 댓글