부수효과를 최소화해서 모듈을 작성하는 방법
2025년 1월 22일
side-effectsfunctional-programmingmodule-designtestingrefactoring
부수효과를 최소화해서 모듈을 작성하는 방법
1. 서론: 모든 것이 하나로 뭉쳐있던 시절
"이 컴포넌트 테스트 코드 좀 작성해줄 수 있어?"
팀장님의 요청에 파일을 열어보니 1,000줄이 넘는 거대한 컴포넌트가 있었다. 스크롤을 내리면서 대략적인 구조를 파악하려 했지만, 모든 것이 서로 얽혀있었다.
- API 호출과 상태 관리가 뒤섞여 있고
- 비즈니스 로직과 UI 로직이 구분되지 않고
- 전역 변수를 직접 수정하고
- localStorage, analytics, DOM을 직접 조작하고
- 하나를 수정하면 어디에 영향을 미칠지 예측 불가능
테스트를 작성하려면? Mock할 것이 너무 많았다. 재사용하려면? 전체를 복사해야 했다. 버그를 수정하려면? 사이드 이펙트가 두려웠다.
이런 코드가 만들어진 이유는 단순했다. 처음엔 작은 기능이었는데, 요구사항이 추가될 때마다 "일단 여기에 넣자"를 반복한 결과였다.
문제의 핵심은 **부수효과(Side Effect)**를 제대로 관리하지 않았기 때문이다.
2. 부수효과란 무엇인가?
순수 함수 vs 부수효과
// 순수 함수: 같은 입력 → 항상 같은 출력
function add(a, b) {
return a + b;
}
// 부수효과가 있는 함수
let total = 0;
function addToTotal(value) {
total += value; // 외부 상태 변경
console.log(`Total is now ${total}`); // I/O 작업
return total;
}
부수효과의 종류
- 상태 변경: 전역 변수, 객체 속성 수정
- I/O 작업: console.log, 파일 읽기/쓰기, 네트워크 요청
- DOM 조작: document.title 변경, element 수정
- 시간 의존: Date.now(), setTimeout
- 랜덤: Math.random()
- 예외 발생: throw new Error()
왜 부수효과가 문제인가?
// 이 함수의 결과를 예측할 수 있을까?
function calculatePrice(productId) {
const product = products.find(p => p.id === productId);
const discount = getCurrentUserDiscount(); // 외부 의존
const tax = getTaxRate(); // 외부 의존
// 로깅 부수효과
analytics.track('price_calculated', { productId });
return product.price * (1 - discount) * (1 + tax);
}
// 테스트하려면?
test('calculatePrice', () => {
// getCurrentUserDiscount를 mock해야 하고...
// getTaxRate도 mock해야 하고...
// analytics.track도 mock해야 하고...
// 😱
});
3. 함수형 프로그래밍의 핵심 원칙
원칙 1: 불변성 (Immutability)
// ❌ Bad: 원본 수정
function addItem(cart, item) {
cart.push(item);
return cart;
}
// ✅ Good: 새로운 배열 반환
function addItem(cart, item) {
return [...cart, item];
}
원칙 2: 함수 합성 (Function Composition)
// 작은 순수 함수들
const addTax = (rate) => (price) => price * (1 + rate);
const addDiscount = (rate) => (price) => price * (1 - rate);
const round = (price) => Math.round(price * 100) / 100;
// 함수 합성
const calculateFinalPrice = (price, taxRate, discountRate) => {
return round(
addTax(taxRate)(
addDiscount(discountRate)(price)
)
);
};
원칙 3: 참조 투명성 (Referential Transparency)
// 참조 투명: 함수 호출을 그 결과값으로 치환 가능
const result = add(2, 3); // 항상 5
const result = 5; // 위와 동일
// 참조 불투명: 치환 불가능
const result = Math.random(); // 매번 다른 값
const result = 0.7; // 위와 다름!
4. 실전: 거대한 컴포넌트 리팩토링하기
Step 1: 순수한 비즈니스 로직 분리
// ❌ Before: UI와 로직이 뒤섞임
function ProductList() {
const [products, setProducts] = useState([]);
const [filter, setFilter] = useState('all');
const handleFilter = (type) => {
let filtered;
if (type === 'available') {
filtered = products.filter(p => p.stock > 0 && !p.discontinued);
} else if (type === 'sale') {
filtered = products.filter(p => p.discount > 0 && p.saleEndDate > new Date());
}
// ... 더 많은 조건
setProducts(filtered);
analytics.track('filter_applied', { type });
};
}
// ✅ After: 순수 함수로 분리
// domain/product.js
export const filterProducts = (products, filterType) => {
switch (filterType) {
case 'available':
return products.filter(p => p.stock > 0 && !p.discontinued);
case 'sale':
return products.filter(p => p.discount > 0);
default:
return products;
}
};
export const isOnSale = (product, currentDate = new Date()) => {
return product.discount > 0 && product.saleEndDate > currentDate;
};
// components/ProductList.jsx
function ProductList() {
const [products, setProducts] = useState([]);
const [filter, setFilter] = useState('all');
const filteredProducts = filterProducts(products, filter);
const handleFilter = (type) => {
setFilter(type);
analytics.track('filter_applied', { type });
};
}
Step 2: 부수효과 격리
// ❌ Before: 부수효과가 곳곳에 산재
function ProductDetail({ id }) {
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(product => {
setProduct(product);
document.title = product.name;
localStorage.setItem('lastViewed', product.id);
analytics.track('product_viewed', product);
});
}, [id]);
}
// ✅ After: 부수효과를 명확히 분리
// effects/product.js
export const productEffects = {
updateDocumentTitle: (productName) => {
document.title = productName;
},
saveLastViewed: (productId) => {
localStorage.setItem('lastViewed', productId);
},
trackProductView: (product) => {
analytics.track('product_viewed', product);
}
};
// hooks/useProduct.js
export function useProduct(id) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
productApi.getById(id)
.then(setProduct)
.finally(() => setLoading(false));
}, [id]);
return { product, loading };
}
// components/ProductDetail.jsx
function ProductDetail({ id }) {
const { product, loading } = useProduct(id);
// 부수효과는 한 곳에서 관리
useEffect(() => {
if (product) {
productEffects.updateDocumentTitle(product.name);
productEffects.saveLastViewed(product.id);
productEffects.trackProductView(product);
}
}, [product]);
}
Step 3: 의존성 주입
// ❌ Before: 하드코딩된 의존성
function PriceCalculator({ product }) {
const finalPrice = product.price * (1 + TAX_RATE) * (1 - getUserDiscount());
return <div>{finalPrice}</div>;
}
// ✅ After: 의존성 주입
// domain/pricing.js
export const createPriceCalculator = (taxRate, discountProvider) => {
return {
calculate: (price) => {
const discount = discountProvider();
return price * (1 + taxRate) * (1 - discount);
},
calculateWithQuantity: (price, quantity) => {
const basePrice = price * quantity;
return createPriceCalculator(taxRate, discountProvider).calculate(basePrice);
}
};
};
// 사용
const priceCalculator = createPriceCalculator(
0.1, // 10% tax
() => currentUser.discountRate
);
function PriceDisplay({ product }) {
const finalPrice = priceCalculator.calculate(product.price);
return <div>{finalPrice}</div>;
}
5. 테스트 가능한 코드 만들기
순수 함수는 테스트가 쉽다
// domain/cart.js
export const cartCalculations = {
getTotalPrice: (items) =>
items.reduce((total, item) => total + item.price * item.quantity, 0),
getItemCount: (items) =>
items.reduce((count, item) => count + item.quantity, 0),
applyDiscount: (total, discountRate) =>
total * (1 - discountRate),
canApplyFreeShipping: (total, threshold = 50000) =>
total >= threshold
};
// domain/cart.test.js
describe('cartCalculations', () => {
const mockItems = [
{ price: 10000, quantity: 2 },
{ price: 5000, quantity: 1 }
];
test('getTotalPrice는 총 가격을 계산한다', () => {
expect(cartCalculations.getTotalPrice(mockItems)).toBe(25000);
});
test('빈 카트의 총 가격은 0이다', () => {
expect(cartCalculations.getTotalPrice([])).toBe(0);
});
test('무료 배송 조건을 확인한다', () => {
expect(cartCalculations.canApplyFreeShipping(60000)).toBe(true);
expect(cartCalculations.canApplyFreeShipping(30000)).toBe(false);
});
});
부수효과는 격리해서 테스트
// effects/storage.js
export const createStorage = (storage = localStorage) => ({
saveCart: (cart) => {
storage.setItem('cart', JSON.stringify(cart));
},
loadCart: () => {
const saved = storage.getItem('cart');
return saved ? JSON.parse(saved) : [];
},
clearCart: () => {
storage.removeItem('cart');
}
});
// effects/storage.test.js
describe('storage', () => {
test('카트를 저장하고 불러온다', () => {
// Mock storage 사용
const mockStorage = {
data: {},
setItem: jest.fn((key, value) => { mockStorage.data[key] = value; }),
getItem: jest.fn((key) => mockStorage.data[key]),
removeItem: jest.fn((key) => { delete mockStorage.data[key]; })
};
const storage = createStorage(mockStorage);
const cart = [{ id: 1, quantity: 2 }];
storage.saveCart(cart);
expect(mockStorage.setItem).toHaveBeenCalledWith('cart', JSON.stringify(cart));
const loaded = storage.loadCart();
expect(loaded).toEqual(cart);
});
});
통합 테스트에서 부수효과 확인
// hooks/useCart.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useCart } from './useCart';
describe('useCart', () => {
test('아이템 추가 시 localStorage에 저장된다', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem({ id: 1, price: 10000 });
});
expect(result.current.items).toHaveLength(1);
expect(localStorage.getItem('cart')).toBeTruthy();
const saved = JSON.parse(localStorage.getItem('cart'));
expect(saved).toHaveLength(1);
expect(saved[0].id).toBe(1);
});
});
6. 실무에서의 균형 잡기
모든 것을 순수하게?
현실적으로 모든 코드를 순수 함수로 만들 수는 없다. 중요한 것은 부수효과를 명확히 인지하고 격리하는 것이다.
// 합리적인 접근
function TodoApp() {
// 상태 관리 - 불가피한 부수효과
const [todos, setTodos] = useState([]);
// 순수한 비즈니스 로직
const completedCount = getCompletedCount(todos);
const pendingTodos = filterPending(todos);
// 부수효과는 이벤트 핸들러에 격리
const handleAdd = (text) => {
const newTodo = createTodo(text); // 순수 함수
setTodos(addTodo(todos, newTodo)); // 순수 함수
todoApi.create(newTodo); // 부수효과
};
return (
<div>
<TodoList todos={pendingTodos} />
<TodoStats completed={completedCount} />
</div>
);
}
점진적 개선
// Phase 1: 가장 쉬운 것부터 - 계산 로직 분리
const calculations = extractCalculations(legacyCode);
// Phase 2: 데이터 변환 로직 분리
const transformers = extractTransformers(legacyCode);
// Phase 3: API 호출 분리
const api = extractApiCalls(legacyCode);
// Phase 4: 부수효과 격리
const effects = extractEffects(legacyCode);
7. 패턴과 안티패턴
좋은 패턴들
1. 커맨드 패턴으로 부수효과 지연
// 부수효과를 즉시 실행하지 않고 명령으로 반환
function createCommands(state, action) {
switch (action.type) {
case 'SAVE':
return [
{ type: 'SAVE_TO_STORAGE', data: state },
{ type: 'SHOW_NOTIFICATION', message: 'Saved!' }
];
default:
return [];
}
}
// 실행은 별도로
commands.forEach(cmd => executeCommand(cmd));
2. Either 모나드로 에러 처리
// 예외를 던지는 대신 Either 타입 사용
const Either = {
right: (value) => ({ isRight: true, value }),
left: (error) => ({ isRight: false, error })
};
function safeDivide(a, b) {
if (b === 0) {
return Either.left('Division by zero');
}
return Either.right(a / b);
}
// 사용
const result = safeDivide(10, 0);
if (result.isRight) {
console.log(result.value);
} else {
console.error(result.error);
}
3. 어댑터 패턴으로 외부 의존성 격리
// 외부 라이브러리를 직접 사용하지 않고 어댑터로 감싸기
const dateAdapter = {
now: () => new Date(),
format: (date, pattern) => dayjs(date).format(pattern),
addDays: (date, days) => dayjs(date).add(days, 'day').toDate()
};
// 테스트에서는 mock 어댑터 사용
const mockDateAdapter = {
now: () => new Date('2023-01-01'),
format: (date) => '2023-01-01',
addDays: (date, days) => date
};
피해야 할 안티패턴
1. 숨겨진 부수효과
// ❌ Bad: 순수해 보이지만 부수효과가 숨어있음
function getUser(id) {
const user = users.find(u => u.id === id);
user.lastAccessed = new Date(); // 숨겨진 변경!
return user;
}
2. 과도한 순수성 추구
// ❌ Bad: 실용성을 잃은 과도한 추상화
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const curry = (f) => (...args) => args.length >= f.length ? f(...args) : curry(f.bind(null, ...args));
// 팀원들이 이해하기 어려운 코드
const processData = compose(
curry(map)(transform),
curry(filter)(isValid),
curry(reduce)(aggregate, 0)
);
8. 실전 체크리스트
부수효과를 최소화한 모듈을 작성할 때 확인해야 할 사항들:
설계 단계
- [ ] 순수한 비즈니스 로직을 먼저 식별했는가?
- [ ] 부수효과가 필요한 부분을 명확히 구분했는가?
- [ ] 의존성을 주입받을 수 있도록 설계했는가?
구현 단계
- [ ] 함수가 같은 입력에 대해 항상 같은 결과를 반환하는가?
- [ ] 외부 상태를 읽거나 변경하지 않는가?
- [ ] 함수의 이름이 부수효과를 암시하는가? (save, update, fetch 등)
테스트 단계
- [ ] 순수 함수는 mock 없이 테스트 가능한가?
- [ ] 부수효과는 적절히 mock되거나 격리되었는가?
- [ ] 테스트가 실행 순서에 의존하지 않는가?
리뷰 단계
- [ ] 팀원들이 코드를 이해하기 쉬운가?
- [ ] 부수효과가 예측 가능한 위치에 있는가?
- [ ] 리팩토링이 용이한 구조인가?
9. 결론: 완벽하지 않아도 괜찮다
// 이상적인 코드
const perfect = compose(
pure,
testable,
reusable,
maintainable
);
// 현실적인 코드
const practical = balance(
purity,
pragmatism,
teamConsensus,
businessNeeds
);
부수효과를 완전히 제거할 수는 없다. 하지만 명확히 인지하고, 격리하고, 관리할 수는 있다.
그 1,000줄짜리 컴포넌트는 이제 이렇게 변했다:
- 200줄의 순수한 비즈니스 로직 (100% 테스트 커버리지)
- 100줄의 UI 컴포넌트 (비즈니스 로직 없음)
- 50줄의 API 레이어 (명확한 인터페이스)
- 30줄의 부수효과 관리 (한 곳에 모음)
완벽하진 않지만, 이제는:
- 테스트하기 쉽다
- 수정이 두렵지 않다
- 재사용할 수 있다
- 무엇보다, 팀원들이 이해할 수 있다
"부수효과를 제거하는 것이 목적이 아니라, 제어하는 것이 목적이다."
작은 것부터 시작하자. 하나의 순수 함수를 만드는 것부터.