ProductService 분리

뚜우웅이·2025년 4월 26일

캡스톤 디자인

목록 보기
18/35

현재 ProductService 클래스는 상품 관리(CRUD 작업), 상품 상태 관리, 상품 조회 등 여러 책임을 담당하고 있다. 이는 단일 책임 원칙(SRP)을 위반하며, 클래스를 유지보수하고 확장하기 어렵게 만든다.

현재 구현의 문제점

  • ProductService가 너무 큼(약 300줄)과 동시에 다양한 책임을 가짐
  • 서로 다른 관심사의 메서드들이 혼재되어 있음
  • 한 측면(예: 상품 조회)을 변경하려면 관련 없는 책임도 함께 처리하는 클래스를 수정해야 함

솔루션

ProductService를 명확하고 집중된 책임을 가진 세 개의 서비스 클래스로 리팩토링:

ProductManagementService

  • CRUD 작업(상품 생성, 수정, 삭제) 담당
  • 파일 업로드 및 이미지 관리 처리
  • 상품 메타데이터 관리

ProductStatusService

  • 상품 상태 전환(판매 완료 표시, 판매 취소) 관리
  • 구매자 할당 처리
  • 상품 라이프사이클 로직 포함

ProductQueryService

  • 모든 상품 조회 및 검색 처리
  • 상품 통계(조회수, 찜 수) 통합
  • 페이지네이션 및 필터링 관리

이점

  • 코드 구성 및 가독성 향상
  • SOLID 원칙 준수 강화
  • 각 특정 기능을 더 쉽게 유지보수 및 확장 가능
  • 더 명확한 의존성
  • 더 집중된 테스트 가능

분리된 Service

ProductManagementService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductManagementService {
    private final ProductRepository productRepository;
    private final UserRepository userRepository;
    private final FileStorageService fileStorageService;

    // 상품 등록
    @Transactional
    public ProductDto.ProductBaseResponse createProduct(Long userId, ProductDto.CreateRequest request, List<MultipartFile> images) throws BadRequestException {
        User seller = getUser(userId);

        // 학교 이메일 인증 여부 확인
        emailVerification(seller);

        Product product = Product.builder()
                .name(request.name())
                .description(request.description())
                .price(request.price())
                .stock(request.stock())
                .category(request.category())
                .seller(seller)
                .build();

        List<FileStorageService.FileUploadResult> uploadedFiles = new ArrayList<>();
        try {
            if (images != null && !images.isEmpty()) {
                for (int i = 0; i < images.size(); i++) {
                    MultipartFile imageFile = images.get(i);
                    FileStorageService.FileUploadResult result = fileStorageService.storeFile(imageFile);
                    uploadedFiles.add(result);
                    boolean isThumbnail = (i == 0); // 첫 번째 이미지 -> 썸네일
                    ProductImage productImage = ProductImage.builder()
                            .imageUrl(result.originalFilePath())
                            .thumbnailUrl(result.thumbnailFilePath())
                            .originalFileName(result.originalFileName())
                            .displayOrder(i)
                            .isThumbnail(isThumbnail)
                            .build();
                    product.addImage(productImage); // Product 와 ProductImage 연결
                }
            }
            Product savedProduct = productRepository.save(product);
            log.info("상품 등록 완료: 상품명 {}, 판매자 ID {}", request.name(), userId);
            return ProductDto.ProductBaseResponse.from(savedProduct);
        } catch (Exception e) {
            log.error("상품 등록 중 오류 발생: 사용자 ID {}", userId, e);
            uploadedFiles.forEach(uploadedFile -> {
                fileStorageService.deleteFile(uploadedFile.originalFilePath());
                fileStorageService.deleteFile(uploadedFile.thumbnailFilePath());
            });
            throw new ProductException.ProductCreationException(e.getMessage());
        }
    }

    // 상품 수정

    @Transactional
    public ProductDto.ProductBaseResponse updateProduct(Long userId, Long productId, ProductDto.UpdateRequest request,
                                                        List<MultipartFile> newImages, List<Long> deleteImageIds) {
        Product product = getProductWithSellerCheck(productId, userId);
        product.update(request.name(), request.description(), request.price(), request.stock(), request.category());
        if (request.status() != null) product.changeStatus(request.status());

        boolean thumbnailDeleted = false; // 썸네일 삭제 여부 추적
        if (deleteImageIds != null && !deleteImageIds.isEmpty()) {
            List<ProductImage> imagesToDelete = product.getImages().stream().filter(img -> deleteImageIds.contains(img.getId())).toList();
            thumbnailDeleted = imagesToDelete.stream().anyMatch(ProductImage::isThumbnail);
            imagesToDelete.forEach(image -> {
                fileStorageService.deleteFile(image.getImageUrl());
                fileStorageService.deleteFile(image.getThumbnailUrl());
                product.removeImage(image);
                log.info("상품 ID {}의 이미지 삭제: {}", productId, image.getOriginalFileName());
            });
        }

        List<FileStorageService.FileUploadResult> newlyUploadedFiles = new ArrayList<>();
        try {
            if (newImages != null && !newImages.isEmpty()) {
                int currentImageCount = product.getImages().size();
                for (int i = 0; i < newImages.size(); i++) {
                    MultipartFile imageFile = newImages.get(i);
                    if (imageFile == null || imageFile.isEmpty()) continue;
                    FileStorageService.FileUploadResult result = fileStorageService.storeFile(imageFile);
                    newlyUploadedFiles.add(result);
                    // 썸네일 지정: (현재 이미지가 없고 추가하는 첫 이미지) OR (기존 썸네일 삭제되었고 추가하는 첫 이미지)
                    boolean isThumbnail = (product.getImages().isEmpty() && i == 0) || (thumbnailDeleted && i == 0);
                    ProductImage productImage = ProductImage.builder()
                            .imageUrl(result.originalFilePath()).thumbnailUrl(result.thumbnailFilePath())
                            .originalFileName(result.originalFileName()).displayOrder(currentImageCount + i).isThumbnail(isThumbnail)
                            .build();
                    product.addImage(productImage);
                    log.info("상품 ID {}에 새 이미지 추가: {}", productId, imageFile.getOriginalFilename());
                }
            }

            // 썸네일 재조정: 이미지가 있는데 썸네일이 없는 경우 -> 첫 번째 이미지 썸네일로
            if (!product.getImages().isEmpty() && product.getImages().stream().noneMatch(ProductImage::isThumbnail)) {
                product.getImages().forEach(img -> img.setThumbnail(false)); // 모두 false 초기화
                product.getImages().get(0).setThumbnail(true); // 첫 번째를 썸네일로
                log.info("상품 ID {}의 썸네일 재지정 완료 (업데이트 중)", productId);
            }

            log.info("상품 수정 완료: 상품 ID {}, 판매자 ID {}", productId, userId);
            return ProductDto.ProductBaseResponse.from(product); // 변경 감지로 업데이트됨
        } catch (Exception e) { // 롤백 로직
            log.error("상품 수정 중 오류 발생: 상품 ID {}", productId, e);
            newlyUploadedFiles.forEach(uploadedFile -> {
                fileStorageService.deleteFile(uploadedFile.originalFilePath());
                fileStorageService.deleteFile(uploadedFile.thumbnailFilePath());
            });
            throw new ProductException.ProductUpdateException(e.getMessage());
        }
    }
    // 상품 삭제

    @Transactional
    public void deleteProduct(Long userId, Long productId) {
        Product product = getProductWithSellerCheck(productId, userId);
        productRepository.delete(product);
        log.info("상품 삭제 완료: 상품 ID {}, 판매자 ID {}", productId, userId);
    }
    // 판매자 권한 확인 후 상품 조회

    Product getProductWithSellerCheck(Long productId, Long userId) {
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new ProductException.ProductNotFoundException(productId));

        if (!product.getSeller().getId().equals(userId)) {
            throw new ProductException.ProductAccessDeniedException();
        }

        return product;
    }
    private static void emailVerification(User seller) {
        if (!seller.isEmailVerified()) {
            throw new EmailVerificationException.EmailNotVerifiedException();
        }
    }

    private User getUser(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new UserException.UserNotFoundException(userId));
    }
}

