본문 바로가기
Spring/experience

Spring Framework에서 복잡한 비즈니스 로직 구조화하기 Filter Chain

by include_hoany 2025. 4. 20.

Chat Gpt가 만들어준 이미지

 

오늘은 스프링 프레임워크에서 복잡한 비즈니스 로직을 구조화해 본 경험을 이야기 해보려고 합니다.

제가 실제로 해결하고자 했던 비즈니스를 기반으로 설명하면 좋겠지만 아무래도 보안상 문제가 있을 수 있어 배달 어플에서 사용 가능한 쿠폰 목록을 고객에게 안내하는 비즈니스 도메인이 가장 적절하다고 생각하여 해당 도메인으로 설명을 해보도록 하겠습니다.
 
우리가 해결하고자 하는 비즈니스 도메인은 다음과 같습니다.
1. 고객이 보유한 쿠폰 목록 중 주문에 사용 가능한 쿠폰 목록을 보여준다.
2. 사용가능한 쿠폰에 대한 정의는 다음과 같습니다.
- 고객이 보유한 쿠폰중 사용기한이 만료되지 않은 쿠폰
- 고객이 보유한 쿠폰중 전체 브랜드 또는 주문하고자 하는 가게 브랜드에 적용이 가능한 쿠폰
- 고객이 보유한 쿠폰중 전체 지역 또는 가게가 속한 지역에 적용이 가능한 쿠폰
 

@Service
public class CouponLegacyService {

    private final StoreService storeService;
    private final CouponRepository couponRepository;

    public CouponLegacyService(StoreService storeService,
                               CouponRepository couponRepository) {
        this.storeService = storeService;
        this.couponRepository = couponRepository;
    }

	@Transactional(readOnly = true)
    public List<CouponResponseDto> getOrderStoreApplicableCouponList(Long userId, Long orderStoreId) {
        // TODO 유저의 쿠폰 리스트를 조회
        List<Coupon> userCouponList = couponRepository.getUserCouponList(userId);
        
        // TODO 주문하고자 하는 가게 조회
        Store orderStore = storeService.getStore(orderStoreId);

        // TODO 유효기한이 유효한 쿠폰 목록만 필터링
        userCouponList = getAbleExpirationFilteredList(userCouponList, orderStore);

        // TODO 주문하고자 하는 브랜드 적용이 가능한 쿠폰 목록만 필터링
        userCouponList = getAbleBrandFilteredList(userCouponList, orderStore);

        // TODO 주문하고자 하는 가게 지역에 적용 가능한 쿠폰만 필터링
        userCouponList = getAbleReagionFilteredList(userCouponList, orderStore);

        // TODO 적용 가능한 쿠폰 리스트 Dto형태로 응답객체 리턴
        return userCouponList.stream().map(CouponResponseDto::of).toList();

    }


    private List<Coupon> getAbleExpirationFilteredList(List<Coupon> userCouponList, Store store) {
		// TODO 유효기한이 유효한 쿠폰 목록만 필터링...
    }

    private List<Coupon> getAbleBrandFilteredList(List<Coupon> userCouponList, Store store) {
        // TODO 주문하고자 하는 브랜드 적용이 가능한 쿠폰 목록만 필터링...
    }

    private List<Coupon> getAbleReagionFilteredList(List<Coupon> userCouponList, Store store) {
        // TODO 주문하고자 하는 가게 지역에 적용 가능한 쿠폰만 필터링...
    }

}

해당 기능을 처음 오픈할 당시 사용 가능한 쿠폰 목록을 처리하는 로직은 절차지향적으로 문제를 해소하였습니다. 이렇게 진행했던 이유는 처리하고자 하는 비즈니스 로직이 단순하기도 했고 해당기능이 고도화될지 예상하기 어려웠기 때문에 빠르게 해당 서비스를 고객들에게 적용하는데 우선순위가 높았기 때문입니다.

해당 서비스를 오픈하고 점차 사용자가 많아지면서 하나 둘 쿠폰에 대한 비즈니스 요구사항이 추가되기 시작했고 어느 정도 수준까지는 현재의 구조대로 진행을 하기에 큰 무리는 없었습니다. 하지만 비즈니스 요구사항이 점차 고도화되면서 개발측면에서 문제가 발생하기 시작했습니다.

