관심사 및 의존성

2024. 8. 21. 22:52기록/리팩토링 기록


작성자 : @김민수

작성일자 : 240821

기술적 의사소통 : @김민수 @세현 임


기존의 프로젝트를 리펙토링 하기 위해 각각의 서비스가 어떤 관심사에 의존성을 가지고 있는지 살펴보았다. 그리고 해당 관심사들의 서비스에서 통일되지 않는 코드를 보며 코드 가독성이 떨어진다고 생각하였다. 그리하여 먼저 어떤 서비스에서 어떤 관심사의 서비스를 의존성 주입으로 받는지 살펴보았다.

Service Dependencies Service Dependencies

chatService chatRepository chatroomRepository naver restTemplate
objectMapper      
chatRoomService chatroomRepository chatRepository userChatRoomRepository postService postService postRepository
commentService commentRepository postRepository recipeService recipeRepository
s3Uploader      
cookstep x scrap scrapRepository recipeRepository
foodInformation x recipe review recipeReviewRepository recipeRepository
ingredient x userService userRepository passwordEncoder
redisUtil jwtUtil refreshTokenTime      
likeService likeRepository recipeRepository    

정리를 해본 결과 다음과 같이 해당하는 관심사가 아니어도 바로 repository를 의존성 주입을 받는 부분들이 존재하였다. 그렇기에 이에 대해서 어떻게 리팩토링을 해야 하는지 찾아보고 알아보았고 팀원인 세현이와 같이 어떤 방식으로 풀어나갈지 이야기를 나눴다. gpt를 통해 해당 경우에 적용할 수 있는 다양한 경우들을 살펴보았다.


1. 파사드 패턴 적용하기

디자인 패턴 중 하나인 파사드 패턴이다. 파사드 패턴은 복잡한 시스템 혹은 서브시스템에 대한 단순화된 인터페이스를 제공하는 역할을 한다. 여러 개의 인터페이스, 클래스들이 존재할 때, 해당 패턴을 통해 복잡한 부분을 감추고 하나의 간단한 인터페이스로 이를 접근할 수 있게 하는 역할을 한다.

여러 개의 서비스나 클래스들이 연계되어 작동하는 복잡한 비즈니스 로직이 존재할 경우, 해당 로직을 파사드 클래스로 감싸서 클라이언트가 단일 진입점을 통해 접근할 수 있도록 만드는 것이다.

이를 통해 클라이언트 코드의 복잡성을 줄이고 유지보수성을 향상할 수 있다.

// 서브시스템 클래스들
class PaymentService {
    public void processPayment(String paymentType) {
        System.out.println("Processing payment using " + paymentType);
    }
}

class InventoryService {
    public void checkInventory(String item) {
        System.out.println("Checking inventory for " + item);
    }
}

class ShippingService {
    public void arrangeShipping(String item) {
        System.out.println("Arranging shipping for " + item);
    }
}

// 파사드 클래스
class OrderFacade {
    private PaymentService paymentService;
    private InventoryService inventoryService;
    private ShippingService shippingService;

    public OrderFacade() {
        this.paymentService = new PaymentService();
        this.inventoryService = new InventoryService();
        this.shippingService = new ShippingService();
    }

    public void placeOrder(String item, String paymentType) {
        inventoryService.checkInventory(item);
        paymentService.processPayment(paymentType);
        shippingService.arrangeShipping(item);
        System.out.println("Order placed successfully for " + item);
    }
}

// 클라이언트 코드
public class FacadePatternExample {
    public static void main(String[] args) {
        OrderFacade orderFacade = new OrderFacade();
        orderFacade.placeOrder("Laptop", "Credit Card");
    }
}

2. 컴포지트 서비스

여러 개의 개별 서비스를 하나의 더 큰 서비스로 조합하여 하나의 단위로 제공하는 것을 의미한다. 이를 통해 MSA에서 다양한 독립된 서비스를 조합하여 하나의 복잡한 비즈니스 기능을 구현할 수 있다.

