본문 바로가기
버그 케이스별 대응 기록 & 컴포넌트별 동작 구조 도식화

Material UI Popper 컴포넌트 버그 케이스 대응 기록 및 동작 구조

by haheehee 2025. 4. 22.
728x90

목차

  1. Popper 컴포넌트 소개
  2. 버그 케이스별 대응 기록
  3. Popper 동작 구조 도식화
  4. 최적화 전략

Popper 컴포넌트 소개

Material UI의 Popper 컴포넌트는 @popperjs/core 라이브러리를 기반으로 구현된 컴포넌트로, 툴팁, 드롭다운, 메뉴 등 다양한 팝오버 UI 요소를 화면에 표시하는 데 사용됩니다. Popper는 포털(Portal)을 통해 DOM의 계층 구조와 독립적으로 렌더링되며, 지정된 앵커 요소(anchorEl)를 기준으로 위치가 계산됩니다.

import { Popper } from '@mui/material';

<Popper
  open={open}
  anchorEl={anchorEl}
  placement="bottom-end"
  transition
  disablePortal={false}
>
  {({ TransitionProps }) => (
    <Fade {...TransitionProps} timeout={350}>
      <Paper>Popper 내용</Paper>
    </Fade>
  )}
</Popper>

버그 케이스별 대응 기록

위치 설정 오류

문제 상황:

Popper가 초기에 좌측 상단에 렌더링된 후 우측 하단으로 이동하는 현상

원인 분석:

  1. requestAnimationFrame 타이밍 문제로 인한 초기 렌더링과 위치 계산 사이의 지연
  2. 가상 요소(virtualElement) 설정이 프레임 간격으로 지연되어 실행됨
  3. 상태 업데이트(setAnchorEl) 후 리렌더링까지의 시간차로 인한 깜빡임 현상

해결 방안:

// 문제가 있는 코드
useLayoutEffect(() => {
  if (progressOpen) {
    requestAnimationFrame(() => {
      const x = window.innerWidth - 10;
      const y = window.innerHeight - 10;
      
      const virtualElement = {
        getBoundingClientRect: () => ({
          top: y,
          left: x,
          bottom: y,
          right: x,
          width: 0,
          height: 0,
          x,
          y,
          toJSON: () => {},
        }),
      };
      
      setAnchorEl(virtualElement as any);
    });
  } else {
    setAnchorEl(null);
  }
}, [progressOpen, location.pathname]);

// 개선된 코드
useLayoutEffect(() => {
  if (progressOpen) {
    // virtualElement를 즉시 설정
    const x = window.innerWidth - 10;
    const y = window.innerHeight - 10;
    
    const virtualElement = {
      getBoundingClientRect: () => ({
        top: y,
        left: x,
        bottom: y,
        right: x,
        width: 0,
        height: 0,
        x,
        y,
        toJSON: () => {},
      }),
    };
    
    // 즉시 anchorEl 설정
    setAnchorEl(virtualElement as any);
    
    // 레이아웃 재계산 강제(필요한 경우)
    document.body.offsetHeight;
  } else {
    setAnchorEl(null);
  }
}, [progressOpen, location.pathname]);

추가 대응:

1. Popper 컴포넌트에 명시적 배치 설정 추가:

<Popper
  open={progressOpen}
  anchorEl={anchorEl}
  placement="bottom-end" // 우측 하단 배치 강제
  modifiers={[
    {
      name: 'preventOverflow',
      options: {
        boundary: 'viewport',
      },
    },
  ]}
>
  {/* 내용 */}
</Popper>

2. useLayoutEffect 사용으로 DOM 업데이트 전 위치 계산 보장

3. 초기 위치를 컴포넌트 마운트 시점에 계산하도록 설정

렌더링 타이밍 이슈

문제 상황:

Popper가 열릴 때 내용이 완전히 로드되기 전에 위치가 계산되어 잘못된 위치에 표시되는 현상

원인 분석:

  1. Popper 내부 컨텐츠의 크기가 비동기적으로 변경됨
  2. 초기 위치 계산 시 최종 컨텐츠 크기가 반영되지 않음
  3. 리플로우/리페인트 타이밍 문제

해결 방안:

// 문제 있는 접근법
const [open, setOpen] = useState(false);
const [content, setContent] = useState(null);

useEffect(() => {
  setOpen(true);
  fetchContent().then(data => {
    setContent(data);
  });
}, []);

