[Web] 의도하지않은 click 이벤트 발생 문제 해결하기

hyeondoonge·2024년 3월 15일
0

문제 상황

프로젝트를 진행하면서, 여러 이미지들을 슬라이드해서 볼 수 있는 캐러셀을 직접 구현해보았다. 여기에 추가적인 기능으로, 캐러셀을 사용자가 선택하면 캐러셀을 화면에서 지우거나 편집하는 등의 메뉴를 볼 수 있는 기능이 제공된다.

구현하면서 캐러셀의 슬라이드 및 선택 상호작용이 함께 발생하는 문제를 식별했다.
캐러셀을 슬라이드할 경우 사용자는 이미지가 슬라이드되는 결과만을 기대할텐데, 요소가 동시에 선택되어 메뉴가 보이게되면서 기대하지 않은 동작으로 인해 사용자 경험이 떨어진다는 느낌을 받았다.

원인과 해결방안을 함께 살펴보자.

문제 원인

아래와 같은 코드로 작성될 경우 문제가 발생했다.

<!DOCTYPE html>
<html>
  <head></head>
  <body>
      <my-wrapper>
          <my-carousel></my-carousel>
      </my-wrapper>
  </body>
  <script>
      customElements.define('my-wrapper', class extends HTMLElement {
    	constructor() {
    		this.addEventListener('click', this.handleClick)
    	}
    	handleClick() {
            // 선택된 요소 표시
        }
      })
    
      customElements.define('my-carousel', class extends HTMLElement {
    	constructor() 
    		this.addEventListener('mousedown', this.handleMouseDown)
        	this.addEventListener('mouseup', this.handleMouseUp)
    	}
        handleMouseDown() {
            // 슬라이드 시작 좌표 기록
        }
        handleMouseUp() {
            // 슬라이드 끝 좌표와 시작 좌표를 계산하여 슬라이딩 처리
        }
    })
  </script>
</html>

이 문제 원인은 이벤트 버블링으로 정의할 수 있다. 기본적으로 대부분의 이벤트들은 이벤트 버블링이 발생해, 발생한 이벤트들은 발생 최초 지점부터 시작해 html의 최상위 요소로 전파가 된다.

carousel을 슬라이드하기 위해서 마우스를 눌렀다가 조금 이동시키고 다시 떼는 상황을 떠올려보자.
(위 코드를 기반으로) 이 흐름에서는 3개의 이벤트 핸들러 모두 동작한다. click이벤트는 mouseup, mousedown이 차례대로 발생한 후 뒤따라 무조건 발생하는데, 이 이벤트에 대한 핸들러를 마침 상위 요소인 wrapper가 가지고 있기 때문이다.
이와 같이 이벤트 버블링이 발생함에 따라, 상위 wrapper 요소의 click 핸들러가 호출되게된다.

해결 방안

1. 전역 상태

vanilla js 기반 프로젝트 개발환경에서 직접 구현한 전역 상태 관리 시스템을 이용하는 방식이다.
(변경된 코드 부분만 작성한 점을 참고)

customElements.define('my-wrapper', class extends HTMLElement {
  // ...
  handleClick() {
    if (store.get('sliding_state')) {
      store.set('sliding_state', false) // 상태 초기화
      return
    }
    // 선택된 요소 표시
  }
})
    
customElements.define('my-carousel', class extends HTMLElement {	
  // ...
  handleMouseUp() {
    if (diffX === 0) {
      store.set('sliding_state', false)
      return
    }
    store.set('sliding_state', true)
    // 슬라이드 끝 좌표와 시작 좌표를 계산하여 슬라이딩 처리
  }
})
  • 컴포넌트 내부 로직 의존성
    -> 관계된 컴포넌트에 대한 이해가 필요
    -> carousel의 변경이 wrapper에 영향을 줄 수 있음
    -> 내부에서 사용하는 상태 시스템에 의존적
    -> 재사용성 저하
    -> 변화에 영향을 받을 수 있음
  • 간편한 방법이지만 state들과의 성격이 다른 값이라, 다른 대안이 필요.

