overflow-y: scroll + position: absolute 팝업 잘림 이슈 (문제 해결)

Devinix·2025년 12월 22일

[문제 해결]

목록 보기
33/43
post-thumbnail

개요

세로 스크롤이 필요한 사이드 네비게이션 내부에서
아이콘 클릭 시 팝업(알림 패널, 프로필 오버레이 등)을 띄워야 하는 경우가 있다.
이때 팝업을 position: absolute로 배치하면,
특정 환경에서 팝업이 잘리거나 보이지 않는 문제가 발생할 수 있다.
본 글은 스크롤 UX는 유지하면서도,
팝업이 항상 아이콘 옆에 안정적으로 고정되도록 만드는 해결 과정을
구조와 좌표 계산 관점에서 코드 중심으로 정리한 기록이다.

함수명, 변수명, 컴포넌트명은 모두 예시용 가명으로 작성되어 있다.

문제 상황

초기 구현을 다음과 같은 구조로 시작하였다.

  1. 사이드바 자체가 overflow-y: scroll
  2. 아이콘의 부모를 position: relative
  3. 팝업을 position: absolute로 배치

구현 자체는 자연스럽지만, 사이드바에 스크롤이 생긴 이후부터 특정 위치에서 팝업이 잘리거나 보이지 않는 문제가 발생한다.

import React, {useState} from 'react';
import styled from 'styled-components';

export function SideNavWithInlinePopovers() {
  return (
    <SideNavScrollContainer>
      <NotificationIconWithPopover />
    </SideNavScrollContainer>
  );
}

function NotificationIconWithPopover() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <IconAnchor>
      <IconButton onClick={() => setIsOpen(v => !v)}>🔔</IconButton>
      {isOpen && <NotificationPopoverPanel>알림 패널</NotificationPopoverPanel>}
    </IconAnchor>
  );
}

const SideNavScrollContainer = styled.aside`
  height: 100dvh;
  overflow-y: scroll;
`;

const IconAnchor = styled.div`
  position: relative;
`;

const IconButton = styled.button``;

const NotificationPopoverPanel = styled.div`
  position: absolute;
  left: 60px;
  bottom: 0px;
  width: 360px;
  height: 500px;
  background: #fff;
  border-radius: 12px;
`;

원인

원인은 팝업이 overflow-y: scroll이 적용된 요소의 DOM 계층 내부에 존재한다는 점이었다.

스크롤 컨테이너는 자식 요소가 부모 영역을 벗어날 경우 클리핑을 발생시킬 수 있다. 따라서 팝업이 부모 박스 바깥으로 튀어나오면 z-index와 무관하게 잘릴 수 있다.

이 때문에 다음 방식들은 근본적인 해결책이 되지 않는다.

  1. z-index 조정
  2. overflow-x: visible 추가

문제의 본질은 레이어 우선순위가 아니라 DOM 구조다.

해결 과정

해결 목표는 다음과 같다.

  1. 스크롤 UX는 그대로 유지
  2. 팝업은 스크롤과 무관하게 항상 노출
  3. 어떤 스크롤 위치에서도 클리핑되지 않음

이를 위해 컨테이너를 두 겹으로 분리한다.

Outer

  1. overflow: visible
  2. 팝업의 absolute 기준

InnerScroll

  1. overflow-y: scroll
  2. 실제 스크롤 담당

[좌표 재계산 필요성]
구조를 분리하면 팝업은 더 이상 아이콘의 부모(relative)를 기준으로 배치할 수 없게 된다.

따라서 다음 절차로 좌표를 계산한다.

  1. 아이콘(anchor)의 getBoundingClientRect() 획득
  2. Outer 컨테이너의 getBoundingClientRect() 획득
  3. 두 좌표 차이로 Outer 기준 left / top 계산
  4. 스크롤 및 리사이즈 시마다 재계산
import React, {useCallback, useEffect, useRef, useState} from 'react';
import styled from 'styled-components';

/** SideNav 바깥(클리핑 없음) 기준 absolute 좌표 */
type PopoverPosition = {left: number; top: number};

