본문 바로가기
개발/리액트

[리액트] useEffect와 useLayoutEffect | 비교시리즈

by 핸디(Handy) 2022. 3. 31.

들어가며

리액트를 사용하다 보면 useEffect와 useLayoutEffect 훅을 마주하곤 합니다.
생긴 것도 비슷하고 실제 공식문서상에서도 둘은 같다고 말합니다. 오히려 useEffect 쓰라고 합니다.

그래도 useLayoutEffect를 만든 이유가 있을테고,
저 또한 깜빡임 관련된 이슈를 수정하기 위해 useLayoutEffect를 사용했습니다.

그래서 이번 글에서는 useEffect와 useLayoutEffect에 대한 설명과 코드 단에선 어떤 차이가 있는지 알아보도록 하겠습니다.

질문 | useEffect 와 useLayoutEffect를 비교해라

useEffect는 랜더링된 이후에 동작하는 hook이고 useLayoutEffect는 랜더링 되기 이전에 동작하는 hook이다.
네 끝났습니다. (하지만 면접을 할때는 모든 지식을 영끌해서 쏟아내야 하므로 좀 더 말해봅시다.)
저의 경우 useLayoutEffect는 Topbar에 유저 로그인관련된 컴포넌트에 적용을 합니다.
유저의 로그인상태에 따라 로그인 버튼 or 유저 아이콘이 랜더링이 됩니다. 이때 로그인 상태를 판별하는 로직은 LocalStorage에서 해당 정보가 있음을 판단하는데 useEffect를 사용하니 깜박임(Flicker)이 생겼습니다. 이를 방지하기 위해 useLayoutEffect를 통해 개선한 경험이 있습니다.

저는 이렇게 답을 했습니다.

그리고 좀더 정확한 답을 위해 이번 기회에 정리를 해봅시다.

useEffect에 대해 알아보자

일단 useEffect는 React에 내장된 Hook입니다. 그리고 Hook은 React 16.8에 추가되었습니다.

React의 Hook이 생기는 이유는 https://ko.reactjs.org/docs/hooks-intro.html#motivation 해당 문서에 좀 더 자세히 나와있는데요.

함수 컴포넌트에서 상태 로직을 재사용하기 위해 생겼다.
그리고 그냥 Class 없이 React를 더 잘 사용하기 위해 나왔다고 기억하시면 될 것 같습니다.

아무튼 우리는 Hook를 통해 React의 복잡한 상태 관련 로직 (componentDidMount, componentDidUpdate) 등에서 벗어나 편리하게 사용할 수 있게 되었습니다.

그렇다면 여러 가지 hook 중에서 useEffect는 어떤 점을 위해 편리함을 위해 생겨났을까요?

바로 Side Effect를 위해 생겨났습니다.

Side Effect의 사전적 의미는 부작용이지만 React 세계에서의 정의는 약간 다릅니다.

공식문서상에 따르면 아래와 같은 것들이 Side Effect라고 하네요.

  1. 데이터 가져오기
  2. 구독 설정하기
  3. 수동으로 Dom을 수정하기

또한 useEffect는 클래스 컴포넌트 기준 componentDidMount와 componentDidUpdate , componentWillUnmount 가 합쳐진 것으로 생각해도 좋다고 합니다.

이제 정의와 목적을 살펴봤으니 예시를 보아야죠. 저는 1번 데이터 가져오기로 예시를 만들어봤습니다.

GuideReviewPage는 이름 그대로 리뷰를 보여주는 페이지입니다.

useEffect 훅 안에서 loadReviews로 데이터를 불러오는 게 보이네요.

해당 페이지는 아래와 같은 순서로 동작합니다

  1. LandingPage가 랜더링 됨
  2. 랜더링이 완료되었으니 useEffect훅이 실행됨
  3. 훅안에서 비동기 요청 처리
  4. 완료되면 reviewList가 업데이트되면서 CustomPage가 랜더링됨

useLayoutEffect에 대해 알아보자

이제 useEffect의 친구? 동생? 같은 느낌의 useLayoutEffect를 살펴보겠습니다.

공식문서상에서는 아래와 같이 요약되어있네요.

네 useEffect를 먼저 사용하라고 하네요. 그만 알아봐도 될 거 같아요 

일반적으로 "동기적"이라는 말은 프로그래밍 세계에서는 부정적인 의미가 강합니다( 제가 느끼기예요 ㅎㅎ)

