JavaScript 함수: 선언식, 화살표 함수, 콜백, 클로저
이 글의 핵심
화살표 함수로 바꿨더니 this가 엉뚱한 객체를 가리키는 문제는 함수 정의 방식마다 this 결정 규칙이 다르기 때문입니다. 함수가 값으로 전달되는 JavaScript 특성을 바탕으로 고차 함수와 클로저가 어떻게 동작하는지 설명하고, 상황에 맞는 함수 형태를 고르는 기준을 제시합니다.
들어가며
함수란?
함수(Function)는 특정 작업을 묶어 이름 붙인 코드 블록입니다. JavaScript에서는 함수가 일급 객체(First-class Object)이므로, 변수에 담거나 인자로 넘기는 것이 자연스럽습니다. 이어서 설명하는 클로저는 함수가 외부 변수를 배낭처럼 들고 다니며 기억하는 개념과 연결됩니다.
“일급 객체”라는 말은 함수가 숫자나 문자열과 똑같이 취급된다는 뜻입니다. 변수에 담을 수 있고, 다른 함수에 인자로 넘길 수 있고, 함수에서 반환할 수 있고, 객체의 속성으로 둘 수 있습니다. 이 성질 하나에서 콜백, 고차 함수, 클로저, 이벤트 핸들러 같은 JavaScript의 핵심 패턴이 모두 나옵니다. 동시에 JavaScript 함수는 정의하는 문법이 여러 가지이고, 문법마다 호이스팅과 this의 규칙이 다릅니다. 초보자가 겪는 함수 관련 버그의 상당수가 “같아 보이는 두 문법이 사실 다르게 동작한다”에서 나오므로, 이 글은 각 문법의 차이가 실제로 어디서 드러나는지를 중심으로 설명합니다.
선언문, 표현식, 화살표 함수
함수 선언문 (Function Declaration)
function add(a, b) {
return a + b;
}
console.log(add(10, 20)); // 30
// 호이스팅됨 (선언 전 호출 가능)
greet(); // 안녕하세요!
function greet() {
console.log("안녕하세요!");
}
함수 표현식 (Function Expression)
// 익명 함수
const subtract = function(a, b) {
return a - b;
};
console.log(subtract(20, 10)); // 10
// 기명 함수 표현식
const factorial = function fact(n) {
if (n <= 1) return 1;
return n * fact(n - 1); // 재귀 시 이름 사용
};
console.log(factorial(5)); // 120
// 호이스팅 안 됨
// multiply(5, 6); // ReferenceError
const multiply = function(a, b) {
return a * b;
};
함수 선언문은 스코프에 진입할 때 본문까지 통째로 등록되므로 파일 아래쪽에 정의한 함수를 위에서 호출할 수 있습니다. 함수 표현식은 “함수 값을 변수에 대입하는 문장”이라서 변수 규칙을 따릅니다. const로 받았다면 대입문 이전은 TDZ라 ReferenceError: Cannot access 'multiply' before initialization이 나고, var로 받았다면 변수만 undefined로 먼저 존재해서 TypeError: multiply is not a function이 납니다. 같은 실수라도 선언 키워드에 따라 에러 메시지가 다르므로, 메시지를 보고 원인을 구분할 수 있으면 디버깅이 빨라집니다.
선언문의 호이스팅은 편리하지만 “파일 어디서든 호출 가능”이라는 점 때문에 코드의 흐름을 따라가기 어렵게 만들기도 합니다. 그래서 많은 팀이 const + 화살표 함수나 함수 표현식을 기본으로 쓰고, 정의 전 호출을 ESLint no-use-before-define으로 막습니다. 기명 함수 표현식의 fact처럼 이름을 붙이면 그 이름은 함수 안에서만 보이므로, 바깥에서 factorial을 다른 값으로 바꿔도 재귀가 깨지지 않고, 에러 스택 트레이스에도 fact라는 이름이 찍혀 디버깅에 도움이 됩니다.
화살표 함수 (Arrow Function, ES6+)
ES6에서 도입된 간결한 함수 문법입니다:
// 기본 형태: 여러 줄일 때
const add = (a, b) => {
// 중괄호 {}가 있으면 return 키워드 필요
return a + b;
};
// 단일 표현식: return과 중괄호 생략 가능
const add = (a, b) => a + b;
// 표현식의 결과가 자동으로 반환됨
// 매개변수 1개: 괄호 생략 가능
const square = x => x * x;
console.log(square(5)); // 25
// (x) => x * x 와 동일
// 매개변수 없음: 빈 괄호 필수
const greet = () => console.log("Hello!");
greet(); // Hello!
// 객체 반환: 괄호로 감싸기 (중요!)
const makePerson = (name, age) => ({ name, age });
// 중괄호 {}는 코드 블록으로 인식되므로
// 객체 리터럴을 반환하려면 ()로 감싸야 함
console.log(makePerson("홍길동", 25)); // { name: '홍길동', age: 25 }
// 잘못된 예:
// const makePerson = (name, age) => { name, age }; // undefined 반환!
// 여러 줄: 중괄호와 return 필요
const complexFunc = (a, b) => {
const sum = a + b;
const product = a * b;
return { sum, product };
};
화살표 함수의 특징:
- 간결한 문법: 코드가 짧고 읽기 쉬움
- this 바인딩: 렉시컬 스코프 (상위 스코프의 this 사용)
- arguments 없음: 나머지 매개변수(
...args) 사용 - 생성자 불가:
new키워드로 호출 불가 언제 사용하나:
- 콜백 함수 (배열 메서드, 이벤트 핸들러)
- 간단한 유틸리티 함수
- this 바인딩이 필요 없는 경우
(위 코드는 문법을 보여 주려고 add를 두 번 선언했는데, 같은 스코프에서 const add를 두 번 쓰면 SyntaxError: Identifier 'add' has already been declared가 나므로 실제로는 하나만 둡니다.)
화살표 함수에서 가장 중요한 특징은 2번, 자기만의 this가 없다는 것입니다. 화살표 함수 안의 this는 함수가 정의된 위치의 바깥 this를 그대로 씁니다. 그래서 메서드 안의 콜백에서 바깥 객체를 가리키는 this를 유지할 때 편리하지만, 반대로 객체의 메서드 자체를 화살표 함수로 만들면 this가 그 객체를 가리키지 않습니다(아래 “자주 하는 실수” 참고). 이벤트 핸들러를 화살표 함수로 등록하면 this가 이벤트 대상 요소를 가리키지 않는다는 점도 같은 이유이며, 이때는 event.currentTarget을 쓰면 됩니다.
객체 리터럴을 반환할 때 괄호가 필요한 이유는 문법 해석 때문입니다. => { name, age }에서 중괄호는 함수 본문 블록으로 해석되고, 그 안의 name, age는 아무것도 반환하지 않는 쉼표 식이 되어 결과는 undefined입니다. 에러가 나지 않고 조용히 undefined를 반환하므로, React에서 map(item => { id: item.id })로 쓰고 빈 목록이 렌더링되는 식으로 발견되는 경우가 많습니다.
함수 선언문 vs 표현식 vs 화살표 함수
| 특징 | 선언문 | 표현식 | 화살표 함수 |
|---|---|---|---|
| 호이스팅 | ✅ | ❌ | ❌ |
| this 바인딩 | 동적 | 동적 | 렉시컬 |
| arguments | ✅ | ✅ | ❌ |
| 생성자 | ✅ | ✅ | ❌ |
| 간결성 | 보통 | 보통 | 높음 |
매개변수와 반환값
기본 매개변수 (Default Parameters)
function greet(name = "손님") {
return `안녕하세요, ${name}님!`;
}
console.log(greet("홍길동")); // 안녕하세요, 홍길동님!
console.log(greet()); // 안녕하세요, 손님님!
// 표현식도 가능
function createArray(length = 10, fill = 0) {
return Array(length).fill(fill);
}
console.log(createArray(5, 1)); // [1, 1, 1, 1, 1]
console.log(createArray(3)); // [0, 0, 0]
기본값은 인자가 undefined일 때만 적용됩니다. greet(null)은 "안녕하세요, null님!"을, greet("")은 빈 이름을 출력합니다. null이나 빈 문자열까지 기본값으로 바꾸고 싶다면 함수 안에서 name = name || "손님"이나 name ?? "손님"(null과 undefined만)으로 처리해야 합니다. ES6 이전 코드에서 흔히 보던 name = name || "손님" 패턴은 0이나 false 같은 정상 값도 기본값으로 바꿔 버리는 버그가 있어서, 기본 매개변수 문법이 그 대안으로 들어왔습니다.
기본값 식은 호출할 때마다 새로 평가됩니다. function add(item, list = [])는 매 호출마다 새 배열을 만들기 때문에, Python처럼 기본 인자 객체가 호출 사이에 공유되는 함정이 없습니다. 앞의 매개변수를 참조할 수도 있어서 function range(start, end = start + 10)처럼 쓸 수 있습니다.
나머지 매개변수 (Rest Parameters)
function sum(...numbers) {
return numbers.reduce((acc, num) => acc + num, 0);
}
console.log(sum(1, 2, 3)); // 6
console.log(sum(1, 2, 3, 4, 5)); // 15
// 일반 매개변수와 함께 사용
function introduce(greeting, ...names) {
return `${greeting}, ${names.join(", ")}!`;
}
console.log(introduce("안녕하세요", "홍길동", "김철수", "이영희"));
// 안녕하세요, 홍길동, 김철수, 이영희!
나머지 매개변수는 남은 인자를 진짜 배열로 모아 줍니다. 예전에는 arguments 객체를 썼는데, 이것은 배열처럼 생겼지만 배열이 아니라서 arguments.map이 없고 Array.from(arguments)로 변환해야 했습니다. 화살표 함수에는 arguments 자체가 없으므로, 가변 인자가 필요하면 나머지 매개변수가 유일한 방법입니다. 나머지 매개변수는 맨 마지막에만 올 수 있고, function f(...a, b)는 SyntaxError: Rest parameter must be last formal parameter입니다.
주의할 점은 sum(...hugeArray)처럼 아주 큰 배열을 펼쳐서 호출하는 경우입니다. 펼친 원소가 모두 함수 인자로 스택에 올라가기 때문에 원소가 수십만 개를 넘으면 RangeError: Maximum call stack size exceeded가 날 수 있습니다. Math.max(...arr)가 큰 배열에서 실패하는 것도 같은 이유라서, 이때는 arr.reduce((a, b) => Math.max(a, b))처럼 반복으로 처리합니다.
구조 분해 매개변수 (Destructuring)
// 객체 구조 분해
function printUser({ name, age, city = "서울" }) {
console.log(`${name} (${age}세, ${city})`);
}
printUser({ name: "홍길동", age: 25 });
// 홍길동 (25세, 서울)
// 배열 구조 분해
function getMinMax([first, ...rest]) {
return {
min: Math.min(first, ...rest),
max: Math.max(first, ...rest)
};
}
console.log(getMinMax([3, 1, 4, 1, 5]));
// { min: 1, max: 5 }
객체 구조 분해 매개변수는 인자가 많은 함수에서 특히 유용합니다. createUser("홍길동", 25, "서울", true, false)처럼 순서로 넘기면 호출부만 보고는 true와 false가 무엇인지 알 수 없지만, createUser({ name: "홍길동", age: 25, admin: true })는 스스로 설명하고 순서도 신경 쓸 필요가 없습니다. 선택적 옵션이 많은 API(fetch(url, { method, headers }))가 이 방식을 쓰는 이유입니다.
흔한 실수는 인자 없이 호출하는 경우입니다. printUser()처럼 아무것도 넘기지 않으면 undefined를 구조 분해하려다 TypeError: Cannot destructure property 'name' of 'undefined' as it is undefined가 납니다. 옵션 객체 전체를 생략할 수 있게 하려면 function printUser({ name, age, city = "서울" } = {})처럼 매개변수 자체에도 기본값 = {}를 둡니다.
여러 값 반환
// 배열로 반환
function getCoordinates() {
return [10, 20];
}
const [x, y] = getCoordinates();
console.log(x, y); // 10 20
// 객체로 반환 (권장)
function getUserInfo() {
return {
name: "홍길동",
age: 25,
city: "서울"
};
}
const { name, age } = getUserInfo();
console.log(name, age); // 홍길동 25
객체 반환을 “권장”하는 이유는 순서 의존성과 확장성 때문입니다. 배열로 반환하면 받는 쪽이 [x, y]의 순서를 정확히 알아야 하고, 나중에 z를 추가하면 모든 호출부를 확인해야 합니다. 객체는 필요한 속성만 이름으로 꺼내면 되므로 속성이 늘어나도 기존 코드가 깨지지 않습니다. 다만 React의 useState처럼 받는 쪽이 이름을 자유롭게 붙여야 하는 경우에는 배열 반환이 더 편합니다(const [count, setCount] = useState(0)).
고차 함수: 함수를 주고받기
함수를 인자로 받기
function repeat(n, action) {
for (let i = 0; i < n; i++) {
action(i);
}
}
repeat(3, console.log);
// 0
// 1
// 2
repeat(3, i => console.log(`${i}번째`));
// 0번째
// 1번째
// 2번째
repeat(3, console.log)처럼 함수를 이름만 넘기는 것이 가능한 것은 함수가 값이기 때문입니다. 여기서 흔한 실수는 repeat(3, console.log(i))처럼 괄호를 붙이는 것인데, 그러면 함수를 넘기는 것이 아니라 함수를 즉시 호출한 결과(undefined)를 넘기게 되어 TypeError: action is not a function이 납니다.
함수를 이름만 넘길 때는 넘기는 쪽이 어떤 인자를 주는지도 확인해야 합니다. 유명한 예가 ["1", "2", "3"].map(parseInt)인데, 결과는 [1, 2, 3]이 아니라 [1, NaN, NaN]입니다. map은 콜백에 (값, 인덱스, 배열) 세 인자를 넘기고, parseInt의 두 번째 인자는 진법이라 parseInt("2", 1), parseInt("3", 2)가 되기 때문입니다. 확실하지 않으면 arr.map(s => parseInt(s, 10))처럼 화살표 함수로 감싸서 인자를 명시하는 편이 안전합니다.
함수를 반환하기
function makeMultiplier(factor) {
return function(x) {
return x * factor;
};
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// 화살표 함수 버전
const makeMultiplier = factor => x => x * factor;
배열 고차 함수
const numbers = [1, 2, 3, 4, 5];
// map: 변환
const doubled = numbers.map(x => x * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
// filter: 필터링
const evens = numbers.filter(x => x % 2 === 0);
console.log(evens); // [2, 4]
// reduce: 누적
const sum = numbers.reduce((acc, x) => acc + x, 0);
console.log(sum); // 15
// 체이닝
const result = numbers
.filter(x => x % 2 === 0) // [2, 4]
.map(x => x * 2) // [4, 8]
.reduce((acc, x) => acc + x, 0); // 12
console.log(result); // 12
map과 filter는 원본 배열을 바꾸지 않고 새 배열을 반환합니다. 그래서 numbers.map(x => x * 2);만 쓰고 결과를 변수에 받지 않으면 아무 일도 일어나지 않은 것처럼 보입니다. 반대로 sort, reverse, splice는 원본을 직접 바꾸므로, React 상태처럼 원본을 유지해야 하는 곳에서는 [...arr].sort()나 ES2023의 toSorted()를 씁니다.
reduce의 두 번째 인자(초기값)는 생략하지 않는 편이 안전합니다. 생략하면 첫 원소가 초기값이 되는데, 빈 배열에 초기값 없이 reduce를 호출하면 TypeError: Reduce of empty array with no initial value가 납니다. 필터 결과가 비는 경우가 흔하므로 체이닝 끝의 reduce에는 특히 초기값이 필요합니다. 체이닝은 단계마다 중간 배열을 만들기 때문에, 원소가 수백만 개인 핫 루프에서는 단일 for 루프가 눈에 띄게 빠를 수 있습니다. 대부분의 코드에서는 읽기 쉬운 체이닝이 더 나은 선택입니다.
클로저와 프라이빗 변수
클로저란?
클로저(Closure)는 함수가 자신이 만들어진 환경의 변수를 붙잡고, 바깥에서 호출해도 그 값을 잃지 않는 닫힌 관계입니다.
비유(배낭): 바깥 함수가 배낭을 메고 있으며, 안쪽 함수가 그 안의 물건(외부 변수)을 계속 꺼내 쓰는 형태라고 보시면 됩니다. makeCounter가 끝나도 count는 반환된 함수에 묶여 남습니다.
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
일반적으로 함수가 끝나면 지역 변수는 사라집니다. 그런데 makeCounter가 반환한 함수는 count를 참조하고 있으므로, JavaScript 엔진은 count를 담은 환경(렉시컬 환경)을 반환된 함수가 살아 있는 동안 유지합니다. 이것이 클로저입니다. 중요한 점은 클로저가 변수의 값을 복사해 두는 것이 아니라 변수 자체를 참조한다는 것입니다. 그래서 counter()를 부를 때마다 같은 count가 계속 증가합니다. makeCounter()를 다시 호출하면 새 실행 환경과 새 count가 만들어지므로 counter2는 1부터 시작합니다.
클로저는 편리하지만 메모리를 붙잡는다는 점을 기억해야 합니다. 반환된 함수가 살아 있는 한 그 함수가 참조하는 외부 변수도 가비지 컬렉션되지 않습니다. 큰 배열이나 DOM 요소를 참조하는 클로저를 이벤트 리스너나 전역 캐시에 등록해 두고 해제하지 않으면, 화면을 떠난 뒤에도 메모리가 계속 남는 누수가 됩니다. 싱글 페이지 앱에서 페이지를 오갈수록 메모리가 늘어나는 문제의 흔한 원인이 이것이라, 컴포넌트가 사라질 때 removeEventListener로 등록한 클로저를 함께 정리해야 합니다.
클로저 활용: 프라이빗 변수
function createBankAccount(initialBalance) {
let balance = initialBalance; // private 변수
return {
deposit(amount) {
if (amount > 0) {
balance += amount;
return balance;
}
},
withdraw(amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return balance;
}
return null;
},
getBalance() {
return balance;
}
};
}
const account = createBankAccount(10000);
console.log(account.getBalance()); // 10000
account.deposit(5000);
console.log(account.getBalance()); // 15000
account.withdraw(3000);
console.log(account.getBalance()); // 12000
// balance에 직접 접근 불가
// console.log(account.balance); // undefined
반환된 객체의 세 메서드는 같은 balance 변수를 공유하는 클로저입니다. 외부에서는 이 메서드들을 통해서만 잔액을 바꿀 수 있으므로, “음수 입금 거부”, “잔액 초과 출금 거부” 같은 규칙을 한 곳에서 강제할 수 있습니다. account.balance = 999999를 대입해도 새 속성이 생길 뿐 실제 balance 변수에는 영향이 없습니다.
이 패턴은 ES2022의 클래스 private 필드(#balance)가 나오기 전까지 JavaScript에서 진짜 private 상태를 만드는 거의 유일한 방법이었습니다. 지금은 클래스 문법으로도 같은 일을 할 수 있는데, 차이도 있습니다. 클로저 방식은 객체마다 메서드 함수가 새로 만들어지므로 객체를 수만 개 만들면 메모리를 더 쓰고, 클래스는 메서드를 프로토타입에서 공유합니다. 소수의 모듈이나 서비스 객체라면 클로저, 대량으로 만드는 데이터 객체라면 클래스가 적합합니다. deposit이 잘못된 금액에서 undefined를, withdraw는 null을 반환해 실패를 표현하는 방식이 서로 다르다는 점도, 실제 코드라면 예외를 던지거나 한 가지 방식으로 통일하는 편이 좋습니다.
클로저 실전 예제
// 1. 함수 팩토리
function makeAdder(x) {
return function(y) {
return x + y;
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(3)); // 8
console.log(add10(3)); // 13
// 2. 이벤트 핸들러
function setupButtons() {
const buttons = ["버튼1", "버튼2", "버튼3"];
buttons.forEach((text, i) => {
// 클로저로 i 값 기억
document.getElementById(`btn${i}`).addEventListener('click', () => {
console.log(`${text} 클릭됨 (인덱스: ${i})`);
});
});
}
// 3. 메모이제이션
function memoize(fn) {
const cache = {};
return function(...args) {
const key = JSON.stringify(args);
if (key in cache) {
console.log("캐시에서 반환");
return cache[key];
}
console.log("계산 중...");
const result = fn(...args);
cache[key] = result;
return result;
};
}
const slowFib = n => {
if (n <= 1) return n;
return slowFib(n - 1) + slowFib(n - 2);
};
const fastFib = memoize(slowFib);
console.log(fastFib(10)); // 계산 중... 55
console.log(fastFib(10)); // 캐시에서 반환 55
이벤트 핸들러 예제에서 forEach 콜백의 i는 반복마다 새로 만들어지는 매개변수라, 각 핸들러가 자기 인덱스를 정확히 기억합니다. 뒤의 “실수 2”에서 볼 var 루프 문제가 forEach에서는 생기지 않는 이유입니다.
메모이제이션 예제에는 처음 보면 놓치기 쉬운 함정이 있습니다. fastFib(10)의 첫 호출은 캐시를 확인한 뒤 slowFib(10)을 부르는데, slowFib 안의 재귀 호출은 slowFib(n - 1)이지 fastFib(n - 1)이 아닙니다. 그래서 캐시에 저장되는 것은 맨 바깥의 fib(10) 결과 하나뿐이고, 내부 재귀는 여전히 지수 시간으로 돕니다. 같은 인자로 다시 부를 때만 빨라질 뿐 fastFib(40)의 첫 호출은 전혀 빨라지지 않습니다. 재귀 함수 자체를 메모이제이션하려면 재귀 호출이 메모이즈된 함수를 부르도록 만들어야 합니다.
const fib = memoize(n => (n <= 1 ? n : fib(n - 1) + fib(n - 2)));
console.log(fib(40)); // 각 n을 한 번씩만 계산
(이 버전은 호출마다 “계산 중…” 로그가 찍히므로 실제로는 로그를 빼고 씁니다.) JSON.stringify(args)를 키로 쓰는 방식도 한계가 있습니다. 객체 인자의 속성 순서가 다르면 다른 키가 되고, 함수나 undefined는 직렬화되지 않으며, 캐시가 계속 커져 메모리를 해제하지 않습니다. 인자가 원시 값 하나라면 Map에 그 값을 그대로 키로 쓰는 편이 빠르고 정확합니다.
this 바인딩과 bind·call·apply
일반 함수의 this
const person = {
name: "홍길동",
greet: function() {
console.log(`안녕하세요, ${this.name}입니다.`);
}
};
person.greet(); // 안녕하세요, 홍길동입니다.
// this는 호출 방법에 따라 달라짐
const greetFunc = person.greet;
// greetFunc();
// 엄격 모드(ES 모듈 등): TypeError: Cannot read properties of undefined (reading 'name')
// 비엄격 스크립트(브라우저): this가 window가 되어 "안녕하세요, 입니다." 출력
일반 함수의 this는 정의가 아니라 호출 방식으로 결정됩니다. person.greet()처럼 점 앞에 객체가 있으면 그 객체가 this이고, greetFunc()처럼 그냥 호출하면 this가 없습니다. 이때 엄격 모드에서는 undefined, 비엄격 모드에서는 전역 객체가 됩니다. 브라우저의 window.name은 기본값이 빈 문자열이라 비엄격 스크립트에서는 에러 없이 이름이 빈 채로 출력되는데, 에러가 나는 쪽보다 오히려 찾기 어렵습니다.
이 현상은 메서드를 콜백으로 넘길 때 가장 자주 발생합니다. setTimeout(person.greet, 1000)이나 button.addEventListener('click', obj.handle)은 메서드를 떼어 넘기는 것이라, 나중에 호출될 때 this가 원래 객체가 아닙니다. React 클래스 컴포넌트에서 생성자에 this.handleClick = this.handleClick.bind(this)를 써야 했던 이유가 이것입니다. 해결책은 setTimeout(() => person.greet(), 1000)처럼 화살표 함수로 감싸 점 호출을 유지하거나, 아래의 bind로 this를 고정하는 것입니다.
화살표 함수의 this
const person = {
name: "홍길동",
hobbies: ["독서", "운동", "코딩"],
// 일반 함수
printHobbies1: function() {
this.hobbies.forEach(function(hobby) {
// console.log(`${this.name}의 취미: ${hobby}`); // 이 콜백의 this는 person이 아님 (엄격 모드: TypeError, 비엄격: 전역 객체)
});
},
// 화살표 함수 (this가 상위 스코프 유지)
printHobbies2: function() {
this.hobbies.forEach(hobby => {
console.log(`${this.name}의 취미: ${hobby}`); // OK
});
}
};
person.printHobbies2();
// 홍길동의 취미: 독서
// 홍길동의 취미: 운동
// 홍길동의 취미: 코딩
printHobbies1의 문제는 forEach에 넘긴 function(hobby)가 자기만의 this를 가진 일반 함수라는 점입니다. forEach는 이 콜백을 person.콜백() 형태가 아니라 그냥 호출하므로, 콜백 안의 this는 person이 아닙니다. 화살표 함수 이전에는 const self = this;로 바깥 this를 변수에 담아 두거나, forEach의 두 번째 인자로 this를 넘기거나(forEach(fn, this)), .bind(this)를 붙이는 방법을 썼습니다. printHobbies2의 화살표 콜백은 자기 this가 없어서 바깥 함수 printHobbies2의 this, 즉 person을 그대로 씁니다.
정리하면 메서드 자체는 일반 함수로, 메서드 안의 콜백은 화살표 함수로 쓰는 것이 기본 규칙입니다. 바깥 메서드가 일반 함수여야 person.printHobbies2() 호출 시 this가 person이 되고, 그 this를 안쪽 화살표 함수가 물려받기 때문입니다.
bind, call, apply
const person = {
name: "홍길동"
};
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
// call: 즉시 호출
greet.call(person, "안녕하세요", "!");
// 안녕하세요, 홍길동!
// apply: 배열로 인자 전달
greet.apply(person, ["Hello", "."]);
// Hello, 홍길동.
// bind: 새 함수 반환 (this 고정)
const boundGreet = greet.bind(person);
boundGreet("Hi", "~");
// Hi, 홍길동~
세 메서드 모두 this를 직접 지정하지만 쓰임새가 다릅니다. call과 apply는 지금 바로 호출하고, 차이는 인자를 나열하느냐 배열로 주느냐뿐입니다. 스프레드 문법이 생긴 뒤로는 greet.call(person, ...args)로 apply를 대신할 수 있어 apply는 덜 쓰입니다. bind는 나중에 호출할 새 함수를 만듭니다. 이벤트 핸들러나 setTimeout처럼 호출 시점을 내가 통제할 수 없는 곳에 메서드를 넘길 때 필요합니다. bind에 인자를 함께 주면 앞쪽 인자가 고정된 함수가 만들어지므로(greet.bind(person, "안녕")), 부분 적용(partial application) 용도로도 쓸 수 있습니다.
주의할 점이 두 가지 있습니다. bind로 만든 함수는 다시 bind하거나 call해도 this가 바뀌지 않습니다. 처음 고정한 this가 끝까지 유지됩니다. 그리고 화살표 함수에는 call, apply, bind로 this를 지정해도 무시됩니다. 화살표 함수는 애초에 자기 this가 없기 때문입니다. bind는 호출할 때마다 새 함수를 만든다는 점도 기억할 만한데, addEventListener('click', this.fn.bind(this))로 등록하면 나중에 removeEventListener에 같은 함수를 넘길 수 없어 리스너가 제거되지 않습니다. 바인딩한 함수를 변수에 저장해 두고 등록과 해제에 같은 참조를 써야 합니다.
콜백 함수
콜백이란?
콜백(Callback)은 다른 함수에 인자로 전달되는 함수입니다.
// 기본 예제
function processArray(arr, callback) {
const result = [];
for (let item of arr) {
result.push(callback(item));
}
return result;
}
const numbers = [1, 2, 3, 4, 5];
// 제곱
const squared = processArray(numbers, x => x * x);
console.log(squared); // [1, 4, 9, 16, 25]
// 2배
const doubled = processArray(numbers, x => x * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
비동기 콜백
// setTimeout: 일정 시간 후 실행
console.log("시작");
setTimeout(() => {
console.log("2초 후 실행");
}, 2000);
console.log("끝");
// 출력 순서:
// 시작
// 끝
// 2초 후 실행
// 콜백 지옥 (Callback Hell)
setTimeout(() => {
console.log("1초");
setTimeout(() => {
console.log("2초");
setTimeout(() => {
console.log("3초");
}, 1000);
}, 1000);
}, 1000);
// Promise로 개선 (다음 글에서)
“시작 → 끝 → 2초 후 실행” 순서가 나오는 이유는 setTimeout이 콜백을 나중에 실행하도록 예약만 하고 즉시 반환하기 때문입니다. JavaScript는 한 번에 한 가지 코드만 실행하는 단일 스레드 모델이라, 현재 실행 중인 코드가 모두 끝나야 이벤트 루프가 예약된 콜백을 꺼내 실행합니다. 그래서 setTimeout(fn, 0)도 “즉시”가 아니라 “현재 코드가 끝난 직후”이고, 지연 시간은 최소 대기 시간이지 정확한 실행 시각이 아닙니다. 앞의 코드가 무거운 계산으로 3초간 막고 있으면 2초짜리 타이머도 3초 뒤에 실행됩니다.
중첩된 콜백의 진짜 문제는 들여쓰기보다 에러 처리입니다. 각 단계에서 실패할 수 있는 비동기 작업(파일 읽기, 네트워크 요청)을 콜백으로 이으면, 단계마다 에러 인자를 확인하는 코드가 반복되고 하나라도 빠뜨리면 에러가 조용히 사라집니다. 콜백 안에서 던진 예외는 바깥의 try...catch로 잡히지 않는다는 점도 콜백 방식의 한계입니다. Promise와 async/await는 이 문제를 해결하려고 도입되었고, JavaScript 비동기 프로그래밍에서 이어서 다룹니다.
즉시 실행 함수(IIFE)와 모듈 패턴
IIFE란?
IIFE (Immediately Invoked Function Expression)는 정의와 동시에 실행되는 함수입니다.
// 기본 형태
(function() {
console.log("즉시 실행!");
})();
// 화살표 함수
(() => {
console.log("화살표 IIFE");
})();
// 매개변수 전달
(function(name) {
console.log(`Hello, ${name}!`);
})("홍길동");
// 반환값 받기
const result = (function() {
return 10 + 20;
})();
console.log(result); // 30
IIFE 활용: 모듈 패턴
const counterModule = (function() {
let count = 0; // private 변수
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getCount() {
return count;
}
};
})();
console.log(counterModule.increment()); // 1
console.log(counterModule.increment()); // 2
console.log(counterModule.getCount()); // 2
// console.log(counterModule.count); // undefined (접근 불가)
IIFE를 괄호로 감싸는 이유는 문법 때문입니다. 문장 맨 앞의 function은 함수 선언문으로 해석되어 function() {}()처럼 바로 호출할 수 없고, 이름 없는 선언문이라 SyntaxError가 납니다. 괄호로 감싸면 표현식으로 해석되어 즉시 호출할 수 있습니다. 세미콜론을 생략하는 코드 스타일에서는 앞 줄이 const a = b 로 끝나고 다음 줄이 (function(){...})()로 시작하면 b(function(){...})()로 이어 해석되는 사고가 나므로, IIFE 앞에 ;를 붙이는 관례가 있었습니다.
모듈 패턴은 ES 모듈이 없던 시절 전역 변수 오염을 막고 private 상태를 만드는 표준 방법이었고, jQuery 같은 오래된 라이브러리 코드 대부분이 이 형태입니다. 지금은 export/import를 쓰는 ES 모듈 파일 자체가 스코프를 가지므로, 파일 최상위에 let count = 0;을 두고 필요한 함수만 export하면 같은 효과를 얻습니다. 새 코드에서 IIFE를 쓸 일은 줄었지만, 최상위에서 await를 쓸 수 없는 환경에서 (async () => { await main(); })();처럼 비동기 진입점을 만드는 용도로는 여전히 자주 쓰입니다.
재귀 함수
재귀란?
재귀(Recursion)는 함수가 자기 자신을 호출하는 것입니다.
// 팩토리얼
function factorial(n) {
if (n <= 1) return 1; // 기저 조건
return n * factorial(n - 1); // 재귀 호출
}
console.log(factorial(5)); // 120
// 피보나치
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(7)); // 13
// 배열 합계
function sumArray(arr) {
if (arr.length === 0) return 0;
return arr[0] + sumArray(arr.slice(1));
}
console.log(sumArray([1, 2, 3, 4, 5])); // 15
재귀 예제들은 개념을 보여 주기엔 좋지만 성능 면에서 주의할 점이 있습니다. fibonacci는 앞의 메모이제이션 절에서 본 것처럼 지수 시간이라 fibonacci(45) 정도만 되어도 수 초가 걸립니다. sumArray는 호출마다 arr.slice(1)로 배열을 복사하므로 전체가 O(n²)이고, 원소가 1만 개를 넘으면 호출 스택 한계에 걸려 RangeError: Maximum call stack size exceeded가 납니다. JavaScript 명세에는 꼬리 호출 최적화(TCO)가 있지만 실제로 구현한 엔진은 Safari의 JavaScriptCore 정도라서, Chrome과 Node.js에서는 꼬리 재귀로 바꿔도 스택이 줄지 않습니다. 깊이가 입력 크기에 비례하는 재귀는 반복문으로 바꾸는 것이 안전하고, 재귀는 트리처럼 깊이가 로그 수준이거나 구조 자체가 재귀적인 데이터에 쓰는 것이 적합합니다.
재귀 실전 예제
// 1. 중첩 객체 탐색
function findValue(obj, key) {
if (key in obj) {
return obj[key];
}
for (let k in obj) {
if (typeof obj[k] === 'object' && obj[k] !== null) {
const result = findValue(obj[k], key);
if (result !== undefined) {
return result;
}
}
}
return undefined;
}
const data = {
user: {
profile: {
name: "홍길동",
age: 25
}
}
};
console.log(findValue(data, "name")); // 홍길동
// 2. 디렉토리 트리 출력
function printTree(node, depth = 0) {
const indent = " ".repeat(depth);
console.log(`${indent}- ${node.name}`);
if (node.children) {
node.children.forEach(child => {
printTree(child, depth + 1);
});
}
}
const tree = {
name: "root",
children: [
{ name: "folder1", children: [
{ name: "file1.txt" },
{ name: "file2.txt" }
]},
{ name: "folder2", children: [
{ name: "file3.txt" }
]}
]
};
printTree(tree);
// - root
// - folder1
// - file1.txt
// - file2.txt
// - folder2
// - file3.txt
두 예제 모두 데이터 구조 자체가 재귀적이라 재귀가 자연스럽게 맞는 경우입니다. 객체 안에 객체가, 폴더 안에 폴더가 있는 구조는 깊이를 미리 알 수 없으므로 반복문으로 쓰려면 스택을 직접 관리해야 하지만, 재귀로는 “현재 노드를 처리하고 자식마다 같은 함수를 호출”이라는 한 문장으로 끝납니다.
findValue에는 실무에서 조심할 점이 두 가지 있습니다. 첫째, 값이 undefined인 키를 찾으면 “못 찾음”과 구분할 수 없습니다. 둘째, 객체가 자기 자신을 참조하는 순환 구조(a.self = a)라면 무한 재귀로 스택이 넘칩니다. DOM 노드나 일부 라이브러리 객체는 부모를 가리키는 참조를 가지고 있어 이런 순환이 흔하므로, 방문한 객체를 WeakSet에 기록하고 이미 방문한 객체는 건너뛰는 처리가 필요합니다. typeof obj[k] === 'object'는 배열에도 참이라 배열 안까지 탐색한다는 점도 의도에 맞는지 확인해야 합니다.
배열 유틸, 함수 조합, 디바운스·쓰로틀 예제
예제 1: 배열 유틸리티 함수
// 배열 청크 (n개씩 나누기)
function chunk(arr, size) {
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
console.log(chunk([1, 2, 3, 4, 5, 6, 7], 3));
// [[1, 2, 3], [4, 5, 6], [7]]
// 배열 평탄화
function flatten(arr) {
return arr.reduce((acc, item) => {
return acc.concat(Array.isArray(item) ? flatten(item) : item);
}, []);
}
console.log(flatten([1, [2, 3], [4, [5, 6]]]));
// [1, 2, 3, 4, 5, 6]
// 중복 제거
function unique(arr) {
return [...new Set(arr)];
}
console.log(unique([1, 2, 2, 3, 3, 3, 4]));
// [1, 2, 3, 4]
직접 구현해 보는 것은 원리를 익히는 데 좋지만, 실무에서는 표준 기능이 있는지 먼저 확인하는 것이 좋습니다. flatten은 ES2019의 arr.flat(Infinity)가 같은 일을 하고, concat을 반복하는 이 구현은 매번 새 배열을 만들어 큰 입력에서 느립니다. unique의 Set은 ===와 거의 같은 기준(SameValueZero)으로 비교하므로 객체 배열의 중복은 제거하지 못합니다. [{id: 1}, {id: 1}]은 내용이 같아도 서로 다른 객체라 둘 다 남습니다. 객체를 특정 속성으로 중복 제거하려면 new Map(arr.map(o => [o.id, o])).values()처럼 키를 명시해야 합니다. chunk는 size가 0이면 i += 0으로 무한 루프에 빠지므로 입력 검증이 필요합니다.
예제 2: 함수 조합 (Composition)
// 함수 조합
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const add1 = x => x + 1;
const double = x => x * 2;
const square = x => x * x;
const combined = compose(square, double, add1);
console.log(combined(3)); // ((3 + 1) * 2)^2 = 64
// 파이프 (왼쪽에서 오른쪽)
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
const piped = pipe(add1, double, square);
console.log(piped(3)); // ((3 + 1) * 2)^2 = 64
compose와 pipe는 적용 순서만 반대입니다. compose(square, double, add1)은 수학의 함수 합성 f(g(h(x)))처럼 오른쪽부터 적용하므로 reduceRight를 쓰고, pipe(add1, double, square)는 읽는 순서대로 왼쪽부터 적용하므로 reduce를 씁니다. 코드를 읽는 순서와 실행 순서가 같은 pipe가 대부분의 사람에게 더 읽기 쉬워서 실무에서는 pipe가 더 많이 쓰입니다.
이 방식은 각 함수가 인자 하나를 받아 값 하나를 반환할 때만 매끄럽게 동작합니다. 인자가 여러 개인 함수는 뒤의 커링으로 한 인자 함수로 바꾸거나 화살표 함수로 감싸야 하고, 중간 함수 하나가 undefined를 반환하면 다음 단계에서 에러가 나는데 스택 트레이스가 reduce 내부를 가리켜 어느 단계가 문제인지 알기 어렵습니다. 디버깅할 때는 const tap = fn => x => (fn(x), x);를 만들어 pipe(add1, tap(console.log), double)처럼 중간값을 찍어 보는 요령이 유용합니다.
예제 3: 디바운스와 쓰로틀
// 디바운스: 마지막 호출 후 일정 시간 대기
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
// 검색 입력 예제
const search = debounce((query) => {
console.log(`검색: ${query}`);
}, 500);
// search("a"); // 취소됨
// search("ab"); // 취소됨
// search("abc"); // 500ms 후 실행
// 쓰로틀: 일정 시간마다 한 번만 실행
function throttle(func, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
func(...args);
}
};
}
// 스크롤 이벤트 예제
const handleScroll = throttle(() => {
console.log("스크롤 중...");
}, 1000);
// window.addEventListener('scroll', handleScroll);
두 함수 모두 클로저의 대표적인 활용입니다. debounce가 반환한 함수는 timeoutId를, throttle이 반환한 함수는 lastCall을 호출 사이에 기억합니다. 구분 기준은 “언제 실행하고 싶은가”입니다. 검색창 자동완성처럼 사용자가 입력을 멈춘 뒤 한 번만 요청하고 싶으면 디바운스, 스크롤 위치에 따른 UI 갱신처럼 계속 일어나는 동안에도 주기적으로 반응해야 하면 쓰로틀입니다. 디바운스를 스크롤에 쓰면 스크롤이 멈출 때까지 아무 반응이 없고, 쓰로틀을 검색에 쓰면 타이핑 도중의 불완전한 검색어로 요청이 나갑니다.
이 구현들은 원리를 보여 주는 최소 버전이라 몇 가지가 빠져 있습니다. func(...args)로 호출하므로 원래 호출의 this가 전달되지 않습니다. 메서드에 디바운스를 적용하려면 function(...args) { ... func.apply(this, args) ... }처럼 this를 넘겨야 합니다. 이 throttle은 첫 호출만 즉시 실행하고 간격 안의 마지막 호출은 버리므로, 스크롤이 멈춘 최종 위치가 반영되지 않을 수 있습니다. 컴포넌트가 사라질 때 대기 중인 디바운스 타이머를 취소하는 cancel 메서드도 실무에서는 필요합니다. 이런 세부 사항 때문에 실제 프로젝트에서는 lodash의 debounce/throttle(leading/trailing 옵션 포함)을 쓰는 경우가 많습니다.
화살표 함수의 this와 반복문 클로저 함정
실수 1: 화살표 함수의 this
// ❌ 잘못된 방법
const person = {
name: "홍길동",
greet: () => {
console.log(`안녕하세요, ${this.name}입니다.`);
}
};
person.greet(); // 안녕하세요, undefined입니다.
// ✅ 올바른 방법
const person = {
name: "홍길동",
greet: function() {
console.log(`안녕하세요, ${this.name}입니다.`);
}
};
person.greet(); // 안녕하세요, 홍길동입니다.
화살표 메서드의 this는 객체 리터럴이 아니라 객체 리터럴이 놓인 바깥 스코프의 this입니다. 객체 리터럴의 중괄호는 함수처럼 새 this를 만들지 않기 때문입니다. 그래서 결과는 실행 환경에 따라 달라집니다. ES 모듈 최상위에서는 this가 undefined라 TypeError가 나고, Node.js CommonJS 파일에서는 module.exports(빈 객체)라 undefined가 출력되며, 브라우저 일반 스크립트에서는 window라서 window.name(보통 빈 문자열)이 출력됩니다. 주석의 “undefined입니다”는 이 중 한 경우일 뿐이고, 어느 쪽이든 의도한 동작은 아닙니다. 두 const person을 한 파일에 두면 재선언 에러가 나므로 따로 실행해야 합니다.
메서드 정의에는 greet: function() {}보다 짧은 메서드 축약 문법 greet() {}을 쓰는 것이 요즘 관례입니다. 동작은 일반 함수와 같아 this가 올바르게 바인딩되고, 추가로 super를 쓸 수 있습니다.
실수 2: 반복문에서 클로저
// ❌ 잘못된 방법
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// 3 3 3
// ✅ 올바른 방법 1: let 사용
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}
// 0 1 2
// ✅ 올바른 방법 2: IIFE
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j);
}, 100);
})(i);
}
// 0 1 2
var 버전에서 세 콜백은 같은 i 변수 하나를 참조하고, 콜백이 실행되는 100ms 뒤에는 루프가 이미 끝나 i가 3입니다. let은 반복마다 새 i를 만들어 각 콜백이 자기 값을 붙잡게 하고, IIFE는 매 반복의 i 값을 매개변수 j로 복사해 새 스코프에 가둡니다. 두 해결책은 원리가 같고, let이 생긴 뒤로는 IIFE를 쓸 이유가 없습니다. 이 주제는 var, let, const 비교에서 더 자세히 다룹니다.
실수 3: 객체 메서드에서 화살표 함수
// ❌ 잘못된 방법
const counter = {
count: 0,
increment: () => {
this.count++; // this는 counter가 아니라 바깥 스코프의 this
}
};
counter.increment(); // 브라우저 스크립트: window.count가 NaN이 됨 / ES 모듈: TypeError
console.log(counter.count); // 0 (변경 안 됨)
// ✅ 올바른 방법
const counter = {
count: 0,
increment: function() {
this.count++;
}
};
counter.increment();
console.log(counter.count); // 1
이 실수가 까다로운 것은 비엄격 스크립트에서 에러가 나지 않는다는 점입니다. this가 window라서 window.count++가 실행되고, window.count는 원래 undefined이므로 NaN이 되어 전역에 조용히 남습니다. counter.count는 계속 0이라 “증가 함수를 불렀는데 값이 안 바뀐다”는 증상으로만 나타납니다. 코드베이스를 ES 모듈로 옮기면 같은 코드가 갑자기 TypeError를 내기 시작하는 것도 이 때문입니다. ESLint는 객체 메서드의 화살표 함수 안에서 this를 쓰는 패턴을 모두 잡아 주지는 못하므로, “객체의 메서드는 화살표 함수로 만들지 않는다”를 팀 규칙으로 두는 편이 확실합니다.
연습 문제: 커링, 파이프라인, 배열 평탄화
문제 1: 커링 (Currying)
여러 인자를 받는 함수를 단일 인자를 받는 함수들의 체인으로 변환하세요.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...nextArgs) => curried(...args, ...nextArgs);
};
}
// 테스트
function add(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6
console.log(curriedAdd(1, 2, 3)); // 6
이 curry는 모인 인자 개수를 원래 함수의 fn.length(선언된 매개변수 개수)와 비교해, 충분하면 실행하고 부족하면 지금까지의 인자를 클로저에 담은 새 함수를 반환합니다. 인자가 쌓이는 곳이 바로 클로저의 args입니다.
fn.length에 의존한다는 점이 이 구현의 한계입니다. 기본값이 있는 매개변수부터는 length에 세지 않고, 나머지 매개변수도 세지 않습니다. function add(a, b = 0, c)의 length는 1이라서 curry(add)(1)이 곧바로 add(1)을 실행해 NaN을 반환하고, function sum(...nums)의 length는 0이라 인자 없이도 즉시 실행됩니다. 이런 함수를 커링하려면 curry(fn, arity)처럼 개수를 직접 받는 버전이 필요합니다.
문제 2: 함수 파이프라인
여러 함수를 순차적으로 실행하는 파이프라인을 구현하세요.
function pipe(...fns) {
return function(x) {
return fns.reduce((acc, fn) => fn(acc), x);
};
}
// 테스트
const add1 = x => x + 1;
const double = x => x * 2;
const square = x => x * x;
const pipeline = pipe(add1, double, square);
console.log(pipeline(3)); // ((3 + 1) * 2)^2 = 64
문제 3: 재귀로 배열 평탄화
function flattenDeep(arr) {
return arr.reduce((acc, item) => {
return acc.concat(
Array.isArray(item) ? flattenDeep(item) : item
);
}, []);
}
// 테스트
const nested = [1, [2, [3, [4, 5]]], 6];
console.log(flattenDeep(nested)); // [1, 2, 3, 4, 5, 6]
함수 문법과 패턴 요약
- 함수 정의:
- 선언문: 호이스팅됨
- 표현식: 호이스팅 안 됨
- 화살표 함수: 간결, this 렉시컬
- 매개변수:
- 기본 매개변수:
function(x = 10) - 나머지 매개변수:
function(...args) - 구조 분해:
function({ name, age })
- 기본 매개변수:
- 고차 함수:
- 함수를 인자로 받기
- 함수를 반환하기
map,filter,reduce
- 클로저:
- 외부 변수 기억
- 프라이빗 변수 구현
- 메모이제이션
- this 바인딩:
- 일반 함수: 동적
- 화살표 함수: 렉시컬
bind,call,apply
함수 작성 습관
- ✅ 화살표 함수 우선 (this 불필요시)
- ✅ 순수 함수 작성 (부작용 최소화)
- ✅ 함수는 한 가지 일만
- ✅ 의미 있는 함수명
- ✅ 매개변수 3개 이하 권장
다음 단계
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 객체 메서드를 화살표 함수로 정의하면 this가 왜 이상하게 동작하나요?
A. 화살표 함수는 자기만의 this를 만들지 않고, 선언된 위치의 바깥 스코프 this를 그대로 가져옵니다. 객체 리터럴 안에서 greet: () => this.name처럼 쓰면 this는 그 객체가 아니라 바깥(모듈 최상위라면 undefined, 일반 스크립트라면 전역 객체)을 가리킵니다. 객체의 속성에 접근하는 메서드는 greet() { ... } 같은 일반 메서드 문법으로 쓰고, 화살표 함수는 콜백 안에서 바깥 this를 유지하고 싶을 때 쓰는 것이 맞습니다.