리액트 심화 전 꼭 알아야 할 JS 개념 4가지

Yujin Jung·2025년 11월 8일

클로저 · 싱글 스레드 · 이벤트 루프 · 렌더링 프로세스

리액트 심화 개념(예: 렌더링 최적화, useCallback, useMemo, 비동기 상태 업데이트 등)을 이해하려면 자바스크립트의 실행 원리를 탄탄히 아는 것이 필수입니다.

이번 글에서는 리액트를 공부하기 전에 반드시 짚고 넘어가야 할 JS 핵심 4개념을 정리했습니다.


클로저 (Closure)

정의: “함수가 선언될 때의 렉시컬 스코프(변수 환경)를 기억하여, 실행 시점에도 그 변수들에 접근할 수 있는 현상(함수 + 환경의 조합).”


1) 메모리·실행 모델

  • 함수가 선언되면, 엔진은 그 함수에 대한 [[Environment]](= 외부 렉시컬 환경 참조)를 저장합니다.
  • 외부 함수가 종료되어도, 내부 함수가 참조하는 변수들이 살아있다면 GC 대상이 되지 않습니다. (“메모리 캡처”)
  • 즉, 스코프 체인은 “문법적(렉시컬) 위치”로 고정됩니다. 호출 위치가 아니라 선언 위치가 중요합니다.
function makeCounter(step = 1) {
  let value = 0;                 // ← 렉시컬 환경에 저장

  return function inc() {        // ← [[Environment]]로 value를 기억
    value += step;
    return value;
  };
}

const c1 = makeCounter();  // c1은 value=0을 기억
const c2 = makeCounter(2); // c2는 value=0, step=2를 기억

console.log(c1()); // 1
console.log(c1()); // 2
console.log(c2()); // 2
console.log(c2()); // 4

흔한 함정: 루프 + var

const btns = Array.from(document.querySelectorAll('button'));
for (var i = 0; i < btns.length; i++) {
  btns[i].addEventListener('click', () => console.log(i));
}
// 모든 버튼이 마지막 값만 출력
  • var는 함수 스코프라 하나의 i를 공유합니다.
    해결 👉 let(블록 스코프) 사용하거나 즉시실행함수(IIFE)로 캡쳐
for (let i = 0; i < btns.length; i++) {
  btns[i].addEventListener('click', () => console.log(i));
}

2) 리액트와의 연결

  • Stale Closure(오래된 값 참조) 문제:
function App() {
  const [count, setCount] = useState(0);

  // 이 핸들러는 "정의된 렌더링 시점의 count"를 기억
  const onClick = () => {
    setTimeout(() => {
      // 여기서의 count는 과거 값일 수 있음
      setCount(count + 1);
    }, 1000);
  };
  return <button onClick={onClick}>{count}</button>;
}
  • 해결 👉 업데이트 함수형(setter 콜백) 사용:
setCount(prev => prev + 1); // 항상 최신 state 기반
  • useCallback/useMemo의존성 배열을 통해 어떤 값을 클로저로 “고정”할지 제어합니다.
    의존성 누락 시 의도치 않은 낡은 값 참조가 발생합니다. (ESLint hooks 규칙으로 방지)

3) 알아두면 좋은 점

  • 이벤트 핸들러에서 state를 읽고 업데이트할 때는 가급적 함수형 업데이트로 안전하게 처리하기
  • 메모리 누수: 클로저가 거대한 객체를 잡고 있으면 GC가 못 합니다. 더 이상 필요없을 때 참조를 끊거나, effect 정리(clean-up)로 해제하기


자바스크립트의 렉시컬 환경(Lexical Environment) 구조를 시각적으로 표현한 다이어그램

싱글 스레드 (Single Thread)

정의: 자바스크립트 엔진은 기본적으로 단 하나의 호출 스택(Call Stack) 에서 코드를 순차 실행합니다.


1) 왜 중요한가

  • 한 번에 하나의 작업만 메인 스레드에서 처리 → 블로킹이 발생하면 UI 멈춤
  • JS 자체는 싱글 스레드지만, 브라우저는 별도 스레드(타이머, 네트워크, I/O, 렌더링 등) 를 갖습니다. 비동기는 이들과의 협업으로 구현됩니다.
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
// A -> C -> B

