함수형 패러다임의 꽃: 함수 합성(composition)

함수형 패러다임에서 최우선 설계 원칙으로 삼아진다고 하는 함수들의 합성에 대해서 설명한다.

합성

이전에 설명했던 compose 함수를 말한다.

1
2
3
4
const compose = (...fns) =>
fns.reduce((f, g) => (...args) =>
f(g(...args)),
);

합성은 왜 하는걸까?

프로그램을 간결하고 실용적으로 작성할 수 있게 한다. 합성이 되므로 함수를 부담 없이 나눌 수 있게 되어 더 작고 의미있는 단위의 함수를 더 편하게 작성할 수 있다. 이렇게 합성된 함수는 가독성이 좋다. 아무래도 객체지향 패러다임을 강하게 지원하는 언어들에선 함수 합성이 쉽지 않다. 애초에 순수 함수를 작성하기도 쉽지 않다. public static으로 도배할 순 없기 때문이다.

합성함수의 결합법칙

함수 합성은 수학에서의 합성함수와 같이 결합법칙이 성립한다. compose(f, compose(g, h)) === compose(compose(f, g), h)가 성립한다. Javascript 상에서 생성되는 함수가 동일하다는 것이 아니라, 그 실행 결과가 언제나 같다는 뜻이다.

결합법칙이 무슨 소용일까

합성한 함수들을 재귀적으로 합성한 경우, 결합법칙을 적용하면 결과 예측과 리팩토링 시에 유용하다.

그 예로, 아래 세가지 loudLastUpper 함수는 동일하다. 더 작고 더 의미있는 함수로 정의할수록 재사용성과 가독성은 높아진다.

버전 1

1
2
3
4
5
6
const loudLastUpper = compose(
exclaim,
toUpperCase,
head,
reverse,
);

버전 2 (리팩토링)

1
2
3
4
5
6
const last = compose(head, reverse);
const loudLastUpper = compose(
exclaim,
toUpperCase,
last,
);

버전 3 (리팩토링)

1
2
3
const last = compose(head, reverse);
const angry = compose(exclaim, toUpperCase);
const loudLastUpper = compose(angry, last);

쓸모있고 재미있는 디버깅 방법

합성 함수를 디버깅하는 재밌는 방법이 있다. 흔히 trace라 부르는 유명한 함수인데, 항등함수(const pass = x => x;)에 console.log만 추가한 함수이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const trace = (tag) => (x) => {
console.log(tag, x);
return x;
};

// 자동 커리
const trace = curry((tag, x) => {
console.log(tag, x);
return x;
});

// 사용 예시
const toDebug = compose(
replace,
trace('after A'),
applyA,
trace('after B'),
applyB,
trace('after last'),
last,
);

당연하게도 trace 함수는 순수하지 않다. console를 사용하기 때문이다.

간단한 함수 합성 예제

이하의 예제 코드는 아래의 cars 객체를 대상으로 한다.

1
2
3
4
5
6
7
8
9
[
{
name: 'Aston Martin One-77',
horsepower: 750,
dollar_value: 1850000,
in_stock: true,
},
//...
];

예제 1

각 함수들의 정의는 이 문서를 참고하라. 이 문서는 ramdajs documentation과도 호환된다.

1
2
3
4
5
6
7
8
9
10
const isLastInStock = (cars) => {
const lastCar = last(cars);
return prop('in_stock', lastCar);
};

// after compose:
const isLastInStock = compose(
prop('in_stock'),
last,
);

예제 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const average = (xs) =>
reduce(add, 0, xs) / xs.length;

const averageDollarValue = (cars) => {
const dollarValues = map(
(c) => c.dollar_value,
cars,
);
return average(dollarValues);
};

// after compose:
const averageDollarValue = compose(
average,
map(prop('dollar_value')),
);

예제 3

1
2
3
4
5
6
7
8
9
10
11
12
13
const fastestCar = (cars) => {
const sorted = sortBy((car) => car.horsepower);
const fastest = last(sorted);
return concat(fastest.name, ' is the fastest');
};

// after compose:
const fastestCar = compose(
append(' is the fastest'),
prop('name'),
last,
sortBy(prop('horsepower')),
);

함수 합성 예제 프로그램

스펙

  1. 검색어에 대응하는 URL을 생성한다.

  2. flicker API를 호출한다.

  3. 결과 JSON에서 이미지 링크를 추출한다.

  4. 이미지를 HTML에 표시한다.

구현 코드

