이벤트 핸들러는 UI를 개발할 때면 항상 사용된다. 웹뿐만 아니라 네이티브 어플리케이션에서도 약간의 차이가 있을뿐 거의 동일한 추상이 사용된다. 자주 사용되는 만큼 중요하지만 사실 너무 자주 사용되다 보니 가볍게 생각하며 습관적으로 고민 없이 좋지 않은 코드를 만들곤 한다. 자주 사용된다는 건 코드의 양적인 비중도 크다는 것이고 그만큼 조금만 더 신경 쓴다면 큰 효과를 볼 수도 있다. 이런 이벤트 핸들러를 어떻게 다루면 좋은지에 대해 간략하게 정리해봤다.

이벤트 핸들러라고 해서 꼭 DOM 이벤트에만 해당하는 것은 아니며 모듈 간의 커플링을 줄이기 위해 사용하는 이벤트 버스나 RX, 각종 이벤트 관련 패턴에서 사용되는 이벤트 핸들러 모두 적용할 수 있다고 생각한다.

네이밍

네이밍은 프로젝트마다 비슷한 대상도 새롭게 고민해야 하는 경우가 많다. 프로젝트마다 다루는 도메인이 다르거나 은유가 달라질 수 있기 때문이다. 물론 범 프로젝트 적으로 자주 사용할 수 있는 네이밍도 있을 것이다. 네이밍에서 생길 수 있는 문제는 이렇게 버릇처럼 자주 사용되는 네이밍에서 발생한다. 그 버릇은 소속되어 있던 팀의 컨벤션 때문 일수도 있고 책에서 봤거나 혹은 다른 사람의 코드를 통해서도 배울 수 있다. 헝가리안 표기법을 사용하던 사람은 계속 헝가리안 표기법을 사용하려고 하고 특정 대상에 자주 사용하던 이름이 있다면 일단 이름을 입력하고 본다. 이 네이밍보다 더 좋은 네이밍이 있을지 없을지는 생각하지도 않는다. 익숙한 대상일 뿐 아니라 이미 네이밍과 대상이 개발자 머릿속에서는 찰떡궁합으로 더 이상의 나은 이름을 찾을 필요도 없기 때문이다. 프런트 엔드 개발에서 가장 흔한 네이밍 버릇 중에 하나는 이벤트 핸들러에서 찾아볼 수 있다. 나 역시 버릇처럼 사용했었고 간단한 테스트를 위한 코드나 교육이나 프리젠테이션중 이해를 돕기 위한 예제코드에서는 종종 사용하기도 한다.

그것은 바로… 바로…on이벤트 작명법! 뚜둥!!

on이벤트 작명법은 그냥 부르기 쉽게 이름을 지어봤다. 네이밍을 네이밍했다. (…) 무슨 말인지 경험많은 개발자라면 바로 눈치챌 수 있을 것이다. 예제 코드를 통해 살펴보자. 예제 코드는 리액트를 사용했지만, 뷰나 앵귤러 혹은 DOM API를 직접 사용한 경우에도 동일하게 해당된다.

function MyFancyButton() {
  const onClick = () => {  }

  return <button onClick={onClick} />
}

예제의 코드는 간단한 리액트 컴포넌트다. 코드는 버튼 태그에 클릭 이벤트를 바인드하는 것이 전부이지만 서비스 코드의 복잡한 코드를 단순화시켰다고 생각하자. 이야기하고자 하는 내용은 사실 복잡한 코드일 때 더 큰 효과를 볼 수 있다. 필요한 이벤트가 클릭 이벤트이기 때문에 클릭 이벤트에 바인드 될 이벤트 핸들러 함수의 이름을 onClick 으로 네이밍한것이다 on이벤트 작명법은 이런것을 말한다.

이런 네이밍의 문제는 이벤트 핸들러 함수를 인라인 무명함수로 작성했을때도 나타난다.

function MyFancyButton() {
  return <button onClick={() => } />
}

// DOM API 라면
button.addEventListener(click, () => , false);

