함수형 프로그래밍 가이드

Published on

default

대체 뭔데?

함수형 프로그래밍, 다들 들어 보셨으리라 믿습니다. 여기저기서 함수형이 어떻고 퓨어하지 않다 어쩌고 말은 많이 하는데 막상 함수형 프로그래밍이 뭐지? 생각을 해보면 조금 막막합니다. 순수함수들로 뭐 잘 하는거 아냐? 그럼 순수함수가 뭔데? 음…

그런데 저는 사실 막막한게 당연하다고 생각합니다. 함수형 프로그래밍이라는 것이 하나의 패러다임이고, 패러다임은 추상적일 수 밖에 없습니다. 모두가 공유하는 포인트는 있지만 실제로 적용하는 부분에서 조금씩 해석이 갈리게 되니까요. 헷갈리는 것이 당연합니다.

이 글은 함수형 프로그래밍에 대해 설명하는 글입니다. 다만 a부터 z까지 설명하는 글은 아닙니다. 이에 대한 설명들은 더 잘 나와 있는 글들이 많으니까요. 이 글은 함수형 프로그래밍이라는 큰 대륙을 탐험하기 위한 작은 안내서에 가깝습니다. 누군가가 먼저 이곳을 탐험하다가 생긴 궁금증들을 써 놓은 거라고 생각해 주세요.

그러면 들어가 볼까요?


프로그래밍 패러다임은 왜 필요할까?


default

함수형 프로그래밍은 프로그래밍 패러다임 중 하나입니다. 패러다임의 정의는 복잡합니다.

프로그래밍 패러다임과 프로그래밍 언어와의 관계는 프로그래밍 언어가 여러 프로그래밍 패러다임을 지원하기도 하기 때문에 복잡할 수도 있다. 예를 들어서 C++는 절차적 프로그래밍, 객체기반 프로그래밍, 객체지향 프로그래밍, 제네릭 프로그래밍의 요소들을 지원하도록 설계되었다. C++에서는 순수하게 절차적 프로그램을 작성할 수…

네. 복잡합니다. 그러나 왜 패러다임이 생겨났고 왜 쓰는지 그 목적은 같습니다. 문제의 빠르고 효과적인 해결입니다. 패러다임을 관점의 문제라고 본다면 이야기가 달라지지만 패러다임은 근본적으로 문제를 해결하는 하나의 도구입니다. 예시를 볼까요?

다들 아시듯 2014년 페이스북은 Flux pattern을 사용한 redux를 발표했습니다. 하나의 새로운 디자인 패턴을 만든 것입니다. 왜 만들었을까요? 어플리케이션의 크기가 너무 커져 기존의 패턴으로 감당하지 못하는 버그들이 터져나오니 이를 해결하기 위해서 만든 것이었습니다.

패러다임도 동일합니다. 패러다임이 있고 문제가 있는 것이 아니라, 문제가 생기고 패러다임이 그걸 해결하기 위해 등장합니다. 사실 함수형 프로그래밍이라는 개념은 아주 오래 전 나온 개념이긴 하지만요.

그래서 결국 함수형 프로그래밍 또한 결국 문제를 해결하기 위한 하나의 방법입니다. 그것도 문제를 효율적이고 단순한 방법으로 해결하게 도와주는 방법입니다.

그렇다면 함수형 프로그래밍은 어떤 문제를 해결하는데 도움을 주는 걸까요?

  • 어디서 문제가 터졌는지 찾는데 한참 걸리는 디버깅
  • 이 함수가 대체 뭘 하는지 알기 위해서 한참 읽어야 하는 코드

이 두 가지 문제를 해결할 수 있습니다. 읽기만 해도 끔찍한 문제들입니다. 그러나 걱정 마세요. 이런 문제를 없애지는 못하겠지만 적어도 우리는 함수형 프로그래밍을 사용해서 이런 문제들을 쉽고 빠르게 해결할 수 있습니다.

근데 그래서 함수형 프로그래밍은 어떠한 방법으로 문제를 해결할까요?


함수형 프로그래밍

순수 함수들로 사이드 이펙트 없이 선언적으로 프로그래밍을 설계하는 것.

