"객체지향은 가동부를 캡슐화하여 코드의 이해를 돕는다. 함수형 프로그래밍은 가동부를 최소화하여 코드의 이해를 돕는다." - 마이클 페더스, 트위터에 쓴 글에서

개발자는 본능적으로 확장성 좋고 깔끔한 애플리케이션 아키텍처를 구축하고 싶어합니다. 오늘날의 웹은 비동기 프로세스를 생성하고 이벤트를 처리하는 등 다양한 기술을 섭렵해야 합니다. 사람들은 유려한 외관에 리액티브 위젯이 달린, 네이티브 데스크톱/모바일 앱처럼 움직이는 웹 애플리케이션을 선호하죠.

자바스크립트에서도 데이터 및 데이터를 다루는 함수에 대해 진지하게 고민하는 프로그래밍 패러다임이 필요한 시기가 도래했습니다. 어플리케이션에서 중요시 여겨지는 설계 요소를 정리해보겠습니다.

  • 확장성: 추가 기능을 지원하기 위해 계속 코드를 리팩토링해야 함.
  • 모듈화 용이성: 파일 하나를 고치면 다른 파일도 영향을 받는가?
  • 재사용성: 중복이 많은가?
  • 테스트성: 함수를 단위 테스트하기 어려운가?
  • 헤아리기 쉬움: 체계도 없고 따라가기 어려운 코드인가?

이 중 한가지라도 해당한다면 함수형 프로그래밍은 여러분의 생산성을 높여주는 적합한 안내서가 될 것입니다.

이번 포스팅에선 함수형 프로그래밍에 대한 기본적인 개념과 함수형 프로그래밍 패러다임이 왜 등장했는지, 어떤 부분에서 유용한지 개론적으로 알아보겠습니다.

함수형 프로그래밍이란?

함수형 프로그래밍이란 부수효과를 방지하고 상태 변이(mutation of state)를 감소하기 위해 데이터의 제어 흐름과 연산을 추상화 하는 것 입니다.

난해하게 들리는 이 정의를 간단한 예제를 살펴보며 시작해보겠습니다.

document.querySelector('#msg').innerHTML = '<h1>Hello World</h1>';

위 예제는 하드코딩한 단순한 프로그램으로 메시지를 동적으로 표시할 수 없습니다. 내용이나 형식을 바꾼다던가, 타깃 요소를 달리 한다던가 하려면 표현식을 전부 재작성해야 합니다. 그러나 함수를 만들어 달라지는 부분만 매개변수로 주면 같은 코드를 다시 사용할 수 있습니다.

function printMessage(elementId, format, message) {
  document.querySelector(`#${elementId}`).innerHTML = `<${format}>${message}</${format}>`; 
}

printMessage('msg', 'h1', 'Hello World');

분명 나아지긴 했으나 완벽하게 재사용 가능한 코드는 아닙니다. 메시지를 HTML페이지가 아니라 파일에 쓴다면 어떨까요? 매개변수가 단순한 스칼라 값이 아닌, 특정 기능을 함수에 추가하여 매개변수로 전달하는, 함수의 매개변수화 과정을 떠올려야 합니다. 이는 함수형 프로그램에서 아주 왕성하게 활용됩니다. 여러 함수를 서로 합성하고 평가해서 더 많은 기능을 탑재하는 것이 유일한 목표니까요.

함수형으로 접근하면 조금 전의 코드가 어떻게 바뀌는지 보겠습니다.

var printMessage = run(addToDom('msg'), h1, echo);

printMessage('Hello World');

완벽히 이해가 되지 않아도 괜찮습니다. 여기선 "작은 함수들을 재료로 새로운 함수를 만들어낸 것 같다" 정도의 느낌을 받으시면 성공입니다.

위 코드는 재사용성과 신뢰성이 좋고 이해하기 쉬운, 더 작은 조각들로 프로그램을 나눈 후, 전체적으로 더 쉬운 형태의 프로그램으로 다시 조합하는 과정을 나타냅니다. 모든 함수형 프로그램은 이 기본 원리를 따릅니다.

run함수는 마법처럼 세 함수를 체인과 같이 연결해 한 함수의 반환값이 다른 함수의 입력값으로 전달되게끔 합니다. 사실 모던 자바스크립트를 잘 아시는 독자분들은 이미 함수형 프로그래밍에 많이 노출되어있죠.

