JAVASCRIPT · 심층 가이드
JavaScript 함수 완전 정리
선언 방식과 기본값 매개변수에서 시작해 클로저, 메모이제이션, 커링, 파이프라인까지 JavaScript 함수를 조립 단위로 다루는 20개 예제입니다.
JavaScript에서 함수는 호출할 수 있는 객체입니다. 변수에 담고, 인자로 넘기고, 다른 함수가 만들어 돌려줄 수 있습니다. 이 사실 하나에서 map, 콜백, 미들웨어, 데코레이터가 전부 파생됩니다. 초보자의 모델이 깨지는 지점은 this입니다. 일반 함수의 this는 정의 위치가 아니라 호출 방식으로 결정되고, 화살표 함수는 그런 this를 아예 갖지 않고 바깥 스코프의 것을 그대로 씁니다. 두 종류가 서로 다른 도구라는 걸 받아들이면 대부분의 혼란이 정리됩니다.
기본 함수 선언과 람다 함수로 두 표기법을 나란히 보고, 클로저 카운터에서 함수가 자신이 태어난 스코프의 변수를 계속 붙잡는다는 사실을 확인합니다. 이 클로저가 뒤쪽 주제 대부분의 토대입니다. 메모이제이션은 캐시를 클로저에 숨긴 것이고, 커링과 함수 팩토리는 인자 일부를 클로저에 미리 담아 둔 것입니다. compose 함수와 파이프라인 함수까지 오면 작은 함수들을 데이터 흐름 순서대로 붙여 쓰는 형태가 완성됩니다.
기본값 매개변수는 호출할 때마다 새로 평가됩니다. function push(item, list = [])는 호출마다 새 배열을 만들기 때문에, 기본 인자가 호출 간에 공유되는 Python의 유명한 함정이 여기서는 발생하지 않습니다. 반대로 매번 새 객체가 생기므로 무거운 값을 기본값에 두면 비용이 반복됩니다. 메모이제이션에는 다른 함정이 있습니다. 캐시 키를 만들 때 인자를 문자열로 이어 붙이면 서로 다른 객체가 모두 [object Object]가 되어 충돌하므로, 객체 인자에는 Map이나 WeakMap을 키로 쓰는 편이 안전합니다.
01기본 함수 선언
function 키워드로 add를 선언하고 두 인자의 합 3을 출력합니다. 이름 있는 선언은 호이스팅되어 정의보다 위쪽에서도 호출할 수 있습니다. +는 숫자 덧셈과 문자열 이어 붙이기를 겸하므로 인자 타입이 섞이면 결과가 달라집니다.
JavaScript code
// 기본 함수 선언
function add(a, b) {
return a + b;
}
console.log(add(1, 2));함수 선언은 정의 전체가 끌어올려져 위쪽에서도 부를 수 있지만, const에 담은 함수 표현식은 대입 전에 부르면 TDZ 때문에 오류가 납니다.
add(1, "2")는 오류 없이 "12"를 돌려줍니다. +는 한쪽이 문자열이면 이어 붙이기로 바뀌어 결과가 조용히 문자열이 됩니다.
02두 값을 더하는 함수
나머지 매개변수로 받은 인자들을 reduce가 0부터 누적해 5를 만듭니다. 인자 개수가 정해지지 않은 유틸리티에 알맞은 형태입니다. 초깃값 0이 없으면 빈 호출에서 TypeError가 나므로 그 인자가 안전장치 노릇을 합니다.
JavaScript code
// 두 값을 더하는 함수
function sum(...values) {
return values.reduce((total, value) => total + value, 0);
}
console.log(sum(1, 2, 2));...values는 진짜 배열이라 배열 메서드를 바로 붙일 수 있고, 유사 배열인 arguments와 달리 화살표 함수 안에서도 쓸 수 있습니다.
나머지 매개변수는 마지막 자리에만 올 수 있어 뒤에 다른 인자를 두면 문법 오류가 납니다. 추가 옵션이 필요하면 객체로 묶어 받아야 합니다.
03기본값 매개변수
makeCounter가 지역 변수 current를 품은 함수를 돌려주고, 두 번 호출해 1과 2를 얻습니다. 기본값 매개변수 대신 클로저가 상태를 기억하는 방식입니다. 인자는 왼쪽부터 평가되므로 출력 순서가 증가 순서와 그대로 맞아떨어집니다.
JavaScript code
// 기본값 매개변수
function makeCounter() {
let current = 0;
return () => ++current;
}
const next = makeCounter();
console.log(next(), next());기본값은 호출할 때마다 새로 평가되므로 function f(list = [])는 호출마다 새 배열을 받습니다. 기본 인자가 공유되는 다른 언어와 다른 점입니다.
인자 평가 순서에 기대어 같은 상태를 두 번 읽는 코드를 쓰면, 표현식을 조금만 손봐도 값이 달라져 원인을 찾기 어려운 버그가 됩니다.
04가변 인자 합계
두 변환 함수를 담은 배열을 reduce로 훑으며 4에 차례로 적용해 10을 출력합니다. 인자 개수가 아니라 단계 수가 늘어나는 구조라 처리 흐름을 데이터로 다룰 수 있습니다. 누적값이 다음 함수의 인자가 되므로 각 단계는 값 하나만 받아야 합니다.
JavaScript code
// 가변 인자 합계
const steps = [(v) => v + 1, (v) => v * 2];
console.log(steps.reduce((v, fn) => fn(v), 4));실행 흐름을 배열로 두면 단계 수를 세거나 로그를 끼워 넣는 일을 데이터 조작으로 처리할 수 있어, 실행기 코드를 고치지 않아도 됩니다.
단계 함수가 값을 반환하지 않으면 다음 단계에 undefined가 넘어가 이후 계산이 전부 NaN이 됩니다. 각 단계는 반드시 새 값을 돌려줘야 합니다.
05객체 또는 구조체 반환
객체를 돌려주는 대신 숫자 하나를 반환하는 가장 단순한 형태로, 5와 2를 더해 내보냅니다. 값을 여러 개 돌려주고 싶으면 객체나 배열로 묶어 구조 분해로 받는 방식이 흔합니다. 자바스크립트 함수는 반환문이 없으면 undefined가 나갑니다.
JavaScript code
// 객체 또는 구조체 반환
function add(a, b) {
return a + b;
}
console.log(add(5, 2));화살표 함수로 객체를 바로 돌려줄 때는 () => ({ a: 1 })처럼 괄호로 감싸야 합니다. 그러지 않으면 중괄호가 함수 본문으로 해석됩니다.
return과 값 사이에 줄바꿈을 넣으면 세미콜론이 자동으로 삽입되어 undefined가 반환됩니다. 반환할 값은 같은 줄에서 시작해야 합니다.
06람다 함수
람다로 쓴 누적 콜백이 값 세 개를 훑어 9를 돌려줍니다. ...values는 진짜 배열이라 arguments와 달리 배열 메서드를 바로 붙일 수 있습니다. 화살표 함수는 자기 this를 만들지 않고 바깥 범위의 것을 그대로 물려받습니다.
JavaScript code
// 람다 함수
function sum(...values) {
return values.reduce((total, value) => total + value, 0);
}
console.log(sum(1, 2, 6));화살표 함수는 this를 바깥에서 물려받아, 콜백 안에서 const self = this 같은 우회를 두지 않아도 원래 객체를 그대로 볼 수 있습니다.
화살표 함수는 new로 호출할 수 없고 prototype도 없습니다. 생성자로 쓰던 함수를 화살표로 바꾸면 그 자리에서 TypeError가 납니다.
07클로저 카운터
반환된 함수가 바깥 current를 계속 참조하기 때문에 makeCounter가 끝난 뒤에도 값이 유지됩니다. 요청 번호나 식별자 발급에 쓰는 형태입니다. makeCounter를 다시 부르면 완전히 별개의 current가 생겨 카운터끼리 섞이지 않습니다.
JavaScript code
// 클로저 카운터
function makeCounter() {
let current = 0;
return () => ++current;
}
const next = makeCounter();
console.log(next(), next());makeCounter를 부를 때마다 독립된 current가 생기므로, 카운터를 여러 개 만들어도 값이 서로 섞이지 않습니다.
클로저가 큰 객체를 참조하면 바깥 함수가 끝나도 그 객체가 회수되지 않습니다. 필요한 값만 꺼내 담아야 메모리가 붙잡히지 않습니다.
08재귀 팩토리얼
재귀 대신 배열 순회로 반복을 표현해 8을 18로 바꿉니다. 깊은 재귀는 호출 스택이 쌓여 RangeError로 터질 수 있지만 이 방식은 스택이 깊어지지 않습니다. 단계를 늘리고 싶으면 배열에 함수를 추가하기만 하면 됩니다.
JavaScript code
// 재귀 팩토리얼
const steps = [(v) => v + 1, (v) => v * 2];
console.log(steps.reduce((v, fn) => fn(v), 8));재귀를 배열 순회로 바꾸면 호출 스택이 깊어지지 않아, 단계 수가 수만 개로 늘어도 스택 한계에 부딪히지 않습니다.
자바스크립트 엔진은 대체로 꼬리 호출 최적화를 하지 않으므로 재귀 깊이가 커지면 RangeError로 멈춥니다. 깊은 재귀는 반복문으로 풀어야 합니다.
09고차 함수 map
map 같은 고차 함수에 넘기기 좋은 함수를 정의하고 직접 호출해 11을 찍습니다. 다만 map은 콜백에 값과 인덱스와 배열까지 세 인자를 주므로, 인자를 둘 받는 함수를 그대로 넘기면 인덱스가 두 번째 인자로 흘러듭니다.
JavaScript code
// 고차 함수 map
function add(a, b) {
return a + b;
}
console.log(add(9, 2));map에 넘길 콜백을 인자 하나만 받도록 감싸 두면, 나중에 다른 함수로 바꿔 끼워도 인덱스가 끼어드는 사고를 막을 수 있습니다.
["1","2","3"].map(parseInt)는 인덱스가 진법 인자로 들어가 [1, NaN, NaN]이 됩니다. 인자를 여러 개 받는 함수는 그대로 넘기면 안 됩니다.
10조건 함수 predicate
조건을 판별하는 대신 모든 인자를 더해 13을 냅니다. 술어 함수로 바꾸려면 누적값 자리에 참·거짓을 두고 &&로 이어 가면 됩니다. reduce는 원소마다 콜백을 한 번씩 호출하므로 배열이 길면 호출 비용이 그대로 반영됩니다.
JavaScript code
// 조건 함수 predicate
function sum(...values) {
return values.reduce((total, value) => total + value, 0);
}
console.log(sum(1, 2, 10));술어 함수는 every와 some, filter에 그대로 재사용할 수 있으므로 조건을 인라인으로 쓰기보다 이름을 붙여 두면 중복이 줄어듭니다.
reduce로 참·거짓을 누적하면 결과가 확정된 뒤에도 남은 요소를 모두 훑습니다. 조기 종료가 필요하면 every나 some을 써야 합니다.
11정렬용 비교 함수
비교 함수와 달리 이 코드는 부를 때마다 1씩 커지는 함수를 만들어 냅니다. 정렬 기준을 정하는 함수라면 두 값을 받아 음수·0·양수를 돌려줘야 하고, 내부 상태를 가지면 결과가 뒤죽박죽이 됩니다. ++current는 증가시킨 뒤의 값을 반환합니다.
JavaScript code
// 정렬용 비교 함수
function makeCounter() {
let current = 0;
return () => ++current;
}
const next = makeCounter();
console.log(next(), next());비교 함수는 상태를 갖지 않아야 하고, 두 인자의 순서를 바꿔 부르면 부호가 뒤집혀야 어떤 정렬 구현에서도 결과가 일관됩니다.
++current와 current++는 반환값이 달라 첫 호출에서 각각 1과 0을 냅니다. 카운터 시작 번호가 하나씩 어긋나는 흔한 원인입니다.
12문자열 파서
문자열을 해석하는 대신 숫자 하나를 두 단계에 통과시켜 26을 얻습니다. 파서를 이렇게 짜면 각 단계가 중간 표현을 받아 다음 표현으로 바꾸는 모양이 됩니다. 시작 값을 reduce의 두 번째 인자로 주었기 때문에 단계 배열이 비어도 안전합니다.
JavaScript code
// 문자열 파서
const steps = [(v) => v + 1, (v) => v * 2];
console.log(steps.reduce((v, fn) => fn(v), 12));단계별 변환으로 파서를 만들면 중간 표현마다 검사를 붙일 수 있어, 어느 단계에서 입력이 어긋났는지 범위를 좁히기 쉬워집니다.
g 플래그를 붙인 정규식 객체를 여러 번 재사용하면 lastIndex가 남아 다음 호출이 문자열 중간부터 검사합니다.
13함수 팩토리
함수를 만들어 내는 팩토리와 달리, 여기서는 미리 선언해 둔 add를 한 번 호출해 15를 냅니다. 팩토리로 바꾸려면 첫 인자를 클로저에 가둔 함수를 반환하도록 감싸면 됩니다. 선언식으로 만든 함수도 값처럼 변수에 담아 넘길 수 있습니다.
JavaScript code
// 함수 팩토리
function add(a, b) {
return a + b;
}
console.log(add(13, 2));팩토리로 만든 함수마다 별도의 클로저가 생기므로, 설정이 서로 다른 여러 버전을 동시에 두고 쓸 수 있습니다.
팩토리를 부를 때마다 새 함수 객체가 만들어져 같은 설정이어도 참조가 달라집니다. 이벤트 해제나 비교에 그 참조를 쓰면 어긋납니다.
14메모이제이션
같은 인자로 몇 번을 불러도 결과가 같은 순수한 합산이라 캐싱을 얹기 좋은 대상이고, 여기서는 17이 나옵니다. 다만 인자가 가변이라 캐시 키를 만들려면 인자 전체를 문자열 등으로 합쳐야 하며, 그 비용이 덧셈 자체보다 커질 수 있습니다.
JavaScript code
// 메모이제이션
function sum(...values) {
return values.reduce((total, value) => total + value, 0);
}
console.log(sum(1, 2, 14));인자가 객체 하나라면 WeakMap을 캐시로 쓰면 키가 된 객체가 회수될 때 캐시 항목도 함께 사라져 메모리가 새지 않습니다.
가변 인자를 JSON.stringify로 키로 만들면 속성 순서가 다를 때 다른 키가 되어 캐시가 빗나가고, 키 생성 비용이 계산보다 커지기도 합니다.
15커링
인자 없이 부르면 다음 번호를 주는 함수를 만들어 1과 2를 출력합니다. 커링도 함수를 반환한다는 점은 같지만, 값을 기억하는 대신 인자를 하나씩 받아 다음 함수로 넘긴다는 점이 다릅니다. 여기 반환된 함수는 매번 다른 값을 내므로 순수하지 않습니다.
JavaScript code
// 커링
function makeCounter() {
let current = 0;
return () => ++current;
}
const next = makeCounter();
console.log(next(), next());커링한 함수는 인자를 하나씩 받으므로, 앞 인자를 채운 상태 그대로 map이나 파이프라인에 넘겨 쓸 수 있습니다.
여기 반환된 함수는 부를 때마다 다른 값을 내므로 순수하지 않습니다. 결과를 캐시하거나 테스트에서 여러 번 부르면 기대와 다른 값이 나옵니다.
16compose 함수
합성 함수를 따로 만들지 않고 reduce 한 줄로 같은 일을 해내며 34를 출력합니다. 수학의 합성과 달리 배열 앞쪽 함수가 먼저 적용되므로, 오른쪽부터 적용하는 compose가 필요하면 reduceRight로 바꿔야 합니다.
JavaScript code
// compose 함수
const steps = [(v) => v + 1, (v) => v * 2];
console.log(steps.reduce((v, fn) => fn(v), 16));reduceRight로 바꾸면 배열 오른쪽 함수부터 적용되어 수학 표기의 합성과 순서가 맞습니다. 어느 방향인지 이름에 드러내면 혼동이 적습니다.
합성 순서를 잘못 잡으면 오류 없이 값만 달라집니다. 더하기와 곱하기처럼 순서를 바꿀 수 없는 연산이 섞이면 차이가 뒤늦게 발견됩니다.
17제네릭 함수
타입 표기가 없는 자바스크립트에서 add는 숫자든 문자열이든 그대로 받아들이며, 여기서는 두 숫자를 더해 19를 냅니다. 인자를 하나만 넘겨도 오류 없이 undefined가 들어와 결과가 NaN이 되므로 검사를 직접 넣어야 합니다.
JavaScript code
// 제네릭 함수
function add(a, b) {
return a + b;
}
console.log(add(17, 2));선언된 인자 개수는 fn.length로 알 수 있는데, 기본값이나 나머지 매개변수가 있으면 그 앞까지만 세어집니다.
인자를 빠뜨리면 undefined가 들어와 결과가 NaN이 됩니다. NaN은 이후 계산에 계속 번져 원인 지점을 찾기가 어려워집니다.
18콜백으로 완료 알림
완료를 알리는 콜백 없이 값을 곧바로 반환하는 동기 함수라, 결과 21이 그 자리에서 출력됩니다. 콜백 방식으로 바꾸면 호출부는 반환값 대신 함수 인자를 받고 오류 전달 규약을 따로 정해야 합니다. 여기 쓰인 콜백은 reduce에 넘긴 누적 함수뿐입니다.
JavaScript code
// 콜백으로 완료 알림
function sum(...values) {
return values.reduce((total, value) => total + value, 0);
}
console.log(sum(1, 2, 18));콜백 방식은 오류를 첫 인자로 넘기는 관례를 따르는 편이 좋습니다. Node 표준 API도 같은 규약을 써서 프로미스로 자동 변환할 수 있습니다.
콜백을 조건에 따라 부르거나 부르지 않으면 호출부가 영원히 기다립니다. 모든 경로에서 정확히 한 번만 부르도록 보장해야 합니다.
19reducer 함수
리듀서가 상태와 동작을 받아 새 상태를 내놓는 것과 달리, 이 카운터는 내부 변수를 직접 고쳐 1과 2를 냅니다. 상태를 감춰 두면 쓰기는 편하지만 바깥에서 현재 값을 읽거나 되돌릴 수 없습니다. 값을 보려면 읽기용 함수를 함께 반환해야 합니다.
JavaScript code
// reducer 함수
function makeCounter() {
let current = 0;
return () => ++current;
}
const next = makeCounter();
console.log(next(), next());리듀서는 상태와 동작을 받아 새 상태를 돌려주는 순수 함수라, 같은 동작 목록을 다시 흘려보내면 언제든 같은 상태를 재현할 수 있습니다.
내부 변수를 직접 고치는 카운터는 바깥에서 현재 값을 읽을 수 없어, 화면에 표시하거나 저장하려면 결국 읽기용 함수를 덧붙이게 됩니다.
20파이프라인 함수
값이 20에서 출발해 더하기와 곱하기를 지나 42로 흘러갑니다. 단계 배열을 밖에서 주입하면 같은 실행기로 여러 파이프라인을 돌릴 수 있습니다. 중간에 비동기 작업이 끼면 reduce가 프라미스를 그대로 다음 단계로 넘기므로 await 처리가 따로 필요합니다.
JavaScript code
// 파이프라인 함수
const steps = [(v) => v + 1, (v) => v * 2];
console.log(steps.reduce((v, fn) => fn(v), 20));단계를 인자로 받게 만들면 같은 실행기로 여러 파이프라인을 돌릴 수 있고, 테스트에서 단계를 가짜 함수로 바꿔 끼우기도 쉬워집니다.
단계 하나가 프로미스를 돌려주면 reduce가 그 프로미스를 다음 단계의 인자로 넘겨 버립니다. 비동기가 섞이면 await를 넣은 루프로 바꿔야 합니다.
정리하며
- this가 필요 없는 콜백에는 화살표 함수를, 메서드에는 일반 함수를 구분해 씁니다
- 설정값이나 캐시를 감추고 싶을 때는 전역 변수 대신 클로저에 담습니다
- 커링과 부분 적용으로 인자를 고정하면 map이나 filter에 바로 넘길 함수가 만들어집니다
- 메모이제이션 캐시 키는 문자열 연결 대신 Map/WeakMap으로 만들어 충돌을 피합니다
더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.