비즈니스 로직이 고도화되면서 발생한 문제점
1. 한 서비스 객체에 소스코드양이 비대해져 직관적이지 않다.
2. 새로운 개발자 또는 쿠폰 로직을 개발하지 않은 개발자가 이해하기까지 많은 리소스가 소요된다.
3. 사이드이펙트 때문에 비즈요구사항이 변경되면 수정하기 무서워지는 코드가 되기 시작했다.

위 문제로 인해 단순한 절차지향적 방법에서 벗어나 구조화가 필요하다는 결론에 도달하게 되어 리팩토링을 진행하게 되었습니다. 이 문제를 해결하기 위해 서블릿 필터의 아이디어를 생각하고 구조화했습니다.
 

@Getter  
@Setter  
public class CouponFilterContext {  
  
	// TODO 쿠폰을 적용하고자 하는 가게  
	private Store store;  
	  
	// TODO 사용 가능한 쿠폰 리스트  
	private List<Coupon> applicableCouponList;  
	  
	// TODO 사용 불가능한 쿠폰 리스트  
	private List<Coupon> inapplicableCouponList;
  
}

Context객체는 사용 가능한 쿠폰을 도출하기 위해 필터로 전달되는 데이터들을 담은 객체입니다. 또한 필터를 거치며 최종적으로 사용가능한 쿠폰과 사용 불가능한 쿠폰으로 분류된 데이터를 전달합니다.
 

// 쿠폰 필터 인터페이스
public interface CouponFilter {  
  
    CouponFilterContext apply(CouponFilterContext couponFilterContext);  
  
}

사용 가능한 쿠폰들을 걸러주는 필터들을 구현하기 위한 인터페이스를 정의해 줍니다. 사용 가능한 쿠폰을 거르기 위한 데이터들은 CouponFilterContext객체를 통해 전달됩니다.
 

//필터 구현체
@Component  
@Order(CouponFilterOrder.COUPON_BRAND_FILTER)  // 필터의 순서
public class CouponBrandFilter implements CouponFilter {  
  
    @Override  
    public CouponFilterContext apply(CouponFilterContext couponFilterContext) {  
  
        // TODO 쿠폰 브랜드 필터 비즈니스 로직  
  
        return couponFilterContext;  
    }  
  
}


@Component  
@Order(CouponFilterOrder.COUPON_EXPIRATION_FILTER)  // 필터의 순서
public class CouponExpirationFilter implements CouponFilter {  
  
    @Override  
    public CouponFilterContext apply(CouponFilterContext couponFilterContext) {  
  
        // TODO 쿠폰 만료기간확인 필터 비즈니스 로직  
  
        return couponFilterContext;  
    }  
  
}

@Component  
@Order(CouponFilterOrder.COUPON_REGION_FILTER) // 필터의 순서
public class CouponRegionFilter implements CouponFilter {  
  
    @Override  
    public CouponFilterContext apply(CouponFilterContext couponFilterContext) {  
  
        // TODO 쿠폰 사용 가능 지역 필터 비즈니스 로직  
  
        return couponFilterContext;  
    }  
  
}

실질적으로 사용 가능한 쿠폰들을 도출하는 CouponFilter 구현체들은 각자 유효기한, 브랜드, 지역 필터의 각자 책임별로 구현되어 정리됩니다.
 

// 필터의 순서를 정의하기 위한 클래스
public class CouponFilterOrder {  
  
    public static final int COUPON_EXPIRATION_FILTER = 1;  
    public static final int COUPON_BRAND_FILTER = 2;  
    public static final int COUPON_REGION_FILTER = 3;  
  
}

@Component  
public class CouponFilterChain {  
  
    private final List<CouponFilter> filters;  
  
    public CouponFilterChain(List<CouponFilter> filters) {  
        this.filters = filters;  
    }  
  
    public CouponFilterContext applyFilters(CouponFilterContext context) {  
  
        // TODO 전체 필터 구현체들을 순회하며 사용 가능한 쿠폰과 사용 불가능 쿠폰을 분류한다.  
        for (CouponFilter filter : filters) {  
            context = filter.apply(context);  
        }  
  
        return context;  
    }  
  
}

CouponFilter구현체들이 CouponFilterChain객체로 의존성 주입되어 applyFilters메서드를 통해 전체 필터를 순회하게 되고 최종적으로 사용가능한 쿠폰과 사용 불가능한 쿠폰으로 정리되게 됩니다.
 

@Service
public class CouponService {