함수형 코드는 본연의 기능은 그대로 간직한 채 코드를 쉽게 변경하기 위해 코드 자체를 매개변수화 합니다. 이렇게 하면 내부 로직을 바꾸지 않고 다른 일을 수행할 수 있죠.

var printMessage = run(console.log, repeat(2), h2, echo);

printMessage('Get Functional');

함수형 프로그래밍은 시각적으로도 명료한 접근 방법을 보여줍니다. 함수형 프로그래밍을 온전히 이해하려면, 먼저 이면에 깔려있는 다음과 같은 기본 개념을 숙지해야 합니다.

  • 선언적 프로그래밍
  • 순수함수
  • 참조 투명성
  • 불변성

함수형 프로그래밍의 특징을 하나씩 확인해보며 함수형 프로그래밍의 이해를 계속해 보겠습니다.

1. 선언적 프로그래밍


네이버 d2 강의에서 나온 함수형 프로그래밍의 위치입니다. 함수형 프로그래밍은 큰 틀에서 선언적 프로그래밍 패러다임에 속합니다. 내부적으로 코드를 어떻게 구현했는지, 데이터는 어떻게 흘러가는지 밝히지 않은 채 연산/작업을 표현하는 사상이죠.

우리는 명령형, 절차형 모델에 익숙해져 있는 경우가 많습니다. 각각의 구문 수행을 통해 문제를 "어떻게" 푸는지에 집중하는 방식이죠. 선언적 방식은 지금까지의 명령적 방식과 차이를 갖고 있습니다.

숫자 배열의 원소를 모두 제곱수로 바꾸는 간단한 예제를 확인해봅시다. 명령형 코드는 다음과 같은 방식일겁니다.

var array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for(let i = 0; i < array.length; i++) {
  array[i] = Math.pow(array[i], 2);  
}
array; // [0, 1, 4, 9, 16, 25 ,36, 49, 64, 81]

위에서 말했듯 명령형 프로그래밍은 컴퓨터에게 원하는 작업을 "어떻게" 하는지 나타냅니다.

이와 달리 선언적 프로그래밍은 프로그램의 서술부와 평가부를 분리하여, 제어흐름이나 상태 변화를 특정하지 않고도 프로그램 로직이 "무엇인지를 표현식으로" 나타냅니다. SQL구문이 대표적인 선언적 프로그래밍에 해당합니다.

같은 작업이라도 함수형으로 접근하면 개발자가 각 요소를 올바르게 작동시키는 일에만 전념하고 루프 제어는 시스템의 다른 파트에 일임할 수 있습니다.

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
	function(num) {
      return Math.pow(num,2); 
    }
);

// [0, 1, 4, 9, 16, 25 ,36, 49, 64, 81]

이전 코드와 비교하면 루프 카운터를 관리하고 배열 인덱스에 정확하게 접근하는 등을 개발자가 신경 쓸 필요가 없어집니다. 일반 루프는 함수로 추상하지 않는 한 재사용도 어렵습니다.

함수형 프로그래밍은 함수로 추상화 하는 작업입니다. map, reduce, filter등 일급 고계함수들은 재사용성, 확장성이 우수한 코드를 작성할 수 있도록 도와주죠.

화살표 함수를 사용하는 경우 코드는 더욱 깔끔해집니다.

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => Math.pow(num,2));
// [0, 1, 4, 9, 16, 25 ,36, 49, 64, 81]

루프는 재사용성이 떨어지고 다른 연산에 끼워 넣는 작업도 어려운 명령형 제어 구조물입니다. 또한 성격상 반복할 때 마다 값이나 상태가 바뀌게 됩니다. 그러나 함수형 프로그램은 무상태성, 불변성을 지향합니다. 따라서 루프와 같은 기능은 추상화 하여 사용합니다.

무상태 코드는 전역 상태를 바꾸거나 혼선을 일으킬 가능성이 없습니다. 상태를 두지 않으려면 부수효과와 상태 변이를 일으키지 않는 순수함수를 사용해야 합니다.

2. 순수함수와 부수효과

함수형 프로그래밍은 순수함수로 구성된 불변 프로그램 구축을 전제로 합니다. 이러한 방식은 분산형, 병렬 처리가 많은 현대 사회에서 공유자원 관리 등에 매우 유리할 수 있도록 도와줍니다.

