JavaScript · 6장 함수 심화
래퍼 함수: 기록 · 재시도 · 캐시를 덧입히기, call과 apply
이미 잘 동작하는 함수에 "호출 기록 남기기", "실패하면 다시 해 보기", "결과 기억하기"를 더하고 싶을 때가 있어요. 함수 안을 고치면 여러 곳에 같은 코드가 퍼져요. 대신 함수를 감싸는 함수를 만들면, 원래 함수는 그대로 두고 기능만 덧입힐 수 있어요.
01. 함수를 받아 함수를 돌려주는 함수
래퍼 함수는 원래 함수를 받아서, 그 함수를 부르는 새 함수를 돌려줘요. 새 함수는 원래 함수를 부르기 전후에 할 일을 더해요. 앞 글의 memoize와 once(클로저)가 이미 래퍼였어요. 받은 함수를 상자에 기억해 두는 클로저 덕분에 가능해요.
function withLogging(fn) {
return function (...args) {
console.log(`→ ${fn.name}(${args.join(', ')})`); // 부르기 전
const result = fn.apply(this, args); // 원래 함수 (apply는 아래에서)
console.log(`← ${result}`); // 부른 뒤
return result;
};
}
function add(a, b) { return a + b; }
const loggedAdd = withLogging(add);
console.log(loggedAdd(2, 3)); // 기록 두 줄, 그리고 5
console.log(add(2, 3)); // 5 — 원래 함수는 그대로예요
02. 실패하면 다시 해 보는 재시도 래퍼
네트워크처럼 가끔 실패하는 일은 몇 번 다시 해 보면 성공하기도 해요. 재시도 규칙을 래퍼로 만들어 두면, 어느 함수든 감싸기만 하면 같은 규칙이 붙어요. 아래 flaky는 처음 두 번은 실패하고 세 번째에 성공하는 함수예요. 실제 요청을 다시 보내는 비동기 버전은 11장에서 이어서 다뤄요.
function withRetry(fn, times) {
return function (...args) {
let lastError;
for (let i = 1; i <= times; i++) {
try {
return fn.apply(this, args); // 성공하면 바로 돌려줘요
} catch (error) {
lastError = error;
console.log(`재시도 ${i}/${times}: ${error.message}`);
}
}
throw lastError; // 끝까지 실패하면 마지막 오류를 던져요
};
}
let attempts = 0;
function flaky() {
attempts++;
if (attempts < 3) throw new Error(`${attempts}번째 실패`);
return '데이터';
}
console.log(withRetry(flaky, 5)()); // 두 번 실패 기록 뒤에 데이터
03. 메서드를 감싸면 this를 잃어요: call과 apply
객체의 메서드를 래퍼로 감쌀 때 조심할 게 있어요. 래퍼 안에서 원래 함수를 fn(...args)처럼 그냥 부르면, 점 앞에 객체가 없으니 this가 사라져요(this를 잃는 순간).
const cart = {
items: [500, 2000],
total() { return this.items.reduce((a, b) => a + b, 0); },
};
function withLoggingNaive(fn) {
return (...args) => {
console.log('호출해요');
return fn(...args); // this 없이 불러요
};
}
cart.loggedTotal = withLoggingNaive(cart.total);
console.log(cart.loggedTotal()); // this가 cart가 아니라서 this.items가 없어요 → 오류
그래서 래퍼는 자기가 받은 this를 원래 함수에 그대로 넘겨야 해요. 이때 쓰는 게 call과 apply예요. 둘 다 "이 객체를 this로 삼아 함수를 불러 줘"라는 뜻이고, 인자를 넘기는 모양만 달라요. 래퍼를 화살표 함수가 아닌 function으로 만든 것도 그래서예요. 화살표 함수는 자기 this를 받지 못하거든요.
function introduce(greeting, mark) {
return `${greeting}, 저는 ${this.name}이에요${mark}`;
}
const sky = { name: '하늘' };
console.log(introduce.call(sky, '안녕', '!')); // 인자를 하나씩
console.log(introduce.apply(sky, ['반가워요', '.'])); // 인자를 배열로
const cart = {
items: [500, 2000],
total() { return this.items.reduce((a, b) => a + b, 0); },
};
function withLogging(fn) {
return function (...args) { // function이라 부른 쪽의 this(cart)를 받아요
console.log('호출해요');
return fn.apply(this, args); // 받은 this와 인자를 그대로 넘겨요
};
}
cart.loggedTotal = withLogging(cart.total);
console.log(cart.loggedTotal()); // 2500
요즘은 전개 구문이 있어서 fn.call(this, ...args)로 써도 똑같아요. apply는 배열을 그대로 넘기는 오래된 코드에서 자주 보여요.
함수가 몇 번 불렸는지 세는 withCount(fn)을 만들어 보세요. 감싼 함수는 원래 결과를 그대로 돌려주고, .count 속성으로 지금까지 불린 횟수를 알려줘야 해요. 메서드를 감싸도 this가 유지돼야 해요. 2500 2500 2가 나와야 해요.
function withCount(fn) {
// 여기를 채워요
return fn;
}
const cart = {
items: [500, 2000],
total() { return this.items.reduce((a, b) => a + b, 0); },
};
cart.total = withCount(cart.total);
console.log(cart.total(), cart.total(), cart.total.count);
해답 보기
함수도 객체라서 속성을 붙일 수 있어요(함수도 객체). 래퍼 함수 자신에 count를 두고, 부를 때마다 하나씩 늘린 뒤 받은 this와 인자를 그대로 넘겨요.
function withCount(fn) {
function wrapper(...args) {
wrapper.count++;
return fn.apply(this, args);
}
wrapper.count = 0;
return wrapper;
}
// 2500 2500 2
04. 실전 포인트
- 언제 쓰나요? 여러 함수에 같은 부가 기능(기록 · 시간 재기 · 재시도 · 캐시 · 권한 확인 · 한 번만 실행)을 붙일 때 써요. 디바운스와 스로틀(6장 끝 글)도 래퍼예요.
- 대신 쓰는 것: 래퍼를 여러 겹 쌓으면 오류가 난 자리를 찾기 어려워져요. 한두 곳에서만 필요하면 함수 안에 직접 쓰는 편이 읽기 쉬워요. TypeScript와 일부 프레임워크에서는 데코레이터 문법(
@log같은 것)으로 같은 일을 하기도 해요. - 흔한 실수: 래퍼에서 원래 함수를
fn(...args)로 불러this를 잃는 것, 결과를return하지 않아 감싼 함수가 늘undefined를 돌려주는 것, 래퍼를 화살표 함수로 만들어this를 받지 못하는 것이에요.
정리
- 래퍼 함수는 함수를 받아, 앞뒤에 할 일을 더한 새 함수를 돌려줘요. 원래 함수는 그대로예요.
- 기록 · 재시도 · 캐시 · 한 번만 실행이 대표적인 래퍼예요.
- 메서드를 감쌀 때는 받은
this를fn.apply(this, args)나fn.call(this, ...args)로 넘겨요. call은 인자를 하나씩,apply는 배열로 받아요.
래퍼 안에서 fn(...args)로 부르면 메서드를 감쌀 때 무엇이 문제일까요?
점 앞에 객체 없이 불러서 원래 메서드의 this가 undefined가 돼요. fn.apply(this, args)처럼 래퍼가 받은 this를 넘겨야 해요.
f.call(obj, 1, 2)와 같은 일을 apply로 쓰면?
f.apply(obj, [1, 2])예요. 둘 다 obj를 this로 삼아 부르고, 인자를 하나씩 넘기느냐 배열로 넘기느냐만 달라요.
래퍼 함수를 화살표 함수가 아니라 function으로 만드는 이유는?
화살표 함수는 자기 this를 받지 않고 바깥의 this를 써요. 래퍼는 메서드로 불릴 때의 this(객체)를 받아서 원래 함수에 넘겨야 하니 function이 필요해요.