최근에 함수형 프로그래밍에 대해서 알아가보고 싶다는 생각이 들었다.
작년 프로그래밍 언어라는 과목을 수강했을 때 처음으로 함수형 언어와 함수형 프로그래밍에 대해서 들었다. 그때는 크게 와닿지는 않았는데, 프론트엔드로 개발을 하면서 자바스크립트 기반으로 리액트를 사용해 보니 함수형 프로그래밍에 대한 개념을 종종 접하게 됐다.
그래서 함수형 프로그래밍에 대해 한번 알아봐겠다는 생각을 했고, 동아리에서 CS 교육으로 함수형 프로그래밍에 대해 발표를 했다.
이번 글은 발표를 준비하면서 공부한 내용을 정리한 글이다.
함수형 프로그래밍이란?
함수형 프로그래밍을 정의하면 다음과 같이 정리할 수 있다.
자료 처리를 수학적 함수의 계산으로 취급하고, 상태와 가변 데이터를 멀리하는 프로그래밍의 패러다임
뒤에서 함수형 프로그래밍의 특징들에 대해 설명을 하겠지만, 위의 문장을 함수형 프로그래밍의 특징으로 분류하면 이렇게 할 수 있을 거 같다.
- 수학적 함수 -> 순수 함수
- 상태와 가변 데이터를 멀리 함 -> 불변성
함수형 프로그래밍에서 함수는 우리가 코드에서 함수를 만들 때 이 함수를 좀더 수학적인 관점으로 본다. 그래서 함수형 프로그래밍은 산업 목적보다는 학술 목적으로 주로 사용됐다.
함수형 언어
개발을 하면서 주로 C언어는 절차 지향, C++와 Java는 객체 지향 언어라고 하는데, 그럼 함수형 프로그래밍이 적용되어 있는 함수형 언어도 있다. C++, Java, Python, JavaScript에서도 함수형 프로그래밍이 가능하지만, 여기서는 함수형 프로그래밍을 메인 패러다임으로 하는 대표적인 함수형 언어에 대해 알아보겠다.
LISP
Fortran이 최초의 프로그래밍 언어이고, LISP은 Fortran 다음으로 나온 두 번째 프로그래밍 언어이다. 당시 MIT에 교수로 있던 존 매카시가 만든 언어이고, 존 매카시는 다트머스 회의에서 최초로 인공지능에 대한 개념을 정리한 분이다. 그래서 언어가 개발된 계기도 인공지능을 연구하기 위함이었다. 하지만 최근에는 Auto CAD에 적용이 되면서 산업에서도 사용이 되고 있다.
Scala
Scala는 함수형 뿐만 아니라 객체 지향의 언어 특징도 가지고 있는 다중 패러다임 언어이다. JVM을 기반으로 동작하여 Java 코드랑 호환이 가능하다는 특징이 있다. Scala는 다른 함수형 언어들과 달리 객체 지향 프로그래밍을 같이 지원하고, 함수형 프로그래밍은 순수하게 작성하게 제약을 두기 보다 개발자가 기호에 맞게 적용을 하도록 권고하고 있다.
Haskell
Haskell은 학술 위원회에서 제작이 되었다. 그 만큼 탄생 자체가 연구의 색이 짙은 언어고, 순수 함수형 언어라고도 불린다. lazy evaluation, currying, monad 등 함수형 프로그래밍에서 중요한 특징들을 담고 있는 언어이다. 학술적인 목적으로 나왔지만 페이스북에서 스팸을 처리하는 프로그램과, 깃허브에서 시멘틱을 분석하는 프로그램이 Haskell로 개발이 됐다.
명령형 프로그래밍과 선언형 프로그래밍
함수형 프로그래밍이 어떤건지 감을 얻기 위해 명령형 프로그래밍과 선언형 프로그래밍의 차이를 통해 알아보도록 하겠다.
"정수형 데이터를 원소로 하는 리스트가 있을 때, 리스트에 있는 원소 중 0을 제외하고, 나머지 원소는 절댓값으로 변환한 새로운 리스트를 반환하라"라는 문제를 해결해야 한다. 그럼 명령형 프로그래밍과 선언형 프로그래밍을 통해 다음과 같이 해결할 수 있다.
명령형 프로그래밍
const arr = [3, 0, -2, 7];
const newArr = [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] > 0) {
newArr.push(arr[i]);
} else if (arr[i] < 0) {
newArr.push(-arr[i]);
}
}
무엇을(What) 할 것인지 나타내기보다,어떻게(How) 할 건지를 설명하는 방식
우리가 사용하는 대부분의 언어들은 명령형 프로그래밍에 해당한다. 절차 지향과 객체 지향이 명령형 프로그래밍에 속해있다. 문제를 해결하기 위해서 문제 자체를 프로그래밍하기보다는, 문제를 어떻게 해결을 할지 컴퓨터에게 일일이 명령을 내리는 방식이다.
선언형 프로그래밍
const arr = [3, 0, -2, 7];
const newArr = arr.filter((v) => v !== 0).map((v) => Math.abs(v));
어떻게 할 건지(How)를나타내기보다, 무엇을(What) 할 건지를 설명하는 방식
글의 주제은 함수형이 선언형 프로그래밍에 해당해고, 또 논리형 프로그래밍이라 하는 방식이 선언형 프로그래밍에 해당한다. 우선 코드 자체가 명령형 프로그래밍 방식보다 간결해진 것을 볼 수 있다. filter 함수를 사용해 0을 제거하고, map 함수를 사용해 abs함수를 적용한다. 이렇게 어떻게 0을 제거하고, 어떻게 절댓값을 만들지는 함수에게 위임하고, 개발자는 무엇을 할 지만 생각한다.
사실 지금까지 filter. map, reduce와 같은 함수를 쓰고 있던 게 사실 함수형 프로그래밍을 하고 있는 거였다!!
함수형 프로그래밍의 특징
함수형 프로그래밍에서 나온 다양한 특징들이 있지만, 이번에는 간단하고 실제 적용할 수 있는 내용들을 위주로 작성했다. Haskell에서 도입된 currying과 monad와 같은 특징도 중요하지만 아직 이해를 하지 못했고, 내용도 많이 길어질 거 같아 다음 기회에 정리를 해 보도록 하겠다.
Side Effect (부수 효과)
let k = 3;
function add1(x, y) {
return x + y;
}
function add2(x, y) {
return x + y + k;
}
function add3(x, y) {
k = x + y;
return k;
}
함수가 함수 외부의 데이터에 영향을 받거나, 영향을 끼치는 경우
Side Effect는 직후에 나올 순수 함수에서 등장하는 개념이다. Side Effect를 직접 해석하면 부수 효과이지만, 사전적 의미로는 부작용이다. 즉 함수가 부작용이 발생하는 상황이 함수가 Side Effect가 있다 하는 것이다.
add1 함수는 인자로 받은 x와 y의 값만을 통해 반환 값이 결정되므로 이는 Side Effect가 없는 함수이다. 하지만 add2는 함수의 인자가 아닌 전역 변수 k의 값을 통해 반환 값이 결정되고, add3는 전역 변수인 k의 값을 변경시키기 때문에 이 둘은 Side Effect가 있는 함수이다.
Pure Function (순수 함수)
동일 입력 시 동일 출력을 보장하며, Side Effet가 없는 함수
순수 함수는 간단하게 Side Effect가 없는 함수이다. 위의 코드에서 add1은 Side Effect가 없기 때문에 순수 함수이고, add2와 add3는 Side Effect가 발생하기 때문에 순수 함수가 아니다,
개인적인 생각으로는 함수형 프로그래밍의 많은 장점이 이 순수 함수 때문인 거 같다. 우선 함수가 순수 함수이면 디버깅이 쉬워진다. 예를 들어 add1(1, 2)와 같이 함수를 호출했는데, 반환 값이 5가 나왔다. add1은 순수 함수이기 때문에 외부 데이터에 영향을 끼치지 않아 함수 내부만 확인을 하면 된다. 하지만 add2와 같이 외부 데이터로부터 영향을 받고 있으면, 어떤 데이터로부터 영향을 받고, 그 데이터는 또 어디서 어떻게 영향을 받고 있는지 다 파악을 해 주어야 한다.
그러면 코딩을 하면서 함수를 만들 때 꼭 순수 함수로 만들어야 할까? 그것은 쉽지 않다. 순수 함수는 수학적 함수에 대한 내용이다. f(X) = 2x라는 함수가 있으면, f(x)는 x에 의해서만 값이 결정되어야 한다. 수학에서 함수는 순수 함수를 보장하지만, 우리가 프로그래밍에서 만드는 Side Effect가 발생할 수밖에 없다. 특히 명령형 프로그래밍 방식으로 코드를 짠다면 Side Effet 없이 함수를 만드는 것은 불가능에 가까울 것이다. 심지어 순수 함수형 언어라 하는 Haskell 또한 모든 함수는 순수 함수라 했지만, I/O 함수에는 Side Effect가 존재한다.개인적인 생각으로는 함수형 프로그래밍에서는 "무조건 순수 함수여야 한다"가 아니라 "함수형 프로그래밍에서는 순수 함수를 지향해야 한다."로 받아들일 수 있을 거 같다.
Referential Transperancy (참조 투명성)
function add1(x, y) {
return x + y;
}
const sum1 = add1(1, 2);
const sum2 = 3;
프로그램의 변경 없이도 어떤 표현식을 값으로 대체할 수 있음
참조 투명성은 순수 함수와 연결되는 내용이다. 우선 add1은 Side Effect가 없는 순수 함수이다. 그럼 add1의 인자로 1과 2를 전달하면 반환 값은 항상 3이다. 그러면 프로그램에 있는 모든 add1(1, 2) 문장을 3으로 변경해도 동작에서 문제가 되지 않는다. 이를 참조 투명성이라 한다.
순수 함수와 참조 투명성이 동질 관계인지는 아직 제대로 찾지 못했다. 얼핏 봐서는 순수 함수는 참조 투명하지만, 그 반례가 존재하지 않음을 증명하는 내용은 보지 못한 거 같다.
Immutability (불변성)
const arr = [1, 2, 3];
function arrPush1(arr, x) {
const newArr = arr.map((v) => v);
newArr.push(x);
return newArr;
}
function arrPush2(arr, x) {
arr.push(x);
return arr;
}
어떤 값의 상태를 변경하지 않아야 함
불변성은 값이 한번 할당되면, 그 값은 변경되어는 안 된다. 즉 메모리에 값이 할당이 되었다면, 그 메모리에 값이 변경되어서는 안된다.
그러면 기존 데이터에서 수정을 가하고 싶으면 어떻게 해야 할 까? 그런 경우에 사용하는 방식이 깊은 복사이다. addPush1 함수와 addPush2 함수에서 인자로 받은 arr는 얕은 복사를 통해 전달받았다. 함수에서 사용하는 arr는 인자로 받은 리스트를 직접 가리키고 있다. addPush2 함수는 인자로 제공받은 arr에 직접 값을 push 하기 때문에 불변성을 지키지 못한 상황이다. 반면 addPush1은 map 함수를 통해 newArr 리스트를 깊은 복사를 통해 만들었다. 그리고 새로 만든 newArr 리스트에 값을 추가해서 기존 arr리스트를 변경시키지 않고 원소에 값이 추가된 리스트를 반환한다.
불변성을 지켜서 얻을 수 있는 장점은 순수 함수와 비슷하다. 순수 함수가 함수 외부에 있는 데이터와 독립적인 상태이기 때문에 디버깅의 범위가 줄어든다. 이와 같이 불변성이 적용되면 할당된 값은 변경되지 않으니 디버깅의 범위가 줄어든다.
First-class Function (일급 함수)
function add1(x, y) {
return x + y;
}
const addFunc = add1;
const sum = addFunc(1, 2);
함수가 객체와 동일하게 평가되어 변수에 저장 가능
이전까지는 함수형 프로그래밍을 하기 위해서는 이렇게 코드를 짜야한다는 의미였지만, 지금부터는 함수형 프로그래밍을 하기 위해서는 프로그래밍 언어에서 이런 특징을 지원해야 한다는 의미이다.
일급 함수는 함수가 일급 객체(First-class Object)이기에 객체와 동일하게 평가된다. 객체와 동일하게 평가된다는 의미는 함수를 변수에 저장이 가능한 특징이다.
High order Function (고차 함수)
function add1(x, y) {
return x + y;
}
function applyFunc(f, x, y) {
return f(x, y);
}
const sum = applyFunc(add1, 1, 2);
함수를 인자로 전달받거나 함수를 결과로 반환하는 함수
고차 함수는 기본적으로 일급 함수 특징이 있기에 가능한 특징이다. 일급 함수 특징을 가지면 함수를 데이터로 취급할 수 있고, 데이터로 취급이 가능하면 이를 함수의 인자로 사용하거나, 함수의 반환 값으로도 사용이 가능하다.
함수의 인자로 전달하는 함수를 콜백 함수라 하는데, 코드에서 applyFunc의 인자 f가 콜백 함수에 해당한다. JavaScript를 사용하다 보면 콜백 함수에 대한 얘기를 많이 들어볼 수 있다. setTimeout이나 addEventListner함수에서 콜백 함수를 전달해야 하는데, 이와 같은 내용이 이미 함수형 프로그래밍의 특징을 적용하고 있었다고 볼 수 있다.
왜 함수형 프로그래밍?
오늘 글의 결론이다. 그래서 왜 함수형 프로그래밍을 알아야 하는가? 필자는 그 질문에 이렇게 답변을 할 거 같다.
앞으로 우리가 코드를 작성하면서 함수형 프로그래밍을 접할 기회가 많아질 거다.
우스갯소리로 책을 0권 읽은 사람 보다 1권 읽은 사람이 더 무섭다고 한다. 사실 필자가 함수형 프로그래밍에 관련해서 책을 1권 읽은 수준이긴 하지만, 지금까지 자료를 조사하면서 느낀 점을 정리해 보겠다.
프로그래밍 언어는 개발자에게 제공해 주는 추상화의 수준은 높아지고 있다.
최초에 프로그래밍을 하기 위해서는 컴퓨터가 이해할 수 있는 기계어를 사용해야 했지만, 이를 추상화한 어셈블리어가 등장했다. 하지만 어셈블리어가 기계어로 번역이 되는 오버헤드가 발생이 하니, 당연히 어셈블리어를 사용하는 것보다는 기계어를 사용하는 것이 더 높은 성능을 낼 수 있을 것이다. 유명한 일화로 폰 노이만이 자신의 제자가 어셈블리어를 사용하는 모습을 보고 왜 컴퓨터의 성능을 낭비하냐라고 화를 내기도 했다. 또 다른 이야기로 C언어에서 malloc과 free를 통해 메모리를 직접 관리하던 상황에서 자바가 가비지 컬렉터를 출시했다. 이는 메모리 조작에서 추상화를 제공한 거지만, 참조 관계를 계속 파악하고 있어야 한다는 성능의 이슈로 초기에는 비판을 받기도 했다.
추상화와 성능은 trade off관계에 있어 높은 추상화를 제공하기 위해서는 그만큼 더 많은 성능을 사용해야 한다. LISP이 두 번째 프로그래밍 언어였지만 당시 메인 패러다임으로 함수형이 사용되지 못한 이유는 당시 하드웨어의 성능 문제가 있었다고 생각된다. 그래서 객체 지향 패러다임이 지금까지 개발에 메인 트렌드로 자리 잡고 있었다. 하지만 Java에서 8 버전부터 Stream API와 람다 표현식, C++은 11 버전에서 람다 표현식을 제공하고 있다. 특히 React에서는 16 버전에서 hook이 도입되면서 클래스형 컴포넌트에서 함수형 컴포넌트로 완전히 전환되었다.
우리가 사용하는 메인 패러다임에 함수형 프로그래밍으로 변경되거나 주력 언어가 함수형 언어가 되지 않더라도, 기존 방법론에서 함수형 프로그래밍의 장점을 계속해서 차용할 것이다.
특히 절차 지향이나 객체 지향으로 개발을 한다고 해도, 함수형 프로그래밍의 특징이었던 순수 함수를 준수하며 함수를 만들면 확실히 추상화를 제공하고 동작 예측이 쉬워질 것이다. 아마도 앞으로의 개발 패러다임에서 함수형 프로그래밍의 장점을 흡수하면서 계속해서 함수형에 대한 얘기를 어렵지 않게 접할 수 있을 것이라 예상한다.