3장 함수형 프로그래밍에 대하여

Sheryl Yun·2022년 6월 11일
0
post-thumbnail

함수형 프로그래밍 기법은 리액트뿐만 아니라
리액트 생태계를 이루는 여러 라이브러리의 근간이다.

함수형 프로그래밍이라는 개념은 1930년대 발명된 람다 계산법(lambda calculus)로부터 시작했으며, 함수는 그보다 이전인 17세기에 등장하여 그 이후로도 계속 계산법(calculus)의 일부로 존재해왔다.

고차함수 (HOC, Higher Order Function)

함수를 인자로 받거나 반환하는 것이 가능한 복잡한 함수

1급 시민 (First Class Citizen)

함수형 프로그래밍에서의 함수의 특징.
함수를 다른 일반적인 값(문자열, 숫자)과 마찬가지로 취급한다.
1급 멤버(First Class member)라고도 한다.

1급 객체 함수의 3가지 특징

  • 변수에 함수를 대입할 수 있다.
  • 함수를 다른 함수에 인자로 할당할 수 있다.
  • 함수에서 함수를 반환할 수 있다.

함수형이란?

함수형 프로그래밍의 함수는 1급 시민이다.
자바스크립트의 함수는 1급 시민이다.
따라서 자바스크립트는 함수형 프로그래밍을 지원한다.

최신 자바스크립트는 함수형 프로그래밍 기법을 더 풍부하게 해 주는
화살표 함수, Promise, spread 연산자 등을 추가했다.

자바스크립트에서는 함수형 프로그래밍 언어라는 특징으로 다음과 같은 것들이 가능하다.

1. 함수를 변수에 넣을 수 있다.

ES6 이전 JS

var log = function(message) {
	console.log(message);
};

ES6 이후

const log = message => {
	console.log(message);
};

함수형 프로그래밍에서는 작은 함수를 아주 많이 작성하고 나중에 그것들을 합쳐 하나의 완성된 프로그램을 만든다.
함수를 많이 만드는 과정에서 화살표 구문을 사용하면 코딩을 훨씬 간편하게 할 수 있다.

2. 함수를 객체 안에 프로퍼티로 넣을 수 있다.

const obj = {
	message: "함수를 객체에 추가할 수 있다.",
    log(message) {
    	console.log(message);
    }
}

obj.log(obj.message);

-----------------------------------------------------
const messages = [
	"함수를 배열에 넣을 수도 있다.",
    message => console.log(message),
]

3. 함수를 다른 함수에 인자로 넘길 수 있다.

