고차함수로 서비스 Bean 간 의존성 줄이기

soonhankwon·2023년 11월 23일

Intro.


프로젝트를 진행하면서 요구사항이 복잡해지면 다음과 같은 서비스 Bean을 빈번하게 만드는 문제를 겪었습니다.

문제의 서비스 Bean


  • 뭔가 뚱뚱해보이는 서비스 Bean!
@Slf4j
@Service
@RequiredArgsConstructor
public class ExpenditureService {

    private final ExpenditureRepository expenditureRepository;
    private final UserRepository userRepository;
    private final ExpenditureCategoryRepository expenditureCategoryRepository;
    private final UserBudgetRepository userBudgetRepository;
    private final BudgetConsultingService budgetConsultingService;
    private final ApplicationEventPublisher applicationEventPublisher;
    private final RedissonLockContext redissonLockContext;
    private final TransactionService transactionService;

....
}
  • Repository는 DB를 조회해야 하는 경우임으로 불가피한 경우가 많다고 생각하지만, 서비스 Bean간의 의존성은 문제가 있다고 생각했습니다.
  • 서비스 Bean간의 높은 결합도순환 참조 가능성 때문입니다.
    • 순환 참조는 기본적으로 스프링 부트에서 애플리케이션 Run시 예외를 던져서 방지할 수 있지만, 결국 가능성을 내포하고 있습니다.
    • 또한 무분별하게 DI해서 사용한 서비스 빈들이 거미줄처럼 꼬여서 스파게티가 될 수 있는 가능성을 가지고 있습니다.

이러한 이유로 프로젝트에서 리팩토링을 통해 서비스 Bean들 간의 의존성을 개선시키고 싶었습니다.

평소 함수형 프로그래밍에 관심이 많던 와중에 이번 KakaoTechMeetup 2023 레퍼런스를 참고하여 고차함수Handler 컴포넌트를 활용하여 리팩토링한 경험을 풀어보고자 합니다.

Legacy & Refactoring


  • 예시의 레거시 코드는 이해를 돕기위해 심플화된 코드입니다.
@Service
@RequiredArgsConstructor
public class ExpenditureService {

    private final ExpenditureRepository expenditureRepository;
    private final UserRepository userRepository;
    private final ExpenditureCategoryRepository expenditureCategoryRepository;
    private final UserBudgetRepository userBudgetRepository;
    private final BudgetConsultingService budgetConsultingService;
    private final ApplicationEventPublisher applicationEventPublisher;
    private final RedissonLockContext redissonLockContext;
    private final TransactionService transactionService;

		public ExpenditureByTodayRecommendationResponse getExpenditureRecommendationByToday(String username) {
		        ..............
		        List<UserBudgetCategoryAndAvailableExpenditure> availableUserBudgetByCategoryByToday = userBudgetRepository.getAvailableUserBudgetByCategoryByToday(user);
						
		        Long realAvailableExpenditure = availableUserBudgetByCategoryByToday.stream()
		                .mapToLong(UserBudgetCategoryAndAvailableExpenditure::availableExpenditure).sum();
		        
						// 예산 컨설팅 서비스에서 실제 예산 대비 지출액으로 구체적인 분석 담당
						// UserBudgetConsultingService를 의존하고 있는 포인트1!
		        String message = budgetConsultingService.analyzeBudgetStatus(realAvailableExpenditure);
		
		        // 지속적인 소비 습관을 생성하기 위한 서비스이므로 예산을 초과하더라도 적정한 금액을 추천
		        List<UserBudgetCategoryAndAvailableExpenditureRecommendation> res =
		                availableUserBudgetByCategoryByToday.stream()
		                        .map(i -> {
		                            if (i.availableExpenditure() < 0) {
																		// UserBudgetConsultingService를 의존하고 있는 포인트2!
		                                Long minimumAvailableExpenditure = budgetConsultingService.getMinimumAvailableExpenditure(i);
		                                return UserBudgetCategoryAndAvailableExpenditureRecommendation.toMinimumRecommendation(i, minimumAvailableExpenditure);
		                            }
		                            return UserBudgetCategoryAndAvailableExpenditureRecommendation.toRecommendation(i);
		                        })
		                        .toList();
					...........
		    }
.......
  • 첫번째로 문제가 됬던 코드의 일부입니다.
    • 포인트1, 포인트2에서 UserBugetConsultingService에 의존하고 있습니다.
    • 이것을 어떻게 의존성을 끊을 수 있을까?
  • 먼저 구조를 변경해주는 방법을 선택했습니다.
  • ExpenditureServiceHandler라는 핸들링 컴포넌트를 만들었습니다.
    • 예를 들자면 공연을 기획하는 기획자의 역할의 컴포넌트입니다.
    • 배우의 역할은 각 모듈들이 될 것입니다.
@Component
public class ExpenditureServiceHandler {

