콘텐츠로 이동

FP 기초: 순수 함수, 부수효과, 불변성

함수형 프로그래밍(FP)은 프로그램을 순수 함수의 조합으로 구성하는 패러다임입니다. 이 글에서는 FP의 가장 기본적인 개념들을 정리합니다.

함수형 프로그래밍을 이해하기 전에, 명령형과 선언형의 차이를 먼저 살펴보겠습니다.

프로그램의 상태에 대한 문장들을 작성하는 스타일입니다. 절차적 프로그래밍, 객체 지향 프로그래밍 등이 이에 해당하며, 조건문과 반복문으로 프로세스를 다룹니다.

(function (fns) {
let acc = x;
for (const fn of fns) {
acc = fn(acc);
}
return acc;
})(x);

반복문을 사용하고, 각 루프에서 무엇을 해야 하는지 명시합니다.

원하는 결과를 묘사하는 방식으로 코드를 작성합니다. 어떻게 할지가 아니라 무엇을 할지에 집중합니다.

(function (fns) {
return fns.reduce((v, f) => f(v), x);
})(x);

반복을 순회하라는 코드도, 값을 어떻게 저장하는지도 적을 필요가 없습니다.

함수형 프로그래밍에서는 모든 코드를 세 가지로 분류합니다.

  • 부르는 시점과 횟수에 의존
  • 호출 시 주의가 필요
  • 예: 이메일 보내기, DB에서 데이터 가져오기

고려 사항:

  • 순서를 보장하는 방법
  • 정확히 한 번만 실행되게 보장하는 방법
  • 시간이 지나도 안전하게 상태를 바꾸는 방법
  • 실행해야 결과를 확인 가능
  • 횟수와 시점과 무관하게 항상 같은 값 반환
  • 예: 쿠폰 등급 결정, 어떤 이메일이 쿠폰을 받을지 결정

고려 사항:

  • 정확성을 위한 정적 분석
  • 테스트 전략
  • 실행 불가하며, 그 자체로 확인 가능
  • 예: 이메일 제목, 이메일 주소, 추천 수
  • 액션에서 최대한 계산을 분리할 것
  • 계산에서는 데이터를 분리
  • 액션이 계산이 될 수 있는지, 계산은 데이터가 될 수 있는지 고민할 것
  • 데이터는 다른 영향을 주지 않으므로 데이터부터 먼저 찾아야 한다
  • 계산은 때로 우리 머릿속에서 일어난다 - 무언가를 결정하거나 계획한다면 그것이 계산

순수 함수는 세 가지 특성을 갖습니다:

  1. 사이드 이펙트가 없다
  2. 같은 인수에 대해 항상 같은 값을 반환한다
  3. 불변성을 유지한다
// 순수하지 않은 함수 - 외부 변수에 의존
let value = 1;
const notPureFunc = () => value + 1;
// 순수 함수 - 인자로 전달받아 처리
const pureFunc = (val) => val + 1;
value = pureFunc(value);
// 순수하지 않은 push - 원본 배열 변경
const arr = ["hi", "there"];
const notPurePush = (elem) => arr.push(elem);
// 순수 함수 - 새 배열 반환
const pushElement = (arr, elem) => [...arr, elem];
const newArr = pushElement(arr, "bye");

다음과 같은 동작이 부수효과에 해당합니다:

  • 전역 데이터를 변경한다
  • 다른 함수의 인수를 변경한다
  • 예외가 발생한다
  • 외부 작업을 유발한다 (네트워크 요청 등)
  • Screen 혹은 Logging(console.log 포함)을 유발한다
  • 다른 함수의 부수효과를 유발한다
let cnt = 0;
let increment = () => {
cnt++;
return cnt;
};

cnt 변수가 어디서 변경될지 모르고, 다른 함수들에게 의존성이 생깁니다.

let increment = (num: number) => (num += 1);
let getAverage = (arr: number[]) => {
const total = arr.reduce((acc, cur) => acc + cur, 0);
const average = total / arr.length;
return average;
};
const average = getAverage([1, 2, 3, 4, 5]);

함수는 외부 데이터를 변경하는 것이 아니라 인자로 전달받아야 합니다. 입력이 같다면 항상 같은 값을 반환해야 합니다.

그러나 부수효과는 결국 발생해야 합니다. 함수형 프로그래밍의 목적은 부수효과를 무조건 지양하는 것이 아니라, 관리하고 불필요한 상황에서 피하는 것입니다.

