WEB FE Repository/React

useState, useEffect - 브라우저 & React의 동작 원리

조금씩 차근차근 2026. 2. 6. 19:00

본 글은 리액트를 빠르게 이해한 후 사용해보고 싶은 백엔드 개발자를 위해 작성되었습니다.

 

초급 문법 중심이 아닌

  • 가장 자주 사용되는 리액트의 훅
  • 리액트의 내부 동작원리와 디버깅 시 필요한 기초 지식

위주로 작성되었음을 알립니다.


현재 나의 프로젝트 핀잇의 경우, 리액트 훅들이 다음과 같은 횟수로 사용되었다.

 

총 380회 (100.0%)

  • useState 155 (40.8%)
  • useEffect 60 (15.8%)
  • useMemo 53 (13.9%) - DP
  • useContext 52 (13.7%)
  • useCallback 37 (9.7%) - DP
  • useRef 23 (6.1%) - 직접 DOM 조작

위 통계에서 보듯이, 보통 리액트에서 구현 시 가장 자주 사용되는 핵심 기능으로는 useStateuseEffect라고 할 수 있다.

 

이 두 메소드를 사용할 줄 모르면, '리액트를 써보자'라는 시작조차 할 수 없다.

또한 이 두가지는 리액트의 아키텍쳐와도 밀접하게 관련이 있기 때문에, 깊이 있게 학습할 가치가 충분하다.
그러니 이 두가지에 대해 자세히 알아보자.


useState

useState는 함수 컴포넌트에 상태(state) 변수를 추가할 수 있게 해주는 React 훅이다.
선언 시 초기값을 인자로 받고, [현재 상태, 상태설정 함수]의 쌍을 반환한다.

const [count, setCount] = useState(0);

setCount(새값)을 호출하면 React에게 현재 컴포넌트 상태를 새값으로 변경하고 다시 렌더링(rendering)해달라 고 요청한다.
이때 상태 변경은 비동기적으로 된다. 즉, 변수 값이 즉시 바뀌는 것이 아니라 다음 렌더링에서 새로운 값으로 업데이트된다.


브라우저의 렌더링 과정

useState를 사용할 때 자주 언급되는 핵심 버그로 다음 두 가지를 들 수 있다.

  1. state를 업데이트했지만 로그에는 계속 이전 값이 표시되는 경우
function handleClick() {
    console.log(count);  // 0

    setCount(count + 1); // 1로 리렌더링 요청합니다.
    console.log(count);  // 아직 0입니다!

    setTimeout(() => {
        console.log(count); // 여기도 0이고요!
    }, 5000);
}
  1. state를 업데이트해도 화면이 바뀌지 않는 경우
    obj.x = 10;  // 🚩 잘못된 방법: 기존 객체를 변경
    setObj(obj); // 🚩 아무것도 하지 않습니다.

이 주의사항은 왜 생기게 된걸까?

 

그 원리를 파헤쳐보기 위해, 브라우저의 렌더링 과정을 살펴보자.

브라우저가 화면을 그리는 원리

먼저 브라우저의 렌더링 엔진은 다음과 같은 과정을 통해 렌더링을 수행한다.

  1. HTML 문서를 DOM 객체로 만든다.
  2. CSS 문서를 CSSOM 객체로 만든다.
  3. 둘을 합쳐서 렌더 트리를 만들어낸다.
  4. 렌더 트리를 바탕으로 레이아웃을 구성한다.
  5. 이제 레이아웃에 cssom 규칙대로 페인팅을 수행한다.

웹 페이지는 브라우저 환경에 독립적으로 동작해야 한다.
해당 DOM을 조작하는 과정이 브라우저의 렌더링 엔진에 종속적이면, 웹을 다루기가 어려웠을 것이다.

 

단순화해서 설명하자면, Web 렌더링 API를 사용하기 위한 표준 언어가 자바스크립트로 채택된 것이다.

그렇게 HTML 문서는 다음과 같이 "DOM 객체"라는 자바스크립트 객체로 변환된다.


Virtual DOM

앞서 렌더링 전 HTML과 렌더링 후 DOM을 살펴볼 수 있었다.
위에서 가진 DOM을 전부 다루고 싶지 않을 수 있다.

 

만약, DOM이 수천 개가 변경된다고 상상해보자.


현재 구조에서는, DOM이 변경되면 브라우저의 렌더링 과정 전체가 반복 동작하게 된다.
이 동작의 범위는 SPA에게는 너무 큰 범위라고 느낄 수 있다.

 

