자바스크립트 함수: this 와 클로저를 jsc 로 실행하며 이해하기

자바스크립트 강좌 2. 함수 선언과 화살표 함수, this 가 정해지는 네 규칙, 클로저, 반복문 var 함정, TDZ ReferenceError, 메모이제이션 실측까지 jsc 로 실행.

시리즈: 자바스크립트 강좌 2/6 (초급). 자바스크립트를 몇 줄은 쓸 수 있지만(1강 참고) 실제 코드에서 this, 화살표 함수, 콜백을 만나 헷갈리는 분을 위한 글입니다. 다 읽으면 함수 작성 방식을 골라 쓰고, this 가 무엇이 될지 예측하고, 클로저를 의도해서 쓰고, 반복문 안 var 와 TDZ 오류를 보자마자 원인을 알 수 있습니다.

자바로 처음 프로그래밍을 배운 분에게 자바스크립트의 this 는 가장 배신감이 큰 부분입니다. 자바의 this 는 언제나 "그 객체"인데, 자바스크립트에서는 같은 메서드라도 어떻게 호출했느냐에 따라 this 가 바뀝니다. 화살표 함수는 function 과 조용히 다르게 동작하고, 타이머 세 개를 거는 반복문은 3 3 3 을 출력합니다. 이런 버그는 진짜 원인에서 멀리 떨어진 곳에서 TypeError: undefined is not an object 로 나타나서, 코드 리뷰에서 신입이 가장 많이 묻는 주제이기도 합니다. 아래는 모두 JavaScriptCore 의 jsc 셸(macOS 26.6, Apple Silicon M1 Pro) 로 실행했습니다. jsc 별칭은 1강처럼 설정하세요. Node.js 와 브라우저는 실행하지 않았고, 차이가 나는 곳은 그때그때 적었습니다.

함수를 쓰는 네 가지 방법

print(add(2, 3));                 // 동작함: 함수 선언은 끌어올려진다
function add(a, b) { return a + b; }

const mul = function (a, b) { return a * b; };
const sq = x => x * x;
const greet = (name = "guest", ...rest) => `hi ${name} (+${rest.length})`;
print(mul(2, 3), sq(4), greet(), greet("Mina", 1, 2));
print(add(1), add(1, 2, 3), add.length, greet.length);
const point = (x, y) => ({ x, y });
const broken = (x, y) => { x, y };
print(JSON.stringify(point(1, 2)), broken(1, 2));
5
6 16 hi guest (+0) hi Mina (+2)
NaN 3 2 0
{"x":1,"y":2} undefined

용어를 정리합니다.

  • 함수 선언(function add(…) {…}): 이름 있는 함수를 만드는 문장입니다. 통째로 호이스팅(끌어올림) 되므로 쓴 줄보다 위에서 호출할 수 있습니다.
  • 함수 표현식(const mul = function …): 변수에 담은 함수입니다. 먼저 존재하는 것은 변수뿐이고 함수는 아닙니다.
  • 화살표 함수(x => x * x): ES2015 에 들어온 짧은 문법입니다. => 뒤가 식 하나면 자동으로 return 됩니다. this 와 arguments 가 없고 new 도 못 합니다. 자바의 람다와 모양은 비슷하지만 this 규칙 때문에 완전히 같지는 않습니다.
  • 기본값 매개변수(name = "guest")와 나머지 매개변수(...rest, 남은 인수를 배열로 받음).

출력에 놀라운 점이 세 개 있습니다. 자바라면 컴파일 오류였을 add(1) 이 실패하지 않습니다. 빠진 b 는 undefined 이고 1 + undefined 는 NaN 입니다. 남는 인수는 조용히 무시됩니다(add(1, 2, 3) 은 3). 그리고 broken 은 undefined 를 돌려줍니다. => 뒤의 { x, y } 가 객체가 아니라 문장 블록으로 읽히기 때문이고, 객체를 돌려주려면 point 처럼 괄호로 감쌉니다.

옛 코드는 arguments 로 인수를 읽는데, 화살표 함수에는 없습니다.

$ jsc arrowargs.js     # const f = () => arguments.length; f(1, 2);
Exception: ReferenceError: Can't find variable: arguments
f@arrowargs.js:1:26
global code@arrowargs.js:1:36

대신 나머지 매개변수를 씁니다.

호이스팅: 미리 부를 수 있는 것은 함수 선언뿐

