자바스크립트로 하는 함수형 프로그래밍에 대해서 글을 써볼까 합니다. 우연한 기회로 함수형 프로그래밍에 대한 관심을 갖게 됐고, 프론트엔드 개발을 하면서 적용했던 함수형 프로그래밍에 대해서 다뤄볼 예정입니다.

시작 글: 코드 스타일

두 번째 글: 함수 컴포지션, 커링

세 번째 글입니다. 오늘은 함수형 프로그래밍의 몇가지 특징에 대해서 살펴보도록 하겠습니다. 함수형 프로그래밍에 대해서 처음 공부할때 참고했던 대 부분의 글은 오늘 같이 살펴 볼 함수형 프로그래밍의 특징들 부터 시작합니다. 그런데 처음 함수형 프로그래밍을 접하는 경우 이런 특징들이 왜 필요한지 확 와닿지 않는것 같습니다. 저의 경우도 이해는 가지만 왜 필요한지까지는 몰랐던것 같습니다. 아무래도 기존에 개발해오던 패러다임과는 많이 다른형태기 때문에 이해하기 힘들었을것 같습니다. 물론 이 특징들부터 봐도 상관은 없겠지만 앞선 두 글에서 사용했던 예제를 다시 살펴보면서 함수형 프로그래밍의 특징들을 대입하면 조금 더 이해하기 쉽지 않을까 합니다.

함수형 프로그래밍의 특징

객체지향 프로그래밍(OOP)를 하기 위해서 몇가지 특징들이 필요합니다. 추상화, 캡슐화, 댜형성 등 여러가지가 있죠. 이와 마찬가지로 함수형 프로그래밍을 하기 위해서 필요한 특징이 있습니다. 일급 함수(First class function), 순수 함수(Pure function)라는 특징이 있습니다.

일급 함수(First class function)

일급 함수라는 말은 함수를 객체로 취급하는 것을 두고 말합니다. 더 쉽게 말하면 일반 변수와 같이 취급할 수 있다는 뜻입니다. 어떤 값을 변수에 저장하고, 함수를 호출할 때 파라미터로 넘기고, 함수의 결과로 어떤 값을 리턴할 수 있습니다. 이와 마찬가지로 함수도 변수에 저장하고, 파라미터로 넘기고, 함수의 결과로 함수를 리턴할 수 있을때 일급함수라는 말을 쓸 수 있게 됩니다.

함수를 변수에 저장

자바스크립트에서는 일급함수의 특징을 가지고 있습니다. 함수를 변수 형태로 저장할 수가 있죠. 아래 함수는 첫 번째 글에서 살펴봤던 예제에서 사용했던 함수 중 하나입니다. 일반적으로 자바스크립트에서 함수를 선언할 때 아래와 같이 선언 할 수 있습니다.

