프론트엔드

preventDefault가 전역 keydown 리스너를 중지시키지 않는 이유

두 기능이 동일한 단축키에 바인딩되어 둘 다 실행되었습니다. `preventDefault`는 다른 리스너가 아닌 기본 동작을 취소합니다. 다음은 전환 후에도 유효한 수정 사항입니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

동일한 편집기에서 두 가지 기능이 Cmd/Ctrl+Enter에 바인딩되었습니다. 하나는 댓글을 게시했고, 다른 하나는 크레딧을 소모하고 기존 결과를 대체하는 비용이 많이 드는 재생성 작업을 시작했습니다. 키를 누르면 두 가지 작업이 모두 실행되었습니다. 댓글 상자는 이미 preventDefault()를 호출했지만 아무것도 바뀌지 않았습니다. preventDefault는 애초에 이 작업에 적합한 도구가 아니었기 때문입니다.

이 버그는 재현하기 쉽고 오진하기도 쉽습니다. 또한 대부분의 팀이 놓치는 두 번째 문제가 있습니다. 단축키를 옮기면 충돌은 해결되지만, 첫 주는 더 나빠질 수 있다는 점입니다.

공유 단축키의 실제 모습

두 기능은 서로 다른 문제를 들여다보던 사람들에 의해, 몇 달의 간격을 두고, 서로 다른 디렉터리에 작성되었습니다.

편집기에는 전역 단축키가 있었습니다. 이 단축키는 window를 리슨하고, Enter와 수식 키 조합을 확인한 다음, 포커스된 세그먼트에 대한 재생성 작업을 시작했습니다.

useEffect(() => {
  const onKeyDown = (e) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      if (focusedId) startExpensiveJob(focusedId)
    }
  }
  window.addEventListener('keydown', onKeyDown)
  return () => window.removeEventListener('keydown', onKeyDown)
}, [focusedId])

나중에 댓글 패널이 독립적인 컴포넌트로 추가되었습니다. 이 컴포넌트는 Cmd/Ctrl+Enter로 텍스트 상자를 제출하는 일반적인 웹 관례를 따랐습니다.

<textarea
  onKeyDown={(e) => {
    if (e.nativeEvent.isComposing) return
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault()
      submit()
    }
  }}
/>

두 스니펫 모두 개별적으로 보면 합리적입니다. 어느 파일도 다른 파일을 언급하지 않습니다. 패널은 자신을 편집기의 일부라고 생각하지 않았기 때문에 편집기의 바로 가기 키 테이블과 대조하여 확인된 적이 없습니다.

그 결과, 검토자가 세그먼트를 클릭하고, 댓글 상자에 메모를 입력한 다음, Cmd+Enter를 누르면 메모는 저장되지만 세그먼트는 조용히 다시 생성됩니다. 의견을 기록하기 위한 작업이 검토 중인 대상을 덮어쓰기도 한 것입니다.

preventDefault가 다른 리스너를 멈추지 않는 이유

댓글 상자는 preventDefault()를 호출합니다. 그래도 우선권을 갖지 못하는데, DOM 이벤트의 세 가지 메서드가 각각 다른 세 가지 작업을 수행하기 때문입니다.

메서드 중단시키는 것 중단시키지 않는 것
preventDefault() 브라우저의 기본 동작 (제출, 스크롤, 줄바꿈 삽입) 어떤 노드에 있는 어떤 리스너든
stopPropagation() 경로상의 상위 조상에 있는 리스너들 같은 노드에 있는 다른 리스너들
stopImmediatePropagation() 아직 호출되지 않은 모든 리스너 (같은 노드의 다른 리스너 포함) 기본 동작

preventDefault는 플래그를 설정합니다. 이벤트는 계속 전파되며, 경로상의 모든 리스너는 여전히 실행됩니다. event.defaultPrevented를 읽지 않는 리스너는 누군가 반대했다는 사실을 인지하지 못할 것입니다.

키 입력 한 번에 핸들러가 두 번 실행된 이유 1 버블링 2 차단되지 않음 3 원함 4 원치 않음 댓글 상자 React 루트 Window 댓글 저장됨 작업 재실행 // preventDefault는 플래그를 설정할 뿐, 다른 리스너를 중지시키지 않습니다

