함수형 프로그래밍, 입문이라도 해보자

HyunHo Lee·2025년 3월 6일
11

프론트

목록 보기
58/58
post-thumbnail

진입 장벽은 생각보다 없다.

평소에 함수형 프로그래밍(FP)에 관한 컨퍼런스 자료나 기술 블로그를 마주하면, 바로 읽기 보다는 나중으로 미뤘다. 궁금한 주제이기는 했지만, 회사에서 사용하는 개발 방법론과는 거리가 멀었고, 당장 만들고 있는 서비스에 즉각적으로 도움이 될지에 대한 의구심 들었기 때문이다. 또한, 여러가지 진입 방벽이 있다는 이야기를 듣고, 나중에 시간을 내서 제대로 공부 해보겠다는 마음도 있었다. 그렇게 나는 1년이 넘는 시간동안 FP에 대한 생각을 지웠다.

그러던 어느 날, 도서관에서 함수형 프로그래밍 관련 신착 도서를 발견했다. 평소에 기술 서적을 고를 때에는 유명하다는 책만 골랐는데, 이번에는 무언가에 홀린듯이 바로 대출했다. 마음 속 깊이 자리잡고 있던 답답함을 해소하기 위한 몸부림이었던 것 같다. 솔직히 나에게 잘 맞는 책은 아니었지만, 이 책을 시작으로 FP 관련 서적들을 읽게 되었다. 나중에는 사이드 프로젝트에 적용해보고, 어느정도 함수형 프로그래밍에 확신이 생긴 시점에서 회사 서비스에 일부 적용하는 것이 목표이다.

이 글의 목적은 'FP의 진입 장벽에 주춤' 하는 사람들을 위한 글이다. 시작이 반이라는 말이 있듯이, 이 글을 통해 다른 개발자들도 함수형 프로그래밍에 입문해보면 좋을 것 같다. FP에 대한 개념과 목적을 간단하게라도 알고 있으면, 나중에 시대의 흐름이 바뀌더라도 유연하게 대응할 수 있다.

람다JS, lodash/fp, fp-ts, Folktale등을 사용하면, 더 효율적인 FP를 할 수 있다.


함수형 프로그래밍(FP)의 목적

조금이라도 더 예측 가능하고 ,테스트 하기 좋은 코드를 만들기 위해 항상 고민한다. SOLID 원칙이나 다양한 방법론들을 적용하다 보면, 자연스럽게 따라오기도 한다. 함수형 프로그래밍도 패러다임만 다를 뿐, 목적은 마찬가지다. 상태 변화와 부작용(side effect)을 최소화하고, 순수 함수와 불변 데이터를 활용해 프로그램을 선언적으로 작성하여, 에러를 줄이고 DX를 향상시키는 방법론이다.

C, Assembly와 같은 근본(?) 언어는 명령형 프로그래밍에 최적화 되어있다. 여러가지 이유가 있겠지만, 위의 컴퓨터만 봐도 짐작이 간다. 과거의 제한된 컴퓨터 성능이 언어 설계와 개발 철학에 영향을 미친 것이다. 연산 속도가 지금처럼 빠르지 않았고, 메모리 역시 극도로 제한적이었다. 따라서 프로그래밍 언어는 CPU 명령어와 직접적으로 매핑될 수 있는 구조를 가지는 것이 효율적이었다. 개발자들은 명확한 실행 흐름을 정의하고, 프로세서를 직접 제어하는 방식인 명령형 프로그래밍을 사용하게 된다. 자바스크립트 코드만 봐도 for문을 흔하게 접할 수 있다.

지금은 하드웨어와 컴파일러 기술의 발전으로, 추상화된 코드도 실행할 수 있게 되었다. 평소에 벼루고 있던 명령형 프로그래밍의 단점들을 보완할 수 있게 된 것이다. 이렇게 선언형 프로그래밍을 통해 복잡성 감소시키고, 생산성을 향상시키며, 병렬성을 보장하는 등 여러 장점을 취하게 된다. 이 방법론은 객체지향 또는 함수형 프로그맹과 함께 사용되며, DX를 더욱 향상시키고 있다.