function startCase(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

startCase('hello'); // Hello

이 함수를 변수에 할당하는 형태로 선언 할 수 있습니다. 그리고 할당한 변수를 통해서 함수를 호출할 수 있죠.

const startCase = (str) => {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

const startCase = function(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

startCase('hello'); // Hello

함수를 파라미터로 전달

함수를 파라미터로도 전달할 수 있습니다. 대표적인 예로 이벤트 리스너의 콜백 함수를 생각하면 됩니다. 버튼을 클릭했을때 동작을 추가하기 위해 콜백 함수를 파라미터로 전달하는 예입니다.

document.querySelector('.my-button').addEventListener('click', (event) => {
  console.log('버튼 클릭!');
})

함수를 리턴하는 함수

그리고 함수의 결과로 다시 함수를 리턴할 수 있습니다. 두 번째 글에서 커링을 설명할 때 나온적이 있습니다. 커링을 하기 전에는 최정 결과만을 반환하는 함수 입니다.

Before

pipe(
  person => dissoc('age', person),
  person => rename({work: 'job'}, person)
)(person); // { name: 'nakta', job: 'developer' }

하지만 커링 기법을 적용하면 모든 파라미터를 넘기기 전까지는 나머지 파라미터를 받는 함수를 다시 반환하게 됩니다.

After

pipe(
  dissoc('age'), // age 필드를 제거할 객체를 받는 새로운 함수를 반환
  rename({ work: 'job' }) // work 필드명을 job으로 변경할 객체를 받는 새로운 함수를 반환
)(person); // { name: 'nakta', job: 'developer' }

즉, dissoc('age')rename({work: 'jbo'}) 의 결과는 모두 함수를 반환하게 됩니다. 일급 객체의 특징을 이용해서 함수를 반환하기 때문에 Befer 코드에서 불필요한 함수형태를 줄여서 After와 같은 형태로 코드를 작성할 수 있습니다.

순수함수 (Pure function)

두 번째 특징은 순수함수입니다. 참조 투명성과 사이드 이펙트가 없는 함수를 순수함수 라고 합니다. 첫 번째 글에서 사용한 negate 함수를 이용해서 살펴보도록 하죠.

참조 투명성

어떤 함수가 같은 파라미터에 대해서 같은 리턴값을 언제나 보장하는 것을 참조 투명성이라고 합니다.

negate 함수는 숫자를 받아서 음수는 양수로 양수는 음수로 전환하는 함수입니다. 아래 함수는 같은 입력에 대해서 항상 같은 출력을 반환합니다. 입력 받은 값에 대해서 * -1을 해주기 때문에 절대로 다른 값이 반환될 일이 없습니다.

// pure
const negate = (num) => {
  return num * -1;
}

negate를 살짝 변경해볼까요? num을 파라미터로 받지 않고 외부에 있는 값을 참조하도록 했습니다. 아래 함수는 순수하지 않은 함수입니다. num이라는 변수가 함수 외부에 있고 이 변수를 변경하고 있는데 지금 상황에서는 num이 10이라고 생각하고 있지만 만약 다른 곳에서 num을 바꾸게 되면 예상했던 -10 또는 10을 반환하지 않게 됩니다.

let num = 10;

// impure
const negate = () => {
  return num * -1;
}

조금 더 와닿는 예제를 하나 더 만들어보겠습니다. 5 보다 큰 값인지 확인하는 함수입니다. 아래 예제는 순수하지 않은 함수입니다. 파라미터로 10, 15, 20 을 넘기면 언제나 true를 반환한다고 기대하지만, maxNum이라는 값을 다른 곳에서 변경하게 된다면 어떻게 될까요? 만약 20으로 변경하게 된다면 true라고 예상했던 결과값이 모두 false로 뒤바뀌게 됩니다. 이런곳에서 버그가 발생하게 됩니다.

let maxNum = 5;

// impure
const isGraterThanFive = (num) => {
  return num > maxNum;
}

위 함수를 순수함수로 바꾸면 아래와 같습니다. 기준으로 잡은 5라는 값이 외부에 선언된 변수가 아니기 때문에 외부 영향으로 바뀔 일이 사라지게 됩니다. 이 함수는 언제나 같은 입력에 대해서 같은 결과값을 기대할수 있는 순수함수입니다.

const isGraterThanFive = (num) => {
  const maxNum = 5;
  return num > maxNum; // 또는 return num > 5;
}

불변 (Immutability)

입력받은 파라미터나 외부 변수를 변경하지 않는 것을 불변이라고 합니다. 절차지향 프로그래밍 스타일과 대조되는 특징중 하나입니다.

지금까지 제가 들었던 예제에서 공통점을 발견하신 분이 계실지 모르겠습니다. 모두 새로운 값을 반환한다는 부분입니다.

const replaceSpace = (str) => {
  return str.replace(/(_|-)/, ' ');
}

const startCase = (str) => {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

const changePartStartCase = (str) => {
  return str.split(' ').map(startCase).join(' ')
}

세 함수에서 사용한 함수에서 사용한 함수의 반환값은 새로운 값을 반환한다고 명시돼있습니다.

replace 반환 값: 어떤 패턴에 일치하는 일부 또는 모든 부분이 교체된 새로운 문자열.

toUpperCase 반환 값: 대문자로 변환한 새로운 문자열.

slice 반환 값: 추출한 요소를 포함한 새로운 배열.

이렇게 입력 받은 값을 오염시키지 않고 새로운 값을 반환하는 함수를 불변이라고 말할 수 있습니다. 즉, 사이드 이펙트(Side Effect)가 없다라고 생각하면 될것 같습니다.

꼭 순수함수를 써야하나??

개발자 스스로 순수함수를 쓰지 않고도 잘 개발할 수 있다면 상관은 없겠지만 어디서 어떻게 값이 변할지 모르는 코드에 대해서는 확실성을 갖기 힘들다고 생각합니다. 이런 측면만 보더라도 순수함수의 장점이 충분하지만, 몇 가지 장점이 더 있습니다.

첫 째, 캐시가 가능합니다. 항상 같은 입력에 대해서 같은 값을 반환하기 때문에 함수의 로직이 복잡하고 큰 계산이 드는 함수라면 memoize를 이용해서 캐시를 할 수 있습니다.

const memoize = (func) => {
  const cache = {};

  return (...args) => {
    const cacheKey = JSON.stringify(args);
    if (!cache[cacheKey]) {
      return cache[cacheKey] = func(...args);
    }
    return cache[cacheKey];
  }
}

함수를 받아서 cache라는 객체를 이용하는 새로운 함수를 반환합니다. 입력 파라미터에 대한 계산 결과가 있다면 캐시 값을 반환하고 그렇지 않으면 함수를 실행해서 결과값을 저장하는 기법입니다.

둘 째, 테스트가 아주 쉽습니다. 이유는 역시 같은 입력에 대해서 같은 출력을 내기 때문에 기대하는 값이 명확하기 때문입니다. 그래서 불필요한 목킹을 할필요도 없게 되는 것이죠.

셋 째, 병렬 처리에 유리하다. 순수함수는 외부 함수에 대한 참조가 없고, 사이드 이펙트를 만들지 않기 때문에 병렬처리가 가능합니다. 공유 메모리가 없기 때문에 race condition등과 같은 문제를 걱정할 필요가 없게 되죠.

종합

함수형 프로그래밍의 특징에 대해서 알아봤습니다.

  1. 일급 객체: 함수를 변수처럼 사용 가능
    • 함수를 변수에 할당
    • 함수를 파라미터로 전달
    • 함수를 함수의 결과로 반환
  2. 순수 함수
    • 참조 투명성
    • 불변

그리고 순수함수의 장점

  1. 캐시가 가능하다.
  2. 테스트가 쉽다.
  3. 공유 자원이 없어서 병렬처리에 유리하다.

다음은?

다음 내용은 함수자(Funtor)에 대해서 살펴보도록 하겠습니다.

부족한 설명 끝까지 읽어주셔서 감사합니다.