[쇼핑몰 프로젝트] 멀티모듈 도입기 -3편

대원·2025년 1월 21일

쇼핑몰

목록 보기
6/13

멀티모듈 도입기 -3편

배경

지난 편에 이어서 멀티모듈 도입을 하며 겪게된 여러가지 상황을 공유하는 글을 써보고자 한다.
저번편까지의 글은 큰 틀에서 아키텍처와 같이 설계를 어떤 식으로 하고 어떻게 방향을 잡고자하는 글이었다면 본편의 글은 조금 더 코드레벨로 들어가서 어떻게 코드를 짜며 어떤 문제를 맞이했는지와 같은 부분에 조금 더 핀트를 맞추고자 한다.

저번 글과 이번 글의 시간의 텀만큼이나 멀티모듈로 전환하게 여러가지 겪게 된 문제사항이 있었다.

단순 리팩토링일 수도 있지만, 모듈 별 의존성의 범위부터 시작해서 모듈간 공통으로 필요한 데이터를 어떻게 처리해야할지 또, 도메인 객체 도입에 대한 고민 및 코드레벨에서 구체적으로 고민하게 되는 요소가 굉장히 많았고, 고민하느라 소모된 시간에 비해서 실제적으로 작성하는 코드 양은 턱 없이 적어서 상당히 침울한(?) 시간을 많이 보냈었다.

아무튼 각설하고 이번 글에서는 멀티모듈을 도입하면서 겪게 된 여러 가지 고민사항 또는 문제사항에 대한 글을 남겨보려고 한다.

기존의 프로젝트 구조

기존의 프로젝트는 일반적으로 많이 사용하는 @Controller - @Service - @Repository 3가지 스프링 Bean 을 기준으로 하나의 프로젝트 모듈 내에서 도메인 별로 패키지를 나누고 해당 도메인 패키지 내에서 위 3가지 Layer를 또 패키지로 나눠서 코드를 작성하는 방식으로 구성하였었다.

각 Layer를 하나의 모듈내에서 구성하고 관리하던 것을,(100프로 명확히 떨어지지는 않지만) 각 Layer에 맞게 모듈을 분리하려고 노력했다.

모듈의 의존성의 범위

  • domain-rdb 모듈의 의존성
  • domain-redis 모듈의 의존성
  • domain-service 모듈의 의존성
  • web 모듈의 의존성
    업로드중..

Page객체 및 FeignClient를 이용하기 위해 불가피한 의존성을 제외하고는 최소한의 의존성을 유지하고자 노력했다.

  • Page, Slice와 같은 의존성은 JPA가 아니라 Spring.data의 세부 라이브러리를 직접 Import할 예정이다.

모듈 간 공유되어야 하는 데이터

현재 기준으로 어플리케이션의 유일한 입구(Entry-point)는 Web모듈이다.
입구의 역할을 하는 Web모듈에서 RequestDto와 ResponseDto의 형태로 객체를 주고 받고 있으나, 기존의 단일모듈 구조에서는 어느 패키지 내에 해당 DTO가 있더라도 JPA와 강결합되어있는 Infrastructure계층에서도 접근 가능했다.

예를 들면 이전에 QueryDsl을 학습하며 @QueryProjection을 이용해서 특정 DTO 형식에 맞춰서 Return하는 경우 ORM과 어플리케이션이 강결합될 수 있다는 설명을 들을 때는 해당 문제점에 대해서 잘 인지하지 못했는데 이번에 멀티모듈을 도입하면서 해당 한계에 대해서 뼈저리게 느끼게 되었다.

따라서 Service단 이하로도 RequestDTO를 매개변수로 아무 고민 없이 넘겨줬다.
또한 QueryDSL을 이용하여 동적쿼리를 작성한경우, ResponseDTO 에 @QueryProjection을 이용해서 ResponseDTO를 생성한 이후 Presentation Layer인 Controller까지 해당 값을 넘겨주었다.

그러나 Layer별로 모듈을 쪼갠 결과 Entry-Point 역할을 수행하는 Web모듈 내에 해당 DTO를 위치시킬경우,
의존성의 방향에 따라서 Web 모듈 이하의 모듈에서는 해당 DTO등을 참조할 수가 없었다.

따라서 해당 DTO 역할을 수행하는 클래스를 놓아야할 적절한 위치에 대한 고민을 하였다.