예제의 스펙에서 보았듯, 2단계 API 호출과 4단계 이미지 표시는 순수하지 않다. 일단 순수하지 않은 함수를 같이 사용하면서 예제를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 유틸 함수 선언
const prop = curry((p, obj) => obj[p]);

// 순수하지 않은 함수
// Impure 객체로 접근하도록 하여 사용자에게 주의를 준다.
const Impure = {
getJSON: curry((callback, url) =>
$.getJSON(url, callback),
),
setHtml: curry((sel, html) =>
$(sel).html(html),
),
};

/*
참고: 서버의 응답이 아래와 같은 형태로 구성됨
{
...
items: [
{
...
media: {
m: '<image-link>'
}
},
{ ... },
{ ... },
]
}
*/
const host = 'api.flicker.com';
const path = '/services/feeds/photos-public.gne';
const query = (t) =>
`?tags=${t}&format=json&jsoncallback=?`;
const url = (t) =>
`https://${host}${path}${query(t)}`;

const mediaUrl = compose(
prop('m'),
prop('media'),
);

const mediaUrls = compose(
map(mediaUrl),
prop('items'),
);

const img = (src) => `<img src="${src}" />`;

const render = compose(
Impure.setHtml('#root'),
map(img),
mediaUrls,
);

const app = compose(Impure.getJSON(render), url);

app('cat');

compose와 map 리팩토링

아주 간단한 리팩토링이다. 같은 배열에 대해 map을 여러 번 실행하기보다, 순서를 유지한 채로 매 원소에 대해 map할 함수를 합성해서 한 번에 실행하게 되면 반복 횟수를 줄일 수 있다.

1
2
3
4
// from
compose(map(img), map(mediaUrl));
// to
compose(map(compose(img, mediaUrl)));

참고

mostly-adequate-guide (EN)

순수함수와 curry 함수

이번 글은 쉽다(?). 순수함수에 대해 이론적으로 다루고, curry 함수를 소개한다. 다만 객체지향과의 비교, 테스트와 설계에 대한 내 생각을 공유하므로 지식이 없으면 어렵게 보일 수도 있다.

순수함수?

순수함수는 수학에서 정의하는 함수와 동일하다. 입력에 대한 출력이 항상 동일하고, 입력에 대한 출력이 항상 1가지이다. 이게 가능하기 위해선 DB, HTTP, 현재 시간 등에 의존하면 안 된다! 함수 외부의 것과 함수 내용이 전혀 연관이 없어야 한다.

부원인과 부작용

영어권에서 흔히 side-effects라고 얘기하는 부수효과부작용은 함수 밖의 코드의 상태에 영향을 주는 일을 말한다. 부수효과를 크게 부작용부원인으로 구분할 수 있다. 부작용은 숨겨진 출력이고, 부원인은 숨겨진 입력이라고 생각하면 된다. 왜 외부의 상태와 상호작용하면 안 될까? 궁금하면 계속 읽어야 된다.

숨겨진 입력

숨겨진 입력이라고 하면 뭐가 있을까? Javascript와 같이 객체지향 패러다임을 지원하는 언어의 경우는 this가 항상 함수에 전달된다. this도 숨겨진 입력이다. 또한 함수 내부에서 new Date() 등의 코드로 현재 시간에 의존하는 경우도 숨겨진 입력이라고 할 수 있다. 둘 모두 외부의 상태를 변경하기 때문이다.

숨겨진 출력

숨겨진 출력은 함수를 실행했을 때 바뀌는 모든 것이라고 할 수 있다. 순수함수 내에서는 어떤 외부의 상태도 변할 수 없으므로, 어떤 외부의 상태가 조금이라도 변경된다면 그 함수는 순수하다고 할 수 없다.

부수효과는 복잡성 빙산

왜 외부의 상태와 상호작용하면 안 될까?

순수함수가 아닌 함수의 Signature는 프로그래머가 읽더라도, 심지어 객체지향 언어의 설계 방식대로 설계했더라도 무슨 부수효과가 일어날지 알 수 없다.

캡슐화는 좋은 규칙이지만 그 구현 코드를 읽기 전까지 부수 효과를 정확히 알 순 없다.

부수효과가 왜 복잡성 빙산일까? 프로그래머가 예상한 그대로 동작하지 않는 경우 논리적 버그의 원인이 되기 때문이다. 부수 효과는 해당 코드 혹은 해당 코드와 간접적으로 연관이 있는 코드를 수정했을 때 바뀌기 또한 쉽고, 바뀌었을 때 작동하지 않게 될 확률도 높다. 그래서 객체지향 방식으로 설계를 하는 경우 회귀테스트를 그렇게 많이 작성해야 하나 보다. 응집성과 캡슐화를 생각해서 상태 의존적이고, 변경 시 서로의 영향을 받아서 깨지기 쉽기 때문이다.

