함수가 뭔지 모르는 사람은 이 글을 볼 리 없다. 값을 받아 뭔가 하고 돌려주는 것, 다 안다. 그런데 JS의 함수는 선언 방식이 여럿이고, 각각 호이스팅(hoisting, 선언이 위로 끌어올려지는 현상)과 스코프에서 다르게 논다. 여기에 클로저까지 얹히면, 분명 맞게 짠 것 같은데 값이 예상과 다르게 나오는 미스터리가 시작된다. 이번 편은 그 미스터리의 뒷면을 연다.


함수 선언문은 끌어올려지고, 함수 표현식은 안 그렇다. function 이름() {} 형태의 선언문은 코드가 실행되기 전에 통째로 위로 끌어올려진다. 그래서 선언보다 위에서 호출해도 멀쩡히 돈다. 반면 변수에 함수를 담는 표현식은 그 변수 규칙을 따르므로 미리 부를 수 없다.


console.log(add(2, 3)); // 5 (선언보다 위인데 됨)
function add(a, b) { return a + b; }

console.log(mul(2, 3)); // ReferenceError
const mul = function(a, b) { return a * b; };


add는 선언보다 위에서 불렀는데도 5가 나온다. 선언문 전체가 끌어올려졌기 때문이다. 그런데 mulReferenceError: Cannot access 'mul' before initialization(초기화 전에 접근할 수 없음)로 터진다. const로 담은 함수 표현식은 그 줄에 도달해야 값이 생기기 때문이다. 이 차이 때문에 예전엔 선언문을 선호했지만, 요즘은 예측 가능성 때문에 오히려 표현식과 화살표 함수를 위에 두고 순서대로 쓰는 쪽이 대세다.


var의 호이스팅은 더 음흉하다. var로 선언한 변수는 선언만 위로 끌어올려지고 값은 원래 자리에 남는다. 그래서 값을 넣기 전에 읽으면 에러가 아니라 undefined가 슬그머니 나온다. 에러라도 나면 고칠 텐데, 조용히 undefined가 나오니 버그를 찾기가 더 어렵다.


function varTest() {
console.log(x); // undefined (에러가 아님!)
var x = 10;
console.log(x); // 10
}
varTest();


첫 줄이 undefined, 둘째 줄이 10이다. var x의 선언부만 함수 꼭대기로 올라가서, 값을 넣기 전 시점엔 undefined 상태로 존재한 것이다. letconst는 이런 짓을 안 하고, 선언 전에 접근하면 아까처럼 에러를 낸다. 이 하나만으로도 var를 버리고 let / const를 쓸 이유가 충분하다.


화살표 함수는 짧게 쓰는 문법이자 this 규칙이 다른 함수다. =>를 쓰면 함수를 간결하게 적을 수 있다. 중괄호와 return을 생략하면 그 식의 결과가 바로 반환값이 된다. 콜백처럼 짧은 함수를 넘길 때 특히 빛난다.


const square = n => n * n;
console.log(square(4)); // 16

function pow(base, exp = 2) { return base ** exp; }
console.log(pow(3)); // 9 (exp 기본값 2)
console.log(pow(3, 3)); // 27

function total(...nums) { return nums.reduce((a, b) => a + b, 0); }
console.log(total(1, 2, 3, 4)); // 10
console.log(total()); // 0


square(4)16이다. 매개변수에 = 2처럼 기본값을 주면 인자를 안 넘겼을 때 그 값이 쓰여서 pow(3)9가 된다. 반대로 인자가 몇 개 올지 모를 땐 ...nums 같은 나머지 매개변수(rest parameter)로 죄다 배열에 쓸어 담는다. total(1,2,3,4)10, 아무것도 안 넘긴 total()0인 건 빈 배열을 0부터 더했기 때문이다.


스코프는 변수가 살아있는 범위다. 그리고 var와 let은 그 범위가 다르다. 함수 안에서 선언한 변수는 밖에서 안 보인다. 같은 이름이 안팎에 있으면 안쪽이 바깥을 가린다. 여기까진 상식인데, var는 블록({ })을 스코프로 안 치고 let은 친다는 차이가 반복문에서 사고를 친다.


var a = [];
for (var i = 0; i < 3; i++) { a.push(() => i); }
console.log(a.map(f => f()).join(',')); // 3,3,3

var b = [];
for (let j = 0; j < 3; j++) { b.push(() => j); }
console.log(b.map(f => f()).join(',')); // 0,1,2


같은 반복문인데 var 쪽은 3,3,3, let 쪽은 0,1,2다. var i는 반복문 전체가 i 하나를 공유해서, 나중에 함수를 부를 땐 이미 i3이 돼 있다. let j는 반복이 돌 때마다 새 j를 만들어 각 함수가 그 순간의 값을 붙든다. 이 3,3,3 버그는 JS 면접 단골이자, let이 왜 필요한지를 가장 극적으로 보여주는 예다.


클로저는 함수가 태어난 곳의 변수를 기억하는 성질이다. 함수를 만들어 밖으로 내보내도, 그 함수는 자기가 태어난 스코프의 변수를 계속 붙들고 있다. 이걸 이용하면 밖에서 직접 못 건드리는 사적인 상태를 만들 수 있다.


function makeCounter() {
let count = 0;
return function() { count++; return count; };
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
const counter2 = makeCounter();
console.log(counter2()); // 1 (새 count라 처음부터)


counter()를 부를 때마다 1, 2, 3으로 늘어난다. makeCounter가 끝났는데도 그 안의 count가 안 사라지고 counter 함수에 붙어서 살아있는 것이다. 게다가 counter2를 새로 만들면 count가 별도로 하나 더 생겨서 1부터 다시 센다. 각 클로저가 자기만의 count를 갖는다는 뜻이다. 이 성질이 상태 관리의 밑바탕이라, 리액트 같은 프레임워크를 쓰다 값이 옛날 값으로 굳어 있는 현상을 만나면 그 뿌리엔 대개 클로저가 있다.


화살표 함수의 this는 자기 걸 안 만든다. 일반 함수는 어떻게 호출됐느냐에 따라 this가 정해지지만, 화살표 함수는 자기 this가 없어서 바깥 스코프의 this를 그대로 물려받는다.


const obj = {
val: 42,
normal: function() { return this.val; },
arrow: () => { return this.val; }
};
console.log(obj.normal()); // 42
console.log(obj.arrow()); // undefined


obj.normal()42인데 obj.arrow()undefined다. 일반 함수 normalobj.normal()로 불렸으니 thisobj를 가리켜 val을 찾는다. 하지만 화살표 arrow는 자기 this가 없어 바깥, 즉 obj가 아닌 상위 스코프의 this를 물려받는 탓에 val을 못 찾는다. 그래서 객체의 메서드는 화살표로 쓰면 안 되고, 반대로 콜백 안에서 바깥 this를 그대로 쓰고 싶을 땐 화살표가 딱 맞는다. 함수는 이렇게 선언 방식마다 호이스팅, 스코프, this가 제각각이라, 어느 걸 언제 쓰는지가 곧 JS를 얼마나 아는지를 가른다.