Frontend

opacity만 0으로 줬는데 클릭은 왜 계속 될까 — pointer-events 이야기

팀원 PR의 모달 컴포넌트를 리뷰하다 발견한 버그를 계기로 정리한, opacity와 pointer-events가 서로 다른 이유와 브라우저 히트 테스트의 원리


팀 프로젝트에서 팀원이 올린 PR을 리뷰하다가, 겉보기엔 멀쩡히 잘 동작하는 모달 컴포넌트에서 숨어있던 버그 하나를 발견했다. 원인을 파고들어 보니 결국 CSS 속성 하나가 빠진 것뿐이었는데, 그 하나가 왜 그렇게 큰 차이를 만드는지 제대로 이해하고 넘어가고 싶어서 정리한다.

발단: 잘 열리고 잘 닫히는 모달

useModal이라는 공용 훅과 그걸 사용하는 SampleModal 컴포넌트였다. 코드만 보면 흠잡을 데가 없었다.

export function SampleModal() {
  const { isOpen, shouldRender, close } = useSampleModal();

  if (!shouldRender) return null;

  return (
    <div
      className={cn(
        'fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-200',
        isOpen ? 'opacity-100' : 'opacity-0'
      )}
      onClick={close}
    >
      {/* 모달 내용 */}
    </div>
  );
}

모달을 열면 배경이 서서히 어두워지고(opacity-100), 닫으면 서서히 투명해진다(opacity-0). 실제로 써봐도 열기/닫기 둘 다 아무 문제 없이 잘 됐다. 배경을 클릭해도 닫히고, 닫기 버튼을 눌러도 잘 닫혔다. 리뷰를 시작하기 전까지는 딱히 의심할 이유가 없었다.

증상: 닫자마자 다른 버튼이 안 눌린다

코드를 뜯어보다가 이상한 지점을 하나 찾았다. shouldRender라는 상태가 따로 있는데, 이게 isOpen과 타이밍이 다르게 움직이고 있었다.

const isOpen = activeModal === modalId;
const [shouldRender, setShouldRender] = useState(isOpen);

useEffect(() => {
  if (isOpen || !shouldRender) return;
  const timer = setTimeout(() => setShouldRender(false), 200); // 200ms
  return () => clearTimeout(timer);
}, [isOpen, shouldRender]);

isOpen은 모달을 닫는 즉시 false가 되지만, shouldRender는 페이드아웃 애니메이션 시간(200ms)만큼 일부러 늦게 false가 되도록 만들어져 있었다. 애니메이션을 위한 자연스러운 설계였다. 문제는 그 컴포넌트를 실제로 실행해서 확인했을 때 나왔다.

  1. 개발자도구 Network 탭을 열어둔다
  2. 모달을 연다
  3. “닫기”를 클릭한다
  4. 0.2초 안에 네트워크 요청을 보내는 다른 버튼을 클릭한다

Network 탭에 아무 요청도 안 찍혔다. 분명 눈에는 모달이 사라지고 있는데(또는 이미 다 사라졌는데), 그 짧은 순간 동안 클릭이 어디론가 씹히고 있었다. 확인 삼아 200ms를 2000ms로 늘려서 슬로모션으로 재생해보니 훨씬 명확하게 보였다 — 모달이 배경째로 서서히 투명해지는 그 2초 동안은, 화면의 다른 버튼을 아무리 눌러도 반응이 없었다.

원인: “안 보인다”와 “클릭이 안 된다”는 다른 이야기

여기서 처음에 착각했던 부분이 있다. “opacity가 0이면 클릭도 안 되는 거 아닌가?” 하는 생각이었다. 결론부터 말하면 전혀 아니다.

브라우저가 클릭을 처리하는 방식을 생각해보면 이해가 쉽다. 사용자가 화면의 어떤 좌표를 클릭하면, 브라우저는 그 좌표에 쌓여있는(stacking) 여러 요소들 중 제일 위에 있는 요소를 찾아서 클릭 이벤트를 넘긴다. 이 과정을 히트 테스트(hit test)라고 부른다.

opacity는 이 히트 테스트 결과에 전혀 영향을 주지 않는다. 그냥 “이 요소를 얼마나 투명하게 그릴지”만 결정하는 시각적 속성이다. 즉 opacity: 0인 요소도 브라우저 입장에서는 여전히 그 자리에, 그 크기로, 그 z-index로 존재하는 멀쩡한 요소다. 안 보일 뿐이지 히트 테스트에서는 계속 “제일 위에 있는 요소” 후보로 참여한다.