const insideFn = logger => {
    	logger("함수를 다른 함수에 인자로 넘길 수 있다."
};

insideFn((message) => console.log(message));

4. 함수가 함수를 반환할 수 있다.

const createScream = function(logger) {
    return function(message) {
        logger(message);
    }
}
    
const scream = createScream((message) => console.log(message));
    
scream('함수가 함수를 반환할 수 있다');

바로 위에서 맨 마지막 줄을 제외한 코드는 고차 함수 형태로 바꿀 수 있다.

const createScream = logger => message => {
	logger(message);
};

고차 함수는 다른 함수를 인자로 받는 함수이다.
즉, 화살표 함수에서 화살표를 2개 이상 사용하고 있다면 그 함수는 고차 함수이다.

명령형 프로그래밍과 선언적 프로그래밍

명령형 프로그래밍

필요한 것을 달성하는 과정(how)을 하나하나 기술하는 방식

선언적 프로그래밍

필요한 것이 어떤 것인지(what) 한 번에 기술하는 방식 (예: 함수형 프로그래밍)

명령형과 선언형 방식의 비교

명령형 방식의 코드

const string = "Restaurants in Hawaii";
const urlFriendly = "";

for (let i = 0; i < string.length; i++) {
	if (string[i] === " ") {
    	urlFriendly += "-";
    } else {
    	urlFriendly += string[i];
    }
}

console.log(urlFriendly); // "Restaurants-in-Hawaii"

직관적으로 코드의 목적을 파악하기 힘들다.
'문자열의 공백을 하이픈('-')으로 치환한다'는 코드의 목적을 알기 위해 그 과정을 한 줄 한 줄 읽어봐야 한다. 이 과정에서 주석이 필요할 수도 있다.
명령형 방식의 코드는 확장도 힘들며, 유연하지 않다.

선언형 방식의 코드

const string = "Restaurants in Hawaii";
const urlFriendly = string.replace(/ /g, "-");

console.log(urlFriendly);

'A를 B로 치환한다'는 코드의 목적을 한 번에 알 수 있다. replace라는 메서드 덕분이다. replace 메서드는 코드의 목적('공백을 하이픈으로 바꾼다')을 달성하는 과정은 메서드 내부에 숨기고, 구체적인 절차(실제 그 작업을 처리하는 방법)를 추상화를 통해 아랫단에 감춘 역할을 했다.

선언적 방식의 특징

  • 사람이 읽기 쉽다.
  • (따라서) 코드의 목적을 더 추론하기 쉽다.
  • 각 함수의 구체적인 구현 과정은 추상화 아래에 감춰진다.
  • 각각의 작은 함수에 그 함수가 하는 일을 잘 설명하는 이름을 붙이고,
    그 함수들이 조합된 방식을 보면 프로그램의 전체적인 과정이 드러나는 방식이다.
  • 직관적이기 때문에 프로그램의 과정을 설명하기 위한 주석이 거의 필요없다.
  • 모듈화가 용이하여 코드의 규모 확장이 쉽다.

리액트와 Vanilla JS 비교

DOM을 만드는 과정

Vanilla JS의 명령형 방식을 택해서 코드를 작성하면, 다음과 같이 'DOM을 구축하는 절차'에 대해 관심을 가질 것이다.

let target = document.getElementById("target");
let wrapper = document.createElement("div");
let headline = document.createElement("h1");

wrapper.id = "welcome";
headline.innerText = "Hello World";

wrapper.appendChild(headline);
target.appendChild(wrapper);

DOM 요소 생성, id와 chlldren 텍스트 설정, 만든 DOM 요소를 상위 DOM에 추가 등 각각의 단계를 일일이 서술하고 있다.
하지만 이런 식으로 DOM을 변경하면 코드가 1만 줄 이상이 되거나 새로운 기능 추가 및 규모 확장을 하는 것이 아주 어려워진다.

다음은 위의 코드를 선언형 방식의 React 컴포넌트로 바꾸어 작성한 것이다.

const { render } = ReactDOM;

const Welcome = () => {
	<div id="welcome">
    	<h1>Hello World</h1>
    </div>
};

render(<Welcome />, document.getElementById('target');

Welcome 컴포넌트는 렌더링해야 하는 DOM의 '모양'을 선언한다.
마지막 줄의 render 함수는 이 Welcome 컴포넌트가 만든 DOM을 id가 target인 DOM에 렌더링하는 역할을 한다.
이러한 과정에서 '실제로 DOM이 어떻게 만들어지는지' 구체적인 과정은 render 메서드와 Welcome 컴포넌트 내부의 '추상화'를 통해 사라진다.

이렇게 컴포넌트를 렌더링하는 선언형 방식은 코드의 구조를 확장하기 쉽고 기능 추가도 어렵지 않으며, 읽기 쉽고 이해하기 쉽다.

함수형 프로그래밍의 핵심 개념

1. 불변성

데이터를 변경한 값을 얻을 때, 변경하기 이전의 원본 데이터는 그대로 유지해야 한다는 특성이다.
이를 위해 원본 데이터를 직접 변경하는 대신, 그 데이터의 복사본을 만들어 해당 복사본을 가지고 변경하는 작업을 진행한다.

먼저 데이터가 객체일 때를 살펴보자.

원본 데이터

let color_lawn = {
	title: "잔디",
    color: "#00FF00",
    rating: 0
};

불변성이 유지되지 않는 코드

function rateColor(color, rating) {
	color.rating = rating;
    return color;
}

console.log(rateColor(color_lawn, 5).rating); // 5
console.log(color_lawn.rating); // 5

rateColor 함수에서 color_lawn이라는 객체의 원본을 직접 변경함으로써 원본 데이터의 불변성이 훼손되었다.

불변성을 유지하여 변경하는 방법

1) Object.assign 사용

메서드가 사용되는 방법을 풀어서 쓰면,

  • 빈 객체를 받고,
  • color 객체를 그 빈 객체에 복사한 다음
  • 복사본 객체의 rating 키에 받아온 rating 값을 넣는다.
function rateColor(color, rating) {
    return Object.assign({}, color, { rating: rating });
};

console.log(rateColor(color_lawn, 5).rating); // 5
console.log(color_lawn.rating); // 0 (원본의 rating은 그대로 유지)

2) spread 연산자 사용

