터치와 클릭, 우리 깐부잖아


터치와 클릭을 사용하지 않는 하루를 상상해보자. 과연 어떨까? 필자는 두 동작 없는 하루를 이제는 상상할 수 없다. 사용자일 때는 무의식적으로 사용해서 이 두 동작의 관계에 대해 깊게 생각해 본 적이 없었다. 프론트엔드 개발자가 되고 터치와 클릭의 차이점 때문에 발생하는 문제를 맞닥뜨린 이후에서야 두 동작의 관계에 대해서 생각해보게 되었다.

이 글로 이제 막 프론트엔드 개발에 뛰어든 필자의 경험을 통해 터치와 클릭의 공통점과 차이점, 발생할 수 있는 문제와 그 해결 방안에 대해 공유하고자 한다.

문제의 발단

필자는 TOAST UI 오픈소스를 메인테이닝 하면서 아래와 비슷한 함수를 본 적이 있다.

function getConvertedEventType(type) {
  if (isMobile()) {
    if (type === 'mousedown') {
      type = 'touchstart';
    } else if (type === 'click') {
      type = 'touchend';
    }
  }

  return type;
}

이 함수는 모바일 기기의 경우 마우스 이벤트 대신 터치 이벤트를 사용하기 위해 이벤트 타입을 변환하는 기능을 한다. mousedown 이벤트 타입을 touchstart 이벤트 타입으로, click 이벤트 타입을 touchend 이벤트 타입으로 변경한다. 따라서 클릭에 대한 이벤트 리스너는 터치에 대한 이벤트 리스너로 변경된다.

이벤트 타입 변경 함수를 사용한 방법은 지금까지 문제없이 동작했었다. 그러나 예상치 못한 문제가 발생했다.

TOAST UI DatePicker에서 이벤트 타입 변환 함수를 사용해 터치 이벤트 리스너를 사용하면 날짜를 touchend할 때 드롭다운 요소가 사라진다. 그러나 위 그림과 같이 드롭다운 요소가 사라진 후 그 아래에 있던 요소가 클릭 되는 문제가 발생한 것이다. 어떤 문제 때문에 이런 일이 일어난 걸까?

터치와 클릭

깐부라면서요!

무엇이 문제였는지 확인하기 위해 먼저 터치와 클릭에 대해서 알아보자. 지금까지는 이벤트 타입 변환 함수를 문제없이 사용했다. 터치와 클릭은 어떤 공통점이 있어서 이벤트 타입을 1:1로 변환할 수 있고, 어떤 차이점이 있어서 이벤트 타입을 변환해야 할까?

공통점

터치와 클릭은 화면과 상호작용을 위해 화면상의 요소를 눌렀다 뗀다는 단순하지만 아주 중요한 공통점을 가지고 있다. 즉, 두 행위는 모두 누르는 동작과 떼는 동작의 시퀀스이다. 이 때문에 이벤트 타입 변환 함수처럼 이벤트 타입을 변환하는 것만으로 터치와 클릭을 바꿔서 지원할 수 있는 것이다.

차이점

두 행위가 그렇게 유사하다면 왜 이벤트 타입 변환 함수가 필요한 것일까?

  1. 마우스는 화면 위에 항상 떠 있고, 터치는 그렇지 않기 때문이다. 한 화면 내에 떨어져 있는 두 요소를 클릭 또는 터치하는 경우를 생각해보자. 마우스를 이용해 클릭하기 위해서는 우선 커서를 첫 번째 요소 위로 이동한 후 클릭하고, 다시 커서를 두 번째 요소 위로 이동해 클릭해야 한다. 즉, 두 클릭 행위를 이어주는 mousemove 동작을 수행할 수밖에 없다. 터치의 경우에는 첫 번째 요소 위로 손가락을 옮긴 후 터치하고, 이어서 어떤 손가락이든 두 번째 요소 위로 옮긴 후 터치한다. 화면은 두 터치 행위를 이어주는 동작을 알 수 없다.
  2. 클릭은 단 하나의 포인터를 이용해 상호작용하지만, 터치는 2개 이상의 터치 포인터로 상호작용할 수 있다. 이로 인해 모바일 장치를 이용할 때는 줌-인, 아웃, 회전 등의 멀티 터치 제스쳐를 사용할 수 있다.
  3. 마우스는 단일 포인터라는 한계를 가지고 있다. 이를 극복하기 위해 우클릭 버튼, 휠 등의 보조 장치를 가지고 있고, 키보드와 조합을 통한 보조 동작을 지원한다. 그러나 터치는 누르는 동작, 이동하는 동작, 떼는 동작만을 이용해 모든 동작을 수행해야 한다. 여기서 발생하는 가장 큰 차이점이 누르고 움직인 후 떼는 동작이다. 마우스의 경우 이 동작을 통해 일반적으로 드래그 앤 드롭을 수행할 수 있지만, 터치는 스크롤을 수행할 수 있다.