예제 코드가 너무 단순해서 문제가 크게 드러나진 않지만 복잡한 코드일수록 코드 가독성이 급격하게 떨어진다. 여기에서 문제는 해당 DOM 엘리먼트 혹은 컴포넌트에 이벤트가 발생했을 때 어떤 작업을 해야 하는지가 바로 드러나지 않는다는 점이다. onclick 이벤트의 이벤트 핸들러 함수의 이름이 onclick인 경우나 혹은 이벤트 핸들러가 인라인으로 정의된다면 이벤트가 발생했을 때 어떤 작업을 수행하는지는 이벤트 핸들러 함수의 코드를 하나하나 읽어야만 알 수 있다. 네이밍 즉 대상에 이름을 지어준다는 건 이름으로 그 대상이 무엇인지를 설명해 사람이 코드를 읽기 쉽게 만들겠다는 의도인데 이름에서 주는 정보가 전혀 없다. 함수 이름을 함수라고 지은 것과 다를 게 없다.

(코드가 주는 정보)
click 이벤트 -> onClick 함수 실행 or 무명 함수 실행

프레임웍에 따라 다를 수 있겠지만 보통 이벤트 핸들러를 바인드 하는 코드와 이벤트 핸들러 정의하는 코드는 물리적인 거리가 있기 마련이다.

// …
return <button onClick={onClick} />

코드를 읽다가 위와 같은 코드를 만나게 되면 버튼이 눌렸을때 어떤 작업을 하는지 알기까지의 과정이 순탄치 않다.

1. click이벤트에 onClick 함수가 이벤트 핸들러로 걸려있음
2. onClick 함수 찾기(스크롤 혹은 검색으로..)
3. onClick 함수 코드 읽기
4. 다시 1번의 코드로 이동
5. 전후 문맥 다시 파악

클릭 이벤트가 발생했을때 무엇을 하는지 파악하려면 우선 onClick 이벤트 핸들러 함수를 정의한 코드로 이동해야 한다. 스크롤 혹은 검색을 통해서 onClick 함수를 정의한 위치까지 이동한 뒤 코드를 읽어 무엇을 하는지 파악한다. 코드를 모두 읽고 난뒤 다시 클릭 이벤트를 발견한 위치로 돌아간다. onClick함수로 이동하기 전까지 코드를 읽던 흐름을 바로 따라잡을 수 있다면 좋겠지만 다른 곳에 다녀온 터라 코드를 읽던 흐름을 복기하기 위해 주변 코드를 다시 읽는다. 예제 코드에서는 버튼 엘리먼트 하나와 클릭이벤트 하나만 사용하고 있지만 다양한 엘리먼트와 다양한 이벤트를 사용하는 코드라면 정신없이 코드를 오가야 한다.

JSX 혹은 Vue나 Angular의 HTML 확장 문법에서 인라인 이벤트 핸들러를 바인드할 수 있기 때문에 이동 거리는 줄일 수 있겠지만 이는 코드의 복잡도를 매우 높일 수 있기 때문에 의식적으로 함수를 미리 정의하고 HTML을 만드는 코드에서는 해당 함수를 바인드하는 정도로만 사용하는 것이 좋다.

이 문제를 해결하는 방법 간단하다.

이벤트 핸들러 함수에게 함수가 하는 일을 충분히 설명하는 제대로된 이름을 지어주는 것이다. onClick으로 네이밍했었던 함수가 하는 일이 서브 메뉴를 여는 것이라고 가정해보자 이 함수의 이름은 openSubMenu 정도면 충분할 것 같다.

function MyFancyButton() {
  const openSubMenu = () => {}
  return <button onClick={openSubMenu} />
}

이렇게 이벤트 핸들러에 제대로된 이름만 주어진다면 코드를 읽는 흐름은 아래와 같이 단순해진다.

1. click 이벤트가 발생하면 openSubMenu 함수가 실행된다.(클릭이벤트 -> 서브매뉴 열기)