고민했던 선택지는 다음과 같았다.

  1. DTO 패키지를 Common 안에 domain 모듈을 만들어서 하위에 둔다.
    • 컨슈머든 배치든 웹이든 어차피 도메인 모듈에는 의존적이다.
      • 그러나 requestDto의 Validation을 가져게되면 common모듈이 Spring에의 의존성이 생기게 된다.
    • 장점: 편리하다.
    • 단점 : Common 모듈에서 DTO가 의존하게 되는 라이브러리에 의존하게 된다.
        1. QueryDSL(@QueryProjection이용에 따른)
        2. Validation(Spring에 의존적인 Validation)

-> 편리하다는 장점이 있지만, 이렇게 되면 많은 클래스를 Common 모듈에 다 몰아넣게 되는 결과를 초래할 거라는 생각이 들었다. Common이라는 모듈 명대로 모든 모듈들에서 참조할 수 있는 공통의 성질의 것을 넣어야하는데 이렇게 되면 주객이 전도될 것 이라는 판단을 하였다. 무엇보다 common 모듈은 POJO로만 구성하고자 하였기 때문에 이렇게 되면 단점이 너무 크다는 생각을 하였다.

  1. Domain-Service 내의 DTO를 위치시킨다.
    • Web모듈이 Domain-Service를 의존하므로 의존관계 방향도 역류하지 않고, 특정 인프라(RDB, Redis 등)와 강결합되어있는 모듈에는 열려있지 않다.
    • 장점 : 의존관계의 순방향도 헤치지 않고 자연스럽고, 기존의 클래스를 옮기면 된다.
    • 단점 : @NotNull, @NotEmpty 등의 Spring MVC에서 @Valid와 강결합되어있는 의존성에 문제가 발생한다.

-> Web모듈 내에서 domain-service 모듈에 의존하므로 해당 DTO를 가져와서 사용할 수 있다. domain-Service에서 중간역할로서 RequestDto를 받고 하위 모듈의 값들을 조합하여 resposneDTO로 응답한다.
이렇게 되면 의존관계가 역류되지도 않고, 아래에서 언급할 domain 객체 등을 domain-service에서 이용해서 responseDTO를 편리하게 조립할 수도 있다.

이에 따라 나는 2번으로 결정하였다.

각 모듈 내에서 역할과 책임에 따른 클래스 세분화

기존의 단일 모듈에서는 Service Layer까지 Entity를 끌고와서 여러 비즈니스적 로직을 처리하고 (경우에 따라서) DTO를 생성하여 Controller로 응답해주는 구조를 큰 틀로 구성했었다.

멀티모듈을 도입하면서 각 Layer 별로 모듈을 구성하려고 노력했기 때문에 Domain-RDB 모듈에서 다루는 객체인 Entity를 직접 Service Layer까지 응답하는 것이 부자연스럽다는 생각이 들었다.

기존에 코드를 작성할때는 (부끄럽지만) 기계적으로 어떤 요청이 있을때 Service Layer에서 InfraStructure Layer를 통해서 DB를 찌르고 난뒤 Entity 객체를 직접 응답 받아서 여러가지 ‘비즈니스 로직’을 조합하고 처리해서 응답을 하는 구조로 코드의 기본 골조를 구성했었다.

그런 골조로 코드 뼈대를 만들었기 때문에, 새롭게 생성하게 된 Domain - Service 모듈에서 비즈니스로직을 어떤 방식으로 어떻게 처리해야하나하고 고민이 들었다.

여태까지 나는 Domain 그 자체의 역할에 대한 고민을 하기보다는 DB 종속적으로 먼저 테이블의 컬럼을 고민하고, 테이블의 연관관계들을 어떻게 맺어줄 것인지를 그려본 이후에 Entity를 설계하고 해당 Entity를 토대로 여러 비즈니스적 로직을 처리하는 무의식적 ‘경향’이 있었던 것이다.

그런데 잘 생각해보면 Domain과 Entity의 관계를 거꾸로 생각해볼 필요가 있었다.

결국 개발자의 구현의 목적은 어떤 문제상황을 해결하기 위함이라는 관점에서 생각한다면, 도메인이라는 것은 개발자가 비즈니스적으로 해결해야할 문제의 영역인 것이고, 해당 문제를 풀기 위해 도메인을 정의한다.
정의한 도메인을 바탕으로 DB라는 하나의 툴을 통해 이용하는 것이 Entity라면 Domain을 별도로 정의하고 domain-service와 domain-rdb와 같은 모듈에서 이용할 수 있겠다는 생각이 들었다.

