Radix UI Tabs에서 발생한 무한 alert 이슈

Jessie·2025년 7월 17일
post-thumbnail

문제 상황

회사에서 페이지를 구현하던 도중, 예상치 못한 동작으로 문제가 발생했다. 사용자가 특정 기능을 사용하지 않으면 해당 기능의 설정을 할 수 있는 탭으로 전환을 못하도록 막아야 했는데, 이에 대한 안내문구를 alert로 띄우니 이 alert가 무한 반복이 되는 것이었다.

예상 동작: alert로 안내문구가 뜬 후 현재 탭 유지
실제 동작: 현재 탭은 유지되나, alert 창을 꺼도 계속해서 alert 창이 나타남

문제 환경은 다음과 같았다:

  • Radix UI(Primitives)의 Tabs 컴포넌트 사용
  • 활성화된 탭에 대한 상태값은 useState로 관리
  • onValueChange 속성에 상태값을 변경하는 함수를 적용
  • 특정 조건을 만족하지 않을 경우에는 alert를 띄운 후 return하여 상태값이 변경되는 것을 막음

문제 해결 과정

처음에는 구현 과정에서 문제가 있었을것이라 전제하고, 이 문제가 발생하는 구간을 찾기 위해 console.log()를 사용하거나, 조건을 하나씩 삭제하며 조건을 좁혀나갔다.

  • 똑같은 조건에서 alert() 대신 console.log()를 사용한 경우에는 무한 반복 이슈가 발생하지 않았음
  • alert()를 사용했더라도, return을 하지 않고 상태값이 바뀌도록 하면 무한 반복되지 않음
  • setState를 사용할 때 값이 반영되지 않아야하는데, 바뀐 값이 들어가는 것인가 싶어 setState(prev => prev)를 추가해보았으나 문제는 똑같이 발생했다.

하지만 조건을 좁혀도 원인을 특정할수가 없었다. 그래서 Radix UI 라이브러리 자체의 동작과 관련하여 발생하는 것일수도 있다는 생각을 가지고 새롭게 Next.js 프로젝트를 생성하여 동일한 조건의 Tabs 컴포넌트를 생성해보았다.

// 동일한 문제가 발생하는 코드
export default function Tab() {
  const [selectedTab, setSelectedTab] = useState("tab1");

  const alertShownRef = useRef(false);

  function handleChangeValue(value: string) {
    if (value !== "tab1") {
      alert("Not allowed");
      return;
    }

    setSelectedTab(value);
  }

  return (
    <div>
      <Tabs.Root value={selectedTab} onValueChange={(value) => handleChangeValue(value)}>
        <Tabs.List>
          <Tabs.Trigger value="tab1">Tab1</Tabs.Trigger>
          <Tabs.Trigger value="tab2">Tab2</Tabs.Trigger>
        </Tabs.List>
        <Tabs.Content value="tab1">Tab1 content</Tabs.Content>
        <Tabs.Content value="tab2">Tab2 content</Tabs.Content>
      </Tabs.Root>
    </div>
  );
}

아무것도 손대지 않은 새 프로젝트에서 Radix UI만 설치하고 컴포넌트를 만들어보았는데, 동일한 문제를 구현해낼 수 있었다.

회사 프로젝트에서는 Tabs 컴포넌트를 랩핑해서 사용하고 있어서 라이브러리와 관련된 문제라고 생각할 수 없었지만, 이렇게 순수한 상태의 컴포넌트를 사용해도 문제가 발생하는 것으로 보아 라이브러리의 동작 원리와 관련이 있을수 있겠다는 생각이 들었다.

문제 원인

Radix UI의 깃허브를 방문하여 코드를 열어보니 문제 원인을 확실하게 알 수 있었다.

const TabsTrigger = React.forwardRef<TabsTriggerElement, TabsTriggerProps>(
  (props: ScopedProps<TabsTriggerProps>, forwardedRef) => {
    
    // ... 생략
    
    return (
      <RovingFocusGroup.Item
        asChild
        {...rovingFocusGroupScope}
        focusable={!disabled}
        active={isSelected}
      >
        <Primitive.button
          // ... props 생략
          onMouseDown={composeEventHandlers(props.onMouseDown, (event) => {
            if (!disabled && event.button === 0 && event.ctrlKey === false) {
              context.onValueChange(value);
            } else {
              event.preventDefault();
            }
          })}
          onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
            if ([' ', 'Enter'].includes(event.key)) context.onValueChange(value);
          })}
          onFocus={composeEventHandlers(props.onFocus, () => {
            const isAutomaticActivation = context.activationMode !== 'manual';
            if (!isSelected && !disabled && isAutomaticActivation) {
              context.onValueChange(value);
            }
          })}
        />
      </RovingFocusGroup.Item>
    );
  }
);