결국 동기적이라는 말은 어떤 작업이 다른 작업을 기다리는 의존성이 생기는 것이라고 생각합니다. 그래서 우린 자바스크립트에서 비동기 통신을 쓰기 동기통신을 쓰진 않잖아요 ㅋㅋ다시 돌아가서 DOM에서 동기적으로 리랜더링이 일어나면 앞선 작업이 끝나기 전까지 유저는 DOM를 제대로 보지 못합니다. 그래서 비동기적으로 동작하는 useEffect를 먼저 사용하라고 권하는 것입니다. 근데 개발을 하다 보면 원하는 대로 안될 때가 많아요. ( 그래서 리액트 형님들도 useLayoutEffect를 만들어 둔 거겠죠 ) 저는 시작할 때 답변에서 말했듯이 DOM의 깜빡이는 UI를 제거하기 위해 useLayoutEffect를 사용했어요. 그리고 다른 업무에서 데이터를 실시간으로 랜더링해야할 경우, 동기적으로 랜더링할 필요가 있는 데이터의 경우에도 useLayoutEffect를 사용했죠. 이렇게 어딘가에는 쓰이는 훅입니다. 그럼 이제 useEffect와 useLayoutEffect를 좀 더 비교해볼까요?

useEffect와 useLayoutEffect의 차이점

아래는 useEffect vs useLayoutEffect를 구글링 했을 때 나오는 내용들을 추린 겁니다.

요약하자면 useLayoutEffect는 어지간하면 쓰지 마, 그냥 useEffect 써입니다.

React 공식문서

The signature is identical to useEffect, but it fires synchronously after all DOM mutations. Use this to read layout from the DOM and synchronously re-render. Updates scheduled inside useLayoutEffect will be flushed synchronously, before the browser has a chance to paint.

Prefer the standard useEffect when possible to avoid blocking visual updates.

useEffect와 시그니쳐가 동일하지만, DOM 변경 후에 동기적으로 발생한다. 이를 통해 DOM에서 레이아웃을 읽고 동기적으로 리 랜더링 한다. useLayoutEffect 내에서 스케줄된 업데이트는 브라우저가 Paint하기 전에 동기적으로 실행(Flush)된다.

Remix 개발자 Kent C. Dodds

It's all about defaults. The default behavior is to let the browser re-paint based on DOM updates before React runs your code. This means your code won't block the browser and the user sees updates to the DOM sooner. So stick with useEffect() most of the time.

기본 동작은 React가 코드를 실행하기 전에 브라우저가 DOM를 업데이트하도록 하는 것이다. 이것은 코드가 브라우저를 차단하지 않고 사용자가 DOM를 더 빨리 볼수 있도록 한다. 그래서 대부분 useEffect를 사용한다.

stack overflow

While similar to some extent to useEffect() , it differs in that it will run after React has committed updates to the DOM. Used in rare cases when you need to calculate the distance between elements after an update or do other post-update calculations / side-effects.

useEffect와 어느 정도 비슷하지만 React가 DOM에 업데이트를 커밋한 후에 실행된다는 점에서 다르다.
업데이트 후 요소 간 거리를 계산하거나, 업데이트 후 side-effect를 수행해야 하는 경우 등 드문 상황(rare cases)에 사용된다

글을 보니 어느 정도 감이 오시나요?

synchronously, DOM, re-paint 등이 보이는듯하고, prefer useEffect, rare cases, side-effect 등이 눈에 띕니다.

useEffect와 useLayoutEffect의 동작 순서

여기에 유명한 짤이 있습니다. 한 번쯤 보셨을 거 같은데요. Hook flow의 대표 사진입니다.

보시면 Browser paints 전에 LayoutEffect가 실행되고 그 이후에 Effect가 실행되는 것으로 나와있네요.

이 사진을 통해 useEffect와 useLayoutEffect의 차이는 Browser paints screen 전후로 실행되는 순서라고 볼 수도 있겠습니다

여기까지가 useEffect와 useLayoutEffect의 차이점이었습니다.

그렇다면 궁금해집니다. react 코드상에서 어디까지가 비슷하고 어디서부터 차이가 발생하는지, 그래서 이번엔 코드를 한번 깊게 들어가 보겠습니다.

React 코드로 살펴보기

일단 react의 index.d.ts 파일을 살펴보겠습니다.

0. index.d.ts

타입이 똑같은걸 확인할 수 있었습니다. 근데 우린 이미 타입이 같은 것을 알고 있어요. useLayoutEffect 쓸 때 useEffect로 구현해놓은 코드를 Layout부분만 추가해서 사용해봤기 때문입니다.

그럼 이제 react 코드로 들어가 봅시다.

1. react/src/ReactHooks.js

여기서도 볼게 딱히 없네요. 여기 Dispatcher를 따라가도 결국 타입 선언된 파일 뿐입니다.

2. react-reconciler/src/ReactFiberHooks.new.js

이 파일은 old와 new로 이뤄 저 있는데 당연히 최신인 new로 살펴보겠습니다.

코드 중간에 보면 renderWithHooks라는 함수가 있습니다.

renderWithHooks 내부에서 Dispacher 붙이는 코드

그리고 __DEV__ 일 때는 건너뛰고 ReactCurrentDispatcher.current에 삼항 연산자로 HooksDispatcher를 붙이는 걸 확인할 수 있습니다. 

HooksDispatcherOnMount 와 HooksDispatcherOnUpdate

그리고 mountEffect, updateEffect, mountLayoutEffect, updateLayoutEffect가 다른 게 보입니다.

mounEffect 와 mountLayoutEffect