2) 메인 스레드를 막는 코드 예시

// 200ms 동안 CPU 바쁜 작업(동기)
const block = (ms) => {
  const end = performance.now() + ms;
  while (performance.now() < end) {}
};

console.log("start");
block(500);       // 이 동안 클릭, 스크롤, 렌더링 모두 지연
console.log("end");

해결 아이디어

  • 작업 쪼개기(Chunking): setTimeout, queueMicrotask, requestIdleCallback, requestAnimationFrame 등으로 틈 주기
  • Web Worker로 CPU 바운드 작업을 메인 스레드 밖에서 처리

3) 리액트와의 연결

React 18의 Concurrent Rendering은 싱글 스레드 환경에서 “작업을 잘게 쪼개고, 우선순위를 둬서 중단/재개할 수 있도록” 만든 모델입니다.

  • 긴 렌더링 작업이 메인 스레드를 독점하지 않도록, 사용자 입력/애니메이션 같은 급한 일에 먼저 시간을 양보합니다.


자바스크립트 런타임 구조

이벤트 루프 (Event Loop)

정의: 콜 스택이 비는 순간, 대기 중인 태스크(작업)를 정해진 우선순위에 따라 가져와 실행시키는 스케줄러/조정자


1) 구성요소와 순서

  • Call Stack: 현재 실행 중인 JS 프레임
  • Web APIs: 타이머, DOM, fetch 등 브라우저/런타임 제공 기능
  • Task Queue(Macro Task): setTimeout, setInterval, MessageChannel, script
  • Microtask Queue: Promise.then/catch/finally, queueMicrotask, MutationObserver
  • Event Loop: 스택이 비면, 먼저 Microtask 전부 → 그 후 다음 Task 한 번 수행 → 다시 Microtask …
console.log("start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve()
  .then(() => console.log("microtask-1"))
  .then(() => console.log("microtask-2"));

console.log("end");
// start -> end -> microtask-1 -> microtask-2 -> timeout

queueMicrotask 타이밍

console.log("A");
queueMicrotask(() => console.log("B (micro)"));
console.log("C");
// A -> C -> B (micro)

requestAnimationFrame(rAF) 타이밍

  • 렌더 직전 한 번 호출됨(프레임 동기화)
requestAnimationFrame(() => {
  // 여기는 다음 페인트 직전: DOM 읽고/쓰고, 스타일/레이아웃 최소화 전략 가능
});
requestAnimationFrame(() => {
  // 여기는 다음 페인트 직전: DOM 읽고/쓰고, 스타일/레이아웃 최소화 전략 가능
});

2) 리액트와의 연결

  • setState는 보통 동기처럼 보이지만 배치/스케줄링되어 나중에 반영됩니다.
  • useEffect커밋 후(페인트 이후) 비동기적으로 실행
  • useLayoutEffectDOM 커밋 직후(페인트 이전) 실행되어 레이아웃 측정에 적합
  • Transition(React 18): 긴 렌더링을 낮은 우선순위로 보내 UI 응답성을 확보

알아두면 좋은 점

  • Network 응답 후 DOM 업데이트 → microtask vs task 타이밍에 따라 reflow 횟수가 달라질 수 있음
  • 긴 루프/대량 DOM 조작rAF/IdleCallback으로 분할, 또는 가상화·버퍼링 적용


이벤트 루프(Event Loop) 의 전체 흐름

브라우저 렌더링 프로세스 (CRP: Critical Rendering Path)

정의: 브라우저가 HTML/CSS/JS를 받아 화면에 픽셀로 그릴 때까지의 전체 파이프라인


1) 파이프라인 단계

  1. HTML 파싱 → DOM 생성
  2. CSS 파싱 → CSSOM 생성
  3. Render Tree = DOM + CSSOM 결합(보이는 노드만)
  4. Layout(Reflow): 각 노드의 기하(위치·크기) 계산
  5. Paint: 배경, 글자, 테두리 등 그리기
  6. Composite: 여러 레이어를 GPU로 합성(Transform/Opacity는 합성 단계에서 처리 가능)

    Recalculate Style → Layout → Paint → Composite 순으로 DevTools 타임라인에 보이는 이유가 이 흐름 때문