    private final BudgetConsultingService budgetConsultingService;
    private final ExpenditureService expenditureService;
    private final RedissonLockContext redissonLockContext;

    public ExpenditureServiceHandler(final BudgetConsultingService budgetConsultingService,
                                     final ExpenditureService expenditureService,
                                     final RedissonLockContext redissonLockContext) {
        this.budgetConsultingService = budgetConsultingService;
        this.expenditureService = expenditureService;
        this.redissonLockContext = redissonLockContext;
    }

    public ExpenditureByTodayRecommendationResponse getExpenditureRecommendationByToday(String username) {
        return expenditureService.getExpenditureRecommendationByToday(
                username,
								// 함수를 파라미터로 넘긴다.
                budgetConsultingService::analyzeBudgetStatus,
								// 함수를 파라미터로 넘긴다.
                budgetConsultingService::getMinimumAvailableExpenditure
        );
    }
}
  • 바로 여기서 고차함수를 사용합니다.
    • expenditureService.getExpenditureRecommendationByToday()에 함수를 파라미터로 넘겨줍니다.
  • 자, 그럼 이제 ExpenditureService의 컴파일 에러를 고치러 가야합니다.
public ExpenditureByTodayRecommendationResponse getExpenditureRecommendationByToday(String username,
                                                                                    Function<Long, String> analyzeBudgetStatus,
                                                                                    Function<String, Long> getMinimumAvailableExpenditure) {
        .......
        List<UserBudgetCategoryAndAvailableExpenditure> availableUserBudgetByCategoryByToday = userBudgetRepository.getAvailableUserBudgetByCategoryByToday(user);
				
        Long realAvailableExpenditure = availableUserBudgetByCategoryByToday.stream()
                .mapToLong(UserBudgetCategoryAndAvailableExpenditure::availableExpenditure).sum();
        // 예산 컨설팅 서비스에서 실제 예산 대비 지출액으로 구체적인 분석 담당
				// UserBudgetConsultingService를 의존하고 있는 포인트1! - 함수 apply
        String message = analyzeBudgetStatus.apply(realAvailableExpenditure);

        // 지속적인 소비 습관을 생성하기 위한 서비스이므로 예산을 초과하더라도 적정한 금액을 추천
        List<UserBudgetCategoryAndAvailableExpenditureRecommendation> res =
                availableUserBudgetByCategoryByToday.stream()
                        .map(i -> {
                            if (i.availableExpenditure() < 0) {
																// UserBudgetConsultingService를 의존하고 있는 포인트2! - 함수 apply
                                Long minimumAvailableExpenditure = getMinimumAvailableExpenditure.apply(i.name());
                                return UserBudgetCategoryAndAvailableExpenditureRecommendation.toMinimumRecommendation(i, minimumAvailableExpenditure);
                            }
                            return UserBudgetCategoryAndAvailableExpenditureRecommendation.toRecommendation(i);
                        })
                        .toList();
		...................
    }
  • 메서드 시그니처에서 함수를 파라미터로 받도록 수정했습니다.
    • Function<Long, String> analyzeBudgetStatus
    • Function<String, Long> getMinimumAvailableExpenditure
    • Function은 자바에 많은 종류가 있으며 상황에 맞춰 사용가능합니다.
  • UserBudgetConsultingService의 메서드를 함수로 대체합니다.

@Service
@RequiredArgsConstructor
public class ExpenditureService {

    private final ExpenditureRepository expenditureRepository;
    private final UserRepository userRepository;
    private final ExpenditureCategoryRepository expenditureCategoryRepository;
    private final UserBudgetRepository userBudgetRepository;
    private final BudgetConsultingService budgetConsultingService;
    private final ApplicationEventPublisher applicationEventPublisher;
    private final RedissonLockContext redissonLockContext;
    private final TransactionService transactionService;