이제 차이점이 조금씩 보이기 시작합니다. 일단 끝에 mountEffectImpl의 1,2번째 인자들의 값이 다릅니다.

mountEffectImpl

mountEffectImpl 내부에서 pushEffect를 호출합니다. 

pushEffect

pushEffect 내부에서 componentUpdateQueue 안에 effect에 따라 넣어줍니다.

그리고 파라미터에 들어가는 순서는 ReactHookEffectTags.js 파일 내부에 number 정해져 있습니다. 

ReactHookEffectTags.js

드디어 Queue에 들어가는 순서가 달라지는 코드를 발견했습니다. 내부 로직을 확인하기 위해 생각보다 먼 길을 돌아왔네요. 

 

그리고 여기서 FunctionComponentUpdateQueue도 한번 살펴보면 좋을듯해서 한 번 더 들어가 보겠습니다.

요건 길어서 코드로 가져왔어요. 여러 가지 값들이 있는데 그중 RE_RENDER_LIMIT = 25라는 값이 보일 겁니다.

export type FunctionComponentUpdateQueue = {|
  lastEffect: Effect | null,
  stores: Array<StoreConsistencyCheck<any>> | null,
|};

type BasicStateAction<S> = (S => S) | S;

type Dispatch<A> = A => void;

// These are set right before calling the component.
let renderLanes: Lanes = NoLanes;
// The work-in-progress fiber. I've named it differently to distinguish it from
// the work-in-progress hook.
let currentlyRenderingFiber: Fiber = (null: any);

// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

// Whether an update was scheduled at any point during the render phase. This
// does not get reset if we do another render pass; only when we're completely
// finished evaluating this component. This is an optimization so we know
// whether we need to clear render phase updates after a throw.
let didScheduleRenderPhaseUpdate: boolean = false;
// Where an update was scheduled only during the current render pass. This
// gets reset after each attempt.
// TODO: Maybe there's some way to consolidate this with
// `didScheduleRenderPhaseUpdate`. Or with `numberOfReRenders`.
let didScheduleRenderPhaseUpdateDuringThisPass: boolean = false;
// Counts the number of useId hooks in this component.
let localIdCounter: number = 0;
// Used for ids that are generated completely client-side (i.e. not during
// hydration). This counter is global, so client ids are not stable across
// render attempts.
let globalClientIdCounter: number = 0;

const RE_RENDER_LIMIT = 25;

// In DEV, this is the name of the currently executing primitive hook
let currentHookNameInDev: ?HookType = null;

// In DEV, this list ensures that hooks are called in the same order between renders.
// The list stores the order of hooks used during the initial render (mount).
// Subsequent renders (updates) reference this list.
let hookTypesDev: Array<HookType> | null = null;
let hookTypesUpdateIndexDev: number = -1;

// In DEV, this tracks whether currently rendering component needs to ignore
// the dependencies for Hooks that need them (e.g. useEffect or useMemo).
// When true, such Hooks will always be "remounted". Only used during hot reload.
let ignorePreviousDependencies: boolean = false;

function mountHookTypesDev() {
  if (__DEV__) {
    const hookName = ((currentHookNameInDev: any): HookType);

    if (hookTypesDev === null) {
      hookTypesDev = [hookName];
    } else {
      hookTypesDev.push(hookName);
    }
  }
}

그렇습니다. 이건 우리가 가끔씩 랜더링 무한루프 들 때 error의 기준이 되는 값입니다.

실제로 renderWithHooks 함수 안에 우리가 많이 보는 error 'Too many re-renders.~~'를 던지도록 코드가 되어있습니다.

 


 

결론

  1. 우선 useEffect를 써라
  2. 동기적인 랜더링, 깜빡임 등에 useLayoutEffect를 제한적으로 고려해봐라
  3. 두 훅의 차이점은 브라우저 페인팅 전후에 따른 실행 순서의 차이다. 
  4. 코드 상의 차이점은 componentUpdateQueue에 들어가는 flag의 차이다.

마치며

react 라이브러리를 사용하는 입장에서 라이브 사이클을 크게 걱정하지 않고 훅을 통해 데이터를 조작하는 것은 매우 편리합니다.

다만 코드레 벨로 갔을 때 어떻게 구별했는지 매번 궁금했는데요.
오늘 출근해서 구현해야 할 기능을 구현하기 위해 코드를 보는데 16개월 과거의 제가 이미 구현을 해놓았더라고요. 그래서 빠르게 react 인터페이스만 뚫었더니 동작을 했다는 기분 좋은 일화가 생겼습니다.

쨋든 예상보다 일이 일찍 끝나서 뭐할까 고민하다가 react 코드 좀 봐보자 싶어서 이번 기회에 궁금했던 2가지를 비교해보았습니다.

Queue 안에서의 정확한 순서를 어떻게 해야 하는지 상세한 것은 더 확인해야겠지만 그래도 flag를 통해 차이를 인지한다는 것을 알았으니 여기서 만족하겠습니다. 

 

댓글