2) 비용이 큰 작업과 최적화 기준

  • Layout(=Reflow): 문서 흐름에 영향 → 비싸다
    (예: width/height/top/left, 폰트 바뀜, DOM 삽입/삭제 등)
  • Paint: 배경/텍스트/그라데이션/박스-쉐도우가 많으면 비용 상승
    Composite-only: transform/opacity 변경은 Layout/ Paint를 건너뛸 수 있어 상대적으로 저렴
/* 좋음: 합성 단계에서 처리됨 */
.box.animate {
  will-change: transform, opacity;
  transition: transform 300ms, opacity 300ms;
}

3) JS와 렌더링의 상호작용

  • JS가 메인 스레드를 오래 잡고 있으면, 렌더 스텝(Layout/Paint/Composite)이 지연됩니다.
  • 반대로, 스타일 계산이나 레이아웃을 자주 강제하면(예: 잦은 offsetWidth 읽기 → layout flush) 프레임이 끊깁니다.
// layout thrashing (피해야 함)
for (const el of items) {
  const w = el.offsetWidth;  // 레이아웃 강제
  el.style.width = (w + 10) + 'px';
}
// layout thrashing (피해야 함)
for (const el of items) {
  const w = el.offsetWidth;  // 레이아웃 강제
  el.style.width = (w + 10) + 'px';
}

개선: 읽기→쓰기 배치

// 1) 먼저 모두 읽고
const widths = items.map(el => el.offsetWidth);
// 2) 다음 프레임에 한번에 쓰기
requestAnimationFrame(() => {
  items.forEach((el, i) => { el.style.width = (widths[i] + 10) + 'px'; });
});

4) 리액트와의 연결

  • 리액트는 Virtual DOM으로 실제 DOM 변경 최소화 → CRP 비용 절감
  • 다만 렌더 함수 자체가 무거우면(큰 리스트, 복잡한 계산) JS가 메인 스레드를 막아 렌더링이 지연됩니다.
    (해결: 컴포넌트 분할, React.memo, useMemo/useCallback, 리스트 가상화, 서버 컴포넌트/SSR, Concurrent 특성(transition) 활용)
  • 측정 우선: Chrome DevTools Performance/Profiler로 Recalculate Style/Layout/Paint가 어디서 발생하는지 확인


브라우저 렌더링 과정(Critical Rendering Path)

profile
매일매일 조금씩 성장하려 노력하는 프론트엔드 개발자입니다!

3개의 댓글

comment-user-thumbnail
2025년 11월 8일

자바스크립트 동작 원리를 리액트와 자연스럽게 연결해 설명해주셔서 이해가 잘 된 것 같아요 !! 특히 클로저 부분에서 stale closure 문제를 예시로 들어 함수형 업데이트가 왜 필요한지 구체적으로 보여주신 점이 인상적이었어요 !! 렌더링 프로세스 부분에서 각 단계의 비용과 최적화 방향까지 짚어주셔서 성능적인 관점에서도 생각해볼 수 있었습니다 !! 글이 전반적으로 개념과 실제 동작을 연결해줘서 덕분에 리액트의 렌더링 구조를 깊이 있게 이해하는데에 너무 좋았네요 ㅎㅎ 아티클 작성하시느라 수고 많으셨어요 ! 다음주도 화이팅 ~!

답글 달기
comment-user-thumbnail
2025년 11월 8일

코드로만 알고 있었던 개념을 함수형 업데이트, 렌더 스텝 등으로 명칭을 명시해줘서 협업에서 소통할 때 많이 도움이 될 것 같아요!
리액트의 Concurrent Rendering으로 UI적인 부분을 먼저 실행한다는 점, useEffect가 페인트 이후 비동기적으로 실행한다는 점을 알게되어서 개발할 때 코드 작동에 대한 이해가 편해졌어요
transform과 opacity 변경이 레이아웃과 페인트를 건너뛸 수 있다는 점도 알게되어서, 앞으로 hover할 때나 background-color를 바꿀 때, 요소를 이동시킬때 두 속성을 먼저 생각해보려고 해요 감사합니다!!

1개의 답글