export function SideNavLayout() {
  const sideNavOuterRef = useRef<HTMLDivElement>(null);
  const sideNavScrollRef = useRef<HTMLDivElement>(null);
  const notificationIconRef = useRef<HTMLDivElement>(null);
  const profileIconRef = useRef<HTMLDivElement>(null);

  const [isNotificationOpen, setIsNotificationOpen] = useState(false);
  const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);

  const [notificationPos, setNotificationPos] = useState<PopoverPosition | null>(null);
  const [profilePos, setProfilePos] = useState<PopoverPosition | null>(null);

  const refreshPopoverPositions = useCallback(() => {
    if (!sideNavOuterRef.current) return;

    if (isNotificationOpen) {
      setNotificationPos(
        computePopoverPosition({
          outerEl: sideNavOuterRef.current,
          anchorEl: notificationIconRef.current,
          panelHeight: 500,
          offsetLeft: 60,
        }),
      );
    }

    if (isProfileMenuOpen) {
      setProfilePos(
        computePopoverPosition({
          outerEl: sideNavOuterRef.current,
          anchorEl: profileIconRef.current,
          panelHeight: 645,
          offsetLeft: 60,
        }),
      );
    }
  }, [isNotificationOpen, isProfileMenuOpen]);

  useEffect(() => {
    refreshPopoverPositions();
  }, [isNotificationOpen, isProfileMenuOpen, refreshPopoverPositions]);

  useEffect(() => {
    const scroller = sideNavScrollRef.current;
    if (!scroller) return;

    const onScroll = () => refreshPopoverPositions();
    scroller.addEventListener('scroll', onScroll, {passive: true});
    window.addEventListener('resize', refreshPopoverPositions);

    return () => {
      scroller.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', refreshPopoverPositions);
    };
  }, [refreshPopoverPositions]);

  return (
    <SideNavOuter ref={sideNavOuterRef}>
      <SideNavInnerScroll ref={sideNavScrollRef}>
        <SideNavTopSection>...</SideNavTopSection>

        <SideNavBottomSection>
          <IconAnchor ref={notificationIconRef}>
            <SideNavIconButton
              onClick={() => {
                setIsProfileMenuOpen(false);
                setIsNotificationOpen(v => !v);
              }}
            >
              🔔
            </SideNavIconButton>
          </IconAnchor>

          <IconAnchor ref={profileIconRef}>
            <SideNavIconButton
              onClick={() => {
                setIsNotificationOpen(false);
                setIsProfileMenuOpen(v => !v);
              }}
            >
              🙂
            </SideNavIconButton>
          </IconAnchor>
        </SideNavBottomSection>
      </SideNavInnerScroll>

      <NotificationPopover
        isOpen={isNotificationOpen}
        position={notificationPos}
        onClose={() => setIsNotificationOpen(false)}
      />

      <ProfileMenuPopover
        isOpen={isProfileMenuOpen}
        position={profilePos}
        onClose={() => setIsProfileMenuOpen(false)}
      />
    </SideNavOuter>
  );
}

function computePopoverPosition({
  outerEl,
  anchorEl,
  panelHeight,
  offsetLeft,
}: {
  outerEl: HTMLElement;
  anchorEl: HTMLElement | null;
  panelHeight: number;
  offsetLeft: number;
}): PopoverPosition | null {
  if (!anchorEl) return null;

  const anchorRect = anchorEl.getBoundingClientRect();
  const outerRect = outerEl.getBoundingClientRect();

  const left = anchorRect.left - outerRect.left + offsetLeft;
  const top = anchorRect.bottom - outerRect.top - panelHeight;

  return {left, top};
}

결론

이 문제는 CSS의 문제가 아니라 레이아웃 구조 문제다. 스크롤 컨테이너와 팝업 기준을 분리하고 ref 기반 좌표를 스크롤/리사이즈 시점마다 재계산하면 팝업은 항상 아이콘 옆에 고정된다.

이 패턴은 사이드 네비게이션뿐 아니라 스크롤 영역 내부에서 팝업·툴팁·오버레이를 다뤄야 하는 다양한 UI에 재사용할 수 있을 것 같다.

profile
React, Next.Js, React-Native

0개의 댓글