이를 아래 그림같이 변경의 영향 범위를 축소시킬 수 없을까?

이를 반영한 것이 Virtual DOM이다.

Virtual DOM 의 핵심 기능 두 가지를 뽑으라면 다음 두 가지를 뽑을 수 있다.

  • Reconciliation & Diffing
    • React는 UI를 직접 실제 DOM에 조작하지 않고, 먼저 Virtual DOM에서 필요한 변경을 수행한 뒤 한 번에 실제 DOM에 최소한의 변경만 적용한다.
    • React는 이전 렌더링의 Virtual DOM과 새로운 Virtual DOM을 비교(diff)하여 변경된 부분만 찾는다.
    • 이 비교 알고리즘을 Diffing 알고리즘이라고 하며, 효율적으로 트리 구조를 비교하도록 고안되어 있다.
    • 비교 결과 변경된 노드가 식별되면, React는 해당 부분만 실제 DOM에 업데이트한다.
  • Batching
    • 동시에 여러 Virtual DOM이 변경되어야 할 때, React는 바뀐 부분들을 모아서 실제 DOM 조작을 가능한 한 한 번에 처리한다.
    • 이는 불필요한 중간 단계의 레이아웃/페인트를 줄여 브라우저 렌더링 부담을 완화시킬 수 있다.

useState 사용 시 자주 발생하는 대표적 버그 두 가지의 발생 원인

이젠 앞서 다뤘던 두 가지 버그를 설명할 기초 지식이 다져졌다.

  • state를 업데이트했지만 로그에는 계속 이전 값이 표시되는 경우
  • state를 업데이트해도 화면이 바뀌지 않는 경우

1. 이벤트 핸들러 내부의 상태 변경은 왜 동작하지 않을까?

React는 이벤트 핸들러가 끝날 때까지 상태 업데이트를 모아두고, 한 번에 처리한다.
이는 앞서 vDOM 배치 처리에서 살펴봤듯이, 브라우저 입장에서 한 이벤트 처리 동안 여러 번의 레이아웃/페인트를 하지 않도록 막아주는 장치이다.

만약 setState 호출마다 즉시 렌더링이 일어났다면, 빠르게 연속 클릭 시 매번 DOM 업데이트 → 레이아웃 → 페인트가 반복되어 성능이 크게 떨어졌을 것이다.

 

setState는 비동기로 동작한다.(비동기 함수라는 뜻은 아니다.)
1. setState 호출 시, 컴포넌트/Fiber에 연결된 업데이트 큐에 값을 바꿀 것이라는 update를 추가한다.
2. 리액트는 루트에 "업데이트 필요"를 표시하고, 렌더 작업을 스케줄링한다.
그렇게 여러 컴포넌트에 들어있는 업데이트는 배치로 한꺼번에 처리한 후 커밋한다.

 

대신 React는 콜스택에 쌓인 모든 상태 변화를 하나의 렌더링으로 합치는 배치 전략을 택했고, 그 결과 상태 값은 해당 틱이 끝날 때까지 "변경 전 값"으로 유지된다.

 

콘솔에 이전 값이 찍힌 것은 바로 이 때문이며, React가 일관된 상태 스냅샷을 유지하면서 효율을 높인 트레이드오프다.
즉, “이전 값이 찍히는 현상”은 사실 React가 중간 렌더링을 생략한 덕분에 나타나는 의도된 동작이며, 브라우저가 쓸데없이 여러 번 그리지 않도록 하는 최적화 효과와 직결된다.

2. 상태 불변성 위반으로 인한 무갱신

이 경우 React는 새로운 상태와 이전 상태가 동일하다고 판단하여 아예 리렌더링을 건너뛰었다.
React 입장에서는 “변화 없음”이므로 Virtual DOM에도 변화가 없고, 결국 실제 DOM에도 아무 조치가 취해지지 않는다.
따라서 브라우저 측에서는 DOM 갱신 → 레이아웃/페인트 작업 자체가 발생하지 않아서 화면이 그대로이다.
React가 이렇게 동작하는 이유는, 진짜로 내용이 바뀌지 않았을 때 불필요한 렌더링을 하지 않는 것이 옳기 때문이다.

 

다만 개발자가 객체를 직접 수정하는 실수를 하면 React의 변화 감지 최적화(Object.is 비교)가 실제 변화까지 막아버리는 현상이 생긴다.
이 역시 React의 최적화 전략(동일 참조시 업데이트 무시)의 산물이다.

 

 