ProductStatusService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductStatusService {

    private final ProductManagementService productManagementService;
    private final UserRepository userRepository;

    // 판매 완료 처리
    @Transactional
    public ProductDto.ProductBaseResponse markProductAsSold(Long sellerId, Long productId, Long buyerId) {
        Product product = productManagementService.getProductWithSellerCheck(productId, sellerId);

        if (product.getStatus() == ProductStatus.SOLD_OUT) {
            throw new ProductException.AlreadySoldProductException();
        }

        User buyer = userRepository.findById(buyerId)
                .orElseThrow(() -> new UserException.UserNotFoundException(buyerId));

        product.markAsSold(buyer);

        log.info("상품 판매완료 처리: 상품 ID {}, 판매자 ID {}, 구매자 ID {}", productId, sellerId, buyerId);

        return ProductDto.ProductBaseResponse.from(product);
    }

    // 판매완료 취소 처리 메서드
    @Transactional
    public ProductDto.ProductBaseResponse cancelProductSold(Long sellerId, Long productId) {
        Product product = productManagementService.getProductWithSellerCheck(productId, sellerId);

        if (product.getStatus() != ProductStatus.SOLD_OUT) {
            throw new ProductException.NotSoldProductException();
        }

        product.cancelSold();
        log.info("판매완료 취소 처리: 상품 ID {}, 판매자 ID {}", productId, sellerId);
        return ProductDto.ProductBaseResponse.from(product);
    }
}

ProductQueryService

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductQueryService {

    private final ProductRepository productRepository;
    private final ProductViewService viewService;
    private final ProductWishlistService wishlistService;

    // 상품 단건 조회
    public ProductDto.ProductDetailResponse getProduct(Long productId, Long userId) {
        Product product = findProduct(productId);

        // 조회수
        Long viewCount = viewService.getViewCount(productId);
        // 관심 등록 수
        Long wishlistCount = wishlistService.getWishlistCount(productId);

        // 현재 사용자의 관심 등록 여부
        boolean isWishlisted = userId != null ? wishlistService.isWishlisted(userId, productId) : false;
        return ProductDto.ProductDetailResponse.from(product, viewCount, wishlistCount, isWishlisted);
    }

    // 상품 목록 조회
    public Page<ProductDto.ProductDetailResponse> getProducts(String keyword, ProductStatus status, Pageable pageable, Long userId) {
        return productRepository.findAllWithStatsAndWishlist(keyword, status, pageable, userId)
                .map(ProductWithStatsDto::toProductDetailResponse);
    }

    // 카테고리별 상품 조회
    public Page<ProductDto.ProductDetailResponse> getProductsByCategory(ProductCategory category, String keyword, ProductStatus status,
                                                                        Pageable pageable, Long userId) {
        return productRepository.findByCategoryWithStatsAndWishlist(category, keyword, status, pageable, userId)
                .map(ProductWithStatsDto::toProductDetailResponse);
    }

    private Product findProduct(Long productId) {
        return productRepository.findById(productId)
                .orElseThrow(() -> new ProductException.ProductNotFoundException(productId));
    }
}
profile
공부하는 초보 개발자

0개의 댓글