Inflearn brand logo image

인프런 커뮤니티 질문&답변

이레님의 프로필 이미지
이레

작성한 질문수

[React / VanillaJS] UI 요소 직접 만들기 Part 1

Tooltip-useSyncExternalStore()에 함수 넘기는 방식에 대해...

해결된 질문

작성

·

116

·

수정됨

0

안녕하세요!

항상 유익한 강의를 제공해 주셔서 감사드리며,

강의를 통해 UI를 다양한 방법으로 구현하는 법과
요구 사항의 디테일한 부분을 어떻게 처리해야 하는지에 대해 많은 것을 배우고 있습니다.

조심스럽게 여쭤보고 싶은 점이 있어 글을 남깁니다.

혹시 시간이 괜찮으시면 선생님의 고견을 듣고 싶습니다.

 

- tooltip의 react 버전

  1. viewportContext.tsx에서 useSyncExternalStore()사용부분


    useSyncExternalStore를 통해 외부요소의 변화를 구독할 수 있게,
    첫번째 인자로 subscribe를 넘기고
    해당 상태를 컴포넌트와 동기화 할 수 있도록 두번째 인자로 getViewportRect를 넘기고 있습니다.

    첫번째 인자인 subscribe는 함수의 참조를 넘기는데,
    두번째 인자인 getViewportRect는 "getViewportRect()"로 값을 넘기고 있어,
    이 부분에서 조금 의문이 생겨 아래와 같이 수정하면 어떨까 생각했습니다.

    useSyncExternalStore의 두번째 인자에도 함수의 참조를 넘기도록 수정하여

    react에게 제어권을 넘기고,
    getViewportRect에서 리턴된 함수는 stored값을 기억할 수 있도록 즉시실행함수로 만드는 방식입니다.

const getViewportRect = (() => {
  let stored: Rect = DefaultRect; 
  return () => {
    const el = typeof document !== 'undefined' && document.scrollingElement;
    if (!el) return stored;
    const { left, top, width, height } = el.getBoundingClientRect();
    const newRect = { left, top, width, height, scrollHeight: el.scrollHeight };
    if (newRect && !isSameRect(stored, newRect)) stored = newRect;
    return stored;
  };
})(); //1. 즉시실행함수로 수정


const ViewportContextProvider = ({ children }: { children: ReactNode }) => {

  const viewportRect = useSyncExternalStore(subscribe, getViewportRect); //2.두 인자 모두 참조만 넘기도록 수정
  return (
    <ViewportContext.Provider value={viewportRect}>
      {children}
    </ViewportContext.Provider>
  );
};

이렇게 수정하는 것이 더 나은 방법일지 여쭙고 싶습니다.

 

 

 

  1. useStyleView.ts에서 useLayoutEffect의 의존성배열에 참조 자료형 넣는 것

     

    viewportRect가 객체인데, 이를 의존성배열에 그대로 넣는것이 괜찮은지 궁금합니다.

     const viewportRect = useViewportRect(); //객체
    
    useLayoutEffect(() => {
     ...
      }, [viewportRect, wrapperRef, targetRef, position]);
    

    viewportRect에서 개별 값만 분리해서 넣는 방법도 고려해 보았습니다.

      const { top, left, width, height } = useViewportRect(); //생각해본 버전
    
    useLayoutEffect(() => {
       ...
      }, [top, left, width, height, wrapperRef, targetRef, position]);
    

     



    강의자료에 완성코드가 있다고 하는 걸 어디서 본 것도 같은데...
    제가 어디있는지 찾지를 못해서...🥲
    강의 내용만 보고 작성한 코드임을 양해 부탁드립니다. 🙇‍♀

     

     


     

 


답변 3

0

정재남님의 프로필 이미지
정재남
지식공유자

