부수효과(Side Effect)를 최소화해서 모듈을 작성하는 방법
1. 예측 불가능한 모듈의 문제
1,000줄 이상의 코드가 담긴 대규모 컴포넌트는 테스트, 재사용, 유지보수가 거의 불가능에 가깝다. 이러한 컴포넌트는 보통 다음과 같은 특징을 공유한다.
- API 호출, 상태 관리, 비즈니스 로직, UI 렌더링이 강하게 결합되어 있다.
- 전역 상태, localStorage, document 등 외부 환경에 직접 의존하고 접근한다.
- 하나의 변경이 어떤 파급 효과를 일으킬지 예측하기 어렵다.
이 문제의 기술적 근원은 부수효과(Side Effect)를 체계적으로 제어하지 못했다는 데 있다. 이 글에서는 함수형 프로그래밍 원칙을 활용하여 부수효과를 식별, 격리, 제어함으로써 모듈의 예측 가능성과 테스트 용이성을 높이는 아키텍처 전략을 알아본다.
2. 좋은 모듈의 조건: 예측 가능성과 부수효과
리팩토링 전략을 논하기에 앞서, '좋은 모듈'이란 무엇인지, 그리고 부수효과가 이를 어떻게 저해하는지 근본적인 개념부터 짚어볼 필요가 있다.
1. JavaScript 모듈의 개념과 목표
JavaScript에서 모듈(주로 파일 단위)은 관련된 코드(변수, 함수, 클래스 등)를 하나로 묶은 독립적인 실행 단위를 의미한다. 우리가 코드를 모듈로 분리하는 이유는 다음과 같은 목표를 달성하기 위함이다.
- 재사용성(Reusability): 다른 부분에서 쉽게 가져와 재사용할 수 있다.
- 유지보수성(Maintainability): 특정 기능을 수정할 때 해당 모듈에만 집중할 수 있다.
- 캡슐화(Encapsulation): 모듈의 내부 구현을 숨기고, 오직 공개된 인터페이스(export)를 통해서만 상호작용하게 한다.
이 모든 목표는 하나의 공통된 특성, 바로 예측 가능성으로 귀결된다. 좋은 모듈은 외부 세계에 어떤 영향을 미칠지 명확하게 예측 가능해야 한다.
2. 부수효과가 모듈의 목표를 저해하는 이유
부수효과는 함수의 결과 값 외에 애플리케이션의 상태나 외부 세계와 상호작용하는 모든 행위를 의미한다. 부수효과는 예측 가능성을 깨뜨려 좋은 모듈의 목표를 정면으로 위반한다.
- 예측 가능성 저하: 부수효과가 포함된 함수는 호출 시점이나 외부 상태에 따라 다른 결과를 내거나 다른 동작을 수행한다.
- 재사용성 저하: 특정 localStorage 키나 전역 객체에 의존하는 모듈은 해당 컨텍스트가 없는 다른 환경에서 재사용할 수 없다.
- 테스트 복잡성 증가: 부수효과는 네트워크, DOM, 타이머 등 테스트하기 어려운 외부 요인에 대한 의존성을 만들어내고, 이는 수많은 모의(mock) 작업을 필요로 한다.
따라서 우리의 목표는 부수효과를 '제거'하는 것이 아니라, 순수한 로직으로부터 '분리'하고 '격리'하여 모듈 전체의 예측 가능성을 높이는 것이다.
3. 부수효과(Side Effect)의 정의와 문제점
순수 함수는 동일한 입력에 대해 항상 동일한 출력을 반환하며, 외부 상태에 어떠한 영향도 주지 않는 함수다. 반면, 부수효과는 함수의 결과 값 외에 애플리케이션의 상태나 외부 세계와 상호작용하는 모든 행위를 의미한다.
- 대표적인 부수효과: 네트워크 요청, DB 접근, 파일 시스템 I/O,
console.log
, DOM 조작, 전역 변수 수정,Date.now()
,Math.random()
등.
부수효과 자체는 웹 애플리케이션을 만드는 데 필수적이다. 문제는 부수효과가 순수한 비즈니스 로직과 뒤섞여 있을 때 발생한다. 로직의 결과를 예측할 수 없게 만들고, 테스트를 위해 수많은 의존성을 모의(mock)해야 하는 상황을 초래한다.
4. 부수효과 제어를 위한 아키텍처 원칙
1. 1. 순수성(Purity)과 불변성(Immutability)
핵심 비즈니스 로직과 데이터 변환 로직은 가능한 한 순수 함수로 작성한다. 순수 함수는 입력과 출력에만 집중하므로 예측 가능하고 테스트가 매우 쉽다. 불변성은 데이터의 원본을 수정하는 대신 항상 새로운 복사본을 반환하여, 상태 변경으로 인한 예측 불가능한 부수효과를 원천적으로 방지한다.
2. 책임 분리
모듈의 책임을 명확히 분리한다.
- 도메인(Domain) 계층: 순수한 비즈니스 로직과 규칙을 담당한다. (e.g., 가격 계산, 데이터 필터링)
- 애플리케이션(Application) 계층: 데이터 페칭, 상태 관리 등 부수효과를 포함한 애플리케이션의 흐름을 제어한다. (e.g., React Hooks)
- 프레젠테이션(Presentation) 계층: UI 렌더링에만 집중한다. (e.g., React Components)
3. 의존성 주입
모듈이 사용하는 외부 의존성(API 클라이언트, 로거, 스토리지 등)을 내부에서 직접 생성하거나 참조하지 않고, 외부에서 인자나 매개변수로 주입받는다. 이를 통해 모듈은 특정 구현으로부터 분리되어 유연성과 테스트 용이성이 극대화된다.
5. 실전 리팩토링: 주문 확인 페이지 분해하기
다음은 주문 데이터를 불러와 가격을 계산하고, 결과를 저장하는 등 모든 기능이 혼합된 대규모 컴포넌트다.
- OrderPage.jsx (리팩토링 전)
function OrderPage({ orderId }) {
const [order, setOrder] = useState(null);
useEffect(() => {
// 1. 데이터 페칭
fetch(`/api/orders/${orderId}`)
.then(res => res.json())
.then(data => {
setOrder(data);
// 2. 외부 환경 조작 (부수효과)
document.title = `Order #${data.id}`;
analytics.track('order_viewed', { orderId: data.id });
});
}, [orderId]);
if (!order) return <Spinner />;
// 3. 비즈니스 로직
const subTotal = order.items.reduce((sum, item) => sum + item.price, 0);
const tax = subTotal * 0.1;
const total = subTotal + tax;
return (
<div>
<h1>Order #{order.id}</h1>
{/* ... UI 렌더링 ... */}
<p>Total: ${total}</p>
</div>
);
}
1단계: 순수한 비즈니스 로직 분리
가장 먼저, 입력과 출력만으로 동작하는 순수한 계산 로직을 별도의 domain
모듈로 추출한다.
- domain/order.js
export const calculateTotals = (items) => {
if (!items || items.length === 0) {
return { subTotal: 0, tax: 0, total: 0 };
}
const subTotal = items.reduce((sum, item) => sum + item.price, 0);
const tax = subTotal * 0.1;
const total = subTotal + tax;
return { subTotal, tax, total };
};
이제 calculateTotals
함수는 어떤 외부 환경에도 의존하지 않으므로 독립적으로 테스트할 수 있다.
2단계: 데이터 페칭 로직 분리 (Custom Hook)
API 호출과 비동기 상태(loading
, error
, data
) 관리는 커스텀 훅으로 분리하여 재사용성을 높인다.
- hooks/useOrder.js
import { useState, useEffect } from 'react';
import { api } from '../services/api'; // API 클라이언트 의존성
export function useOrder(orderId) {
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
api.getOrderById(orderId)
.then(setOrder)
.finally(() => setLoading(false));
}, [orderId]);
return { order, loading };
}
3단계: 부수효과 격리 및 의존성 주입
document.title
변경이나 분석 전송과 같은 부수효과는 별도의 서비스 모듈로 격리한다. 이때, 실제 analytics
객체를 외부에서 주입받도록 설계하여 테스트 용이성을 확보한다.
- services/tracking.js
// analytics 객체를 인자로 받아 추상화 계층을 만든다.
export const createTrackingService = (analytics) => ({
trackOrderViewed: (orderId) => {
analytics.track('order_viewed', { orderId });
}
});
// 실제 프로덕션에서 사용할 인스턴스
import { analyticsClient } from './analytics-client';
export const trackingService = createTrackingService(analyticsClient);
4단계: 재구성된 컴포넌트
모든 로직과 부수효과가 분리된 OrderPage
는 이제 각 모듈을 조립하는 역할만 수행한다.
- OrderPage.jsx (리팩토링 후)
import { useEffect } from 'react';
import { useOrder } from '../hooks/useOrder';
import { calculateTotals } from '../domain/order';
import { trackingService } from '../services/tracking'; // 의존성 주입된 인스턴스 사용
function OrderPage({ orderId }) {
const { order, loading } = useOrder(orderId);
// 부수효과 실행은 한 곳에서 명확하게 관리
useEffect(() => {
if (order) {
document.title = `Order #${order.id}`;
trackingService.trackOrderViewed(order.id);
}
}, [order]);
if (loading) return <Spinner />;
// 순수 함수를 이용한 상태 계산
const { total } = calculateTotals(order.items);
return (
<div>
<h1>Order #{order.id}</h1>
{/* ... UI 렌더링 ... */}
<p>Total: ${total}</p>
</div>
);
}
이제 각 부분(도메인 로직, 데이터 페칭, 부수효과, UI)은 독립적으로 개발, 테스트, 수정이 가능하다.
6. 테스트 전략의 변화
리팩토링을 통해 각 계층이 명확히 분리되면서, 우리는 각기 다른 테스트 전략을 적용할 수 있게 되었다.
1. 단위 테스트(Unit Test): 각 계층의 독립적 검증
domain/order.js
(순수 함수):: 어떤 모의(mock)도 필요 없이, 입력과 출력만으로 간단히 테스트할 수 있다.
test('calculateTotals는 빈 배열에 대해 0을 반환해야 한다', () => {
expect(calculateTotals([])).toEqual({ subTotal: 0, tax: 0, total: 0 });
});
services/tracking.js
(부수효과): 가짜 analytics 객체를 주입하여 track 메서드가 올바른 인자와 함께 호출되었는지 확인한다.
test('trackOrderViewed는 올바른 이벤트 이름과 데이터를 전달해야 한다', () => {
const mockAnalytics = { track: jest.fn() };
const service = createTrackingService(mockAnalytics);
service.trackOrderViewed('123');
expect(mockAnalytics.track).toHaveBeenCalledWith('order_viewed', { orderId: '123' });
});
2. 통합/E2E 테스트: Playwright를 이용한 사용자 시나리오 검증
단위 테스트가 각 부품의 정상 동작을 보장한다면, 통합 테스트는 이 부품들이 조립되었을 때 전체 시스템이 사용자 관점에서 올바르게 동작하는지를 검증한다. Playwright를 사용하면 실제 브라우저 환경에서 이를 확인할 수 있다.
OrderPage.spec.js
(Playwright 테스트 예시)
import { test, expect } from '@playwright/test';
const MOCK_ORDER_DATA = {
id: '123',
items: [{ price: 8000 }, { price: 2000 }],
};
test.describe('OrderPage', () => {
test('주문 상세 정보를 올바르게 표시하고 부수효과를 실행해야 한다', async ({ page }) => {
// 1. API 요청 가로채기 (Mocking)
await page.route('/api/orders/123', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_ORDER_DATA),
});
});
// 2. 분석(Analytics) API 요청을 감시할 Promise 생성
const analyticsRequestPromise = page.waitForRequest(
(req) => req.url().includes('/api/analytics') && req.method() === 'POST'
);
// 3. 페이지로 이동하여 컴포넌트 렌더링
await page.goto('/orders/123');
// 4. UI 검증: 순수 로직의 계산 결과가 올바르게 표시되는가?
await expect(page.getByRole('heading')).toHaveText('Order #123');
await expect(page.getByText('Total: $11000')).toBeVisible(); // 10000 * 1.1
// 5. 부수효과 검증 (1): document.title이 올바르게 변경되었는가?
await expect(page).toHaveTitle('Order #123');
// 6. 부수효과 검증 (2): 분석 이벤트가 올바른 데이터와 함께 전송되었는가?
const analyticsRequest = await analyticsRequestPromise;
const postData = JSON.parse(analyticsRequest.postData());
expect(postData.event).toBe('order_viewed');
expect(postData.payload.orderId).toBe('123');
});
});
이 테스트는 API 모의, UI 검증, 그리고 눈에 보이지 않는 부수효과(타이틀 변경, 분석 이벤트 전송)까지 하나의 사용자 시나리오 안에서 모두 검증하여 리팩토링된 아키텍처의 견고함을 증명한다.
7. 부수효과의 제어와 모듈 설계
부수효과를 애플리케이션에서 완전히 제거하는 것은 불가능하며, 바람직하지도 않다. 진정한 목표는 부수효과를 제거하는 것이 아니라 제어하는 것이다.
순수한 비즈니스 로직을 코드베이스의 핵심에 두고, 부수효과는 애플리케이션의 가장자리에서 격리하여 관리하는 아키텍처는 다음과 같은 이점을 제공한다.
- 예측 가능성: 순수 함수는 언제나 동일한 결과를 보장한다.
- 테스트 용이성: 모의(mocking)를 최소화하고, 각 계층을 독립적으로 테스트할 수 있다.
- 유연성 및 재사용성: 의존성 주입을 통해 특정 기술(e.g., axios -> fetch)로부터 자유로워지며, 순수 로직은 어떤 환경에서도 재사용 가능하다.
1,000줄의 대규모 컴포넌트는 기술적 부채의 결과물이지만, 부수효과를 체계적으로 분리하고 제어하는 원칙을 점진적으로 적용함으로써, 우리는 예측 가능하고 견고한 모듈로 리팩토링할 수 있다.