이벤트 버블링(event bubbling) 현상과 억제

이영섭·2025년 6월 11일

bovo 프로젝트

목록 보기
9/10

문제

독서 감상 공유 플랫폼(이하 bovo 프로젝트)에서 제공되는 서비스인 독서토론방내에서 독서 기록 공유 모달 내 독서 기록 목록들은 Accordian으로 구성되어 있다.
Accordian을 클릭시에는 독서 기록에 대한 상세 내용이 나타나지만, 공유하려는 독서 기록을 선택하는 checkbox를 check할 시 의도치 않게 독서 기록 상세 내용도 같이 펼쳐지는 현상이 나타났다.

  • 독서기록 공유 모달의 이벤트 버블링 현상

초기에는 checkbox의 check시에만 check되고 독서 기록에 대한 상세내용이 보이는 동작이 동시에 진행되므로 다시 한번 확인하는 차원에서 좋지 않을까라는 생각에 그대로 두었다. 하지만 기능의 명확한 구분을 위해서 checkbox에 check시에는 check만 되고 독서 기록 상세 내용은 나타나지 않도록 코드를 수정하기로 결정했다.

문제의 원인

문제의 원인을 이해하기 위해서는 우선 이벤트 버블링 현상(event bubbling)을 이해할 필요가 있다.

이벤트 버블링 현상이란?

요약
특정 DOM 요소에 이벤트가 발생했을 때, 해당 이벤트가 상위 요소로 전파되는 현상이벤트 버블링(Event Bubbling) 현상이란 한다.

DOM 이벤트 흐름 이해하기: 캡처링, 타깃, 버블링

웹 페이지에서 사용자가 마우스를 클릭하거나 키보드를 누르는 등의 특정 동작을 수행하면, 브라우저 내부에서는 일련의 이벤트 처리 과정이 시작된다. 이 과정을 이해하는 것은 자바스크립트를 이용한 동적인 웹 애플리케이션 개발에 매우 중요하며, 특히 e.stopPropagation()과 같은 메서드의 동작 원리를 파악하는 데 필수적이다.

이벤트가 DOM(Document Object Model) 트리에서 어떻게 동작하는지 세 가지 단계로 나누어 설명하겠다.

1. 캡처링 (Capturing) 단계
사용자가 웹 페이지의 특정 지점을 클릭했다고 가정해보자. 브라우저는 이 클릭 이벤트를 감지하고, 해당 이벤트에 대한 Event 객체를 생성한다. 이 이벤트 객체는 window 객체에서 시작하여 document를 거쳐, 클릭된 실제 DOM 요소(이벤트 타깃)를 향해 DOM 트리를 따라 가장 바깥에서부터 안쪽으로 아래로 전파된다. 이 과정에서 이벤트는 먼저 <html>, <body>, <div> 등과 같이 타깃 요소를 감싸는 모든 조상 요소들을 통과하게 된다. 이 단계를 이벤트 캡처링 단계라고 부른다.

2. 타깃 (Target) 단계
캡처링 단계를 거쳐 이벤트가 실제로 발생한 DOM 요소, 즉 클릭된 element에 도달하는 단계를 타깃 단계라고 한다. 이 타깃 요소에 해당 이벤트 타입(예: 'click')에 대한 이벤트 리스너가 등록되어 있다면, 이 단계에서 그 리스너에 연결된 콜백 함수가 실행된다. 이벤트는 이 타깃 요소에서 한 번만 '명중'한다.

3. 버블링 (Bubbling) 단계
타깃 단계에서 이벤트가 처리된 후, 이벤트는 다시 타깃 요소에서부터 document 객체까지 DOM 트리를 따라 상위 조상 요소들로 위로 전파된다. 이 단계를 이벤트 버블링 단계라고 부른다. 이 과정에서 타깃 요소를 감싸는 모든 부모 요소들이 이 이벤트에 대해 "알게 되고" 각 요소에 등록된 이벤트 리스너가 있다면 순서대로 실행된다.

왜 이런 이벤트 전파가 발생할까?
이벤트 전파는 DOM 트리의 근간이 되는 HTML 태그들이 여러 겹으로 중첩되어 있기 때문에 발생한다. 사용자가 가장 안쪽에 있는 하위 HTML 요소를 클릭하더라도, 그 하위 요소는 결국 상위 HTML 요소들로 감싸져 있다. 따라서 클릭 이벤트는 마치 물방울(버블)이 수면으로 떠오르듯, 클릭된 요소에서 시작하여 이를 포함하는 모든 상위 요소들에게까지 전달되는 것이다. 이것이 우리가 e.stopPropagation()을 사용하지 않으면, 하위 요소의 클릭이 상위 요소의 클릭 리스너까지 트리거하는 이유이다.