따라서 나는 domain-rdb내의 Bean에서는 최대한 DB를 단순하게 찌르고 Entity를 Domain객체로 변환해서 Domain-service 모듈에 Return해주는 형식으로 구성해주고자 하였다.

CF) 해당 글에서는 모든 도메인에 대해서 리팩토링한 내용을 다 다룰 수 없으므로 이번 글에서는 Product 도메인의 하나의 기능(상품생성)이라는 작업에 대해서 코드레벨로 분석해보도록 한다.

Product를 예를 들면 기존에는 아래와 같이 ‘상품 생성’이라는 하나의 요청에 대해서 ProductService 내에서 상품을 저장하는 로직(도메인 로직)과 타 도메인 로직(이미지 저장) 및 이미지형식에 대한 판단까지 if문 등으로 처리하는 복잡한 역할과 책임을 지니고 있었다.

기존 단일 모듈 내 ProductService 코드


    @Transactional
    public ProductCreateResponseDto createProduct(final ProductCreateRequestDto requestDto, List<MultipartFile> images) {
        // 1. requestDTO의 imageURL을 변환 및 저장과정

        Category category = categoryRepository.findById(requestDto.getCategoryId()).orElseThrow(() -> new ApiException(CategoryErrorCode.NO_EXIST_CATEGORY));

        // dto -> product entity 변환 필요
        User seller = userService.findUserByIdAndSeller(requestDto.getSellerId());

        Product product = requestDto.toEntity(category, seller);

        Product savedProduct = productRepository.save(product);

        // 이미지를 파일 타입에 맞춰 저장
        List<Image> thumbnailImages = new ArrayList<>();
        List<Image> detailImages = new ArrayList<>();

        // 첫 번째 이미지는 썸네일로 처리
        if (!images.isEmpty()) {
            MultipartFile thumbnailImage = images.get(0);
            thumbnailImages = imageService.saveImage(List.of(thumbnailImage), savedProduct.getId(), FileType.PRODUCT_THUMBNAIL);

            // 나머지 이미지는 상세 이미지로 처리
            List<MultipartFile> detailImageFiles = images.subList(1, images.size());
            if (!detailImageFiles.isEmpty()) {
                detailImages = imageService.saveImage(detailImageFiles, savedProduct.getId(), FileType.PRODUCT_DETAIL_IMAGE);
            }
        }


        // 저장된 이미지 ID 리스트를 생성
        List<Long> imageIds = new ArrayList<>();
        imageIds.addAll(thumbnailImages.stream().map(Image::getId).toList());
        imageIds.addAll(detailImages.stream().map(Image::getId).toList());

        return ProductCreateResponseDto.of(product, category.getId(), imageIds);

    }

위의 코드와 같이 Product라는 도메인의 범위를 넘어서는 , 즉 각 도메인의 비즈니스의 책임의 범위를 넘어서는 것들에 대해서는 domain-service라는 모듈 내에 여러 Service를 역할에 맞게끔 쪼개서 해당 Service 내에서 domain-rdb내의 Service를 호출할 수 있도록 구성하였고, 각 역할의 Service를 Web모듈 내 Usecase라는 클래스에서 호출하여 이용하게끔 구성하였다.

간단하게 상품 생성이라는 기능에 대한 모식도는 다음과 같다.

멀티모듈 후 domain-rdb모듈 내 ProductRdbService 내 코드 중 일부

@Transactional
public Long createProduct(final ProductDomain productDomain) {

    Product savedProduct = productRepository.save(ProductEntityMapper.createProductEntity(productDomain));
    return savedProduct.getId();

}

domain-service 모듈 내 ProductCreateService 내 코드

package shoppingmall.domainservice.domain.product.service;

import lombok.RequiredArgsConstructor;
import shoppingmall.domainrdb.category.service.CategoryId;
import shoppingmall.domainrdb.product.domain.ProductDomain;
import shoppingmall.domainrdb.category.service.CategoryRdbService;
import shoppingmall.domainrdb.product.service.ProductRdbService;
import shoppingmall.domainrdb.user.UserId;
import shoppingmall.domainrdb.user.service.UserRdbService;
import shoppingmall.domainservice.common.annotation.DomainService;
import shoppingmall.domainservice.domain.product.dto.request.ProductCreateRequestDto;

