함수형 프로그래밍

함수형 프로그래밍
Photo by Jexo / Unsplash

프로그래밍의 패러다임은 10년전만 하더라도 절차 지향과 객체 지향으로 대부분 이루어졌지만, 이제는 함수형 프로그래밍 패러다임이 본격적으로 떠오르고 있다. 이런 추세를 반영해서 Swift, JavaScript, Kotlin 같은 언어들도 함수형 프로그래밍의 개념을 도입했다.

이는 복잡한 애플리케이션 개발과 데이터 처리에 있어 높은 효율성과 유지 보수성을 제공하기 때문이다. 함수형 프로그래밍은 순수 함수와 불변성 등을 중심으로 코드를 작성, 이로써 버그를 줄이고 코드의 가독성을 높일 수 있다. 이 글에서는 함수형 프로그래밍의 기본 개념과 장점을 소개하고, 어떻게 효율적으로 활용할 수 있는지 알아보겠다.

1급 객체 (First-Class Citizen) 란?

함수형 언어/프로그래밍을 이해하기 위해서는 우선 1급 객체 의 정의를 이해해야한다. 1급 객체는 다음과 같은 특징을 가지는데 자바스크립트 샘플과 함께 알아보자.

변수에 할당될 수 있다.

함수 자체가 다른 정수나 문자열처럼 어떤 변수에 할당될 수 있어야 한다.

const myFunction = function() {
  console.log("Hello, I am a function!");
};

변수에 할당될 수 있다 보니, 자료 구조에 저장하는 것도 자연스럽게 가능해진다.

const functionsArray = [myFunction, newFunction, runtimeFunction];
functionsArray.forEach(fn => fn()); // 각 함수가 순서대로 호출됨

함수의 인자(parameter)로 전달될 수 있다.

만약 swift나 typescript처럼 함수의 타입까지 따진다면, 모든 입력 파라미터와 출력 파라미터가 동일하면 동일한 타입이라고 정의할 수 있다.

function callAnotherFunction(fn) {
  fn();
}

callAnotherFunction(myFunction); // 출력: "Hello, I am a function!"

함수의 반환값으로 사용될 수 있다.

function returnFunction() {
  return function() {
    console.log("I was returned!");
  };
}

const newFunction = returnFunction();
newFunction(); // 출력: "I was returned!"

함수가 반환될 때 이 함수를 반환하는 곳에 있던 환경을 기억하는 것이 클로져의 중요한 특징이다. 그렇지만 이는 1급 객체의 필수 특징은 아니다.

function returnFunction(x) {
  return function(y) {
    console.log("기억된 값 x:", x);
    console.log("전달받은 값 y:", y);
    return x + y;
  };
}

const newFunction = returnFunction(5); // x에 5를 "기억"
console.log(newFunction(3));  // 출력: 기억된 값 x: 5, 전달받은 값 y: 3, 8

런타임에 생성될 수 있다.

const runtimeFunction = new Function('a', 'b', 'return a + b');
console.log(runtimeFunction(5, 3)); // 출력: 8

위와 같은 특징들이 1급 객체의 주요 특징들이다.

함수형 프로그래밍 패러다임

1급 객체를 이해했다면 이제 함수형 프로그래밍의 패러다임에 대해 이해해보자. 9가지의 특징을 자바스크립트 예제로 알아볼텐데, 패러다임을 잘 적용해 구현한 예와 그렇지 않은 예로 나누어 제시하겠다.

1급 객체 함수

함수형 프로그래밍에서 함수는 1급 객체로 변수에 저장해두고 재사용될 수 있다.

console.log(function(x) { return x * x; }(5));
잘못된 예: 함수를 변수에 저장하지 않고 재사용하지 않음
const square = x => x * x;
console.log(square(5));
잘 된 예: 함수를 변수에 저장하여 재사용

순수함수(pure functions)를 지향한다.

순수함수란 마치 수학의 함수처럼 동일한 입력값에 대해 언제나 동일한 출력값을 내고 어떠한 부수효과도 만들어내지 않는 함수를 뜻한다.