순수함수의 특성은 다음과 같습니다.

  • 주어진 입력에만 의존하며 평가, 호출 간 변경될 수 있는 숨겨진 값이나 외부 상태와 무관하게 동작
  • 전역 객체나 레퍼런스로 전달된 매개변수를 수정하는 등 함수 스코프 밖의 변경을 일으키지 않음

명령형 프로그램에서는 변수가 값이 변하는 것이 기본입니다. 다음 함수를 보겠습니다.

var counter = 0;
function increment() {
  return ++counter; 
}

위 함수는 자신의 스코프 외부의 변수 counter를 읽고 수정하므로 불순합니다. 일반적으로 외부 자원을 읽고 쓰는 함수는 부수효과를 동반합니다. 또 Date.now()처럼 날짜/시간 함수도 미리 알 수 있는 일정한 결과값을 내지 않기 때문에 순수함수가 아닙니다.

여기서 counter는 암시적 전역변수를 통해 접근합니다. this 키워드를 거쳐 인스턴스 데이터에 접근하는 것 역시 부수효과가 유발되는 흔한 예입니다. 특히 자바스크립트의 this는 함수의 런타임 컨텍스트에 따라 달라지므로 더욱 코드가 헷갈릴 수 있습니다.

부수효과가 발생하는 상황은 다양합니다.

  • 전역 범위에서 변수, 속성, 자료구조를 변경
  • 함수의 원래 인수 값을 변경
  • 사용자 입력 처리
  • 예외를 일으킨 함수가 catch하지 않고 throw
  • 화면 또는 로그 파일에 출력
  • HTML, 브라우저 쿠키, DB에 질의

온갖 변경이 난무하는 프로그램에서 순수함수를 사용하는 것은 어려울 수 있지만, 실제 함수형 프로그래밍은 모든 상태 변이를 근절하는 것은 아닙니다.

상태 변이를 줄이고 관리할 수 있는 프레임워크를 제공하여 순수/불순 함수를 구분하여 사용하는 것이죠. 불순한 코드는 방금 전에 알아봤듯 가시적은 부수효과를 일으킵니다.

사회보장번호(SSN)로 학생 레코드를 검색하여 브라우저에 표시하는 명령형 프로그램을 살펴보겠습니다.

function showStudent(ssn) {
  let student = db.find(ssn); // 비동기지만 일단 동기로 가정하겠습니다.
  if(student !== null) {
    document.querySelector(`#${elementId}`).innerHTML = `${student.ssn}, ${student.firstname}, ${student.lastname}`; 
  } else {
    throw new Error('학생을 찾을 수 없습니다!'); 
  }
}

showStudent('444-44-4444');

위 함수는 몇 가지 부수효과를 일으킵니다.

  • 데이터에 접근하는 변수 db는 외부 변수입니다. 이 변수가 실행 중 언제라도 null을 참조하거나 호출 단계마다 상이한 값을 가리키면 결과값이 완전히 달라지고 무결성이 깨질 수 있습니다.
  • elementId는 언제라도 바뀔 수 있는 전역 변수입니다.
  • HTML 요소를 직접 고칩니다. HTML은 그 자체로 가변적인, 전역 공유 자원입니다.
  • 학생 레코드를 찾지 못해 예외를 던지면 전체 프로그램이 종료될 수 있습니다.

위 함수는 외부 자원에 의존하므로 코드가 유연하지 않고 테스트 역시 어렵습니다. 반면 순수함수는 서명에 정규 매개변수를 빠짐없이 명시하므로 코드를 이해하고 사용하기 쉽습니다.

함수형 프로그래밍을 생각하며 위 함수에서 두 가지를 개선해보겠습니다.

  • 긴 함수를 하나의 목적을 가진 짧은 함수로 분리
  • 함수가 해야 할 작업에 필요한 인수를 모두 명시하여 부수효과 를 감소

먼저 학생 레코드를 조회하는 일과 화면에 그리는 일을 분리할 수 있습니다. 외부 저장소, DOM과 연동되면서 비롯되는 부수효과는 불가피하지만, 조금이라도 더 다루기 쉽고 주요 로직에 충실한 코드로 떼어낼 수 있습니다. 여기서 커링이라는 함수의 여러 인수를 부분적으로 나누어 세팅하는 기법을 살펴보겠습니다.

var find = curry((db, id) => {
  let obj = db.find(id);
  if (obj === null) throw new Error('객체를 찾을 수 없습니다!');
  return obj;
});

