최근 프로젝트에서 체크박스 UI를 구현하는 과정에서 아주 킹받는 버그를 마주쳤습니다.
이번 글에서는 그 해결 과정을 공유해볼까 합니다.
얼마 전 정부 부처 필터 기능을 가진 체크박스 UI를 만들고 있었습니다.
체크박스를 만드는 건 어렵지 않죠.
당연히 윈도우나 맥, 안드로이드 환경(웹, 앱 모두!)에서는 문제없이 잘 동작했어요.
그런데... iOS와 iPadOS의 브라우저와 앱에서는 이상하게 작동하더라고요.

정확히 말하면, 국토부부터 과기부까지 빠르게 연속으로 체크를 하면, 가끔씩 다음 항목이 체크되지 않고 이전 항목이 체크 해제되는 괴이한 현상이었습니다.
처음엔 React에서 함수형 업데이트를 해주지 않아서라고 생각했습니다.
setState(value) 방식으로 상태 업데이트를 하면 문제가 생길 때가 많으니까요.
근데... 잘 썼더라고요?
function handleDepartmentChange(value: DepartmentFilterType[]) {
setValues((values) => ({ ...values, departments: value }));
}
"그럼 범인은 누구지...?" 하고 한참 헤맸습니다.
이 이슈를 유심히 살펴보면서 몇 가지 단서들을 추려냈습니다.
한 가지 가설을 세웠습니다.
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 터치 이벤트의 특징을 경험한 재밌는 삽질이었습니다.
여러분들도 이 글이 도움이 되어 삽질을 조금이라도 덜 하시길 바랍니다!