728x90
반응형
React의 핵심 철학은 "무엇을 렌더링할지"에 집중하는 선언적 표현입니다. 하지만 컴포넌트 내부에 모든 로직을 작성하다 보면 이 철학에서 벗어나 명령형 코드로 가득 찬 컴포넌트를 만들게 됩니다. 오늘은 적절한 모듈 격리를 통해 선언적 표현을 유지하면서 유지보수성을 높이는 방법을 알아보겠습니다.
서론
명령형으로 변질된 컴포넌트
function ProductList({ categoryId, sortBy, filterOptions }) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [filteredProducts, setFilteredProducts] = useState([]);
// 선언적이어야 할 컴포넌트가 명령형 로직으로 오염됨
const validateProduct = (product) => {
if (!product.name || product.name.trim().length === 0) {
return false;
}
if (product.price <= 0) {
return false;
}
if (!product.category || !product.category.id) {
return false;
}
return true;
};
const calculateDiscountedPrice = (originalPrice, discountRate, membershipLevel) => {
let finalDiscount = discountRate;
if (membershipLevel === 'GOLD') {
finalDiscount += 0.05;
} else if (membershipLevel === 'PLATINUM') {
finalDiscount += 0.1;
}
return originalPrice * (1 - Math.min(finalDiscount, 0.5));
};
const sortProducts = (products, sortBy) => {
return [...products].sort((a, b) => {
switch (sortBy) {
case 'price_asc':
return a.price - b.price;
case 'price_desc':
return b.price - a.price;
case 'name_asc':
return a.name.localeCompare(b.name);
case 'rating_desc':
return b.rating - a.rating;
default:
return new Date(b.createdAt) - new Date(a.createdAt);
}
});
};
const applyFilters = (products, filters) => {
return products.filter(product => {
if (filters.minPrice && product.price < filters.minPrice) return false;
if (filters.maxPrice && product.price > filters.maxPrice) return false;
if (filters.brands && filters.brands.length > 0 && !filters.brands.includes(product.brand)) return false;
if (filters.inStock && !product.inStock) return false;
return true;
});
};
// 복잡한 사이드 이펙트
useEffect(() => {
const fetchProducts = async () => {
setLoading(true);
try {
const response = await fetch(`/api/categories/${categoryId}/products`);
const data = await response.json();
const validProducts = data.filter(validateProduct);
setProducts(validProducts);
} catch (error) {
console.error('상품 로딩 실패:', error);
} finally {
setLoading(false);
}
};
fetchProducts();
}, [categoryId]);
// 복잡한 계산과 변환
useEffect(() => {
let result = products;
result = applyFilters(result, filterOptions);
result = sortProducts(result, sortBy);
setFilteredProducts(result);
}, [products, filterOptions, sortBy]);
return (
<div>
{loading ? (
<div>로딩 중...</div>
) : (
<div>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
discountedPrice={calculateDiscountedPrice(
product.price,
product.discountRate,
'GOLD'
)}
/>
))}
</div>
)}
</div>
);
}
위 컴포넌트는 "상품 목록을 렌더링한다"는 선언적 의도가 복잡한 명령형 로직에 묻혀버렸습니다.
선언적 표현을 위한 모듈 격리 원칙
1. 의도 표현의 명확하게 표현한다
컴포넌트는 "무엇을 하는지"만 표현해야 하고, "어떻게 하는지"는 격리된 모듈에 위임해야 합니다.
2. 하나의 역할을 수행한다. (Single Responsibility)
각 모듈은 하나의 명확한 책임만 가져야 합니다:
- 컴포넌트: UI 상태 관리와 렌더링 의도 표현
- 비즈니스 로직 모듈: 도메인 규칙과 계산
- 데이터 처리 모듈: 변환, 필터링, 정렬
- 서비스 모듈: 외부 시스템과의 통신
3. 추상화 수준이 일관성적이어야 한다. (Consistent Abstraction Level)
같은 레이어에서는 같은 추상화 수준의 모듈들만 함께 배치해야 합니다.
선언적 모듈 격리
1. 도메인 로직
// @feature/product/model/productValidation.js - 비즈니스 규칙 격리
export const ProductValidator = {
isValid(product) {
return this.hasValidName(product) &&
this.hasValidPrice(product) &&
this.hasValidCategory(product);
},
hasValidName(product) {
return product.name && product.name.trim().length > 0;
},
hasValidPrice(product) {
return product.price > 0;
},
hasValidCategory(product) {
return product.category && product.category.id;
},
getValidationErrors(product) {
const errors = [];
if (!this.hasValidName(product)) errors.push('상품명이 유효하지 않습니다');
if (!this.hasValidPrice(product)) errors.push('가격이 유효하지 않습니다');
if (!this.hasValidCategory(product)) errors.push('카테고리가 유효하지 않습니다');
return errors;
}
};
// @feature/product/model/pricingCalculator.js - 가격 계산 로직 격리
export const PricingCalculator = {
calculateDiscountedPrice(originalPrice, discountRate, membershipLevel = 'BASIC') {
const membershipBonus = this.getMembershipBonus(membershipLevel);
const totalDiscount = Math.min(discountRate + membershipBonus, 0.5);
return originalPrice * (1 - totalDiscount);
},
getMembershipBonus(level) {
const bonuses = {
'BASIC': 0,
'GOLD': 0.05,
'PLATINUM': 0.1
};
return bonuses[level] || 0;
},
calculateSavings(originalPrice, discountedPrice) {
return originalPrice - discountedPrice;
}
};
2. 데이터 처리 모듈 분리
// utils/productProcessor.js - 데이터 변환 로직 격리
export const ProductProcessor = {
sortBy(products, sortType) {
const sortStrategies = {
'price_asc': (a, b) => a.price - b.price,
'price_desc': (a, b) => b.price - a.price,
'name_asc': (a, b) => a.name.localeCompare(b.name),
'rating_desc': (a, b) => b.rating - a.rating,
'newest': (a, b) => new Date(b.createdAt) - new Date(a.createdAt)
};
const strategy = sortStrategies[sortType] || sortStrategies.newest;
return [...products].sort(strategy);
},
filterBy(products, filters) {
return products.filter(product =>
this.matchesPriceRange(product, filters) &&
this.matchesBrands(product, filters) &&
this.matchesAvailability(product, filters)
);
},
matchesPriceRange(product, { minPrice, maxPrice }) {
if (minPrice && product.price < minPrice) return false;
if (maxPrice && product.price > maxPrice) return false;
return true;
},
matchesBrands(product, { brands }) {
if (!brands || brands.length === 0) return true;
return brands.includes(product.brand);
},
matchesAvailability(product, { inStock }) {
if (inStock === undefined) return true;
return inStock ? product.inStock : !product.inStock;
},
transformForDisplay(products, membershipLevel) {
return products.map(product => ({
...product,
displayPrice: PricingCalculator.calculateDiscountedPrice(
product.price,
product.discountRate,
membershipLevel
),
savings: PricingCalculator.calculateSavings(
product.price,
PricingCalculator.calculateDiscountedPrice(product.price, product.discountRate, membershipLevel)
)
}));
}
};
3. 서비스 모듈 분리
// services/productService.js - 데이터 접근 로직 격리
export const ProductService = {
async fetchByCategory(categoryId) {
const response = await fetch(`/api/categories/${categoryId}/products`);
if (!response.ok) {
throw new Error(`상품 로딩 실패: ${response.statusText}`);
}
return response.json();
},
async fetchWithFilters(categoryId, filters) {
const queryParams = new URLSearchParams(filters).toString();
const response = await fetch(`/api/categories/${categoryId}/products?${queryParams}`);
if (!response.ok) {
throw new Error(`필터링된 상품 로딩 실패: ${response.statusText}`);
}
return response.json();
},
async searchProducts(query, categoryId) {
const response = await fetch(`/api/products/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, categoryId })
});
if (!response.ok) {
throw new Error(`상품 검색 실패: ${response.statusText}`);
}
return response.json();
}
};
4. 선언적 커스텀 훅
// hooks/useProductList.js - 상태 관리 로직 격리
import { ProductValidator } from '../domain/productValidation';
import { ProductProcessor } from '../utils/productProcessor';
import { ProductService } from '../services/productService';
export function useProductList(categoryId, sortBy, filterOptions, membershipLevel) {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const loadProducts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const rawProducts = await ProductService.fetchByCategory(categoryId);
const validProducts = rawProducts.filter(ProductValidator.isValid);
setProducts(validProducts);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [categoryId]);
useEffect(() => {
loadProducts();
}, [loadProducts]);
const processedProducts = useMemo(() => {
let result = products;
result = ProductProcessor.filterBy(result, filterOptions);
result = ProductProcessor.sortBy(result, sortBy);
result = ProductProcessor.transformForDisplay(result, membershipLevel);
return result;
}, [products, filterOptions, sortBy, membershipLevel]);
return {
products: processedProducts,
loading,
error,
refetch: loadProducts
};
}
5. 선언적 표현으로 개선된 컴포넌트
// components/ProductList.js - 순수하게 선언적인 컴포넌트
import { useProductList } from '../hooks/useProductList';
function ProductList({ categoryId, sortBy, filterOptions, membershipLevel = 'BASIC' }) {
const { products, loading, error, refetch } = useProductList(
categoryId,
sortBy,
filterOptions,
membershipLevel
);
if (error) {
return <ErrorDisplay message={error} onRetry={refetch} />;
}
if (loading) {
return <LoadingDisplay />;
}
return (
<ProductGrid>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
displayPrice={product.displayPrice}
savings={product.savings}
/>
))}
</ProductGrid>
);
}
선언적 표현의 핵심: 의도 중심 코드
Before: 구현 세부사항이 노출된 코드
// 명령형 - "어떻게"에 집중
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
let result = products.filter(product => {
if (filterOptions.minPrice && product.price < filterOptions.minPrice) return false;
if (filterOptions.maxPrice && product.price > filterOptions.maxPrice) return false;
return true;
});
result = result.sort((a, b) => {
if (sortBy === 'price_asc') return a.price - b.price;
if (sortBy === 'price_desc') return b.price - a.price;
return 0;
});
setFilteredProducts(result);
}, [products, filterOptions, sortBy]);
After: 의도가 명확히 드러나는 코드
// 선언형 - "무엇을"에 집중
const processedProducts = useMemo(() => {
const filtered = ProductProcessor.filterBy(products, filterOptions);
const sorted = ProductProcessor.sortBy(filtered, sortBy);
return ProductProcessor.transformForDisplay(sorted, membershipLevel);
}, [products, filterOptions, sortBy, membershipLevel]);
유지보수성 관점에서의 모듈 격리
1. 변경 지점의 격리
// 비즈니스 규칙 변경 시 한 곳에서만 수정
// domain/productValidation.js
export const ProductValidator = {
isValid(product) {
// 새로운 검증 규칙 추가도 이곳에서만
return this.hasValidName(product) &&
this.hasValidPrice(product) &&
this.hasValidCategory(product) &&
this.hasValidDescription(product); // 새 규칙 추가
},
hasValidDescription(product) {
return product.description && product.description.length >= 10;
}
};
2. 테스트 가능한 단위 분리
// productValidation.test.js
describe('ProductValidator', () => {
describe('isValid', () => {
it('유효한 상품에 대해 true를 반환해야 한다', () => {
const validProduct = {
name: '테스트 상품',
price: 1000,
category: { id: 1 },
description: '상품 설명입니다'
};
expect(ProductValidator.isValid(validProduct)).toBe(true);
});
it('이름이 없는 상품에 대해 false를 반환해야 한다', () => {
const invalidProduct = {
name: '',
price: 1000,
category: { id: 1 },
description: '상품 설명입니다'
};
expect(ProductValidator.isValid(invalidProduct)).toBe(false);
});
});
});
// productProcessor.test.js
describe('ProductProcessor', () => {
describe('sortBy', () => {
it('가격 오름차순으로 정렬해야 한다', () => {
const products = [
{ name: 'A', price: 2000 },
{ name: 'B', price: 1000 },
{ name: 'C', price: 3000 }
];
const sorted = ProductProcessor.sortBy(products, 'price_asc');
expect(sorted[0].price).toBe(1000);
expect(sorted[1].price).toBe(2000);
expect(sorted[2].price).toBe(3000);
});
});
});
3. 의존성 방향의 명확화
// 의존성 그래프가 명확함
// Components -> Hooks -> Services/Utils -> Domain
// 상위 레벨 (UI 계층)
ProductList -> useProductList
// 중간 레벨 (애플리케이션 계층)
useProductList -> ProductService, ProductProcessor
// 하위 레벨 (도메인 계층)
ProductProcessor -> ProductValidator, PricingCalculator
모듈 격리의 실용적 가이드라인
1. 격리 우선순위
- 도메인 로직 (최우선): 비즈니스 규칙, 검증, 계산
- 데이터 가공: 필터링, 정렬, 포맷팅
- 외부 통신: API 호출, 데이터 페칭
- 유틸리티: 공통 헬퍼 함수
2. 점진적 리팩토링 전략
// 1단계: 명백한 순수 함수부터 분리
const calculateTotal = (items) => items.reduce((sum, item) => sum + item.price, 0);
// → utils/calculations.js로 이동
// 2단계: 비즈니스 로직 분리
const validateItem = (item) => item.price > 0 && item.name.length > 0;
// → domain/itemValidation.js로 이동
// 3단계: 데이터 처리 로직 분리
const sortItems = (items, sortBy) => { /* ... */ };
// → utils/itemProcessor.js로 이동
// 4단계: 서비스 로직 분리
const fetchItems = async () => { /* ... */ };
// → services/itemService.js로 이동
마무리: 선언적 표현과 지속 가능한 개발
좋은 모듈 격리는 단순히 파일을 나누는 것이 아닙니다. 선언적 표현을 통해 코드의 의도를 명확히 하고, 변경에 유연하게 대응할 수 있는 구조를 만드는 것입니다.
핵심 원칙:
- 컴포넌트는 "무엇을" 렌더링할지만 선언
- "어떻게" 하는지는 격리된 모듈에 위임
- 각 모듈은 단일 책임과 명확한 의도
- 테스트 가능하고 재사용 가능한 단위로 분리
이러한 접근 방식을 통해 React의 선언적 철학을 유지하면서도 복잡한 애플리케이션을 지속 가능하게 개발할 수 있습니다. 클로저 디펜던시 문제는 자연스럽게 해결되고, 코드는 더욱 읽기 쉽고 유지보수하기 쉬워집니다.
728x90
반응형
'react' 카테고리의 다른 글
| 추상화와 추상화 수준: 깔끔한 코드의 핵심 (0) | 2025.08.17 |
|---|---|
| 컴포넌트 설계: 시나리오별 인터페이스 분리 전략 (2) | 2025.08.17 |
| 바퀴의 재발명을 멈추고 비즈니스 로직에 집중하기 (5) | 2025.08.09 |
| [Nextjs] 다국어(i18n, internationalization) 적용하기 (0) | 2024.07.19 |
| [설계] 격리된 컴포넌트간의 통신 설계 (feat. Eventbus) (0) | 2024.06.28 |