화살표 함수 + spread 연산자 조합으로 작성한 코드이다.
화살표 함수에서 return문을 생략하면 소괄호로 감싸서 반환한다.

const rateColor = (color, rating) => ({ 
	...color, rating
}); // 화살표 함수와 spread 연산자 사용

console.log(rateColor(color_lawn, 5).rating); // 5
console.log(color_lawn.rating); // 0 (원본의 rating 그대로 유지)

이제는 데이터가 배열일 때의 경우이다.

원본 데이터

let list = [
	{ title: "red" },
    { title: "pink" },
    { title: "blue" }
];

불변성이 유지되지 않는 코드

const addColor = function(title, colors) {
	colors.push({ title: title });
    return colors;
};

console.log(addColor('green', list).length); // 4
console.log(list.length); // 4 (변경된 길이 그대로 반환)

새로운 요소가 추가된 배열을 얻을 때 Array.push 메서드를 사용할 수 있지만, 원본 배열을 변경하여 원본 데이터의 불변성이 유지되지 않는다.

불변성을 유지하여 변경하는 방법

1) Array.concat 사용

const addColor = (title, array) => array.concat({ title });

console.log(addColor('green', list).length); // 4
console.log(list.length);

Array.concat은 새로운 빈 배열을 받고, 그 배열에 원본 배열을 복사한다. 복사본 배열에 새로운 객체를 추가한 뒤 반환하면, 원본 배열을 훼손시키지 않고서 새로운 값이 추가된 배열을 얻을 수 있다.

2) spread 연산자 사용

const addColor = (title, list) => [ ...list, { title } ];

spread 연산자를 통해 원본 배열을 새 배열에 바로 복사한 뒤, 새로운 값을 그 뒤에 추가한다. Array.concat을 사용한 것보다 더 간결하며 원본 배열의 불변성도 지켜준다.

2. 순수 함수

파라미터(인수)에 의해서만 반환값이 결정되는 함수

  • 최소한 하나 이상의 인수를 받음
  • 인수가 같으면 항상 같은 값이나 함수를 반환

순수 함수에는 부수 효과(side effect)가 없다.
부수 효과란 함수 바깥의 값이나 상태를 변경시키는 효과를 말한다.
순수 함수는 인수를 변경 불가능한 데이터로 취급한다.

순수한 함수와 순수하지 않은 함수를 살펴보자.

const frederick = {
	name: "Frederick Douglass",
    canRead: false,
    canWrite: false
};

먼저, 순수하지 않은 함수로 해당 객체를 변경한다.

function selfEducate() {
	frederick.canRead = true;
    frederick.canWrite = true;
    return frederick;
}

selfEducate();
console.log(frederick);

위의 코드에서 selfEducate 함수는 순수 함수가 될 수 없다. 왜냐하면,

  • 인자를 취하지 않고,
  • 새로운 값을 반환하지 않으며
  • 함수 바깥에 있는 frederick 객체를 직접 변경시킨다.
    즉, 함수 호출에 따른 부수 효과가 발생한다.

그렇다면 selfEducate 함수가 인수를 받게 만들면 어떨까?

const selfEducate = (person) => {
	person.canRead = true;
    person.canWrite = true;
    return person;
};

console.log(selfEducate(frederick));
console.log(frederick);

여기서의 selfEducate 함수 또한 순수 함수가 아니다.
이번에는 인수를 받기는 하지만, 인수를 가지고 객체 내부의 값을 직접 조작하는 것은 변하지 않았기 때문이다.
그러므로 단지 인수를 1개 이상 받는 것은 필요 조건일 뿐 충분 조건은 아님을 알 수 있다.

이번에는 spread 연산자를 사용하여 기존 객체를 복사한 뒤 객체의 일부 프로퍼티를 일부 수정하여 뒤에 추가한다.

