본문 바로가기
빌드노트 검색

빌드노트

버튼 클릭 핸들러에서 await 뒤 currentTarget이 null이 된다

네이티브 버튼 클릭에서 타이머 대기 뒤 currentTarget이 null이 되는 조건을 확인한다. 동기 구간에 요소 참조를 보존하는 수정과 target의 차이, 즉시 완료된 Promise로는 증상이 숨을 수 있는 이유를 실제 실행 결과로 설명한다.

버튼 클릭 핸들러에서 await 뒤 event.currentTarget.disabled를 바꾸다가 TypeError가 나면, 버튼 참조를 읽는 위치부터 확인한다. 첫 대기 전에 const button = event.currentTarget으로 참조를 보존하고, 대기 뒤에는 button을 사용한다. currentTarget은 이벤트에 영구히 붙어 있는 버튼 주소가 아니다. 브라우저가 그 이벤트의 전달을 끝내면 null로 비운다.

여기서는 addEventListener()로 등록한 네이티브 DOM 클릭 리스너를 다룬다. MDN의 currentTarget 설명 (새 탭에서 열림)은 이 값을 현재 핸들러가 등록된 대상으로 설명한다. DOM 표준의 디스패치 절차 (새 탭에서 열림)에는 리스너 호출을 마친 뒤 eventPhase를 NONE으로, currentTarget을 null로 설정하는 단계가 있다. async 함수의 본문이 나중에 이어진다고 해서 그 이벤트가 계속 전달 중인 것은 아니다.

같은 이벤트에서 버튼 참조만 남았다

다음 내용을 UTF-8의 current-target.html로 저장해 브라우저에서 연다. 개발자 도구의 Console을 열고 ‘저장’ 글자를 마우스로 한 번 누르면, 같은 이벤트에서 대기 전후의 두 속성과 저장한 참조를 비교할 수 있다. 서버나 외부 라이브러리는 필요 없다. 타이머는 이벤트 전달이 끝난 뒤 코드를 재개하기 위한 비교 조건이며, 정확히 20ms에 실행된다는 뜻은 아니다.

<!doctype html>
<meta charset="utf-8">
<button id="save" type="button"><span id="label">저장</span></button>
<script>
const button = document.querySelector("#save");
const id = node => node?.id ?? null;
button.addEventListener("click", async event => {
  const saved = event.currentTarget;
  const log = stage => console.log(stage, JSON.stringify({
    target: id(event.target),
    currentTarget: id(event.currentTarget),
    saved: id(saved)
  }));
  log("before");
  await new Promise(resolve => setTimeout(resolve, 20));
  log("after");
  saved.dataset.finished = "yes";
  console.log("saved-update", saved.dataset.finished);
});
</script>

이 글을 위한 별도 실험으로 Windows 11의 headless Chromium 147.0.7727.15에서 실행했다. 위 코드를 로컬 파일로 연 경우와 loopback HTTP로 연 경우 모두 브라우저 마우스 입력으로 글자를 클릭했고, 다음 세 줄이 나왔다.

before {"target":"label","currentTarget":"save","saved":"save"}
after {"target":"label","currentTarget":null,"saved":"save"}
saved-update yes

target은 버튼 안에서 누른 span#label이었고, 리스너가 등록된 버튼은 currentTarget의 save였다. 타이머를 기다린 뒤에도 target은 label이었지만 currentTarget만 null로 바뀌었다. saved에는 같은 버튼 참조가 남아 있었으며, 그 참조로 data-finished 속성을 실제로 바꿨다. 로그는 각 시점의 ID와 null을 문자열로 남긴 것이므로 나중에 Console에서 Event 객체를 펼쳐 읽는 것과도 구분된다.

