Java & Spring

알람 기능의 Template Method + Strategy 패턴 적용기

ju_young 2024. 7. 30. 20:53
728x90

이전 포스트에서 Template Method 패턴을 적용하여 확장성을 개선해보았다. 하지만 이후 알람을 보내야하는 이벤트가 추가됨에따라 해당 알람을 전송하는 클래스를 추가하고 변경해야하는 코드가 많아지게 된다는 문제가 있다. 따라서 이러한 문제를 개선하기 위해 Strategy 패턴을 적용한 경험을 설명해보려고 한다.

설계

이전 구조에서 AlarmTemplate이라는 인터페이스와 AlarmMappingHandlerAdapter라는 클래스를 추가해주었다.

  • AlarmTemplate: Template Method를 적용한 추상 클래스의 다형성을 제공한다.
  • AlarmMappingHandlerAdapter: 알림 전송 기능 수행을 대신 진행해준다.

구현

AlarmTemplate

AlarmTemplate 인터페이스를 추가하고 지원 여부를 판단할 수 있는 supports 메소드와 Template Method에 해당하는 sendAlarm 메소드를 정의해준다. 이때 supports 메소드는 이후에 설명할 Adapter 클래스 부분에서 설명한다.

public interface AlarmTemplate {  
    void sendAlarm(AlarmTemplateParameter param);  
    boolean supports(AlarmType alarmType);  
}

AlarmTemplateParameter

AlarmTemplate을 구현한 모든 클래스에서 공통적으로 입력하여 호출될 수 있도록 AlarmTemplateParameter라는 인터페이스를 추가한다.

public interface AlarmTemplateParameter {  
}

이 인터페이스를 구현하여 파라미터를 필드로 가짐으로써, AlarmTemplatesendAlarm 메소드를 정의할 수 있게 된다. (AlarmTemplate을 구현한 클래스의 sendAlarm의 파라미터가 제각각이면 인터페이스에 해당 메소드를 정의할 수 없다.)

AlarmMappingHandlerAdapter

DispatcherServlet을 참고하여 구현

해당 Adapter 클래스는 프론트 컨트롤러 패턴이 적용된 DispatcherServlet 에서 초기에 빈에 등록된 HandlerMapping 클래스를 모두 가져오고 HttpServletRequest를 입력받아 지원되는 Handler만 찾는 로직에서 영감을 얻어 추가하였다. 실제 DispatcherServlet 코드를 확인해보면 다음과 같다. (편의상 필요한 부분만 첨부했다.)

// 초기화 시 모든 HandlerMapping을 ApplicationContext에서 불러온다.
private void initHandlerMappings(ApplicationContext context) {  
    this.handlerMappings = null;  
    if (this.detectAllHandlerMappings) {  
        Map<String, HandlerMapping> matchingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);  
        if (!matchingBeans.isEmpty()) {  
            this.handlerMappings = new ArrayList(matchingBeans.values());  
            AnnotationAwareOrderComparator.sort(this.handlerMappings);  
        }  
    } else {  
        try {  
            HandlerMapping hm = (HandlerMapping)context.getBean("handlerMapping", HandlerMapping.class);  
            this.handlerMappings = Collections.singletonList(hm);  
        } catch (NoSuchBeanDefinitionException var4) {  
        }  
    }  

    ...

}

// HttpServletRequest로 Handler를 찾아 반환한다.
@Nullable  
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {  
    if (this.handlerMappings != null) {  
        Iterator var2 = this.handlerMappings.iterator();  

        while(var2.hasNext()) {  
            HandlerMapping mapping = (HandlerMapping)var2.next();  
            HandlerExecutionChain handler = mapping.getHandler(request);  
            if (handler != null) {  
                return handler;  
            }  
        }  
    }  

    return null;  
}

모든 AlarmTemplate 조회

AlarmTemplate를 구현하여 빈에 등록된 모든 Alarm 클래스들을 다음과 같이 조회할 수 있다.