각 서비스는 독립적으로 배포 및 운영이 되며 컴포지트 서비스는 이러한 서비스를 조합하여 클라이언트에게 통합된 서비스를 제공한다.

해당 패턴은 서비스 간 재사용성을 높이고 복잡한 비스니스 로직을 단순화하는 데 도움을 주는 역할을 한다.

// 개별 서비스 클래스들
class DataCollectionService {
    public String collectData() {
        return "Collected Data";
    }
}

class DataProcessingService {
    public String processData(String data) {
        return "Processed Data: " + data;
    }
}

class ReportGenerationService {
    public String generateReport(String processedData) {
        return "Report based on " + processedData;
    }
}

// 컴포지트 서비스 클래스
class ReportService {
    private DataCollectionService dataCollectionService;
    private DataProcessingService dataProcessingService;
    private ReportGenerationService reportGenerationService;

    public ReportService(DataCollectionService dataCollectionService,
                         DataProcessingService dataProcessingService,
                         ReportGenerationService reportGenerationService) {
        this.dataCollectionService = dataCollectionService;
        this.dataProcessingService = dataProcessingService;
        this.reportGenerationService = reportGenerationService;
    }

    public String createReport() {
        String data = dataCollectionService.collectData();
        String processedData = dataProcessingService.processData(data);
        return reportGenerationService.generateReport(processedData);
    }
}

// 클라이언트 코드
public class CompositeServiceExample {
    public static void main(String[] args) {
        DataCollectionService dataCollectionService = new DataCollectionService();
        DataProcessingService dataProcessingService = new DataProcessingService();
        ReportGenerationService reportGenerationService = new ReportGenerationService();

        ReportService reportService = new ReportService(dataCollectionService, dataProcessingService, reportGenerationService);

        String report = reportService.createReport();
        System.out.println(report);
    }
}


3. CQRS (Command Query Responsibility Segregation)

Command Query Responsibility Segregartion의 줄임말이다. 해당 패턴의 경우 명령(command)과 조회(query)를 분리하는 형태이다. 이때 명령은 시스템의 상태를 변경하는 작업이고, 조회는 시스템의 상태를 읽는 작업이다. 이 패턴은 두 가지 책임을 명확히 분리함으로 더 효율적인 시스템 설계를 가능하게 한다.

명령은 데이터의 쓰기를 담당하고, 조회는 데이터의 읽기를 담당하기 때문에 각각의 작업을 최적화할 수 있다.

특히 해당 패턴은 읽기와 쓰기가 다른 시스템에서 유용하게 사용이 된다.


4. 서비스 간 협력(협력 서비스)

서비스 간 협력은 MSA - 마이크로 서비스 - 개별 서비스들이 협력하여 하나의 비즈니스 프로세스를 완성하는 방식이다. 각 서비스는 독립적으로 배포 및 운영이 되며, 비즈니스 요구사항에 따라 다른 서비스와 상호작용하여 데이터를 주고받으며 수행하는 경우가 존재한다. 그렇기에 서비스 간의 협력은 전체 시스템의 확장성과 유연성을 높이는 데 기여하며, 각 서비스가 잘 정의된 인터페이스를 통해 상호작용하도록 설계됨으로써 서비스의 변경이 다른 서비스에 미치는 영향을 최소화한다.

// OrderService.java
public class OrderService {

    private final PaymentService paymentService;
    private final InventoryService inventoryService;
    private final ShippingService shippingService;
    private final NotificationService notificationService;

    public OrderService(PaymentService paymentService, InventoryService inventoryService,
                        ShippingService shippingService, NotificationService notificationService) {
        this.paymentService = paymentService;
        this.inventoryService = inventoryService;
        this.shippingService = shippingService;
        this.notificationService = notificationService;
    }

