Push 알림 기능 개발기 (5) ‐ 알림 기능 FCM Spring Event로 교체하기 (feat.의존성, 트랜잭션) - YJGwon/connectruck GitHub Wiki

기본적인 동작 흐름

sequenceDiagram
    Client->>+Server: 주문 알림 구독 요청 with FCM 기기 토큰
		Server->>+DB: 구독 정보 저장
		DB-->>-Server: OK
		Server-->>-Client: 구독 정보 등록됨
		loop 주문 발생할 때 마다
			Server->>+DB: 구독 정보 조회
			DB-->>-Server: 구독 정보 with FCM 기기 토큰
			Server->>+FCM: 메시지 발송 요청 with FCM 기기 토큰
			FCM-->>-Client: 메시지 발송
			Note over Client: browser notification
    end

Loading

주문 - 알림 패키지 간 의존성 관리 with Spring Event

주문 도메인은 안정적으로 관리되어야 하는 서비스의 핵심 도메인이다. 또한 알림 도메인은 외부 인프라와 밀접하게 얽혀있으며 변경의 여지 또한 다분하다. 따라서 주문 → 알림이 아니라 알림 → 주문 방향으로 의존하는 것이 안정적이다.

실행 순서는 A → B이지만 A가 B 작업 대해 무엇도 알 필요 없게(인터페이스 조차 정의하지 않게) 하고 싶다면 Pub-Sub 모델을 활용할 수 있다. 알림쪽에서 주문 이벤트를 구독하고, 주문쪽에서는 알림에 관한 것은 알 필요 없이 그저 주문 이벤트를 발행하면 된다(알림 → 주문).

Spring에서는 Spring Application Event를 통해 Application Context 내의 pub-sub 매커니즘을 지원한다. 기존 SSE 방식에서는 구독 상태를 각 서버에서 관리하기 때문에 모든 서버가 event를 수신해야 했고, Redis Pub-Sub을 분산 이벤트 브로커로 활용했었다. 이와 달리 FCM은 구독 상태를 서버가 아닌 DB에 저장할 수 있다(기기 토큰 저장). 따라서 굳이 다른 서버에 이벤트를 발행하지 않아도 된다고 보고 Spring Application Event를 사용하기로 했다.

sequenceDiagram
	Client->>+OrderService: 주문 발생
	activate OrderService
	Note Over OrderService: 주문 처리
	deactivate OrderService
	OrderService->>OrderEventListener: publish application event (async)
	activate OrderEventListener
	OrderService-->>-Client: OK
	OrderEventListener->>+NotificationService: 알림 발송 로직 호출
	Note Over NotificationService: FCM 알림 발송 요청
	NotificationService-->>OrderEventListener: return
	deactivate OrderEventListener
Loading

이벤트 발행 로직 변경(feat. DIP)

앞서 Redis Pub-Sub을 통한 이벤트 발행을 구현할 때 OrderMessagePublisherOrderMessageHandler라는 interface를 만들어 DIP 원칙을 지켰다. event 관련 로직은 알림 발송 만큼이나 외부(= 비즈니스 영역 밖) 인프라 의존도가 높고 변경 가능성이 높기 때문에 핵심 비즈니스 영역으로부터 분리해야 하기 때문이다.

event 발행은 비즈니스 영역 → 외부 인프라 순으로, 구독은 외부 인프라 → 비즈니스 영역 순으로 처리된다. OrderMessagePublisher는 외부 인프라 사용 로직을 내부에 숨기는 역할, OrderMessageHandler는 외부 인프라로부터 내부 로직을 격리하는 역할을 맡는다. 이 상태에서 이벤트 발행 인프라를 교체하는 것은 두 단계면 된다.

  1. OrderMessagePublisher 구현체 교체
  2. 전달받은 message를 마샬링하여 OrderMessageHandler에 전달하는 로직 교체

실제로 외부 인프라로부터 전달받은 메시지를 비즈니스 영역에서 사용하는 객체로 변환하는 OrderEventListenerOrderMessagePublisher 구현체만 변경하여 Service에 영향을 주지 않고 이벤트 발행 로직을 교체할 수 있었다.

Class Dependancies