let total = 0;
function addAndStoreToTotal(a, b) {
  total += a + b;
}
잘못된 예: 부수효과가 있는 함수. 외부의 total이 바뀐다.
let total = 0;
function getTotal() {
  return total;
}
잘못된 예: 함수 외부의 변수를 참조하고, 항상 같은 결과를 리턴하지 않는다.
function getTotal(a, b) {
  const total = a + b;
  return total;
}
잘 된 예: 어떤 부수효과도 만들지 않고, 동일한 입력에 대해 언제나 동일한 결과를 리턴한다.

참조 투명성 (Referential Transparency)

위에서 말한 순수함수의 조건 중 출력부분, 즉 동일한 입력에 대해 동일한 출력을 보장하는 성질을 참조투명성이라고 한다.

let y = 5;
const addY = x => x + y;
잘못된 예: 함수의 출력이 입력만으로 결정되지 않음
const add = (x, y) => x + y;
잘 된 예: 동일한 입력에 대해 동일한 출력

불변성(Immutability)

기존의 패러다임에서 오류의 많은 부분은 변수가 생각하는 값을 더 이상 들고 있지 않기 때문인 경우가 많다.  함수형 프로그래밍에서는 불변성을 최대한 지킴으로써 이 문제를 해결하려 한다.

var newId = getMaxId(); // 이 순간 newId는 변수명대로 새id가 아니라 기존id중 최대값일 뿐이다. 
newId++;
const newElement = createElement(newId);
잘못된 예: 기존 값을 변경시킨다. 변수는 어떤 순간 이름과는 상관없는 값을 가진다.

위와 같은 코드에서 실수하는 사람은 거의 없을 것이다. 그렇지만 만약 var newId ... 와 newId++; 사이에 많은 줄이 들어간다면? 다른 개발자가 여기서 newId를 가져다 쓴다면?

const currentMaxId = getMaxId();  
const newId = currentMaxId + 1;
const newElement = createElement(newId);
잘 된 예: 변수에 담겨있는 값은 변하지 않고 매 순간 변수는 이름 그대로를 의미한다.

고차 함수(High-Orider Functions)

절차지형형 패러다임에서는 우리는 프로그램이 어떻게 동작할지에 집중하면서 프로그래밍한다. 함수형 패러다임에서는 선언적으로 무엇을 나타낼지에 집중하는 방식으로 프로그래밍한다.

이를 위해서는 추상화된 함수들이 실제로 일을 할 다른 함수들을 받을 수 있어야 한다. 다음 두 예를 비교해보자.

const arr = [1, 2, 3];
for(let i = 0; i < arr.length; i++) {
  arr[i] = arr[i] * arr[i];
}
잘못된(?) 예: arr[i]를 어떻게 채워야 하는지를 알려준다.

이는 이렇게 짤 수도 있다.

const arr = [1, 2, 3];
for(let i = 1; i <= arr.length; i++) {
  arr[i-1] = arr[i-1] * arr[i-1];
}
여전히 잘못된 예: 어떻게 해야하는지 계속 가르쳐준다.

이를 좀 더 함수형 패러다임에 맞게 바꾸어보자.

const arr = [1, 2, 3];
const squaredArr = arr.map(x => x * x);
잘 된 예

이 코드는 두가지 면에서 더 함수형 패러다임을 잘 반영한다.

  • 배열의 내용을 바꾸지 않고 두 배열은 각각 자기 이름 그대로를 의미한다.
  • 컴퓨터에게 arr의 각 원소를 제곱한 것이 squaredArr이야 라고 무엇인지 알려준다.

이것이 가능했던 이유는 array container가 제공하는 map 함수가 다른 함수를 인자로 받을 수 있었기 때문이다.

정의형 문법 (Declarative Syntax)

위에서 말한 무엇 을 지향하는 것을 구체적으로 정의형 문법이라고 한다. 이의 다른 예를 알아보자.

const arr = [1, 2, 3];
let sum = 0;
for(let i = 0; i < arr.length; i++) {
  sum += arr[i];
}
잘못된 예: 명령형 스타일로 배열의 합을 계산하는 법을 우리가 알려준다.