Tabs.Root에 전달된 onValueChange 함수는 내부에서 TabsProvider를 통해 Tabs.Trigger에 context로 전달된다.

이렇게 전달된 onValueChangeTabs.Trigger

  1. 왼쪽 마우스를 통해 클릭되었을 때 (onMouseDown)
  2. 엔터키가 눌렸을 때 (onKeyDown)
  3. 포커스되었을때 (onFocus)

실행된다.

처음 탭을 클릭했을때는 onMouseDown 이벤트 핸들러에 의해 alert가 발생한다. 하지만 여기서 alert가 UI를 블로킹해버린다. 브라우저는 alert가 발생한 순간 모든 렌더링과 포커스 흐름을 멈추고 사용자가 상호작용을 하길 기다린다. 이때 클릭된 탭은 포커스를 잠시 잃어버렸다가, 사용자가 상호작용을 마치면 브라우저가 기억해둔 엘리먼트로 포커스를 다시 되돌린다. 그러면 Tabs.TriggeronFocus가 실행되며 onValueChange가 다시 실행된다. 그리고 alert가 발생하면 다시 포커스를 잃어버리고, 복귀시키며 또 다시 onValueChange를 실행하는 과정이 무한반복 되는 것이다.

동일한 코드에서 console.log()가 문제를 발생시키지 않는 이유는 UI 블로킹을 하지 않기 때문에 onFocus를 실행시키지 않아서이다. 문제가 발생하는 Tabs.Trigger에 onFocus로 로그를 남겨보면 console.log()는 로그가 한번만 찍히지만, alert()는 계속해서 로그가 찍히는 것을 확인할 수 있다.

해결 방법

나는 useRefsetTimeout()을 통해 문제를 해결했다.

useRef 사용한 이유는, 렌더링에 영향을 받지 않고, 또 영향을 주지 않기 때문에 렌더링 흐름에 상관없이 안전하게 값을 저장할 수 있을 것이라고 판단했기 때문이다.

const alertShownRef = useRef(false);

function handleChangeValue(value: string) {
  if (value === "tab2") {
    if (!alertShownRef.current) {
      alertShownRef.current = true;
      alert("Not allowed");

      setTimeout(() => {
        alertShownRef.current = false;
      }, 400);
    }
    return;
  }

  setSelectedTab(value);
}

우선, useRef에 alert가 보여졌는지 여부를 저장한다. 탭을 클릭하면 alert가 보여지기 전에 ref 값을 확인하여 false일때만 alert를 노출한다. 조건문을 통과했다면, alert가 노출되기 전에 (UI 블로킹이 일어나기 전에) ref 값을 true로 바꾼다. 그러면 alert창이 닫히고 포커스가 탭으로 돌아가도 조건에 막혀 alert가 다시 노출되지 않는다. 탭이 닫히고 나면 setTimeout을 사용하여 조건문에 의해 alert 무한 반복이 끊길때까지 충분한 시간을 준 후 ref 값을 false로 바꾸어 사용자가 다시 클릭했을때는 alert 창이 노출될 수 있도록 해준다.

...라고 끝낼뻔 하였으나❗️❗️ 더 간단하고 좋은 방법이 있었다.

onFocus={composeEventHandlers(props.onFocus, () => {
  const isAutomaticActivation = context.activationMode !== 'manual';
  if (!isSelected && !disabled && isAutomaticActivation) {
    context.onValueChange(value);
  }
})}

Tabs.TriggeronFocus 코드를 잘 읽어보면 context.activationMode가 'manual'이 아닐때만 onValueChange가 실행되는 것을 알 수 있다.

Radix UI의 공식문서에서도 확인할 수 있는데, 이 activationMode를 'manual'로 설정하면 탭이 포커스 될때는 활성화되지 않는다.

<Tabs.Root value={selectedTab} onValueChange={(value) => handleChangeValue(value)} activationMode="manual">

이렇게 속성 하나를 추가하여 간단히 해결할 수 있었다.

끝~!!!


참고자료

profile
주니어 프론트엔드 개발자입니다 😎

0개의 댓글