    public void placeOrder(String item, String paymentType, String userEmail) {
        // 1. 재고 확인
        if (!inventoryService.checkInventory(item)) {
            throw new RuntimeException("Item is out of stock");
        }

        // 2. 결제 처리
        boolean paymentSuccess = paymentService.processPayment(paymentType);
        if (!paymentSuccess) {
            throw new RuntimeException("Payment failed");
        }

        // 3. 배송 준비
        shippingService.arrangeShipping(item);

        // 4. 사용자에게 주문 완료 알림
        notificationService.sendOrderConfirmation(userEmail, item);

        System.out.println("Order placed successfully for " + item);
    }
}

// PaymentService.java
public class PaymentService {
    public boolean processPayment(String paymentType) {
        System.out.println("Processing payment using " + paymentType);
        // 결제 처리 로직 구현
        return true; // 결제가 성공적으로 처리되었다고 가정
    }
}

// InventoryService.java
public class InventoryService {
    public boolean checkInventory(String item) {
        System.out.println("Checking inventory for " + item);
        // 재고 확인 로직 구현
        return true; // 재고가 있다고 가정
    }
}

// ShippingService.java
public class ShippingService {
    public void arrangeShipping(String item) {
        System.out.println("Arranging shipping for " + item);
        // 배송 준비 로직 구현
    }
}

// NotificationService.java
public class NotificationService {
    public void sendOrderConfirmation(String userEmail, String item) {
        System.out.println("Sending order confirmation to " + userEmail + " for item " + item);
        // 이메일 알림 전송 로직 구현
    }
}

// 클라이언트 코드
public class CollaborativeServiceExample {
    public static void main(String[] args) {
        PaymentService paymentService = new PaymentService();
        InventoryService inventoryService = new InventoryService();
        ShippingService shippingService = new ShippingService();
        NotificationService notificationService = new NotificationService();

        OrderService orderService = new OrderService(paymentService, inventoryService, shippingService, notificationService);

        orderService.placeOrder("Laptop", "Credit Card", "customer@example.com");
    }
}


5. 도메인 이벤트 사용

도메인 내에서 중요한 사건이나 상태 변경이 발생한 경우 발생하는 이벤트이다. 비즈니스 로직을 중심으로 시스템의 흐름을 관리할 수 있게 해주는 역할을 한다.

예를 들면 사용자가 주문을 완료하면 주문 완료라는 도메인 이벤트가 발생하고 이 이벤트를 구독하는 다른 서비스들이 이를 처리하게 된다.

도메인 이벤트를 사용하는 경우 서비스 간의 결합도를 낮추고, 느슨하게 연결된 시스템을 설계할 수 있다. 이를 통해 각 서비스는 자신이 구독하는 이벤트에 대해 반응적으로 동작할 수 있게 된다.


기술적 의사소통

백엔드 팀인 나랑 세현이가 가장 크게 문제라고 여긴 것은 프로젝트 대부분의 요소에서 비즈니스 로직 처리에 대한 부분이 구조적으로 정리가 안되어있던 점과 몇몇 관점에서는 너무 많은 역할을 하나의 클래스에서 처리하게 된다는 점이었다.

그렇기에 위에서 정리한 내용 중 어떤 경우를 우리 프로젝트에 적용하여 리팩토링을 할지 고민하였다.

일단 도메인 이벤트와 CQRS는 제외하기로 하였다. 현재 프로젝트는 n 계층 아키텍처를 사용하고 있다. 그렇기에 다양한 경우에서 아키텍처의 구조가 변하는 경우는 일단 최대한 피하고자 하였다.

물론 CQRS의 경우 현재 아키텍처를 유지하면서 서비스 클래스를 늘리면서 생성하는 방법도 존재한다. 해당 경우는 현재 우리의 프로젝트의 각 관심사를 전부 다 수정해야 하기에 시간적으로 많이 소요되는 작업으로 판단하였다. 또한 얻을 수 있는 이점은 코드의 가독성만 좋아진다는 점만 존재하고 성능 향상에는 크게 기여하지 못할 것이라고 판단하였다. 그렇기에 CQRS는 제외를 하기로 하였다.