그래서 함수형 패러다임에서는 공유 자체를 하지 않는 방향으로 설계하도록 지향한다. 그 결과가 순수함수이다.

순수함수가 아니면 테스트하기 힘들다

어떤 함수가 부수효과가 있는 경우 이미 그 함수는 다른 코드랑 최소한 1번은 엮여 있을 수 밖에 없다. 덕분에 그 함수를 테스트하기 위해서는 다른 코드까지 테스트할 수 밖에 없고, 이 과정에서 Blackbox Testing이 불가능해진다. 구현 상세에 외부 코드와의 연관이 존재하기 때문이다. 이 과정은 객체지향 언어로 작성한 경우 자주 발생하며 덕분에 Mock을 자주 사용하게 된다. 또한 테스트 자체도 구현 상세의 변경에 취약하게 된다.

부수효과를 제거하기, 제거했을 때의 장점

부수효과를 제거하려면 순수함수를 만들고 사용하면 된다.

모든 부작용, 부원인은 숨겨진것이기에 이를 Signature에 명시하면 된다. 이렇게 명시하는 것은 객체지향 언어에서는 응집성과 캡슐화를 위해 구현 상세로 분류하여 함수 안에 전부 집어넣는 등 지양하는 편이지만, 함수형 패러다임에서는 권장된다. 덕분에 덜 복잡해지고, 훨씬 테스트하기 쉬워지며, 추론이 훨씬 쉬워지기 때문이다.

부수효과를 완전히 제거할 수는 없다.

아무래도 웹 등 실세계의 애플리케이션은 함수 내의 수식을 한 번 계산하고 종료하는 게 목적이 아니라, 부수효과로 불리는 것들 대부분을 사용하여 목적을 달성할 수 밖에 없다. 함수형 패러다임은 이런 한계를 인정하고, 가능한 모든 곳에서 부수효과를 제거하고, 제거할 수 없을 땐 강력히 통제한다.

순수함수의 조합과 재사용성

순수함수는 그 자체의 명료함 덕분에 재사용성과 조합이 굉장히 쉽고, 많이 조합하더라도 쉽게 그 결과가 예측 가능하다. 특히 한 번에 풀 수 없는 크고 복잡한 문제를 쪼개서 작은 함수의 조합으로 해결할 수 있다. 앞서 만들어 놓은 산출물을 쉽게 조합하여 새로운 문제를 해결할 수 있게 되고, 생산성도 비약적으로 늘어난다.

순수함수에 대한 간단한 사실들

  1. 순수함수는 수학의 함수와 동일한 정의를 갖는다.

  2. 순수함수는 (input, output) 쌍이므로 객체로도 표현 가능하다. (key,value 쌍)

  3. 순수함수는 항상 캐시 가능하다.

  4. 순수함수는 필요한 건 다 전달받는다(dependency injection)

  5. 동시성 문제가 적거나 없다. 공유하는 메모리가 없기 때문이다.

curry 함수

2번째 글에서 currying을 이미 다루었다. 그 때의 currying은 프로그래머가 함수에 대해 직접 curry한 방식이고, 이번에는 어떤 함수에 대해 알아서 curry된 함수를 반환하는 함수를 소개한다.

curry 함수는 함수를 받아, 인자가 완전히 전달되지 않은 경우 남은 인자를 받을 함수를 반환한다. curry 함수의 구현은 function.lengthbind, apply를 사용하는 게 핵심이다.

1
2
3
4
5
6
7
8
9
10
11
function curry(f) {
const len = f.length;
return function $curry() {
if (arguments.length < len) {
// 원래 함수의 매개변수의 갯수보다 $curry에 전달된 매개변수의 갯수가 작은 경우.
return $curry.bind(null, ...arguments); // $curry에 계속 전달받은 매개변수들이 bind 된다. (arguments가 계속 쌓인다.)
} else {
return f.apply(null, arguments); // 실제 함수 호출.
}
};
}
1
2
3
4
5
6
7
8
9
10
// 예시
const add = (a, b, c) => a + b + c;
const addC = curry(add);
const add1 = addC(1);
const add1and2 = add1(2);
const add1and2and3 = add1and2(3);
console.log(addC(1, 2)); // function $curry
console.log(add1(2)); // function $curry
console.log(add1and2and3); // 6
console.log(add1and2(3) === add1and2and3); // true