// 개선된 접근법
const [open, setOpen] = useState(false);
const [content, setContent] = useState(null);
const [isContentLoaded, setIsContentLoaded] = useState(false);

useEffect(() => {
  fetchContent().then(data => {
    setContent(data);
    setIsContentLoaded(true);
  });
}, []);

useEffect(() => {
  if (isContentLoaded) {
    setOpen(true);
  }
}, [isContentLoaded]);

// 컴포넌트에 업데이트 훅 추가
<Popper
  open={open}
  anchorEl={anchorEl}
  placement="bottom-end"
  modifiers={[
    {
      name: 'updateOnContentChange',
      enabled: true,
      phase: 'afterWrite',
      fn: ({ state }) => {
        // 내용 변경 후 위치 업데이트 로직
      },
    },
  ]}
>
  {content}
</Popper>

리사이징 및 스크롤 문제

문제 상황:

윈도우 리사이징이나 스크롤 시 Popper 위치가 앵커 요소를 따라가지 않는 문제

원인 분석:

  1. 윈도우 이벤트(resize, scroll)에 대한 대응 부재
  2. 가상 요소 사용 시 위치 업데이트 로직 누락
  3. 포지셔닝 업데이트 최적화 부족

해결 방안:

useLayoutEffect(() => {
  if (!progressOpen) {
    setAnchorEl(null);
    return;
  }
  
  // 초기 위치 설정
  const updatePosition = () => {
    const x = window.innerWidth - 10;
    const y = window.innerHeight - 10;
    
    const virtualElement = {
      getBoundingClientRect: () => ({
        top: y,
        left: x,
        bottom: y,
        right: x,
        width: 0,
        height: 0,
        x,
        y,
        toJSON: () => {},
      }),
    };
    
    setAnchorEl(virtualElement as any);
  };
  
  // 초기 위치 설정
  updatePosition();
  
  // 이벤트 리스너 등록
  window.addEventListener('resize', updatePosition);
  window.addEventListener('scroll', updatePosition, true); // capture phase
  
  // 클린업
  return () => {
    window.removeEventListener('resize', updatePosition);
    window.removeEventListener('scroll', updatePosition, true);
  };
}, [progressOpen]);

최적화 방안:

- 디바운스/스로틀 적용으로 과도한 업데이트 방지

import { debounce } from 'lodash';

// 디바운스 적용된 위치 업데이트 함수
const debouncedUpdatePosition = useCallback(
  debounce(() => {
    // 위치 업데이트 로직
    updatePosition();
  }, 16), // 약 60fps에 맞춤
  []
);

z-index 및 오버레이 이슈

문제 상황:

다른 고정 위치(fixed) 요소나 오버레이 뒤에 Popper가 가려지는 현상

원인 분석:

  1. z-index 값이 충분히 높지 않음
  2. 스택 컨텍스트(stacking context) 문제
  3. Portal 사용 시 DOM 구조상 위치 문제

해결 방안:

// Popper 스타일 조정
<Popper
  open={progressOpen}
  anchorEl={anchorEl}
  style={{ zIndex: 1500 }} // Material UI 기본값보다 높게 설정
  // 또는
  sx={{ zIndex: theme => theme.zIndex.modal + 1 }}
>
  {/* 내용 */}
</Popper>

// Portal 타겟 설정으로 DOM 최상위에 렌더링
<Popper
  open={progressOpen}
  anchorEl={anchorEl}
  container={document.body} // body에 직접 렌더링
>
  {/* 내용 */}
</Popper>

Popper 동작 구조 도식화

렌더링 프로세스

+--------------------------+
|     컴포넌트 마운트      |
+--------------------------+
            |
            v
+--------------------------+
|  anchorEl 유효성 확인    |
| (null이면 렌더링 중단)   |
+--------------------------+
            |
            v
+--------------------------+
|   Portal 렌더링 준비     |
| (disablePortal 옵션 확인)|
+--------------------------+
            |
            v
+--------------------------+
|  Popper.js 인스턴스 생성 |
|  및 초기 위치 계산       |
+--------------------------+
            |
            v
+--------------------------+
|   DOM에 컨텐츠 렌더링    |
|   (Portal 통해 분리)     |
+--------------------------+
            |
            v
+--------------------------+
|   위치 조정 및 적용      |
|  (modifiers 실행)        |
+--------------------------+
            |
            v