이렇게도 짤 수 있다.

const arr = [1, 2, 3];
let sum = 0;
for(let i = arr.length-1; i >= 0; i--) {
  sum += arr[i];
}
여전히 잘못된 예: 또다른 방법으로 계산법을 알려준다.

좋은 예는 합이란 무엇인지를 알려주는 것이다.

const arr = [1, 2, 3];
const sum = arr.reduce((acc, val) => acc + val, 0);
잘 된 예: reduce를 사용하여 선언적으로 배열의 합을 계산

함수의 조합으로 일을 처리 (Functional Composition)

함수형 패러다임에서는 어떤 일을 수행한다는 것은 다른 작은 일을 이것저것 수행하는 것이다.

function calculate(x) {
  return (x * x + 5) * 2;
}
잘못된 예: 복잡한 로직을 하나의 함수에서 처리
const square = x => x * x;
const addFive = x => x + 5;
const double = x => x * 2;

const calculate = x => double(addFive(square(x)));
잘 된 예: 작은 함수들로 분리하고 조합

지연된 계산 (Lazy Evaluation)

배려가 잘 되어 있는 언어들에서는 지연된 계산을 제공한다. 가령 원소가 100만개가 있는 배열에서 제곱해서 50이 넘는 첫 index를 알고 싶다면? 100만개의 squaredArr을 미리 구하는 것은 소모적이다.

이 때 squaredArr을 순회할 때 비로소 계산이 일어난다면 어떨까?

const arr = [1, 2, 3].map(x => x * x);
잘못된 예: 배열의 모든 원소를 미리 계산
function* lazySquares(arr) {
  for(const x of arr) {
    yield x * x;
  }
}
잘 된 예: 필요한 시점에만 계산 (Generators 사용)

재귀 (Recursion)

함수형 패러다임에서는 가능한 경우 반복문을 재귀로 해결하는 것을 선호한다.  잘 된 예를 먼저 보자.

const factorial = n => (n === 0 ? 1 : n * factorial(n - 1));

구형 컴파일러나 언어들에서 위 코드는 별로 효율적이지 못하다. 함수의 호출과 리턴이 단계마다 반복되기 때문이다. 그러나 최근의 최적화가 잘 되어있는 언어들에서는 10단계까지 들어가더라도 일일이 리턴하는 것이 아니라 한번에 모든 단계를 리턴해버리기도 한다. (꼬리 재귀 최적화, Tail Recursion Optimization) 따라서 이로 인해 얻는 손해보다 명쾌한 코드에서 오는 이득이 더 크다.

단, 이 최적화는 swift(지원), kotlin(tailrec 명시해야 지원), javascript(모든환경에서 지원하는 것은 아님) 등 언어마다 편차가 있다.

결론

지금까지 함수형 패러다임의 특징과 구현 예시를 살펴보았다.

함수형 프로그래밍 패러다임은 코드의 가독성, 유지보수성, 그리고 테스트 용이성을 향상시킬 수 있는 방법론 중 하나이다. 순수 함수, 불변성, 고차 함수 등의 개념은 로직을 분명하고 예측 가능하게 만들어 준다. 이런 접근 방식은 복잡한 애플리케이션과 대규모 시스템에서 특히 중요한데, 부작용이나 상태 변경을 최소화함으로써 디버깅을 더 쉽게 만들기 때문이다.

그러나 모든 상황에서 함수형 패러다임이 최선의 선택이라고 할 수는 없으며, 필요에 따라 다른 패러다임과 결합하여 사용하는 것이 효과적일 수 있다. 전반적으로 함수형 프로그래밍은 프로그래머에게 더 나은 추상화와 모듈화 도구를 제공하여 소프트웨어 개발을 더욱 효율적이고 안정적으로 만들 수 있다.

함수형 프로그래밍은 어떤 패러다임 이다. 우리가 특정 언어를 쓴다고 해서 꼭 지켜지는 것도 아니고, 다른 언어를 쓴다고 해서 구현할 수 없는 것도 아니다. 철학을 이해하고 이를 지킬수록 그 안에서 얻는 것이 있을 것이다.