참고

ES6 bind 함수 (KO)

함수형 자바스크립트 기본

함수형 패러다임을 자바스크립트에 적용할 때에 알면 좋은 Javascript의 기본 문법과 배경 지식을 설명한 글이다.

자바스크립트와 타 언어들간의 함수형 프로그래밍 지원의 차이

Javascript에는 없는 함수형 언어의 특징

  1. 순수 함수 강제 (side effect를 발생시키는 표현식을 허용하지 않음)
  2. 불변성 강제 (변수라고 부르지만, 상수. 객체까지도.)
  3. 재귀 강제 (반복문 미지원)

아무래도 Javascript는 멀티 패러다임이라 함수형 패러다임만 지원하는 언어에 비해서는 제약이 덜할 수 밖에 없을 것이다.

Javascript에서 이를 극복하는 방법

  1. eslint 규칙 사용으로 부분적으로 극복.
  2. ImmerJS 등의 라이브러리 도입
  3. 함수형 프로그래밍에 익숙해진다면, 괜찮을 것. (1과 마찬가지로 eslint 규칙 도입 등.)

Javascript 런타임들이 대부분 꼬리 재귀 호출 최적화를 지원하지 않기 때문에, 재귀가 타 함수형 언어에 비해 성능이 낮을 수 밖에 없다.

꼬리 재귀 호출 최적화는 재귀 호출이 함수의 마지막에서 발생하는 경우에 적용된다. 컴파일러가 자동으로 재귀를 반복문으로 치환한다. 덕분에 스택 프레임을 1개만 사용한다.

Javascript의 장점

  1. 다른 모든 함수형 언어는 학습 곡선이 높다. 누구나 이해하고 사용할 수 있다고 하기 힘들다.
  2. ES6+부터 함수형 지원이 좋은 편이다.
  3. 타 함수형 언어들에 비해 시장이 크고, Production-level Application 구축이 용이하다.

함수 실행(call, apply)과 인자(arguments), 점(.) 다시 보기

Javascript의 함수 안에서는 arguments 객체와 this 키워드를 사용할 수 있다.

※ 화살표 함수에서는 arguments를 사용할 수 없다.

1
2
3
Uncaught ReferenceError: arguments is not defined
at hi (<anonymous>:1:37)
at <anonymous>:1:1

arguments 객체

배열과 유사한 Arguments 객체(Arguments(4) [ ‘a’, ‘b’, ‘c’, ‘d’, … ])로 매개변수들이 전달된다.

arguments에 접근하는 시점에 따라 값이 변경될 수 있다. Javascript의 parameter는 변경할 수 있기 때문에, 이를 변경 후 arguments를 찍어보면 다르게 나온다.

1
2
3
4
5
function hello(a, b) {
a = 1;
console.log(arguments);
}
hello('a', 'b'); // Arguments [1, 'b'];

this 객체

obj.prop()으로 호출 시 objthis가 된다. . 좌측의 객체가 항상 this가 된다.

최상단 scope에서 호출하면 기본적으로 window., global.이 생략된 것이기 때문에 thiswindow, global이 된다. (global은 Node.js 환경에서.)

const { prop } = obj; prop(); 하면 propobj에 속해있음에도 불구하고 thiswindow, global이 된다.

Function.prototype.call

this 객체를 지정해서 함수를 호출할 수 있다.

1
2
3
4
5
6
7
8
const obj = {
thisIs: 'obj',
};
function hello(a, b) {
console.log(this);
}
// 매개변수는 가변 매개변수여서 개수 제한 없이 전달 가능하다.
hello.call(obj, 'a', 'b'); // { thisIs: "obj" }

Function.prototype.apply

call과 다른 점은 매개변수를 배열과 유사한 객체로 넘겨야 한다는 점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const obj = {
thisIs: 'obj',
};
function hello(a, b) {
console.log(this);
}
// 매개변수는 배열과 같은 객체로 전달하면 된다.
hello.apply(obj, ['a', 'b']); // { thisIs: "obj" }
hello.apply(obj, {
0: 'a',
1: 'b',
length: 2,
}); // { thisIs: "obj" }

함수를 부분 실행하는 currying 개념

사용 예시 1

1
2
3
4
5
6
7
8
<Field
name="username"
onChange={handleChange('username')}
/>
<Field
name="password"
onChange={handleChange('password')}
/>