var 에 담은 함수를 그 줄보다 앞에서 부르면

print(typeof early);   // undefined
early();
var early = function () { print("never"); };
undefined
Exception: TypeError: early is not a function. (In 'early()', 'early' is undefined)
global code@hoistfn.js:2:6

const 로 같은 일을 하면

$ jsc hoistfn2.js      # later(); const later = () => print("never");
Exception: ReferenceError: Cannot access 'later' before initialization.
global code@hoistfn2.js:1:6

같은 실수인데 오류 종류가 다릅니다. var 는 이름이 있고 값이 undefined 라서 "undefined 를 호출했다"는 TypeError, const 는 이름이 TDZ 안에 있어서 ReferenceError 입니다.

상황 쓸 것 이유
파일 최상위의 재사용할 이름 있는 함수 function name() {} 끌어올려지고 스택 트레이스도 읽기 쉽다
콜백(map, setTimeout, 이벤트) 화살표 x => … 짧고 바깥 this 를 유지한다
객체나 클래스의 메서드 메서드 축약 name() {} 호출한 쪽에서 this 를 받는다
생성자 class (4강) 화살표 함수는 new 불가
인수를 전부 받고 싶다 나머지 매개변수 ...args 화살표 함수에는 arguments 가 없다

스코프와 TDZ

스코프는 이름이 보이는 범위입니다. let 과 const 는 블록 스코프(가장 가까운 { } 안에서만), var 는 함수 스코프입니다. 자바는 모든 변수가 블록 스코프이니, var 만 예외라고 기억하면 됩니다.

if (true) { var v = 1; let l = 2; const c = 3; }
print(v, typeof l, typeof c);    // 1 undefined undefined
function f() { var inside = 1; }
f();
print(typeof inside);            // undefined

var v 는 if 블록 밖으로 새어 나왔고 l 과 c 는 그렇지 않았습니다. typeof 는 모르는 이름에도 예외를 던지지 않는 유일한 연산자라서 오류 대신 undefined 가 나왔습니다.

1강의 TDZ 는 함수 안에 들어가면 헷갈려집니다.

let x = "outer";
function show() {
  print(x);            // 어느 x?
  let x = "inner";
}
show();
Exception: ReferenceError: Cannot access 'x' before initialization.
show@tdz.js:3:10
global code@tdz.js:6:5

outer 가 나올 거라고 예상하는 분이 많습니다. 하지만 안쪽 let x 가 함수 본문 전체에서 바깥 x 를 가리고(섀도잉), 선언 줄 이전에는 초기화되지 않은 상태입니다. 여기서는 typeof 마저 예외를 던집니다. print(typeof later); let later = 1; 도 같은 ReferenceError 였고, typeof notDeclared 는 undefined 였습니다. 안쪽 변수 이름을 바꾸거나 선언을 위로 올리면 해결됩니다.

this 의 정체 (네 규칙을 모두 실행)

this 는 숨겨진 매개변수이고, 그 값은 함수를 어디에 썼는지가 아니라 어떻게 호출했는지로 정해집니다. 화살표 함수만 예외로, 자기 this 없이 바깥의 this 를 씁니다. 한 파일로 전부 확인합니다(this1.js, strict 모드).

"use strict";
const counter = {
  count: 0,
  inc() { this.count++; return this.count; },
  incArrow: () => typeof this,
  later() { setTimeout(function () { print("function cb this:", this); }, 0);
            setTimeout(() => { this.count++; print("arrow cb count:", this.count); }, 0); },
};
print(counter.inc(), counter.inc());
print("arrow method this:", counter.incArrow());
counter.later();
const inc = counter.inc;
const bound = counter.inc.bind(counter);
print("bound:", bound());
try { inc(); } catch (e) { print(e.name + ": " + e.message); }
1 2
arrow method this: object
bound: 3
TypeError: undefined is not an object (evaluating 'this.count')
function cb this: undefined
arrow cb count: 4

한 줄씩 읽어 봅니다.

  1. counter.inc() 는 메서드 호출이라 this 는 점 앞의 객체입니다.
  2. incArrow 는 화살표 함수라서 this 가 스크립트 최상위의 this(여기서는 전역 객체)이고 counter 가 아닙니다. 화살표 함수는 메서드로 부적합합니다.
  3. bind 는 this 를 영구히 고정한 새 함수를 돌려줍니다(call 과 apply 는 한 번의 호출에만 지정).
  4. 같은 함수를 inc() 처럼 그냥 호출하면 strict 모드에서 this 가 undefined 라서 this.count 가 TypeError 입니다.
  5. setTimeout 에 넘긴 function 콜백도 jsc 에서는 undefined 였습니다. 화살표 콜백은 counter 의 this 를 유지했습니다. (브라우저는 타이머 콜백을 this = window 로 부르고, Node 는 Timeout 객체를 넘깁니다. 여기서는 실행 안 함. 어느 쪽에도 기대지 마세요.)