서브 메뉴를 여는 동작의 코드를 수정할 것이 아니라면 openSubMenu 함수는 굳이 살펴보지 않아도 될 것이다. 코드를 작성할 시점에는 이벤트가 발생하면 무엇을 하는지를 너무 잘 알고 있는 상태에서 개발하기 때문에 onClick 이란 이벤트 핸들러 이름도 코드를 이해하는데 아무런 문제가 없었을 것이다. 하지만 시간이 흐른 뒤 코드를 다시 읽을 때나 협업하는 다른 개발자가 해당 코드를 읽을 때는 네이밍이 주는 정보가 거의 없기 때문에 코드를 읽을 때 더 많은 시간이 소요된다. onClick 함수는 함수의 이름을 함수라고 지은 것과 동일한 수준의 네이밍이다.

간혹 인터렉션 이벤트에 대응되는 행위가 컴포넌트 상황에 따라 다양하게 선택될 경우등에서 이벤트 핸들러의 이름으로 on이벤트 작명법이 제일 어울리는 경우가 있다. 그 경우를 제외하고는 가능하면 사용하지 말자.

여러 통계에서 볼 수 있듯 개발자들이 개발할 때 가장 어렵다고 느끼는 부분이 바로 네이밍이다. 그냥 대충 지어도 되는데 어렵다고 느낀다는 건 네이밍이 주는 가치를 잘 알기 때문이다. 네이밍이 주는 가치가 크기 때문에 네이밍이 어려운 것이다. 잘 만든 네이밍 하나 열 주석 안 부럽다.

이벤트 핸들러의 역할

지금까지 이벤트 핸들러에 명확한 이름을 지어주자라는 내용을 설명했다.

이벤트 핸들러라는 것은 일종의 이벤트 메시지를 수신할 수 있는 홀더라고 생각하고 그 자체를 행위를 담는 함수라고 생각하지 않는 것이 좋다고 생각한다. 이벤트 핸들러는 외부에서 전달된 이벤트 메시지와 컴포넌트의 인터페이스인 메서드를 연결해주는 통로일 뿐이다.

개발 중인 UI 컴포넌트의 스펙이 아래와 같다고 생각해보자

  • 컴포넌트에는 버튼 엘리먼트가 있다.
  • 버튼 엘리먼트를 클릭하면 A라는 동작을 수행해야 한다.

이런 스펙을 만나게되면 보통 아래와 같은 의식의 흐름으로 개발을 진행한다.

  1. 버튼 엘리먼트를 만든다.
  2. 버튼에 A동작을 하는 이벤트 핸들러를 정의한다.
    • 길어질것 같으니 인라인으로 정의하지 않고 별도의 함수로 정의해서 바인드한다.

하지만 앞서 언급했듯 이벤트 핸들러는 이벤트 메시지를 수신하는 홀더일 뿐이다. 무엇보다 우선해야 할 것은 컴포넌트의 행위다. 위와 같은 스펙을 만나면 아래와 같은 의식의 흐름으로 작업을 진행해야 한다.

  1. 컴포넌트는 A라는 동작(행위, 책임)이 필요하다.
  2. A라는 동작을 설명할수 있는 메서드명을 정한다.
  3. 메서드가 A라는 동작을 충분히 수행하는지 테스트할 테스트 케이스를 작성한다.
  4. 메서드의 코드를 작성한다.
  5. 버튼의 이벤트 핸들러로 작성한 메서드를 바인드 한다.

갑자기 테스트가 피처링 됐다. 엇? 이벤트 핸들러는 내부 구현인데 테스트 케이스를 작성합니까? 라는 질문을 할 수도 있다.(참고: 효율적인 테스트를 위한 개발자의 자세) 하지만 이벤트 핸들러는 외부 인터페이스에 가깝다. 메서드를 실행하는 방법만 다를 뿐이다. 수신된 메시지가 이벤트다. 반대로 설명하면 모듈의 외부 인터페이스가 있는데 그것이 이벤트 핸들러로(에서) 사용된 것이다.

이벤트 핸들러를 정의한다는 것은 이벤트 핸들러 함수부터 시작하는 게 아니라 컴포넌트(모듈)의 역할에 충족하는 메서드(함수)가 있는 것이고 하필이면 그 메서드가 특정 이벤트가 수신될때 실행된다는 개념으로 생각하는 게 옳다. 이벤트 핸들러는 인터렉션을 위해 이벤트 메시지와 모듈의 행위(메서드)를 연결해주는 브릿지 정도라고 생각하자.