함수 본문 1

1
2
3
const handleChange = (fieldName) => (value) => {
fields[fieldName].value = value;
};

함수 본문 1 해설

함수 handleChangefieldName를 받아 새로운 함수를 반환한다. 반환한 함수에 value를 전달하는 경우, fields 객체에서 fieldName에 해당하는 프로퍼티에 value를 전달받은 value로 갱신한다.

사용 예시 2

1
2
3
4
5
6
const createForm = createElHtml('form');
const createInput = createElHtml('input');

const form = createForm();
const idInput = createInput('name="username"');
form.innerHTML = idInput;

함수 본문 2

1
2
3
4
const createElHtml = (tag) => (
arrtibutes = '',
children = '',
) => `<${tag} ${arrtibutes}>${children}</${tag}>`;

사용 예시 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const toBoolean = (x) => !!x;
const double = (n) => n * 2;
const toNumber = (str) =>
Number.parseInt(str, 10);
const composedFunction = compose(
toBoolean,
double,
toNumber,
); // toNumber -> double -> toBoolean 순으로 실행된다.
// 매개변수는 composedFunction에 전달된 것부터
// 각 함수의 반환값이 다음 함수의 매개변수가 된다.

composedFunction('0'); // false
composedFunction(''); // false
composedFunction(); // false
composedFunction('hello'); // false
composedFunction('2'); // true

함수 본문 3

1
2
3
4
const compose = (...functions) =>
[...functions].reduce((f, g) => (...args) =>
f(g(...args)),
);

함수 본문 3 해설

  • compose(f,g,h)(1) => f(g(h(1)))의 형태로 실행한다.
  • compose 내부에선 functions를 배열에 넣어 reduce를 호출한다. Array.reduce(result, currentvalue) => result; 이다. reduce에게 (f, g) => (...args) => f(g(...args))를 인자로 넘겨준다.
  • 단계별 흐름도
    • (f, g) => (...args) => f(g(...args)) // f, g
    • ((...args) => f(g(...args)), h) => ((...args) => f(g(h(...args)))) // f,g 합성함수, h 함수

함수형 프로그래밍과의 연관성

  1. currying함수를 인자로 받게 만들 수도 있고, 함수가 아닌 값을 받게 만들 수도 있다. 두 방법 모두 유용하다.
  2. currying은 마지막으로 반환되는 경우를 제외하면 대부분 계속해서 함수를 반환한다. 이는 함수간의 조합이 쉽다.

참고

  1. JavaScript ES6 curry functions with practical examples (EN)

  2. Learning Javascript Courses: ES6 Curry (EN)

  3. What is the advantage of currying? (EN)

실행 결과를 재사용하는 함수 skip

사용 예시

1
2
3
4
5
<ul id="list">
<li>Item 1<button>삭제</button></li>
<li>Item 2<button>삭제</button></li>
<li>Item 3<button>삭제</button></li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const askOnRemove = skip(() =>
confirm(
'정말 삭제하시겠습니까? 최초 1회만 확인합니다.',
),
);

const list = document.getElementById('list');
list.addEventListener('click', (e) => {
const {
nodeName,
parentNode: targetItem,
} = e.target;
const { parentNode } = targetItem;
nodeName === 'BUTTON' &&
askOnRemove() &&
parentNode.removeChild(targetItem);
});

함수 본문

1
2
3
4
5
6
function skip(callback) {
let result; // 클로저 영역
return function (...args) {
return result || (result = callback(...args));
};
}

함수 본문 해설

함수 skipcallback를 받아 실행하고, 만약 그 함수의 반환값이 truthy이면 해당 값을 반환하고, 이후에는 함수를 실행하지 않는다.

함수형 프로그래밍과의 연관성

  1. 함수형 자바스크립트는 함수를 N 단계로 조합해서 사용한다. 즉, 고차 함수 응용의 반복이다.
  2. skip은 고차 함수이고, callbackskip이 남겨 놓은 로직을 완성하는 함수다.
  3. 예시로 사용된 askOnRemove는 클로저인데, 함수로 만들어진 함수는 대부분 클로저다.

고차 함수란?

함수를 인수로 사용하거나 함수를 반환하는 함수이다.

예시:

  1. const filter = (predicate, xs) => xs.filter(predicate) // 함수가 인자
  2. const is = (type) => (x) => Object(x) instanceof type // 함수를 반환
  3. filter(is(Number), [0, '1', 2, null]) // [0, 2]