setState는 어떻게 무엇을 해야 할지 알까?

I
Inkyu Oh

Front-End2025.11.19

overreacted 블로그 포스트 번역


컴포넌트에서 setState를 호출할 때 어떤 일이 일어난다고 생각하시나요?
import React from 'react';
import ReactDOM from 'react-dom';

class Button extends React.Component {
constructor(props) {
super(props);
this.state = { clicked: false };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState({ clicked: true });
}
render() {
if (this.state.clicked) {
return <h1>Thanks</h1>;
}
return (
<button onClick={this.handleClick}>
Click me!
</button>
);
}
}

ReactDOM.render(<Button />, document.getElementById('container'));
당연히 React는 다음 상태인 { clicked: true }로 컴포넌트를 다시 렌더링하고, 반환된 <h1>Thanks</h1> 엘리먼트와 일치하도록 DOM을 업데이트합니다.
간단해 보이네요. 하지만 잠깐, 이것을 React가 하는 걸까요? 아니면 React DOM이 하는 걸까요?
DOM을 업데이트하는 것은 React DOM이 담당할 일처럼 들립니다. 하지만 저희는 this.setState()를 호출하고 있지, React DOM의 무언가를 호출하는 게 아닙니다. 그리고 저희의 React.Component 기본 클래스는 React 자체 내부에 정의되어 있습니다.
그렇다면 React.Component 내부의 setState()는 어떻게 DOM을 업데이트할 수 있을까요?
Disclaimer: 이 블로그의 대부분의 다른 포스트들처럼, 저희는 실제로 React를 생산적으로 사용하기 위해 이 모든 것을 알 필요는 없습니다. 이 포스트는 커튼 뒤에 무엇이 있는지 보고 싶어 하는 분들을 위한 것입니다. Optional한 내용입니다!


저희는 React.Component 클래스가 DOM 업데이트 로직을 포함하고 있다고 생각할 수 있습니다.
하지만 만약 그렇다면, this.setState()가 다른 환경에서는 어떻게 작동할까요? 예를 들어, React Native 앱의 컴포넌트들도 React.Component를 확장합니다. 위에서 한 것처럼 this.setState()를 호출하지만, React Native는 DOM 대신 Android와 iOS 네이티브 뷰와 함께 작동합니다.
저희는 또한 React Test Renderer나 Shallow Renderer에 익숙할 수도 있습니다. 이 두 테스팅 전략 모두 일반 컴포넌트를 렌더링하고 그 안에서 this.setState()를 호출할 수 있게 해줍니다. 하지만 둘 다 DOM과는 작동하지 않습니다.
React ART와 같은 렌더러를 사용했다면, 페이지에서 하나 이상의 렌더러를 사용할 수 있다는 것을 알 수도 있습니다. (예를 들어, ART 컴포넌트는 React DOM 트리 내부에서 작동합니다.) 이는 전역 플래그나 변수를 사용할 수 없게 만듭니다.
따라서 어떻게든 React.Component는 상태 업데이트 처리를 플랫폼별 코드에 위임합니다. 이것이 어떻게 일어나는지 이해하기 전에, 패키지가 어떻게 분리되어 있는지, 그리고 왜 그런지 더 깊이 파고들어 봅시다.


React "엔진"이 react 패키지 내부에 있다는 일반적인 오해가 있습니다. 이것은 사실이 아닙니다.
실제로 React 0.14의 패키지 분할 이후로, react 패키지는 의도적으로 컴포넌트를 정의하기 위한 API만 노출합니다. React의 대부분의 구현은 "렌더러"에 있습니다.
react-dom, react-dom/server, react-native, react-test-renderer, react-art는 렌더러의 몇 가지 예입니다 (그리고 저희는 자신만의 렌더러를 만들 수 있습니다).
이것이 react 패키지가 어떤 플랫폼을 대상으로 하든 유용한 이유입니다. React.Component, React.createElement, React.Children 유틸리티 및 (결국) Hooks와 같은 모든 내보내기는 대상 플랫폼과 무관합니다. React DOM, React DOM Server 또는 React Native를 실행하든, 저희의 컴포넌트는 동일한 방식으로 이들을 가져오고 사용합니다.
반대로, 렌더러 패키지는 ReactDOM.render()와 같은 플랫폼별 API를 노출하여 React 계층을 DOM 노드에 마운트할 수 있게 해줍니다. 각 렌더러는 이와 같은 API를 제공합니다. 이상적으로, 대부분의 컴포넌트는 렌더러에서 아무것도 가져올 필요가 없어야 합니다. 이는 이들을 더 이식 가능하게 유지합니다.
대부분의 사람들이 React "엔진"이라고 상상하는 것은 각 개별 렌더러 내부에 있습니다. 많은 렌더러는 동일한 코드의 복사본을 포함합니다 — 저희는 이를 "reconciler"라고 부릅니다. 빌드 단계는 reconciler 코드를 렌더러 코드와 함께 더 나은 성능을 위해 단일의 고도로 최적화된 번들로 압축합니다. (코드 복사는 일반적으로 번들 크기에 좋지 않지만, React 사용자의 대다수는 react-dom과 같이 한 번에 하나의 렌더러만 필요합니다.)
여기서의 핵심은 react 패키지가 저희에게 React 기능을 사용하게만 해주지만, 이들이 어떻게 구현되는지는 알지 못한다는 것입니다. 렌더러 패키지 (react-dom, react-native 등)는 React 기능과 플랫폼별 로직의 구현을 제공합니다. 그 코드의 일부는 공유됩니다 ("reconciler")이지만 이는 개별 렌더러의 구현 세부 사항입니다.