이러한 방법으로 문제를 해결합니다. 함수형 프로그래밍의 정의는 다양하지만, 저는 이 정의가 가장 명쾌한 설명인 것 같습니다. 각 키워드에 대한 설명은 뒤에서 하도록 하고 일단 함수형 프로그래밍의 코드 예제를 보겠습니다. 정말 간단합니다.

let a = 0
function foo() {
  a++
}

이것은 함수형 프로그래밍이 아닙니다.

function foo(a) {
  return a + 1
}

이게 함수형 프로그래밍입니다.

감이 오시나요? 둘의 차이가 뭘까요? 위의 함수는 외부 변수에 의존합니다. 아래 함수는 외부 변수에 의존하지 않습니다. 아래 함수는 뭐가 들어오든 인자에 +1 을 더해서 리턴합니다. 무언가가 끼어들 여지가 없습니다.

다시 말해서 함수형 프로그래밍은 외부 변수에 의존하지 않는, 사이드 이펙트가 없는 순수 함수들을 조합해서 ,읽기 쉽고 디버깅하기 쉬운 코드를 이끌어 내는 것입니다.


사이드 이펙트Side Effect라는 말을 들으면 우리는 흔히 부정적인 부작용을 떠올리지만, 프로그래밍의 세계에서는 그 의미가 조금 다릅니다. 다만 문제는 의미가 또 사람마다 다르다는 것이죠. 그래도 사이드 이펙트의 의미는 크게 두가지로 나뉠 수 있습니다.

  • 사이드 이펙트는 외부의 변수나 오브젝트에 영향을 끼치는 어떠한 작업을 뜻합니다.
  • 사이드 이펙트는 원했던 변화 이외의 다른 요소에 변화를 끼치는 것을 뜻합니다.

사이드 이펙트가 없는 순수 함수들을 조합한다고 했지만, 사실 프로그래밍에서 사이드 이펙트는 피할 수 없는 부분입니다. API를 호출하는 것도 사이드 이펙트고 디스크에서 무언가를 읽는 것도 사이드 이펙트를 유발합니다. 최대한 사이드 이펙트를 피해야 하는 것은 맞지만, 여기서는 그것을 잘 다루어야 한다는 의미로 이해하는게 편하실 겁니다.

좀 더 구체적인 예시를 보도록 합시다.


const sandwich = function() ;

예를 하나 들어 보겠습니다. 샌드위치를 만들어 주는 함수가 있다고 생각해 봅시다.

const addbutter = (sandwich) => {
  return `${sandwich} with butter`
}
const addLetuce = (sandwich) => {
  return `${sandwich} with letuce`
}
const addHam = (sandwich) => {
  return `${sandwich} with ham`
}
const makeSandwich = (bread, ...steps) => {
  return steps.reduce((bread, step) => {
    return step(bread)
  }, bread)
}
makeSandwich('honeybread', addbutter, addLetuce, addHam)
  • addbutter 함수는 인자 sandwich 를 받아서 문자열을 리턴합니다.
  • addLetuce 함수는 인자 sandwich 를 받아서 문자열을 리턴합니다.
  • addHam 함수는 인자 sandwich 를 받아서 문자열을 리턴합니다.
  • makeSandwich 함수는 인자 breadsteps 라는 여러 함수들을 받아서 샌드위치를 리턴합니다.

간단한 함수들입니다. 우리가 하는 일은 여러 함수들을 모으고 쌓아서 샌드위치를 만드는 것입니다. 외부 변수들을 참조하지 않고 있기 때문에 항상 같은 결과가 보장됩니다. 함수형 프로그래밍의 기본적인 원칙은 지켜진 것 같습니다. makeSandwich 에 다른 빵을 넣어도 항상 버터와 상추, 햄을 더한 샌드위치가 나오니까요. 그러면 반례를 봅시다.

let sandwich = 'honeybread'
const addbutter = () => {
  return (sandwich = `${sandwich} with butter`)
}
const addLetuce = () => {
  return (sandwich = `${sandwich} with letuce`)
}
const addHam = () => {
  return (sandwich = `${sandwich} with ham`)
}
const getSandwich = () => {
  return sandwich
}
addbutter()
addLetuce()
addHam()
const mySandwich = getSandwich()

차이점을 잘 보여드리기 위해 극단적인 예시로 짜 보았습니다. 이제 함수들은 외부에 있는 변수 sandwich 를 참조하고 있습니다. 두 예제 모두 동일한 결과를 리턴하고 있습니다. 지금은 말이죠. 그런데 아래 코드에서는 어떤 일들이 발생할 수 있을까요?

