[리액트] 깊이알아보는 mobx의 observer작동원리, useSyncExternalStore를 활용한 동시성 처리 로직
카테고리: react
태그: deep dive
🚀Introduction
mobx는 부동의 1위 redux를 제외하면 꽤 많이 사용되는 상태관리 라이브러리이다. 그럼에도 불구하고 mobx를 사용하다보면 자료가 없어서 곤혹스러울 때가 너무 많다. mobx에 대해 공부하면서 알게된 몇가지 작동원리를 질문답변 형식으로 공유해보고자 한다.
⭐️질문1. mobx는 어떻게작동해요?
observable로 처리된 객체를 observer를 통해 변경사항을 알아차린다.
observable로 감싸진 객체가 observer구독자님들께 “상태 변했어요~~” 하고 알려주는 행태인 것이다.
💁🏻♂️꼬리질문1. observer는 어떻게 바뀜을 알아차리나요?
우리가 유튜브 구독을 하고있으면 새로운 동영상 소식을 유튜브에서 우리에게 알려준다.
즉, observable
이 observer
에게 알려준다.
즉, observe
가능한 객체가 observe
하는놈들 에게 알려준다.
⭐️질문2. observer는 어떻게 사용해요?
아래와 같은 형태로 사용하면 된다.
const 내가만든컴포넌트 = () => {
const ...
const [..., ...] = ...
return <div>{mobx객체}</div>
}
export default observer(내가만든컴포넌트)
물론 아래처럼 작성해도 위와 완전히 동일하게 동작한다.
export default 내가만든컴포넌트 = observer(() => {
const ...
const [..., ...] = ...
return <div>{mobx객체}</div>
})
💁🏻♂️꼬리질문1. observer 는 mobx에서 import해오면 되나요?
깃헙 mobx레포(최신버전)를 보면 5개의 패키지가 함께 있는걸 알 수 있다.
- eslint-plugin-mobx
- mobx-react-lite
- mobx-react
- mobx-undecorate
- mobx
정확히는 mobx에서 import해오는것이 아니다. mobx로 관찰가능한 스토어객체를 정의하고나서, 컴포넌트에서 응당 해당 값을 가져와야한다. mobx는 proxy객체로 정의한 관찰가능한 값 자체이고, 이를 컴포넌트에서 사용하기위해서는 mobx-react를 사용해야한다. 코드들을 검색해보면 다음 두가지 방식으로 가져온다.
import { observer } from "mobx-react-lite"; //1번방식
import { observer } from "mobx-react"; //2번방식
💁🏻♂️꼬리질문2. 두개의 차이가 뭔가요?
위의 두 observer는 통상 작동에 차이가 없다. 그럼 어떤 다른점이 있을까?
이중 먼저 mobx-react
의 observer.ts
를 보자
import * as React from "react"
import { observer as observerLite } from "mobx-react-lite"
import { makeClassComponentObserver } from "./observerClass"
import { IReactComponent } from "./types/IReactComponent"
/**
* Observer function / decorator
*/
export function observer<T extends IReactComponent>(component: T): T {
if (component["isMobxInjector"] === true) {
console.warn(
"Mobx observer: You are trying to use `observer` on a component that already has `inject`. Please apply `observer` before applying `inject`"
)
}
if (
Object.prototype.isPrototypeOf.call(React.Component, component) ||
Object.prototype.isPrototypeOf.call(React.PureComponent, component)
) {
// Class component
return makeClassComponentObserver(component as React.ComponentClass<any, any>) as T
} else {
// Function component
return observerLite(component as React.FunctionComponent<any>) as T
}
}
아래부분을 보면 classComponent
면 makeClassComponentObserver
를, functionComponent
면 (mobx-react-lite에서 import해온)observerLite
를 리턴한다. 즉, 함수형 컴포넌트에 observer를 사용해주면, mobx-react와 mobx-react-lite의 차이점은 전혀 없는 것이다. 2023년 기준으로 프론트엔드개발자라면 당연히 함수형컴포넌트를 사용할것이므로… 실 사용에는 차이가 없다고 보면 된다.
공식문서에는 패키지를 조금 가볍게만들어두었는데… 그게 mobx-react-lite란다~~ 라고 적혀있다.
⭐️질문3. 그렇다면 observer는 어떻게 동작하나요?
오늘 글의 하이라이트다. mobx-react-lite에서의 observer
에대해 살펴보도록 하겠다.
168줄짜리 observer.ts파일은 여기에서 볼 수 있다. 해당 내용을 보면 결국 다음처럼 useObserver를 사용해서 컴포넌트를 mobx관찰자로 컴포넌트를 감싸고 있다. 추후 기술할 observable로 만들어진 proxy객체의 구독자(관찰자)로 만들어주는것이다. observer.ts파일은 결국 프록시기반 mobx객체를 컴포넌트에 반응성있게 연결해주는 부분이다. 그리고 useObserver는 실제 구독자들이 컴포넌트 변화에 따라 리렌더링되게 해주는 부분이다. 프록시 작동 원리에 대해서는 여기에서 확인할 수 있다.
export function useObserver<T>(render: () => T, baseComponentName: string = "observed"): T {
if (isUsingStaticRendering()) {
return render()
}
const admRef = React.useRef<ObserverAdministration | null>(null)
// Provides ability to force component update without changing state version
const [, forceUpdate] = React.useState<Symbol>()
if (!admRef.current) {
// First render
const adm: ObserverAdministration = {
reaction: null,
onStoreChange: null,
stateVersion: Symbol(),
name: baseComponentName,
subscribe(onStoreChange: () => void) {
// Do NOT access admRef here!
observerFinalizationRegistry.unregister(adm)
adm.onStoreChange = onStoreChange
if (!adm.reaction) {
// We've lost our reaction and therefore all subscriptions, occurs when:
// 1. Timer based finalization registry disposed reaction before component mounted.
// 2. React "re-mounts" same component without calling render in between (typically <StrictMode>).
// We have to recreate reaction and schedule re-render to recreate subscriptions,
// even if state did not change.
createReaction(adm)
// `onStoreChange` won't force update if subsequent `getSnapshot` returns same value.
forceUpdate(Symbol())
}
return () => {
// Do NOT access admRef here!
adm.onStoreChange = null
adm.reaction?.dispose()
adm.reaction = null
}
},
getSnapshot() {
// Do NOT access admRef here!
return globalStateVersionIsAvailable
? _getGlobalState().stateVersion
: adm.stateVersion
}
}
admRef.current = adm
}
const adm = admRef.current!
if (!adm.reaction) {
// First render or reaction was disposed by registry before subscribe
createReaction(adm)
// StrictMode/ConcurrentMode/Suspense may mean that our component is
// rendered and abandoned multiple times, so we need to track leaked
// Reactions.
observerFinalizationRegistry.register(admRef, adm, adm)
}
React.useDebugValue(adm.reaction!, printDebugValue)
useSyncExternalStore(
// Both of these must be stable, otherwise it would keep resubscribing every render.
adm.subscribe,
adm.getSnapshot,
getServerSnapshot
)
// render the original component, but have the
// reaction track the observables, so that rendering
// can be invalidated (see above) once a dependency changes
let renderResult!: T
let exception
adm.reaction!.track(() => {
try {
renderResult = render()
} catch (e) {
exception = e
}
})
if (exception) {
throw exception // re-throw any exceptions caught during rendering
}
return renderResult
}
useObserver부분만 가져왔다. 위아래를 다 쳐냈음에도 꽤나 길어보인다. 그럼에도 불구하고 2가지만 주목하면 된다.
바로 adm
과 forceUpdate
이다. adm은 mobx로 관리하고 있는 객체이고, forceUpdate는 useState를 이용해서 만든 상태변경함수이다.
forceUpdate
를 이용해서 컴포넌트를 강제로 리렌더링 시켜주고있다. 그렇다면 스토어의 이전값에서 새로운 값으로 직접 바꾸어주는 부분이 어디인지 궁금할수도 있다.
하지만 생각해보면 찾을 수 없음을 알 수 있다.
우리가 보고있는 useObserver는 observer의 내부로직이다. observer
의 뜻이 무엇인가? 그저 관찰할 뿐인 관찰자이다. 프로토스 옵저버를 떠올려보면 아무것도 못하고 그저 무엇인가를 관음할 뿐이다. 그렇다. 값을 직접적으로 바꾸어주지 않고, 리렌더링 시켜줄 뿐이다. 따라서 결국 observer는 값이 바뀌면 내부적으로 useState를 사용해서 컴포넌트를 강제로 리렌더링 시켜주는 역할을 하는 함수인 것이다. 그저 리렌더링을 해주는것이다. 구독자들에게 알람이 오면 그저 유튜브를 열어볼 뿐이다. 무엇이 어떻게 바뀌었는지는 모르지만 그저… 앱을 열어보도록 트리거를 시켜줄 뿐이다.
이처럼 observer는 관리대상이 바뀌었음을 인지하고 컴포넌트를 강제로 리렌더링 시켜주는 방식으로 작동한다.
(코드원본은 여기에서 볼 수 있다)
💁🏻♂️꼬리질문1. useSyncExternalStore는 뭔가요? 처음들어보는 훅인데..
써드파티 라이브러리를 사용할 때, tearing 이슈를 막아주는 함수이다. react 18부터 제공되는 기능이다. 리액트 18에서 concurrent기능을 도입했다. 따라서 화면에 보이는것이 현재 상태가 아닐수도 있다. 극히 드물게, 화면이 최신의 상태를 반영하지 못할수도 있는 경우가 생길수도 있는데 이를 tearing이슈라고 한다.
💁🏻♂️꼬리질문2. tearing이슈가 뭔가요?
tearing과 관련된 이슈를 검색해보면 대부분이 여기읽어보라고 한다. 요약하자면, 결국 극히 제한적인 상황에서 UI가 현재 상태를 반영하지 못하는 상황이 있는데 이를 tearing이슈가 있다고 한다.
우리가 생각하는 “눈물”의 “tear”가 아니다. “찢는”이라는 뜻의 “tearing”이라는 형용사이다.
게임을 하다가 화면이 이렇게 찢어질 때, tearing 이슈가 있다고 한다. 통상 gpu에서 그래픽을 순차적이지 않은, 병렬처리의 결과값을 모니터로 쏴주는데, 이때 모니터에서 frame의 싱크를 잘 맞춰주지 못하면 이와같은 현상이 발생한다. 그래서 모니터에 sync어쩌고 기능 하면서 모니터의 성능을 광고하곤 하는것이다.
그렇다 모니터처럼 react에서도 sync
를 잘 맞춰주는것이 중요하다. 하지만 react 18에서 concurrent모드를 지원하기 시작하면서 “순차적이지 않게” 상태를 업데이트해주는 경우가 생기곤 한다.
useState로 정의된 상태들이라면, react에서 내부적으로 잘 처리를 해두었지만 외부 상태관리는 그렇지 않다. 모니터에서 sync
를 맞춰주듯이, 외부라이브러리의 상태, 즉 state
의 sync
를 맞춰주기 위해 나온것이 바로 이 useSyncExternalStore
인 것이다.
참고로 여기서는 리액트의 것을 import해서 사용하지 않는다. React.useSyncExternalStore가 아니다. 아래처럼 shim을 가져와서 사용한다.
import { useSyncExternalStore } from "use-sync-external-store/shim"
💁🏻♂️꼬리질문3. …? shim이 뭔가요?
shim
에대해 찾아보면 소프트웨어에서 어떤 API를 투명하게 가로챈다, 불안정한 라이브러리의 지원을 해준다, 등 다양한 말이 있다.
그러나 본질은 간단하다. 그냥 완전히 똑같이 작동하는데 따로 빼둔거라고 생각하면 된다. npmPackage 에서는 다음과 같이 설명한다.
Backwards-compatible shim for React.useSyncExternalStore.
그렇다 그냥 리액트 버전 상관없이 잘 작동하도록 라이브러리의 일부를 따로 똑 떼어둔거다. 참고로 shim의 사전적 의미는 끼움쇠, 두개 사이에 연결해주는 무언가, 뭐 이런뜻이다.
💁🏻♂️꼬리질문4. 그렇다면 결국 useObserver는 어떻게 동작하나요?
조금 힘들더라도, 위의 길다란 코드를 다시 바라보자. adm
과 forceUpdate
에만 초점을 맞춰서 읽으면 마냥 읽기어렵지는 않다.
stateVersion은 항산 Symbol()로 들어온다. (자바스크립트 딥다이브 스터디를 하며, 절대 볼일 없을 것 같은 Symbol을 여기서 보게되어 내심 기뻤다ㅎㅎ)
Symbol을 통해 새로운 버전인지 아닌지에 대해 구분을 할 수 있다. getSnapshot()에서 현재 상태의 버저닝을 하고 이를 useSyncExternalStore로 전달해준다. 즉, tearing이슈가 생기지 않도록 상태를 sync시켜주는 작업인 것이다.
모니터에서 tearing이 생길때는 현재 화면(프레임)이 몇번째인지 sync가 맞지 않아서 생기는 것이다. 1236번째 화면과 1237번째 프레임이 같이 출력되어버리는 것이다. 리액트에서도 0.2초전의 state와 현재의 state가 화면에 같이 나오는 문제가 발생할 수 있는 것이다. 따라서 Symbol을 통해 상태를 versioning하고 이를 useSyncExternalStore를 통해 이를 일치시켜 주는 것이다. 역으로 생각해보면, 당연하게도 이는 stateVersion이 바뀌지 않는다면 리렌더링이 일어나지 않음을 의미하기도 한다.
💁🏻♂️꼬리질문5. useObserver는 어떻게 symbol을 비교하는거죠?
useObserver내부에 createReaction()이 있는걸 볼 수있다.
그리고 그 내부는 mobx의 Reaction으로 되어있다
import { Reaction} from "mobx"
function createReaction(adm: ObserverAdministration) {
adm.reaction = new Reaction(`observer${adm.name}`, () => {
if (!globalStateVersionIsAvailable) {
// BC
adm.stateVersion = Symbol()
}
// onStoreChange won't be available until the component "mounts".
// If state changes in between initial render and mount,
// `useSyncExternalStore` should handle that by checking the state version and issuing update.
adm.onStoreChange?.()
})
}
그렇다. mobx의 핵심에는 결국 reaction이 있었던 것이다. mobx에서 사용되는 observer
, reaction
, observable
, autoRun
모두 Reaction을 기반으로 만들어진 것이다. 저번글(당신의 useEffect가 mobx와 작동하지 않는 이유)에서도 reaction
을 이용해서 강제로 상태변화를 감지하도록 하는데 이것도 Reaction
으로 만들어진 것이다.
observer는 결국 useObserver를 통해 컴포넌트와 mobx객체를 연결시키고, useObserver에서는 Reaction
을 활용해서 특정 값의 변경을 감지하고 Symbol()을 업데이트 해주는 것이다. Symbol()이 바뀌게 되면, 내부적으로 useState를 통해 강제로 리렌더링해주며, 이 때 tearing이슈가 발생하지 않도록 스냅샷을 찍어 store변경의 sync를 맞춰주는 것이다.
하나의 컴포넌트 자체를 mobxStore에 대해 반응하도록 만들기 위해 이처럼 복잡하게 감싸고 또 감싼 형태를 가지고 있는 것이다. 하지만, 단일 값을 감지하고, 그에대한 사이드이펙트를 만들어내는 autorun
과 reaction
은 말하지 않아도 어떻게 동작하는지 쉽게 유추할 수 있으리라 생각한다. 훨씬 더 간단한 형태인 것이다.
⭐️정리
오늘은 observer를 통해 mobx객체를 구독하는방법에 대해 알아보았다. 정확히 말하자면 직접적으로 구독자 목록에 넣어주는 observable이 이 역할을 수행한다고 볼 수 있겠다. 무튼, observer의 내부는 useObserver로 구현되어있으며, tearing이슈를 방지하기 위해 내부적으로 useSyncExternalStore가 구성되어있다. state마다 version을 snapShot으로 찍어 useSyncExternalStore로 전달해주는 형식인 것이다
댓글 남기기