React

[React/리액트] Dialog의 위치 계산과 깜빡임 현상 해결하기 - RAF와 visibility 조합으로 (feat. 렌더링 사이클과 가시성)

solfa 2025. 2. 9. 20:46

문제 상황

Dialog 컴포넌트와 버튼을 합쳐서 드롭다운 컴포넌트를 만들고 있었다.

내가 만든 컴포넌트의 요구사항은 다음과 같다.

  1. 버튼을 클릭하면 Dialog가 열림
  2. Dialog는 화면 여유 공간에 따라 버튼의 위나 아래에 위치
  3. 스크롤/리사이즈 시에도 적절한 위치 유지

핵심 구현을 위해 위치 계산이 필요했고 위치 계산에는 여러가지 방법이 있다.

처음에는 그냥 순수하게 useEffect만 사용해 구현했다.

useEffect(() => {
  if (isOpen && anchorEl && dialogRef.current) {
    const updatePosition = () => {
      // 1. 버튼의 위치 정보 가져오기
      const buttonRect = anchorEl.getBoundingClientRect();
      // 2. Dialog의 높이 측정
      const dialogHeight = dialogRef.current?.offsetHeight ?? 0;

      // 3. 화면 아래 남은 공간 계산
      const bottomSpace = window.innerHeight - buttonRect.bottom;
      // 4. 위에 둘지 아래에 둘지 결정
      const shouldShowOnTop = bottomSpace < dialogHeight + 10;

      // 5. 위치 설정
      setPosition(shouldShowOnTop ? "top" : "bottom");
      setCoordinates({
        top: shouldShowOnTop
          ? buttonRect.top - dialogHeight - 10  // 위에 둘 때
          : buttonRect.bottom + 10,             // 아래에 둘 때
        left: buttonRect.left,
      });
    };

    updatePosition();
  }
}, [isOpen, anchorEl]);

 

 

문제 발견 : 깜빡임 현상

 

코드를 실행해보니 Dialog가 열릴 때 왼쪽 상단에 나타났다가 사라지는 == 계산이 늦게 되는? 현상이 발생했다.

버튼을 클릭하면 주변 위치를 계산해서 Dialog가 나타나야 하는데 이 과정에서 Dialog가 화면 왼쪽 상단에 잠깐 나타났다 사라지는 flicker 현상이 발생하는 건데... flicker의 종류도 여러가지가 있을 텐데 음 사실 정확한 용어를 찾지 못해서 그냥 flicker라고 썼다.

이 현상을 정확히 지칭하는 단어가 있다면 댓글 부탁

 

원인을 찾아보니 React의 렌더링 사이클과 브라우저의 레이아웃 계산 타이밍의 차이였다.

Dialog의 위치를 계산하기 전에 이미 화면에 그려져버리는 현상~~~

React의 렌더링 사이클 살펴보기

  1. React가 가상 DOM을 업데이트
  2. 실제 DOM 업데이트 커밋
  3. useEffect 실행
  4. 브라우저의 레이아웃 계산
  5. 마이크로태스크 실행 (Promise 등)
  6. RAF (requestAnimationFrame) 실행
  7. 매크로태스크 (setTimeout) 실행

렌더링 사이클을 설명해주는 좋은 짤이 있어서 첨부한다.

출처 : https://beomy.github.io/tech/javascript/javascript-runtime/

해결 방법

1. setTimeout 사용

const timer = setTimeout(updatePosition, 0);
  • 매크로태스크 큐에서 가장 마지막에 실행
  • 브라우저가 레이아웃을 계산할 시간은 충분
  • 하지만 실행 타이밍이 불안정하고 디바이스 성능에 영향을 받음

2. requestAnimationFrame 사용

requestAnimationFrame(updatePosition);
  • 브라우저의 렌더링 사이클과 완벽한 동기화
  • 다음 프레임에서 안정적으로 실행
  • 백그라운드 탭에서 성능 최적화
  • 부드러운 애니메이션에 최적화

3. ResizeObserver 사용

const resizeObserver = new ResizeObserver(() => {
  requestAnimationFrame(updatePosition);
});
  • 요소 크기 변경을 정확하게 감지
  • 불필요한 업데이트 최소화
  • 하지만 초기 위치 설정에는 부적합

근데 세 가지 방법 전부 미세한 깜빡임이 존재했다. 🤦‍♂️ 🤦‍♂️ 🤦‍♂️

첫 계산이 완료되기 전에 그냥 dialog를 안보이게 했다가 계산 후에 보이게 하면 해결될 것 같긴 했고 밑에서 그렇게 해결한다.