객체 지향이 등장하기 전에는 변경 가능한 수 많은 상태를 관리하는 것이 어려웠다. 그래서 상태를 코드 전체에 분산시키는 것이 아니라, 캡슐화를 통해 해결하게 된다. 형태가 비슷한 것들을 하나의 변수에서 관리하고, 변경은 메소드를 통해 하는 것이다. 이는 통해 변경 가능성의 제어, 코드 가독성과 유지보수성 향상, 의존성 감소, 유연한 확장 등의 장점이 있다. 어찌됐든 객체 지향은 상태를 변경하는 방법론이다. 반면에 함수형 프로그래밍은 상태 변화 자체를 지양하는 방법론이다. 오늘은 이와 같은 FP의 특징들을 알아볼 것이다.

웹 프론트엔드에서도 FP를 위한 좋은 라이브러리들이 많이 나왔다. 심지어 요즘에는 이미 FP를 채택하고 있는 회사도 많다. 명령형에서 선언형으로 넘어간 것 처럼, 객체지향에서 함수형으로 넘어갈 때의 대비를 해두는게 좋다. 그렇지 않더라도 FP에서 배울점이 많다는 것은 분명하다. 다음으로 FP에서 중요한 개념인 불변성, 참조 투명성, 지연 평가 등에 대해 간단히 알아보자.

리엑트에서 함수형 컴포넌트는 UI 로직을 단순화하고 컴포넌트를 효율적으로 관리하기 위해 등장했다. FP의 철학을 모두 반영한 것이 아니며, 함수형 컴포넌트를 사용하는 것은 리엑트만의 특화된 패턴을 이용하는 것이다. 그러므로 함수형 컴포넌트를 사용하는 것은 FP를 하고 있는 것과는 다르다.


불변성(Immutable)

FP는 코드를 복잡하게 만드는 주 요인은 상태 변경이며, 상태 변경의 최소화하자고 주장한다. 한 번 설정한 변수는 이후에 값을 변경할 수 없다는 의미이다. 새로운 변수를 만들어 새로운 값을 할당하고, 이 과정에서 기존 값을 변경하는 것이 아닌, 새로운 값을 생성하는 것이다.

FP와 리엑트는 불변성을 중요시 한다. 하지만 그 목적과 적용 방식에는 차이가 있다. FP에서는 코드의 안정성과 예측 가능성을 위해 불변성을 유지하지만, 리엑트에서는 효율적인 상태 변화 감지와 UI 업데이트를 위해 불변성을 활용한다.

// 명령형
function getNumberArray(): number[] {
    const result: number[] = [];
    for (let num = 0; num 100; num++) {
      	console.log(num);
        result.push(num);
    }
    return result;
}

명령형 프로그래밍의 특징 중 하나는 상태 변화가 많다는 것이다. getNumberArray 함수의 for문에서는 내부적으로 num이라는 상태를 선언하고, 이 값을 증가시키고 있다. num의 상태 변화를 통해 result를 완성하여 리턴하고 있는 것이다.

// 함수형1: 재귀
function getNumberArray(num: number = 0, acc: number[] = []): number[] {
    if (num > 99) {
        return acc; // 결과 반환
    } else {
      	console.log(num);
        return logCountRecursive(count + 1, [...acc, num]);
    }
}

// 함수형2: 선언형
function getNumberArray(): void {
    const result = Array.from({ length: 100 - num }).map((_, index) => {
        const currentNum = num + index;
        console.log(currentNum);
        return currentNum;
    });
    return result;
}

재귀 FP를 활용 하면, 상태 변화 없이 getNumberArray함수를 만들 수 있다. 선언형 예시에서도 num의 값을 직접적으로 수정하는 것이 아닌, 불변성을 유지한 채로 새로운 값 currentNum를 생성하여 처리하고 있다.

FP의 주요 철학 중 하나는 데이터의 흐름을 제어하는 것이다. 함수는 입력 데이터를 변환하여 결과를 반환하며, 다른 함수는 이 반환값을 이어받아 처리한다. 만약 함수가 void(값이 없음)를 반환하면, 이 함수는 어떤 결과도 제공하지 않으므로 데이터의 흐름이 끊어진다. 일관적인 값을 리턴하도록 명시하고, 에러를 핸들링하는 것이 중요하다.