+--------------------------+
|   이벤트 리스너 등록     |
|  (리사이즈, 스크롤 등)   |
+--------------------------+
            |
            v
+--------------------------+
|   변화 감지 및 업데이트  |
|  (위치, 사이즈 변경 시)  |
+--------------------------+

포지셔닝 메커니즘

Popper는 placement 속성을 통해 기본 배치 위치를 설정하고, 다양한 modifiers를 통해 위치를 세밀하게 조정합니다.

기본 배치 옵션:

  • top, bottom, left, right (기본 방향)
  • -start, -end 접미사 추가 가능 (예: top-start, bottom-end)

주요 Modifiers:

  1. preventOverflow: 뷰포트 밖으로 넘어가지 않도록 방지
  2. flip: 공간이 부족할 경우 반대쪽으로 위치 변경
  3. arrow: 화살표 요소 위치 조정
  4. offset: 기본 위치에서 오프셋 적용
<Popper
  placement="bottom-end"
  modifiers={[
    {
      name: 'offset',
      options: {
        offset: [0, 10], // [skidding, distance]
      },
    },
    {
      name: 'flip',
      options: {
        fallbackPlacements: ['top-end', 'left-end'],
      },
    },
    {
      name: 'preventOverflow',
      options: {
        boundary: 'viewport',
        padding: 8,
      },
    },
  ]}
>
  {/* 내용 */}
</Popper>

가상 요소 활용

실제 DOM 요소 대신 가상 요소를 사용하여 Popper 배치 방법:

+-------------------------+
|   가상 요소 객체 생성   |
+-------------------------+
           |
           v
+-------------------------+
| getBoundingClientRect() |
|    메서드 구현          |
+-------------------------+
           |
           v
+-------------------------+
|   위치 좌표 계산 및     |
|   오브젝트 반환         |
+-------------------------+
           |
           v
+-------------------------+
|  가상 요소를 anchorEl로 |
|   Popper에 전달         |
+-------------------------+
           |
           v
+-------------------------+
|  Popper가 가상 요소의   |
|  위치 기준으로 배치     |
+-------------------------+

가상 요소 구현 예시:

// 화면 우측 하단에 배치하는 가상 요소
const virtualElement = {
  getBoundingClientRect: () => {
    const x = window.innerWidth - 10;
    const y = window.innerHeight - 10;
    
    return {
      top: y,
      left: x,
      bottom: y,
      right: x,
      width: 0,
      height: 0,
      x,
      y,
      toJSON: () => {}, // Popper.js가 필요로 함
    };
  },
};

최적화 전략

성능 최적화

1. 메모이제이션 활용:

// 가상 요소 메모이제이션
const virtualElement = useMemo(() => ({
  getBoundingClientRect: () => ({
    // 위치 계산
  }),
}), [dependencies]);

2. 업데이트 최적화:

// 위치 업데이트 함수 최적화
const updatePosition = useCallback(() => {
  // 위치 업데이트 로직
}, [dependencies]);

// 이벤트 핸들러에 스로틀 적용
const throttledUpdatePosition = throttle(updatePosition, 16);

안정성 향상

1. 조건부 렌더링 처리:

// anchorEl 유효성 검사
{anchorEl && (
  <Popper
    open={open}
    anchorEl={anchorEl}
    // 기타 속성
  >
    {/* 내용 */}
  </Popper>
)}

2. 오류 처리 및 폴백:

// 위치 계산 중 오류 처리
const getPosition = () => {
  try {
    // 위치 계산 로직
    return { x, y };
  } catch (error) {
    console.error('위치 계산 오류:', error);
    // 기본 위치 반환 (폴백)
    return { x: window.innerWidth / 2, y: window.innerHeight / 2 };
  }
};

접근성 개선

1. 키보드 네비게이션 지원:

<Popper
  role="dialog"
  aria-modal="true"
  tabIndex={-1}
  onKeyDown={(e) => {
    if (e.key === 'Escape') {
      handleClose();
    }
  }}
>
  {/* 내용 */}
</Popper>

2. 포커스 관리:

const popperRef = useRef(null);

useEffect(() => {
  if (open && popperRef.current) {
    // Popper 열릴 때 포커스 이동
    popperRef.current.focus();
  }
}, [open]);

<Popper
  ref={popperRef}
  // 기타 속성
>
  {/* 내용 */}
</Popper>
728x90

댓글