본문 바로가기
Spring

인터페이스 구현체 동적 선택을 활용한 리팩토링

by 지금 느낌 그대로 2023. 9. 7.

 

개요

개인프로젝트를 진행하면서 클라이언트가 전송한 파일을 종류에 따라 외부 저장소와 내부 저장소에 나눠서 관리해야 하는 기능이 필요했습니다. 상품의 이미지들을 등록하는 기능에서, 이미지의 종류 혹은 크기에 따라 다른 저장소에 저장을 하는 기능입니다.

 

먼저, 저장소를 크게 내부 저장소와 외부저장소로 구분해서 서버 내부에 저장할 이미지와 서버 외부에 저장할 이미지로 분류하고 분류한 이미지를 처리할 서비스가 2개 필요합니다. 그리고 이미지를 등록할 때, 적절한 서비스를 동적으로 선택합니다.

 

다음에는 클라이언트가 등록할 상품의 이미지들을 전송하면, 이미지들을 각각 적절한 저장소에 저장하고, 클라이언트가 등록한 이미지들을 조회할 때는 이미지의 경로를 반환하고,  이미지를 요청하면 서버가 저장소에서 이미지를 가져와 클라이언트에게 전송하는 전체적인 구조를 생각했습니다.

 

Spring 에서 인터페이스 구현체를 동적으로 선택할 수 있으므로 , 이를 활용해서 클래스의 역할과 책임을 분리하여 의존성은 낮고 결합도는 높아 확장하기 좋은 코드를 작성해보도록 하겠습니다.

 

 

리팩토링 과정

처음에는 리팩토링하기 전의 코드로 시작해서 점차 개선해 나가는 방식으로 진행하겠습니다.

 

@Transactional
@Service
public class ProductService {

    private final CheckStoreOwnerService checkStoreOwnerService;
    private final ProductQueryService productQueryService;
    private final ProductRepository productRepository;
    private final InternalStorageService internalStorageService;
    private final ExternalStorageService externalStorageService;

    public ProductService(CheckStoreOwnerService checkStoreOwnerService, ProductQueryService productQueryService, StorageServiceRouter storageServiceRouter,
    InternalStorageService internalStorageService, ExternalStorageService externalStorageService) {
        this.checkStoreOwnerService = checkStoreOwnerService;
        this.productQueryService = productQueryService;
        this.productRepository = productRepository;
        this.InternalStorageService = internalStorageService;
        this.externalStorageService = externalStorageService
    }

    public void updateProductImages(ProductId productId, UserId userId, List<MultipartFile> files, List<FileInfo> fileInfos) {

        Product product = productQueryService.findById(productId);
        
        checkStoreOwnerService.checkStoreOwner(product.getStoreInfo(), userId);
        
        for(MultipartFile file : files) {
        	if(getStorageType(file).equlas(StorageType.INTERNAL)) {
            		product.saveImage(file, internalStorageService);
        	} else {
            		product.saveImage(file, externalStorageService);
            }
        }

        productRepository.save(product);

    }
}

 

이미지 파일들을 순회하면서 파일의 정보에 따라 처리할 서비스를 선택하는 방법입니다.

하지만 이렇게 구현하면 Product 엔티티에는 saveImage 메소드가 2개가 필요합니다.

그리고, Product 에서는 분류된 타입에 따라 처리할 다른 서비스로 연결됩니다.

여기까지는 괜찮을 수 있지만, 이미지를 수정하거나, 이미지를 삭제하는 기능이 추가된다면 모든 메소드가 2개가 되고 코드는 복잡해지고 개발자는 힘들어 질것입니다.

게다가 서비스 단에서 반복문을 돌면서 이미지를 하나씩 등록하는 로직을 Product 안으로 숨기고 싶었습니다.

도메인 로직의 일부가 밖에 나와있는 느낌도 있고, Product 와 Image 는 1:N의 단방향관계이기 때문에 product의 메소드가 여러개의 이미지를 전달받아 처리하는 것이 깔끔하다고 생각했기 때문입니다.

하지만 현재 코드에서는 product의 다른 메소드를 호출해야하기 때문에 한 번에 하나의 이미지만 처리할 수 있습니다.

 

그래서 인터페이스를 선언하고 saveImage 메소드가 인터페이스 타입의 인자를 받도록 수정해주었습니다.

그리고 파일 타입에 따라 처리할 2개의 서비스가 인터페이스를 구현하도록 했습니다.

그러면 인터페이스 타입을 인자로 받는 하나의 메소드로 모든 이미지를 처리할 수 있어 방금 했던 고민들의 많은 부분을 해결할 수 있습니다.

 

public interface StorageService {

    boolean isAvailableType(String storageTypeCode);

    String save(MultipartFile file);
}
public void updateProductImages(ProductId productId, UserId userId, List<MultipartFile> files, List<FileInfo> fileInfos) {

        Product product = productQueryService.findById(productId);

        checkStoreOwnerService.checkStoreOwner(product.getStoreInfo(), userId);
        product.updateImages(files,fileInfos, storageService);

        productRepository.save(product);

    }

 

@Service
public class ExternalStorageServiceImpl implements StorageService{
    @Override
    public boolean isAvailableType(String storageTypeCode) {
        return StorageType.findStorage(storageTypeCode).equals(StorageType.EXTERNAL);
    }

    @Override
    public String save(MultipartFile file) {
        // 이하 생략...
    }
}

 

@Service
public class InternalStorageServiceImpl implements StorageService{