useSyncExternalStore의 두번째 인자(getViewportRect)를 참조로 바꾸고자 했던 사유는
함수의 실행값으로 인자를 넘기게 되면 리렌더시 매번 새로운 클로저 함수가 생성될테니(=stored 변수가 계속 초기화 될테니) 생각하지 못한 sideeffect가 있지 않을까? 해서 였습니다.

아뇨, 처음 컴포넌트 렌더링 시에 한 번만 실행됩니다. 이는 useState에 전달하는 initialState에 '함수'를 전달하는 경우와 비슷한 맥락으로 이해하시면 됩니다.

https://ko.react.dev/reference/react/useState#parameters

스크린샷 2025-04-30 오후 3.39.22.png

 

이레님의 프로필 이미지
이레
질문자

음...그럼 getViewportRect()와 같이 값을 넘겨서 일부러 리렌더링을 막는 효과를 내는 것이 의도였을까요?

제가 useSyncExternalStore() 동작방식을 잘 못 알고 있는건지,
제가 강의 코드를 잘 못 이해한 부분이 있는건지,
번거로우시겠지만, 아래 내용 한번만 더 확인 부탁드리겠습니다. 🙇‍♀

제가 알기로 useSyncExternalStore의 동작 방식은...
1. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)에서
getSnapshot은 렌더링전에 값 계산해서 초기 렌더링시 싱크맞춰서 적용됨
2. 이후 subscribe가 외부상태변화 구독하다가 scroll, resize와 같은 이벤트 발생하면 callback()실행.
3. callback을 받은 react는 getSnapshot을 다시 호출하여 이전값과 비교하고,
- 이전vs현재값이 변경되었으면 리렌더링 발생
- 이전vs현재값이 동일하면 아무일 없음
4. 세번째 인자값(getServerSnapshot)은 서버사이드렌더링때 초기값 설정으로 작동.


excerpt) https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberHooks.js


function mountSyncExternalStore<T>( //1. 초기 렌더링시 실행
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
 ...
    }
function updateSyncExternalStore<T>( //2. 구독의 callback받았을 때 실행
      subscribe: (() => void) => () => void,
      getSnapshot: () => T,
      getServerSnapshot?: () => T,
    ): T {
      const fiber = currentlyRenderingFiber;
      const hook = updateWorkInProgressHook();
      let nextSnapshot;

      const isHydrating = getIsHydrating();
      if (isHydrating) {
      ...
        nextSnapshot = getServerSnapshot();
      } else {
        nextSnapshot = getSnapshot(); //현재 업데이트값 가져옴
       ...
      }
      const prevSnapshot = (currentHook || hook).memoizedState; //이전getSnapshot값
      const snapshotChanged = !is(prevSnapshot, nextSnapshot); //이전-현재값비교      
        if (snapshotChanged) {
            hook.memoizedState = nextSnapshot;
            markWorkInProgressReceivedUpdate(); //리렌더링 예약
          }
      const inst = hook.queue;
    
      updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
        subscribe,
      ]);
    


function subscribeToStore<T>(
  fiber: Fiber,
  inst: StoreInstance<T>,
  subscribe: (() => void) => () => void,
): any {
  const handleStoreChange = () => {
    // The store changed. Check if the snapshot changed since the last time we
    // read from the store.
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      startUpdateTimerByLane(SyncLane, 'updateSyncExternalStore()');
      forceStoreRerender(fiber);
    }
  };
  // Subscribe to the store and return a clean-up function.
  return subscribe(handleStoreChange);
}

 

react의 useSync...()는 두가지 방식 모두 리렌더링을 해야되는지 확인을 위해,
2번째 인자를 먼저 실행해보고 최신 값으로 갱신해주기 때문에
값을 넘겨도, 참조를 넘겨도 둘 다 동일하게 잘 동작 합니다.


그래서 ... ㅎㅎ 이렇게까지 질문할 일은 아니었던 것 같지만...ㅎㅎㅎ;;;;

제가 질문했던 의도는 stored 변수가 리렌더마다 초기화 될 가능성, 그래서 의도한대로 동작하지 않을 수 있다는 생각이 들어서였습니다.