classDiagram
        ApplicationEventPublisher <-- SpringOrderMessagePublisher
	OrderMessagePublisher <|.. SpringOrderMessagePublisher : implements
	OrderService --> OrderMessagePublisher 
	EventListener <.. OrderEventListener
	OrderEventListener --> OrderMessageHandler 
	OrderMessageHandler <|.. OrderMessageHandlerImpl : implements
	OrderMessageHandlerImpl --> PushNotificationService

	class ApplicationEventPublisher
	class EventListener {
		<<annotation>>
	}

	class OrderMessagePublisher {
		<<interface>>
		+publish(OrderMessage message)* void
	}
	class OrderService
	class SpringOrderMessagePublisher 

	class  OrderMessageHandler {
		<<interface>>
		+handle(OrderMessage orderMessage)* void
	}
	class OrderEventListener
	class OrderMessageHandlerImpl
	class PushNotificationService
Loading

알림 발송 로직 변경 후 FCM 의존성 격리

알림 발송의 경우 기존 service 로직이 SseEmitter를 완전히 의존하고 있어 변경이 쉽지 않았다. 다시는 그런 대참사를 겪지 않기 위해 알림 발송에도 최대한 의존성 역전을 적용했다.

알림 발송에 필요한 객체는 네 가지로 정의할 수 있다.

  1. 알림 메시지 객체
  2. 알림 구독 정보 객체
  3. 알림 발송 객체
  4. 알림 발송 결과 객체

알림 메시지와 알림 발송 결과를 도메인 객체로 정의하고, 메시지 도메인과 구독 정보 도메인을 parameter로 받아 결과 도메인을 return하는 알림 발송 interface를 정의했다. 그 후 FCM을 통한 발송 로직을 해당 interface 구현체 내부로 캡슐화했다. 이렇게 해서 알림 발송 인프라에 대한 의존성도 비즈니스 영역으로부터 격리되었다.

Class Dependancies

classDiagram
	direction TB
        NotificationService --> PushSender
	PushSender <|.. FcmPushSender
	FcmPushSender --> FirebaseMessaging

	class NotificationService

	class  PushSender {
	    <<interface>>
	    +send(PushNotification pushNotification, List<PushSubscription> pushSubscriptions)* PushResult
	}
	class FcmPushSender
	class FirebaseMessaging

Loading

주문 - 알림 발송 트랜잭션 격리 with Spring Async

Spring Event를 사용하면 코드상의 의존성은 분리되지만 transaction이 분리되는 것은 아니다. 실제 transaction의 영향까지 격리하기 위해 주문 도메인 측의 주문 이벤트 발행을 비동기로 처리했다. 주문한 client는 알림 발송 로직의 처리 결과와 상관 없이 성공 응답을 먼저 받게 된다.

@Async를 사용할 때는 내부 호출에서 동작하지 않는 다는 점을 유의해야 한다. @Transactional, @Async 같은 AOP 기반 기능들은 proxy를 통해 적용되는데, 내부 호출 시 proxy를 타지 않기 때문에 동작하지 않는다.

thread pool 설정

# async thread pool
task:
  execution:
    pool:
      core-size: 8
      keep-alive: 60s # idle time before thread termination
    shutdown:
      await-termination: true
      await-termination-period: 30s
    thread-name-prefix: async-push-sender-

queue capacity 설정은 뺐다. 이렇게 되면 task가 밀릴 경우 thread를 늘리는 게 아니라 queue에 계속 추가하게 된다. 서비스 전체를 생각했을 때 알림 발송은 thread를 많이 할당해가며 빨리 처리해야 할 만큼 중요하다고 볼 수 없다. 알림 발송이 밀렸다면 그 보다 더 중요한 주문, (현재 구현되어 있진 않지만) 결제가 밀리고 있는 상황일 것이다. 이런 상황에 알림 발송 처리를 위해 thread를 늘리는 것은 좋지 않다.

graceful shutdown을 위한 설정도 추가했다. await-termination을 설정하지 않으면 executor 객체가 shutdown될 때 실행 중이던 task들도 함께 강제 종료되어버린다. await-termination-period는 container의 shutdown을 해당 시간 만큼 block할 수 있는 설정이다. await-termination을 설정했더라도 해당 값을 설정하지 않으면 spring container의 shutdown은 계속된다. 따라서 spring bean을 사용하는 task라면 설정해주는 것이 좋다.

⚠️ **GitHub.com Fallback** ⚠️