말 그대로, 이벤트 핸들러

이벤트 핸들러에 인자로 전달되는 이벤트 객체가 필요한 메서드의 경우는 어떨까? 딱 봐도 이벤트 핸들러와 모듈의 행위가 명확히 분리 되지 않고 이벤트 핸들러라는 특수한 함수로 강하게 제약을 갖게 된다. 메서드만 독립적으로 실행하는 것은 의미가 없기 때문이다.

아래의 코드는 마우스 위치를 이용해 컨텍스트 팝업을 노출하는 간단한 예제다.


const openPopupMenu = (ev) => {
 const {clientX, clientY} = ev;

 this.popup.style.left = `${clientX}px`;
 this.popup.style.top = `${clientY}px`;
 this.popup.display = block;
}

popupButton.addEventListener(click, openPopupMenu);

위의 onClick 이벤트 핸들러가 그런 경우가 현재는 이벤트 객체에 강하게 디펜던시를 갖고 있다. 이런 경우는 핸들러와 모듈의 메서드가 분리될 수 없을까? 아니다. 이 경우도 이벤트 핸들러와 모듈의 행위가 분리될 수 있다.

const openPopupMenu = (x, y) => {
 popup.style.left = `${x}px`;
 popup.style.top = `${y}px`;
 popup.display = block;
}

popupButton.addEventListener(click, ({clientX, clientY}) => openPopupMenu(clientX, clientY));

인라인으로 정의된 이벤트 핸들러는 인터렉션에 의해 발생한 이벤트를 받아 내부의 메서드(openPopupMenu)를 선택해 적절하게 실행해주는 브릿지 역할을 하고 있다. 이벤트 객체의 데이터를 가공해 x 와 y와 같은 정보를 전달해 줄 수도 있고 인터렉션의 컨트롤도 이벤트 핸들러 함수 안에서 처리할 수 있다. 예를 들면 이벤트 객체의 stopPropagation() 메서드나 preventDefault()같은 메서드를 실행할 수 있다. 이 정도가 온전한 이벤트 핸들러의 역할이다. 이벤트를 받아 적절한 모듈의 행동을 선택해 실행한다. 원래 컴포넌트에 팝업을 특정한 위치에서 여는 기능(책임)이 있던 것이고 이것이 이벤트에 의해 실행된 것이다. 이렇게 이벤트 핸들러와 모듈의 메서드가 구분될 수 있다.

예제 코드의 이벤트 핸들러는 내용이 간단해서 인라인으로 정의될 수 있었지만, 이벤트 핸들러에서 특정 상황에 따라 다양한 메서드로 분기되거나 혹은 여러 개의 메서드가 순차적으로 실행된다면 그런 행동을 아울러 설명할 수 있는 이름으로 네이밍 되고 추상화 래밸이 한 단계 높아진 별도의 함수로 추출되어야 한다. 무명 함수를 이용해 인라인으로 정의 할 때는 이벤트 객체를 사용하거나 이를 해석해 모듈의 메서드에 전달할 데이터를 만들고 하나의 메서드를 실행하는 정도가 좋다. 이벤트 핸들러의 역할은 이벤트라는 메시지를 해석해 메소드의 실행으로 변환하는 것이다.

이벤트 핸들러의 테스트

이벤트 핸들러와 모듈의 메서드를 분리해서 생각하면 테스트도 수월해질 수 있다. 이벤트 핸들러는 모듈의 메서드를 선택하고 이벤트 객체를 해석해 이 메서드가 필요한 데이터를 전달할 뿐이다. openPopupMenu() 함수의 경우 인자로 number 타입의 값 x, y가 필요하다. 이 메서드는 코드에 의해 실행될 수도 있고 이벤트에 의해 실행될 수 있다. 이때 유닛 테스트는 모든 경우가 아닌 openPopupMenu() 메서드를 코드로 직접 실행한 경우의 테스트 케이스만 작성해도 충분하다.