React에는 알아둘 만한 까다로운 점이 있습니다. React 17부터 합성 이벤트는 document가 아닌 앱의 루트 컨테이너에 연결됩니다. onKeyDown prop은 textarea 자체에 대한 리스너가 아닙니다. React가 자체 트리를 통해 이벤트를 다시 재생하는 동안 실행되며, 그때는 이미 네이티브 이벤트가 루트로 전달된 상태입니다. window 리스너는 그 루트 위에 위치하므로, 어느 쪽이든 이벤트를 수신합니다.

이것이 컴포넌트 내부에 stopPropagation()을 추가하는 순진한 해결책이 취약한 이유입니다. React가 내부적으로 네이티브 stopPropagation을 호출하고 경로상에서 루트가 window 아래에 있기 때문에 작동할 수는 있습니다. 하지만 이는 사용 중인 버전의 React가 리스너를 어디에 연결했는지에 따라 달라지며, 라이브러리에 의해 나중에 추가된 리스너를 포함하여 원했던 다른 모든 상위 리스너를 조용히 망가뜨립니다. 전파를 막는 것은 자신보다 위에 있는 모든 것을 겨냥하는 무딘 도구입니다.

키를 변경하는 것은 해결책의 절반에 불과합니다

명백한 해결책은 두 단축키 중 하나를 옮기는 것입니다. 우리의 경우 댓글 상자는 Shift+Enter로 이동했는데, 이는 버그를 보고한 사람이 요청한 것입니다.

그렇게 하면 충돌은 사라집니다. 하지만 손상은 사라지지 않으며, 여기에 놓치기 쉬운 부분이 있습니다. 손에 옛 습관이 밴 사람에게는 첫 주가 더 나아지는 것이 아니라 더 나빠집니다.

변경 전에는 댓글 상자에서 Cmd+Enter를 누르면 두 가지 일이 일어났고, 그중 하나는 사용자가 원하던 것이었습니다. 변경 후에는 같은 키를 눌러도 아무것도 게시되지 않고 비용이 많이 드는 작업은 여전히 시작됩니다. 사용자는 자신의 노트를 잃고 요청하지 않은 재생성 비용을 지불하게 됩니다. 혼란스러운 결과를 순전히 파괴적인 결과로 맞바꾼 셈입니다.

따라서 단축키 이동에는 동반 조치가 필요합니다. 즉, 전역 핸들러는 댓글 상자에서 온 키 입력을 무시해야 합니다.

화이트리스트 마커가 포괄적인 입력 검사보다 우수합니다

가장 먼저 드는 생각은 전역 핸들러가 모든 텍스트 입력을 건너뛰게 만드는 것입니다.

const tag = e.target.tagName
if (tag === 'TEXTAREA' || tag === 'INPUT') return

여기서는 이렇게 하지 마세요. 저희 에디터에서 이 단축키의 주된 용도는 세그먼트에서 텍스트를 편집하는 동안 Cmd+Enter를 누르는 것입니다. 그러면 핸들러가 필드를 블러 처리하여 편집 내용을 반영한 후, 새 콘텐츠로 다시 생성합니다. 일괄적인 입력 확인은 부차적인 충돌을 해결하려다 주요 기능을 막게 됩니다.

효과적인 규칙은 화이트리스트 방식입니다. 전역 단축키를 막아야 하는 특정 필드를 표시하고, 그 외 모든 것은 기존 동작을 유지하도록 하세요.

<textarea data-comment-input onKeyDown={...} />
const target = e.target
if (target?.closest?.('[data-comment-input]')) return
e.preventDefault()

이것이 제대로 작동하게 하는 세 가지 세부 사항이 있습니다:

  • closest()는 노드 자체에서 시작하므로, textarea에 속성을 넣는 것만으로 충분합니다. 래퍼(wrapper)는 필요하지 않습니다.
  • 가드(guard)는 preventDefault()보다 먼저 실행되어야 합니다. 기본 동작을 먼저 취소하면, 핸들러를 건너뛰기로 결정했음에도 불구하고 Enter 키가 댓글 상자에 줄 바꿈을 삽입하지 않게 됩니다.
  • 선택적 호출 ?.이 중요합니다. 포커스가 document에 있거나 이벤트 대상이 엘리먼트가 아닐 때, closest는 존재하지 않으며, 일반적인 호출은 전역 리스너 안에서 오류를 발생시킵니다.

여기서는 컴포넌트와 멀리 떨어진 곳에서 확인이 이루어지기 때문에 속성 선택자가 컴포넌트 수준 플래그보다 낫습니다. 전역 핸들러는 댓글 패널의 React 상태에 접근할 수 없지만, 이벤트가 발생한 DOM 노드는 가지고 있습니다.