React는 불변성 패턴을 권장하며, 이는 브라우저 렌더링 최적화와도 연결된다.

  • equals & hashcode와 같은 방식으로 내부 필드의 변경을 감지하는 것은 사용하긴 쉽지만, 성능이 느려진다.
  • Object.is(prev, next) (사실상 O(1)) 같은 얕은 비교는 사용자에게 빠른 화면 전환을 보여주기 적합하다.


useEffect

useState, useContext, useMemo는 비교적 직관적으로 이해되지만, useEffect는 처음 보면 이게 뭔 코드지? 싶다.

useEffecf는 컴포넌트의 상태 전이 시 콜백 함수를 넣어주는 역할을 수행하는 리액트 훅이다.

 

즉, useEffect를 이해하려면

  • 컴포넌트의 상태전이란?
  • 컴포넌트의 상태 전이 시점은?

다음 두 가지를 이해해야 한다.

리액트 컴포넌트의 상태 전이

사실 아래 그림은 리액트 컴포넌트의 생명주기라는 이름으로 더욱 잘 알려져 있다.
하지만 개인적으로 이 그림 내 "상태"로 정의된 요소들은 상태라기보단 "상태 전이 이벤트"에 가깝기 때문에, 상태 전이의 발생 순서 개념을 엮어 "생명주기"라는 용어를 사용하지 않았다.

리액트의 컴포넌트는 크게 다음 세 가지 상태 전이를 수행할 수 있다.

  • Mount
    • 컴포넌트가 탄생하는 순간 진행하는 상태 전이.
    • 화면에 처음으로 렌더링되는 순간 진행하는 상태 전이.
  • Update
    • 컴포넌트가 리렌더링 되는 순간 진행하는 상태 전이.
  • UnMount
    • 컴포넌트가 화면에서 사라지는 순간 진행하는 상태 전이.
    • 컴포넌트가 렌더링에서 제외되는 순간 진행하는 상태 전이.

Mount/Update/Unmount 시점에 발생하는 상세 작업들을 공부해보는 것도 나쁘진 않겠지만, 현재 글의 독자는 "프론트를 직접 구현해보고 싶은 백엔드 개발자" 이기 때문에, useEffect의 개념에 집중해서 설명을 이어나가보도록 하겠다.

useEffect의 구조

  • (콜백함수, 포커싱할 값들 모은 배열→의존성 배열(deps)) 을 매개변수로 갖는다.
  • 포커싱한 값이 바뀔때마다 콜백함수가 수행되게 된다.
  • 콜백함수는 실행할 알고리즘(first) 과 리턴 함수(second) 로 나뉘며,
    • first 부분은 deps 의 값이 변경되었을 때 수행하는 부분을 작성하고,
    • second 부분은 클린업 함수 로, 해당 효과가 다음으로 실행되기 전에 이전 효과를 정리하는 부분을 작성한다.

deps


deps는 '의존성 배열'이라는 의미이다.

deps는 useEffect를 해당 컴포넌트의 특정 필드에 대한 변경이 발생했을 시에만 동작하게 만든다.
deps는 useEffect 파라미터의 람다 메소드를 호출시킬 수 있도록 제어하는 기능을 수행한다.

  • [] : 마운트 1회 + 언마운트 1회(cleanup)
  • [a, b] : (a 또는 b가 변경된 업데이트)마다 cleanup 1회 → effect 1회
  • 생략 : 모든 렌더마다 실행(업데이트 빈도가 높으면 호출 횟수도 그만큼 증가)

useEffect 사용 예시

  • setState 함수는 지연 처리 방식(비동기)으로 동작하기에, State값을 컴포넌트 내에서 내부적으로 사용할 땐 useEffect를 사용하는 것이 좋다.
  • deps 가 비어있을 때
    • first : Mount 를 제어하는 생명주기 제어함수로 동작한다.
    • second(클린업) : unMount를 제어하는 생명주기 제어함수로 동작한다.
  • deps 에 원소가 존재할 떄
    • first : 의존성 값이 변화했을 때 처리하는 함수로 동작한다.
    • second(클린업) : 의존성 값이 변화하기 직전에 처리하는 함수로 동작한다.

개인적으로 리액트를 AI Agent의 도움을 받아가며 사용할 때, 가장 까다로웠던 부분이 다음 두 리액트 훅의 사용이었다.

 

부디 이 내용이 도움이 되었길 바란다.

 

출처