RainyLab
Dev · JavaScript

이벤트 핸들러 안의 this — 누가 그 함수를 불렀는가

onchange="handleFile(this.files)" 라고 쓰면 this 는 그 input 입니다. 그런데 handleFile 안으로 한 발 들어가는 순간 this 가 사라집니다. 같은 줄에 있던 this 인데 함수 경계 하나를 넘었다고 달라집니다. 자바스크립트에서 this 는 함수가 어디에 쓰였는지가 아니라 어떻게 불렸는지로 정해지기 때문입니다.

Archive · 2018

원문은 2018년 12월에 쓴 노트입니다. this 의 규칙 자체는 언어의 기본이라 그때와 지금이 같지만, 그 사이에 답이 바뀐 부분이 있습니다 — 화살표 함수가 흔해지면서 새로운 함정이 생겼고, 모던 코드에서는 애초에 this 를 쓰지 않는 쪽이 정답이 됐습니다. 원문에 없던 그 두 가지를 채웠습니다.

1. 인라인 핸들러는 문자열이 아니라 함수 본문이다

파일 선택을 받으려고 이렇게 썼다고 합시다.

<input type="file" onchange="handleFile(this.files)">
function handleFile(files) {
  console.log(this)  // ← input 일 거라고 기대했다
}

찍히는 것은 input 이 아니라 Window 입니다. 이유를 알려면 브라우저가 저 속성값으로 무엇을 하는지를 봐야 합니다. 속성 안의 문자열은 함수의 본문이 됩니다. 브라우저는 대략 이런 함수를 만들어 onchange 에 답니다.

// 브라우저가 만들어 다는 것
function (event) {
  handleFile(this.files)   // this = 이벤트가 달린 그 input
}

믿기 어렵다면 속성에 문장을 하나 더 넣어 보면 분명해집니다. 세미콜론으로 여러 문장을 쓸 수 있다는 것 자체가 저기가 함수 본문이라는 증거입니다.

<input type="file" onchange="console.log(this); handleFile(this.files)">
// 첫 줄은 input 을 찍는다. 두 번째 줄 안에서는 아니다

2. 그래서 왜 한 겹 들어가면 사라지는가

handleFile(this.files) 를 보면 this.files 라는 값만 넘어갑니다. this 자체는 넘어가지 않습니다. 그리고 handleFile(...)아무 객체에도 붙지 않은 맨 호출입니다.

그 기본값이 Window 인데, 여기가 원문에서 한 걸음 더 나갔어야 할 곳입니다. Window 가 되는 것은 느슨한 모드(sloppy mode)일 때뿐입니다.

맨 호출 fn() 안의 this
일반 <script> (느슨한 모드)Window
'use strict'undefined
ES 모듈 <script type="module">undefined — 모듈은 항상 엄격 모드
클래스 본문 안undefined — 클래스도 항상 엄격 모드

차이가 중요한 이유는 증상이 다르기 때문입니다. Windowthis.files 가 조용히 undefined 로 흘러가 한참 뒤에 엉뚱한 곳에서 터지고, undefinedCannot read properties of undefined그 자리에서 멈춥니다. 요즘 코드는 대부분 모듈이라 후자입니다.

인라인 핸들러의 숨은 스코프

속성 안에서는 this. 를 빼고 onchange="handleFile(files)" 라고 써도 동작합니다. 브라우저가 그 함수를 만들 때 스코프 체인에 요소 자신과 문서를 끼워 넣기 때문입니다. 편해 보이지만 실제로는 함정입니다 — id, name, title 같은 이름을 쓰면 내가 만든 전역 변수가 아니라 요소의 속성이 잡힙니다. 인라인 핸들러를 쓰지 말아야 할 이유가 하나 더 늘어난 셈입니다.

3. 굳이 넘기려면 — bind · call · apply

호출 방식으로 this 가 정해지므로, 호출 방식을 바꾸면 됩니다.

// 셋 다 handleFile 안의 this 를 input 으로 만든다
onchange="handleFile.call(this, this.files)"
onchange="handleFile.apply(this, [this.files])"
onchange="handleFile.bind(this)(this.files)"

callapply지금 부르면서 this 를 지정하고, bindthis 가 고정된 새 함수를 만들어 돌려줍니다. 인자를 낱개로 넘기면 call, 배열로 넘기면 apply 입니다.

bind 는 매번 다른 함수를 만든다

실무에서 자주 밟는 함정입니다. bind 는 호출할 때마다 새 함수 객체를 만듭니다. 그래서 아래는 절대 떼어지지 않습니다.

el.addEventListener('click', this.onClick.bind(this))
el.removeEventListener('click', this.onClick.bind(this))  // 다른 함수다

const handler = this.onClick.bind(this)   // 참조를 붙들어 둔다
el.addEventListener('click', handler)
el.removeEventListener('click', handler)

4. 애초에 감싸지 않으면 된다

addEventListener 로 붙이거나 onchange 프로퍼티에 함수를 직접 대입하면, 내 함수가 곧 핸들러가 됩니다. 브라우저가 다른 함수로 감싸지 않으므로 this 가 바뀔 일이 없습니다.

const input = document.querySelector('input[type=file]')

input.addEventListener('change', function () {
  console.log(this)  // input — 브라우저가 요소를 this 로 걸어 준다
})

