순수 함수와 함수형 프로그래밍
순수 함수란 무엇인가?
꼬리 질문
- 순수 함수의 두 가지 필수 조건은 무엇인가?
- 왜 순수 함수가 테스트하기 쉬운가?
- 순수 함수가 아닌 경우를 어떻게 판별하는가?
답변 보기
- 핵심 포인트 💡:
- 동일한 입력에 대해 항상 동일한 출력 반환
- 부수 효과(Side Effect) 없음 - 외부 상태 변경하지 않음
- 예측 가능하고 테스트하기 쉬움
- 함수형 프로그래밍의 핵심 개념
- 순수 함수의 정의
- 함수형 프로그래밍의 핵심 개념으로, 두 가지 조건을 만족하는 함수입니다
- 조건 1: 참조 투명성 - 같은 인자를 전달하면 언제나 같은 값을 반환
- 함수 외부의 상태나 시간에 영향을 받지 않음
- 실행 시점과 무관하게 결과가 일정함
- 조건 2: 부수 효과 없음 - 함수 실행이 외부 상태를 변경하지 않음
- 전역 변수 수정 ❌
- 파일 쓰기 ❌
- 화면/콘솔 출력 ❌
- 네트워크 요청 ❌
- 데이터베이스 변경 ❌
- 순수 함수 예시
// ✅ 순수 함수: 같은 입력에 같은 출력, 부수 효과 없음
function add(a, b) {
return a + b;
}
add(2, 3); // 5
add(2, 3); // 5 - 항상 같은 결과
// ✅ 순수 함수: 배열을 변경하지 않고 새 배열 반환
function addItem(array, item) {
return [...array, item]; // 원본 배열을 변경하지 않음
}
const original = [1, 2, 3];
const newArray = addItem(original, 4);
// original: [1, 2, 3] - 원본 유지
// newArray: [1, 2, 3, 4]
- 비순수 함수 예시
// ❌ 비순수: 외부 변수에 의존 (부수 효과 발생)
let count = 0;
function increment() {
count++; // 외부 상태 변경
return count;
}
increment(); // 1
increment(); // 2 - 같은 입력(없음)에도 다른 출력
// ❌ 비순수: 부수 효과가 있는 함수
function addAndLog(a, b) {
console.log(`Adding ${a} + ${b}`); // 부수 효과: 콘솔 출력
return a + b;
}
- 순수 함수의 장점
- 테스트 용이성: 입력만 제어하면 되므로 단위 테스트 작성이 쉬움
- 디버깅 편의성: 외부 상태를 추적할 필요 없이 입력과 출력만 확인
- 병렬 처리 안전: 외부 상태 변경이 없어 동시 실행 가능
- 재사용성: 독립적이어서 다양한 컨텍스트에서 사용 가능
- 캐싱 최적화: 메모이제이션(memoization)으로 성능 향상 가능
출처: 순수 함수 (Pure Function) - minsone.github.io (opens in a new tab)
왜 모든 함수를 순수 함수로 만들 수 없는가?
꼬리 질문
- 실제 애플리케이션에서 필수적인 부수 효과는 무엇인가?
- 부수 효과 없이 프로그램을 작성할 수 있는가?
- 함수형 프로그래밍은 부수 효과를 어떻게 다루는가?
답변 보기
- 핵심 포인트 💡:
- 프로그램이 외부 세계와 상호작용하려면 부수 효과 필수
- I/O, 네트워크, DB 작업은 본질적으로 부수 효과를 동반
- 함수형 프로그래밍은 부수 효과를 제거하는 것이 아니라 격리하고 관리하는 것
- 부수 효과가 필수적인 상황들
- 사용자 입력 처리
- 키보드, 마우스 이벤트 리스닝
- 폼 데이터 수집
- 데이터 저장
- 데이터베이스 쓰기
- 로컬 스토리지 저장
- 파일 시스템 쓰기
- 네트워크 통신
- REST API 호출
- WebSocket 통신
- GraphQL 쿼리
- 화면 출력
- DOM 조작
- 콘솔 로그
- 알림 표시
- 시간/난수 의존
Date.now()
호출Math.random()
사용- 타이머 설정
- 사용자 입력 처리
- 실제 애플리케이션의 현실
// 실제 웹 애플리케이션의 필수 부수 효과들
async function registerUser(userData) {
// 부수 효과 1: 사용자 입력 검증 (DOM 읽기)
const formData = document.getElementById('userForm').value;
// 부수 효과 2: 네트워크 요청
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData)
});
// 부수 효과 3: 데이터베이스 저장
const user = await response.json();
// 부수 효과 4: UI 업데이트
document.getElementById('message').textContent = '회원가입 완료!';
// 부수 효과 5: 로컬 스토리지 저장
localStorage.setItem('userId', user.id);
return user;
}
- 함수형 프로그래밍의 철학
- ❌ 부수 효과를 완전히 제거하는 것이 목표가 아님
- ✅ 부수 효과를 프로그램의 가장자리로 밀어내고 격리하는 것
- ✅ 핵심 비즈니스 로직은 순수하게 유지
- ✅ 부수 효과는 명확히 표시하고 최소화
순수 함수와 비순수 함수를 함께 사용하는 방법은?
꼬리 질문
- 부수 효과를 프로그램 경계로 밀어낸다는 것의 의미는?
- 불변성을 유지하는 구체적인 방법은?
- React, Redux에서는 어떻게 적용되는가?
- fp-ts 같은 함수형 라이브러리는 어떻게 도움이 되는가?
답변 보기
- 핵심 포인트 💡:
- 핵심 비즈니스 로직은 순수 함수로, 부수 효과는 프로그램 경계에 배치
- 불변성(Immutability) 유지: 원본 데이터를 변경하지 않고 새 값 반환
- 순수/비순수 함수를 명확히 분리하여 관리
- IO, Either, Task 같은 타입으로 부수 효과 캡슐화
- 패턴 1: 부수 효과를 프로그램의 경계로 밀어내기
- 핵심 로직(Core Logic)은 순수 함수로 작성
- 입출력, 네트워크, DB 작업은 프로그램의 진입점/출력부에만 배치
- Functional Core, Imperative Shell 패턴
// ✅ 순수 함수: 비즈니스 로직만 담당
function calculateTotal(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
function applyDiscount(total, discountRate) {
return total * (1 - discountRate);
}
function formatCurrency(amount) {
return `₩${amount.toLocaleString()}`;
}
// ❌ 부수 효과: 프로그램 경계에 위치 (진입점)
async function checkout(cartId, discountCode) {
// 부수 효과: DB에서 데이터 읽기
const items = await database.getCartItems(cartId);
const discount = await database.getDiscount(discountCode);
// ✅ 순수 함수 체인: 핵심 로직
const total = calculateTotal(items);
const finalAmount = applyDiscount(total, discount.rate);
const formatted = formatCurrency(finalAmount);
// 부수 효과: 결과 저장 및 출력
await database.saveOrder({ items, total: finalAmount });
console.log(`결제 금액: ${formatted}`);
return finalAmount;
}
- 패턴 2: 불변성(Immutability) 유지
- 객체나 배열을 직접 수정하지 않고 새로운 값 생성
map
,filter
,reduce
같은 불변 메서드 활용- Spread 연산자(
...
) 사용
// ❌ 나쁜 예: 원본 배열/객체 변경
function addItemBad(cart, newItem) {
cart.items.push(newItem); // 부수 효과: 원본 변경
cart.total += newItem.price;
return cart;
}
// ✅ 좋은 예: 새 객체 반환
function addItemGood(cart, newItem) {
return {
...cart,
items: [...cart.items, newItem], // 새 배열 생성
total: cart.total + newItem.price
};
}
// ✅ 불변 메서드 체인
function filterExpensiveItems(items, maxPrice) {
return items
.filter(item => item.price <= maxPrice) // 새 배열 반환
.map(item => ({ ...item, discounted: true })) // 새 객체들의 배열
.sort((a, b) => a.price - b.price); // 정렬된 새 배열
}
- 패턴 3: 순수 함수와 부수 효과 함수 명확히 분리
// ✅ 순수 함수: 데이터 변환만 담당
function processUserData(user) {
return {
...user,
name: user.name.toUpperCase(),
email: user.email.toLowerCase(),
registered: true,
createdAt: new Date().toISOString() // 시간은 인자로 받는 게 더 순수하지만 실용적 타협
};
}
function validateUser(user) {
const errors = [];
if (!user.email.includes('@')) errors.push('Invalid email');
if (user.password.length < 8) errors.push('Password too short');
return { valid: errors.length === 0, errors };
}
// ❌ 부수 효과: 별도 함수로 명확히 분리
async function registerUser(userData) {
// 순수 함수로 검증
const validation = validateUser(userData);
if (!validation.valid) {
throw new Error(validation.errors.join(', '));
}
// 순수 함수로 데이터 변환
const processedUser = processUserData(userData);
// 부수 효과: 저장 및 알림
await database.save(processedUser); // 부수 효과
await emailService.sendWelcome(processedUser.email); // 부수 효과
return processedUser;
}
- 패턴 4: React에서의 적용
// ✅ 순수 컴포넌트: 렌더링 로직
function ShoppingCart({ items }) {
// 순수 함수로 계산
const total = items.reduce((sum, item) => sum + item.price, 0);
const itemCount = items.length;
// 순수한 JSX 반환
return (
<div>
<h2>장바구니 ({itemCount}개)</h2>
<ul>
{items.map(item => (
<li key={item.id}>{item.name} - ₩{item.price}</li>
))}
</ul>
<p>총액: ₩{total}</p>
</div>
);
}
// ❌ 부수 효과: useEffect로 격리
function ShoppingCartContainer() {
const [items, setItems] = useState([]);
// 부수 효과: 데이터 페칭
useEffect(() => {
fetch('/api/cart')
.then(res => res.json())
.then(data => setItems(data)); // 상태 업데이트
}, []);
// 순수 컴포넌트 렌더링
return <ShoppingCart items={items} />;
}
- 패턴 5: Redux에서의 적용
// ✅ 순수 함수: Reducer
function cartReducer(state = { items: [], total: 0 }, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price
};
case 'REMOVE_ITEM':
const newItems = state.items.filter(item => item.id !== action.payload);
return {
...state,
items: newItems,
total: newItems.reduce((sum, item) => sum + item.price, 0)
};
default:
return state;
}
}
// ❌ 부수 효과: Middleware (Redux Thunk)
function fetchCart() {
return async (dispatch) => {
// 부수 효과: API 호출
const response = await fetch('/api/cart');
const items = await response.json();
// 순수 함수 호출: dispatch
dispatch({ type: 'SET_ITEMS', payload: items });
};
}
- 패턴 6: 함수형 라이브러리 패턴 (fp-ts)
- IO 모나드: 부수 효과를 나타내는 타입으로 캡슐화
- Either 모나드: 에러 처리를 타입으로 표현
- Task 모나드: 비동기 작업 표현
- tap 패턴: 부수 효과를 수행하되 원본 값 유지
import { pipe } from 'fp-ts/function';
import * as IO from 'fp-ts/IO';
import * as IOE from 'fp-ts/IOEither';
import * as Console from 'fp-ts/Console';
// ✅ 순수 함수: 데이터 변환
const processOrder = (order: Order) => ({
...order,
total: order.items.reduce((sum, item) => sum + item.price, 0),
processedAt: new Date().toISOString()
});
// IO로 부수 효과 캡슐화
const logOrder = (order: Order): IO.IO<void> =>
Console.log(`Processing order ${order.id}`);
// tap 패턴: 부수 효과를 수행하되 원본 값 유지
const saveOrderWithLogging = (order: Order) =>
pipe(
IOE.of(order),
IOE.tap(() => logOrder(order)), // 로깅 부수 효과
IOE.tapIO(() => Console.log(`Total: ${order.total}`)) // 또 다른 부수 효과
);
// 실행 시점에 부수 효과 발생
const result = saveOrderWithLogging(processOrder(order))();
// Either로 에러 처리를 순수하게
import * as E from 'fp-ts/Either';
const validateEmail = (email: string): E.Either<string, string> =>
email.includes('@')
? E.right(email)
: E.left('Invalid email');
const validatePassword = (password: string): E.Either<string, string> =>
password.length >= 8
? E.right(password)
: E.left('Password too short');
// 순수한 함수 체인
const validateUser = (email: string, password: string) =>
pipe(
validateEmail(email),
E.chain(() => validatePassword(password))
);
- 요약 및 베스트 프랙티스
- ✅ 핵심 비즈니스 로직: 순수 함수로 작성
- ✅ I/O, 네트워크, DB: 프로그램 경계(진입점/출력부)에 배치
- ✅ 불변성 유지: Spread 연산자,
map
/filter
/reduce
활용 - ✅ 순수/비순수 함수 명확히 분리 및 명명
- ✅ React:
useEffect
로 부수 효과 격리 - ✅ Redux: Reducer는 순수, Middleware에서 부수 효과 처리
- ✅ 함수형 라이브러리(fp-ts): 타입으로 부수 효과 명시적 관리
- 트레이드오프: 100% 순수함을 추구하기보다 실용성과 가독성의 균형
출처: Jeremy's Blog - 함수형 프로그래밍이란 무엇인가? (opens in a new tab)
출처: fp-ts 공식 문서 - IO/Either/Task 패턴 (opens in a new tab)
출처: Functional Core, Imperative Shell Pattern (opens in a new tab)
불변성(Immutability)이 함수형 프로그래밍에서 중요한 이유는?
꼬리 질문
- 불변성을 지키지 않으면 어떤 문제가 발생하는가?
- JavaScript에서 불변성을 유지하는 방법은?
- Immer 같은 라이브러리는 어떻게 도움이 되는가?
- 성능 문제는 없는가?
답변 보기
- 핵심 포인트 💡:
- 불변성은 순수 함수의 핵심 전제 조건
- 예측 가능한 상태 관리와 디버깅 용이성 제공
- 원본 데이터 변경 대신 새로운 데이터 생성
- React, Redux에서 변경 감지 최적화에 필수
- 불변성이란?
- 한번 생성된 데이터는 변경하지 않는 것
- 데이터를 수정할 필요가 있으면 새로운 복사본을 생성
- 원본 데이터는 항상 보존됨
- 불변성을 지키지 않았을 때의 문제
// ❌ 문제 상황: 원본 배열 변경
const originalCart = [
{ id: 1, name: '사과', price: 1000 }
];
function addItem(cart, newItem) {
cart.push(newItem); // 원본 변경!
return cart;
}
const updatedCart = addItem(originalCart, { id: 2, name: '바나나', price: 1500 });
console.log(originalCart);
// [{ id: 1, name: '사과', price: 1000 }, { id: 2, name: '바나나', price: 1500 }]
// 😱 원본이 변경되어 버렸다!
console.log(originalCart === updatedCart); // true
// 같은 참조를 가리키므로 React는 변경을 감지하지 못함
// 실제 버그 발생 예시
function processOrders(orders) {
const highPriorityOrders = orders.filter(o => o.priority === 'high');
highPriorityOrders.sort((a, b) => b.amount - a.amount); // sort는 원본 변경!
return {
high: highPriorityOrders,
all: orders // 😱 orders도 정렬되어 버림 (filter는 새 배열이지만 같은 객체 참조)
};
}
- JavaScript에서 불변성 유지 방법
- Spread 연산자 (
...
)
- Spread 연산자 (
// 배열 복사
const original = [1, 2, 3];
const newArray = [...original, 4]; // [1, 2, 3, 4]
// original은 [1, 2, 3] 유지
// 객체 복사
const user = { name: 'John', age: 30 };
const updatedUser = { ...user, age: 31 }; // { name: 'John', age: 31 }
// user는 { name: 'John', age: 30 } 유지
- 불변 배열 메서드
const numbers = [1, 2, 3, 4, 5];
// ✅ 불변 메서드 (새 배열 반환)
const doubled = numbers.map(n => n * 2); // [2, 4, 6, 8, 10]
const evens = numbers.filter(n => n % 2 === 0); // [2, 4]
const sum = numbers.reduce((acc, n) => acc + n, 0); // 15
const sliced = numbers.slice(1, 3); // [2, 3]
const concatenated = numbers.concat([6, 7]); // [1, 2, 3, 4, 5, 6, 7]
// ❌ 가변 메서드 (원본 변경) - 사용 주의!
numbers.push(6); // 원본 변경
numbers.pop(); // 원본 변경
numbers.sort(); // 원본 변경
numbers.reverse(); // 원본 변경
numbers.splice(1, 1); // 원본 변경
- 중첩 객체 불변 업데이트
// ❌ 얕은 복사의 함정
const user = {
name: 'John',
address: {
city: 'Seoul',
zipCode: '12345'
}
};
const updated = { ...user };
updated.address.city = 'Busan'; // 😱 중첩 객체는 같은 참조!
console.log(user.address.city); // 'Busan' - 원본도 변경됨
// ✅ 깊은 복사
const properUpdate = {
...user,
address: {
...user.address,
city: 'Busan'
}
};
console.log(user.address.city); // 'Seoul' - 원본 유지
console.log(properUpdate.address.city); // 'Busan'
- React에서 불변성의 중요성
// React는 참조 동일성(===)으로 변경 감지
function ShoppingCart() {
const [items, setItems] = useState([]);
// ❌ 잘못된 방법: 원본 변경
const addItemWrong = (newItem) => {
items.push(newItem); // 원본 변경
setItems(items); // React가 변경을 감지하지 못함 (같은 참조)
};
// ✅ 올바른 방법: 새 배열 생성
const addItemRight = (newItem) => {
setItems([...items, newItem]); // 새 배열 생성
};
// ✅ 함수형 업데이트
const addItemBest = (newItem) => {
setItems(prevItems => [...prevItems, newItem]);
};
return (/* ... */);
}
- Redux에서 불변성
// ✅ Redux Reducer는 반드시 불변성 유지
function todosReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
// ✅ 새 배열 반환
return [...state, action.payload];
case 'TOGGLE_TODO':
// ✅ map으로 새 배열 생성
return state.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
);
case 'DELETE_TODO':
// ✅ filter로 새 배열 생성
return state.filter(todo => todo.id !== action.payload.id);
default:
return state;
}
}
- Immer 라이브러리로 편리하게 불변성 유지
import produce from 'immer';
// ❌ 복잡한 중첩 업데이트 (순수 JS)
const state = {
users: [
{ id: 1, name: 'John', todos: [{ id: 1, text: 'Learn React', done: false }] }
]
};
const newState = {
...state,
users: state.users.map(user =>
user.id === 1
? {
...user,
todos: user.todos.map(todo =>
todo.id === 1
? { ...todo, done: true }
: todo
)
}
: user
)
};
// ✅ Immer로 간단하게
const newStateWithImmer = produce(state, draft => {
// draft는 변경 가능한 것처럼 작성
const user = draft.users.find(u => u.id === 1);
const todo = user.todos.find(t => t.id === 1);
todo.done = true;
// Immer가 자동으로 불변 업데이트 수행
});
- 성능 고려사항
- 구조적 공유(Structural Sharing)
- 변경되지 않은 부분은 기존 참조 재사용
- 전체를 복사하지 않아 메모리 효율적
- 얕은 비교 최적화
- React.memo, useMemo는 참조 비교로 재렌더링 방지
- 불변성이 있어야 정확한 변경 감지 가능
- 성능 측정 후 최적화
- 대부분의 경우 불변성 유지의 오버헤드는 미미
- 정말 큰 데이터셋에서만 성능 이슈 발생 가능
- Immer, Immutable.js 같은 라이브러리로 최적화
- 구조적 공유(Structural Sharing)
// React.memo는 props 참조 비교
const TodoItem = React.memo(({ todo }) => {
console.log('Rendering TodoItem');
return <li>{todo.text}</li>;
});
// ✅ 불변 업데이트 시 변경된 항목만 재렌더링
const todos = [
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Learn Redux' }
];
const newTodos = todos.map(todo =>
todo.id === 1
? { ...todo, text: 'Learn React Hooks' } // 새 객체
: todo // 같은 참조 재사용
);
// id: 1인 TodoItem만 재렌더링
출처: 불변성 - React 공식 문서 (opens in a new tab)