프로젝트의 구조를 이야기하면서 과연 우리의 코드에 적용할 수 있는 좋은 경우는 뭐가 있을까 고민을 하였다. 일단 우리의 코드가 어떤 형태를 가지고 있는지 생각하게 되었다. 우리의 코드, 책임이 과중되어 있는 코드는 실제로 어떤 서비스를 표현하고자 했는지 생각하게 되었다.

우리의 서비스는 일단 하나의 서비스를 생각하고 해당 서비스를 n개의 서비스로 나눈 경우가 많이 존재했다. 그렇기에 하나의 서비스에서 쪼개져서 나왔기에 하나의 관심사에 여러 관심사의 책임이 몰려있는 경우가 대다수였던 점이었다.

그리고 위에서 말한 요소들은 ‘각각 어떤 차이점이 존재하는가?’를 생각하게 되었다. 위의 경우를 생각하면서 둘이서 나온 결론은 해당 경우들은 각 서비스들의 책임을 분산시켰으며, 해당 서비스를 어떤 방식으로 호출하는지(하나의 서비스 클래스에서 다른 서비스 클래스를 호출 및 관리, 다른 서비스들이 서로 협력하는 등)에 차이점이 존재한다는 점이었다. 이때 공통점은 각 관심사마다의 책임을 확실하게 분리했다는 것이었으며, 우리는 해당 부분을 채용해서 각 관심사에 맞게 확실하게 역할을 나누고 서로 협력 및 호출을 하는 것으로 방향을 잡았다.


개선 전과 후 코드 비교

가장 많은 역할을 담당하고 있던 recipe 부분의 코드를 수정하고 예시로 남기려고 한다. 먼저 의존성 주입부터 작업을 하였다.

// 기존
private final RecipeRepository recipeRepository;

private final S3Uploader s3Uploader;

//수정 후
private final RecipeRepository recipeRepository;

private final FoodInformationService foodInformationService;

private final CookStepService cookStepService;

private final IngredientService ingredientService;

private final S3Uploader s3Uploader;

 

사용이 되는 관심사에 맞게 각 서비스를 의존관계로 주입하였다. 이를 적용한 이유는 기존에는 recipeService에서 주어진 모든 요청, 관심사에 대해 일괄적으로 처리하였다. 이번에 개선을 하면서 각 관심사에 맞는 요청의 처리는 해당 서비스로 책임을 분배하는 것을 목표로 잡았다. 그렇기에 먼저 책임에 맞는 서비스를 주입하였다.

다음은 변화가 컸던 레시피 생성 로직이다.

 

// 기존
    @Override
    @Transactional
    public void createRecipe(RecipeCreateDto recipeCreateDto, MultipartFile img, User user) throws Exception {
        String imgUrl = new String();

        FoodInformation foodInformation = FoodInformation.builder()
                .text(recipeCreateDto.getFoodInformation().getText())
                .serving(recipeCreateDto.getFoodInformation().getServing())
                .cookingTime(recipeCreateDto.getFoodInformation().getCookingTime())
                .build();

        List<IngredientCreateDto> ingredientCreateDtos = recipeCreateDto.getIngredients();
        List<CookStepCreateDto> cookStepCreateDtos = recipeCreateDto.getCookSteps();

        List<Ingredient> ingredients = ingredientCreateDtos.stream().map(Ingredient::makeIngredient)
                .collect(Collectors.toList());

        List<CookStep> cookSteps = cookStepCreateDtos.stream().map(CookStep::makeCookStep)
                .collect(Collectors.toList());

        try {
            imgUrl = s3Uploader.upload(img, "image/recipeImg");
            Recipe recipe = Recipe.builder()
                    .foodName(recipeCreateDto.getFoodName())
                    .foodImgUrl(imgUrl)
                    .user(user)
                    .foodInformation(foodInformation)
                    .ingredients(ingredients)
                    .cookSteps(cookSteps)
                    .build();

            foodInformation.setRecipe(recipe);

            ingredients.forEach(ingredient -> ingredient.setRecipe(recipe));
            cookSteps.forEach(cookStep -> cookStep.setRecipe(recipe));

            recipeRepository.save(recipe);
        } catch (Exception e) {
                // 레시피 저장에 실패한 경우, S3에서 이미지 삭제
            if (!imgUrl.isEmpty()) {
                try {
                    s3Uploader.delete(imgUrl);
                } catch (IOException ioException) {
                    log.error("Failed to delete uploaded image from S3", ioException);
                }
            }
            throw e; // 예외를 다시 던져 트랜잭션 롤백 활성화
        }

    }

 

 