e.stopPropagation()의 역할
e.stopPropagation() 메서드는 위에서 설명한 이벤트 전파(버블링 및 캡처링)를 중간에서 멈추게 한다. 특정 요소에서 이 메서드가 호출되면, 이벤트는 더 이상 상위(버블링 시)나 하위(캡처링 시)로 전파되지 않아 다른 요소의 이벤트 리스너가 실행되는 것을 방지할 수 있다. 이는 특정 UI 요소의 독립적인 동작을 보장할 때 유용하지만, 이벤트 흐름을 예측하기 어렵게 만들 수 있으므로 신중하게 사용해야 한다.
(=> 즉 상위 요소에서 이벤트를 관측하기 힘들다.)

이벤트 버블링 현상이 일어난 컴포넌트 코드

현재 문제가 발생되는 컴포넌트 코드는 독서 기록 목록을 보여주는 TemplateListItem이라는 컴포넌트 코드로 인해 발생된 것이다.

  • TemplateListItem 컴포넌트
<AccordionSummary ... onClick={handleAccordionSummaryClick}>
    <Checkbox ... onChange={handleCheckboxChangeWithPrevent} />
    <Typography>...</Typography>
</AccordionSummary>

보시는 것과 같이 MUI의 태그 중 하나인 AccordionSumary 태그 내에 check를 할 수 있는 checkbox가 존재하여 checkbox의 check 이벤트는 Accordion에 전파된다.
물론 handleAccordionSummaryClick 함수와 handleCheckboxChangeWithPrevent 함수는 아래의 코드에서 알 수 있듯이 e.stopPropagation을 통해 상위 요소의 이벤트 전파를 막도록 하고 있다.

const handleCheckboxChangeWithPrevent = (e) => {

  e.stopPropagation(); // 클릭 이벤트가 Accordion에 전달되지 않도록 막기

  handleCheckboxChange(e.target.checked); // **체크박스 상태 변경** (변경된 부분)

};

const handleAccordionSummaryClick = (e) => {
  
  // 클릭 이벤트가 AccordionSummary에만 전파되도록
  e.stopPropagation();
};

하지만 문제는 AccordionSummary는 클릭을 통해 Accordion의 확장/축소 상태를 제어하는 자체적인 로직을 가지고 있기 때문에 Checkbox의 onClick 이벤트를 직접적으로는 받지 않더라도, 그 내부 자식 요소에 대한 클릭을 '감지'하고 자신의 내부적인 onChange 핸들러 (handleAccordionChange)를 트리거한다.

이에 따라 accordion 클릭시에는 독서기록에 대한 상세내용만 보이지만 checkbox 클릭시에는 accordion에 영향을 주어 check 뿐만 아니라 독서기록 상세내용이 보이게 되는 것이다.

해결

해결 방법

결국 핵심은 checkbox의 이벤트 전파를 막는 것이다.
현재 프로젝트 상황에서 checkbox의 이벤트 전파를 막는 방법은 3가지가 있다.

1. checkbox의 onClick 이벤트에도 e.stopPropagation을 사용한다.
문제의 원인이 되는 것은 Accordion의 확장/축소 이벤트가 AccordionSummary 내에서 견고하게 상태관리를 하기 때문에 기존 코드에서 checkbox의 onChange 이벤트가 bubbling되는 것을 막았더라도 결국 클릭이라는 마우스 이벤트로 인해 Accordion의 확장/축소가 야기되는 것으로 추정되며, 이 onClick 이벤트에도 e.stopPropagation을 통해 이벤트 전파를 막아야 한다.

2. checkbox를 별도의 컴포넌트로 분리
checkbox를 별도의 컴포넌트로 분리하여 내부에서 checkbox의 onClick 이벤트를 e.stopPropagation으로 막고, 구조적으로는 e.stopPropagation()을 캡슐화하는 방식이다. 이 방식은 언뜻보면 1번과 큰 차이를 보이지 않는 것처럼 보이나 별도의 컴포넌트로 분리함으로써 AccordionSummary의 handleAccordionSummaryClick함수(e.stopPropagation 존재)가 더이상 필요하지 않게 된다.
Accordion의 확장/축소는 native한 onChange DOM 이벤트가 아닌 prop상의 이벤트 함수이기 때문에 별도의 checkbox컴포넌트 분리만으로도 Accordion의 확장/축소 이벤트는 더이상 checkbox의 영향을 주지 않는다.

3. 컴포넌트 구조 변경을 통한 형제 컴포넌트화
이 방법은Checkbox와 Accordion을 서로 형제 컴포넌트로 만드는 방식이다.
이벤트 버블링은 부모-자식 관계에서 발생하기 때문에 형제 컴포넌트 사이에서는 직접적인 이벤트 버블링이 일어나지 않으므로, 이 둘을 별도의 컴포넌트로 나란히 배치하면 체크박스와 accordion의 이벤트가 서로에게 영향을 주지 않는다.

  • TemplateListItem.jsx (개념적 구조)