모바일 장치가 대중화되던 초기에는 앞서 말한 공통점과 차이점을 고려하지 않은 OS나 애플리케이션을 쉽게 볼 수 있었다. 이것들은 사용자에게 불편함과 어색함을 느끼게 했고 원성을 들을 수밖에 없었다. 결국 이 문제들은 머지않아 우리가 알고 있는 방식으로 수정되었다.

진짜 원인

정말 아직도 같다고 믿나?

그러나 필자는 문제를 맞닥뜨리고 말았다. 무엇 때문에 발생한 문제인지 알아보기 위해 자바스크립트에서 클릭과 터치가 어떻게 다른지 알아보자.

연속된 동작

클릭은 이에 대한 이벤트 타입으로 click이 있지만, 터치는 그렇지 않다. 그러니 대신 터치가 시작될 때 발생하는 이벤트인 touchstart 이벤트 타입을 이용해 터치와 클릭을 수행해보자.

크롬 개발자 도구를 켜고 장치 툴바 토글()을 클릭해 터치 동작도 수행해보자.

위 예제에서 수행하는 코드는 아래와 같다.

function createParagraph(text) {
  const el = document.createElement('p');
  el.innerText = text;

  return el;
}

const printEl = document.getElementById('print');

document.addEventListener('click', () => {
  printEl.appendChild(createParagraph('click'));
});

document.addEventListener('touchstart', () => {
  printEl.appendChild(createParagraph('touch'));
});

마우스를 이용해 클릭할 때는 click 이벤트만 발생하지만, 터치할 때는 touchstart 이벤트 이후에 click 이벤트도 발생한다. 그렇다면 터치할 때는 항상 click 이벤트가 같이 발생하는 걸까?

터치와 클릭은 누르는 동작과 떼는 동작의 시퀀스라고 했다. 자바스크립트의 이벤트에서도 각 구분 동작에 대한 이벤트를 따로 발생시킨다. 터치와 관련된 이벤트 타입으로는 touchstart, touchemove, touchend가 있고, 클릭과 관련이 있는 이벤트 타입으로는 mousedown, mousemove, mouseup, click이 있다.

그러면 각 이벤트가 어떤 순서로 발생하는지 확인해보자.

이번에도 터치 동작을 수행해보자.

위 예제에서 수행하는 코드는 아래와 같이 발생한 이벤트 타입을 출력한다.

function createParagraph(text) {
  const el = document.createElement('p');
  el.innerText = text;

  return el;
}

const printEl = document.getElementById('print');

['touchstart', 'touchmove', 'touchend', 'mousedown', 'mousemove', 'mouseup', 'click'].forEach(
  (eventType) => {
    document.addEventListener(eventType, () => {
      printEl.appendChild(createParagraph(eventType));

      // 스크롤을 최하단으로 이동시켜준다.
      window.scrollTo(0, document.body.scrollHeight);
    });
  }
);

수행 결과, 우리는 위와 같은 순서로 이벤트가 발생한다는 것을 알았다. 특이한 점은 단순 터치 동작을 수행할 때만 click 이벤트가 같이 발생하며, 심지어 마우스 이벤트도 같이 발생한다는 것이다. (일반적인 이벤트 발생 순서이며, 브라우저마다 차이가 있을 수 있다.) 이는 단순 터치를 수행해도 마우스 이벤트가 발생하므로 이벤트 타입 변환 함수를 사용하지 않아도 우리가 원하는 동작을 수행할 수 있다는 것을 의미한다.