코드가 1억 줄의 코드 가운데 일부분이라고 생각해 봅시다. 누군가가 실수로 변수 sandwich 를 바꾸었습니다. 잘 작동하던 코드가 갑자기 다른 결과를 내뱉습니다. 변수를 참조하고 있는 코드가 너무 많기 때문에 어디서부터 디버깅을 해야 할지 모르겠습니다. 시간은 가는데 도대체 어디서 바뀐 건지 1억줄의 코드를 다 보아야 합니다.


default

만약 함수 내부에서 외부 변수에 대한 변경을 차단했었더라면 문제가 어디서 생겼는지 쉽게 알 수 있을 것입니다. 이것이 사이드 이펙트가 없는 순수함수들로 이루어졌다는 것의 장점입니다. 와, 그러면 순수 함수들로만 프로그래밍 하면 되지 않을까요? 네. 할 수 있다면 최대한 순수하게 만드는 것이 좋습니다.

그러나 위에 사이드 이펙트의 정의에서 적었듯, 외부에서 오는 변화는 프로그래밍에서 피할 수 없습니다. 외부 api에서 무언가를 받아와서 변수를 변화시키고 하는 일은 사실 너무나 당연한 일인 것 처럼요. 함수 내부에서 fetch를 하는 코드가 있다고 생각해 봅시다. 함수가 외부의 무언가를 변경시키려고 하고 있습니다. 순수한 함수는 아니지만, 쓸 수 없는 함수는 아닙니다.

변화가 없으면 지금까지 우리는 단순 마크업에 불과한 페이지들을 보고 있었야 할 것입니다. SPA 페이지를 하나 생각해 봅시다. 얼마나 많은 상태 변화가 일어나나요? 유저가 버튼을 클릭하고 스크롤을 내리고 이 모든 것이 변화를 일으킵니다.

피할 수 없는 거라면 즐겨라-라는 말처럼 변화를 피할 수 없으면 잘 해야 합니다. 여기서 불변성이라는 함수형 프로그래밍의 중요한 두 번째 키워드가 등장합니다.


불변성은 변하지 않는 것이 아니다.

The true constant is change. Mutation hides change. Hidden change manifests chaos. Therefore, the wise embrace history.

Redux를 쓰면서 그런 생각을 해본 적이 있습니다. 불변성을 지키기 위해 항상 reducer에 새로운 객체를 넣어야 합니다. 불변성을 지키기 위해 ‘새로운’ 객체를 넣으라고? 그러면 객체가 바뀌는 건데 이게 불변성을 지키는 거라고?

불변성이라는 단어를 보면 무엇이 떠오르시나요? 우리 사랑은 영원할 거야 같은 변하지 않는 무언가를 떠올렸다면 땡, 아닙니다. 함수형 프로그래밍에서 불변성이라는 변했다는 사실을 잘 아는 채로 변하는 것에 가깝습니다.

어떤 변수를 건드렸다면, 그걸 알 수 있어야 합니다. 제 지갑에 천원이 있어서 신나게 슈퍼에 갔는데 지갑을 열어보니 돈이 없습니다. 황당하죠. 자바스크립트에서도 비슷한 일이 일어날 수 있습니다. 정말 간단한 예시를 봅시다.

const sandwiches = [2, 1, 4, 3]
const sortSandwiches = sandwiches.sort()
const firstSandwich = sandwiches.slice(0, 1)
//firstSandwich=== ?

firstSandwich는 몇개일까요? 1입니다. 자바스크립트의 오브젝트는 mutable value, 즉 변형 가능한 값이기 때문입니다.

위의 코드를 보면 사용자는 원본 배열인 sandwiches 를 건들지도 않았는데 sort 메소드가 나도 모르게 원본 배열을 변형시켜 버렸습니다. 다시 코드가 1억줄이 있습니다. 아까와 같은 문제의 반복입니다. 그 밑에 sandwiches 의 값이 필요해서 썼는데 이상한 값이 나옵니다.