"use strict" 가 없으면 그냥 호출해도 예외가 나지 않는데, 결과는 더 나쁩니다.

const counter = { count: 0, inc() { this.count++; return this.count; } };
const inc = counter.inc;
print(inc(), globalThis.count);   // NaN NaN

undefined + 1 이라 NaN 이 되고, 부작용으로 전역 변수 count 까지 생겼습니다. 오류 없이 틀린 숫자와 오염된 전역만 남습니다.

this 값을 정하는 판단 그림. 질문 1: 화살표 함수인가? 그렇다면 this 는 바깥 코드의 this. 아니라면 질문 2: obj.method() 형태로 호출됐나? 그렇다면 this 는 obj. 아니라면 질문 3: bind·call·apply 로 지정했거나 new 로 호출했나? 그렇다면 지정한 객체나 새 인스턴스. 모두 아니면 그냥 함수 호출이고, strict 모드에서는 undefined(TypeError 의 원인), 비 strict 에서는 전역 객체.
this 는 무엇인가. 어디에 썼는지가 아니라 어떻게 호출됐는지로 정해진다. 화살표 함수는 이 판단을 건너뛴다.

흔한 실수: 콜백에서 this 를 잃어버리기

실수. 메서드 안에서 forEach, setTimeout, 이벤트 리스너에 일반 function 을 넘기고 그 안에서 this 를 쓴다.

class Timer {
  constructor() { this.ticks = 0; }
  start() { [1, 2, 3].forEach(function () { this.ticks++; }); }
}
new Timer().start();

실제로 일어나는 일. 콜백은 forEach 가 그냥 함수로 호출하므로 this 가 undefined 입니다(class 내부는 항상 strict 모드).

Exception: TypeError: undefined is not an object (evaluating 'this.ticks')
@this3.js:3:49
forEach@[native code]
start@this3.js:3:30
global code@this3.js:5:18

스택 트레이스가 실마리입니다. 맨 위 프레임에는 이름이 없고(익명 함수), 그것을 forEach 가, forEach 를 start 가 불렀습니다. 크롬과 Node 에서는 Cannot read properties of undefined (reading 'ticks') 라는 문구로 나옵니다(여기서는 실행 안 함).

고치는 법. start 의 this 를 이어받는 화살표 함수로 바꿉니다: [1, 2, 3].forEach(() => { this.ticks++; });. 2015년 이전 방식인 .bind(this) 나 const self = this; 도 동작하고, 오래된 코드에서 자주 보입니다.

클로저: 기억하는 함수

클로저는 함수와, 그 함수가 만들어질 때 보이던 변수의 묶음입니다. 바깥 함수가 return 한 뒤에도 함수가 살아 있는 동안 그 변수도 살아 있습니다.

function makeCounter(start = 0) {
  let n = start;                       // makeCounter 가 끝나도 남는다
  return { next: () => ++n, peek: () => n };
}
const a = makeCounter(), b = makeCounter(100);
a.next(); a.next();
print(a.next(), b.next(), a.peek(), b.peek(), a.n);   // 3 101 3 101 undefined

makeCounter 를 부를 때마다 새 n 이 생겨서 a 와 b 는 서로 간섭하지 않습니다. 그리고 a.n 은 undefined 입니다. next 와 peek 를 거치지 않고는 n 에 손댈 방법이 없습니다. 자바의 private 필드에 해당하는 것을, 클래스에 #private 필드(4강)가 생기기 전까지 20년 가까이 이렇게 숨겨 왔습니다.

은행이나 카페의 대기번호 발급기를 클로저로 만들면 이렇습니다.

function makeTicketMachine(prefix) {
  let next = 1;
  return () => `${prefix}-${String(next++).padStart(3, "0")}`;
}
const bank = makeTicketMachine("A"), cafe = makeTicketMachine("C");
print(bank(), bank(), cafe(), bank());   // A-001 A-002 C-001 A-003