고차 함수(Higher-Order Function)는 함수를 인수로 전달받거나, 함수를 반환값으로 반환하는 함수이다. 우리가 흔히 사용하고 있는 map, filter, reduce 등이 고차 함수이다. 불변성을 유지하면서, 데이터 처리를 간결하게 표현하는 선언적 방식을 제공하기 때문에, 함수형 프로그래밍과는 죽이 잘 맞는다. 고차 함수는 문제를 더 작은 단위로 나누고, 각 단위를 독립적으로 조합하여 처리하는데 사용되는 것이다.


참조 투명성 / 순수성(≒ 순수 함수)

// 순수하지 않고, 참조 투명하지 않은 예시
let globalNum = 2;

function multiply(x: number): number {
    return x * globalNum; // 외부 상태 multiplier에 의존
}

console.log(multiply(3)); // 결과: 6
globalNum = 3;
console.log(multiply(3)); // 결과: 9 (다른 결과가 나옴)

외부 상태를 변경하거나, 외부 상태에 의존하는 것을 불순하다고 말한다. multiply 함수는 외부 상태인 globalNum를 참조하고 있다. 동일한 입력값에 대해 결과가 달라지는 불순 함수인 것이다. 이는 디버깅과 테스트를 어렵게 하고, 상태를 복잡하게 만드는 원인이 될 수 있다. FP에서는 이와 같은 불순성을 제거하여, 코드의 안정성과 가독성을 높이는 것을 목표로 한다.

// 순수하며, 참조 투명한 예시
function add(x: number, y: number): number {
    return x + y;
}

const result = add(2, 3); // 결과: 5

console.log(add(2, 3)); // 5
console.log(add(2, 3)); // (항상 동일한 결과 반환) => 순수
console.log(5); // 'add(2, 3)'를 '5'로 대체해도 프로그램의 동작은 변하지 않음 => 참조 투명

참조 투명성표현식을 그 값으로 대체해도 프로그램의 동작에 영향을 주지 않는 성질이다. 순수 함수함수가 외부 상태에 의존하거나 변경하지 않고, 동일한 입력값에 대해 항상 동일한 출력값을 반환하는 것을 의미한다. 순수성과 참조 투명성은 동의어처럼 사용해도 될 정도로 차이가 미묘하기 때문에, 너무 깊게 생각하며 고민하지 말자.

Date나 random과 같은 함수들도 동일한 출력값을 반환하지 않기 때문에 순수 함수가 아니다.


평가 전략

즉시 평가(Eager Evaluation)

평가 전략은 프로그래밍 언어가 표현식을 언제, 어떻게 계산할지를 결정하는 방식이다. 주요 유형으로는 즉시 평가와 지연 평가가 있다. 즉시 평가함수가 호출되면, 매개변수가 평가된 후에 함수 본문이 실행 되는 것을 의미한다. 이는 모든 매개변수가 조건과 상관없이 즉시 실행된다는 것이다. 대부분의 언어는 즉시 평가를 기본으로 하며, 타입스크립트도 마찬가지다.

// 즉시 평가
function eager(condition: boolean, thenAction: void, elseAction: void): void {
  if (condition) {
    console.log("condition true");
  } else {
    console.log("condition false");
  }
}

eager(
  1 > 0,
  console.log("thenAction complete"),
  console.log("elseAction complete")
);

// 출력
// thenAction complete
// elseAction complete
// condition true

eager 함수를 호출할 때, 인자가 어떻게 평가되는지 알아보자. 첫 번째 인자인 1>0true로 평가된다. 두 번째 인자인 console.log("thenAction complete")는 값을 반환하는 것이 아닌 단순히 출력하는 함수이다. 그 결과 thenAction complete가 출력되고 undefined를 반환한다. 세 번째 인자도 이와 유사하다.

인자가 평가된 이후에는 eager(true, undefined, undefined);가 실행된다. 마지막으로 조건문 결과에 따른 condition true가 출력되는 것이다.

지연 평가(Lazy Evaluation)

// 지연 평가
function lazy(
  condition: boolean,
  thenAction: () => void,
  elseAction: () => void
): void {
  if (condition) {
    thenAction(); // 필요할 때 호출
  } else {
    elseAction(); // 필요할 때 호출
  }
}