이벤트 헨들러의 로직은 최대한 단순화하고 가볍게 만들어 이벤트 핸들러 자체는 테스트하지 않는다. 어차피 유닛 테스트에서의 이벤트 핸들러 테스트는 모킹일뿐이라 실제 인터렉션 환경을 보장해주지 못하고 복잡한 인터렉션일수록 유닛테스트에서 커버하기 힘든 경우가 많다. 테스트 케이스 작성에 시간이 많이 소요될뿐더러 겨우 테스트를 작성했더라도 복잡도가 너무 높아 유지보수가 힘든 경우가 많다. 드래그엔 드랍은 정말….

이벤트 핸들러 자체의 기능을 최대한 단순화한다면 이벤트가 발생했을 때 이벤트 핸들러가 실행되는지 정도를 확인해야 하는데 이건 브라우저 개발의 테스트 코드에서나 해야 할 일일 것이다. 이벤트 핸들러를 최대한 담백하게 특정 메서드를 선택하고 해당 메서드에게 데이터만 전달하는 수준으로 만든다면 이벤트 메시지를 받았을 때 특정 함수가 실행되어야 한다는 정도의 테스트일 텐데 굳이 테스트할 필요가 있을까? 이것이 효율적인 테스트를 위한 개발자의 자세에서 언급한 테스트 가능한(효율적인) 것과 테스트 불가능한(비효율적인) 것을 구분한 한 예이다. 테스트 불가능한 영역 혹은 테스트 효율이 떨어지는 영역은 별도의 레이어로 분리(격리)해 영향력을 최대한 좁히고 가볍고 단순하게 유지한다. 그리고 효율적으로 테스트 가능한 영역에만 집중해 테스트한다. 꼭 인터렉션에 의한 이벤트 핸들러를 제대로 테스트해야 한다면 E2E 테스트가 답이 될 수 있다.

하지만 이런 테스트 전략은 라이브러리나 프레임웍 코드를 작성할 때나 유효하다. 보통 웹 서비스를 개발할 때는 요즘 유행하는 프레임웍을 이용해 컴포넌트를 개발하기 마련인데 이런 컴포넌트는 테스트 케이스 코드에서 컴포넌트의 이벤트 핸들러에 사용되는 메서드나 함수에 접근하기 힘들기 때문에 유닛테스트를 작성할 때 어쩔 수 없이 이벤트를 모킹하는 형태로 테스트를 작성해야 한다. 이때도 E2E 테스트를 권장하지만, 여건상 유닛 테스트를 작성해야 한다면 testing-library 라는 라이브러리를 사용해서 테스트하는 것을 추천한다.

규모가 어느 정도 있는 프로젝트에서는 프레임웍, 도메인, 개발 환경 등에 따라 각 레이어 별로 테스트 전략을 다르게 취해야 한다. 유닛테스트로 충분한 영역이 있고 E2E 테스트가 효율이 좋은 영역이 있고 혹은 스토리북을 이용한 눈 테스트나 비주얼 테스트가 적절한 영역이 있다. 갑자기 기승전테스트가 되었다. 다시 돌아가 마무리 짓자.

마무으리

전반적으로 개발자가 이벤트 핸들러라는 일종의 함수(메서드) 실행 추상을 어떻게 다뤄야 하는지에 대해 정리해봤다.

이벤트를 활용한 개발 패턴은 인터렉션뿐 아니라 어플리케이션 전반에서 사용되고 있다. 모듈 간의 커플링을 효과적으로 줄여줄 수 있기 때문이다. 특히 자바스크립트 진영의 비동기 처리 해법은 대부분 이벤트와 관련된 패턴을 기반으로 한다. 프로미스도 마찬가지다. 기본적으로 대부분 비슷한 추상이기 때문에 이벤트 핸들러를 어떻게 다루는 게 효과적인지를 아는 것은 중요하다.

테스트에 관해서는 개인의 의견이 과도하게 들어갔다. 극단적인 실용주의 TDD 성향을 가진 개발자의 편협한 의견이라고 생각해주면 좋겠다.

이 글을 통해 이벤트 핸들러를 한 번 더 생각하고 더 나은 코드를 작성할 수 있었으면 한다.