올해 처음으로 책을 읽었습니다. 작년 여름에 집 주변에 있는 도서관에서 우연히 발견한 책입니다. 호기심으로 몇 장 읽다보니 내용이 흥미로워서 나중에 한 번 읽어야겠다는 생각만 갖고 있었다가, 이번 기회에 완독했습니다.
함수형 프로그래밍이라는 단어를 들어보기만 하신 분도 계실 것 같습니다. 코드가 깔끔해진다느니, 부수 효과(side effect)가 없다느니, 객체 지향 프로그래밍(OOP)의 단점을 극복할 수 있다느니… 주변 얘기를 들어보자면 아주 호평일색(?)입니다만, 나만 모르는 마법 같은 소리를 듣고 있자니 덜컥 겁이 나기도 하죠.
“뭐지? 나 뒤쳐진건가?”
그렇다고 해서 호기심에 잠깐 검색을 해보면… 이상하고 어려운 단어들이 튀어 나오니 혼란스럽기만 합니다.
사실 함수형 프로그래밍은 람다 대수(lambda calculus)라는 수학 이론에서 비롯된 새로운 프로그래밍 패러다임입니다. 패러다임의 변화는 지금껏 갖고 있었던 사고 방식을 처음부터 뜯어고쳐야 한다는 것을 의미하죠. 그러다보니 용어가 조금 낯선 면이 있습니다.
하지만 이미 많은 프로그래밍 언어에서 함수형으로 코드를 작성할 수 있는 API가 이미 있습니다. JavaScript는 ES6 버전부터 지원하고요. 만약 여러분이 React를 써보신 경험이 있다면 이미 함수형 프로그래밍을 경험해 본 셈입니다.
그럼에도 불구하고 함수형 프로그래밍에 대한 본질을 바닥부터 파헤친 건 이번이 처음이다보니… 책을 읽으면서도 이해하기 힘든 내용을 종종 마주치곤 했습니다. 그래서 조금씩 파트를 나눠 읽다보니 시간이 꽤 걸렸습니다. 결국 반납 기한을 꽤 넘겨서야 다 읽었네요. 😇
이번 포스트를 통해 JavaScript를 이용한 함수형 프로그래밍에 관심이 있는 개발자분들에게 도움이 되었으면 좋겠습니다.
책 소개
원서의 제목은 『Functional Programming in JavaScript』 입니다. 즉, 이 책의 목표는 JavaScript를 이용해 함수형 프로그래밍의 개념과 응용을 빠르게 학습하는 것입니다.
따라서 관련된 수학 이론까지 깊게 공부하지는 않지만, ES6 이후 JavaScript와 lodash, ramda.js 등의 함수형 라이브러리에 대한 이해가 어느정도 있으면 이해하기가 쉬울 것 같습니다.
책 내용은 크게 3부로 구성되고, 전체 책 분량은 300쪽 정도로 부담스러울 정도는 아닙니다.
- 함수형으로 사고하기: 함수형 자바스크립트의 개념을 소개하고 함수형으로 사고하기 위해 꼭 필요한 핵심을 짚습니다.
- 함수형으로 전환하기: 함수 체인, 커링, 합성, 모나드 등 함수형 프로그래밍의 핵심 기법을 집중 조명합니다.
- 함수형 스킬 갈고 닦기: 함수형 프로그래밍을 실무에 응용하면 어떤 점이 좋은지 알아봅니다.
함수형으로 사고하기
책에서 이야기하는 함수형 프로그래밍의 정의를 먼저 읽어봅시다.
외부에서 관찰 가능한 부수 효과가 제거된 불변 프로그램을 작성하기 위해 순수함수를 선언적으로 평가하는 것
- 38p
뭔가 한 번에 이해하기 쉬운 내용은 아니지만… 여기에서 몇 가지 포인트를 잡아봅시다.
- 불변 프로그램
- 순수함수
- 선언적
이 내용을 이해하기 위해서는 함수형 프로그래밍을 이루는 특징 3가지를 먼저 알아볼 필요가 있습니다.
선언적 프로그래밍
함수형 프로그래밍은 선언적(declarative) 프로그래밍 패러다임에 속합니다. 즉 컴퓨터에게 원하는 작업을 어떻게 수행하는지를 상세히 이르기보다는, 내부적인 제어 흐름이나 상태 변화를 밝히지 않으면서 로직을 표현식(expression)으로 나타냅니다.
var array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
// 명령형, 절차적 프로그래밍
for (let i = 0; i < array.length; i++) {
array[i] = array[i] * 2;
}
// 함수형 프로그래밍
array.map(function (value) {
return value * 2;
});
위 코드를 잠깐 살펴볼까요? 반환값이 다르긴 하지만 두 코드 모두 배열의 각 원소에 2를 곱하는 코드입니다. 명령형 프로그래밍에서는 루프를 순환하면서 그것을 원본 배열에 적용하는 로직이 상세히 적힌 반면, 함수형 프로그래밍에서는 각 요소를 처리하는 방법만 함수로 기술했을 뿐 그 처리 방법에 대해서는 개발자가 신경쓰지 않아도 되죠.
특히 ES6부터 등장한 화살표 함수(arrow function)를 이용하면 인수로 전달된 익명 함수를 대체하여 람다 표현식(lambda expression)으로도 나타낼 수 있습니다. 코드의 길이가 더 짧아지면서 이해하기 쉬운 것을 확인할 수 있죠.
array.map((value) => value * 2);
ES6에서는 map
이외에도 reduce
, filter
와 같은 함수형 메서드를 제공하고, 이를 이용하면 루프를 완전히 대체할 수 있습니다. 이러한 함수형 메서드에서 공통적으로 나타나는 특징은 바로 함수를 인수로 받는다는 것입니다.
// 함수의 인수에 또 다른 함수를 넣었다
array.map((value) => value * 2);
array.filter((value) => value % 2 == 0);
array.reduce((acc, cur) => acc + cur);
이처럼 함수를 인수로 전달하거나, 함수를 반환하는 함수를 고계 함수(high-order function) 이라고 합니다.
순수함수
함수형 프로그래밍은 순수함수(pure function) 로 구성됩니다. 함수가 순수(purity) 하다는 것은 다음의 특징을 가졌다는 것을 의미합니다.
- 주어진 입력에만 의존할 뿐, 평가 도중 또는 호출 간 변경될 수 있는 숨겨진 값이나 외부 상태와 무관하게 동작
- 전역 객체나 레퍼런스로 전달된 매개변수를 수정하는 등 함수 스코프 밖에서 어떠한 변경도 일으키지 않음
- 동일한 입력을 받았을 때 동일한 출력을 반환하는 참조 투명성(referential transparency)을 지님
따라서 아래 함수는 순수함수가 아닙니다.
// 함수 외부 스코프(전역 변수)에 의존, 외부 값의 변경을 일으킴
var counter = 0;
function increment() {
return ++counter;
}
// 호출 시마다 일정한 결과값을 반환하지 않음
function now() {
return Date.now();
}
위의 increment()
함수를 순수함수로 만들면 다음과 같은 모양이 됩니다.
// 주어진 입력에만 의존하며, 변경을 일으키지도 않고,
// 동일한 입력을 받았을 때 동일한 결과를 출력함
var increment = (counter) => counter + 1;
이를 정의역과 치역으로 표현하는 매핑 다이어그램으로 나타내면, 순수함수의 특징을 더 잘 이해할 수 있습니다.
매핑 관계를 보자. 동일 입력은 몇 번이고 호출하더라도 동일 출력.
순수함수를 사용한 코드는 전역 상태를 바꾸거나 깨뜨릴 일이 전혀 없으므로, 테스트나 유지보수가 더 쉬운 코드를 개발하는 데 도움이 됩니다.
데이터는 불변
함수형 프로그래밍에서 한 번 선언한 데이터는 불변(immutable) 해야 합니다. 즉 원본 변수의 값을 수정하거나 내부 속성을 변경하는 것은 허용되지 않습니다.
하지만 JavaScript는 기본적으로 동적 언어이기 때문에, 불변 데이터 관리에 대한 언어적 지원은 미흡한 편입니다. 즉 아래의 예시처럼 함수의 인수로 전달했을 때 원본 인수의 값을 변경하는 일이 발생할 수도 있습니다.
// 새 배열을 반환하는 것이 아니라 원본 배열을 수정한다
var sorted = (arr) => arr.sort();
JavaScript에서는 불변 데이터를 흉내내기 위해 상수 레퍼런스(const
)를 쓰거나, 클로저 패턴, Object.writable
이나 Object.freeze
와 같은 메타 속성을 제어하는 방식을 사용할 수 있습니다만… 내부 속성 수정이라던가 재귀 객체 등의 문제를 완전히 해결하기 위해서는 추가적인 코드를 작성해야만 합니다.
JavaScript에서 함수는 일급이면서 고계
JavaScript는 객체 지향 프로그래밍 언어이면서 동시에 함수형 프로그래밍 언어입니다. 그렇기 때문에 두 패러다임의 장점을 멋지게 섞어 쓸 수 있죠.
JavaScript에서 함수형 프로그래밍을 이용할 수 있는 이유에는 사실 두 가지 비밀이 숨어있습니다. 바로 JavaScript의 함수가 일급이면서 고계라는 특성 때문인데요, 여기에 대해 자세히 알아봅시다.
프로그래밍 변수의 정의에 따라 JavaScript에서 함수는 일급(first-class), 또는 일급 시민(first-class citizen) 에 속합니다. 이게 처음 들었을 때 좀 의아한 이름(?)이긴 한데, 쉽게 말해서 다음 특징을 가지면 됩니다.
- 변수의 값으로 사용되거나
- 함수의 인자로 넘길 수 있고
- 함수의 반환값으로 사용될 수 있는지
첫 번째 특징인 JavaScript 함수가 변수로 사용되는 것은 아래의 용법으로 확인할 수 있습니다.
// 함수는 그 자체로 변수에 담을 수 있습니다.
var add = (x) => x + 1;
// 사실 애초에 함수는 객체입니다.
var add = new Function('x', 'return x + 1');
또한 고계 함수가 가능하다는 점에서 나머지 두 특징이 성립합니다.
// 함수를 다른 함수의 인자로 넘길 수도 있습니다.
function do(f) {
return f();
}
// 함수를 반환값으로 쓰는 클로저 패턴을 쓸 수도 있습니다
function add5(x) {
const y = 5
return function(y) {
return x + y;
};
}
처음 언급했던 map
, reduce
, filter
를 생각해보세요. 위의 세 가지 특징들이 모두 드러나지 않나요? 특히 마지막 규칙을 활용한 클로저 패턴을 이용하면 정보 은닉, 모듈 개발 뿐만 아니라 함수에 원하는 기능을 매개변수로 넘기는 등 다양한 쓰임새를 활용할 수 있습니다.
함수형으로 전환하기
애플리케이션이 정답을 도출하는 데까지 거치는 경로를 제어 흐름(flow control) 이라고 합니다. 명령형 프로그래밍은 if
문을 활용한 분기와 for
, while
등의 루프로 제어하는 반면, 함수형 프로그래밍은 연속된 블랙박스 연산을 제어하는 방식을 씁니다.
이번에는 함수형 체인과 파이프라인을 이용해서 재사용 가능한 모듈적인 프로그램 조각을 연결하는 기법에 대해 알아보도록 하겠습니다.
체이닝
객체에 속한 여러 메서드를 단일 구문으로 연쇄 호출하는 객체 지향 패턴을 체이닝(chaining) 이라고 합니다. 단순 중첩 함수로만 작성한 코드는 가장 안쪽에 감싸진 함수부터 한 꺼풀씩 벗겨내는 방식으로 읽어야 하기 때문에 가독성이 훨씬 떨어집니다.
// 스트링 객체에 속한 메서드 체이닝
'Functional Programming'.toUpperCase().split('').reverse().join('');
// 만약 그냥 함수형로 썼다면 이렇게 됐을 걸...
join(reverse(split(toUpperCase('Functional Programming'), '')), '');
체이닝된 메소드를 수행할 때마다 반환값으로 결과물이 담긴 새 객체를 반환하기 때문에 이러한 연속 호출이 가능합니다.
파이프라인
체이닝 기법을 이용하면 구조적으로 짜임새가 있어지고 가독성이 좋아집니다. 하지만 객체에 값이 얽매여있기 때문에, 체이닝에서 실행 가능한 메서드 종류에 한계가 존재하기 때문에 코드 표현성이 줄어든다는 한계점도 존재합니다.
파이프라인(pipeline) 은 함수를 연결하는 또 다른 기법으로, 한 함수의 출력이 다른 함수의 입력이 되게끔 느슨하게 배열한 방향성(directional)이 있는 함수 순차열을 말합니다. 유닉스의 셸 프로그램을 떠올리면 이해가 빠를 것 같네요.
tr 'A-Z' 'a-z' < words.txt | uniq | sort
위 코드는 단어를 대문자에서 소문자로 바꾸고, 중복을 제거한 다음 그 단어를 정렬하는 셸 명령어입니다. 만약 이를 명령형으로 쓴다면 루프를 반복하며 문자열을 비교하고, 전역 변수로 중복 상태를 추적하는 조건문이 들어갔겠죠.
이처럼 각 단계를 이루는 함수를 일련의 순서로 합성(compose)하는 것이 파이프라인의 기본입니다.
// compose는 오른쪽 인자부터 출발하여 차곡차곡 결과값을 쌓아갑니다.
function compose(...functions) {
return function (arg) {
return functions.reduceRight((composed, f) => f(composed), arg);
};
}
// pipe는 왼쪽 인자부터 출발하여 차곡차곡 결과값을 쌓아갑니다.
function pipe(...functions) {
return function (arg) {
return functions.reduce((composed, f) => f(composed), arg);
};
}
compose(
// ...
reverse,
toUpperCase
)('Functional Programming');
체이닝이 객체 기반의 단단한 결합으로 인해 제한된 표현성을 지니는 반면, 파이프라인은 느슨한 결합을 바탕으로 한 유연성을 지니는 특징을 가집니다.
커링
커링(currying) 은 다변수(multi-variable) 함수가 인수를 받을 때까지 실행을 보류 또는 지연시켜 단계별로 나뉜 단항함수의 순차열로 전환하는 기법입니다. JavaScript에서는 커링 구현을 위해 클로저 패턴을 사용합니다.
const add = (a, b) => a + b;
// 일반적인 커링
function do(func) {
return function (x) {
return function (y) {
return func(x, y);
}
};
}
// 화살표 함수를 쓰면 이렇게도 가능합니다.
const do = (func) => (x) => (y) => func(x, y);
do(add)(1)(2)
커링으로 함수를 만들게 되면 모자란 인수가 채워지기를 기다리는 새로운 함수가 반환됩니다. JavaScript는 기본적으로 조급한 연산을 하지만 커링을 쓰게 되면 마지막 인수가 들어오기까지 전체 값을 구하지는 않기 때문에 부분적으로 느긋한 계산(lazy evaluation) 이 가능합니다. 이를 잘 활용하면 성능 상으로도 이점을 누릴 수 있습니다.
필요에 따라서는 커링에 필요한 인수 일부를 미리 채울 수도 있는데, 이는 부분 적용 함수(partial applied function) 라고 부릅니다. 이를 이용해 함수의 인수를 일부만 미리 평가할 수 있을 뿐만 아니라 함수 팩토리를 모방할 수도 있습니다.
안전한 예외 처리
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
arr
.map(makeError) // 여기에서 뭔가 예외 처리가 발생했을 때
.filter(isEven) // 아래에 연결된 함수에서는 어떻게 해야 안전하지?
.map(isOk);
만약 위와 같은 함수 체이닝이 있다고 생각해봅시다. 첫 번째 함수 호출 도중에 오류가 발생했다면 어떻게 해야 할까요?
객체 지향 방식으로 예외를 처리하면 호출자가 try
와 catch
로 예외를 붙잡아 처리해야 합니다. 예외가 발생하면 별도의 탈출구로 이동하기 때문에 함수가 순수하지 않을 뿐더러, 합성하기가 어렵습니다. 또한 예외 처리 구조가 아래처럼 중첩될 수 있습니다.
try {
try {
try {
// ...
} catch (e) {
// ...
}
} catch (e) {
// ...
}
} catch (e) {
// ...
}
함수형 프로그래밍에서는 하나의 참조 투명한 프로세스에서 가능한 변이를 모두 감싸는 패턴, 즉 값을 컨테이너화하는 방식으로 부수 효과 없이 코드를 작성할 수 있습니다.
함수자와 모나드
함수자(functor) 는 어떤 불안전한 값을 감싸는 컨테이너 역할을 하는 자료 구조입니다. 함수자 안에 값을 집어넣는다는 것을 리프팅(lifting, 또는 승급) 이라 부릅니다. 함수자는 외부 함수를 인자로 받아, 현재 리프트된 값을 적용한 결과값을 다시 새 함수자로 반환하는 매핑(mapping) 기능을 제공합니다. Array.map
역시 함수자의 개념과 일치합니다.
함수자가 이런 식으로 값을 감싸는 이유는 부수 효과가 있는 모든 함수를 안전하게 순수 함수로 만들기 위한 목적입니다. 쉽게 말해, 외부 함수 실행 과정에서 발생할 수 있는 예외 사항들을 컨테이너에서 미리 모형화해둡니다.
예외는 발생할 수도 있고 발생하지 않을 수도 있기 때문에 인터페이스를 동일하게 유지하되 이름만 다르게 유지합니다. 분기, 비동기 처리 등을 처리하기 위한 Maybe, Either, IO 등 여러 패턴이 이미 고안되어 있습니다.
모나드(monad) 는 이렇게 다양한 종류의 함수자가 중첩되는 경우를 깔끔하게 연결하기 위해 고안되었습니다. 즉 모나드는 함수자를 연결할 수 있게 만든 함수형 프로그래밍의 디자인 패턴입니다. 함수 실행 도중에 예외가 발생한다고 해도, 해당 예외는 함수자라는 컨테이너에 싸여 있기 때문에 예외를 따르면서 남은 함수들을 실행시킵니다. 이 모나드 덕에 함수 간 데이터가 안전하게 흘러가도록 조정하고 역할 자원을 추상화할 수 있습니다.
빈틈 없는 코드 만들기
테스트 코드는 안전한 프로그램을 만드는데 있어 필수적입니다. 그 중에서도 함수형 프로그래밍으로 작성된 코드는 특히 단위 테스트(unit test) 에 있어 아주 강력한 효과를 발휘할 수 있습니다. 이는 함수형 프로그래밍의 특징 중 하나인 참조 투명한 순수 함수가 단위 테스트의 특징인 격리성과 상당 부분 일치하기 때문입니다. 덕분에 함수형 코드의 테스트 코드 역시 평가 순서와 공유 자원에 의존하지 않을 수 있습니다.
함수형 최적화
함수형 코드는 동등한 결과를 내는 명령형 코드에 비해 더 느리게 실행되거나 더 많은 메모리를 점유하는 경우가 있습니다. 따라서 JavaScript의 언어적 특징을 적절히 고려한 최적화 방법을 적용해야 성능 상의 이점을 누릴 수 있죠.
먼저 JavaScript에서 함수형 코드가 명령형 코드에 비해 성능이 나빠질 수 있는 이유에 대해 잠깐 알아볼게요. JavaScript는 각 함수를 호출할 때마다 실행 컨텍스트가 프레임(frame) 이라는 단위로 호출 스택(call stack) 에 쌓이게 됩니다.
함수형 프로그래밍에서는 불변 데이터와 커링 및 재귀를 많이 쓰게 됩니다. 이것은 호출 스택 크기와 갯수에 영향을 주게 되는데 단일 쓰레드로 동작하는 JavaScript의 특성 상 스택 오버플로우를 일으킬 수도 있기 때문입니다.
따라서 함수형 라이브러리의 도움을 받아 느긋한 평가를 이용하거나, 함수 수준의 캐시를 위해 메모이제이션을 쓰거나, 재귀 함수를 호출할 때는 꼬리 재귀 호출 등의 기법 등을 적용할 수 있습니다.
비동기 이벤트와 데이터 관리
실제 웹 애플리케이션을 개발하다보면 비동기로 데이터를 가져오거나, 특정 이벤트를 처리해야 하는 경우가 생깁니다. 이러한 비동기 로직을 함수형으로 처리하기 위해 프로미스(Promise)나 제네레이터(Generator)를 쓸 수 있습니다.
프로미스를 이용하면 미래의 함수를 합성하고 체이닝할 수 있고 의존 관계가 형성된 코드의 저수준 로직을 추상화할 수 있습니다. 제네레이터를 이용하면 이터레이터를 활용한 느긋한 평가가 가능합니다.
이러한 비동기 이벤트를 구독 가능한 스트림으로 추상화한 것이 바로 리액티브 프로그래밍(reactive programming)이며, 이벤트를 처리하는 로직을 함수형으로 구현하면 함수형 리액티브 프로그래밍(functional reactive programming)이라는 패러다임으로 이어집니다. 함수형 리액티브 프로그래밍은 프로그램의 추상화 수준을 높여 이벤트를 논리적으로 독립된 단위로 다룰 수 있게 합니다.
후기
함수자랑 모나드 나오니까… 오 마이 갓
책 제목에서 볼 수 있듯이 JavaScript라는 언어의 특성을 깊게 고려한 부분이 많아서 좋았습니다. 단순히 함수형 코드만 나열하는 것이 아니라, 함수형 프로그래밍을 위해 클로저나 실행 컨텍스트, 스코프 등 JavaScript 동작의 기본 원리를 어떻게 응용하는지를 잘 나타냅니다. 덕분에 기본 개념에 대한 설명은 친절한 편입니다.
다만 구체화된 예시를 쓰기 위해 특히 함수형 라이브러리로 작성된 예시가 많이 사용됩니다. 저는 함수형 라이브러리에 익숙하지 않았기에 예제가 좀 낯설게 느껴지긴 했는데… 애초에 JavaScript가 함수형 언어가 아니기 때문에 이를 제대로 흉내내려면 라이브러리가 필요하다고 하는 저자의 말 때문에 이건 그러려니 하고 넘어갔습니다.
함수자나 모나드 등 심화 부분으로 넘어가면서부터는 친절한 설명임에도 이해가 잘 안가다보니 검색해가며 책을 읽었습니다. 근데 이게 워낙 친절한 비유를 들어주느라 개념이 살짝 왜곡된 건지, 검색 결과와 책 내용이 상이한 경우가 좀 있었습니다. 안 그래도 난해한 내용인데 어느게 맞는지 헷갈려서… 사실 이 부분은 아직도 긴가민가합니다.
심화 부분은 좀 더 자세히 알아봐야 하겠지만, 기본 개념 정리에 대해서는 충분히 가치 있는 책이라고 생각되기에 혹시라도 관심이 가시는 분께는 추천해드릴만 한 것 같습니다.