발급기마다 자기 next 를 따로 가지고 있어서 cafe() 가 은행 번호를 건드리지 않았습니다.

반복문 안 var 함정

가장 유명한 클로저 버그를 실제로 돌려 봅니다.

for (var i = 0; i < 3; i++) setTimeout(() => print("var i =", i), 0);
for (let j = 0; j < 3; j++) setTimeout(() => print("let j =", j), 0);
const fns = [];
for (var k = 0; k < 3; k++) fns.push(() => k);
print("after loop, k =", k, "fns:", fns.map(f => f()).join(","));
after loop, k = 3 fns: 3,3,3
var i = 3
var i = 3
var i = 3
let j = 0
let j = 1
let j = 2

반복문 전체에 var i 는 하나뿐입니다. 화살표 함수 셋이 모두 같은 변수를 붙잡고 있고, 실행될 때(setTimeout 은 지금 스크립트가 끝날 때까지 기다리므로 반복문 이후)에는 3 이 되어 있습니다. for 머리에 쓴 let 은 특별해서 반복마다 새 변수가 만들어지므로 화살표 함수마다 자기 j 를 가집니다. 반복문이 끝난 뒤에도 k 가 보이는 것도 var 의 누수입니다. 2015년 이전에는 즉시 실행 함수 ((m) => setTimeout(() => print(m), 0))(m) 로 피했고 이것도 0, 1, 2 를 출력했지만, 지금은 let 이라고 쓰면 끝입니다.

실측: 클로저로 메모이제이션

클로저가 함정만 있는 것은 아닙니다. 대표적인 쓰임이 메모이제이션, 즉 결과를 비공개 Map 에 기억해 두는 감싸개입니다.

let calls = 0;
function fib(n) { calls++; return n < 2 ? n : fib(n - 1) + fib(n - 2); }

function memoize(fn) {
  const cache = new Map();             // 이 클로저만 만질 수 있다
  return function (n) {
    if (!cache.has(n)) cache.set(n, fn(n));
    return cache.get(n);
  };
}
const mfib = memoize(n => n < 2 ? n : mfib(n - 1) + mfib(n - 2));

memo.js 결과입니다(Apple M1 Pro, macOS 26.6 의 jsc, performance.now() 로 측정).

fib(25) = 75025: 242785 calls, 1.70 ms
fib(30) = 832040: 2692537 calls, 7.90 ms
fib(32) = 2178309: 7049155 calls, 17.88 ms
run 1: plain fib(30) 6.52 ms, memoized 0.220 ms
run 2: plain fib(30) 6.36 ms, memoized 0.020 ms
run 3: plain fib(30) 6.00 ms, memoized 0.020 ms
run 4: plain fib(30) 6.12 ms, memoized 0.160 ms
run 5: plain fib(30) 6.22 ms, memoized 0.020 ms
832040 memo body calls: 31
second call body calls: 0
단순 재귀 피보나치 소요 시간 막대그래프. fib(25) 는 242,785번 호출에 1.70밀리초, fib(30) 은 2,692,537번에 7.90밀리초, fib(32) 는 7,049,155번에 17.88밀리초. 클로저로 메모이제이션하면 fib(30) 은 31번 호출, 0.02밀리초였다.
jsc(macOS 26.6, Apple M1 Pro)에서 단순 재귀 fib(n)(각 1회). n 이 1 늘 때마다 호출 횟수는 약 1.6배. 메모이제이션 버전의 fib(30) 은 0.02 ms 였다.

단순 버전은 fib(30) 에 270만 번을 호출하고, n 이 1 늘 때마다 호출 횟수가 약 1.6배(황금비), fib(25) 에서 fib(30) 까지 11배가 됩니다. 메모이제이션 버전은 값마다 한 번만 계산하므로 0~30 에 31번, 두 번째 호출에서는 전부 캐시에 있어서 0번입니다. 매 회차마다 새 캐시를 만들었으므로 0.02 ms 는 31번 계산의 실제 비용입니다. 1·4회차의 0.16~0.22 ms 는 워밍업이나 가비지 컬렉션에 의한 흔들림이라 다섯 번을 모두 실었습니다. 대가는 메모리입니다. 캐시는 mfib 가 살아 있는 한 남고, 오래 도는 서버에서 잊지 않는 캐시는 메모리 누수와 같으니 실무에서는 크기 상한을 둡니다.