    @Override
    public boolean isAvailableType(String storageTypeCode) {
        return StorageType.findStorage(storageTypeCode).equals(StorageType.INTERNAL);
    }

    @Override
    public String save(MultipartFile file) {
    // .... 이하 생략
    }
}

 

 

구조를 변경하니 많은 부분이 개선되었습니다.

이미지를 저장하는 일을 처리하는 메소드도 하나로 가능하고,  각 클래스의 역할도 분명해졌습니다.

하지만 해결되지 않은 부분이 남아있습니다. 

인터페이스를 Product 엔티티의 updateImage 메소드에 전달할 수 없다

파라미터로 인터페이스 타입을 받을 수는 있지만, 인자는 구현체여야 합니다. 

그리고 Product 내부에 처리할 서비스를 선택하는 로직이 포함되는 것은 좋지 않습니다.

서비스를 선택하는 로직을 포함하려면 필연적으로 의존성이 생기고, 엔티티 클래스가 서비스에 의존성을 가지는 것은 좋지 않습니다. 

엔티티는 DB와 밀접한 관계를 가지고 데이터를 교환하므로 다른 클래스에 의존성을 가지는 것은 바람직하지 않습니다.

그래서 이미지를 처리할 서비스를 선택하는 Router를 만들고 내부에 로직을 작성한 다음, Product 엔티티의 updateImages 메소드에 Router 클래스를 전달해 주어 처리하도록 하겠습니다.

 

Router 클래스에서는 StorageService를 구현한 모든 구현체의 의존성을 하나하나 주입하지 않고, List<StorageService> 타입의 필드를 선언한 다음 의존성을 주입하면 Spring이 모든 구현체에 의존성을 주입해줍니다.

그리고 List를 순회하면서 이미지를 처리할 수 있는 적절한 서비스를 반환하는 메소드를 작성하여 Product 엔티티 내부에서는 호출만해서 적절한 StorageService를 얻어오도록 하겠습니다.

 

@Component
public class StorageServiceRouter {

    private final List<StorageService> storageServices;

    public StorageServiceRouter(List<StorageService> storageServices) {
        this.storageServices = storageServices;
    }


    public StorageService getStorageService(String storageTypeCode) {
        return storageServices.stream()
                .filter(storageService -> storageService.isAvailableType(storageTypeCode))
                .findFirst().orElseThrow(() -> new NoSuchElementException("요청한 저장소 서비스 코드와 일치하는 서비스가 존재하지 않습니다."));
    }
}

 

    public void updateProductImages(ProductId productId, UserId userId, List<MultipartFile> files, List<FileInfo> fileInfos) {

        Product product = productQueryService.findById(productId);

        checkStoreOwnerService.checkStoreOwner(product.getStoreInfo(), userId);
        product.updateImages(files,fileInfos, storageServiceRouter);

        productRepository.save(product);

    }

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "product")
@Entity
public class Product extends BaseTimeEntity {

@EmbeddedId
    @Column(name = "product_id")
    private ProductId id;

    @ManyToOne
    @JoinColumn(name = "store_id")
    private Store storeInfo;

    @Column(name = "product_name")
    private String name;

    @Convert(converter = MoneyConverter.class)
    private Money price;

    @BatchSize(size = 2000)
    @OneToMany(cascade = {CascadeType.ALL}, orphanRemoval = true, fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    @OrderBy("listIdx asc")
    private List<Image> images = new ArrayList<>();

    @Enumerated(EnumType.STRING)
    @Column(name = "product_state")
    private ProductState state;

	
    public void updateImages(List<MultipartFile> files, List<FileInfo> fileInfos, StorageServiceRouter storageServiceRouter) {

        if(files.size() > 5) throw new IllegalArgumentException("등록할 수 있는 이미지의 최대개수는 5개입니다.");

        for(int idx = 0; idx < fileInfos.size(); idx++) {
            FileInfo fileInfo = fileInfos.get(idx);

            StorageService storageService = storageServiceRouter.getStorageService(fileInfo.getStorageType());

            if(idx < files.size()) images.add(createImage(storageService, files.get(idx), fileInfo.getUpdateListIdx()));

        }
    }

 

 

 

Router 클래스는 이미지를 처리할 서비스를 선택하는 일에 집중합니다.

Product의 updateImage 메서드는 이미지를 저장하는 일에 집중합니다.

ProductService 클래스의 updateImages 메서드는 비즈니스 로직의 흐름과 트랜잭션에  집중합니다.

 

여기까지 해서 리팩토링을 마쳤습니다.

나중에 이미지를 저장하는 외부저장소가 변경되거나 늘어난다거나,

이미지를 저장하는 과정에 새로운 로직이 추가되거나 변경되어도 수정해야하는 코드를 최소화 시킬 수 있습니다.

 

 

 

마치며...

여기까지 해서 리팩토링을 마쳤습니다.

코드를 리팩토링하는 일은 할 때는 힘이 들기도 하고, 해야할 일도 많은데 부담스럽기도 합니다.

하지만 하고 나서 나중에 새로운 기능을 구현해야 하거나, 유지보수를 할 때 과거의 나를 칭찬하게 되는 것 같습니다. 

이 글에서 다룬 프로젝트는 제가 개인적으로 진행하는 프로젝트라 시간의 압박이나 성과를 내야한다는 조급함이 적지만 평소에 조금씩이라도 연습하고 습관화해서 더 좋은 코드를 작성할 수 있다는 점에서 의미가 있는 시간이었다고 생각합니다.