@DomainService
@RequiredArgsConstructor
public class ProductCreateService {
    private final ProductRdbService productRdbService;
    private final CategoryRdbService categoryRdbService;
    private final UserRdbService userRdbService;


    public Long createProduct(final ProductCreateRequestDto createRequestDto) {

        // 중복 category 검증
        Long categoryId = categoryRdbService.findByCategoryName(createRequestDto.getCategoryName());

        // email로 회원 존재여부 검증
        Long userId = userRdbService.findSellerByEmail(createRequestDto.getSellerEmail());

        ProductDomain productDomain = ProductDomain.createForWrite(createRequestDto.getName(), createRequestDto.getPrice(), CategoryId.from(categoryId), UserId.from(userId));

        // Domain 객체 -> Entity 변환해서 매개변수로 넘겨준다.
        return productRdbService.createProduct(productDomain);
    }

}

web 모듈 내 ProductUsecase 클래스

package shoppingmall.web.api.product.usecase;


import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import shoppingmall.domainservice.domain.product.dto.request.ProductCreateRequestDto;
import shoppingmall.domainservice.domain.product.dto.request.ProductSearchConditionRequestDto;
import shoppingmall.domainservice.domain.product.dto.request.ProductUpdateRequestDto;
import shoppingmall.domainservice.domain.product.dto.response.ProductCreateResponseDto;
import shoppingmall.domainservice.domain.product.dto.response.ProductQueryResponseDto;
import shoppingmall.domainservice.domain.product.service.*;
import shoppingmall.web.common.annotataion.Usecase;
import shoppingmall.web.common.validation.product.ProductCreateValidator;
import shoppingmall.web.common.validation.product.ProductSearchValidator;

import java.util.List;

@Usecase
@RequiredArgsConstructor
public class ProductUsecase {
    private final ProductCreateService productCreateService;
    private final ProductImageService productImageService;
    private final ProductSearchService productSearchService;
    private final ProductUpdateService productUpdateService;
    private final ProductDeleteService productDeleteService;
    private final ProductCreateValidator productCreateValidator;
    private final ProductSearchValidator productSearchValidator;


    @Transactional
    public ProductCreateResponseDto createProduct(final ProductCreateRequestDto productCreateRequestDto, final List<MultipartFile> multipartFiles) {

        // ProductCreateRequest Validation
        productCreateValidator.validate(productCreateRequestDto);


        Long productId = productCreateService.createProduct(productCreateRequestDto);

        final List<Long> imageIds = productImageService.saveProductImages(multipartFiles, productId);


        return ProductCreateResponseDto.builder()
                .id(productId)
                .name(productCreateRequestDto.getName())
                .price(productCreateRequestDto.getPrice())
                .categoryName(productCreateRequestDto.getCategoryName())
                .imageIds(imageIds).build();

    }

    public Slice<ProductQueryResponseDto> getAllProductList(final ProductSearchConditionRequestDto searchConditionRequestDto, final Pageable pageable) {

        // ProductSearchConditionRequestDto Validation
        productSearchValidator.validate(searchConditionRequestDto);


        return productSearchService.searchProducts(searchConditionRequestDto, pageable);


    }


    public void updateProducts(final Long productId, final ProductUpdateRequestDto updateRequestDto) {
        productUpdateService.updateProduct(productId, updateRequestDto);
    }

    public void updateProductThumbNailImage(final Long productId, final MultipartFile multipartFile) {
        productImageService.updateThumbNailImage(productId, multipartFile);

    }


    @Transactional
    public void deleteProduct(final Long productId) {
        productDeleteService.deleteProduct(productId);
        productImageService.deleteImage(productId);

    }


}

동일한 ‘상품 생성’이라는 기능에 대해서 기존에 ProductService라는 하나의 클래스에서 수행하던 것이 클래스가 꽤나 많이 늘어나게 된 것을 확연히 알 수 있다. 클래스는 여러 개로 쪼개지고 분화되었지만, 쪼개된 개수만큼이나 확실히 하나의 클래스가 하나의 역할과 책임을 다한다는 것을 여실히 체감할 수 있다. 이에 따라 가독성도 확실히 좋아진 것을 느낄 수 있다.

Entity와 Domain 객체 설계