계측을 더한 별도 비교에서 대기 뒤 event.currentTarget.disabled = false는 Cannot set properties of null (setting 'disabled')라는 TypeError를 냈다. 대기를 생략한 동기 조건에서는 같은 접근이 성공했다. 버튼 여백을 눌러 target까지 버튼이 된 조건에서도 타이머 대기 뒤 currentTarget은 null이었다. 버튼 안쪽 요소를 눌렀기 때문에 생긴 오류는 아니었다.

event를 저장하는 것과 버튼을 저장하는 것은 다르다

const savedEvent = event로 이벤트 객체만 보관한 뒤 savedEvent.currentTarget을 읽으면 여전히 그때의 속성값을 조회한다. 위 비교에서도 이벤트 객체는 동일했지만 디스패치가 끝난 뒤 속성은 null이었다. const saved = event.currentTarget은 그 속성이 버튼을 가리키는 동안 버튼 참조 자체를 따로 꺼내 두는 것이다.

자기 코드에서는 첫 await보다 앞에 이 참조 저장을 놓고, 대기 뒤의 event.currentTarget 접근을 저장한 변수로 바꾼다. 버튼을 다시 활성화하는 finally에서도 같은 변수를 사용한다. 상위 목록에 리스너를 위임했다면 currentTarget은 목록이므로, 그 경우에는 동기 구간에서 처리할 버튼을 찾아 보존해야 한다.

event.target으로 치환하는 것은 같은 수정이 아니다. 예제에서는 대기 뒤에도 남아 있었지만 그 값은 버튼 안의 span이었다. 리스너가 붙은 버튼을 조작하려는지, 클릭한 자손 요소를 조작하려는지 먼저 구분한다. 참조를 보존하는 것은 DOM을 복사하거나 그 요소가 계속 화면에 연결돼 있음을 보장하는 일도 아니다. 대기 중 화면이 교체되는 코드는 별도로 연결 상태와 현재 화면의 처리 대상인지 확인해야 한다.

즉시 완료된 Promise에서는 증상이 숨을 수 있다

await 뒤에는 언제나 null이라는 규칙으로 외우면 이 실험도 잘못 해석하게 된다. 같은 브라우저에서 대기를 await Promise.resolve()로 바꾸고 마우스로 클릭했을 때는, 재개 시점에도 currentTarget이 버튼이었다. 반면 스크립트의 dispatchEvent()로 같은 클릭을 전달한 조건에서는 null이었다. 두 조건 모두 브라우저의 DOM Event를 사용했지만 실행 순서는 달랐다.

MDN의 await 설명 (새 탭에서 열림)은 async 함수가 멈춘 동안 호출한 쪽은 계속 실행하며, 후속 실행은 마이크로태스크로 예약된다고 설명한다. HTML 표준의 스크립트 실행 정리 절차 (새 탭에서 열림)는 실행 스택이 비면 마이크로태스크 체크포인트를 수행하도록 한다. 관찰한 차이는 이 실행 경계와 맞는다. 판단 기준은 await의 유무보다 다시 읽는 시점에 디스패치가 끝났는가다. 필요한 참조를 미리 보존하면 그 순서에 기대지 않아도 된다.

자기 화면에서 결과가 다르면 먼저 실제로 기다리는 작업과 클릭 입력 방식을 확인한다. 리스너의 동기 구간부터 값이 예상과 다르면 등록한 요소와 실행 중인 이벤트 종류를 확인한다. saved가 남아 있는데 화면이 바뀌지 않는다면, currentTarget의 수명 외에 저장한 요소가 현재 화면의 요소인지도 조사한다.

직접 실행한 범위는 프레임워크 없는 일반 DOM, 버튼과 그 안의 span, 단일 클릭, 타이머와 즉시 완료된 Promise다. 실제 네트워크 저장 요청, 대기 중 요소 제거·교체, Shadow DOM, 다른 브라우저 엔진과 모바일 입력은 확인하지 않았다. 이 글은 해당 조건의 관찰과 네이티브 Event의 디스패치 규칙을 근거로 한다.

← 전체 글 보기