lazy(
  1 > 0,
  () => console.log("thenAction complete"),
  () => console.log("elseAction complete")
);

// 출력
// Then branch executed

지연 평가값이 필요할 때까지 계산을 미루는 방식으로 함수형 프로그래밍 언어에서 많이 사용된다. 타입스크립트에서도 지연 평가를 사용할 수 있다. 익명 함수인 () => console.log("thenAction complete")는 즉시 실행되지 않고, thenAction으로 전달 되그 elseAction도 마찬가지다. 그 결과 thenAction complete만 출력된다. 이렇게 지연 평가를 통해 실행 시점을 제어할 수 있고, 필요한 값만 계산하여 메모리 사용량을 줄일 수 있다.

실용적인 예제

function eagerSquare(x: number): number {
  console.log(`Calculating square of ${x}`);
  return x * x;
}

function main() {
  const value = eagerSquare(4); // 호출 시 바로 계산
  console.log(value);
}

main();

// 출력
// Calculating square of 4
// 16

즉시 평가의 다른 예시이다. eagerSquare(4)가 호출되는 순간 계산을 수행한다. 메모리에 값을 저장하는 방식이므로 자주 사용하는 값이면 유리하지만, 불필요한 연산이 발생할 수도 있다.

function lazySquare(x: number): () => number {
  console.log(`Preparing to calculate square of ${x}`);
  return () => {
    console.log(`Calculating square of ${x}`);
    return x * x;
  };
}

function main() {
  const deferredCalculation = lazySquare(4); // 계산을 준비하지만 실행하지 않음
  console.log("Before calling deferred function...");
  const value = deferredCalculation(); // 이 시점에 계산
  console.log(value);
}

main();

// 출력
// Preparing to calculate square of 4
// Before calling deferred function...
// Calculating square of 4
// 16

lazySquare(4)는 실제 계산을 수행하지 않고 함수를 반환한다. deferredCalculation()이 호출되는 순간에만 계산이 수행되므로, 비용이 큰 연산을 최적화할 수 있다.

함수형 프로그래밍에서 지연 평가를 선호하는 이유
1. 불필요한 연산을 방지하여 성능을 최적화할 수 있음.
2. 무한 데이터 스트림을 효율적으로 처리할 수 있음.
3. 순수 함수와 조합하면 부작용을 최소화할 수 있음.
4. 고차 함수(map, filter 등)와 함께 사용하면 연산을 최적화할 수 있음.


FP를 위한 기법

메서드 체이닝

const result = [1, 2, 3, 4, 5]
  .map((x) => x * 2)   // 배열의 각 값을 2배로 변경
  .filter((x) => x > 5) // 5보다 큰 값만 필터링
  .reduce((sum, x) => sum + x, 0); // 결과를 합산
console.log(result); // 18

메서드 체이닝일관된 흐름을 구성하여, 데이터가 어떻게 변환되는지를 명확하게 보여준다. 좋은 방식이지만, 몇 가지 단점도 존재한다. map, filter, reduce가 배열에 종속된 메서드이다. 위 예시를 보면, 데이터의 흐름이 array에 종속되고 있다.

중간에 새로운 작업을 추가하는 경우, 배열 메서드에 종속된 작업으로 해야한다. 즉, 작업의 컨텍스트를 유지해야 하는 것이다. 이번에는 체인 내 특정 구간에서 에러가 발생했다고 가정해보자. 상태를 추적하기 위해서는 메서드 체인을 중단하거나, 여러 부분에 로깅을 출력하는 코드를 넣어야 한다.


함수 파이프라이닝

// 파이프라이닝 정의
const pipe = (...fns) => (x) => fns.reduce((v, fn) => fn(v), x);

// 작은 함수들
const double = (x) => x * 2;
const greaterThanFive = (x) => x > 5;
const filter = (fn) => (arr) => arr.filter(fn);
const sum = (arr) => arr.reduce((acc, x) => acc + x, 0);

// 결합
const result = pipe(
  (arr) => arr.map(double), 
  filter(greaterThanFive), 
  sum
)([1, 2, 3, 4, 5]);

console.log(result); // 18