input.onchange = function () {
  console.log(this)  // input — 마찬가지
}

화살표 함수로 바꾸면 다시 깨진다

원문을 쓰던 2018년 이후 가장 많이 늘어난 실수입니다. 화살표 함수는 자기 this 를 갖지 않습니다. 바깥 스코프의 것을 그대로 씁니다. 그래서 브라우저가 요소를 걸어 줘도 그 값이 무시됩니다.

input.addEventListener('change', () => {
  console.log(this)  // input 이 아니다 —
                     // 모듈이면 undefined, 일반 스크립트면 Window
})

bindcall 도 화살표 함수에는 듣지 않습니다. 고칠 수 있는 값이 아니라 처음부터 없는 값이기 때문입니다. 요소가 필요하면 다음 절의 방법을 쓰면 됩니다.

5. 지금의 정답 — this 를 쓰지 않는다

여기까지가 원문의 범위였습니다. 그런데 실무에서 권할 결론은 따로 있습니다. 이벤트 객체가 같은 값을 더 정확하게 갖고 있습니다.

input.addEventListener('change', (event) => {
  console.log(event.currentTarget)  // 핸들러가 달린 요소 = this 와 같다
  console.log(event.target)         // 실제로 이벤트가 시작된 요소
})
currentTarget
  • 핸들러를 붙인 요소
  • this 와 같은 값
  • 화살표 함수에서도 동작한다
target
  • 이벤트가 시작된 요소
  • 버블링으로 올라온 경우 자식일 수 있다
  • 목록 하나에 핸들러를 달 때 이쪽이 필요하다

<button><span>저장</span></button> 의 글자를 클릭하면 targetspan, currentTargetbutton 입니다. 둘을 헷갈려서 생기는 버그가 this 로 생기는 버그보다 많습니다.

currentTarget 은 핸들러가 끝나면 null 이 된다

비동기 안에서 쓰면 사라집니다. 이벤트 전파가 끝나면 브라우저가 값을 비우기 때문입니다. 쓸 값은 동기 구간에서 미리 꺼내 두어야 합니다.

btn.addEventListener('click', async (e) => {
  await save()
  e.currentTarget.disabled = true   // null 이라 터진다

  const el = e.currentTarget          // 먼저 붙든다
  await save()
  el.disabled = true
})

6. 클래스 메서드를 그냥 넘기면 안 되는 이유

같은 규칙이 그대로 적용되는 자리인데, 증상이 달라 보여서 따로 헤매게 됩니다.

class Uploader {
  constructor(input) {
    this.count = 0
    input.addEventListener('change', this.onChange)  // 함수만 떼어 넘겼다
  }
  onChange() {
    this.count++   // TypeError — this 가 undefined
  }
}

this.onChange 라고 쓰긴 했지만 그건 함수를 꺼내는 표현일 뿐입니다. 꺼낸 함수는 인스턴스와의 연결을 갖고 있지 않고, 나중에 브라우저가 부를 때는 요소this 로 걸어 줍니다. 그 요소에는 count 가 없습니다. 해결은 셋 중 하나입니다.

// 1) 생성자에서 묶는다 — 예전 React 클래스 컴포넌트의 그 코드
this.onChange = this.onChange.bind(this)

// 2) 클래스 필드에 화살표 함수로 둔다 — 여기서는 화살표가 정답이다
onChange = () => { this.count++ }

// 3) 넘길 때 감싼다
input.addEventListener('change', (e) => this.onChange(e))

4절에서는 화살표 함수가 문제였는데 여기서는 해법입니다. 모순이 아닙니다 — 규칙은 하나입니다. 화살표 함수는 바깥의 this 를 쓴다. 바깥이 인스턴스면 이득이고, 바깥이 모듈 최상위면 손해입니다.

7. 인라인 핸들러를 쓰지 말아야 할 진짜 이유

지금까지는 this 이야기였지만, 실무에서 onclick="..." 을 지우는 이유는 따로 있습니다. 콘텐츠 보안 정책(CSP)에 걸립니다.

Content-Security-Policy: script-src 'self'
// → 인라인 이벤트 속성이 전부 차단된다.
//   오류도 조용해서 "왜 버튼이 안 눌리지"로 한참 헤맨다

'unsafe-inline' 을 넣으면 동작하지만, 그건 XSS 방어를 통째로 푸는 일입니다. addEventListener 로 옮기면 this 문제도 CSP 문제도 같이 사라집니다.

8. 한 줄로 줄이면

this 는 함수가 어디에 적혀 있는지가 아니라 어떻게 불렸는지로 정해진다. 인라인 속성은 브라우저가 함수로 감싸므로 그 안에서만 요소이고, 한 겹 들어가면 끊깁니다.

쓰는 방식핸들러 안의 this
onclick="fn()" 의 속성 안요소
fn 내부Window 또는 undefined
addEventListener('click', function () {})요소
addEventListener('click', () => {})요소가 아니다 — 바깥 스코프의 것
클래스 메서드를 떼어 넘김요소 (인스턴스가 아니다)
event.currentTarget언제나 요소

마지막 줄이 결론입니다. 이벤트 핸들러에서는 this 를 아예 쓰지 말고 event.currentTarget 을 쓰면, 함수 모양이 무엇이든 위 표를 외울 일이 없어집니다.