터치-클릭 딜레이

그런데 왜 이벤트 타입 변경 함수를 사용해서 클릭에 대한 이벤트 리스너를 터치에 대한 이벤트 리스너로 변경할까? 일반적으로 click 이벤트는 터치 이벤트 발생 직후에 발생하는 것이 아니기 때문이다.

실제로 그런지 확인해보자.

/*
 * 딜레이를 확인하기 위해 console.time과 console.timeEnd를 사용했다. 자세한 설명은 아래 링크를 확인하자.
 * https://developer.mozilla.org/ko/docs/Web/API/Console/time
 * performance.now를 이용해 측정할 수도 있다.
 * https://developer.mozilla.org/ko/docs/Web/API/Performance/now
 */

document.addEventListener('touchstart', () => {
  console.time('touch-click delay');
});

document.addEventListener('click', () => {
  console.timeEnd('touch-click delay');
});

위 코드는 touchstart 이벤트의 발생과 click 이벤트의 발생 간의 딜레이를 확인하기 위한 코드다. 이를 실행해보니 touchstart 이벤트 발생 이후 click 이벤트의 발생까지 약 300ms의 딜레이가 있는 것을 알 수 있다. 그러나 사용자가 즉각적이라고 느끼는 최대 지연 시간은 100ms이다. 그렇다면 왜 사용자가 반응이 굼뜨다고 느낄 만큼 딜레이를 넣었을까?

터치 동작에는 마우스 동작과 달리 다양한 제스쳐가 있으며 더블 탭 줌도 그런 다양한 제스처 중 하나다. 더블 탭 줌을 시도하려 할 때 해당 위치에 버튼이 있는 경우를 생각해보자.

위 그림처럼 만약 딜레이가 없다면 우리는 첫 번째 터치에서 버튼을 클릭하게 될 것이다. 버튼이 우리가 하려는 동작에 아무런 영향을 미치지 않는다면 괜찮겠지만 그렇지 않다면 우리는 제스쳐를 수행할 수 없을 것이다.

딜레이가 있다면 어떻게 될까? 위 그림을 보자. 첫 번째 터치에서 버튼을 터치하지만, 아직 클릭은 되지 않은 상태이다. 그사이에 다시 한번 터치하면 클릭은 수행되지 않고 우리가 원하는 대로 제스쳐를 수행할 수 있다.

그러나 앞서 필자의 경험처럼 딜레이로 인해 문제가 발생할 수도 있다. TOAST UI DatePicker의 날짜를 터치하면 touchend 이벤트가 발생하고 드롭다운 요소는 사라진다. 그러나 300ms 이후에 해당 위치에서 click 이벤트가 발생한다. 그러나 우리가 클릭을 의도했던 드롭다운 요소는 더 이상 없으므로 그 아래에 있던 버튼이 클릭 되는 것이다.

해결하기

우리 깐부 할까?

문제점에 대해 봤으니 이제는 문제를 해결하자.

딜레이 제거

일반적으로 모바일 기기에 보여줄 페이지는 모바일 기기에 최적화해 만든다. 이 경우 페이지 내 요소의 크기가 충분히 커서 더블 탭 줌 등의 제스쳐가 필요 없을 수 있다. 2014년 배포된 Chrome 32버전부터는 모바일 페이지에 최적화된 사이트의 경우 딜레이를 만들지 않는다. (다른 브라우저도 대부분 지금은 동일하게 동작한다.) 뷰포트의 너비가 기기 화면의 너비와 동일하다면 이를 모바일에 최적화된 사이트라고 한다.

그렇다면 딜레이 예제 페이지 <head> 태그 내에 아래 구문을 추가해보자.

<meta name="viewport" content="width=device-width" />

단순히 뷰포트의 너비만 지정해주었을 뿐인데 앞서 보았던 300ms의 딜레이가 제거된 것을 볼 수 있다. 권장되는 방법은 아니지만 아래 스타일을 사용하여 해당 요소에 대해 터치-클릭 딜레이를 제거할 수도 있다.

* {
  touch-action: manipulation;
}

마우스 이벤트 제거