함수 파이프라이닝에서는 여러 개의 독립적이고 모듈화된 작은 함수들을 결합하여 처리한다. 데이터와 연산이 분리되어 있어, 배열이 아니라도 문제없이 동작한다. 또한, 함수가 독립적으로 설계되어 디버깅 및 재사용이 쉽고, 느슨한 결합으로 인해 동작도 함수들의 재조합을 통해 쉽게 바꿀 수 있다.

pipe는 파이프라이닝을 구현하는 고차 함수이다. 여러 함수(변환 단계)를 받아, 이를 데이터의 흐름(flow)에 따라 순차적으로 실행한다. 이 방식은 데이터 흐름을 함수의 조합으로 선언적으로 표현한다.


함수 합성

// 함수 합성 정의
const compose = (...fns) => (x) => fns.reduceRight((v, fn) => fn(v), x);

// 파이프라이닝에서 선언했던 작은 함수들...
const double ...

// 결합
const processNumbers = compose(
  sum,
  filter(greaterThanFive),
  (arr) => arr.map(double)
);

const result = processNumbers([1, 2, 3, 4, 5]);

console.log(result); // 18

함수 합성은 파이프라이닝과 같은 목표를 가지고 있다. 차이점은 함수 실행 순서가 반대라는 것이다. 첫 번째 함수부터 차례대로 실행되는 파이프라이닝과는 다르게, 합성은 내부 연산부터 실행된다.


구분파이프라이닝 (pipe)함수 합성 (compose)
실행 방향왼쪽 → 오른쪽오른쪽 → 왼쪽
가독성직관적 (데이터 흐름이 자연스러움)함수 조합을 강조 (수학적인 표현)
디버깅중간 값을 쉽게 확인 가능중간 디버깅 어려움
주요 사용 사례데이터 변환, UI 상태 관리, RxJS 등함수 조립, 재사용 가능한 로직 생성
예제pipe(f, g, h)(x) === h(g(f(x)))compose(f, g, h)(x) === f(g(h(x)))

pipe는 데이터가 순차적으로 변환되는 흐름을 강조한다면, compose는 최종 결과를 만드는 함수 자체를 조립하는 느낌이다. 자바스크립트에서는 pipe를 더 많이 사용하는 것 같다. 직관적이기도 하고, await / async와 함께 사용하는 경우에도 자연스럽다는 장점이 있다.


커링

// 일반적인 함수
const calculate = (a, b, c) => {
  return a + b + c;
};

console.log(calculate(1)); // NaN

커링다중 인자 함수를 단항 함수로 바꾸는 기법이다. 함수를 더 작은 단위로 나누어, 부분 적용이나 함수 합성을 할 수 있게 하는 것이다. 일반적인 함수는 모든 인수를 즉시 받기 때문에, 위 예시에서 bcundefined가 된다. 그 결과 1 + undefined + undefined 을 수행하여, NaN 이 출력된다.

// 커링된 함수
const calculate = (a) => (b) => (c) => a + b + c;

// 인자를 점진적으로 전달 가능
console.log(calculate(1)(2)(3)); // 6

// 특정 인자만 고정
const addOne = calculate(1);
console.log(addOne(2)(3)); // 6 (1 + 2 + 3)

const addOneAndTwo = addOne(2); 
console.log(addOneAndTwo(3)); // 6 (1 + 2 + 3)

커링은 점진적으로 인자를 전달 할 수 있어, 부분 인자도 고정이 가능하다. addOne을 보면, 첫 번째 인자를 먼저 고정하고, 나머지 인자는 나중에 전달하고 있다. 이처럼 다단계 구조로 로직을 설계할 수 있어, 코드의 가독성과 재사용성이 증가한다.

커링과 유사한 개념으로 부분 적용 함수 (Partial Application)가 있다. 커링은 모든 인자를 개별적으로 전달하는 방식이며, 완전한 단항 함수들의 조합을 만든다. 부분 적용은 여러 개의 인자를 한 번에 고정하고, 나머지 인자를 나중에 받을 수 있도록 한다.

  • 커링
const add = (a) => (b) => a + b;
add(2)(3); // 5
  • 부분 적용