// Before - 액션과 계산이 섞여 있음
function update_tax_dom() {
set_tax_dom(shopping_cart_total * 0.1);
}
// After - 계산을 분리
function update_tax_dom() {
set_tax_dom(getTax(shopping_cart_total));
}
function getTax(total) {
return total * 0.1;
}

데이터를 직접 수정하지 않는 것

섹션 제목: “데이터를 직접 수정하지 않는 것”
// 변경 가능 - 전역 상태를 직접 변경
const arr = [1, 2, 3];
const pushElem = (num) => arr.push(num);
// 불변성 유지 - 새 배열을 반환
const arr = [1, 2, 3];
const pushElem = (arr, num) => [...arr, num];
const updatedArr = pushElem(arr, 4);

**공유 상태(Shared State)**란 공유된 스코프에서 존재하거나 스코프 간에 전달되는 객체의 속성으로 존재하는 변수, 객체, 또는 메모리 공간을 의미합니다. 공유 상태를 방지하기 위해서는 불변성을 유지해야 합니다.

const를 사용하더라도 참조값(배열, 객체 등)은 변경 가능합니다:

const arr = [3, 2, 1, 8, 4];
const sortArr = (arr1: typeof arr) => {
return arr1.sort();
};
const newArr = sortArr(arr);
console.log(arr); // [1, 2, 3, 4, 8] - 원본도 변경됨!
console.log(newArr); // [1, 2, 3, 4, 8]

1. Object.assign()

let obj2 = Object.assign({}, obj);

2. 스프레드 연산자

let obj2 = { ...obj };

위 두 방법은 얕은 복사이므로 중첩된 객체에 대한 불변성이 보장되지 않습니다.

3. JSON.stringify() + JSON.parse()

let obj2 = JSON.parse(JSON.stringify(obj));

깊은 복사가 필요할 때 사용합니다. 다만, 함수나 undefined, Symbol 등은 복사되지 않습니다.

신뢰할 수 없는 코드를 사용할 때는 방어적 복사를 활용합니다:

  1. 데이터가 안전한 코드에서 나갈 때 복사하기
  2. 안전한 코드로 데이터가 들어올 때 복사하기
// Before - 신뢰할 수 없는 코드가 직접 접근
function add_item_to_cart(name, price) {
const item = make_cart_item(name, price);
shopping_cart = add_item(shopping_cart, item);
const total = calc_total(shopping_cart);
set_cart_total_dom(total);
update_tax_dom(total);
black_friday_promotion(shopping_cart); // 신뢰할 수 없는 코드
}
// After - 방어적 복사 적용
function add_item_to_cart(name, price) {
const item = make_cart_item(name, price);
shopping_cart = add_item(shopping_cart, item);
const total = calc_total(shopping_cart);
set_cart_total_dom(total);
update_tax_dom(total);
shopping_cart = black_friday_promotion_safe(shopping_cart);
}
function black_friday_promotion_safe(cart) {
const cart_copy = deepCopy(cart); // 나가는 데이터 복사
black_friday_promotion(cart_copy);
return deepCopy(cart_copy); // 들어오는 데이터 복사
}

map, filter, reduce는 원본 배열을 변형시키지 않으므로 불변성을 유지합니다.

const scores = [50, 6, 100, 0, 10, 75, 8, 60, 90, 80, 0, 30, 100, 30, 110];
// 1. 10점 이하는 10을 곱한다
const multiplyTen = scores.map((score) =>
score <= 10 ? score * 10 : score
);
// 2. 100 초과인 점수는 제거한다
const underHundred = multiplyTen.filter((score) => score <= 100);
// 3. 0 이하인 점수는 제거한다
const overZero = underHundred.filter((score) => score > 0);
// 4. 합을 구한다
const sum = overZero.reduce((accumulator, score) => accumulator + score, 0);

reduce는 배열을 하나의 값(어떤 형태든)으로 반환하는 메서드이므로, 익숙해지면 다양하게 활용할 수 있습니다.

함수형 프로그래밍의 기초는 결국 이 세 가지로 요약됩니다:

  1. 순수 함수: 같은 입력에 항상 같은 출력, 부수효과 없음
  2. 불변성: 데이터를 직접 수정하지 않고 복사본을 만들어 사용
  3. 액션/계산/데이터 분리: 부수효과가 있는 코드를 명확히 분리하고 관리

다음 글에서는 일급 함수와 고차 함수, 그리고 Copy-on-Write 패턴을 살펴보겠습니다.