return (
    <Box className={styles.templateListItem}>
        <MemoCheckbox checked={checked} onChange={handleCheckboxChange} /> {/* ✅ 독립된 체크박스 */}
        <MuiAccordion /> {/* ✅ 독립된 아코디언 */}
    </Box>
);

코드 수정

이 3가지 방법 중 1번째는 제외시키고 시작하였다. 1번째 방법은 e.stopPropagation이 너무 남용되는 방식이라 생각했기 때문이다.
2번째와 3번째 방식의 차용을 고민하였고, 결국 2번째 방법을 채택하였다.
가장 e.stopPropagation을 사용하지 않는 방식은 3번째 방법이었으나, 과연 UI적으로 적절한지 의문이 들었다. 사실상 알아볼 수 있는 방법이 생각나지 않아 gemini를 사용하여 UI적으로 어떤것이 적절한지 물어봤고, UI상으로는 checkbox랑 accordion이 별도로 분리되는 경우는 잘 사용하지 않는다는 답변이 돌아왔기 때문에 2번째 방법을 사용하게 되었다.

그에 따라 수정된 코드는 다음과 같다.

  • MemoCheckbox 컴포넌트
import PropTypes from 'prop-types';
import Checkbox from '@mui/material/Checkbox';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';

const MemoCheckbox = ({ checked, onChange }) => {
    // 이 컴포넌트에서 모든 체크박스 관련 이벤트 처리를 담당
    const handleClick = (e) => {
        e.stopPropagation(); 
    };

    const handleChange = (e) => {
        onChange(e.target.checked);
    };

    return (
        <Checkbox
            checked={checked}
            onClick={handleClick} // 클릭 이벤트 버블링 방지
            onChange={handleChange} // 실제 체크 상태 변경 처리
            icon={<CheckBoxOutlineBlankIcon sx={{ fontSize: "2rem" }} />}
            checkedIcon={<CheckBoxIcon sx={{ fontSize: "2rem", color: "#739CD4" }} />}
        />
    );
};

MemoCheckbox.propTypes = {
    checked: PropTypes.bool.isRequired,
    onChange: PropTypes.func.isRequired,
};

export default MemoCheckbox;

MemoCheckbox라는 checkbox 이벤트만 담당하는 별도의 컴포넌트로 분리시켜 내부의 checkbox의 click이벤트를 e.stopPropagation함수로 이벤트 전파를 막는다.

이에 따라 TemplateListItem 컴포넌트는 내부에 존재하는 함수의 수가 좀 더 간소화된다.

  • TemplateListItem 컴포넌트
const TemplateListItem = ({ checked, handleCheckboxChange, memo }) => {
    const [expanded, setExpanded] = useState(false);

    const handleAccordionChange = (event, isExpanded) => {
        setExpanded(isExpanded);
    };

    return (
        <Box 
            className={styles.templateListItem}
            sx={{
                backgroundColor: checked ? "#739CD4" : "#FFFFFF",
                transition: "background-color 0.3s ease", 
            }}
        >
            <Accordion expanded={expanded} onChange={handleAccordionChange}>
                <AccordionSummary
                    expandIcon={<ExpandMoreIcon sx={{ fontSize: "2.625rem" }} />} // 화살표 아이콘
                    aria-controls="panel1a-content"
                    id="panel1a-header"
                >
                    <MemoCheckbox 
                        checked={checked} 
                        onChange={handleCheckboxChange}
                    />
                    <Typography
                        sx={{
                            fontSize: "1.75rem",
                            letterSpacing: "0.0175rem",
                            width: "27rem",
                            height: "2.5rem",
                            overflow: "hidden",
                            whiteSpace: "nowrap",
                            textOverflow: "ellipsis", 
                        }}
                    >
                        {memo.memo_Q} {/* 질문 표시 */}
                    </Typography>
                </AccordionSummary>
                <AccordionDetails>
                    <Typography
                        sx={{
                        fontSize: "1.5rem",
                            letterSpacing: "0.0175rem",
                            color: "#739CD4",
                        }}
                    >
                        {memo.memo_A} {/* 답변 표시 */}
                    </Typography>
                </AccordionDetails>
            </Accordion>
        </Box>
    );
};

export default memo(TemplateListItem);

Accordion의 확장/축소 관련 이벤트는 MemoCheckbox의 prop 이벤트로 전달되지 않기 때문에 checkbox에 영향이 가지 않게 된다.

이렇게 코드를 수정한 후 화면을 살펴보면 기능이 명확히 구분되는 것을 확인할 수 있다.

profile
신입 개발자 지망생

0개의 댓글