const selfEducate = (person) => ({
	...person,
    canRead = true,
    canWrite = true
});

console.log(selfEducate(frederick));
console.log(frederick);

이제 selfEducate는 순수 함수이다. 인수가 하나 이상 있고, 값을 반환하고, 함수 바깥의 상태나 변수를 변경하지 않기 때문이다. (= 부수 효과가 없음)

3. 데이터 변환 시 원본 변경 x (=> 불변성)

함수형 프로그래밍은 데이터를 변환할 때 함수를 사용하여 원본을 변경한 복사본을 만들어낸다. 이를 통해 원본 데이터의 불변성을 유지하고, 더 읽기 쉬운 직관적인 코드를 만들며, 부수 효과를 발생시키지 않는 순수 함수를 표방한다. (= 선언적 프로그래밍)

데이터 변환 시 원본의 불변성을 유지하는 자바스크립트의 순수 함수에는 다음과 같은 것들이 있다.

  • Array.map
  • Array.join
  • Array.filter
  • Array.reduce

이와 달리 원본을 변경시키는 부수 효과가 있는 순수하지 못한 함수들은 다음과 같다.

  • Array.pop
  • Array.splice

객체를 배열로 변경하는 법

자바스크립트에서 객체를 배열로 변경하려면 Object.keys와 Array.map을 사용하면 된다.

const schools = {
	"Yorktown": 10,
    "Washington & Lee": 2,
    "Wakefield": 5
};

const schoolArray = Object.keys(schools).map((key) => ({
	name: key,
    wins: schools[key]
}));

console.log(schoolArray);

// [
//   { name: 'Yorktown', wins: 10 },
//   { name: 'Washington & Lee', wins: 2 },
//   { name: 'Wakefield', wins: 5 }
// ]

배열 중에 최대값을 찾는 법

배열 내에서 최대값을 찾으려면 reduce 함수를 쓰면 된다.

const ages = [ 21, 18, 42, 40, 64, 63, 34 ];

const maxAge = ages.reduce((max, age) => {    
    if (age > max) return age;
    else return max;
}, 0);

// if-else문을 삼항연산자로 표현
const max = ages.reduce((max, age) => (age > max ? age : max), 0);

배열을 객체로 바꾸는 법

const colors = [
	{
    	id: 'xekare',
        title: '과격한 빨강',
        rating: 3
    },
    {
    	id: 'jbwsof',
        title: '큰 파랑',
        rating: 2
    },
    {
    	id: 'prigbj',
        title: '회색곰 회색',
        rating: 5
    }
];

reduce 메서드를 통해 colors 배열을 해시로 변환한다.

해시: 값이 객체인 프로퍼티가 여러 개 들어 있는 객체 (형태로 추측)

const hashColors = colors.reduce(
	(hash, {id, title, rating}) => {
		hash[id] = { title, rating };
        return hash;
    },
	{}
);

console.log(hashColors);

// { 'xekare': { title: '과격한 빨강', rating: 3 },
//   'jbwsof': { title: '큰 파랑', rating: 2 },
//   'prigbj': { title: '회색곰 회색', rating: 5 } }

hash의 초기값을 빈 객체({ })로 설정하고, key 값으로 id, value 값으로 title과 rating을 축약하여 프로퍼티로 추가한다.

배열에서 중복된 값 제거하는 법

reduce를 통해 배열에서 중복값을 제거할 수 있다.

const colors = [ 'red', 'red', 'green', 'blue', 'green' ];

const uniqueColors = colors.reduce((unique, color) => 
	unique.indexOf(color) !== -1 ? unique : [...unique, color],
    []
);

console.log(uniqueColors); // [ 'red', 'green', 'blue' ]

unique의 초기값은 빈 배열이다. colors 배열에서 값을 하나씩 꺼내 unique 배열 안에 꺼낸 값이 있으면 unique 배열을 그대로 유지하고, 없으면 기존 unique 배열 안에 color 값을 추가한다.

4. 고차 함수

: 다른 함수를 인자로 받거나, 반환값으로 함수를 반환하는 함수
Array.map, Array.filter, Array.reduce는 모두 다른 함수를 인자로 받는 고차함수이다.

고차 함수 예시

const invokeIf = (condition, fnTrue, fnFalse) => 
	(condition) ? fnTrue() : fnFalse();
    
