아이폰에서 체크박스가 이상해요!

모나·2025년 3월 16일
post-thumbnail

최근 프로젝트에서 체크박스 UI를 구현하는 과정에서 아주 킹받는 버그를 마주쳤습니다.
이번 글에서는 그 해결 과정을 공유해볼까 합니다.

🤔 체크박스야, 너 왜 그러니?

얼마 전 정부 부처 필터 기능을 가진 체크박스 UI를 만들고 있었습니다.

체크박스를 만드는 건 어렵지 않죠.
당연히 윈도우나 맥, 안드로이드 환경(웹, 앱 모두!)에서는 문제없이 잘 동작했어요.
그런데... iOS와 iPadOS의 브라우저와 앱에서는 이상하게 작동하더라고요.

정확히 말하면, 국토부부터 과기부까지 빠르게 연속으로 체크를 하면, 가끔씩 다음 항목이 체크되지 않고 이전 항목이 체크 해제되는 괴이한 현상이었습니다.

🧑‍💻 React 상태 문제가 아니라고?

처음엔 React에서 함수형 업데이트를 해주지 않아서라고 생각했습니다.
setState(value) 방식으로 상태 업데이트를 하면 문제가 생길 때가 많으니까요.
근데... 잘 썼더라고요?

function handleDepartmentChange(value: DepartmentFilterType[]) {
  setValues((values) => ({ ...values, departments: value }));
}

"그럼 범인은 누구지...?" 하고 한참 헤맸습니다.

🕵️‍♂️ iOS, 너 수상해...

이 이슈를 유심히 살펴보면서 몇 가지 단서들을 추려냈습니다.

  • iOS와 iPadOS에서만 발생
  • Safari뿐만 아니라 Chrome에서도 동일하게 발생.
    (iOS의 Chrome은 Chromium 엔진이 아니라 Webkit 엔진이다.)
  • 키보드/마우스를 붙여서 사용하면 정상, 순수한 터치 환경에서만 발생.
  • 천천히 터치하면 아무 문제 없는데, 빠르게 터치하면 발생.
  • 버그 발생 시 이전 체크박스에 다시 Ripple 효과(MUI 터치 효과)가 나타남.

한 가지 가설을 세웠습니다.

iOS가 체크박스를 빠르게 누를 때 두 개가 너무 가까워서 하나의 더블 터치로 인식한다.

그래서 바로 로그를 찍어보기로 했습니다.

🖥 본격 수사 시작

(다행이도!!) iOS의 Safari는 macOS의 Safari를 통해서 로그 확인이 가능합니다.
바로 체크박스 UI에 있는 웬만한 이벤트 리스너(onTouchStart, onTouchEnd, onChange, onDoubleClick)에 로그를 찍었습니다.

로그 결과는 이랬습니다:

로그를 보면 onTouch 관련 이벤트는 MOLIT, MOE를 차례로 터치한 것으로 잘 나옵니다.
그런데 onChange를 보면 이상하죠. MOLIT을 두번 터치한 것으로 인식했습니다.
심지어 onDoubleClick 이벤트가 발생했습니다.

더블 터치로 인식하는게 확실해졌습니다.

🎯 해결은 간단했다

이걸 저만 알게 된게 아닐텐데 검색을 해도 잘 안나오더라고요.
사실 뭐라고 검색해야 할지 몰랐던게 컸을 것 같아요.

일단 임시로 모바일 환경에선 onTouchEnd 이벤트로 상태 변경을 했더니 문제가 사라졌습니다.

<FormControlLabel
  key={index}
  control={<Checkbox />}
  checked={value.includes(department)}
  onTouchEnd={() => handleChange(department)}
  label={t(`filter.departmentFilter.${department}`)}
/>

근데 이렇게 하면 마우스로 클릭했을 때는 동작 안 하는 문제가 생기죠.
그래서 "그럼 두 이벤트를 다 쓰면 되지 않나?"라고 생각했는데, 그렇게 하면 상태 업데이트가 두 번 일어나더라고요.

그러다가 떠오른 게 event.stopPropagation()

이벤트가 중복으로 발생하는 거니까, 이벤트 전파를 막아버리면 되겠구나!

그래서 onTouchEnd 이벤트 리스너에 event.stopPropagation()을 추가했더니, 딱 한 번만 깔끔하게 이벤트가 발생하며 문제를 완벽히 해결할 수 있었답니다.

🎉 최종 해결 코드 공개!

그렇게 탄생한 최종 코드는 이렇게 생겼어요.

export default function FilterDepartment() {
  // 기타 코드...

  function handleChange(event: SyntheticEvent, filterType: DepartmentFilterType) {
    event.stopPropagation();
    event.preventDefault();

    // 기타 코드...
    
    onChange(sortedValues);
  }

  return (
    <List disablePadding>
      <ListSubheader
        disableSticky
        component={ButtonBase}
        onClick={toggle}
        sx={{
          display: 'flex',
          justifyContent: 'space-between',
          alignItems: 'center',
          backgroundColor: 'inherit',
          width: '100%',
        }}
      >
        {t('filter.departmentFilter.title')}
        {open ? <ExpandLess /> : <ExpandMore />}
      </ListSubheader>
      <Collapse in={open} unmountOnExit>
        <FormGroup sx={{ pl: 1 }}>
          {departments.map((department, index) => (
            <FormControlLabel
              key={index}
              control={<Checkbox />}
              checked={value.includes(department)}
              onChange={(event) => handleChange(event, department)}
              onTouchEnd={(event) => handleChange(event, department)}
              label={t(`filter.departmentFilter.${department}`)}
            />
          ))}
        </FormGroup>
      </Collapse>
    </List>
  );
}
/>

이렇게 해서 결국 iOS에서만 나타나는 괴상한 체크박스 문제를 완벽히 해결했습니다.

🚀 결론

아주 독특하고 미묘한 iOS 터치 이벤트의 특징을 경험한 재밌는 삽질이었습니다.
여러분들도 이 글이 도움이 되어 삽질을 조금이라도 덜 하시길 바랍니다!

0개의 댓글