자바스크립트 함수, 선언부터 클로저와 this까지 한 번에 이해하기
빠른 답
- 자바스크립트에서 함수는 값처럼 다룰 수 있는 일급 객체라서 전달, 반환, 저장이 모두 가능합니다.
- 함수 선언식과 함수 표현식은 호이스팅 방식이 다르므로 호출 가능 시점이 달라집니다.
- 클로저는 함수가 자신이 만들어질 때의 스코프를 기억하는 동작이며, 상태 캡슐화의 핵심 도구입니다.
- 화살표 함수는 문법만 짧은 것이 아니라 this와 arguments를 자체적으로 만들지 않는다는 점이 중요합니다.
목차
한눈에 비교
함수를 이해할 때는 문법 이름보다 먼저 네 가지를 보면 된다. 값처럼 전달되는가, 언제 호출 가능한가, 어떤 스코프를 기억하는가, this를 스스로 가지는가. 이 기준이 잡히면 선언식, 표현식, 클로저, 화살표 함수가 따로 놀지 않는다.
실행 환경을 먼저 맞추기
this와 전역 바인딩은 실행 환경에 따라 결과가 달라질 수 있다. 브라우저의 일반 스크립트, 브라우저의 ES 모듈, Node.js CommonJS, Node.js ES 모듈은 top-level this가 서로 같지 않다. 같은 코드를 따라 쳤는데 출력이 다르다면 함수 개념이 틀린 게 아니라 실행 환경이 섞였을 가능성이 높다.
실습을 고정하려면 Node.js를 ES 모듈 기준으로 맞춰 두는 편이 좋다.
{
"type": "module",
"scripts": {
"demo": "node demo.js"
}
}
이렇게 두면 파일 전체가 모듈 문맥으로 실행되고, this와 스코프를 비교할 때 브라우저의 오래된 일반 스크립트 규칙이 끼어들지 않는다. 특히 아래 예시처럼 “내부 일반 함수의 this가 왜 undefined인가”를 볼 때 해석이 훨씬 선명해진다.
함수는 값이면서 호출 가능한 객체다
함수는 코드 묶음이기 전에 값이다. 그래서 변수에 넣을 수 있고, 배열에 넣을 수 있고, 다른 함수에 넘길 수 있다. 동시에 일반 객체와 달리 호출할 수 있다는 점이 핵심이다. “함수는 객체다”와 “모든 객체는 함수다”는 전혀 다른 말이다.
아래 예시는 함수가 값처럼 흘러다니고, 선언 방식에 따라 호출 가능 시점이 어떻게 갈리는지 한 번에 보여준다.
const run = (fn, input) => fn(input);
const square = n => n * n;
console.log(typeof square);
console.log(run(square, 4));
console.log(declared(1, 2));
try {
console.log(expressedConst(1, 2));
} catch (error) {
console.log(`${error.name} - ${error.message}`);
}
try {
console.log(expressedVar(1, 2));
} catch (error) {
console.log(`${error.name} - ${error.message}`);
}
function declared(a, b) {
return a + b;
}
const expressedConst = function add(a, b) {
return a + b;
};
var expressedVar = function (a, b) {
return a + b;
};
console.log(expressedConst.name);
여기서 run(square, 4)는 함수가 값이라는 사실을 가장 단순하게 드러낸다. square는 전달되고, run 안에서 호출되며, 결과는 다시 값으로 반환된다. 콜백, 이벤트 핸들러, 미들웨어, 고차 함수가 자연스럽게 성립하는 이유가 바로 이것이다.
또 하나 중요한 점은 “함수 표현식”과 “익명 함수”를 같은 말로 보면 안 된다는 것이다. const expressedConst = function add() {}는 함수 표현식이지만 익명 함수가 아니다. 이름 있는 함수 표현식은 디버깅할 때 스택 트레이스와 name 프로퍼티에서 차이를 만든다.
선언식, 표현식, 화살표 함수의 차이
함수 선언식은 문장이고, 함수 표현식은 값이다. 화살표 함수는 함수 표현식의 한 형태지만 런타임 의미가 일반 함수와 완전히 같지 않다. 이 차이를 문법 취향 정도로 보면 이후에 this, arguments, 생성자 호출에서 계속 헷갈린다.
실무에서 자주 틀리는 기준만 좁혀 보면 이렇다.
- 선언식은 파일 어디서든 먼저 읽히는 유틸리티 함수에 잘 맞는다.
- 표현식은 함수 자체를 값으로 조합하거나 조건부로 전달할 때 자연스럽다.
- 화살표 함수는 짧은 콜백과 바깥
this유지에 강하다. - 메서드 문맥, 생성자 문맥,
arguments가 중요한 자리는 일반 함수가 더 안전하다.
용어 기준을 더 확인하고 싶다면 MDN의 Function declaration, Function expression, Arrow function expressions, Closures를 함께 보면 좋다.
호이스팅과 TDZ를 오류 이름으로 읽기
호이스팅은 코드가 물리적으로 위로 올라간다는 뜻이 아니다. 실행 전에 식별자 바인딩이 준비된다고 이해해야 실제 동작과 맞다. 이 관점으로 보면 왜 같은 “선언 전 호출”인데도 오류가 다르게 나오는지 설명할 수 있다.
위 코드에서 declared(1, 2)는 성공한다. 함수 선언식은 평가 단계에서 함수 객체까지 준비되기 때문이다. 반면 expressedConst(1, 2)는 ReferenceError가 난다. 식별자를 아예 모르는 게 아니라, 바인딩은 있지만 아직 초기화되지 않았기 때문이다. 이것이 let과 const의 TDZ, 즉 일시적 사각지대다.
expressedVar(1, 2)는 또 다르다. 여기서는 TypeError가 난다. var는 선언 시점에 undefined로 초기화되므로 “읽기는 되지만 호출할 값이 아님”이라는 상태가 된다. 즉 ReferenceError는 접근 시점 문제이고, TypeError는 읽어 온 값의 타입 문제다. 둘을 구분해 읽으면 디버깅 속도가 크게 빨라진다.
많이 놓치는 포인트도 하나 있다. TDZ 안의 const나 let에 대해 typeof를 써도 안전하지 않다. 이 경우에도 ReferenceError가 날 수 있다. “typeof는 항상 안전하다”는 오래된 감각은 TDZ 앞에서 깨진다.
클로저는 값을 복사하지 않고 환경을 붙잡는다
클로저는 외부 값을 복사해 보관하는 기능이 아니다. 함수가 만들어질 때의 렉시컬 환경에 계속 접근하는 구조다. 그래서 같은 팩토리 함수를 여러 번 호출하면 모양은 같아 보여도 서로 다른 상태를 가진 함수들이 생긴다.
function createCounter(start = 0) {
let count = start;
return {
up() {
count += 1;
return count;
},
current() {
return count;
},
};
}
const left = createCounter(1);
const right = createCounter(10);
console.log(left.current());
console.log(left.up());
console.log(left.up());
console.log(right.up());
const ArrowCtor = () => {};
try {
new ArrowCtor();
} catch (error) {
console.log(`${error.name} - ${error.message}`);
}
const obj = {
value: 42,
method(label) {
console.log("method this.value:", this.value);
function normal() {
console.log("normal inner this:", this?.value);
}
const arrow = () => {
console.log("arrow inner this:", this.value);
console.log("arrow outer arguments[0]:", arguments[0]);
};
normal();
arrow();
},
};
obj.method("first");
left와 right는 같은 createCounter에서 나왔지만 서로 다른 렉시컬 환경을 가진다. 그래서 left를 두 번 올려도 right에는 영향이 없다. 이 때문에 클로저는 상태 캡슐화, 설정 고정, 팩토리 함수 패턴에서 강력하다.
동시에 오해도 많다. 클로저는 “옛날 값을 얼려 두는 기능”이 아니다. 같은 환경을 계속 바라보므로 외부 상태가 바뀌면 내부 함수가 보는 값도 함께 달라진다. 값 복사와 참조 유지의 차이를 혼동하면 비동기 코드에서 특히 자주 틀린다.
this와 arguments는 호출 방식과 선언 방식이 가른다
this는 함수가 어디에 적혀 있는지가 아니라 어떻게 호출됐는지에 더 크게 좌우된다. 반면 클로저는 어디에서 선언됐는지가 핵심이다. 둘 다 함수와 함께 등장하지만 규칙은 전혀 다르다.
위 코드에서 obj.method("first")의 method는 메서드 호출이므로 this가 obj를 가리킨다. 하지만 그 안의 normal()은 그냥 일반 함수 호출이기 때문에 모듈의 엄격 모드 기준으로 this가 undefined다. 그래서 this?.value는 예외 없이 undefined를 찍는다. 만약 ?. 없이 this.value를 읽었다면 여기서는 TypeError가 났을 것이다.
반대로 arrow는 자신의 this를 만들지 않는다. 바깥 method의 this를 그대로 닫아 두기 때문에 42를 읽는다. arguments도 마찬가지다. 화살표 함수 내부의 arguments는 그 함수 자신의 것이 아니라 바깥 일반 함수 method의 arguments를 참조한다. 그래서 "first"가 출력된다.
new ArrowCtor()가 TypeError를 내는 이유도 같은 축에서 이해할 수 있다. 화살표 함수는 생성자 역할을 하도록 설계되지 않았기 때문에 new 대상이 될 수 없다. 화살표 함수는 짧아서 좋은 게 아니라, 호출 문맥을 고정하고 싶을 때 좋은 것이다.
출력으로 읽는 디버깅 포인트
함수 관련 버그는 에러 이름과 출력 형태만 제대로 읽어도 절반은 정리된다. 아래는 앞선 예시를 실행했을 때 볼 수 있는 대표적인 출력이다.
function
16
3
ReferenceError - Cannot access 'expressedConst' before initialization
TypeError - expressedVar is not a function
add
1
2
3
11
TypeError - ArrowCtor is not a constructor
method this.value: 42
normal inner this: undefined
arrow inner this: 42
arrow outer arguments[0]: first
이 출력은 이렇게 해석하면 된다.
function: 함수가 값으로 평가된다는 뜻이다.16: 함수를 다른 함수에 넘겨 실행했다는 뜻이다.3: 선언식은 선언 전 호출이 가능하다는 뜻이다.ReferenceError: 바인딩은 있지만 아직 접근할 수 없다는 뜻이다.TypeError - ... is not a function: 값을 읽긴 했지만 호출 가능한 타입이 아니라는 뜻이다.add: 함수 표현식은 익명일 수도 있고 이름을 가질 수도 있다는 뜻이다.1,2,3,11: 같은 팩토리에서 나온 함수라도 각자 다른 상태를 붙잡는다는 뜻이다.... is not a constructor: 화살표 함수는 생성자가 아니라는 뜻이다.normal inner this: undefined: 일반 함수의this는 호출 방식에 따라 끊긴다는 뜻이다.arrow inner this: 42: 화살표 함수는 바깥this를 유지한다는 뜻이다.
함수 디버깅에서 흔한 오해는 세 가지다. “호이스팅 때문에 함수는 다 미리 쓸 수 있다”, “화살표 함수가 더 최신이니 항상 더 좋다”, “클로저는 외부 값을 복사한다”는 모두 틀린 문장이다. 실제로는 바인딩 시점, 호출 문맥, 렉시컬 환경이라는 서로 다른 규칙이 동시에 작동한다.
실무에서 어떤 형태를 고를까
선택 기준은 단순하다. 이름이 드러나는 공용 유틸리티면 선언식이 읽기 좋고, 함수를 조합하거나 전달해야 하면 표현식이 유연하다. 짧은 콜백이나 바깥 this를 유지해야 하는 비동기 코드라면 화살표 함수가 편하다. 반대로 메서드 문맥을 직접 다루거나 arguments와 생성자 의미가 중요하면 일반 함수가 더 안전하다.
결국 자바스크립트 함수는 “문법”보다 “의미”로 봐야 한다. 값으로 다뤄지는가, 초기화 시점이 언제인가, 어떤 환경을 기억하는가, 호출 문맥이 어떻게 결정되는가. 이 네 가지를 먼저 보면 선언식, 표현식, 클로저, 화살표 함수가 각각 따로 외울 대상이 아니라 하나의 런타임 규칙으로 묶인다.
원문 참고
https://www.maeil-mail.kr/question/33
댓글
댓글 쓰기