문제의 모달 배경은 fixed inset-0 z-50으로 화면 전체를 덮고 있었다. 닫히는 중에는 opacity-0이 붙어서 눈에는 안 보이지만, shouldRender가 아직 true라서 DOM에는 그대로 남아있었고, 화면 전체를 차지한 채로 계속 클릭을 가로채고 있었던 것이다. 사용자가 “안 보이니까 이제 없다”고 생각하고 그 자리를 클릭해도, 브라우저는 여전히 그 투명한 배경 div를 찾아서 onClick={close}를 실행시키고 있었다.

해결: pointer-events

이 문제를 해결하는 속성이 정확히 이 상황을 위해 존재한다. pointer-events: none을 주면, 브라우저는 히트 테스트를 할 때 그 요소를 아예 없는 것처럼 건너뛴다. 그 요소 뒤(아래)에 쌓여있는 다음 요소를 찾아서 클릭을 넘겨준다.

isOpen ? 'opacity-100' : 'pointer-events-none opacity-0'

닫히는 상태(isOpen === false)일 때 pointer-events-none을 같이 붙여주는 것으로 끝이었다. opacity로 인한 페이드아웃 애니메이션은 그대로 유지되면서, 그 순간부터는 클릭이 배경을 그냥 통과해서 뒤에 있는 실제 페이지 요소에 도달한다. “보이는 것”과 “클릭을 받는 것”이 서로 다른 속성이라는 걸 알고 나니, 고치는 데는 코드 한 줄이면 충분했다.

pointer-events, 생각보다 자주 마주치는 속성

이번 일을 계기로 pointer-events를 좀 더 찾아봤는데, 이 속성이 필요한 상황이 의외로 꽤 흔하다는 걸 알게 됐다.

  • 사라지는 애니메이션 중인 오버레이/모달/토스트: 이번에 겪은 것과 같은 케이스. opacity나 scale로 트랜지션을 주는 요소는 애니메이션이 끝날 때까지 DOM에 남아있는 경우가 많은데, 그 사이의 클릭을 막으려면 pointer-events-none이 필요하다.
  • disabled처럼 보이게만 하고 싶은 요소: 실제 disabled 속성 없이 CSS만으로 흐리게(opacity-50) 처리한 버튼은, 그냥 두면 여전히 클릭이 된다. disabled:pointer-events-none 같은 조합을 같이 써야 진짜로 눌리지 않는다.
  • 아이콘이 부모 버튼의 클릭을 가로채는 경우: 버튼 안에 SVG 아이콘을 넣었는데 아이콘 쪽 클릭 판정이 미묘하게 어긋나는 문제가, 자식 요소에 pointer-events-none을 줘서 클릭 이벤트가 항상 부모로 곧장 전달되게 만드는 걸로 해결되기도 한다.
  • 커스텀 툴팁/뱃지처럼 시각 효과만 있고 상호작용은 없어야 하는 요소: 마우스 이벤트가 그 요소 밑에 있는 실제 콘텐츠에 그대로 전달돼야 자연스러운 경우.

공통점은 전부 “화면에는 있어야 하지만, 클릭/마우스 이벤트는 그 뒤에 있는 걸 대신 받아야 하는” 상황이라는 것이다.

배운 점

이번 버그는 코드 자체는 문법적으로도, 로직적으로도 전혀 틀린 게 없었다. 실제로 열고 닫는 기본 동작도 정상이었고, 리뷰 없이 그냥 넘어갔다면 아마 한동안 아무도 몰랐을 것이다. 문제는 “안 보이면 없는 셈 치기 쉽다”는 착각이었다 — CSS에서 시각적 표현(opacity, visibility와는 또 다르게 동작한다)과 상호작용 가능 여부(pointer-events)는 완전히 독립된 축이라는 걸 코드로 직접 부딪혀보고 나서야 확실히 체감했다.

애니메이션이 들어간 UI를 만들 때는 “지금 이 요소가 시각적으로 어떤 상태인가”뿐 아니라 “지금 이 요소가 클릭을 받아야 하는 상태인가”를 따로 한 번 더 물어봐야겠다는 생각이 들었다.