Java & Spring

알림 기능의 Template Method 패턴 적용기

ju_young 2024. 7. 27. 15:17
728x90

[Design Patterns] Template Method Pattern 에서 간단한 예시로 Template Method 패턴에 대해서 이해할 수 있다.

기능 요구사항

중고 거래 서비스를 개발하면서 판매자, 구매자에게 알림을 보내는 기능을 구현하게 되었다. 각 알림은 다음과 같은 경우 발생한다.

  1. 거래가 완료되었을 경우, 판매자와 구매자에게 알림을 전송하고 채팅을 통해 관심을 가졌던 사용자들에게도 알림을 전송한다.
  2. 판매 상태가 예약 중으로 변경 되었을 경우, 예약을 신청한 사용자에게 알림을 전송한다.
  3. 예약이 취소되어 판매 상태가 판매 중으로 변경 되었을 경우, 예약을 취소한 사용자를 포함하여 채팅을 통해 관심을 가졌던 사용자들에게 모두 알림을 전송한다.

구현 (패턴 적용 X)

RabbitMQPublisher

알림 기능의 경우 메시지 큐로 RabbitMQ를 사용하였고, 이로 인해 다음과 같은 클래스를 추가해주었다.

@Service
@RequiredArgsConstructor
public class RabbitMqProducer {
    private final RabbitTemplate rabbitTemplate;

    public void publish(BaseEvent<?> event) {
        rabbitTemplate.convertAndSend(event.getRoutingKey(), event.getPayload());
    }
}

API 구현

RabbitMQPublisher 클래스를 사용하여 다음과 같이 API를 구현했다.

 

먼저, 거래가 완료되었을 때 요청할 API이다.

@PostMapping("/{wasteId}/chats/{chatRoomId}")
public ResponseEntity<Void> completeTransaction(@PathVariable Long wasteId, @PathVariable Long chatRoomId) {
    ChatRoom closedChatRoom = chatRoomService.getChatRoom(chatRoomId);
    transactionService.completeTransaction(
            wasteId, chatRoomId, closedChatRoom.getSellerId(), closedChatRoom.getBuyerId(), SellStatus.CLOSE);

    // 판매자, 구매자에게 알람 전송
    eventPublisher.publishEvent(AlarmEvent.of(
            WASTE_TRANSACTION_COMPLETE.getRoutingKey(),
            MessageRequest.builder()
                    .message(COMPLETED_SELL_MESSAGE.getMessage())
                    .wasteId(wasteId)
                    .memberId(closedChatRoom.getSellerId())
                    .fromMemberId(closedChatRoom.getBuyerId())
                    .alarmType(AlarmType.TRANSACTION)
                    .build()));
    eventPublisher.publishEvent(AlarmEvent.of(
            WASTE_TRANSACTION_COMPLETE.getRoutingKey(),
            MessageRequest.builder()
                    .message(REQUEST_REVIEW_MESSAGE.getMessage())
                    .wasteId(wasteId)
                    .memberId(closedChatRoom.getBuyerId())
                    .fromMemberId(closedChatRoom.getSellerId())
                    .alarmType(AlarmType.TRANSACTION)
                    .build()));

    // 구매자가 아닌 사용자들에게 알람 전송
    chatRoomService.getChatRoomsByWasteId(wasteId, SellStatus.CLOSE).forEach(chatRoom -> {
        eventPublisher.publishEvent(AlarmEvent.of(
                WASTE_TRANSACTION_COMPLETE.getRoutingKey(),
                MessageRequest.builder()
                        .message(COMPLETED_SELL_MESSAGE.getMessage())
                        .wasteId(wasteId)
                        .memberId(chatRoom.getBuyerId())
                        .fromMemberId(chatRoom.getSellerId())
                        .alarmType(AlarmType.TRANSACTION)
                        .build()));
    });

    return ResponseEntity.ok(null);
}

 

그리고 판매 상태가 변경되었을 때 요청할 API이다.