이제 저희는 새로운 기능을 위해 reactreact-dom 패키지 모두를 업데이트해야 하는 이유를 알고 있습니다. 예를 들어, React 16.3이 Context API를 추가했을 때, React.createContext()는 React 패키지에 노출되었습니다.
하지만 React.createContext()는 실제로 context 기능을 구현하지 않습니다. 구현은 React DOM과 React DOM Server 사이에서 다를 필요가 있습니다. 따라서 createContext()는 몇 가지 평범한 객체를 반환합니다:
// 약간 단순화됨
function createContext(defaultValue) {
let context = {
_currentValue: defaultValue,
Provider: null,
Consumer: null
};
context.Provider = {
$$typeof: Symbol.for('react.provider'),
_context: context
};
context.Consumer = {
$$typeof: Symbol.for('react.context'),
_context: context,
};
return context;
}
코드에서 <MyContext.Provider> 또는 <MyContext.Consumer>를 사용할 때, 이들을 처리하는 방법을 결정하는 것은 렌더러입니다. React DOM은 한 가지 방식으로 context 값을 추적할 수 있지만, React DOM Server는 다르게 할 수 있습니다.
따라서 react를 16.3+로 업데이트했지만 react-dom을 업데이트하지 않았다면, 특수한 ProviderConsumer 타입을 아직 인식하지 못하는 렌더러를 사용하게 됩니다. 이것이 이전 react-dom이 타입들이 유효하지 않다고 말하며 실패하는 이유입니다.
동일한 주의 사항이 React Native에도 적용됩니다. 하지만 React DOM과 달리, React 릴리스가 즉시 React Native 릴리스를 "강제"하지는 않습니다. 이들은 독립적인 릴리스 일정을 가지고 있습니다. 업데이트된 렌더러 코드는 몇 주마다 한 번씩 React Native 저장소에 별도로 동기화됩니다. 이것이 기능이 React DOM과 다른 일정에 따라 React Native에서 사용 가능해지는 이유입니다.


좋습니다. 이제 저희는 react 패키지가 흥미로운 것을 포함하지 않으며, 구현이 react-dom, react-native 등과 같은 렌더러에 있다는 것을 알고 있습니다. 하지만 이것이 저희의 질문에 답하지는 않습니다. React.Component 내부의 setState()는 어떻게 올바른 렌더러와 "대화"할까요?
답은 모든 렌더러가 생성된 클래스에 특수한 필드를 설정한다는 것입니다. 이 필드는 updater라고 불립니다. 이것은 저희가 설정할 무언가가 아닙니다 — 오히려 React DOM, React DOM Server 또는 React Native가 저희 클래스의 인스턴스를 생성한 직후에 설정하는 것입니다:
// React DOM 내부
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMUpdater;

// React DOM Server 내부
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactDOMServerUpdater;

// React Native 내부
const inst = new YourComponent();
inst.props = props;
inst.updater = ReactNativeUpdater;
React.ComponentsetState 구현을 보면, 모든 것이 이 컴포넌트 인스턴스를 생성한 렌더러에 작업을 위임하는 것입니다:
// 약간 단순화됨
setState(partialState, callback) {
// 렌더러와 다시 대화하기 위해 `updater` 필드를 사용합니다!
this.updater.enqueueSetState(this, partialState, callback);
}
React DOM Server는 상태 업데이트를 무시하고 경고하고 싶을 수 있지만, React DOM과 React Native는 이들의 reconciler 복사본이 이를 처리하도록 할 것입니다.
그리고 이것이 this.setState()가 React 패키지에 정의되어 있음에도 불구하고 DOM을 업데이트할 수 있는 비법입니다. 이것은 this.updater를 읽으며, 이는 React DOM에 의해 설정되었고, React DOM이 업데이트를 예약하고 처리하도록 합니다.