var csv = student => `${student.ssn}, ${student.firstname}, ${student.lastname}`;

var append = curry((selector, info) => {
  document.querySelector(selector).innerHTML = info;
});

위 코드는 처음 코드에 비해 여러 가지 장점을 갖고 있습니다.

  • 재사용 가능한 컴포넌트 3개로 나뉘어 코드가 더 유연하다.
  • 함수의 재사용으로 관리할 코드 크기가 줄어 생산성이 높아진다.
  • 고수준에서 단계별로 보여주는 선언적 스타일을 따르므로 가독성이 높아진다.
  • HTML 객체와의 상호작용을 자체 함수로 빼내어 순수하지 않은 로직을 순수함수에서 배제한다.

아직 수정이 필요한 부분들이 남아있긴 하지만 부수효과를 줄임으로써 외부 조건 변화에 덜 취약한 프로그램이 되었습니다. 함수가 일관된 반환값을 보장하도록 해서 전체 함수 결과를 예측 가능한 방향으로 유도하면 여로모로 좋습니다. 이것을 참조 투명성이라고 하며 순수함수 본연의 특징입니다.

3. 참조 투명성과 치환성

참조 투명성은 순수함수를 정의하는 공식적인 방법이며, 순수성이란 함수의 인자와 결과값 사이의 순수한 매핑 관계를 의미합니다.

어떤 함수가 동일한 입력을 받았을 때 동일한 결과를 내면 이를 참조 투명한 함수라고 합니다.

var counter = 0;

function increment() {
  return ++counter; 
}

increment는 상태적 함수로 외부 변수 counter에 완전히 종속된 반환값을 내므로 참조 투명하지 않습니다. 참조 투명한 함수로 만들려면 함수가 의존하는 상태, 즉 외부 변수를 제거하고 함수 서명에 정규 매개변수로 명시해야 합니다.

var increment = counter => counter+1;

이제 increment는 같은 입력에 같은 결과를 반환하는 안전한 함수입니다.

이런 함수는 코드를 테스트하기 쉽고 전체 로직을 파악하는 것도 쉽습니다. 이렇게 구축한 프로그램은 시스템의 상태를 머릿속으로 그려볼 수 있고 코드를 재작성하거나 치환하더라도 원하는 결과를 얻을 수 있기 때문에 좋습니다.

주어진 입력을 처리해서 결과를 내는 일련의 함수들로 임의의 프로그램을 정의한다고 해보겠습니다. 의사 형식은 다음과 같습니다.

Program = [Input] + [func1, func2, func3, ...] -> Output

[func1, func2, func3, ...]이 모두 순수함수면 이들이 내는 결과를 바꾸지 않고 [val1, va2, val3, ...] 로 나열하여 프로그램을 쉽게 고칠 수 있습니다.

var input = [80,90,100];
var average = (arr) => divide(sum(arr), size(arr));
average(input); // 90

sum, size는 둘 다 참조 투명한 함수여서 이 표현식은 다음과 같이 바꾸는 것이 가능합니다.

average = divide(270, 3); // 90

divide는 100% 순수함수로 수식으로 표기도 가능합니다. 따라서 270/3 = 90 이죠. 참조 투명성 덕분에 이렇게 수학적인 형태로 프로그램을 헤아릴 수 있게 됩니다.

var sum = (total, current) => total + current;
var total = arr => arr.reduce(sum);
var size = arr => arr.length;
var divide = (a, b) => a/b;
var average = arr => divide(total(arr), size(arr));
average(input);

순수 함수형 프로그램의 밑바탕에는 이러한 사고방식이 내재되어 있으며, 부수효과가 있는 함수라면 이런 일이 가능하지 않습니다. 다른 포스팅에서 함수형 코드의 단위 테스트를 살펴보며 이 중요성을 다시 확인해보겠습니다.

함수 인수를 전부 명확하게 정의하면 스칼라 값을 비롯해 대부분의 경우 부수효과를 예방할 수 있지만, 객체를 레퍼런스로 넘길 때 실수로 객체에 변이를 일으키지 않도록 주의해야 합니다.

이런 부분에서 타입스크립트의 위상을 예상할 수 있겠습니다.

4. 불변 데이터 유지하기

함수형 프로그래밍의 장점

복잡한 작업의 분해

데이터 체이닝 처리

비동기 어플리케이션에서의 반응

profile
웹 개발을 공부하고 있는 윤석주입니다.

0개의 댓글