@PostMapping("/chats/{chatRoomId}/status")
public ResponseEntity<Void> updateSellStatus(@PathVariable Long chatRoomId, @RequestParam SellStatus sellStatus) {
    ChatRoom chatRoom = chatRoomService.getChatRoom(chatRoomId);
    transactionService.updateSellStatus(chatRoom.getWasteId(), chatRoomId, sellStatus);
    if (sellStatus  SellStatus.BOOKING) {
        // 구매자에게 예약중 알림 보내기
        eventPublisher.publishEvent(AlarmEvent.of(
                WASTE_CHANGE_SELL_STATUS.getRoutingKey(),
                MessageRequest.builder()
                        .message(String.format(
                                BOOKING_MESSAGE.getMessage(),
                                chatRoom.getSeller().getNickname()))
                        .wasteId(chatRoom.getWasteId())
                        .memberId(chatRoom.getBuyerId())
                        .fromMemberId(chatRoom.getSellerId())
                        .alarmType(AlarmType.TRANSACTION)
                        .build()));
    } else if (sellStatus  SellStatus.ONGOING) {
        // 해당 폐기물에 채팅요청했던 다른 구매자들에게 판매중 알림 보내기
        chatRoomService
                .getBuyerIdByWasteId(chatRoom.getWasteId(), chatRoom.getBuyerId())
                .forEach(buyerIdSummary -> {
                    eventPublisher.publishEvent(AlarmEvent.of(
                            WASTE_CHANGE_SELL_STATUS.getRoutingKey(),
                            MessageRequest.builder()
                                    .message(String.format(
                                            ONGOING_MESSAGE.getMessage(),
                                            chatRoom.getSeller().getNickname()))
                                    .wasteId(chatRoom.getWasteId())
                                    .memberId(buyerIdSummary.buyerId())
                                    .fromMemberId(chatRoom.getSellerId())
                                    .alarmType(AlarmType.TRANSACTION)
                                    .build()));
                });
    }

    return ResponseEntity.ok(null);
}

문제

위에서 구현한 코드를 보면 중복된 코드가 많고, 변경 사항이 생겼을 경우 수정해야하는 부분이 많이 발생하여 반복적인 작업이 발생할 것이다. 또한 이 과정에서 휴먼 에러가 발생할 수 있을 것이다.


이후 다른 이벤트가 추가되어 알림을 추가될 경우에도 걷잡을 수 없이 유지 보수의 어려움이 커지게된다. 이러한 문제를 해결하기 위해서 중복된 부분을 최소화하고 유지 보수성을 향상시켜야한다.

해결

먼저 각 API의 알고리즘을 정리해보자. 간단하게 수도 코드로 작성하는게 좋을 것 같다.

1. 거래 완료 알림

현재 채팅 중인 채팅방을 조회한다.
조회한 채팅방의 판매 상태와 중고 상품 게시글의 판매 상태를 거래 완료 상태로 DB를 업데이트하고, 거래 내역을 저장한다.
판매자, 구매자에게 각각 알림을 전송한다.
구매자는 아니지만 채팅으로 관심가졌던 사용자들에게 모두 알림을 전송한다.

2. 판매 상태가 예약 중으로 변경 되었을 경우 알림

현재 채팅 중인 채팅방을 조회한다.
조회한 채팅방의 판매 상태와 중고 상품 게시글의 판매 상태를 거래 완료 상태로 DB를 업데이트한다.
구매자에게 알림을 전송한다.

3. 예약 취소되어 판매 상태가 판매 중 상태로 변경 되었을 경우 알림

현재 채팅 중인 채팅방을 조회한다.
조회한 채팅방의 판매 상태와 중고 상품 게시글의 판매 상태를 거래 완료 상태로 DB를 업데이트한다.
예약을 취소한 사용자를 제외하고 채팅으로 관심가졌던 사용자들에게 모두 알림을 전송한다.

 

이렇게 정리해서 확인해보았을 때 동작하는 알고리즘이 상당히 비슷하다는 사실을 알 수 있다.

  1. 채팅방 조회
  2. DB 업데이트
  3. 알림 전송

이 사실을 바탕으로 디자인 패턴 중 Template Method 패턴을 적용하여 다음과 같이 구성해 볼 수 있다.

 

결과

참고로 위에서 transaction -> productDeal로 rename 해주었다.

추상 클래스 정의

@RequiredArgsConstructor  
public abstract class ProductAlarmTemplate {  
    protected final ChatRoomService chatRoomService;  
    protected final ProductDealService productDealService;  
    protected final ProductDealProducer producer;  

    // Template Method
    public void sendAlarm(Long chatRoomId, Long memberId) {  
        ChatRoom chatRoom = chatRoomService.getChatRoom(chatRoomId, memberId);  
        update(chatRoom);  
        publishEvent(chatRoom);  
    }  

    protected abstract void update(ChatRoom chatRoom);  

    protected abstract void publishEvent(ChatRoom chatRoom);  
}

알람 클래스 구현

1. 거래 완료 알람

@Component  
public class CompleteDealProductAlarm extends ProductAlarmTemplate {  

    public CompleteDealProductAlarm(  
            ChatRoomService chatRoomService, ProductDealService productDealService, ProductDealProducer producer) {  
        super(chatRoomService, productDealService, producer);  
    }  