그러나 TOAST UI처럼 페이지 전체가 아닌 내부에 들어갈 요소를 만든다면 위 방법은 효과적이지 않다. 그러므로 이벤트 타입 변환 함수를 사용해 마우스 이벤트 리스너를 터치 이벤트 리스너로 변경하는 방법을 사용해보자. 그러나 마우스 이벤트는 계속 발생하므로 필자의 경험처럼 의도하지 않은 사이드 이펙트가 유발될 수 있다. 앞서 본 이벤트 발생 순서에서 다른 터치 동작처럼 마우스 이벤트를 발생시키지 않는다면, 이런 문제를 감수하지 않아도 된다.

이는 touchstart 또는 touchend에서 preventDefault()를 호출함으로써 해결할 수 있다.

이번에도 터치 동작도 수행해보자.

위 예제에서 수행하는 코드는 아래와 같다.

function createParagraph(text) {
  const el = document.createElement('p');
  el.innerText = text;

  return el;
}

const printEl = document.getElementById('print');

document.addEventListener('click', () => {
  printEl.appendChild(createParagraph('click'));
});

document.addEventListener('touchend', (ev) => {
  ev.preventDefault();
  printEl.appendChild(createParagraph('touch'));
});

수행 결과 마우스 클릭 시에는 click 이벤트만 발생하지만, 터치 시에는 이전 예제와 달리 터치 이벤트만 발생한다. 위 예제는 처음 예제와 거의 유사한 코드이지만, touchstart 대신 touchend 이벤트에 리스너를 할당하고 내부에서 마우스 이벤트의 발생을 방지하기 위해 preventDefault()를 호출한다.

주의할 점

앞서 "마우스 이벤트 제거"에서 preventDefault()를 호출하는 것은 마우스 이벤트를 발생시키지 않기 위한 것임을 알았지만, 왜 touchstarttouchend로 바꿨을까? addEventListener의 세 번째 인자의 옵션 중 하나인 passive와 관련이 있다. 사양에 따르면, passive 옵션은 false를 기본값으로 가지지만 일부 브라우저(특히 크롬과 파이어폭스)에서는 스크롤 성능 향상을 위해서 touchstarttouchmove 이벤트에 대해 기본값이 true로 설정된다. 만약 touchstart 이벤트를 사용한다고 했을 때, passive 옵션이 true로 설정되어 있으면 콜백 함수 내부에서 preventDefault()를 호출해도 콘솔 경고만 출력할 뿐 preventDefault()가 제대로 동작하지 않는다.

또한, 리스너에 첫 번째 인자로 전달되는 이벤트 객체도 MouseEventTouchEvent로 다르다. 따라서 MouseEvent에서 사용하던 프로퍼티를 사용하기 위해서는 TouchEventtouches, targetTouches, changedTouches 중 적절한 프로퍼티를 선택하고 해당 프로퍼티 내부에서 다시 적절한 Touch 객체를 찾아 사용해야 한다.

마치면서

터치와 클릭, 우리는 깐부잖아.

이 글에서는 필자가 경험한 문제와 이를 해결하기 위해 이벤트 타입 변환 함수를 올바르게 사용하여 코드 중복은 최소화하면서 터치와 클릭을 문제없이 지원하는 방법도 같이 보았다. 터치 또는 클릭 시 이벤트 발생 순서를 잘 알고, 딜레이의 제거와 마우스 이벤트 제거 등을 적절히 활용하는 것을 잊지 말자.

모바일 시장의 규모가 데스크톱을 처음 역전한 이후, 모바일 웹 브라우징의 중요성은 계속해서 증가해왔다. 그러나 아직은 데스크톱 브라우징의 규모도 작지 않아 모바일 브라우징에만 집중할 수는 없는 상황이다. 따라서 웹 브라우징의 사용자 상호작용에서 가장 기본인 터치와 클릭을 명확히 구분해서 올바르게 사용하는 것이 매우 중요하다.

프론트엔드 개발에 뛰어들기 시작한 많은 개발자들이 이 글을 모바일 웹 브라우징에서 더 많은 기능을 지원하기 위한 초석으로 삼을 수 있기를 바란다.

김대연2022.01.06
Back to list