const showWelcome = () => console.log('Welcome!');
const showUnauthorized = () => console.log('Unauthorized!');

invokeIf(true, showWelcome, showUnauthorized); // 'Welcome!'
invokeIf(false, showWelcome, showUnauthorized); // 'Unauthorized!'

고차 함수 invokeIf는 인자 condition이 true인지 false인지에 따라 각각 다른 함수를 반환한다.

이렇게 함수를 반환하는 고차 함수를 사용하면 자바스크립트에서 다음 2가지가 가능하다.

  • 비동기적인 실행 맥락 처리
  • 필요할 때 재활용할 수 있는 함수 만들기

커링(currying)

어떤 연산을 수행할 때 필요한 값 중 일부를 저장하고 나중에 나머지 값을 전달받는 기법
일부 값을 저장한 뒤 반환되는 함수로 다음 전달받은 값을 처리하는데, 이 과정에서 반환값으로 다른 함수를 반환하는 고차 함수를 커링된 함수(curried function)라고 부른다.

// 화살표가 2개 -> 고차 함수!
const userLogs = (userName) => (message) => console.log(`${userName} -> ${message}`);

// userLogs 함수에 인자 userName을 넣은 1번째 반환값이 log에 담긴다.
const log = userLogs("grandpa23");

// 1번째 반환값을 받은 log(= userLogs 함수)에 다음 인자 message를 전달하면 최종 userLogs의 실행문 console.log()가 실행된다.
log("attempted to load 20 fake members");

getFakeMembers(20).then(
	(member) => log(`successfully loaded ${members.length} members`),
    (error) => log("encountered an error loading members")
);

// getFakeMembers 함수가 성공했을 때 출력
// grandpa23 -> attempted to load 20 fake members
// grandpa23 -> successfully loaded 20 members

// getFakeMembers 함수가 실패했을 때 출력
// grandpa23 -> attempted to load 20 fake members
// grandpa23 -> encountered an error loading members

5. 재귀

: 자기 자신을 호출하는 함수 만들기
반복문(for문, while문)은 모두 재귀로 바꿀 수 있고, 일부 반복문은 재귀로 표현하는 쪽이 더 간단하다.

예시: 10부터 0까지 거꾸로 세는 코드

const countdown = (value, fn) => {
	fn(value);
    return value > 0 ? countdown(value - 1, fn) : value;
};

countdown(10, (value) => console.log(value));

// 10
// 9
// 8
.
.
.
// 1
// 0

비동기 처리에서 재귀 사용하기: 1초마다 카운트다운하는 시계

const countdown = (value, fn, delay=1000) => {
	fn(value);
    return value > 0 
    	? setTimeout(() => countdown(value - 1, fn), delay) 
        : value;
};

const log = (value) => console.log(value);

countdown(10, log);

재귀를 사용하여 객체에서 값 찾기

const dan = {
	type: "person",
    data: {
    	gender: "male",
        info: {
        	id: 22,
            fullname: {
            	first: "Dan",
                last: "Deacon"
            }
        }
    }
};

const deepPick = (fields, object={}) => {
	const [first, ...remaining] = fields.split(".");
    return remaining.length 
    	? deepPick(remaining.join("."), object[first] 
        : object[first];
};

console.log(deepPick("type", dan));
console.log(deepPick("data.info.fullname.first", dan);

6. 합성

함수형 프로그래밍은 여러 개의 작은 순수 함수들로 나눠지는데,
작은 함수들을 한데 합쳐서 병렬적 또는 연쇄적으로 호출하거나
이들을 조합해서 더 큰 함수를 만드는 과정을 합성이라고 한다.

가장 대표적인 방식은
점(.)을 이어나가며 함수를 계속 호출하는 체이닝(chaining)이다.

const template = "hh:mm:ss tt";

// template 문자열의 각 자리를 replace의 2번째 인자로 대체한다.
const clockTime = template.replace("hh", "03")
				  .replace("mm", "33")
                  .replace("ss", "33")
                  .replace("tt", "PM");
                  
console.log(clockTime); // "03:33:33 PM"
profile
데이터 분석가 준비 중입니다 (티스토리에 기록: https://cherylog.tistory.com/)

0개의 댓글