    public String createExpenditure(String username, Long categoryId, ExpenditureRequest request) {
        redissonLockContext.executeLock(username, () ->
                // 락을 점유한 스레드만 트랜잭션 적용
                transactionService.executeAsTransactional(() -> {
                    User user = userRepository.findUserByUsername(username)
                            .orElseThrow(() -> new ApiException(CustomErrorCode.USER_NOT_FOUND_DB));

                    ExpenditureCategory category = expenditureCategoryRepository.findById(categoryId)
                            .orElseThrow(() -> new ApiException(CustomErrorCode.CATEGORY_NOT_FOUND_DB));

                    Expenditure expenditure = new Expenditure(user, category, request);
                    expenditureRepository.save(expenditure);
                    return null;
                }));
        return "created";
    }

.........
  • 두번째로 문제가 됬던 코드의 일부입니다.
  • 지출을 생성하는 간단한 비즈니스 코드입니다.
    • RedissonLock을 활용하여 동시성을 제어합니다.(executeLock)
    • redissonLockContext는 전략패턴의 Context입니다.
    • 람다 부분은 Context에서 실행되는 Strategy입니다.(전략 패턴 사용)
    • 락을 점유한 스레드만 트랜잭션을 적용하기 위해 함수형 인터페이스를 활용한 트랜잭션 서비스의 executeAsTransactional 메서드 안에서 비즈니스 로직을 수행합니다.
  • 굳이 트랜잭션 서비스를 만들어 사용한 이유?
    • 스프링 빈은 프록시 기반으로 작동하기 때문에 클래스 내부에서 A메서드(@Transactional 미적용)에서 B메서드(@Transactional 적용)를 호출하면 트랜잭션이 적용되지 않습니다. (this로 호출)
  • ExpenditureServiceHandler를 만들면서 TransactionService와 RedissonLockContext 고민을 해결할 수 있었습니다.
public class ExpenditureServiceHandler {

    private final BudgetConsultingService budgetConsultingService;
    private final ExpenditureService expenditureService;
    private final RedissonLockContext redissonLockContext;

    public ExpenditureServiceHandler(final BudgetConsultingService budgetConsultingService,
                                     final ExpenditureService expenditureService,
                                     final RedissonLockContext redissonLockContext) {
        this.budgetConsultingService = budgetConsultingService;
        this.expenditureService = expenditureService;
        this.redissonLockContext = redissonLockContext;
    }

    public String createExpenditure(String username, Long categoryId, ExpenditureRequest request) {
        redissonLockContext.executeLock(username,
                () -> expenditureService.createExpenditure(username, categoryId, request));
        return "created";
    }
...........
  • RedissonLockContext
    • 트랜잭션을 적용하지 않고 핸들러에서 을 적용해줍니다.
    • 핸들러락 로직과 책임이 넘어감으로써 복잡도가 개선되었습니다.
  • TransactionService
    • Handler 활용으로 락은 Handler에서 잡아주고 @Transactional을 ExpenditureService에서 적용해줌으로써 TransactionService가 필요가 없어지게 되었습니다.

      	@Transactional
          public void createExpenditure(String username, Long categoryId, ExpenditureRequest request) {
              User user = userRepository.findUserByUsername(username)
                      .orElseThrow(() -> new ApiException(CustomErrorCode.USER_NOT_FOUND_DB));
      
              ExpenditureCategory category = expenditureCategoryRepository.findById(categoryId)
                      .orElseThrow(() -> new ApiException(CustomErrorCode.CATEGORY_NOT_FOUND_DB));
      
              Expenditure expenditure = new Expenditure(user, category, request);
              expenditureRepository.save(expenditure);
          }

Summary.


  • 위 방법으로 리팩토링을 진행하여 아래의 그림과 같이 의존성을 제거할 수 있었습니다.

트레이드 오프


  • 함수형 인터페이스를 사용함으로 성능이 매우 미세하게 떨어집니다.
  • 파라미터에 함수가 들어감으로 메서드 파라미터가 많아질 수 있습니다.
    • 해당 경우 메서드가 하고 있는 일이 너무 많지 않은지 의심해볼것!

장점


  • 서비스 빈들간의 의존성의 매우 약한 결합으로 개선됩니다.
  • 서비스 단위 테스트시 Mockito와 같은 라이브러리를 사용하는대신, 함수를 구현하여 사용할 수 있어서 독립적인 테스트가 가능해집니다.
  • 핸들러에서 빈들의 의존관계를 명확하게 확인할 수 있습니다.

Reference.


https://www.youtube.com/watch?v=ESwYltg47Yw&t=43s

profile
ProblemOverFlow - Product Engineer

0개의 댓글