2. stopPropagation()

  • 이벤트 버블링을 강제로 제한하는 방법으로, 간단하며 이해하기 쉽다.
  • carousel에서 click이 발생했을 때 시작 좌표, 끝좌표를 비교해 슬라이딩이 발생했다고 판단이 되면, 버블링을 막는다.
    그렇지 않고 순전히 동일한 좌표위에서의 상호작용으로 클릭이 발생한 것이면, 선택된 요소임을 표시해야하기 때문에 전파를 막지않는다.
 customElements.define('my-carousel', class extends HTMLElement {
    	constructor() 
    		this.addEventListener('mousedown', this.handleMouseDown)
        	this.addEventListener('mouseup', this.handleMouseUp)
            this.addEventListener('click', this.handleClick)
    	}
        handleMouseDown() {
            // 슬라이드 시작 좌표 기록
        }
        handleMouseUp() {
            // 슬라이드 끝 좌표와 시작 좌표를 계산하여 슬라이딩 처리
        }
        handleClick(event) {
        	if (diffX === 0) {
        		return
            }
            event.stopPropagation()
        }
    })
  • 컴포넌트 간 양방향 의존성 제거
  • 주의할 점은 상위 요소에서 발생한 이벤트 정보를 알아낼 필요가 있는 경우 (ex. 집계 시스템), 무분별하게 이 함수를 사용한다면, 수집이 제대로 이루어지지 않을 수 있다는 것이다.
    하지만 무조건 지양해야하는 것은 아니고 상황에 따라 달라질 수 있다.
    현재 상황의 경우, 슬라이드시 예상치 못하게 클릭 이벤트가 발생하기 때문에 이의 전파를 막도록 stopPropagation()을 호출하는 것도 괜찮은 방법일 수 있다.

3. Custom Event

  • 사용자 정의 이벤트를 정의하는 방식이다.
  • new CustomEvent(type[, options])과 같이 선언한다
  • Event 클래스를 상속받기 때문에 bubbles, cancelable과 같은 멤버변수에 접근가능함.
    * bubbles기본값이 false이기 때문에 버블링을 허용하려면bubbles: true를 설정해야함.
customElements.define('my-wrapper', class extends HTMLElement {
	// ...
	constructor () {
      let isSlide = false
      this.addEventListener('slide', () => {
        isSlide = true
      })
      this.addEventListener('click', () => {
        if (isSlide) 
          isSlide = false
          return
        }
        // 선택된 요소 변경
      })
  }
}

customElements.define('my-carousel', class extends HTMLElement {
	// ...
	handleMouseUp() {
		if (diffX === 0) {
      	  return
    	}
		const slideEvent = new CustomEvent('slide', { bubbles: true })
		event.target?.dispatchEvent(slideEvent)
	}
}
  • 하위(carousel) 컴포넌트에서 slide이벤트를 발생 => 상위(wrapper) 컴포넌트에서 isSlide 변수를 관리하고, click이 발생하면 slide로 인한 것인지 또는 그렇지 않은지를 판단 후 처리함
  • 1번 방안과 유사한 부분이 있긴하나, 차이점은 web api를 이용해서만 구현되어 재사용성있고, 영향을 받는 요소가 비교적 적음

결론

최종적으로 적용한 방식은 이벤트 전파를 중지하는 방식이었다.
전역 상태를 활용할 경우 의존 로직이 많고, CustomEvent 방안의 경우 stopPropagation() 방안에 비하여 로직이 복잡했기 때문이다.
만약 모든 이벤트들을 수신해야하는 것이 원칙이었다면 CustomEvent를 활용하는 방식을 선택했을 것이다.

참고

https://ko.javascript.info/bubbling-and-capturing
https://developer.mozilla.org/ko/docs/Web/API/CustomEvent

0개의 댓글

관련 채용 정보