함수형 프로그래밍이란?
https://medium.com/javascript-scene/master-the-javascript-interview-what-is-functional-programming-7f218c68b3a0 글의 번역. - Eric Elliott 번역하면 이상해지는 프로그래밍 언어들은 원문 그대로 둠. 오역 많음.
함수형 프로그래밍(Functional programming)은 자바스크립트 생태계에서 매우 중요한 주제가 되었다. 불과 몇 년전만해도 자바스크립트 개발자들은 함수형 프로그래밍에 대해 알지 못했지만, 내가 본 대부분의 큰 어플리케이션들은 함수형 프로그래밍의 아이디어를 차용하고 있었다.
함수형 프로그래밍(약식으로 FP라고도 불리기도 함)은 순수 함수(pure functions) 작성과 공유 상태(shared state), 변경 가능한 데이터(mutable date)와 side-effects 피하기로 소프트웨어를 구축하는 프로세스이다. 또 한, 함수형 프로그래밍은 명령적이라기보단 선언적이고 어플리케이션의 상태의 흐름이 순수 함수를 통해 흐른다. 이는 어플리케이션의 상태가 공유되고, 객체의 메소드와 함께 배치되는 객체 지향 프로그래밍과는 대조적이다.
함수형 프로그래밍은 프로그래밍 패러다임이다. 무슨 말이냐면, 위에 열거 된 기본적인 정의에 기반한 소프트웨어 구성을 생각하는 방법이라는 뜻이다. 프로그래밍 패러다임의 다른 예로는 객체지향 프로그래밍과 절차적 프로그래밍이 있다.
함수형 코드는 객체 지향 코드에 비해 더 간결하고, 더 예측 가능하고, 더 테스트하기 쉬운 경향이 있다. 그렇지만, 만약 당신이 함수형 코드와 그와 연관된 일반적인 패턴들에 대해 생소하다면, 함수형 코드 또한 복잡해보일 수 있고, 그와 관련한 글들도 읽기 어려울 수 있다.
하지만, 함수형 프로그래밍에 대해 구글링을 하게 된다면, 처음 접하는 사람들에게 어려운 용어에 대한 벽을 금방 허물 수 있다. 만약 당신이 자바스크립트 프로그래밍을 조금이라도 해봤다면, 실제 소프트웨어에서 많은 함수형 프로그래밍의 개념이나 도구들을 사용했을 가능성이 많다.
새로운 단어들을 두려워 하지 마라! 보이는 것 보다 훨씬 쉽다.
가장 어려운 부분은 익숙하지 않은 단어들을 머리에 집어 넣는 것이다. 함수형 프로그래밍의 의미를 알아가기 위해 이해가 꼭 필요한 정의(위에서 잠시 보았던)들은 많은 아이디어들을 가지고 있다.
- 순수 함수(Pure functions)
- 합성 함수(Function composition)
- 공유 상태 피하기(Avoid shared state)
- 상태 변경 피하기(Avoid mutating state)
- side effects 피하기(Avoid side effects)
다시 말해서, 함수형 프로그래밍이 실제 의미하는 바를 알고 싶다면, 이러한 핵심 개념들을 이해해야 한다.
함수형 프로그래밍의 핵심 개념
순수 함수
먼저 순수 함수는 다음을 만족하는 함수이다.
- 같은 입력 값이라면, 항상 같은 결과 값을 반환한다.
- side-effects를 가지지 않는다.
순수 함수는 함수형 프로그래밍에서 중요한 많은 속성들을 가지고 있다. 참조 투명성(referential transparency)(프로그램에 영향 없이 함수 호출을 그것에 대한 결과 값으로 바꿀 수 있는 것). “What is a Pure Function?” 이 글에서 더 알아볼 수 있다.
잠시 예를 들어 설명하자면
const double = x => x * 2;
double(5); // 10으로 대체 가능
라는 코드가 있을 때 double(5)
는 10으로 대체 가능하다. 이런걸 참조 투명성이라고 한다.
const doubleWithSave = x => {
localStorage.setItem("doubled", x * 2); // 참조 투명성을 해침
return x * 2;
};
doubleWithSave(5); // 10으로 대체 불가능 안녕하세요
이런 함수가 있다면 doubleWithSave(5)
는 10으로 대체 불가능하다. 결과 값은 같지만 10으로 대체할 경우 원래 프로그램과 달라지는 부분이 생기기 때문.
합성 함수
합성 함수은 새로운 함수를 생성하거나 어떤 계산을 수행하기 위해 둘 이상의 함수를 결합하는 프로세스이다. 예를 들어, fㆍg
는 자바스크립트에서 f(g(x))
와 같다. 합성 함수을 이해하는 것은 소프트웨어가 어떻게 함수형 프로그래밍으로 구성되었는지 이해하기 위한 중요한 단계이다. 더 자세한 것은 “What is Function Composition?” 에서 볼 수 있다.
상태 공유
상태 공유는 공유되는 스코프안에 존재하는 모든 변수, 객체, 메모리 공간이거나 스코프 간에 전달되는 객체의 속성이다. 공유되는 스코프에는 전역 스코프나 클로져 스코프가 포함된다. 객체 지향 프로그래밍에서는 다른 객체에 속성을 추가하는 것으로써 스코프 안에서 객체가 공유된다.
예를 들어, 컴퓨터 게임은 캐릭터, 게임 아이템 등이 속성으로서 저장된 마스터 게임 객체를 가지고 있을 수 있다. 함수형 프로그래밍은 상태 공유를 피하고, 대신에 변하지 않는 데이터 구조와, 순수 계산을 이용하여 새로운 데이터를 기존의 데이터로 부터 뽑아낸다. 함수형 프로그래밍이 어떻게 어플리케이션의 상태를 관리하는지에 대해 더 자세히 알아보려면 “10 Tips for Better Redux Architecture” 이 글을 보자.
상태 공유의 문제는 어떤 함수의 영향을 이해하기 위해서 그 함수가 사용하거나 영향을 미친 모든 공유된 변수의 전체 히스토리를 알아야 된다는 것이다!
당신이 저장이 필요한 유저 객체를 가지고 있다고 상상해보자. 당신의 saveUser()
함수는 서버 API에 요청할 것이다. 이 요청이 일어나는 동안, 유저는 프로필 사진을 updateAvatar()
함수를 이용해 바꾸었고 이것은 또 다른 saveUser()
를 일으킨다. 저장 시에 서버는 서버에서 발생하는 변경사항이나, 다른 API 호출에 대한 응답으로 동기화 하기 위해서 메모리에 있는 내용을 대체해야 하는 유저 객체를 되돌려 보낸다.
불행하게도, 두 번째 응답이 첫 번째 응답전에 받아지고 , 첫 번째 응답(이전 프로필 사진)이 받아졌을 때 새로운 프로필 사진이 메모리에서 지워지고 이전 프로필 사진으로 교체된다. 이게 경쟁 조건(race condition)의 한 예이다. — 상태 공유와 연관된 자주 발생하는 버그이다.
또 다른 상태 공유와 관련된 버그는 함수가 호출되는 순서를 변경하면, 공유 상태에서 발생되는 함수가 타이밍에 따라 다르기 때문에, 연쇄 적인 실패를 발생 시킨다는 것이다.
// 상태 공유에서는, 함수 호출 순서의 변경은
// 함수 호출의 결과도 바꿔버린다.
const x = {
val: 2
};
const x1 = () => (x.val += 1);
const x2 = () => (x.val *= 2);
x1();
x2();
console.log(x.val); // 6
// 위 예와 정확히 같다. 한 가지 ...
const y = {
val: 2
};
const y1 = () => (y.val += 1);
const y2 = () => (y.val *= 2);
// ...함수 호출의 순서 변경만 빼고!
y2();
y1();
// ...결과 값의 변경도 가져옴
console.log(y.val); // 5
당신이 상태 공유를 피한다면, 함수 호출의 타이밍과 순서가 함수 호출의 결과를 바꾸지 않을 것이다. 순수 함수를 이용하면, 같은 input에는 항상 같은 output이 나온다. 이것은 함수 호출을 다른 함수 호출들로 부터 완전히! 독립시키고 변경 과 리팩토링을 근본적으로 단순화 시킨다.
const x = {
val: 2
};
const x1 = x => Object.assign({}, x, { val: x.val + 1 });
const x2 = x => Object.assign({}, x, { val: x.val * 2 });
console.log(x1(x2(x)).val); // 5
const y = {
val: 2
};
// 외부 변수에 대한 의존성이 없기 때문에
// 다른 외부 변수를 사용하기 위해 다른 함수를 사용할 필요 없다.
// 의도적으로 비워둔 공간
// 함수가 변경되지 않기 때문에,
// 아래와 같이 아무때나, 아무순서로 함수를 호출해도
// 결과값이 변하지 않는다.
x2(y);
x1(y);
console.log(x1(x2(y)).val); // 5
이 예에서는, Object.assign()
를 사용하고 빈 객체를 Object.assign()
의 첫 번째 매개 변수로 전달하여서 x
의 속성을 변경하지 않고 복사한다. 물론 이런 경우 Object.assign()
을 사용하지 않고도 새로운 객체를 처음부터 새로 작성하는 것과 동일한 결과를 얻을 수 있다. 하지만, 위 예는 첫 번째 예제에서 본 변경가능한 상태를 사용하는 대신에 기존 상태의 복사본을 만드는 일반적인 패턴이다.
여기서 console.log()
부분을 자세히 살펴 보면, 아까 말했던 합성 함수을 찾을 수 있을 것이다. 아까 말했던 것처럼, 합성 함수은 f(g(x))
와 같이 생겼다. 이 예에서는 f()
와 g()
대신에 x1()
과 x2()
를 합성에 사용한 것이다.
물론, 합성의 순서를 바꾸면 결과 값도 변할 것이다. 명령에 대한 순서는 아직 고려해야한다. f(g(x))
는 g(f(x))
와 항상 같지 않다. 하지만, 더 이상 고려할 필요 없는 것은, 함수 바깥에 있는 외부 변수에 어떤 일이 일어나는지 이고 이는 중요한 것이다. 순수 함수가 아닌 경우, 함수가 사용하거나 영향을 주는 변수의 모든 환경과 변화 과정을 이해하지 않으면 그 함수를 이해할 수 없다.
함수 호출 타이밍의 종속성을 제거하면 잠재적 버그가 제거된다!
Immutability
immutable 객체( 변할 수 없는 객체 )는 생성된 이후에는 바꿀 수 없는 객체이다. 반대로, mutable 객체 (변할 수 있는 객체 )는 생성된 이후에도 바꿀 수 있는 객체이다.
불변성은 함수형 프로그래밍의 핵심 개념이다. 불변성이 없다면 프로그램의 데이터 흐름이 손실된다. 상태의 history가 버려지고, 이상한 버그가 당신의 소프트웨어에 발생할 수 있다. 불변성에 대해 더 자세한 의미를 보려면 “The Dao of Immutability.”를 참고하자.
자바스크립트에서 const
와 불변성을 혼동하지 않는 것은 중요하다. const
는 생성 후에 재 할당 할 수 없는 변수의 이름 바인딩을 생성한다. 하지만 const
는 불변한 객체를 생성하는 것은 아니다. 물론, 객체에 바인딩된 이름은 바꿀 수 없지만, 그 객체의 속성은 여전히 바꿀 수 있다. 이것은 const
로 생성된 바인딩은 불변한 것이 아니라 가변한 것을 의미한다.
불변한 객체는 아예 바뀔 수가 없다. 객체를 freezing 시킴으로써 실제로 불변하게 만들 수 있다. 자바스크립트에서는 이용하여 객체의 한 단계 깊이를 freeze 시키는 메소드가 있다.
const a = Object.freeze({
foo: "Hello",
bar: "world",
baz: "!"
});
a.foo = "Goodbye";
// Error: Cannot assign to read only property 'foo' of object Object
하지만 frozen
객체는 표먼적으로만 불변하다. 예를 들어, 아래와 같은 객체는 변할 수 있다.
const a = Object.freeze({
foo: { greeting: 'Hello' },
bar: 'world',
baz: '!'
});
a.foo.greeting = 'Goodbye';
console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);
여기서 볼 수 있듯이, frozen 객체의 젤 위 레벨의 속성은 바뀔 수 없지만, 객체인 속성은 여전히 바뀔 수 있다. 그러므로 frozen 객체도 당신이 전체 객체 트리의 모든 속성을 freeze 시키지 않는 이상 불변하지 않다.
많은 함수형 프로그래밍 언어에서는, 깊게 frozen시키는 트리 자료 구조 ( 트리라고 발음) 라고 불리는 특별한 불변 데이터 구조가 있다. 즉, 객체의 계층에서 깊이에 관계없이 모든 속성이 변할 수 없는 것이다.
Tries는 객체가 연산자에 의해 복사 된 후, 변경되지 않은 객체의 모든 부분에 대해 구조적 공유를 사용하여 참조 메모리 위치를 공유한다. 이 방법을 사용하면 메모리를 적게 사용하고, 일부 연산에 대한 상당한 성능 향상을 가능하게 한다.
예를 들어, 비교를 위해 객체 트리의 루트에서 id 비교를 할 수 있다. 만약 id가 같으면, 차이점을 찾기 위해 모든 트리를 들여다 볼 필요가 없어진다.
자바스크립트에는 이러한 tries의 여러 장점을 가져온 Immutable.js 나 Mori 같은 여러 라이브러리가 있다.
필자는 두 가지를 모두 실험해 보았고, 많은 양의 불변한 상태가 필요한 큰 프로젝트에서는 Immutable.js를 사용하는 경항이 있다. 더 자세히 알아보려면 “10 Tips for Better Redux Architecture” 이 글을 한번 보자.
Side Effects
Side effect는 반환 값 이외에, 호출 된 함수 밖에서 관찰할 수 있는 어플리케이션의 상태 변경이다.
- 외부 변수나 외부 객체 속성 수정 ( 전역 변수나 부모 함수 스코프 체인의 변수)
- 콘솔 로그
- 화면에 작성
- 파일에 작성
- 네트워크에 작성
- 외부 프로세스 트리거
- side-effect가 있는 다른 함수 호출
Side effects는 함수형 프로그래밍에서 대부분 피할 수 있고, 이는 프로그램을 더 쉽게 이해할 수 있고, 더 쉽게 테스트 할 수 있도록 한다.
하스켈과 같은 다른 함수형 언어들은 monads 를 사용하여 순수 함수에서 Side effects를 격리하고 캡슐화한다. monads에 대한 주제는 책으로 쓸만큼 상당히 깊기 때문에 나중을 기약하자.
지금 당신이 알아야 할 것은 바로 side-effect를 일으키는 행동들은 나머지 소프트웨어들과 격리되어야 한다는 것이다. 그렇게 한다면, 당신의 소프트웨어의 확장, 리팩토링, 디버그, 테스트 그리고 유지를 훨씬 간단하게 할 수 있다.
이것이 대부분의 프론트 엔드 프레임 워크가 사용자에게 느슨하게 결합 된 모듈들에서 상태 관리와 컴포넌트 랜더링을 관리하도록 유도하는 이유이다.
상위 함수(Higher Order Functions)를 통한 재사용성
함수형 프로그래밍은 일련의 유틸리티 함수들을재사용하여 데이터를 처리하는 경향이 있다. 객체 지향 프로그래밍에서는 객체에 메소드와 데이터를 배치하는 경향이 있다. 이렇게 배치된 메소드들은 오직 그 메소드들이 연산하도록 설계된 타입의 데이터들만 연산할 수 있고, 심지어 종종 해당 특정 객체 인스턴스에 포함 된 데이터만 연산할 수 있다.
함수형 프로그래밍에서는 모든 유형의 데이터가 공정하게 다뤄진다. 같은 map()
유틸리티는 객체,문자열, 숫자 또는 어떤 다른 데이터 타입을 매핑할 수 있다. 이것은 map()
이 주어진 데이터 타입을 적절하게 다룰 함수를 인자로 받기 때문이다. 함수형 프로그래밍은 상위 함수를 사용하여 일반적인 유틸리티 속임수를 푼다.
자바스크립트는 함수를 변수에 지정하고, 다른 함수에 인자로 보내고, 함수의 반환 값으로 보내는 등등 데이터처럼 취급할 수 있게 하는 개념인 1급 함수(first class functions) 라는 것을 가지고 있다.
상위 함수는 함수를 인자로 받거나 반환하거나 둘 다하는 모든 함수를 말한다. 상위 함수는 보통 이럴 때 사용 된다.
- 콜백 함수, promise, monads 등을 사용하여 액션, 효과 또는 비동기 흐름 제어를 추상화 하거나 격리
- 다양한 데이터 타입에 대해 작동할 수 있는 유틸리티 생성
- 재사용이나 합성함수의 목적으로 함수를 부분적으로 인자에 적용하거나 커링함수 생성
- 함수 리스트를 받고 그 함수들의 합성 함수를 반환해주는 함수
컨테이너, 펑터(functor), 리스트 그리고 스트림
펑터는 매핑될 수 있는 것이다. 즉, 내부에 있는 값에 함수를 적용하는데 사용할 수 있는 인터페이스가 있는 컨테이너이다.펑터라는 말을 보면 “매핑가능한(mappable)” 이라는 말이 떠올라야 된다.
이전에 우리는 map()
유틸리티가 다양한 데이터에 적용 될 수 있다고 배웠다. 이는 펑터 API를 사용하여 매핑 연산을 가능하게 한다. map()
이 사용하는 중요한 흐름의 제어 작업은 펑터 인터페이스를 사용한다. Array.prototype.map()
에서 컨테이너는 배열이지만, 다른 데이터 구조도 펑터가 될 수 있다. 물론, 그들이 매핑 API를 제공하는 한이다.
Array.prototype.map()
이 매핑 유틸리티에서 데이터 타입을 추상화하여 map()
을 모든 데이터 타입에서 사용할 수 있도록 하는 방법을 살펴보자. 전달된 모든 값에 2를 곱하는 간단한 double()
매핑을 만들어 보자.
const double = n => n * 2;
const doubleMap = numbers = > numbers.map(double);
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
게임의 목표를 조작하여서 점수를 두 배로 늘리려면 어떻게 해야 할까? map()
에 전달되는 double()
함수만 약간 변경하면 된다.
const double = n => n.points * 2;
const doubleMap = numbers => numbers.map(double);
console.log(
doubleMap([
{ name: "ball", points: 2 },
{ name: "coin", points: 3 },
{ name: "candy", points: 4 }
])
); // [ 4, 6, 8 ]
여러 가지 유형의 데이터 타입을 조작하는 일반 유틸리티 함수를 사용하기 위해서 펑터 및 상위 함수와 같은 추상화 개념을 사용하는 개념은 함수형 프로그래밍에서 중요하다. 이와 같은 개념이 다양한 방식으로 적용되는 것을 볼 수 있다.
“시간의 경과에 따라 표현된 리스트는 스트림이다.”
지금 당신이 알아야 할 것은 배열과 펑터만이 컨테이너와 컨테이너의 값의 개념이 적용되는 유일한 방법이 아니라는 것이다. 예를 들어, 배열은 단지 어떤 것들의 리스트이다. 시간의 경과에 따라 표현된 리스트는 스트림이다. 즉, 동일한 종류의 유틸리티를 적용하여 새로 들어오는 이벤트 스트림을 처리할 수 있다. 실제 함수형 프로그래밍으로 소프트웨어를 만들 때 이러한 것들을 자주 볼 수 있을 것이다.
선언적(Declarative) vs 명령적(Imperative)
함수형 프로그래밍은 선언적 패러다임이다. 이 의미는 프로그램 로직이 명시적으로 흐름 제어를 기술하지 않고 표현된다는 것을 의미한다.
명령적 프로그램은 코드 한 줄 한 줄이 원하는 결과를 얻기 위한 특정 단계 즉, 흐름 제어: 방법 에 대한 것이다. 선언적 프로그램은 흐름 제어 프로세스를 추상화 하고, 한 줄의 코드가 데이터 흐름: 무엇을 할지이다. 방법 은 추상화된다.
예를 들어, 이 명령적 매핑은 숫자 배열을 받아서 각 숫자에 2를 곱한 새로운 배열을 반환한다.
const doubleMap = numbers => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
return doubled;
};
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
이 선언적 매핑은 똑같은 일을 하지만, Array.prototype.map()
을 사용하여 흐름 제어에 대한 것은 추상화하고 이는데이터 흐름을 조금 더 명확하게 표현할 수 있도록 한다.
const doubleMap = numbers => numbers.map(n => n * 2);
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
명령적 코드는 명령문을 자주 사용한다. 명령문은 어떤 행동을 하는 코드 조각이다. 자주 사용되는 명령문의 예로는 for
, if
, switch
, throw
, 등등… 이 있다.
선언적 코드는 표현식에 더 의존한다. 표현식은 어떤 값을 풀어내는 코드 조각이다. 표현식은 보통 함수 호출, 값, 그리고 결과 값을 생성하기 위해 계산된 연산자의 조합이다.
표현식의 예:
2 * 2;
doubleMap([2, 3, 4]);
Math.max(4, 3, 2);
일반적으로 코드내에서 식별자에 할당되거나, 함수에서 반환되거나, 함수로 전달되는 표현식을 보게 된다. 할당되거나, 반환되거나, 전달되기 전에 표현식은 먼저 계산되고, 그 결과 값이 사용된다.
결론
함수형 프로그래밍:
- 상태 공유 & side effects 대신 순수 함수
- 변경 가능한 데이터에 대한 불변성
- 명령적 흐름 제어 보다 합성 함수
- 자신에게 배치된 데이터에 대해서만 연산하는 메소드 대신에 상위 함수를 사용하여 여러 데이터 타입에 동작하는 일반적이고 재사용가능한 여러 유틸리티들
- 명령적 코드 대신에 선언적 코드 ( 어떻게 할지 보다는 무엇을 할지 )
- 명령문 대신 표현식
- 특별한 목적을 위한 임시의 다형성(ad-hoc polymorphism) 보다 컨테이너와 상위함수
해야할 것
이 함수형 배열 기능의 핵심을 배워보고 연습해보자
.map()
.filter()
.reduce()