해당 생성 로직에는 다음과 같은 서비스 요청 처리를 진행하였다.

  • FoodInformation 생성
  • Ingredient, CookStep 리스트 생성
  • 이미지 업로드 후 url 저장
  • 해당 객체들을 저장하는 Recipe 객체 저장
    • 만약 저장에 실패한 경우 S3 업로드 이미지 삭제

이러한 다양한 서비스에 대한 요청을 RecipeService에서 한 번에 처리를 진행하고 있었다. 그렇기에 이번에 각각의 관심사에 맞게 책임을 분배하였으며 정리가 된 코드는 다음과 같아진다.

 

  @Override
  @Transactional
  public void createRecipe(RecipeCreateDto recipeCreateDto, MultipartFile img, User user)
      throws Exception {
    String imgUrl = new String();

    FoodInformation foodInformation = foodInformationService.createFoodInformation(recipeCreateDto);

    List<Ingredient> ingredients = ingredientService.createIngredients(
        recipeCreateDto.getIngredients());

    List<CookStep> cookSteps = cookStepService.createCookSteps(recipeCreateDto.getCookSteps());

    try {
      imgUrl = s3Uploader.upload(img, "image/recipeImg");
      Recipe recipe = createRecipe(recipeCreateDto, user, imgUrl, foodInformation, ingredients,
          cookSteps);

      foodInformationService.relationRecipe(foodInformation, recipe);
      ingredientService.relationRecipe(ingredients, recipe);
      cookStepService.relationRecipe(cookSteps, recipe);

      recipeRepository.save(recipe);
    } catch (Exception e) {
      // 레시피 저장에 실패한 경우, S3에서 이미지 삭제
      deleteImgInS3(imgUrl);
      throw e; // 예외를 다시 던져 트랜잭션 롤백 활성화
    }
  }

 

기능은 기존과 같이 동작을 한다. 그렇지만 기존에 RecipeSerivce에서 한번에 관리하던 것들을 각 관심사의 Service로 전달을 하였다. 일단 눈에 띄는 점은 코드의 가독성이 좋아졌다는 점과 책임성이 적절하게 분배가 되었다는 것이다. 기본적인 생성 로직부터 읽기, 수정하기의 로직들도 똑같이 가독성과 책임을 분배할 수 있게 되었다.


정리

해당 관심사 및 의존성 부분의 리팩토링 결과로 얻게 되는 점은 다음과 같다.

  1. 단일 책임 원칙 SRP
  2. 개선을 통해 RecipeService는 이제 CookStep, FoodInformation, Ingredient 객체에서 책임져야 할 요청을 처리하지 않아도 된다. 그리고 이를 통해 후에 코드 수정이 발생하는 경우 각 객체에서 책임질 부분만 수정을 하기에 영향을 최소화할 수 있게 되었다.
  3. 테스트 가능성 향상
  4. 기존의 경우 테스트를 진행하기 위해 RecipeService 부분만 단위테스트를 진행할 수 있었지만 이번에 개선을 통해 다른 책임이 분산된 서비스 클래스들도 단위테스트를 작성할 수 있게 되었다. 그렇기에 좀 더 기능에 대한 테스트를 확장하고 가독성 있게 생성할 수 있게 되었다.
  5. 확장성
  6. 서비스의 관심사 분리를 통해 특정 로직에 변화가 생길 경우 해당 부분만 수정하면 된다. 이는 위의 SRP에서 말한 각 서비스 간의 영향을 최소화하는 점과 동일하다.