우리는 이제 클래스에 대해 알고 있지만, Hooks는 어떨까요?
사람들이 처음 Hooks 제안 API를 볼 때, 종종 궁금해합니다: useState는 어떻게 "무엇을 해야 할지 알까"? 보통 사람들의 생각은 기본 React.Component 클래스의 this.setState()보다 더 "마법 같다"는 것입니다.
하지만 오늘 저희가 본 것처럼, 기본 클래스 setState() 구현은 처음부터 환상이었습니다. 이것은 현재 렌더러에 호출을 전달하는 것 외에는 아무것도 하지 않습니다. 그리고 useState Hook은 정확히 동일한 일을 합니다.
updater 필드 대신, Hooks는 "dispatcher" 객체를 사용합니다. React.useState(), React.useEffect() 또는 다른 내장 Hook을 호출할 때, 이 호출들은 현재 dispatcher로 전달됩니다.
// React에서 (약간 단순화됨)
const React = {
// 실제 속성은 조금 더 깊이 숨겨져 있습니다. 찾을 수 있는지 봅시다!
__currentDispatcher: null,

useState(initialState) {
return React.__currentDispatcher.useState(initialState);
},

useEffect(initialState) {
return React.__currentDispatcher.useEffect(initialState);
},
// ...
};
그리고 개별 렌더러는 저희의 컴포넌트를 렌더링하기 전에 dispatcher를 설정합니다:
// React DOM에서
const prevDispatcher = React.__currentDispatcher;
React.__currentDispatcher = ReactDOMDispatcher;
let result;
try {
result = YourComponent(props);
} finally {
// 이를 다시 복원합니다
React.__currentDispatcher = prevDispatcher;
}
예를 들어, React DOM Server 구현은 여기에 있고, React DOM과 React Native에 의해 공유되는 reconciler 구현은 여기에 있습니다.
이것이 react-dom과 같은 렌더러가 저희가 Hooks를 호출하는 동일한 react 패키지에 접근해야 하는 이유입니다. 그렇지 않으면, 저희의 컴포넌트가 dispatcher를 "보지" 못할 것입니다! 이는 동일한 컴포넌트 트리에 React의 여러 복사본이 있을 때 작동하지 않을 수 있습니다. 하지만 이는 항상 모호한 버그로 이어졌으므로 Hooks는 저희가 비용을 치르기 전에 패키지 중복을 해결하도록 강제합니다.
저희는 이를 권장하지 않지만, 고급 도구 사용 사례를 위해 기술적으로 dispatcher를 직접 재정의할 수 있습니다. (__currentDispatcher 이름에 대해 거짓말을 했지만 React 저장소에서 실제 이름을 찾을 수 있습니다.) 예를 들어, React DevTools는 JavaScript 스택 추적을 캡처하여 Hooks 트리를 내부 검사하기 위해 특수 목적의 dispatcher를 사용합니다. Don’t repeat this at home.
이는 또한 Hooks가 본질적으로 React에 연결되어 있지 않다는 것을 의미합니다. 미래에 더 많은 라이브러리가 동일한 기본 Hooks를 재사용하고 싶다면, 이론상 dispatcher는 별도의 패키지로 이동하고 덜 "무서운" 이름으로 첫 번째 클래스 API로 노출될 수 있습니다. 실제로, 저희는 필요할 때까지 조기 추상화를 피하는 것을 선호합니다.
updater 필드와 __currentDispatcher 객체 모두 의존성 주입이라고 불리는 일반적인 프로그래밍 원칙의 형태입니다. 두 경우 모두, 렌더러는 setState와 같은 기능의 구현을 일반 React 패키지에 "주입"하여 저희의 컴포넌트를 더 선언적으로 유지합니다.
React를 사용할 때 이것이 어떻게 작동하는지 생각할 필요는 없습니다. 저희는 React 사용자들이 의존성 주입과 같은 추상적인 개념보다 저희의 애플리케이션 코드에 더 많은 시간을 보냈으면 좋겠습니다. 하지만 this.setState() 또는 useState()가 어떻게 무엇을 해야 할지 아는지 궁금해하셨다면, 이것이 도움이 되기를 바랍니다.

0
7

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!