JAVASCRIPT · 심층 가이드
JavaScript 함수형 완전 정리
compose와 curry부터 Maybe·Result 스타일 처리까지, 라이브러리 없이 순수 JavaScript로 함수형 기법을 조립하는 15편의 실전 코드입니다.
함수형 스타일이 JavaScript에서 특히 자연스러운 이유는 함수가 값이고 클로저가 언어에 내장돼 있기 때문입니다. 다만 타입 시스템이 없어서 Haskell이나 Rust처럼 컴파일러가 대신 확인해 주지 않습니다. 불변성도 관례일 뿐이라 const로 선언한 배열의 내용은 얼마든지 바뀝니다. 결국 규율을 코드 형태로 강제해야 하고, 그 형태를 익히는 것이 이 묶음의 목적입니다.
순수 함수와 불변 업데이트가 토대이고, compose·partial 적용·curried 함수는 같은 아이디어를 세 각도에서 본 것입니다. 인자를 나눠 받는 curry가 있어야 compose에 넣을 단항 함수가 만들어지고, 그 조각들이 pipeline으로 이어집니다. Maybe 스타일 처리와 Result 스타일 매핑은 null과 예외를 값으로 다루는 방식입니다.
실무에서 발목을 잡는 것은 대개 성능이 아니라 얕은 복사입니다. 스프레드 연산자로 만든 사본은 한 겹만 새것이라, 중첩 객체를 그대로 물고 갑니다. 최신 런타임에는 structuredClone이 있지만 함수나 클래스 인스턴스는 복제하지 못합니다. 또 lazy iterator를 제너레이터로 만들면 한 번 소비된 이터레이터는 재사용이 불가능하다는 점을 놓치기 쉽습니다. 두 번 순회할 계획이라면 배열로 실체화해 두는 편이 안전합니다.
01순수 함수
입력만으로 결과가 정해지는 addTax가 가격에 1.1을 곱해 10에 대한 값을 냅니다. 외부 상태를 건드리지 않아 테스트와 캐싱이 쉬운 형태입니다. 다만 부동소수점 곱셈이라 결과가 11이 아니라 11.000000000000002로 찍힙니다.
JavaScript code
// 순수 함수
const addTax = (price) => price * 1.1;
console.log(addTax(10));순수 함수는 같은 입력에 같은 결과를 내므로 호출을 캐시로 바꿔치기해도 동작이 달라지지 않고, 테스트에서도 준비 코드 없이 값만 비교하면 됩니다.
price * 1.1은 11.000000000000002처럼 오차가 남습니다. 금액은 정수 단위로 계산하거나 표시 직전에 반올림해야 합니다.
02불변 업데이트
state를 그대로 두고 전개 연산자로 새 객체를 만들어 page를 올리고 tags에 항목을 더합니다. 이전 값과 새 값을 참조로 비교하는 화면 갱신 로직에서 이 방식이 필요합니다. 전개는 한 겹만 복사하므로 배열을 따로 펼치지 않으면 참조가 공유됩니다.
JavaScript code
// 불변 업데이트
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);전개로 만든 새 객체는 참조가 달라지므로, 화면 갱신 로직이 깊은 비교 없이 참조 비교만으로 변경 여부를 판단할 수 있습니다.
{ ...state }는 한 겹만 복사해 state.tags를 그대로 참조합니다. 배열을 함께 펼치지 않으면 새 객체를 통해 원본 배열이 수정됩니다.
03Maybe 스타일 처리
함수 두 개를 배열에 담고 reduce로 차례대로 적용해 3을 8로 바꿉니다. 값이 있을 수도 없을 수도 있는 흐름을 단계로 쪼갤 때 이 구조가 뼈대가 됩니다. 초깃값을 빼면 첫 원소인 함수 자체가 시작 값이 되어 엉뚱한 결과나 오류로 이어집니다.
JavaScript code
// Maybe 스타일 처리
const steps = [(value) => value + 1, (value) => value * 2];
console.log(steps.reduce((value, step) => step(value), 3));단계 배열을 밖에서 조립하면 실행 순서를 데이터로 다룰 수 있어, 조건에 따라 단계를 빼거나 새로 끼워 넣는 일이 코드 수정 없이 가능해집니다.
reduce의 초기값을 빼면 첫 요소인 함수 자체가 시작 값이 되어, 다음 단계가 함수를 인자로 받아 NaN이나 엉뚱한 결과를 냅니다.
04Result 스타일 매핑
제너레이터 numbers가 세 값을 순서대로 내놓고, 전개 구문이 이를 모두 소비해 배열 [1, 2, 4]를 만듭니다. 성공과 실패를 순서대로 흘려보내는 구조를 흉내 낼 때 쓸 수 있습니다. 전개하는 순간 지연 계산이 사라져 끝없는 제너레이터에는 쓸 수 없습니다.
JavaScript code
// Result 스타일 매핑
function* numbers() { yield 1; yield 2; yield 4; }
console.log([...numbers()]);제너레이터가 return한 값은 완료 결과에만 담기고, 전개 구문과 for...of는 그 값을 버리고 yield한 것만 모읍니다.
무한히 yield하는 제너레이터에 전개 구문을 쓰면 메모리를 다 쓸 때까지 멈추지 않습니다. 개수를 제한하는 단계를 반드시 사이에 끼워야 합니다.
05fold 합계
합계를 접어 나가는 흐름의 재료로 삼을 만한 addTax에 50을 넣어 세금이 붙은 값을 얻습니다. 여러 금액을 하나로 합칠 때 이런 단항 변환 함수를 앞단에 둡니다. 세율 1.1이 본문에 박혀 있어 값이 바뀌면 흩어진 곳을 모두 고쳐야 합니다.
JavaScript code
// fold 합계
const addTax = (price) => price * 1.1;
console.log(addTax(50));세율을 인자로 빼서 (rate) => (price) => price * (1 + rate) 형태로 만들면 여러 세율을 같은 코드로 다룰 수 있습니다.
항목마다 세금을 반올림해 더하면 총액에 한 번 적용한 결과와 몇 원씩 어긋납니다. 반올림 시점을 어디로 둘지 먼저 정해야 합니다.
06compose
두 단계 변경을 객체 리터럴 하나로 합성해 next를 만듭니다. 변환을 이어 붙이는 구조에서는 각 단계가 새 객체를 돌려주므로 중간 결과를 그대로 로그로 남길 수 있습니다. 대신 단계마다 객체가 새로 생겨 큰 구조를 자주 갱신하면 복사 비용이 쌓입니다.
JavaScript code
// compose
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);합성을 (f, g) => (x) => f(g(x))로 정의하면 단계마다 이름을 붙이지 않아도 되지만, 중간 값을 들여다보기는 어려워집니다.
단계마다 큰 객체를 통째로 복사하면 항목이 많은 목록에서 비용이 누적됩니다. 실제로 바뀐 가지만 새로 만들고 나머지는 참조를 공유하는 편이 낫습니다.
07partial 적용
고정된 단계 목록에 시작 값 7만 바꿔 넣어 16을 얻습니다. 인자 일부를 미리 묶어 두는 부분 적용과 같은 발상으로, 달라지는 값만 나중에 넘기는 구성입니다. 단계 순서가 결과를 바꾸므로 배열의 나열 순서 자체가 사양의 일부입니다.
JavaScript code
// partial 적용
const steps = [(value) => value + 1, (value) => value * 2];
console.log(steps.reduce((value, step) => step(value), 7));단계 배열이 같고 시작 값만 다르다면 실행부를 함수로 감싸 두어, 호출부가 값 하나만 넘기면 되도록 만들 수 있습니다.
더하기와 곱하기의 순서를 바꾸면 결과가 달라지는데 배열은 순서를 강제하지 않습니다. 단계를 조립하는 코드가 흩어지면 순서 실수가 드러나지 않습니다.
08curried 함수
function*로 정의된 함수는 호출해도 몸통이 실행되지 않고 이터레이터만 돌려주며, 값은 소비 시점에 하나씩 만들어집니다. 인자를 나눠 받는 커링처럼 실행을 미루는 성질이 핵심입니다. 이미 소진한 이터레이터를 다시 펼치면 빈 배열이 나옵니다.
JavaScript code
// curried 함수
function* numbers() { yield 1; yield 2; yield 8; }
console.log([...numbers()]);제너레이터 함수는 호출해도 몸통이 실행되지 않으므로, 안에서 던지는 오류조차 첫 next()를 부르기 전에는 발생하지 않습니다.
같은 이터레이터를 두 번 전개하면 두 번째는 빈 배열입니다. 여러 번 순회해야 한다면 결과를 배열에 담아 두거나 제너레이터를 다시 호출해야 합니다.
09pipeline
파이프라인의 한 칸으로 쓸 만한 addTax를 90에 적용합니다. 인자 하나에 결과 하나인 단항 함수라서 map이나 합성 함수에 그대로 넘길 수 있습니다. 반대로 인자가 둘 이상인 함수는 미리 부분 적용해 단항으로 줄여야 이어 붙일 수 있습니다.
JavaScript code
// pipeline
const addTax = (price) => price * 1.1;
console.log(addTax(90));단항 함수만 모으면 별도 헬퍼 없이 reduce 한 줄로 어떤 파이프라인이든 실행할 수 있어, 합성 라이브러리를 끌어올 이유가 줄어듭니다.
문자열을 넘겨도 *가 숫자로 바꿔 주어 오류 없이 값이 나옵니다. 타입이 틀린 입력이 조용히 통과해 뒤늦게 이상한 결과로 드러납니다.
10lazy iterator
느긋한 계산과 달리 이 코드는 곧바로 새 상태 객체를 만들어 출력합니다. 갱신 단위마다 스냅숏을 남기고 싶을 때 쓰는 형태입니다. state가 const여도 재할당만 막힐 뿐 속성 변경은 가능해서, 진짜 고정이 필요하면 Object.freeze가 따로 있어야 합니다.
JavaScript code
// lazy iterator
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);느긋한 처리를 제너레이터로 만들면 중간 배열 없이 필요한 만큼만 계산해, 앞의 몇 개만 쓰는 경우 나머지 작업을 아예 하지 않습니다.
Object.freeze(state)를 걸어도 state.tags는 얼지 않아 push가 그대로 먹힙니다. 깊은 동결은 재귀로 직접 걸어야 합니다.
11패턴 분기
각 단계가 조건 분기 없이 값을 그대로 변환하며 11에서 24까지 나아갑니다. 분기를 넣고 싶다면 단계 함수 안에서 판단해 다른 값을 돌려주면 됩니다. reduce는 중간에 멈출 수 없어, 조기 종료가 필요한 분기에는 for...of가 맞습니다.
JavaScript code
// 패턴 분기
const steps = [(value) => value + 1, (value) => value * 2];
console.log(steps.reduce((value, step) => step(value), 11));분기를 단계 안에 감추는 대신 조건에 맞는 단계만 골라 배열을 만들면, 실제 실행 경로가 배열 내용으로 그대로 드러나 추적하기 쉬워집니다.
reduce는 도중에 빠져나올 수 없어, 결과가 정해진 뒤에도 남은 단계를 모두 훑습니다. 조기 종료가 필요하면 for...of로 바꿔야 합니다.
12reducer 상태 누적
yield가 실행을 멈췄다 재개하는 덕분에 함수 안의 지역 변수가 호출 사이에도 살아남습니다. 리듀서처럼 상태를 이어 가는 계산을 짤 때 이 성질을 씁니다. 다만 여기서는 결과를 배열로 모으므로 세 값이 한꺼번에 메모리에 올라옵니다.
JavaScript code
// reducer 상태 누적
function* numbers() { yield 1; yield 2; yield 12; }
console.log([...numbers()]);제너레이터 안의 지역 변수는 yield로 멈춘 사이에도 유지되므로, 바깥 상태 없이 누적 계산을 함수 안에 담아 둘 수 있습니다.
결과를 전개해 배열로 만드는 순간 지연 계산의 이점이 사라지고 모든 값이 메모리에 올라옵니다. 마지막 값만 필요하면 순회하며 갱신하는 편이 낫습니다.
13all predicate
모든 원소가 조건을 만족하는지 보는 대신, addTax가 130을 받아 10퍼센트가 더해진 값을 돌려줍니다. 화살표 함수 본문이 표현식 하나라 return 없이 값이 나갑니다. 이런 함수는 자기 this를 갖지 않아 객체 메서드로 붙일 때 주의해야 합니다.
JavaScript code
// all predicate
const addTax = (price) => price * 1.1;
console.log(addTax(130));every는 빈 배열에 항상 참을, some은 항상 거짓을 돌려줍니다. 이 규칙을 모르면 목록이 비었을 때 판단이 통째로 뒤집힙니다.
화살표 함수를 객체 메서드로 붙이면 this가 객체가 아니라 바깥 범위를 가리켜, 형제 속성을 읽으려 할 때 undefined가 나옵니다.
14flatMap
tags 배열을 펼쳐 새 항목을 덧붙이고 나머지 속성은 복사합니다. 평탄화가 필요할 만큼 중첩이 깊어지면 flatMap이나 헬퍼를 끌어오지만, 여기서는 한 단계 갱신이라 전개만으로 충분합니다. 키가 겹치면 뒤에 쓴 값이 앞의 값을 덮습니다.
JavaScript code
// flatMap
const state = { page: 1, tags: ["x"] };
const next = { ...state, page: state.page + 1, tags: [...state.tags, "y"] };
console.log(next);flatMap의 콜백이 빈 배열을 돌려주면 그 항목은 결과에서 사라져, 걸러 내기와 변환을 한 번의 순회로 함께 처리할 수 있습니다.
전개로 객체를 합칠 때 같은 키가 겹치면 뒤에 쓴 값이 앞을 덮습니다. 기본값을 뒤에 두면 사용자가 지정한 값이 오히려 지워집니다.
15함수 값을 반환하는 함수
배열에 담긴 익명 함수들이 값 하나를 넘겨받아 다음 함수로 전달하고, 마지막에 32가 남습니다. 함수를 값처럼 담고 꺼내 쓸 수 있어야 이런 목록이 성립합니다. 화살표 함수는 arguments 객체를 만들지 않아 선언한 매개변수로만 값을 받습니다.
JavaScript code
// 함수 값을 반환하는 함수
const steps = [(value) => value + 1, (value) => value * 2];
console.log(steps.reduce((value, step) => step(value), 15));함수를 반환하면 바깥 인자가 클로저에 갇혀 유지되므로, 설정을 한 번만 받아 두고 이후에는 데이터만 넘기는 구조를 만들 수 있습니다.
루프 안에서 만든 함수가 var로 선언한 변수를 참조하면 모두 마지막 값을 봅니다. let은 반복마다 새 바인딩을 만들어 이 문제를 없앱니다.
정리하며
- compose에 넣을 조각은 curry나 partial로 단항 함수로 만들어 둡니다
- 스프레드 복사는 한 겹만 새것이므로 중첩 구조는 경로별로 갱신합니다
- 제너레이터 기반 lazy 시퀀스는 일회용이니 재사용 전에 배열화합니다
- Maybe와 Result는 null 체크와 try 블록을 값 흐름으로 바꿔 줍니다
더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.