프로그래밍을 배우다 보면 ‘함수형 프로그래밍’이라는 용어를 자주 접하게 됩니다. 특히 최근 몇 년간 React, Vue 같은 프론트엔드 프레임워크나 Swift, Kotlin 같은 모던 언어들이 함수형 프로그래밍 개념을 적극 도입하면서 이에 대한 이해가 더욱 중요해졌습니다. 하지만 막상 공부하려고 하면 어렵고 추상적인 개념들 때문에 좌절하는 경우가 많습니다.
저 역시 처음 함수형 프로그래밍을 접했을 때 “순수 함수”, “불변성”, “고차 함수” 같은 용어들이 너무 어렵게 느껴졌습니다. 하지만 실제로 깊이 파고들어보니 이 개념들은 우리가 일상적으로 사용하는 프로그래밍 패턴을 좀 더 체계적으로 정리한 것에 불과했습니다. 이번 글에서는 제가 겪었던 시행착오를 바탕으로 함수형 프로그래밍을 처음 접하는 분들도 쉽게 이해할 수 있도록 기초부터 실전 예제까지 상세하게 설명드리겠습니다.
함수형 프로그래밍이 왜 중요한가?
본격적으로 들어가기 전에 왜 함수형 프로그래밍을 배워야 하는지 이해하는 것이 중요합니다. 전통적인 명령형 프로그래밍에서는 프로그램의 상태를 변경하면서 원하는 결과를 얻습니다. 예를 들어 변수에 값을 할당하고, 반복문으로 값을 변경하고, 조건문으로 흐름을 제어하는 방식입니다.
이러한 방식은 직관적이지만 프로그램이 커질수록 버그를 찾기 어려워지고, 코드의 동작을 예측하기 힘들어집니다. 특히 여러 함수가 같은 변수를 수정할 때 어디서 어떻게 값이 바뀌었는지 추적하기가 매우 어렵습니다. 함수형 프로그래밍은 이런 문제를 근본적으로 해결하기 위해 “상태 변경을 최소화하고 함수의 조합으로 프로그램을 구성한다”는 철학을 가지고 있습니다.
실무에서 함수형 프로그래밍의 장점은 명확합니다. 테스트하기 쉽고, 병렬 처리가 안전하며, 코드의 재사용성이 높아집니다. 또한 한 번 이해하고 나면 코드를 읽고 쓰는 속도가 훨씬 빨라집니다.
순수 함수: 함수형 프로그래밍의 핵심
함수형 프로그래밍의 가장 기본이 되는 개념은 바로 순수 함수입니다. 순수 함수란 같은 입력에 대해 항상 같은 출력을 반환하고, 외부 상태를 변경하지 않는 함수를 말합니다. 이 정의만 들으면 추상적으로 느껴질 수 있으니 구체적인 예제를 통해 이해해보겠습니다.
먼저 순수하지 않은 함수의 예를 살펴보겠습니다.
javascript
let counter = 0;
function incrementCounter() {
counter = counter + 1; // 외부 변수를 수정함
return counter;
}
console.log(incrementCounter()); // 1
console.log(incrementCounter()); // 2 - 같은 호출인데 다른 결과!이 함수는 외부 변수인 counter를 수정하기 때문에 순수 함수가 아닙니다. 같은 방식으로 호출해도 매번 다른 결과가 나오고, 다른 코드가 counter 변수를 수정하면 예상치 못한 결과가 발생할 수 있습니다.
이제 같은 기능을 순수 함수로 구현해보겠습니다.
javascript
function increment(number) {
return number + 1; // 입력값만 사용하고, 새로운 값을 반환
}
console.log(increment(0)); // 1
console.log(increment(0)); // 1 - 같은 입력에 같은 출력!이 함수는 오직 입력값만 사용하고 외부 상태를 변경하지 않습니다. 언제 호출하든 같은 입력에 대해 같은 결과를 반환합니다. 이것이 바로 순수 함수의 핵심입니다.
순수 함수의 장점은 디버깅할 때 극명하게 드러납니다. 버그가 발생했을 때 해당 함수만 확인하면 됩니다. 외부 변수의 상태나 호출 순서를 걱정할 필요가 없기 때문입니다.
불변성: 데이터를 보호하는 방법
불변성은 한번 생성된 데이터를 변경하지 않는다는 원칙입니다. 대신 데이터를 수정해야 할 때는 기존 데이터를 복사한 후 새로운 데이터를 만들어냅니다. 처음에는 비효율적으로 보일 수 있지만, 실제로는 많은 장점이 있습니다.
배열을 다루는 예제를 통해 가변성과 불변성의 차이를 이해해보겠습니다.
javascript
// 가변적인 방식 - 원본 배열을 직접 수정
let numbers = [1, 2, 3, 4, 5];
numbers.push(6); // 원본 배열이 변경됨
console.log(numbers); // [1, 2, 3, 4, 5, 6]
// 불변적인 방식 - 새로운 배열을 생성
const originalNumbers = [1, 2, 3, 4, 5];
const newNumbers = [...originalNumbers, 6]; // 스프레드 연산자로 복사 후 추가
console.log(originalNumbers); // [1, 2, 3, 4, 5] - 원본 유지
console.log(newNumbers); // [1, 2, 3, 4, 5, 6] - 새로운 배열불변성을 지키면 코드의 예측 가능성이 높아집니다. 함수에 데이터를 전달해도 원본이 변경될 걱정이 없기 때문입니다. 특히 React 같은 프레임워크에서는 불변성이 매우 중요한데, 상태 변경을 감지하여 화면을 다시 렌더링하는 메커니즘이 불변성을 전제로 하기 때문입니다.
객체를 다룰 때도 마찬가지입니다. 아래 예제는 사용자 정보를 수정하는 두 가지 방법을 보여줍니다.
javascript
// 가변적인 방식
let user = { name: "홍길동", age: 25 };
user.age = 26; // 원본 객체 수정
console.log(user); // { name: "홍길동", age: 26 }
// 불변적인 방식
const originalUser = { name: "홍길동", age: 25 };
const updatedUser = { ...originalUser, age: 26 }; // 새 객체 생성
console.log(originalUser); // { name: "홍길동", age: 25 } - 원본 유지
console.log(updatedUser); // { name: "홍길동", age: 26 } - 새 객체고차 함수: 함수를 다루는 함수
고차 함수는 함수형 프로그래밍에서 가장 강력한 도구 중 하나입니다. 고차 함수란 함수를 인자로 받거나 함수를 반환하는 함수를 말합니다. 이 개념이 처음에는 낯설 수 있지만, 실제로 자바스크립트에서 매일 사용하는 map, filter, reduce 같은 배열 메서드들이 모두 고차 함수입니다.
먼저 간단한 예제로 고차 함수의 개념을 이해해보겠습니다.
javascript
// 함수를 인자로 받는 고차 함수
function executeOperation(operation, a, b) {
return operation(a, b); // 전달받은 함수를 실행
}
// 일반 함수들
function add(x, y) {
return x + y;
}
function multiply(x, y) {
return x * y;
}
console.log(executeOperation(add, 5, 3)); // 8
console.log(executeOperation(multiply, 5, 3)); // 15이 예제에서 executeOperation은 함수를 인자로 받아 실행하는 고차 함수입니다. 같은 고차 함수에 다른 함수를 전달함으로써 동작을 유연하게 바꿀 수 있습니다.
이제 실무에서 가장 많이 사용하는 배열 고차 함수들을 살펴보겠습니다. 먼저 map 함수는 배열의 각 요소를 변환하여 새로운 배열을 만듭니다.
javascript
const numbers = [1, 2, 3, 4, 5];
// 각 숫자를 2배로 만드는 변환
const doubled = numbers.map(function(num) {
return num * 2; // 각 요소에 이 함수가 적용됨
});
console.log(doubled); // [2, 4, 6, 8, 10]
console.log(numbers); // [1, 2, 3, 4, 5] - 원본은 그대로filter 함수는 특정 조건을 만족하는 요소만 걸러내어 새로운 배열을 만듭니다.
javascript
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// 짝수만 걸러내기
const evenNumbers = numbers.filter(function(num) {
return num % 2 === 0; // true를 반환하는 요소만 남김
});
console.log(evenNumbers); // [2, 4, 6, 8, 10]reduce 함수는 배열의 모든 요소를 하나의 값으로 축약합니다. 가장 강력하지만 처음에는 이해하기 어려울 수 있습니다.
javascript
const numbers = [1, 2, 3, 4, 5];
// 모든 숫자의 합 구하기
const sum = numbers.reduce(function(accumulator, currentValue) {
// accumulator: 지금까지 누적된 값
// currentValue: 현재 처리 중인 배열 요소
return accumulator + currentValue;
}, 0); // 0은 초기값
console.log(sum); // 15reduce의 동작을 단계별로 살펴보면 다음과 같습니다. 첫 번째 단계에서 accumulator는 0이고 currentValue는 1이므로 0 더하기 1은 1입니다. 두 번째 단계에서 accumulator는 1이고 currentValue는 2이므로 1 더하기 2는 3입니다. 이런 식으로 계속 누적되어 최종적으로 15가 됩니다.
함수 합성: 작은 함수들을 조합하기
함수 합성은 작은 함수들을 조합하여 더 복잡한 기능을 만드는 기법입니다. 레고 블록을 조립하듯이 각각의 단순한 함수를 연결하여 복잡한 로직을 구현할 수 있습니다.
실제 예제를 통해 함수 합성의 장점을 이해해보겠습니다. 사용자 데이터를 처리하는 시나리오를 생각해봅시다.
javascript
const users = [
{ name: "김철수", age: 25, active: true },
{ name: "이영희", age: 30, active: false },
{ name: "박민수", age: 22, active: true },
{ name: "정수진", age: 28, active: true }
];
// 작은 함수들을 정의
function isActive(user) {
return user.active === true;
}
function isAdult(user) {
return user.age >= 25;
}
function getUserName(user) {
return user.name;
}
// 함수들을 조합하여 사용
const activeAdultNames = users
.filter(isActive) // 활성 사용자만
.filter(isAdult) // 성인만
.map(getUserName); // 이름만 추출
console.log(activeAdultNames); // ["김철수", "정수진"]이 코드의 장점은 각 함수가 하나의 명확한 역할만 한다는 것입니다. isActive는 활성 여부만 확인하고, isAdult는 성인 여부만 확인합니다. 이런 함수들은 재사용하기 쉽고, 테스트하기도 간단합니다.
더 나아가서 화살표 함수를 사용하면 코드를 더욱 간결하게 만들 수 있습니다.
javascript
const activeAdultNames = users
.filter(user => user.active)
.filter(user => user.age >= 25)
.map(user => user.name);실전 예제: 쇼핑몰 장바구니 구현하기
이제 배운 개념들을 종합하여 실제로 사용할 수 있는 쇼핑몰 장바구니 기능을 함수형 프로그래밍 방식으로 구현해보겠습니다. 이 예제는 실무에서 자주 마주치는 상황이므로 특히 유용할 것입니다.
javascript
// 초기 장바구니 데이터
const cart = [
{ id: 1, name: "노트북", price: 1200000, quantity: 1 },
{ id: 2, name: "마우스", price: 30000, quantity: 2 },
{ id: 3, name: "키보드", price: 80000, quantity: 1 }
];
// 순수 함수: 상품 추가
function addItem(cart, newItem) {
// 이미 존재하는 상품인지 확인
const existingItem = cart.find(item => item.id === newItem.id);
if (existingItem) {
// 존재하면 수량만 증가 (불변성 유지)
return cart.map(item =>
item.id === newItem.id
? { ...item, quantity: item.quantity + newItem.quantity }
: item
);
}
// 새로운 상품이면 배열에 추가 (불변성 유지)
return [...cart, newItem];
}
// 순수 함수: 상품 제거
function removeItem(cart, itemId) {
// filter를 사용하여 해당 id를 제외한 새 배열 생성
return cart.filter(item => item.id !== itemId);
}
// 순수 함수: 수량 변경
function updateQuantity(cart, itemId, newQuantity) {
if (newQuantity <= 0) {
// 수량이 0 이하면 상품 제거
return removeItem(cart, itemId);
}
// map을 사용하여 해당 상품의 수량만 변경
return cart.map(item =>
item.id === itemId
? { ...item, quantity: newQuantity }
: item
);
}
// 순수 함수: 총 금액 계산
function calculateTotal(cart) {
return cart.reduce((total, item) => {
// 각 상품의 가격 * 수량을 누적
return total + (item.price * item.quantity);
}, 0);
}
// 순수 함수: 할인 적용
function applyDiscount(cart, discountRate) {
// 각 상품의 가격에 할인율 적용
return cart.map(item => ({
...item,
price: Math.floor(item.price * (1 - discountRate))
}));
}
// 사용 예제
let myCart = cart;
// 새 상품 추가
myCart = addItem(myCart, { id: 4, name: "모니터", price: 300000, quantity: 1 });
console.log("상품 추가 후:", myCart);
// 총 금액 계산
console.log("총 금액:", calculateTotal(myCart).toLocaleString() + "원");
// 10% 할인 적용
const discountedCart = applyDiscount(myCart, 0.1);
console.log("할인 후 총 금액:", calculateTotal(discountedCart).toLocaleString() + "원");
// 상품 제거
myCart = removeItem(myCart, 2);
console.log("마우스 제거 후:", myCart);이 장바구니 구현의 핵심은 모든 함수가 순수 함수라는 점입니다. 원본 장바구니를 직접 수정하지 않고 항상 새로운 장바구니를 반환합니다. 이렇게 하면 실행 취소 기능을 쉽게 구현할 수 있고, 상태 변화를 추적하기도 간편합니다.
커링과 부분 적용: 함수를 더 유연하게
커링은 여러 개의 인자를 받는 함수를 하나의 인자만 받는 함수들의 연속으로 바꾸는 기법입니다. 이름이 낯설고 개념도 어렵게 느껴질 수 있지만, 실제로는 매우 실용적인 패턴입니다.
javascript
// 일반적인 함수
function multiply(a, b, c) {
return a * b * c;
}
console.log(multiply(2, 3, 4)); // 24
// 커링된 함수
function curriedMultiply(a) {
return function(b) {
return function(c) {
return a * b * c;
};
};
}
// 단계별로 인자를 전달
const step1 = curriedMultiply(2); // a = 2로 고정
const step2 = step1(3); // b = 3으로 고정
const result = step2(4); // c = 4를 전달하여 최종 결과
console.log(result); // 24
// 또는 한 번에 체이닝
console.log(curriedMultiply(2)(3)(4)); // 24화살표 함수를 사용하면 커링을 더 간결하게 표현할 수 있습니다.
javascript
const curriedMultiply = a => b => c => a * b * c;커링의 실용적인 예제를 살펴보겠습니다. 로그 메시지를 출력하는 함수를 만든다고 가정해봅시다.
javascript
// 커링을 사용한 로그 함수
const log = level => message => time => {
console.log(`[${level}] ${time}: ${message}`);
};
// 로그 레벨별로 특화된 함수 생성
const errorLog = log("ERROR");
const infoLog = log("INFO");
const debugLog = log("DEBUG");
// 현재 시간을 자동으로 추가하는 함수 생성
const errorWithTime = errorLog;
const getCurrentTime = () => new Date().toISOString();
// 사용
errorWithTime("데이터베이스 연결 실패")(getCurrentTime());
infoLog("사용자 로그인 성공")(getCurrentTime());
debugLog("API 호출 시작")(getCurrentTime());실전 프로젝트: 할 일 관리 앱
마지막으로 배운 모든 개념을 활용하여 완전한 할 일 관리 앱을 만들어보겠습니다. 이 예제는 실제 프로젝트에서 함수형 프로그래밍을 어떻게 적용하는지 보여줍니다.
javascript
// 할 일 데이터 구조
const initialTodos = [
{ id: 1, text: "함수형 프로그래밍 공부하기", completed: false, priority: "high" },
{ id: 2, text: "장보기", completed: true, priority: "medium" },
{ id: 3, text: "운동하기", completed: false, priority: "low" }
];
// ID 생성기 (순수 함수)
let nextId = 4;
function generateId() {
return nextId++;
}
// 할 일 추가 (순수 함수)
function addTodo(todos, text, priority = "medium") {
const newTodo = {
id: generateId(),
text: text,
completed: false,
priority: priority
};
return [...todos, newTodo];
}
// 할 일 완료 토글 (순수 함수)
function toggleTodo(todos, id) {
return todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
);
}
// 할 일 삭제 (순수 함수)
function deleteTodo(todos, id) {
return todos.filter(todo => todo.id !== id);
}
// 할 일 수정 (순수 함수)
function updateTodoText(todos, id, newText) {
return todos.map(todo =>
todo.id === id
? { ...todo, text: newText }
: todo
);
}
// 필터링 함수들
function getCompletedTodos(todos) {
return todos.filter(todo => todo.completed);
}
function getActiveTodos(todos) {
return todos.filter(todo => !todo.completed);
}
function getTodosByPriority(todos, priority) {
return todos.filter(todo => todo.priority === priority);
}
// 정렬 함수
function sortByPriority(todos) {
const priorityOrder = { high: 1, medium: 2, low: 3 };
return [...todos].sort((a, b) =>
priorityOrder[a.priority] - priorityOrder[b.priority]
);
}
// 통계 함수 (reduce 활용)
function getTodoStats(todos) {
return todos.reduce((stats, todo) => {
return {
total: stats.total + 1,
completed: stats.completed + (todo.completed ? 1 : 0),
active: stats.active + (todo.completed ? 0 : 1),
highPriority: stats.highPriority + (todo.priority === "high" ? 1 : 0)
};
}, { total: 0, completed: 0, active: 0, highPriority: 0 });
}
// 복합 작업: 우선순위가 높은 미완료 할 일 찾기
function getHighPriorityActiveTodos(todos) {
return todos
.filter(todo => !todo.completed)
.filter(todo => todo.priority === "high")
.sort((a, b) => a.text.localeCompare(b.text));
}
// 사용 예제
let myTodos = initialTodos;
// 새 할 일 추가
myTodos = addTodo(myTodos, "프로젝트 문서 작성하기", "high");
console.log("할 일 추가 후:", myTodos);
// 할 일 완료 처리
myTodos = toggleTodo(myTodos, 1);
console.log("완료 처리 후:", myTodos);
// 통계 확인
const stats = getTodoStats(myTodos);
console.log("통계:", stats);
console.log(`전체: ${stats.total}, 완료: ${stats.completed}, 진행중: ${stats.active}`);
// 우선순위별 필터링
const highPriorityTodos = getTodosByPriority(myTodos, "high");
console.log("높은 우선순위 할 일:", highPriorityTodos);
// 정렬된 목록
const sortedTodos = sortByPriority(myTodos);
console.log("우선순위순 정렬:", sortedTodos);
// 복합 필터링
const urgentTodos = getHighPriorityActiveTodos(myTodos);
console.log("긴급하고 미완료인 할 일:", urgentTodos);이 할 일 관리 앱의 모든 함수는 순수 함수이며 불변성을 지킵니다. 각 함수는 하나의 명확한 책임만 가지고 있어서 테스트하기 쉽고, 필요에 따라 자유롭게 조합할 수 있습니다.
함수형 프로그래밍의 실전 적용 팁
함수형 프로그래밍을 실제 프로젝트에 적용할 때 알아두면 좋은 몇 가지 팁을 공유하겠습니다.
첫째, 처음부터 완벽하게 함수형으로 작성하려고 하지 마세요. 기존 명령형 코드를 점진적으로 함수형으로 리팩토링하는 것이 훨씬 효과적입니다. 예를 들어 for 반복문을 발견하면 map이나 filter로 바꿀 수 있는지 고민해보세요.
둘째, 모든 코드를 함수형으로 만들 필요는 없습니다. 성능이 중요한 부분이나 상태 관리가 필수적인 부분에서는 전통적인 방식이 더 적합할 수 있습니다. 함수형 프로그래밍은 도구일 뿐이며, 상황에 맞게 사용하는 것이 중요합니다.
셋째, 함수 이름을 명확하게 지으세요. 함수형 프로그래밍에서는 함수가 무엇을 하는지 이름만으로 알 수 있어야 합니다. getUsersByAge, filterActiveUsers처럼 동사와 명사를 조합한 명확한 이름을 사용하세요.
넷째, 에러 처리도 함수형으로 할 수 있습니다. try-catch 대신 Maybe나 Either 같은 함수형 에러 처리 패턴을 사용하면 더 안전한 코드를 작성할 수 있습니다.
javascript
// 안전한 JSON 파싱
function parseJSON(jsonString) {
try {
return { success: true, data: JSON.parse(jsonString) };
} catch (error) {
return { success: false, error: error.message };
}
}
// 사용
const result = parseJSON('{"name": "홍길동"}');
if (result.success) {
console.log("파싱 성공:", result.data);
} else {
console.log("파싱 실패:", result.error);
}성능 고려사항
함수형 프로그래밍을 사용할 때 성능 문제가 걱정될 수 있습니다. 특히 불변성을 지키기 위해 매번 새로운 객체나 배열을 만드는 것이 비효율적으로 보일 수 있습니다.
실제로 대부분의 경우 이런 걱정은 기우입니다. 최신 자바스크립트 엔진은 이런 패턴을 매우 효율적으로 처리합니다. 또한 불변 데이터 구조는 메모리를 공유하는 방식으로 구현되어 있어서 생각보다 메모리 효율이 좋습니다.
하지만 정말 큰 데이터를 다룰 때는 주의가 필요합니다. 예를 들어 수만 개의 항목이 있는 배열에서 여러 번 map과 filter를 체이닝하면 성능 문제가 발생할 수 있습니다.
javascript
// 비효율적: 배열을 여러 번 순회
const result = data
.map(transform1) // 첫 번째 순회
.map(transform2) // 두 번째 순회
.filter(condition); // 세 번째 순회
// 효율적: 한 번의 순회로 처리
const result = data.map(item => {
const temp1 = transform1(item);
const temp2 = transform2(temp1);
return temp2;
}).filter(condition);또는 reduce를 사용하여 한 번의 순회로 여러 작업을 처리할 수도 있습니다.
마치며
함수형 프로그래밍은 처음에는 어렵게 느껴질 수 있지만, 핵심 개념을 이해하고 나면 코드의 품질을 크게 향상시킬 수 있는 강력한 도구입니다. 순수 함수, 불변성, 고차 함수라는 세 가지 기둥만 제대로 이해하면 나머지는 자연스럽게 따라옵니다.
이 글에서 다룬 예제들은 모두 실제 프로젝트에서 바로 사용할 수 있는 패턴들입니다. 특히 React, Vue 같은 프론트엔드 프레임워크를 사용한다면 함수형 프로그래밍 개념이 필수적입니다. Redux나 MobX 같은 상태 관리 라이브러리도 함수형 프로그래밍 원칙을 기반으로 설계되었습니다.
앞으로 코드를 작성할 때 “이 함수가 순수한가?”, “이 데이터를 직접 수정하고 있지는 않은가?”, “이 로직을 더 작은 함수로 나눌 수 있을까?” 같은 질문을 스스로에게 던져보세요. 이런 사고방식이 습관이 되면 자연스럽게 더 좋은 코드를 작성하게 될 것입니다.
함수형 프로그래밍은 단순히 코딩 스타일이 아니라 문제를 바라보는 새로운 관점입니다. 이 관점을 통해 더 예측 가능하고, 테스트하기 쉬우며, 유지보수가 간편한 코드를 작성할 수 있기를 바랍니다.