강의 코드대로라면 클로저로 작성했지만 stored가 초기화 될 수 있고, 이에 대한 오버헤드가 아주 조금 더 들지 않을까... 하는 생각이 들어 질문하게 되었고,

동작에 유의미한 차이는 전혀 없다고 생각됩니다.







실험) stored는 정말 초기화 되는가?
stored와 같은 스코프를 가지도록 count 변수를 만들고, count++해서 출력해보았을 때...

ㄱ.즉시실행함수로 바꾸고 useSync...()의 인자로 참조를 넘길 때

const getViewportRect = (() => {  //즉시실행함수
  let stored: Rect = DefaultRect;
  let count: number = 0;
  return () => {
    ....

    count++;
    console.log(`${count}번째 랜더링중 top:${stored.top}`);
    return stored;
  };
})();

useSyncExternalStore(subscribe, getViewportRect);//참조


ㄴ. 클로저 함수 그대로, useSync..()의 인자로 함수 실행 값 넘길 때

const getViewportRect = () => { //클로저 함수 그대로
  let stored: Rect = DefaultRect; 
  let count: number = 0;
  return () => {
     .... 
    count++;
    console.log(`${count}번째 랜더링중 top:${stored.top}`);
    return stored;
  };
};
useSyncExternalStore(subscribe, getViewportRect()); //실행값넘기기



ㄱ. count++가 누적 연산이 되고, 뷰포트 연산값도 잘 계산해서 가져옴

스크린샷 2025-05-06 오후 9.32.24.png.webp



ㄴ. count++가 1,2,3,4이후 초기화 되어 1,2,3,4로 반복됨.
하지만 뷰포트 연산값은 위와 동일하게 잘 가져옴

스크린샷 2025-05-06 오후 9.30.59.png.webp




제가 잘 못 이해하고 잘 못 생각한 지점이 있는지 확인부탁드립니다. 🙏








정재남님의 프로필 이미지
정재남
지식공유자

말씀하신 내용 읽고 저도 다시 테스트해보니, 말씀하신게 맞네요.
제가 틀렸습니다.
안이하게 '당연히 그렇다'고 생각하고 깊이 고민하지 않은 채 답변 한 점 사과드립니다. (_ _)

리렌더링을 최소화하기 위해서는 처음부터 '완성된 함수'를 전달하는 것이 맞겠네요.

덕분에 작동 원리를 조금 더 이해할 수 있게 되었네요. 감사합니다!

이레님의 프로필 이미지
이레
질문자

과분하게 사과의 말씀까지 전해주시니 송구스러운 마음입니다. 부족한 글에 시간 할애해 읽어주시고, 빠르게 답변까지 주신 점 감사합니다.

덕분에 저 역시 항상 많이 배우고 있습니다. 감사합니다🙇‍♀

0

정재남님의 프로필 이미지
정재남
지식공유자

1. 둘은 사실상 거의 비슷합니다.

1) getViewportRect를 클로저가 있는 함수로만 작성 / useSyncExternalStore 내부에서 함수 호출:

 이렇게 하면 ViewportContextProvider 컴포넌트를 처음 렌더링할 때 한 번 getViewportRect 함수를 실행하고, 이후로는 다시 실행하지 않습니다. 서버에서 한 번, 클라이언트에서 한 번 실행됩니다.

 

2) getViewportRect를 즉시실행함수로 작성 / useSyncExternalStore 내부에서는 결과함수 전달: 

이렇게 하면 위 파일을 import할 때 즉시실행함수가 실행됩니다. 마찬가지로 서버에서 한 번, 클라이언트에서 한 번 실행됩니다.

 

즉, '최초 한 번 실행되는 시점'에서는 차이가 있지만, 이후로는 완전히 동일하게 동작합니다.

저도 아마 강의 중 어디선가 즉시실행함수로 고쳤던 것으로 기억해요 ㅎㅎ

 