const add = (a, b) => a + b;
const addTwo = add.bind(null, 2);
addTwo(3); // 5
const pipe = (...fns) => (x) => fns.reduce((v, fn) => fn(v), x);
const double = (x) => x * 2;
const greaterThan = (threshold) => (x) => x > threshold;
const filter = (fn) => (arr) => arr.filter(fn);
const sum = (arr) => arr.reduce((acc, value) => acc + value, 0);

// 파이프라인
const result = pipe(
  (arr) => arr.map(double),
  // 커링으로 특정 조건(> 5)을 고정
  filter(greaterThan(5)),
  sum
)([1, 2, 3, 4, 5]);

console.log(result); // 18

함수 파이프라이닝에서 greaterThanFive 함수는 독립적으로 정의 했었다. 연결성은 있지만, 느슨한 결합이 부족했다. 커링을 통해 greaterThan을 구성함으로써, 임계치를 설정할 수 있게 되었고, 모듈성 및 재사용성이 증가하게 된다. 또한, 간단한 인수 처리로 함수를 사용하여, 의도를 더 쉽게 파악할 수 있게 되었다.

커링과 파이프라이닝은 독립적인 개념이다. 커링은 파이프라이닝을 더 강력하게 만들어주는 핵심 도구 중 하나이며, 함수형 프로그래밍의 "작은 조각으로 문제를 해결하고 조립하는" 철학에 매우 잘 맞는다.


마무리 하기 전에..

데이터 흐름을 스트림 기반으로 다루는 패러다임리엑티브 프로그래밍(RP)은 함수형 프로그래밍(FP)과 함께 사용하면, 더 강력한 함수적 사고를 할 수 있다. 이런 방법론을 함수형 리엑티브 프로그래밍(FRP)이라고도 부르는 것 같다. RxJS도 이를 돕기 위해 나온 도구이다. 흥미가 있다면 테오의 RxJS 글을 보는 것을 추천한다.

마무리

평소에 함수형 프로그래밍을 사용하지 않았지만, 사이드 이펙트를 줄이기 위한 고민은 꾸준하게 했었다. 이 경험들은 FP를 이해하는데 도움을 많이 주었다. 책에서 소개된 내용이 이미 사용하고 있는 구조인 경우에는 재밌기도 하고, 신기하기도 했다. 결국 방법론들은 버그를 최소화 한다던가, 코드 품질 및 DX 향상을 통해 유지보수하기 좋은 환경을 구성하려고 수 많은 개발자들이 고민한 결과물이다. 나도 더 열심히 고민하면, '나만의 방법론을 만들 수 있지 않을까?' 라는 생각이 들었던 순간이었다.

책과 다양한 기술 블로그를 통해 FP 이론을 먼저 공부하고 있다. 이제 어떤 목표를 지향하고 있고, 여러가지 장점들이 있다는 것은 알겠다. 하지만 실무 레벨에서는 어떤 페인 포인트들이 있을지 궁금하다. 그래서 작은 규모의 사이드 프로젝트를 시작으로 함수형 프로그래밍으로 접해볼 예정이다. 아직 FP에 대해 모르는 것이 대부분이지만, 이제는 직접 사용해보면서 몸으로 느껴보는 것이 효율적일 것 같다.

profile
함께 일하고 싶은 개발자가 되기 위해 달려나가고 있습니다.

2개의 댓글

comment-user-thumbnail
2025년 3월 13일

재미있고 솔직한 경험담이네요! 함수형 프로그래밍(FP)은 한 번 발을 들이면 개발자로서 사고의 폭이 넓어지는 아주 흥미로운 분야죠. 당신의 글에서 FP에 대한 진입 장벽을 극복하는 과정과 배우며 느낀 점들이 잘 드러나 있어 읽는 이들에게 큰 동기부여가 될 것 같아요. 기억에 남는 부분 중 하나는 불변성과 참조 투명성에 대한 설명이에요. FP 철학의 핵심이 잘 나타난 대목이라 정말 공감됩니다. 또한 명령형 프로그래밍과 비교하며 설명해주신 코드 예제들이 FP 초심자들에게 많은 도움을 줄 것 같네요.

혹시 앞으로 FP를 학습하며 사이드 프로젝트에 적용하신다면 어떤 방식으로 활용하실 계획인지 듣고 싶습니다.

1개의 답글

관련 채용 정보