임팩트: 이커머스 상품 도메인 구조 재설계 — 컴포넌트 수/복잡도 감소 수치 필요
SCRUM-14725 | 커밋 수: 59개 | 기간: 2025.07 ~ 2025.09
문제상황
상품 관련 비즈니스 로직이 컴포넌트에 분산되어 있었습니다. Service Layer와 UI Helper의 구분 없이 혼재되어 있었고, 테스트 커버리지도 부족한 상태였습니다.
Before (컴포넌트에 로직 혼재)
장점
- 빠른 초기 개발
고려할 점
- 비즈니스/UI 로직 혼재
- 테스트 불가
- 재사용 불가
After (Service + UI Helper 분리)
장점
- 테스트 용이
- 재사용 가능
- 관심사 분리
고려할 점
- 초기 리팩토링 비용
- 레이어 간 인터페이스 설계 필요
주요 개선사항
Product Service Layer 아키텍처 구조를 정립하고, Transform 함수 패턴을 표준화했습니다. 가격/혜택/재고 관련 로직을 Service와 UI Helper로 분리했으며, TabNavigator 공통 컴포넌트를 구현했습니다.
- Service Functions (비즈니스 로직)
- calculateDiscountPrice()
- calculateBenefitAmount()
- checkStockAvailability()
- UI Helper Functions (표시 로직)
- formatPrice()
- formatStockStatus()
- getBenefitBadgeText()
정량적 성과
상품 관련 통합 테스트 10개 이상을 추가하고, BDD 스타일로 테스트를 리팩토링했습니다. Search 도메인 아키텍처 구조도 함께 정립했습니다.
| 항목 | 비교 | 값 (개/줄) |
|---|---|---|
| 테스트 케이스 | 10 개/줄 | |
| 테스트 코드 | 4900 개/줄 |
비즈니스 임팩트
코드 재사용성이 향상되었고, 테스트 가능성이 증가했습니다. 비즈니스 로직이 명확히 분리됨에 따라 신규 개발자 온보딩 시간도 단축되었습니다.
테스트 가능성
4,900줄+ 테스트 코드, BDD 스타일
재사용성
Service/UI Helper 분리로 도메인 간 공유
구조 표준화
Transform 함수 패턴 정립
기술적 구현 상세
AS-IS
기존에는 상품 관련 로직이 컴포넌트에 직접 구현되어 있었습니다.
<!-- 기존 구조 (더미 예시) -->
<script setup lang="ts">
// 컴포넌트 내부에 비즈니스 로직 혼재
const calculateDiscountPrice = (price: number, discountRate: number) => {
return price * (1 - discountRate / 100);
};
const formatPrice = (price: number) => {
return price.toLocaleString() + '원';
};
const checkStock = (stock: number) => {
return stock > 0;
};
</script>
문제점:
비즈니스 로직과 UI 로직이 혼재되어 있어 테스트하기 어려운 구조였고, 로직 재사용도 불가능했습니다.
TO-BE
Service Layer와 UI Helper를 분리하여 관심사를 명확히 구분했습니다.
hooks/product.ts
├── Service Functions (비즈니스 로직)
│ ├── calculateDiscountPrice()
│ ├── calculateBenefitAmount()
│ └── checkStockAvailability()
│
└── UI Helper Functions (표시 로직)
├── formatPrice()
├── formatStockStatus()
└── getBenefitBadgeText()
핵심 구현
1. Service Layer 분리
// hooks/product.ts - Service Functions
export const useProductService = () => {
// 할인가 계산 (비즈니스 로직)
const calculateDiscountPrice = (
originalPrice: number,
discountRate: number
): number => {
if (discountRate <= 0 || discountRate > 100) {
return originalPrice;
}
return Math.floor(originalPrice * (1 - discountRate / 100));
};
// 혜택 금액 계산 (비즈니스 로직)
const calculateBenefitAmount = (
price: number,
benefits: ProductBenefit[]
): number => {
return benefits.reduce((total, benefit) => {
if (benefit.type === 'point') {
return total + benefit.amount;
}
if (benefit.type === 'coupon') {
return total + Math.floor(price * benefit.rate / 100);
}
return total;
}, 0);
};
// 재고 확인 (비즈니스 로직)
const checkStockAvailability = (
stock: number,
requestedQuantity: number
): StockStatus => {
if (stock === 0) {
return 'out_of_stock';
}
if (stock < requestedQuantity) {
return 'insufficient';
}
return 'available';
};
return {
calculateDiscountPrice,
calculateBenefitAmount,
checkStockAvailability,
};
};
2. UI Helper 분리
// hooks/product.ts - UI Helper Functions
export const useProductUIHelper = () => {
// 가격 포맷팅 (UI 로직)
const formatPrice = (price: number): string => {
return price.toLocaleString() + '원';
};
// 재고 상태 텍스트 (UI 로직)
const formatStockStatus = (status: StockStatus): string => {
const statusMap: Record<StockStatus, string> = {
available: '구매 가능',
insufficient: '재고 부족',
out_of_stock: '품절',
};
return statusMap[status];
};
// 혜택 뱃지 텍스트 (UI 로직)
const getBenefitBadgeText = (benefit: ProductBenefit): string => {
if (benefit.type === 'point') {
return `${benefit.amount}P 적립`;
}
if (benefit.type === 'coupon') {
return `${benefit.rate}% 쿠폰`;
}
return '';
};
return {
formatPrice,
formatStockStatus,
getBenefitBadgeText,
};
};
3. 통합 테스트 (BDD 스타일)
// test/unit/hooks/product.test.ts
describe('useProductService', () => {
describe('calculateDiscountPrice', () => {
it('할인율이 적용된 가격을 계산한다', () => {
// Given
const { calculateDiscountPrice } = useProductService();
const originalPrice = 10000;
const discountRate = 20;
// When
const result = calculateDiscountPrice(originalPrice, discountRate);
// Then
expect(result).toBe(8000);
});
it('할인율이 0이면 원래 가격을 반환한다', () => {
// Given
const { calculateDiscountPrice } = useProductService();
const originalPrice = 10000;
const discountRate = 0;
// When
const result = calculateDiscountPrice(originalPrice, discountRate);
// Then
expect(result).toBe(10000);
});
});
describe('checkStockAvailability', () => {
it('재고가 0이면 품절 상태를 반환한다', () => {
// Given
const { checkStockAvailability } = useProductService();
// When
const result = checkStockAvailability(0, 1);
// Then
expect(result).toBe('out_of_stock');
});
});
});
4. 컴포넌트에서 활용
<!-- components/product/UnifiedProductBenefit.vue -->
<script setup lang="ts">
import { useProductService, useProductUIHelper } from '@/hooks/product';
const props = defineProps<{
product: ProductEntity;
}>();
const { calculateBenefitAmount } = useProductService();
const { formatPrice, getBenefitBadgeText } = useProductUIHelper();
const totalBenefit = computed(() =>
calculateBenefitAmount(props.product.price, props.product.benefits)
);
</script>
<template>
<div class="product-benefit">
<span
v-for="benefit in product.benefits"
:key="benefit.id"
class="benefit-badge"
>
{{ getBenefitBadgeText(benefit) }}
</span>
<p class="total-benefit">
총 {{ formatPrice(totalBenefit) }} 혜택
</p>
</div>
</template>
회고
잘한 점
Service와 UI Helper를 분리하여 테스트 용이성을 확보했습니다. BDD 스타일 테스트로 요구사항 문서화 효과를 거두었고, 4,900줄 이상의 테스트 코드를 추가하여 안정성을 향상시켰습니다.
개선할 점
초기 설계 시 레이어 분리를 고려했다면 리팩토링 비용을 절감할 수 있었을 것입니다. 타입 정의를 더 세분화하여 타입 안전성을 강화할 필요도 있습니다.
배운 점
Service Layer와 UI Helper의 명확한 구분 기준을 확립했습니다. BDD 스타일 테스트 작성의 장점을 체감했고, 대규모 리팩토링 시 테스트 코드의 중요성을 다시 한번 확인했습니다.