2. 의존성 배열에 언제나 참조형 데이터를 지양해야 하는 것은 아닙니다.

useViewportRect 는 내부 프로퍼티 중 하나라도 변하면 무조건 새로운 객체를 생성하도록 하고 있습니다. 즉 내부 프로퍼티만 달라지는 것이 아니라 객체 자체가 다른 객체로 바뀝니다. 따라서 내부 변수를 하나씩 풀어서 의존성 배열에 넣지 않더라도, 변경사항이 발생할 때마다 useEffect가 잘 실행될 것입니다.

 내부 프로퍼티가 달라지더라도 새로운 객체를 생성하지 않고, 기존 객체에 내부 프로퍼티만 변경하도록 작성한 경우라면, 말씀하신 대로 객체를 의존성 배열에 넣었을 때 변경사항을 제대로 감지하지 못할 것입니다.

 

강의자료는 강의 커리큘럼 맨앞에 있어요 :)

이레님의 프로필 이미지
이레
질문자

답변 주셔서 감사드립니다. 강의자료 완성본은 덕분에 잘 찾았습니다 ㅎㅎ

1. useSyncExternalStore의 두번째 인자(getViewportRect)를 참조로 바꾸고자 했던 사유는
함수의 실행값으로 인자를 넘기게 되면 리렌더시 매번 새로운 클로저 함수가 생성될테니(=stored 변수가 계속 초기화 될테니) 생각하지 못한 sideeffect가 있지 않을까? 해서 였습니다.

그래서 초기 실행은 필요하니 즉시실행함수로 바꾸고, 참조만 넘기는 것을 생각했습니다.

그런데 ? 생각해보니 tooltip을 사용할 때 해당 컴포넌트의 리렌더가 빈번할까... 고려했을 때는 유의미한 차이는 없을 것 같단 생각이 드네요! ㅎㅎ 말씀 주신 것처럼 초기 실행시엔 두 부분 거의 유사한 점 잘 확인했습니다.

2. 이건 꽤 오랫동안 한 켠에서 고민하던 부분인데 답변으로 많은 도움이 되었습니다 감사합니다!

0

안녕하세요, 인프런 AI 인턴입니다.

이레 님의 질문에 대해 설명드리겠습니다. 제안하신 두 가지 사항에 대해 말씀드리자면:

  1. useSyncExternalStore 수정 제안: useSyncExternalStore의 두 번째 인자로 “함수 자체”가 아닌 함수 “결과값”이 사용되는 것이 일반적이지 않은 방식이긴 합니다. 대신 함수의 참조 자체를 넘겨 변경 사항을 즉시 반영하도록 하는 방법은 상황에 따라 더 효율적일 수 있습니다. 단, 함수 참조를 통해 상태 변경 감지를 적절히 구현해야하며, 불필요한 리렌더링을 피하도록 최적화하는 것이 중요합니다.

  2. useLayoutEffect 의존성 배열 문제: 객체를 그대로 의존성 배열에 전달하는 경우, 객체의 프로퍼티 값이 변경되지 않았어도 객체 참조가 변경되면 useLayoutEffect가 실행될 수 있어요. 따라서, 의존성 배열에 필요한 프로퍼티만 개별적으로 지정하면 불필요한 실행을 줄일 수 있습니다. 이 방법은 특히 의존성 배열이 자주 변경될 경우 유용합니다.

이 두 가지 변경 사항에 대한 자세한 설명과 예측되는 장단점을 고려하여 적용하는 것이 좋습니다.

추가적으로 useSyncExternalStoreChoosing the State Structure를 참고하여 보다 심도 있는 내용을 학습하실 수 있습니다.

강의자료의 완성 코드 찾기에 어려움을 겪으신 것에 대해 사과드립니다. 질문의 내용을 통해 더 나은 방향으로 코드를 구성하시리라 믿습니다.

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.

현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

이레님의 프로필 이미지
이레

작성한 질문수

질문하기