private void initHandlerMappings(ApplicationContext context) {  
    this.handlerMappings = null;  
    Map<String, AlarmTemplate> alarmTemplates =  
            BeanFactoryUtils.beansOfTypeIncludingAncestors(context, AlarmTemplate.class, false, false);  
    if (!alarmTemplates.isEmpty()) {  
        this.handlerMappings = alarmTemplates.values().stream().toList();  
    }  
}

여기서 BeanFactoryUtils.beansOfTypeIncludingAncestors라는 메소드가 생소할 수 있다. 이 메소드는 ApplicationContext에 등록된 빈들을 조회할 수 있다.

NOTE
ApplicationContext는 Spring Container이다.

 

그리고 두 번째 파라미터부터는 다음과 같은 값들을 필요로 한다.

  • type: 클래스 형식의 타입을 지정한다.
  • includeNonSingletons: true일 경우 싱글톤이 아닌 빈들도 모두 가져온다.
  • allowEagerInit: true일 경우 가져온 모든 빈들을 캐싱하여 이후 조회할 때 즉시 가져올 수 있도록 한다.

여기서 조회된 빈들은 RequestBookingProductAlarm, CancelBookingProductAlarm, CompleteDealProductAlarm이다.

NOTE
'Adapter가 먼저 빈에 등록되어서 조회되지 않는 빈들이 있으면 어떻게 하지?' 라는 의문이 들 수 있다. 이럴 경우 @PostConstruct 어노테이션을 적용하여 초기화를 진행하거나 @Order 어노테이션으로 빈 생성 우선순위를 지정해주는 방법을 적용해줄 수 있다.

지원하는 Handler 조회

AlarmTemplate에서 정의한 supports 메소드는 바로 여기서 사용된다.

private AlarmTemplate getHandler(AlarmType alarmType) {  
    return this.handlerMappings.stream()  
            .filter((alarmTemplate -> alarmTemplate.supports(alarmType)))  
            .findFirst()  
            .orElseThrow(() -> new AlarmException(ErrorCode.NOT_FOUND_ALARM_TEMPLATE));  
}

supports 메소드에 AlarmType을 입력하여 지원 여부(boolean)을 반환받고, 지원하는 AlarmTemplate만 찾아서 반환한다.

DispatcherServlet에서는 Iterator로 구현했지만, 필자는 조금 더 가독성있게 체이닝 메소드 방식으로 구현했다.

 

여기서 고민했던 점이 있는데, handlerMappings을 List가 아닌 Map으로 구현하면 어땠을까 하는 점이다. 선형 탐색으로 클래스를 찾는 List보다 Map이 더 빠르다고 생각할 수 있었지만, 조회된 빈들은 대게 개수가 많지 않다. 따라서 성능 상 큰 차이가 없다고 판단했기에 List를 사용하여 구현하게 되었다.

중간 정리

지금까지 구현한 코드들을 통합하여 확인해보자.

@Component  
public class AlarmMappingHandlerAdapter {  
    private List<AlarmTemplate> handlerMappings;  

    public AlarmMappingHandlerAdapter(ApplicationContext context) {  
        this.initHandlerMappings(context);  
    }  

    // 빈에 등록된 모든 Alarm 클래스 조회
    private void initHandlerMappings(ApplicationContext context) {  
        this.handlerMappings = null;  
        Map<String, AlarmTemplate> alarmTemplates =  
                BeanFactoryUtils.beansOfTypeIncludingAncestors(context, AlarmTemplate.class, false, false);  
        if (!alarmTemplates.isEmpty()) {  
            this.handlerMappings = alarmTemplates.values().stream().toList();  
        }  
    }  

