JavaScript 함수는 객체입니다, 그리고 그게 전부를 바꿉니다

현업 개발자에게 "JavaScript에서 함수가 뭡니까?"라고 물으면 대부분 "코드를 묶어서 재사용하는 단위"라고 답합니다. 틀린 말은 아닙니다. 그런데 조금만 더 깊이 살펴보면 상황이 완전히 달라집니다. JavaScript에서 함수는 단순한 코드 묶음이 아니라 객체입니다. 프로퍼티를 가질 수 있고, 변수에 담을 수 있고, 다른 함수에 인자로 넘길 수 있고, 함수의 반환값이 될 수도 있습니다. 프로그래밍 언어 이론에서는 이런 특성을 가진 값을 "일급 시민"이라고 부릅니다.
27년간 개발을 해오면서 수많은 언어를 다뤘지만, 함수를 이 정도로 자유롭게 다룰 수 있는 언어는 많지 않습니다. C에서는 함수 포인터라는 우회로가 있고, Java에서는 한참 뒤에야 람다가 들어왔습니다. JavaScript는 처음부터 함수를 일급 시민으로 설계했고, 이 결정 하나가 클로저, 고차함수, 콜백, 프로미스, 그리고 현대 프론트엔드 프레임워크의 핵심 패턴을 가능하게 했습니다.
이 글에서는 세 가지를 다룹니다. 첫째, 함수가 객체라는 사실이 구체적으로 무엇을 의미하는지. 둘째, 클로저가 어떻게 작동하고 왜 중요한지. 셋째, 고차함수가 실무 코드를 어떻게 바꾸는지. 초보 개발자는 "아, 그래서 이렇게 되는 거구나"를, 중급 개발자는 "이 부분은 몰랐는데"를 느낄 수 있도록 깊이 있게 풀어보겠습니다.
함수는 정말 객체인가
typeof의 거짓말
JavaScript에서 typeof를 써보면 함수는 'function'이라고 나옵니다.
function greet() { return 'hello'; } console.log(typeof greet); // 'function'
이걸 보고 "함수는 함수 타입이구나"라고 생각하기 쉽습니다. 하지만 ECMAScript 명세를 보면 상황이 다릅니다. 함수는 [[Call]]이라는 내부 슬롯을 가진 객체입니다. typeof가 'function'을 반환하는 건 편의상 그렇게 정해둔 것이지, 함수가 객체와 다른 별도의 타입이라는 뜻이 아닙니다.
실제로 instanceof를 써보면 이 사실이 드러납니다.
function greet() { return 'hello'; } console.log(greet instanceof Object); // true console.log(greet instanceof Function); // true
Function은 Object의 하위 타입이고, 모든 함수는 일반 객체가 할 수 있는 걸 다 할 수 있습니다.
함수에 프로퍼티를 달아보면
객체라면 프로퍼티를 추가할 수 있어야 합니다. 실제로 가능합니다.
function counter() { counter.count++; return counter.count; } counter.count = 0; console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3 console.log(counter.count); // 3
함수 자체에 count라는 프로퍼티를 추가하고, 호출할 때마다 그 값을 증가시킵니다. 이게 가능한 이유는 함수가 객체이기 때문입니다.
함수에는 이미 여러 내장 프로퍼티도 있습니다.
function add(a, b) { return a + b; } console.log(add.name); // 'add' - 함수의 이름 console.log(add.length); // 2 - 매개변수의 개수 console.log(add.constructor); // [Function: Function]
name은 함수의 이름, length는 매개변수 개수입니다. 이런 프로퍼티가 붙어 있다는 것 자체가 함수가 객체라는 증거입니다.
Function.prototype이 제공하는 무기
모든 함수는 Function.prototype을 상속받는데, 여기에 call(), apply(), bind()라는 세 가지 메서드가 있습니다. 함수의 this 바인딩을 제어하는 메서드입니다.
const user = { name: '김개발', greet: function() { return '안녕하세요, ' + this.name + '입니다.'; } }; const admin = { name: '박관리' }; // call: this를 admin으로 바꿔서 호출 console.log(user.greet.call(admin)); // '안녕하세요, 박관리입니다.' // apply: call과 같지만 인자를 배열로 전달 function introduce(role, team) { return this.name + ', ' + role + ', ' + team; } console.log(introduce.apply(admin, ['관리자', '인프라팀'])); // '박관리, 관리자, 인프라팀' // bind: 새로운 함수를 생성 (this가 고정됨) const adminGreet = user.greet.bind(admin); console.log(adminGreet()); // '안녕하세요, 박관리입니다.'
함수가 단순한 코드 블록이었다면 이런 메서드가 있을 이유가 없습니다. 함수가 객체이기 때문에 메서드를 가질 수 있고, 그 메서드를 통해 함수의 동작을 바꿀 수 있습니다.
bind()는 특히 실무에서 많이 씁니다. React의 클래스 컴포넌트에서 이벤트 핸들러를 바인딩하던 시절, this.handleClick = this.handleClick.bind(this)라는 코드를 안 써본 프론트엔드 개발자는 없을 겁니다. 요즘은 화살표 함수 덕분에 덜 쓰지만, bind()가 하는 일을 이해하고 있으면 this 관련 버그를 잡을 때 큰 도움이 됩니다.
화살표 함수는 다른 종류의 객체입니다
화살표 함수도 일급 시민이고 객체이지만, 일반 함수와 중요한 차이가 있습니다.
// 일반 함수 const normalFn = function() {}; console.log(normalFn.prototype); // {} console.log(normalFn.constructor); // [Function: Function] // 화살표 함수 const arrowFn = () => {}; console.log(arrowFn.prototype); // undefined console.log(arrowFn.constructor); // [Function: Function]
화살표 함수는 prototype 프로퍼티가 없습니다. 따라서 new 키워드로 호출할 수 없고, 생성자로 사용할 수 없습니다. arguments 객체도 없습니다.
가장 큰 차이는 this 바인딩입니다. 일반 함수의 this는 호출 방식에 따라 결정되지만, 화살표 함수의 this는 정의 시점의 상위 스코프에서 상속됩니다. 앞서 call, apply, bind로 this를 바꾸는 걸 봤는데, 화살표 함수에는 이 메서드들이 this에 영향을 주지 않습니다.
const obj = { value: 42, normalMethod: function() { return this.value; }, arrowMethod: () => { return this.value; // 상위 스코프의 this (여기서는 전역) } }; console.log(obj.normalMethod()); // 42 console.log(obj.arrowMethod()); // undefined (전역의 this.value)
이 차이를 이해하고 있으면, 어디서 화살표 함수를 쓰고 어디서 일반 함수를 써야 하는지 판단할 수 있습니다. 메서드를 정의할 때는 일반 함수가, 콜백에서 상위 스코프의 this를 유지하고 싶을 때는 화살표 함수가 적절합니다.
일급 시민이 열어준 세계
함수가 객체라는 사실은 JavaScript에게 세 가지 능력을 줍니다. 변수에 담기, 인자로 전달하기, 반환값으로 쓰기. 이 세 가지가 "일급 시민"의 조건입니다. 하나씩 살펴보겠습니다.
변수에 담기
// 함수 선언식 function square(x) { return x * x; } // 함수 표현식: 함수를 변수에 담는다 const sq = function(x) { return x * x; }; // 화살표 함수도 마찬가지 const cube = (x) => x * x * x; console.log(sq(5)); // 25 console.log(cube(3)); // 27
여기서 중요한 건 sq와 cube가 함수 이름이 아니라 변수 이름이라는 점입니다. 이 변수에는 함수 객체가 담겨 있고, ()를 붙이면 그 함수를 실행합니다. 변수이기 때문에 다른 변수에 다시 담을 수도 있습니다.
const myFunc = sq; console.log(myFunc(4)); // 16
이게 당연해 보일 수 있지만, C나 Java를 먼저 배운 개발자에게는 상당히 낯선 개념입니다. C에서 int result = add;라고 쓰면 컴파일 에러가 납니다. 함수 포인터를 써야 합니다. Java에서는 메서드를 변수에 담으려면 함수형 인터페이스와 메서드 참조라는 우회로를 거쳐야 합니다. JavaScript에서는 그냥 됩니다.
인자로 전달하기 (콜백)
함수를 다른 함수의 인자로 넘길 수 있습니다. 이렇게 넘겨진 함수를 콜백 함수라고 부릅니다.
function repeat(n, action) { for (let i = 0; i < n; i++) { action(i); } } repeat(3, function(i) { console.log('현재 인덱스:', i); }); // 현재 인덱스: 0 // 현재 인덱스: 1 // 현재 인덱스: 2
repeat 함수는 "무엇을 몇 번 반복할지"를 정합니다. "무엇을"에 해당하는 부분을 함수로 받기 때문에, 같은 repeat 함수로 완전히 다른 동작을 시킬 수 있습니다.
repeat(3, (i) => console.log('*'.repeat(i + 1))); // * // ** // ***
JavaScript에서 콜백은 어디에나 있습니다. setTimeout, addEventListener, Array.prototype.forEach, Promise.then, Express의 미들웨어 -- 전부 함수를 인자로 받습니다. 이 모든 패턴이 가능한 이유가 함수가 일급 시민이기 때문입니다.
반환값으로 쓰기 (함수 팩토리)
함수가 함수를 반환할 수 있습니다. 이걸 활용하면 "함수를 만드는 함수", 즉 함수 팩토리를 구현할 수 있습니다.
function createMultiplier(factor) { return function(number) { return number * factor; }; } const double = createMultiplier(2); const triple = createMultiplier(3); const toPercent = createMultiplier(100); console.log(double(5)); // 10 console.log(triple(5)); // 15 console.log(toPercent(0.7)); // 70
createMultiplier는 숫자를 받아서 "그 숫자를 곱하는 함수"를 반환합니다. double, triple, toPercent는 각각 독립적인 함수이지만, 같은 팩토리에서 나왔습니다.
이 패턴이 왜 중요한지는 조금 뒤에 클로저를 다루면서 더 깊이 설명하겠습니다. 여기서는 "함수가 함수를 반환할 수 있다"는 사실만 기억하면 됩니다.
클로저, 함수가 기억하는 것
클로저는 면접 단골 질문이고 실무에서도 매일 마주치는데, 정작 "클로저가 뭔가요?"라고 물으면 깔끔하게 설명하는 개발자가 많지 않습니다.
렉시컬 스코프부터 이해하기
클로저를 이해하려면 먼저 렉시컬 스코프를 알아야 합니다.
const globalVar = '전역'; function outer() { const outerVar = '외부'; function inner() { const innerVar = '내부'; console.log(innerVar); // '내부' - 자기 스코프 console.log(outerVar); // '외부' - 바깥 스코프 console.log(globalVar); // '전역' - 전역 스코프 } inner(); } outer();
inner 함수는 자기 스코프에 있는 innerVar에 접근할 수 있고, 바깥 함수인 outer의 outerVar에도 접근할 수 있고, 전역의 globalVar에도 접근할 수 있습니다. 이걸 스코프 체인이라고 부릅니다.
다음 그림은 클로저의 스코프 체인이 어떻게 동작하는지를 보여줍니다.

전역 스코프 안에 outer() 스코프가, 그 안에 inner() 스코프가 중첩되어 있습니다. inner 함수는 자기 스코프의 변수뿐 아니라 스코프 체인을 따라 outer와 전역의 변수에도 접근할 수 있습니다. 이 체인은 코드가 작성된 위치, 즉 렉시컬 스코프에 의해 결정됩니다.
클로저의 정확한 정의
MDN의 정의를 빌리면, 클로저는 "함수와 그 함수가 선언된 렉시컬 환경의 조합"입니다. 말이 어렵습니다. 코드로 보겠습니다.
function makeGreeter(greeting) { // greeting은 makeGreeter의 지역 변수 return function(name) { // 이 내부 함수는 greeting에 접근할 수 있다 return greeting + ', ' + name + '!'; }; } const helloGreeter = makeGreeter('안녕하세요'); const byeGreeter = makeGreeter('안녕히 가세요'); console.log(helloGreeter('김개발')); // '안녕하세요, 김개발!' console.log(byeGreeter('박디자인')); // '안녕히 가세요, 박디자인!'
makeGreeter가 실행되고 반환된 후에도, 반환된 내부 함수는 greeting 변수에 접근할 수 있습니다. 일반적인 지역 변수는 함수 실행이 끝나면 사라져야 합니다. 그런데 사라지지 않습니다. 내부 함수가 그 변수를 참조하고 있기 때문에 가비지 컬렉터가 수거하지 않는 것입니다.
이것이 클로저입니다. 함수가 자신이 생성된 환경을 기억하고, 그 환경의 변수에 계속 접근할 수 있는 것. 좀 더 직관적으로 말하면, 함수가 자기가 태어난 곳의 변수를 "품고 다니는" 겁니다.
클로저의 세 가지 특성
첫째, 각 클로저는 독립적인 환경을 갖습니다. 위 예제에서 helloGreeter와 byeGreeter는 각각 다른 greeting 값을 기억하고, 서로 영향을 주지 않습니다.
둘째, 클로저는 값이 아니라 변수 자체를 기억합니다. 이 차이가 중요합니다.
function createCounter() { let count = 0; return { increment: function() { count++; }, decrement: function() { count--; }, getCount: function() { return count; } }; } const counter = createCounter(); counter.increment(); counter.increment(); counter.increment(); counter.decrement(); console.log(counter.getCount()); // 2
increment, decrement, getCount는 모두 같은 count 변수를 공유합니다. 클로저가 count의 초기값인 0을 스냅샷으로 찍어둔 게 아니라, count 변수 자체에 대한 참조를 유지하고 있기 때문입니다. 그래서 한 함수에서 count를 바꾸면 다른 함수에서도 바뀐 값이 보입니다.
셋째, 외부에서는 클로저 내부 변수에 접근할 수 없습니다. counter.count를 시도해봐야 undefined입니다. count는 createCounter 함수의 지역 변수이고, 클로저를 통해서만 접근할 수 있습니다. 이게 바로 JavaScript에서 private 변수를 구현하는 전통적인 방법입니다.
console.log(counter.count); // undefined // count에 직접 접근하는 방법은 없다
실전 예제: 이벤트 핸들러와 상태 관리
클로저가 실무에서 가장 많이 쓰이는 곳은 이벤트 핸들러입니다.
function setupButton(buttonId, message) { const button = document.getElementById(buttonId); let clickCount = 0; button.addEventListener('click', function() { clickCount++; console.log(message + ' (클릭 횟수: ' + clickCount + ')'); }); } setupButton('btn1', '첫 번째 버튼'); setupButton('btn2', '두 번째 버튼');
setupButton이 실행되고 나면 message와 clickCount는 사라져야 할 지역 변수입니다. 하지만 이벤트 리스너로 등록된 콜백 함수가 이 변수들을 참조하고 있기 때문에, 버튼을 클릭할 때마다 정상적으로 동작합니다. 그리고 각 버튼은 독립적인 clickCount를 가집니다.
React의 useState 훅도 내부적으로 클로저를 활용합니다. 간소화된 구현을 살펴보겠습니다.
// useState의 원리를 보여주는 간소화된 구현 function createUseState() { let states = []; let currentIndex = 0; function useState(initialValue) { const index = currentIndex; if (states[index] === undefined) { states[index] = initialValue; } function setState(newValue) { states[index] = newValue; // 실제로는 여기서 리렌더링을 트리거 console.log('상태 변경:', states); } currentIndex++; return [states[index], setState]; } function resetIndex() { currentIndex = 0; } return { useState, resetIndex }; } const { useState, resetIndex } = createUseState(); // 컴포넌트가 렌더링될 때 resetIndex(); const [name, setName] = useState('김개발'); const [age, setAge] = useState(30); setName('박개발'); // 상태 변경: ['박개발', 30] setAge(31); // 상태 변경: ['박개발', 31]
setState 함수가 index를 기억하는 것이 클로저입니다. 각 useState 호출마다 고유한 index가 캡처되기 때문에, setName은 항상 인덱스 0을, setAge는 항상 인덱스 1을 가리킵니다.
루프 안의 클로저: 가장 유명한 함정
JavaScript 면접에서 가장 자주 나오는 클로저 문제입니다. 직접 테스트해본 결과를 보여드리겠습니다.
// 문제 코드 for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 1000); } // 기대: 0, 1, 2, 3, 4 // 실제: 5, 5, 5, 5, 5
왜 5가 다섯 번 출력될까요. var는 함수 스코프이기 때문입니다. for 루프 전체에서 i는 단 하나의 변수입니다. setTimeout의 콜백 함수 다섯 개가 모두 같은 i를 참조합니다. 콜백이 실행되는 시점(1초 후)에는 루프가 이미 끝나서 i는 5입니다.
이 문제를 해결하는 방법은 여러 가지 있습니다.
방법 1: let 사용 (가장 깔끔)
for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 1000); } // 0, 1, 2, 3, 4
let은 블록 스코프입니다. for 루프의 각 반복마다 새로운 i가 생성됩니다. 각 콜백이 서로 다른 i를 참조하기 때문에 의도한 대로 동작합니다.
방법 2: IIFE (let 이전 시대의 해결책)
for (var i = 0; i < 5; i++) { (function(j) { setTimeout(function() { console.log(j); }, 1000); })(i); } // 0, 1, 2, 3, 4
즉시 실행 함수(IIFE)를 써서 각 반복마다 새로운 스코프를 만듭니다. i의 값이 매개변수 j로 복사되기 때문에, 각 콜백이 독립적인 j를 갖게 됩니다.
방법 3: forEach 활용
[0, 1, 2, 3, 4].forEach(function(i) { setTimeout(function() { console.log(i); }, 1000); }); // 0, 1, 2, 3, 4
forEach의 콜백은 각 호출마다 새로운 스코프를 만들기 때문에 자연스럽게 해결됩니다.
현대 JavaScript에서는 let을 쓰면 끝나는 문제이지만, 이 문제를 이해하고 있으면 var와 let의 스코프 차이, 클로저가 값이 아니라 변수를 참조한다는 특성, 비동기 실행 타이밍 같은 핵심 개념을 한 번에 짚을 수 있습니다.
클로저와 메모리
클로저가 공짜는 아닙니다. 외부 변수의 참조를 유지하기 때문에 가비지 컬렉터가 해당 변수를 수거하지 못하거든요. 보통은 문제가 안 되지만, 대형 객체를 참조하는 클로저가 오래 살아있으면 메모리 누수로 이어집니다.
// 메모리 누수 위험이 있는 코드 function createHandler() { const hugeData = new Array(1000000).fill('데이터'); return function() { // hugeData를 사용하지 않지만, 클로저가 참조를 유지한다 console.log('핸들러 실행'); }; } const handler = createHandler(); // hugeData는 handler가 존재하는 한 메모리에 남아있다
이 코드에서 반환된 함수는 hugeData를 사용하지 않습니다. 하지만 같은 스코프에 있기 때문에 이론적으로는 클로저가 참조를 유지합니다.
실제로는 V8 엔진이 이 상황을 최적화합니다. V8은 클로저를 생성할 때 내부 함수가 실제로 참조하는 외부 변수만 클로저 스코프에 포함시킵니다. 위 예제에서 반환된 함수가 hugeData를 사용하지 않으면, V8은 hugeData를 클로저 스코프에서 제외하고 가비지 컬렉터가 수거할 수 있게 합니다.
그러나 이 최적화가 작동하지 않는 경우가 있습니다. eval()을 사용하거나, 같은 스코프의 다른 클로저가 해당 변수를 참조하거나, 디버거가 활성화된 상태에서는 엔진이 어떤 변수가 필요한지 정적으로 판단할 수 없어 모든 외부 변수를 보존합니다.
Chrome DevTools의 Sources 탭에서 클로저 안에 브레이크포인트를 걸고 Scope 패널을 확인하면, 해당 클로저가 실제로 어떤 변수를 캡처하고 있는지 직접 볼 수 있습니다. 메모리 누수가 의심될 때 가장 먼저 확인해야 할 곳입니다.
그래도 방어적으로 코딩하는 게 낫습니다. 필요 없어진 참조는 직접 끊어주면 확실합니다.
function createHandler() { let hugeData = new Array(1000000).fill('데이터'); const summary = processData(hugeData); // 필요한 정보만 추출 hugeData = null; // 참조 해제 return function() { console.log('요약:', summary); }; }
Node.js 서버에서 장시간 돌아가는 애플리케이션을 만든 개발자들에게 물어보면, 이벤트 리스너의 클로저가 대형 객체를 잡고 있어서 메모리가 계속 올라가는 문제를 겪은 경우가 꽤 있습니다. 이벤트 리스너를 제거하거나, WeakRef/WeakMap을 활용하는 것이 실전에서의 해결책입니다.
고차함수, 함수가 함수를 다루는 법
고차함수는 함수를 인자로 받거나 함수를 반환하는 함수입니다. 앞에서 본 콜백과 함수 팩토리가 사실 고차함수의 두 가지 형태였습니다. 여기서는 JavaScript에 내장된 고차함수들과 실전 활용 패턴을 다루겠습니다.
map, filter, reduce: 배열의 삼총사
Array.prototype에 있는 map, filter, reduce는 JavaScript에서 가장 자주 사용되는 고차함수입니다.
map: 각 요소를 변환
const prices = [10000, 25000, 8000, 42000, 15000]; // 10% 할인 가격 계산 const discounted = prices.map(price => price * 0.9); console.log(discounted); // [9000, 22500, 7200, 37800, 13500] // 실전 예제: API 응답 데이터 가공 const users = [ { id: 1, firstName: '개발', lastName: '김', email: 'kim@dev.com' }, { id: 2, firstName: '디자인', lastName: '박', email: 'park@design.com' }, { id: 3, firstName: '기획', lastName: '이', email: 'lee@plan.com' } ]; const displayNames = users.map(user => ({ id: user.id, fullName: user.lastName + user.firstName, email: user.email })); console.log(displayNames); // [ // { id: 1, fullName: '김개발', email: 'kim@dev.com' }, // { id: 2, fullName: '박디자인', email: 'park@design.com' }, // { id: 3, fullName: '이기획', email: 'lee@plan.com' } // ]
map은 원본 배열을 변경하지 않고 새로운 배열을 반환합니다. 이게 forEach와의 핵심 차이입니다.
filter: 조건에 맞는 요소만 추출
const products = [ { name: '노트북', price: 1200000, inStock: true }, { name: '키보드', price: 89000, inStock: true }, { name: '모니터', price: 450000, inStock: false }, { name: '마우스', price: 45000, inStock: true }, { name: '헤드셋', price: 230000, inStock: false } ]; // 재고가 있고 10만원 이하인 상품 const affordable = products.filter( product => product.inStock && product.price <= 100000 ); console.log(affordable); // [ // { name: '키보드', price: 89000, inStock: true }, // { name: '마우스', price: 45000, inStock: true } // ]
reduce: 배열을 하나의 값으로 축소
const orders = [ { product: '노트북', quantity: 2, unitPrice: 1200000 }, { product: '키보드', quantity: 5, unitPrice: 89000 }, { product: '마우스', quantity: 10, unitPrice: 45000 } ]; // 총 주문 금액 계산 const totalAmount = orders.reduce((sum, order) => { return sum + (order.quantity * order.unitPrice); }, 0); console.log(totalAmount); // 3295000
reduce는 map이나 filter보다 범용적입니다. 사실 map과 filter를 reduce로 구현할 수도 있습니다.
// reduce로 map 구현 function myMap(array, fn) { return array.reduce((result, item) => { result.push(fn(item)); return result; }, []); } // reduce로 filter 구현 function myFilter(array, fn) { return array.reduce((result, item) => { if (fn(item)) result.push(item); return result; }, []); } console.log(myMap([1, 2, 3], x => x * 2)); // [2, 4, 6] console.log(myFilter([1, 2, 3, 4, 5], x => x > 3)); // [4, 5]
메서드 체이닝: 삼총사의 합체
map, filter, reduce의 진짜 힘은 체이닝에서 나옵니다.
const employees = [ { name: '김개발', department: '개발', salary: 5000, years: 3 }, { name: '박디자인', department: '디자인', salary: 4500, years: 5 }, { name: '이기획', department: '기획', salary: 4800, years: 2 }, { name: '최개발', department: '개발', salary: 6000, years: 7 }, { name: '정개발', department: '개발', salary: 5500, years: 4 }, { name: '한디자인', department: '디자인', salary: 5200, years: 6 } ]; // 개발팀에서 경력 4년 이상인 직원의 평균 연봉 const avgSalary = employees .filter(emp => emp.department === '개발') .filter(emp => emp.years >= 4) .map(emp => emp.salary) .reduce((sum, salary, _, arr) => sum + salary / arr.length, 0); console.log(avgSalary); // 5750
이 코드를 for 루프로 작성하면 이렇게 됩니다.
let sum = 0; let count = 0; for (let i = 0; i < employees.length; i++) { if (employees[i].department === '개발' && employees[i].years >= 4) { sum += employees[i].salary; count++; } } const avgSalary2 = count > 0 ? sum / count : 0;
동작은 같지만, 체이닝 버전은 각 단계가 무엇을 하는지 명확하게 읽힙니다. "개발팀을 거르고, 4년 이상을 거르고, 연봉만 뽑아서, 평균을 낸다." 코드가 의도를 그대로 드러냅니다.
함수를 반환하는 고차함수: 커링과 부분 적용
커링은 여러 인자를 받는 함수를 인자 하나씩 받는 함수의 체인으로 바꾸는 기법입니다.
// 일반 함수 function add(a, b) { return a + b; } // 커링된 함수 function curriedAdd(a) { return function(b) { return a + b; }; } console.log(add(3, 5)); // 8 console.log(curriedAdd(3)(5)); // 8 // 화살표 함수로 더 간결하게 const curriedAdd2 = a => b => a + b; console.log(curriedAdd2(3)(5)); // 8
커링이 유용한 이유는 재사용 가능한 특화 함수를 만들 수 있기 때문입니다.
// 로그 함수 커링 const createLogger = (level) => (module) => (message) => { const timestamp = new Date().toISOString(); console.log('[' + timestamp + '] [' + level + '] [' + module + '] ' + message); }; // 특화된 로거 생성 const errorLog = createLogger('ERROR'); const authError = errorLog('AUTH'); const dbError = errorLog('DB'); authError('로그인 실패: 잘못된 비밀번호'); // [2026-02-26T14:00:00.000Z] [ERROR] [AUTH] 로그인 실패: 잘못된 비밀번호 dbError('연결 시간 초과'); // [2026-02-26T14:00:00.000Z] [ERROR] [DB] 연결 시간 초과 const infoLog = createLogger('INFO'); const apiInfo = infoLog('API'); apiInfo('GET /users 200 OK'); // [2026-02-26T14:00:00.000Z] [INFO] [API] GET /users 200 OK
범용 curry 함수를 만들 수도 있습니다.
function curry(fn) { return function curried(...args) { if (args.length >= fn.length) { return fn(...args); } return function(...nextArgs) { return curried(...args, ...nextArgs); }; }; } // 사용 예 function calculatePrice(basePrice, taxRate, discount) { return basePrice * (1 + taxRate) * (1 - discount); } const curriedPrice = curry(calculatePrice); const price1 = curriedPrice(10000)(0.1)(0.05); console.log(price1); // 10450 // 기본 가격을 고정한 함수 만들기 const withTax10 = curriedPrice(10000)(0.1); console.log(withTax10(0)); // 11000 (할인 없음) console.log(withTax10(0.1)); // 9900 (10% 할인) console.log(withTax10(0.2)); // 8800 (20% 할인)
커링에는 한계가 있습니다. 인자를 왼쪽부터 순서대로 채워야 한다는 점입니다. 예를 들어 "세율만 고정하고 기본 가격은 나중에 받고 싶다"는 요구를 순수 커링으로는 해결하기 어렵습니다. 이런 경우에는 부분 적용을 사용합니다.
// 부분 적용: 특정 인자만 미리 고정 function partial(fn, ...presetArgs) { return function(...laterArgs) { return fn(...presetArgs, ...laterArgs); }; } // 세율 10%를 고정한 가격 계산 함수 const withKoreanTax = partial(calculatePrice, undefined); // 이렇게 하면 안 됩니다. partial도 순서 의존적입니다. // 인자 순서를 바꿔서 해결 function calculatePriceV2(taxRate, discount, basePrice) { return basePrice * (1 + taxRate) * (1 - discount); } const koreanPrice = curry(calculatePriceV2)(0.1); console.log(koreanPrice(0.05)(10000)); // 10450 console.log(koreanPrice(0)(20000)); // 22000
커링과 부분 적용의 차이가 헷갈릴 수 있습니다. 커링은 인자를 하나씩 받는 함수 체인을 만드는 것이고, 부분 적용은 일부 인자를 미리 고정한 새 함수를 만드는 것입니다. 실무에서는 인자 순서를 잘 설계하는 것이 커링 활용의 핵심입니다. 가장 자주 고정되는 인자를 앞에 두면 재사용성이 높아집니다.
범용 curry 함수를 직접 구현해서 사용하는 경우는 드뭅니다. 대부분 Lodash의 _.curry나 Ramda의 R.curry를 사용합니다. 그런데 실은 커링 형태를 이미 사용하고 있으면서 그걸 모르는 경우가 많습니다. Redux의 connect가 대표적입니다. connect(mapStateToProps)(MyComponent) 형태로 호출하는데, 이게 정확히 커링입니다. 설정을 먼저 받고, 그 설정이 적용된 함수로 컴포넌트를 감쌉니다. React의 고차 컴포넌트 패턴 전반이 이 구조를 따릅니다.
함수 합성: pipe와 compose
함수 합성은 작은 함수를 조합해서 복잡한 처리를 만드는 패턴입니다.
// 개별 변환 함수들 const trim = str => str.trim(); const toLowerCase = str => str.toLowerCase(); const replaceSpaces = str => str.replace(/\s+/g, '-'); const removeSpecialChars = str => str.replace(/[^a-z0-9-]/g, ''); // pipe: 왼쪽에서 오른쪽으로 실행 const pipe = (...fns) => (input) => fns.reduce((result, fn) => fn(result), input); // compose: 오른쪽에서 왼쪽으로 실행 const compose = (...fns) => (input) => fns.reduceRight((result, fn) => fn(result), input); // URL 슬러그 생성 함수를 합성 const toSlug = pipe(trim, toLowerCase, replaceSpaces, removeSpecialChars); console.log(toSlug(' Hello World! This is JavaScript ')); // 'hello-world-this-is-javascript'
pipe는 함수들을 실행 순서대로 나열하기 때문에 읽기 편합니다. trim -> toLowerCase -> replaceSpaces -> removeSpecialChars 순서로 처리된다는 걸 코드만 봐도 알 수 있습니다.
좀 더 실전적인 예제를 보겠습니다.
// 데이터 처리 파이프라인 const parseJSON = str => JSON.parse(str); const extractUsers = data => data.users; const filterActive = users => users.filter(u => u.active); const sortByName = users => [...users].sort((a, b) => a.name.localeCompare(b.name)); const formatForDisplay = users => users.map(u => u.name + ' (' + u.email + ')'); const processUserData = pipe( parseJSON, extractUsers, filterActive, sortByName, formatForDisplay ); const rawData = JSON.stringify({ users: [ { name: '박개발', email: 'park@dev.com', active: true }, { name: '김디자인', email: 'kim@design.com', active: false }, { name: '이기획', email: 'lee@plan.com', active: true }, { name: '가나다', email: 'abc@test.com', active: true } ] }); const result = processUserData(rawData); console.log(result); // [ // '가나다 (abc@test.com)', // '박개발 (park@dev.com)', // '이기획 (lee@plan.com)' // ]
각 함수는 하나의 일만 합니다. 테스트도 쉽고, 재사용도 쉽고, 합성도 자유롭습니다. 이 패턴이 가능한 이유는 함수가 일급 시민이기 때문입니다.
세 개념이 만나는 지점
지금까지 일급 함수, 클로저, 고차함수를 하나씩 살펴봤습니다. 실전에서는 이 세 가지가 동시에 작동하는 경우가 대부분입니다.
다음 그림은 세 가지 개념이 실전에서 어떻게 만나는지를 보여줍니다.

일급 함수가 스코프 캡처를 가능하게 해서 클로저가 생기고, 함수를 인자로 전달하거나 반환할 수 있어서 고차함수가 성립합니다. 클로저와 고차함수는 상태 보존이라는 연결고리로 이어집니다. API 클라이언트, Express 미들웨어, React Hooks, 이벤트 핸들러 모두 이 세 개념이 합쳐진 결과물입니다.
// 세 개념이 모두 작동하는 실전 예제: API 요청 래퍼 function createAPIClient(baseURL) { // 클로저: baseURL을 기억 const headers = { 'Content-Type': 'application/json' }; // 고차함수: 함수를 반환 function request(method) { // 클로저: method를 기억 return function(endpoint, body) { // 일급 함수: fetch에 옵션 객체를 전달 const url = baseURL + endpoint; const options = { method: method, headers: headers }; if (body) { options.body = JSON.stringify(body); } return fetch(url, options).then(res => { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); }); }; } // 함수를 프로퍼티로 가진 객체 반환 (함수 = 객체) return { get: request('GET'), post: request('POST'), put: request('PUT'), delete: request('DELETE'), setHeader: function(key, value) { headers[key] = value; } }; } // 사용 const api = createAPIClient('https://api.example.com'); // 인증 토큰 설정 api.setHeader('Authorization', 'Bearer abc123'); // 각 메서드는 독립적인 클로저 // api.get은 method='GET'을, api.post는 method='POST'를 기억 api.get('/users') .then(users => console.log(users)); api.post('/users', { name: '김개발', role: 'backend' }) .then(result => console.log(result));
이 코드에서 일급 함수, 클로저, 고차함수가 어떻게 얽혀 있는지 보겠습니다.
request는 고차함수입니다. method를 인자로 받고 함수를 반환합니다. 반환된 함수는 클로저입니다. baseURL, headers, method를 기억하고 있습니다. api.get, api.post 같은 프로퍼티에 함수가 담기는 건 함수가 일급 시민이기 때문입니다.
한 가지 더, setHeader를 통해 headers 객체를 수정하면 get, post 등 모든 메서드에 반영됩니다. 모든 메서드가 같은 headers 객체를 클로저로 공유하고 있기 때문입니다. 이건 의도된 동작이면 편리하지만, 의도하지 않았다면 버그가 됩니다. 클로저가 "값"이 아니라 "참조"를 공유한다는 특성을 다시 한번 보여주는 예입니다.
백엔드에서도 같은 패턴이 반복됩니다. Express 미들웨어가 대표적입니다.
// Express 미들웨어: 세 개념이 모두 작동하는 또 다른 예 function rateLimiter(maxRequests, windowMs) { // 클로저: 설정값과 요청 기록을 기억 const requests = new Map(); // 고차함수: 미들웨어 함수를 반환 return function(req, res, next) { const ip = req.ip; const now = Date.now(); if (!requests.has(ip)) { requests.set(ip, []); } // 윈도우 밖의 기록 제거 const history = requests.get(ip).filter( time => now - time < windowMs ); if (history.length >= maxRequests) { return res.status(429).json({ error: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.' }); } history.push(now); requests.set(ip, history); next(); // 일급 함수: next도 함수이고, 인자로 전달된 것 }; } // 사용: 1분에 100번까지 허용 app.use(rateLimiter(100, 60 * 1000));
rateLimiter는 고차함수로, 설정값을 받고 미들웨어 함수를 반환합니다. 반환된 미들웨어는 클로저로 maxRequests, windowMs, requests Map을 기억합니다. next는 일급 시민으로서 함수에 인자로 전달된 함수입니다. Express 앱에서 app.use()로 미들웨어를 등록하는 것 자체가 함수를 인자로 넘기는 행위입니다. 이 세 줄짜리 코드에 세 개념이 전부 들어 있습니다.
마무리
함수가 객체이고 일급 시민이라는 건 그냥 언어 스펙 한 줄이 아닙니다. 콜백 패턴이 여기서 나왔고, 클로저를 통해 상태 관리와 데이터 은닉이 가능해졌고, 고차함수 덕분에 선언적으로 코드를 조합할 수 있게 됐습니다.
처음 JavaScript를 배울 때 function을 단순히 "코드를 묶는 상자"로만 이해하고 넘어가면, 나중에 클로저를 만났을 때 혼란스러워집니다. "왜 이 변수가 살아있지?" "왜 반환된 함수가 바깥 변수를 알고 있지?" 이런 의문이 풀리지 않는 것입니다. 하지만 함수가 객체이고, 객체가 자신의 환경에 대한 참조를 유지한다는 걸 알고 나면, 클로저는 당연한 결과입니다.
커뮤니티에서 JavaScript를 오래 쓴 개발자들에게 물어보면, 이 세 개념을 제대로 이해한 시점이 "JavaScript를 쓰는 사람"에서 "JavaScript를 아는 사람"으로 넘어가는 분기점이었다는 말을 자주 합니다.
typeof function(){}가 'function'이 아니라 'object'를 반환했다면, 처음부터 혼란이 덜했을 겁니다. 하지만 그 작은 편의적 선택 하나가 "함수는 함수이고 객체는 객체"라는 잘못된 직관을 심어놓았습니다. 이제 typeof가 'function'이라고 말해도 속지 않을 겁니다.
참고 자료






댓글
댓글을 작성하려면 이 필요합니다.