    @Override  
    public void update(ChatRoom closedChatRoom) {  
        this.productDealService.completeProductDeal(  
                closedChatRoom.getProductId(),  
                closedChatRoom.getId(),  
                closedChatRoom.getSellerId(),  
                closedChatRoom.getBuyerId(),  
                SellStatus.CLOSE);  
    }  

    @Override  
    public void publishEvent(ChatRoom closedChatRoom) {  
        // 판매자, 구매자에게 알람 전송  
        this.producer.publishForCompletedProductDeal(closedChatRoom);  
        this.producer.publishToBuyerForRequestReview(closedChatRoom);  

        // 그 밖의 채팅 요청한 사용자들에게 알람 전송  
        this.chatRoomService  
                .getNotClosedChatRoomsByProductId(closedChatRoom.getProductId())  
                .forEach(this.producer::publishForCompletedProductDeal);  
    }  
}

2. 예약 신청 알람

예약 신청 알람도 예약 취소 알람과 마찬가지로 예약 신청한 사용자를 포함하여 채팅으로 관심을 가진 모든 사용자들에게 알림을 보내도록 로직을 변경해주었다.

@Component  
public class RequestBookingProductAlarm extends ProductAlarmTemplate {  

    public RequestBookingProductAlarm(  
            ChatRoomService chatRoomService, ProductDealService productDealService, ProductDealProducer producer) {  
        super(chatRoomService, productDealService, producer);  
    }  

    @Override  
    public void update(ChatRoom chatRoom) {  
        this.productDealService.updateSellStatus(chatRoom.getProductId(), chatRoom.getId(), SellStatus.BOOKING);  
    }  

    @Override  
    public void publishEvent(ChatRoom ongoingChatRoom) {  
        String message = generateMessage(ongoingChatRoom.getSeller().getNickname());  
        chatRoomService  
                .getNotClosedChatRoomsByProductId(ongoingChatRoom.getProductId())  
                .forEach(chatRoom -> {  
                    this.producer.publishForUpdatedSellStatus(chatRoom, message, AlarmType.REQUEST_BOOKING);  
                });  
    }  

    private String generateMessage(String nickname) {  
        return String.format(UPDATED_BOOKING_MESSAGE.getMessage(), nickname);  
    }  
}

3. 예약 취소 알람

@Component  
public class CancelBookingProductAlarm extends ProductAlarmTemplate {  

    public CancelBookingProductAlarm(  
            ChatRoomService chatRoomService, ProductDealService productDealService, ProductDealProducer producer) {  
        super(chatRoomService, productDealService, producer);  
    }  

    @Override  
    public void update(ChatRoom chatRoom) {  
        this.productDealService.updateSellStatus(chatRoom.getProductId(), chatRoom.getId(), SellStatus.ONGOING);  
    }  

    @Override  
    public void publishEvent(ChatRoom bookedChatRoom) {  
        String message = generateMessage(bookedChatRoom.getSeller().getNickname());  
        this.chatRoomService  
                .getNotClosedChatRoomsByProductId(bookedChatRoom.getProductId())  
                .forEach(chatRoom -> {  
                    this.producer.publishForUpdatedSellStatus(chatRoom, message, AlarmType.CANCEL_BOOKING);  
                });  
    }  

    private String generateMessage(String nickname) {  
        return String.format(UPDATED_ONGOING_MESSAGE.getMessage(), nickname);  
    }  
}

API 수정

쿼리 파라미터로 이벤트 종류를 입력받아 구현한 알람 클래스를 선택하여 로직을 수행하도록 수정할 수 있다.

@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);  
}

정리

  • 디자인 패턴 중 Template Method 패턴을 적용하여 확장성과 유지보수성을 개선시켰고, API도 2개에서 1개로 구현하면서 복잡성을 최소화할 수 있었다.
  • 아쉬운 점이 있다면 구현한 모든 알람 클래스를 의존하여, 이후 추가될 경우 의존하는 알람 클래스가 많아진다는 점이다. 이러한 점을 Spring의 프론트 컨트롤러에서 사용할 핸들러를 찾아서 가져오는 것처럼 사용할 알람 클래스를 찾아서 가져오는 방법을 구상해보면 해결할 수 있을 것 같다.
  • 디자인 패턴을 초반에 적용하면 오히려 이후에 기능을 추가할 때 복잡성이 커보이는 것 같다. 따라서 폭발적으로 최대한 기능을 구현한 후, 개발 속도가 더뎌질 때 디자인 패턴을 적용하는 것이 좋은 방향이라는 생각이 든다.
728x90