    // 입력받은 AlarmType을 지원하는 AlarmTemplate 조회
    private AlarmTemplate getHandler(AlarmType alarmType) {  
        return this.handlerMappings.stream()  
                .filter((alarmTemplate -> alarmTemplate.supports(alarmType)))  
                .findFirst()  
                .orElseThrow(() -> new AlarmException(ErrorCode.NOT_FOUND_ALARM_TEMPLATE));  
    }  
}

적용

이제 구현한 Adapter로 이전에 구현한 코드를 수정해보자.

이전 코드

이전 컨트롤러에서는 productEventType 라는 쿼리 파라미터를 입력받고, 이를 통해 사용해야하는 Alarm 클래스를 선택해 sendAlarm 메소드를 호출했다.

@RestController  
@RequiredArgsConstructor  
@RequestMapping("/api/v1/chats")  
public class ChatRoomEventApi {  
    private final CancelBookingProductAlarm cancelBookingProductAlarm;  
    private final CompleteDealProductAlarm completeDealProductAlarm;  
    private final RequestBookingProductAlarm requestBookingProductAlarm;  

    @PostMapping("/{chatRoomId}/productDeal")  
    public ResponseEntity<Void> handleProductDeal(  
            @PathVariable Long chatRoomId,  
            @RequestParam ProductEventType productEventType,  
            @AuthenticationPrincipal MemberPrincipal memberPrincipal) {

        switch (productEventType) {  
            case CANCEL_BOOKING -> cancelBookingProductAlarm.sendAlarm(chatRoomId, memberPrincipal.id());  
            case REQUEST_BOOKING -> requestBookingProductAlarm.sendAlarm(chatRoomId, memberPrincipal.id());  
            default -> completeDealProductAlarm.sendAlarm(chatRoomId, memberPrincipal.id());  
        }  

        return ResponseEntity.ok(null);  
    }  
}

적용 코드

구현한 Adapter만 주입하여 필요한 파라미터를 handle 메소드에 입력만 해주도록 구현할 수 있었다. 이때, handle 메소드라는 것을 위에서 아직 구현을 안했는데, 여기서 어떤 인자값들이 건네지는지 확인하기 위해서 일부러 구현해놓지 않았다.

@RestController  
@RequiredArgsConstructor  
@RequestMapping("/api/v1/chats")  
public class ChatRoomEventController {  
    private final AlarmMappingHandlerAdapter alarmMappingHandlerAdapter;  

    @PostMapping("/{chatRoomId}/productDeal")  
    public ResponseEntity<Void> handleProductDeal(  
            @PathVariable Long chatRoomId,  
            @RequestParam ProductEventType productEventType,  
            @AuthenticationPrincipal MemberPrincipal memberPrincipal) {  
        alarmMappingHandlerAdapter.handle(chatRoomId, memberPrincipal.id(), productEventType.getAlarmType());  
        return ResponseEntity.ok(null);  
    }  
}

handle 메소드 구현

앞서 구현한 내용을 바탕으로 Adapter 클래스에 handle 메소드를 아래처럼 추가해주면 된다.

public void handle(Long chatRoomId, Long memberId, AlarmType alarmType) {  
    // AlarmType을 지원하는 Alarm 클래스 조회
    AlarmTemplate handler = this.getHandler(alarmType);
    // sendAlarm 호출하여 알림 전송 로직 수행  
    handler.sendAlarm(new ProductAlarmParameter(chatRoomId, memberId));  
}

이로써 Adapter 클래스가 알아서 작업을 처리할 Alarm 클래스를 찾아주고, 실행시켜준다.

결과

지금까지 Strategy 패턴을 적용하여 알람 기능을 개선해보았다. Adapter 클래스가 알림을 처리할 로직을 대신 수행해주게 함으로써, Alarm 클래스가 추가되더라도 Controller의 코드를 더 이상 수정하지 않고 Adapter 코드만 수정하거나 아예 수정하지 않도록 할 수 있었다. 즉, Adapter 클래스로 인해 의존성이 분리되어 유지보수성을 향상시킬 수 있었다.

728x90