결국 불변성을 지킨다는 것은, sandwiches 를 변화시킬 때, 그 변화가 일어났다는 것을 알고 있어야 한다는 것입니다. 변화가 일어났다는 사실을 인지하고 이걸 다른 함수들에게 통보를 해 줄 수 있어야 계속 프로그램이 원하는 대로 동작할 수 있겠죠.

그래서 자바스크립트에서 불변성을 지키는 가장 쉬운 방법은 새 객체를 만드는 것입니다. 이 부분에 대한 설명은 너무 길어질 수 있어서 넣지 않겠습니다. 어쨌든 그래도 Redux에서 왜 새로운 객체를 넣으라는지 알 것 같습니다. 만약 새 객체를 넣지 않고, 기존 객체의 이전 상태를 건드렸다고 생각해 보세요. 위 사진의 Flux 패턴 자체가 붕괴될 것입니다.

불변성은 최적화에 있어서도 중요한 키 포인트가 될 수 있습니다. useCallback, useMemo 모두 다 같은 불변성이라는 원리에서 출발합니다. 변화의 유무를 통해서 다시 함수를 사용할지, 안 할지를 판별할 수 있기 때문입니다.


선언형


default

글의 윗부분에서 함수형 프로그래밍은 순수 함수들로 사이드 이펙트 없이 선언적으로 프로그래밍을 설계하는 것이라고 말씀 드렸습니다. 순수 함수와 사이드 이펙트 그리고 불변성은 어느정도 설명이 되었으리라 믿습니다.

그러면 이제 선언 이라는 키워드를 살펴볼 차례입니다. 선언적, 선언형이라는 것이 도대체 무슨 말일까요?

잠깐 제가 함수형 프로그래밍에 대해서 제가 가졌던 궁금증 이야기를 해보겠습니다. 처음 함수형 프로그래밍을 접하면서 충격을 받았던 것은, for loop를 쓰지 말라는 것이었습니다. 아니 for loop을 쓰지 말라고? 그러면 도대체 어떻게 iterate 하라는 거야? 이런 생각이 들었습니다.

함수형 프로그래밍에서는 for loop의 대안으로 reduce, map, filter를 쓰라고 말합니다. 도대체 왜, 이 메소드를 쓰라고 하는 걸까요? 그냥 for문 하나 쓰면 해결 될 일일텐데.

왜냐면 선언적으로 코드를 쓸 수 있기 때문입니다. 물론 선언적으로 코드를 쓸 수 있다는 것 뿐만이 아니지만, 저는 이것이 가장 큰 장점이라고 생각합니다. 예시를 위해서 아까 쓴 코드를 다시 불러와 보겠습니다.

const makeSandwich = (bread, ...steps) => {
  return steps.reduce((bread, step) => {
    return step(bread)
  }, bread)
}
makeSandwich('honeybread', addbutter, addLetuce, addHam)

이 코드를 for loop로 다시 쓰면

const makeSandwich = (bread, ...steps) => {
  let sandwich = bread
  for (let i = 0; i < steps.length - 1; i++) {
    sandwich = steps[i](sandwich)
  }
  return sandwich
}

가 됩니다.

어떤 차이점이 보이시나요? 일단 코드 길이가 늘어났습니다. 읽기도 조금 불편해 진 것 같습니다. 할당하는 부분도 생겼습니다.

차이점을 보았으니 좀 더 본질적인 부분을 생각해 봅시다. 우리가 makeSandwich 함수를 통해서 진정 이루고자 하는 것이 무엇일까요? 샌드위치를 하나 만드는 것입니다.

우리는 샌드위치를 만드는 것에 관심이 있어서 이 함수를 만든 것이지, 이 함수가 어떻게 버터를 넣고 어떻게 햄을 자르고 하는 것에는 관심이 없습니다. 물론 햄을 자르는 함수는 햄을 자르는 방법에 관심이 있을 것입니다. 그러나 적어도 makeSandwich 함수는 이 부분에 관심이 없습니다.

코드를 읽어봅시다. reduce 메소드는 어떤 엘리먼트를 순회하면서 값을 누적합니다. reduce라는 이름 자체에서 우리는 뭔가가 줄어든다는 것을 예상할 수 있습니다. steps 를 순회하면서 주어진 step 들을 bread 이라는 인자를 가지고 수행합니다. 무엇을 하나요? step 을 빵에 수행합니다.