    private final StoreService storeService;
    private final CouponFilterChain couponFilterChain;
    private final CouponRepository couponRepository;

    public CouponService(StoreService storeService,
                         CouponFilterChain couponFilterChain,
                         CouponRepository couponRepository) {
        this.storeService = storeService;
        this.couponFilterChain = couponFilterChain;
        this.couponRepository = couponRepository;
    }

	@Transactional(readOnly = true)
    public List<CouponResponseDto> getOrderStoreApplicableCouponList(Long userId, Long orderStoreId) {
        // TODO 유저의 쿠폰 리스트를 조회
        List<Coupon> userCouponList = couponRepository.getUserCouponList(userId);
        // TODO 주문하고자 하는 가게 조회
        Store orderStore = storeService.getStore(orderStoreId);
        // TODO 필터 체이닝을 동작시키기 위한 Context 객체 생성
        CouponFilterContext context = CouponFilterContext.of(orderStore, userCouponList);
        // TODO 필터 체이닝을 통해 주문하고자 하는 가게에 적용 가능한 쿠폰 목록 도출
        context = couponFilterChain.applyFilters(context);
        // TODO 적용 가능한 쿠폰 리스트 Dto형태로 응답객체 리턴
        return context.getApplicableCouponList().stream().map(CouponResponseDto::of).toList();
    }

}

기존에 사용 가능한 쿠폰을 응답해 주는 메서드에 쿠폰 필터 체이닝로직을 적용하면 위와 같은 코드가 되게 됩니다. 

위와같은 구조로 변경된 이후로 얻은 이점은 다음과 같습니다.
1. 각 클래스들이 책임이 명확하기 때문에 로직을 파악하기 수월하다.
2. 특정 쿠폰 필터 비즈니스 로직이 변경된다면 해당 책임을 지고 있는 필터 로직만 확인하면 된다.
3. 새로운 비즈니스 로직이 추가된다 해도 간단하게 CouponFilter 구현체를 구현하기만 하면 된다.
4. @Order어노테이션을 통해 필터의 순서를 직관적으로 관리할 수 있다.
 

com.example.project

├── coupon
│   ├── controller
│   │   └── CouponController
│   ├── constants
│   │   └── CouponFilterOrder
│   ├── dto
│   │   └── response
│   │   │   └── CouponResponseDto
│   ├── entity
│   │   ├── Coupon
│   ├── filter
│   │   ├── CouponBrandFilter
│   │   ├── CouponExpirationFilter
│   │   ├── CouponFilter
│   │   ├── CouponFilterChain
│   │   ├── CouponFilterContext
│   │   └── CouponRegionFilter
│   ├── repository
│   │   └── CouponRepository
│   └── service
│   │   └── CouponService

패키지 구조는 다음과 같습니다. 이해를 돕기 위해 쿠폰 필터로직을 동작시키기 위한 클래스들만 구현되어 있는 부분이므로 이 부분은 참고 부탁드립니다.

리팩토링 이후로 목표로 했던 이해하기 쉬운 코드, 다양한 비즈니스 로직을 녹여내기 쉽고 다양한 비즈니스 수정 요구사항을 발 빠르게 적용할 수 있었기 때문에 좋은 평가를 받았던 리팩토링이었습니다.

쿠폰 필터로직을 구현하면서 고민이었던 부분
CouponFilterContext객체가 쿠폰 필터를 동작시키기 위한 데이터와 쿠폰 필터 로직을 거치며 처리된 데이터를 모두 가지고 있어서 많은 책임을 가지고 있는 객체라는 생각이 들었습니다.
 

public interface CouponFilter {
    CouponFilterResultContext apply(CouponFilterInputContext input, CouponFilterResultContext result);
}

따라서 추후 위 코드처럼 사용가능한 쿠폰을 도출하기 위한 데이터성 객체와 쿠폰 필터를 거쳐 처리된 데이터를 따로 분리해서 관리하는 것도 좋은 방법일 거라는 생각이 들었습니다
 
이상 비즈니스 로직이 고도화되며 리팩토링 했던 경험을 간단하게 설명해 보았습니다. 블로그 글을 정리하며 생각이 정리되고 제가 잘 이해하고 있는 부분과 애매모호한 부분이 어떤 부분인지 명확해지니 한결 마음이 편안해지는 것 같습니다.

그럼 이상 글을 마무리하겠습니다. 행복 코딩하세요!