
함수형 프로그래밍: 순수 함수와 불변성
요리 수업(OOP)과 수학 수업(FP)의 차이. 상태를 배제하여 버그를 원천 봉쇄하는 패러다임.

요리 수업(OOP)과 수학 수업(FP)의 차이. 상태를 배제하여 버그를 원천 봉쇄하는 패러다임.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

나는 개발을 시작했을 때 이상한 불안감을 느꼈다. 코드가 늘어날수록, 기능이 추가될수록, 오히려 더 불안해졌다. "이 변수를 여기서 바꾸면... 저기 있는 함수가 영향을 받지 않을까?" 같은 생각이 머릿속을 떠나지 않았다. 디버깅할 때는 더 심했다. 버그의 원인을 찾으려면 코드 전체를 뒤져야 했다. 어디선가 변수가 바뀌었고, 그게 연쇄 반응을 일으켰기 때문이다.
그러다 함수형 프로그래밍(Functional Programming, FP)이라는 걸 접했다. 처음엔 "이게 뭐지? 왜 이렇게 불편하게 코드를 짜?" 싶었다. 변수를 바꾸지 말라고? 복사를 계속 하라고? 비효율적이지 않나? 하지만 실제로 써보니 깨달았다. 이건 비효율이 아니라 안정성을 사는 거래였다.
함수형 프로그래밍을 처음 이해한 건 이 비유 덕분이었다.
요리 수업에서 선생님이 말한다. "김치찌개에 소금을 한 스푼 넣으세요." 나는 냄비(객체)에 소금을 넣는다(메소드 호출). 그러면 김치찌개의 맛(상태)이 변한다. 이게 객체지향의 핵심이다. 객체는 상태를 가지고, 메소드를 통해 상태가 변한다.
문제는 여기서 발생한다. 만약 옆 사람이 몰래 설탕을 넣으면? 찌개는 망한다. 내가 소금을 넣었는지, 설탕을 넣었는지, 언제 넣었는지를 추적하기 어렵다. 여러 사람(스레드)이 동시에 같은 냄비(공유 상태)를 만지면 혼란이 온다. 이게 바로 Side Effect다.
수학 시간에 선생님이 말한다. "f(x) = x + 1이라는 함수가 있습니다. x에 3을 넣으면?" 당연히 4다. 100번을 물어봐도 4다. 내일 물어봐도 4다. 이 함수는 절대 변하지 않는다.
중요한 건 3이 4로 변하는 게 아니라는 점이다. 3은 3으로 그대로 있다. 함수는 단지 4라는 새로운 값을 만들어낼 뿐이다. 이게 함수형 프로그래밍의 핵심이다. 입력을 바꾸지 않고, 출력을 새로 만든다.
나는 이 차이가 처음엔 사소해 보였다. "그래서 뭐?" 싶었다. 하지만 코드가 복잡해질수록 이 차이는 엄청나게 커졌다.
초보 시절, 나는 이런 코드를 자주 짰다.
let cart = [];
function addItem(item) {
cart.push(item);
updateUI();
saveToLocalStorage();
}
function removeItem(index) {
cart.splice(index, 1);
updateUI();
saveToLocalStorage();
}
function applyDiscount() {
cart.forEach(item => {
item.price = item.price * 0.9; // 원본 수정
});
updateUI();
}
겉보기엔 괜찮아 보인다. 하지만 실제로는 재앙이었다. cart라는 전역 변수가 여기저기서 변한다. applyDiscount()를 호출하면 cart 안의 아이템 가격이 영구적으로 바뀐다. 할인을 취소하고 싶으면? 이전 상태를 어떻게 복원할까? 저장해뒀어야 하는데, 깜빡했다.
더 큰 문제는 예측 불가능성이었다. 어디선가 cart를 바꾸면, 그 영향이 전체 앱에 퍼진다. 버그가 생기면 "대체 어디서 이 값이 바뀐 거지?"를 추적하느라 몇 시간을 날렸다.
함수형 프로그래밍에서 배운 첫 번째 원칙은 불변성(Immutability)이었다. 변수를 바꾸지 마라. 대신 복사해서 새로운 값을 만들어라.
const cart = [];
function addItem(cart, item) {
return [...cart, item]; // 원본 유지, 새 배열 반환
}
function removeItem(cart, index) {
return cart.filter((_, i) => i !== index);
}
function applyDiscount(cart) {
return cart.map(item => ({
...item,
price: item.price * 0.9 // 새 객체 생성
}));
}
// 사용 예시
let myCart = [];
myCart = addItem(myCart, { name: 'Book', price: 10000 });
myCart = addItem(myCart, { name: 'Pen', price: 2000 });
const discountedCart = applyDiscount(myCart);
// myCart는 그대로, discountedCart는 할인 적용된 새 배열
처음엔 "이게 더 비효율적이지 않나?"라고 생각했다. 매번 복사하니까 메모리도 더 쓰고, 속도도 느릴 것 같았다. 하지만 실제로 써보니 장점이 압도적이었다.
나는 이걸 받아들인 후 코드 짜는 게 훨씬 편해졌다. 불안감이 사라졌다.
함수형 프로그래밍의 두 번째 핵심은 순수 함수(Pure Function)다. 순수 함수는 두 가지 조건을 만족한다.
// 순수 함수
function add(a, b) {
return a + b;
}
// 순수하지 않은 함수
let total = 0;
function addToTotal(value) {
total += value; // 외부 변수 수정 (Side Effect)
return total;
}
// 순수하지 않은 함수 (외부 의존)
function getCurrentTime() {
return new Date().getTime(); // 매번 다른 값 반환
}
// 순수하지 않은 함수 (I/O)
function saveToDatabase(data) {
db.save(data); // 외부 시스템 변경
}
나는 처음에 "그럼 I/O는 어떻게 해? DB 저장은? API 호출은?"이라고 의문을 가졌다. 정답은 간단했다. 순수한 부분과 비순수한 부분을 분리하라는 거였다.
// 순수한 비즈니스 로직
function calculateOrderTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
function applyTax(total, taxRate) {
return total * (1 + taxRate);
}
// 비순수한 I/O 부분
async function processOrder(items) {
const total = calculateOrderTotal(items); // 순수
const finalTotal = applyTax(total, 0.1); // 순수
await saveToDatabase({ items, finalTotal }); // 비순수 (하지만 격리됨)
}
이렇게 하면 비즈니스 로직(calculateOrderTotal, applyTax)은 순수 함수로 테스트하기 쉽고, I/O는 최소한으로 격리된다.
함수형 프로그래밍에서 함수는 특별한 게 아니다. 숫자, 문자열처럼 값이다. 변수에 담을 수 있고, 함수의 인자로 넘길 수 있고, 함수가 함수를 반환할 수도 있다. 이걸 일급 함수(First-Class Function)라고 한다.
// 함수를 변수에 할당
const greet = function(name) {
return `Hello, ${name}`;
};
// 함수를 인자로 전달
function executeFunc(func, value) {
return func(value);
}
executeFunc(greet, 'Alice'); // "Hello, Alice"
// 함수가 함수를 반환
function makeMultiplier(factor) {
return function(number) {
return number * factor;
};
}
const double = makeMultiplier(2);
double(5); // 10
고차 함수(Higher-Order Function)는 함수를 인자로 받거나 함수를 반환하는 함수다. JavaScript의 배열 메소드(map, filter, reduce)가 대표적이다.
나는 처음엔 for문을 쓰다가, 이 메소드들을 알고 나서 코드가 훨씬 읽기 쉬워졌다는 걸 느꼈다.
// 명령형 스타일 (Imperative)
const numbers = [1, 2, 3, 4, 5];
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
// 선언형 스타일 (Declarative) - 함수형
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
명령형은 "어떻게(How)" 할지를 코드로 쓴다. 반복문, 인덱스, push... 복잡하다. 선언형은 "무엇을(What)" 할지만 쓴다. "각 요소를 2배로 만들어라." 의도가 명확하다.
함수형 프로그래밍의 진가는 함수 조합(Function Composition)에서 나온다. 작은 순수 함수들을 레고처럼 조합해서 복잡한 로직을 만든다.
// 작은 순수 함수들
const users = [
{ name: 'Alice', age: 25, premium: true },
{ name: 'Bob', age: 17, premium: false },
{ name: 'Charlie', age: 30, premium: true },
{ name: 'David', age: 16, premium: false },
];
// 명령형 방식
function getAdultPremiumUserNames(users) {
const result = [];
for (let i = 0; i < users.length; i++) {
if (users[i].age >= 18 && users[i].premium) {
result.push(users[i].name);
}
}
return result;
}
// 함수형 방식
const isAdult = user => user.age >= 18;
const isPremium = user => user.premium;
const getName = user => user.name;
const adultPremiumNames = users
.filter(isAdult)
.filter(isPremium)
.map(getName);
// ['Alice', 'Charlie']
나는 이 방식이 훨씬 마음에 들었다. 각 함수(isAdult, isPremium, getName)는 독립적으로 테스트할 수 있다. 재사용도 쉽다. 읽기도 편하다. "성인인 사람들을 필터링하고, 프리미엄 사용자를 필터링하고, 이름을 추출한다." 코드가 그대로 설명이 된다.
더 나아가면 compose나 pipe 같은 유틸리티를 쓸 수 있다.
// compose: 오른쪽에서 왼쪽으로 실행
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
// pipe: 왼쪽에서 오른쪽으로 실행 (더 직관적)
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
// 예시
const addOne = x => x + 1;
const double = x => x * 2;
const square = x => x * x;
const compute = pipe(addOne, double, square);
compute(2); // ((2 + 1) * 2)^2 = 36
// 데이터 파이프라인
const processUsers = pipe(
users => users.filter(isAdult),
users => users.filter(isPremium),
users => users.map(getName),
names => names.join(', ')
);
processUsers(users); // "Alice, Charlie"
이건 마치 Unix의 파이프라인(cat file.txt | grep "error" | wc -l)처럼 동작한다. 데이터가 함수들을 거치면서 변환된다. 나는 이 패턴을 써보고 나서 복잡한 데이터 처리 로직이 훨씬 깔끔해진다는 걸 느꼈다.
커링(Currying)은 여러 인자를 받는 함수를 단일 인자를 받는 함수들의 체인으로 바꾸는 기법이다.
// 일반 함수
function add(a, b, c) {
return a + b + c;
}
add(1, 2, 3); // 6
// 커링된 함수
function curriedAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
curriedAdd(1)(2)(3); // 6
// 화살표 함수로 간결하게
const curriedAdd = a => b => c => a + b + c;
처음엔 "왜 이렇게 복잡하게?"라고 생각했다. 하지만 부분 적용(Partial Application)을 하면 강력해진다.
const add = a => b => c => a + b + c;
const add5 = add(5); // b => c => 5 + b + c
const add5and10 = add5(10); // c => 5 + 10 + c
add5and10(3); // 18
// 실제 예시
const multiply = a => b => a * b;
const double = multiply(2);
const triple = multiply(3);
[1, 2, 3].map(double); // [2, 4, 6]
[1, 2, 3].map(triple); // [3, 6, 9]
이 패턴은 설정을 미리 해두고 나중에 사용할 때 유용하다. 예를 들어 로깅 함수를 만들 때:
const log = level => message => timestamp =>
`[${timestamp}] ${level}: ${message}`;
const infoLog = log('INFO');
const errorLog = log('ERROR');
infoLog('Server started')('2025-01-01 10:00:00');
// "[2025-01-01 10:00:00] INFO: Server started"
나는 이걸 알고 나서 설정 함수를 만들 때 자주 쓰게 됐다.
React를 쓰면서 함수형 프로그래밍의 가치를 더 절실히 느꼈다. React의 함수형 컴포넌트는 말 그대로 순수 함수다.
// 순수 함수형 컴포넌트
function UserCard({ name, age, email }) {
return (
<div className="card">
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p>
</div>
);
}
// 같은 props를 주면 항상 같은 UI가 나온다
React의 useState, useEffect 등도 함수형 개념을 기반으로 한다. 불변성을 지켜야 리렌더링이 제대로 작동한다.
// 잘못된 방법 (불변성 위반)
const [items, setItems] = useState([1, 2, 3]);
items.push(4); // 원본 수정 - React가 감지 못함
setItems(items); // 리렌더링 안 됨!
// 올바른 방법 (불변성 유지)
setItems([...items, 4]); // 새 배열 생성 - React가 감지
Immer 같은 라이브러리를 쓰면 불변성을 더 쉽게 유지할 수 있다.
import produce from 'immer';
const [state, setState] = useState({ cart: [], total: 0 });
// Immer 사용
setState(produce(draft => {
draft.cart.push({ id: 1, name: 'Book' }); // 마치 변경하는 것처럼 보이지만
draft.total += 10000; // 실제론 불변 업데이트
}));
클로저(Closure)는 함수가 자신이 생성된 환경(렉시컬 스코프)을 기억하는 특성이다. 함수형 프로그래밍에서 클로저는 필수다.
function createCounter() {
let count = 0; // 외부에서 접근 불가 (캡슐화)
return {
increment: () => ++count,
decrement: () => --count,
getValue: () => count
};
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.getValue(); // 2
// count에 직접 접근 불가 - 안전함
나는 클로저를 써서 private 변수를 만들 수 있다는 게 신기했다. JavaScript에는 원래 private 키워드가 없었는데, 클로저로 비슷한 효과를 낸다.
함수형 프로그래밍을 공부하다 보면 "모나드(Monad)"라는 무시무시한 단어를 만난다. 수학적 정의는 복잡하지만, 나는 이렇게 이해했다. Monad는 값을 포장해서 안전하게 다루는 컨테이너다.
JavaScript에서 가장 친숙한 Monad는 Promise다.
// Promise는 비동기 값을 포장한 Monad
fetch('/api/user')
.then(response => response.json())
.then(data => data.name)
.then(name => console.log(name))
.catch(error => console.error(error));
// map과 비슷한 패턴
// 배열: [1, 2, 3].map(x => x * 2)
// Promise: Promise.resolve(3).then(x => x * 2)
Promise는 실패할 수 있는 값(에러)을 안전하게 처리한다. null이나 undefined를 다루는 Maybe Monad도 있다.
// Maybe Monad (간단 구현)
class Maybe {
constructor(value) {
this.value = value;
}
static of(value) {
return new Maybe(value);
}
map(fn) {
return this.value == null ? this : Maybe.of(fn(this.value));
}
getOrElse(defaultValue) {
return this.value == null ? defaultValue : this.value;
}
}
// 사용 예시
const user = Maybe.of({ name: 'Alice', age: 25 });
const userName = user
.map(u => u.name)
.map(name => name.toUpperCase())
.getOrElse('UNKNOWN'); // "ALICE"
const nullUser = Maybe.of(null);
const nullUserName = nullUser
.map(u => u.name) // null이므로 실행 안 됨
.getOrElse('UNKNOWN'); // "UNKNOWN"
나는 Monad를 완전히 이해했다고 할 순 없지만, "에러나 null을 안전하게 다루는 패턴"이라고 받아들였다. 실제로 Promise만 잘 써도 충분하다고 느꼈다.
나는 자주 "FP가 좋다는 건 알겠는데, OOP는 버려야 하나?"라고 고민했다. 정답은 둘 다 쓴다였다.
실제로는 보통 이렇게 섞어 쓴다.
// OOP로 도메인 모델링
class User {
constructor(name, email, age) {
this.name = name;
this.email = email;
this.age = age;
}
}
// FP로 데이터 처리
const users = [
new User('Alice', 'alice@example.com', 25),
new User('Bob', 'bob@example.com', 17),
];
const getAdultEmails = users =>
users
.filter(u => u.age >= 18)
.map(u => u.email);
getAdultEmails(users); // ['alice@example.com']
나는 이 균형을 찾는 게 중요하다고 느꼈다. FP 광신자가 될 필요는 없다. 상황에 맞게 쓰면 된다.
함수형 프로그래밍을 실제에 적용하면서 느낀 실질적 이득은 이거였다.
순수 함수는 입력과 출력만 확인하면 된다. Mock도 필요 없다.
// 순수 함수 - 테스트 쉬움
function calculateDiscount(price, discountRate) {
return price * (1 - discountRate);
}
test('discount calculation', () => {
expect(calculateDiscount(10000, 0.1)).toBe(9000);
expect(calculateDiscount(5000, 0.2)).toBe(4000);
});
// 비순수 함수 - 테스트 어려움
function applyDiscountToCart() {
const cart = getCartFromDB(); // DB 의존
const discount = getCurrentDiscount(); // 외부 상태 의존
cart.total = cart.total * discount;
saveCartToDB(cart); // DB 변경
}
// 이걸 테스트하려면 DB mock, 상태 mock... 복잡함
순수 함수는 같은 입력이면 항상 같은 출력이다. 재현이 쉽다.
// 버그 재현이 쉬움
function processOrder(items, taxRate) {
const total = calculateTotal(items);
return applyTax(total, taxRate);
}
// 버그 발견 시
processOrder([{ price: 1000 }], 0.1); // 이 입력으로 항상 재현 가능
불변 데이터는 여러 스레드가 동시에 읽어도 안전하다. Race Condition이 없다.
// 불변 방식 - 안전함
const data = [1, 2, 3];
Promise.all([
processData(data), // 원본 안 바뀜
processData(data), // 원본 안 바뀜
]);
// 가변 방식 - 위험함
let data = [1, 2, 3];
Promise.all([
mutateData(data), // data를 바꿈
mutateData(data), // 동시에 바꾸면? 충돌!
]);
나는 함수형 프로그래밍을 배우고 나서 코드에 대한 불안감이 많이 줄었다. "이 변수를 여기서 바꾸면 어떻게 될까?"라는 걱정 대신, "이 함수는 항상 같은 결과를 낸다"는 확신을 갖게 됐다.
함수형 프로그래밍은 마법이 아니다. 메모리를 더 쓰고, 때론 성능이 조금 떨어진다. 하지만 그 대가로 얻는 건 예측 가능성, 테스트 용이성, 안정성이다. 현대 개발에서 이 가치는 몇 밀리초의 성능보다 훨씬 중요하다고 나는 받아들였다.
결국 함수형 프로그래밍은 "상태를 어떻게 관리할 것인가"에 대한 하나의 답이다. 상태를 최소화하고, 바꾸지 않고, 복사해서 새로 만든다. 이 원칙만 지켜도 코드는 훨씬 견고해진다. 나는 이제 변수를 선언할 때마다 "이게 정말 변해야 하나?"를 먼저 생각하게 됐다. 그게 함수형 프로그래밍이 내게 준 가장 큰 변화다.