세 방식 모두 미세한 깜빡임이 발생하는 근본적인 원인

원인은 React의 렌더링 사이클과 브라우저의 레이아웃 계산 과정의 근본적인 차이 때문이다.

1. 초기 렌더링 시점의 문제
Dialog가 처음 마운트될 때, React는 먼저 컴포넌트를 기본 위치(0,0 또는 지정된 초기값)로 렌더링한다. 그니까 왼쪽 상단에 잠깐 보이는 건데 이 시점에서는 아직 위치 계산이 이루어지지 않은 상태인 거고!
브라우저는 이 계산되지 않은 초기 DOM을 먼저 그리게 된다.

2. 위치 계산과 업데이트 타이밍
   - setTimeout: 매크로태스크 큐에서 실행되므로, 이미 초기 렌더링이 완료된 후에 실행된다.
   - RAF: 다음 프레임에서 실행되므로, 역시 초기 렌더링 이후에 실행된다.
   - ResizeObserver: 요소의 크기 변경을 감지하지만, 초기 렌더링을 막을 수는 없다.

그니까 초기 렌더링을 전부 막을 수 없는게 당연하다.


3. 레이아웃 계산의 순서

   // DOM 업데이트 -> 레이아웃 계산 -> 페인팅의 순서
   dialogRef.current.getBoundingClientRect();  // 이 시점에서 강제 레이아웃 계산 발생


위치 계산을 위해 DOM 측정이 필요한데 이는 레이아웃 계산을 강제로 발생시킨다. 그니까 dom을 다시 그려서 이것 때문에 추가적인 렌더링 사이클이 발생할 수 있다. 걍 dom 그리고 계산이 이후니까 !

이러한 이유들로 인해, 어떤 방식을 사용하더라도 초기 렌더링에서의 깜빡임을 완전히 피하기는 어렵다.

결국 `visibility: hidden`을 사용하여 시각적으로 이를 숨기는 것이 가장 실용적인 해결책이 된다.

최종 해결책: RAF + 가시성 제어

결국 이렇게 해결했다. 여러 방법을 검토한 결과, requestAnimationFrame과 가시성 제어를 조합한 방식을 선택했다.

// 1. 위치 계산이 끝났는지 추적하는 상태 추가
const [isPositioned, setIsPositioned] = useState(false);

useEffect(() => {
  if (isOpen && anchorEl && dialogRef.current) {
    const updatePosition = () => {
      // ... 위치 계산 코드 ...
      
      // 2. 위치 계산이 끝나면 보이게 설정
      setIsPositioned(true);
    };

    // 3. 위치 계산 전에는 숨기기
    setIsPositioned(false);
    
    // 4. RAF로 다음 프레임에 실행
    requestAnimationFrame(updatePosition);

    // 5. 스크롤/리사이즈 대응
    window.addEventListener("scroll", () => requestAnimationFrame(updatePosition));
    window.addEventListener("resize", () => requestAnimationFrame(updatePosition));

    return () => {
      window.removeEventListener("scroll", updatePosition);
      window.removeEventListener("resize", updatePosition);
      setIsPositioned(false);
    };
  }
}, [isOpen, anchorEl]);

 

그리고 Dialog 컴포넌트에서

<DialogContainer
  style={{ visibility: isPositioned ? "visible" : "hidden" }}
  // ... 다른 props ...
>

 

이렇게 하면
1. 위치 계산이 끝날 때까지 Dialog는 투명
2. 계산이 끝나면 제자리에 나타남
3. 깜빡임 없어짐

나는 왜 이 방식을 선택했을까?

어차피 세 방식 모두 미세한 깜빡임은 존재하고 깜빡임 해결은 visivility 스타일을 줘서 해결했다.

세 가지중에는 RAF가 낫다고 생각해서 이 조합을 선택했다.

 

RAF를 선택한 것은 위의 문제를 완전히 해결할 수는 없지만, 적어도
1. 브라우저의 렌더링 사이클과 가장 잘 동기화되어 있고
2. 성능 면에서 최적화되어 있으며
3. 다른 애니메이션과의 호환성이 좋기 때문

배운 점

1. 브라우저가 일하는 방식을 이해하면 더 좋은 해결책을 찾을 수 있다.

면접 볼 때만 단기 암기하는 이벤트 루프와 렌더링 순서였는데... 애니메이션이나 더 복잡한 구현을 할 수록 이론이 중요하게 느껴지는 요즘이다

2. 가끔은 단순한 해결책(숨겼다가 보여주기)가 최고일 수도?

728x90