한계와 버전 메모

  • 엔진: macOS 26.6.2 에 들어 있는 JavaScriptCore(프레임워크 21624.5.1.11.3)를 jsc 로 실행했습니다. Node.js·Deno·브라우저는 실행하지 않았습니다. 호이스팅·TDZ·this·클로저·반복마다의 let 은 ECMAScript 명세가 정하는 것이라 어디서나 같지만, 오류 메시지 문구는 엔진마다 다릅니다.
  • 타이머: setTimeout 은 jsc 가 제공하는 것이지 언어 명세의 일부가 아닙니다. 브라우저와 Node 는 콜백의 this 와 타이밍을 각자의 규칙으로 구현합니다(설명만, 실행 안 함).
  • 버전: 화살표 함수, 블록 스코프 let/const, 기본값·나머지 매개변수, Map 은 ES2015 에 들어왔습니다. 그전에는 var 와 즉시 실행 함수밖에 없었기 때문에 오래된 코드와 오래된 답변에 그 방식이 많이 남아 있습니다.
  • 시간은 한 대의 기기에서 잰 값입니다. 호출 횟수는 정확하고 엔진과 무관합니다.

연습 문제

1. once(fn) 을 작성하세요. 돌려주는 함수는 처음 한 번만 fn 을 호출하고, 이후에는 fn 을 부르지 않고 첫 결과를 돌려줍니다.

2. 출력을 예상한 다음 실행하고, 첫 번째 값을 설명하세요.

const obj = { name: "box", greet: () => "hi " + this.name, greet2() { return "hi " + this.name; } };
print(obj.greet(), "|", obj.greet2());

3. makeCounter 에 reset() 을 추가해서 c.next(); c.next(); c.reset(); c.next() 가 1 을 돌려주게 하세요.

정답

1.

function once(fn) {
  let done = false, result;
  return (...args) => { if (!done) { done = true; result = fn(...args); } return result; };
}
const init = once(() => { print("initialising"); return 42; });
print(init(), init(), init());

initialising 이 한 번만 나오고 이어서 42 42 42 가 나왔습니다. done 과 result 는 클로저 안에 있습니다.

2. 실제 출력은 hi undefined | hi box 입니다. greet 는 화살표 함수라 this 가 스크립트 최상위의 this(name 이 없는 전역 객체)이지 obj 가 아닙니다. greet2 는 메서드 축약이라 this 가 obj 입니다.

3. function makeCounter() { let n = 0; return { next: () => ++n, reset() { n = 0; } }; } 로 1 이 나왔습니다. 두 함수가 같은 n 을 붙잡고 있습니다.

다음 강좌

3강은 객체와 배열입니다. map/filter/reduce, 스프레드와 구조 분해, 얕은 복사 함정, 그리고 [10, 9, 1].sort() 가 왜 1,10,9 인지 다룹니다.

자주 묻는 질문

var 와 setTimeout 을 쓴 반복문이 왜 같은 숫자를 세 번 출력하나요?

var 는 반복문 전체(함수 전체)에 변수를 하나만 만들기 때문입니다. 콜백은 반복문이 끝난 뒤에 실행되므로 모두 마지막 값을 읽습니다. jsc 에서 for (var i = 0; i < 3; i++) setTimeout(() => print(i)) 는 3, 3, 3 을 출력했고, let 으로 바꾸면 반복마다 변수가 따로 생겨 0, 1, 2 가 나왔습니다.

화살표 함수와 일반 function 은 this 가 어떻게 다른가요?

일반 함수의 this 는 호출 방식으로 정해집니다. obj.method() 면 obj, 그냥 함수로 호출하면 strict 모드에서 undefined 입니다. 화살표 함수는 자기 this 가 없어서 바깥 코드의 this 를 그대로 씁니다. 그래서 메서드 안의 콜백에는 화살표 함수가 맞고, 객체의 메서드 자체로는 맞지 않습니다.

"Cannot access 'x' before initialization" 은 무슨 뜻인가요?

let 이나 const 변수를 선언 줄이 실행되기 전(TDZ 안)에 읽었다는 뜻입니다. 함수 안 아래쪽에 let x 가 있어서 바깥 x 를 가릴 때 자주 납니다. 함수 본문 전체가 안쪽 x 를 가리키게 되기 때문입니다. 선언을 위로 옮기거나 이름을 바꿉니다.

  • #자바스크립트
  • #초급
  • #함수
  • #클로저
  • #this
  • #스코프
  • #jsc

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