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
주문 도메인은 안정적으로 관리되어야 하는 서비스의 핵심 도메인이다. 또한 알림 도메인은 외부 인프라와 밀접하게 얽혀있으며 변경의 여지 또한 다분하다. 따라서 주문 → 알림
이 아니라 알림 → 주문
방향으로 의존하는 것이 안정적이다.
실행 순서는 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
앞서 Redis Pub-Sub을 통한 이벤트 발행을 구현할 때 OrderMessagePublisher
와 OrderMessageHandler
라는 interface를 만들어 DIP 원칙을 지켰다. event 관련 로직은 알림 발송 만큼이나 외부(= 비즈니스 영역 밖) 인프라 의존도가 높고 변경 가능성이 높기 때문에 핵심 비즈니스 영역으로부터 분리해야 하기 때문이다.
event 발행은 비즈니스 영역 → 외부 인프라
순으로, 구독은 외부 인프라 → 비즈니스 영역
순으로 처리된다. OrderMessagePublisher
는 외부 인프라 사용 로직을 내부에 숨기는 역할, OrderMessageHandler
는 외부 인프라로부터 내부 로직을 격리하는 역할을 맡는다. 이 상태에서 이벤트 발행 인프라를 교체하는 것은 두 단계면 된다.
-
OrderMessagePublisher
구현체 교체 - 전달받은 message를 마샬링하여
OrderMessageHandler
에 전달하는 로직 교체
실제로 외부 인프라로부터 전달받은 메시지를 비즈니스 영역에서 사용하는 객체로 변환하는 OrderEventListener
와 OrderMessagePublisher
구현체만 변경하여 Service에 영향을 주지 않고 이벤트 발행 로직을 교체할 수 있었다.
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
알림 발송의 경우 기존 service 로직이 SseEmitter
를 완전히 의존하고 있어 변경이 쉽지 않았다. 다시는 그런 대참사를 겪지 않기 위해 알림 발송에도 최대한 의존성 역전을 적용했다.
알림 발송에 필요한 객체는 네 가지로 정의할 수 있다.
- 알림 메시지 객체
- 알림 구독 정보 객체
- 알림 발송 객체
- 알림 발송 결과 객체
알림 메시지와 알림 발송 결과를 도메인 객체로 정의하고, 메시지 도메인과 구독 정보 도메인을 parameter로 받아 결과 도메인을 return하는 알림 발송 interface를 정의했다. 그 후 FCM을 통한 발송 로직을 해당 interface 구현체 내부로 캡슐화했다. 이렇게 해서 알림 발송 인프라에 대한 의존성도 비즈니스 영역으로부터 격리되었다.
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
Spring Event를 사용하면 코드상의 의존성은 분리되지만 transaction이 분리되는 것은 아니다. 실제 transaction의 영향까지 격리하기 위해 주문 도메인 측의 주문 이벤트 발행을 비동기로 처리했다. 주문한 client는 알림 발송 로직의 처리 결과와 상관 없이 성공 응답을 먼저 받게 된다.
@Async
를 사용할 때는 내부 호출에서 동작하지 않는 다는 점을 유의해야 한다. @Transactional
, @Async
같은 AOP 기반 기능들은 proxy를 통해 적용되는데, 내부 호출 시 proxy를 타지 않기 때문에 동작하지 않는다.
# 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라면 설정해주는 것이 좋다.