함수형 프로그래밍으로 작성한 예제에서는 한눈에 makeSandwich 함수가 무엇을 하는지 알 수 있습니다. 슥 읽어보아도 주어진 step을 계속 실행하는군-이라고 알 수 있죠.

for loop로 작성한 예시를 봅시다. 한 줄씩 코드를 읽어 나가야 합니다. 물론 지금은 코드의 길이가 짧으니 괜찮다라고 생각이 들 수 있습니다. 그런데 for loop 안의 코드가 1억줄이면요? 1억줄을 다 읽어야 이 함수가 어떤 역할을 결국 하겠구나 이해할 수 있습니다. 결국 for loop 안의 코드들은 어떻게 하는지에 대한 것이지 무엇에 대한 코드는 아닌 것입니다.

다른 예시를 하나 더 보겠습니다.

CSS
.button {
  opacity: 0;
  transition: opacity 0.5s ease-in-out;
  width: 100%;
  height: 50vh;
  border: solid 3px black;
  cursor: pointer;
}
.button.active {
  opacity: 1;
  transition: opacity 0.5s ease-in-out;
}
JavaScript
  // Reduce opacity
  buttonElement.classList.toggle('active');
  buttonElement.style.opacity = 0;
  // Show button by increasing opacity
  buttonElement.classList.toggle('active');
  buttonElement.style.opacity = 1;

버튼이 눌러졌을 때 opacity를 바꾸는 기능을 작성하려면 이렇게 해야 합니다. 애니메이션을 선언적으로 쓸 수 있다고 말하는 framer-motion 이라는 라이브러리를 사용해서 해당 코드를 다시 쓴다면 어떻게 변할까요?

const variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1 },
}
return <motion.div initial="hidden" animate="visible" variants={variants} />

간단함은 제쳐두고라도 일단 initial 일때 hidden 이고 animate 해서 visible 하라는 이것이 무엇에 관한 코드인지 눈에 잘 들어옵니다. 이것이 선언형의 장점입니다.

물론 여기서 framer-motion 을 사용하는 것이 더 좋다고 말하는 것이 아닙니다. 단순히 선언형으로 쓴다는 것의 장점을 보여드리기 위해 예시를 든 것이니 오해하지 말기를 바라겠습니다.

선언형은 결국 함수가 무엇을 하는지에 더 집중하자는 것입니다. 간단하죠.


키워드 연결하기


default

각 키워드들이 어떤 것인지 알았으니 정리를 해 봅시다.

함수형 프로그래밍의 키워드들은 각기 긴밀하게 연결되어 있습니다. 순수함수와 불변성, 선언형 모두가 말이죠. 복습하는 차원에서 어떻게 연결되어 있는지 한번 볼까요?

순수 함수들은 어떤 인풋이 들어오든 일정한 값을 리턴합니다. 그 말은 곧 함수 자체를 값으로 볼 수 있다는 말과 같습니다. a를 넣으면 항상 a가 나온다면 그 함수는 곧 a입니다.

항상 주어진 무언가를 반으로 자르는 함수를 cutHalf 라고 이름 붙였다고 생각해 봅시다. 우리는 이 함수가 무엇을 하는지 압니다. 반으로 자르는 거죠. 그리고 만약에 햄을 넣었는데 햄이 1/3 으로 잘렸습니다. 이게 순수 함수들로 이루어진 함수형 프로그래밍 공장이라면 우리는 이 함수에 가서 어떤 문제가 있나 확인하면 됩니다.

선언형으로 쓰여진 함수 덕분에 기계 내부를 보지 않아도 이게 무슨 역할을 하는지 바로 알 수 있습니다. 순수 함수 덕분에 문제가 생기면 어디서 문제가 생겼는지 빨리 가서 고칠 수 있습니다. 저는 이것이 함수형 프로그래밍의 장점이라고 생각합니다.


마치며

천재 폰 노이만은 이렇게 말했습니다. 수학은 이해하는게 아니라 익숙해지는 것이다.

함수형 프로그래밍은 어렵고 헷갈립니다. 다만 자꾸 보다가 익숙해진다면 어느 순간 함수형 프로그래밍이 이런 거구나 하는 날이 오실 거라고 믿습니다. 이 글이 그 순간이 오는데 조금이나마 빨리 올 수 있도록 도움이 되길 바라겠습니다.