임팩트: 이커머스 상품 도메인 구조 재설계 — 컴포넌트 수/복잡도 감소 수치 필요


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 공통 컴포넌트를 구현했습니다.

hooks/product.ts
  • Service Functions (비즈니스 로직)
    • calculateDiscountPrice()
    • calculateBenefitAmount()
    • checkStockAvailability()
  • UI Helper Functions (표시 로직)
    • formatPrice()
    • formatStockStatus()
    • getBenefitBadgeText()

정량적 성과

상품 관련 통합 테스트 10개 이상을 추가하고, BDD 스타일로 테스트를 리팩토링했습니다. Search 도메인 아키텍처 구조도 함께 정립했습니다.

테스트 코드 추가량
항목비교값 (개/줄)
테스트 케이스1010 개/줄
테스트 코드49004900 개/줄

비즈니스 임팩트

코드 재사용성이 향상되었고, 테스트 가능성이 증가했습니다. 비즈니스 로직이 명확히 분리됨에 따라 신규 개발자 온보딩 시간도 단축되었습니다.

🧪

테스트 가능성

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 스타일 테스트 작성의 장점을 체감했고, 대규모 리팩토링 시 테스트 코드의 중요성을 다시 한번 확인했습니다.