동일한 논리가 기본값의 방향에도 적용됩니다. 화이트리스트는 안전하게 실패합니다. 즉, 표시하는 것을 잊은 모든 필드는 이전과 똑같이 계속 작동합니다. 블랙리스트("세그먼트 편집기를 제외한 모든 것을 건너뛰기")는 반대로 작동하여, 전혀 생각하지 못했던 기능들을 망가뜨립니다.

자신의 앱에서 확인해야 할 사항

전역 키보드 리스너가 있다면, 이 목록을 한 번 살펴보세요:

  1. 단축키 목록을 만드세요. addEventListener('keydown'과 프레임워크 키 핸들러를 각각 grep으로 검색하세요. 충돌은 이 두 목록 사이의 간극에 존재합니다. 왜냐하면 각각을 작성하는 사람들이 서로의 코드를 거의 읽지 않기 때문입니다.
  2. 바인딩당 핸들러 수를 세어보세요. 각 조합에 대해 오늘날 몇 개의 코드 경로가 실행될지 자문해보세요. 하나 이상이라면 습관을 가진 사용자를 기다리는 버그입니다.
  3. 명백한 것뿐만 아니라 모든 입력 변형을 확인하세요. 저희 댓글 패널에는 새 댓글, 댓글 수정, 새 답글, 답글 수정의 네 가지 텍스트 필드가 있었습니다. 보고서에는 첫 번째 것만 언급되었습니다. 넷 중 하나만 수정했다면 단일 패널 내에서 동작이 분리되었을 것입니다.
  4. 어느 쪽이 충돌을 소유할지 결정하세요. 비용이 발생하거나 데이터를 변경하는 기능이 양보해야 합니다. 키 입력을 놓치는 것이 원치 않는 작업을 수행하는 것보다 비용이 적게 들기 때문입니다.
  5. 최종 상태뿐만 아니라 전환 과정을 검증하세요. 이전 단축키를 누르고 결과가 무해한지 확인하세요. 이는 테스트 계획에서 건너뛸 수 있는 경우입니다.

대신 stopImmediatePropagation을 사용해야 할까요?

경로상의 모든 리스너를 소유하고 그중 어느 것도 실행되지 않기를 원할 때만 사용하세요. 이는 같은 노드의 형제 요소도 중지시키는데, 보통 의도한 것보다 더 많은 것을 중지시킵니다. 전역 핸들러의 대상화된 가드는 1년 후에 추론하기가 더 쉽습니다. 예외가 수정하는 동작 바로 옆에 있기 때문입니다.

전역 핸들러가 event.defaultPrevented를 확인하게 하지 않는 이유는 무엇인가요?

이는 로컬 핸들러가 항상 기본 동작을 취소할 때 작동하며, 한 줄만 변경하면 됩니다. 하지만 이는 더 약한 계약이기도 합니다. 정당한 이유로 preventDefault를 건너뛰는 모든 로컬 핸들러는 조용히 전역 동작을 다시 활성화합니다. 마커 속성은 "이 영역은 전역 단축키에 참여하지 않습니다"라는 여러분의 의도를 명시합니다.

이것이 React 외부에서도 적용되나요?

네. 합성 이벤트 시스템은 로컬 핸들러가 실행되는 위치를 변경하지만 결과는 바꾸지 않습니다. 같은 경로에 있는 두 리스너는 순수 JavaScript, Vue, 또는 Svelte에서도 모두 실행됩니다. React의 세부 사항은 컴포넌트 내의 stopPropagation이 때로는 작동하는 것처럼 보이고 때로는 그렇지 않은 이유만을 설명합니다.

단축키 라이브러리가 진짜 해답일까요?

중앙 레지스트리가 있는 라이브러리는 동일한 바인딩을 두 번 등록하는 것이 등록 시점에 보이게 되므로 이러한 종류의 버그를 방지합니다. 이는 단축키가 많은 앱에게 좋은 방향입니다. 하지만 가드보다 더 큰 변경이며, 모든 기능이 이를 통하지 않으면 도움이 되지 않습니다. 이는 애초에 실패했던 바로 그 가정입니다.

기억해 둘 만한 일반적인 교훈은 작습니다. preventDefault는 다른 코드가 아닌 브라우저와 통신합니다. 두 기능이 키를 공유해야 할 때, 둘 중 하나는 다른 하나에 대해 알아야 하며, 이를 알리는 가장 안전한 방법은 이벤트가 전달하는 DOM 노드에 표시를 하는 것입니다.