Domain과 Entity를 분리하면서 고민이 되었던 또 다른 포인트가 있다.

Domain은 어떠한 필드를 갖고있어야 하며 어떠한 의존관계를 지녀야 하느냐 하는 것이었다.

Entity의 관계에 따라서 의존관계가 생성되는 Domain을 생성해줘야하리라 생각했지만, 만약 이렇게 설계하게 되면 의존관계 순환참조 문제가 발생할 수 있을 것 이라는 생각이 들었다.

따라서 각 도메인별 Id필드를 참조하는 방식으로 구성하되, 도메인적 의미를 조금 주기 위해서 도메인별 Id를 구성해서 설정해줬다.

또한 읽기 작업과 쓰기작업에서 필요한 각 필드들이 다른 것을 깨달았기 때문에, 요구하는 생성자가 다르다고 판단하였고 이에 따라 정적 팩토리 메서드를 아래와 같이 구성해줬다.

(해당 ProductDomain 객체를 쓰기작업용, 읽기작업용으로 따로 분리하여 CQRS를 도입하거나 개선할 수도 있으리라 생각하긴 했지만, 일단은 현 상황에서 멀티모듈 리팩토링이 제1의 우선순위였기때문에 더 들어가지는 않았다.)

새롭게 분리한 ProductDomain 클래스

package shoppingmall.domainrdb.product.domain;

import lombok.Builder;
import lombok.Getter;
import shoppingmall.domainrdb.category.CategoryDomain;
import shoppingmall.domainrdb.category.service.CategoryId;
import shoppingmall.domainrdb.user.UserDomain;
import shoppingmall.domainrdb.user.UserId;

@Getter
public class ProductDomain {
    private final ProductId productId;
    private final String name;
    private final Integer price;
    private final CategoryId categoryId;
    private final UserId userId;

    public ProductDomain(ProductId productId, String name, Integer price, CategoryId categoryId, UserId userId) {
        this.productId = productId;
        this.name = name;
        this.price = price;
        this.categoryId = categoryId;
        this.userId = userId;
    }

    // 쓰기 작업을 위한 팩토리 메서드
    public static ProductDomain createForWrite(String name, int price, CategoryId categoryId, UserId userId) {
        return new ProductDomain(null, name, price, categoryId, userId);
    }

    // 읽기 작업을 위한 팩토리 메서드
    public static ProductDomain createForRead(Long id, String name, int price, CategoryId categoryId, UserId userId) {
        return new ProductDomain(ProductId.from(id), name, price, categoryId, userId);
    }


    private void validate() {
        validateName();
        validatePrice();
    }


    private void validateName() {
        if (name == null || name.isEmpty()) {
            throw new IllegalArgumentException("Product name is empty");
        }
    }

    private void validatePrice() {
        if (price < 0) {
            throw new IllegalArgumentException("Price is negative");
        }
        if (price > 9999999999L) {
            throw new IllegalArgumentException("Price is over 9999999999");
        }
    }
}

결론

멀티모듈을 도입하면서 각 계층과 도메인별로 클래스를 분리하여 의존 관계와 책임이 명확해졌음을 느낄 수 있었다. 모듈 경계가 존재함으로써 불필요하게 넓은 범위로 의존성이 퍼지는 일이 줄어들고, 각 모듈의 역할이 분명해져 시스템의 설계가 한층 체계적으로 보이게 된다.

단일 모듈에서 모든 로직을 처리하던 시점에 비해 파일이나 클래스의 수는 증가했지만, 그만큼 도메인별 역할과 책임이 분산되어 코드 가독성과 유지보수성이 높아졌다.

토이 프로젝트 수준에서는 크게 체감하기 어려울 수 있으나, 규모가 확장될수록 모듈 간 경계를 명확히 하여 복잡도를 제어할 수 있다.

또한, DB 중심의 하향식 설계에서 벗어나 비즈니스 로직과 실제 구현체를 구분함으로써, 도메인 로직을 좀 더 유연하게 발전시킬 수 있는 토대를 마련하게 된다.
결국 멀티모듈 도입은 단순히 코드를 나누는 것이 아니라, 도메인 로직의 의도를 더 명확히 드러내고 해당 로직에 집중할 수 있게 해주는 하나의 좋은 기법임을 경험적으로 확인할 수 있었다

profile
고민하고 공부하는 사람

0개의 댓글