문제 상황
우테코에서 진행하고 있는 프로젝트에서 알림 기능 구현을 담당하게 되었다. 알림을 보내는 방법에 대해서는 PWA 기반의 웹 푸시 알림을 적용하게 되었는데 여기서 자세하게 이 내용을 다루지는 않고 설계에 관해서만 초점을 맞추고자 한다. 일단 알림을 보내는 클래스와 메서드 api만 간단하게 살펴보자.
webPushService.sendNotification(pushSubscription, pushNotification);
WebPushService의 sendNotification 메서드를 이용하면 이용자에게 알림을 보낼 수 있다.
- pushSubscription에는 pc 브라우저나 스마트폰 브라우저 등 알림을 수신할 브라우저에 대한 정보가 들어 있다.
- pushNotification에는 이용자에게 보낼 알림의 종류, 내용 등의 대한 데이터가 들어 있다.
알림을 보내야 하는 상황
알림 기능은 알림 기능 자체가 목적이라기 보단 다른 비즈니스 로직이 실행되는 도중이나 후에 함께 실행되는 경우가 많다. 우리 서비스 로직에서는 다음과 같은 상황에 알림이 보내져야 한다.
- 알림을 받겠다고 알림을 구독하는 경우 → ‘{nickname}님 알림이 구독되었습니다’
- 어떤 챌린지를 도전했을 때, 특정 시간에 알림을 예약한다.→ ‘{challengeName} 인증이 얼마 남지 않았어요~’
- (챌린지 도전 시 바로 알림을 보내는 것이 아닌 미래의 마감 시간 직전에 예약)
- 다른 사람의 피드에 댓글을 남긴 경우 → {nickname}님이 피드에 댓글을 남겼어요
알림을 보내기 위해 필요한 의존성
- 알림 구독의 경우
- WebPushService - 알림을 바로 보내기 위해
- PushNotificationService - 알림 내역을 ‘발송 완료’ 상태로 DB에 저장하기 위해
- 챌린지 인증 임박 알림의 경우
- PushNotificationService - 알림 내역을 ‘발송 예정’ 상태로 DB에 저장하기 위해
- 댓글 등록의 경우
- WebPushService - 알림을 바로 보내기 위해
- PushNotificationService - 알림 내역을 ‘발송 완료’ 상태로 DB에 저장하기 위해
살펴보면 필요한 의존성의 비슷하지만 조금 다른 부분도 있고, 알림 메시지를 만들기 위해 필요한 정보도 다 다르다.
알림 구독의 경우는 회원의 닉네임을 알아야 하고, 챌린지 알림에선 챌린지 이름을, 댓글 알림에선 피드 작성자의 닉네임을 알아야 한다.
가장 직관적인 구현 방법
가장 직관적이게 구현하려면 의존성을 다 넣고 각각에 상황에 맞게 그 자리에서 다 구현하는 것이다.
예를 들어 살펴보자.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PushSubscriptionService {
private final PushSubscriptionRepository pushSubscriptionRepository;
private final MemberService memberService;
// 아래부터 알림에 필요한 의존성
private final WebPushService webPushService;
private final PushNotificationService pushNotificationService;
@Transactional
public void subscribe(TokenPayload tokenPayload, SubscriptionRequest subscriptionRequest) {
// 비즈니스 로직
// ...
// 알림 로직
PushNotification pushNotification = pushNotificationService.register(buildNotification(member));
webPushService.sendNotification(pushSubscription, pushNotification);
}
public PushNotification buildNotification(Member member) {
return PushNotification.builder()
.message(member.getNickname() + "님 스모디 알림이 구독되었습니다.")
.pushTime(LocalDateTime.now())
.pushStatus(PushStatus.COMPLETE)
.member(member)
.pushCase(PushCase.SUBSCRIPTION)
.build();
}
위와 같은 방법에는 여러 불편한 점이 등장한다.
- PushSubscriptionService는 사용자가 알림을 받고 싶을 때 알림을 구독하는 역할을 갖고 있다. 그런데 알림을 보내는 책임까지 맡으면서 핵심 로직이 한눈에 들어오지 않는다.
- 때문에 WebPushService와 PushNotificationService 등의 알림 관련 객체들과 결합이 강해졌다.
- 다른 서비스 로직에서도 알림을 보내야 한다면 위와 같은 의존성과 알림 관련 코드들이 추가될 것이다.
- 알림을 보내는 로직이 수정되어야 한다면 알림 관련 클래스로 가야 할 것 같은데 알림이 아닌 엉뚱한 클래스에서 알림 로직을 만져야 한다.
Spring Event 적용
우선 강한 의존과 결합을 분리시키기 위해 Spring의 ApplicationContext에서 제공하는 이벤트를 사용하기로 했다. 이벤트의 자세한 사용 법은 공식 문서를 참고하면 된다.
공식 문서에서는 ApplicationEvent 클래스를 상속하고 ApplicationListener 인터페이스를 구현하는 방법을 중점으로 살펴보고 있는데 여기서는 @EventListener 어노테이션과 ApplicationEventPublisher를 사용해서 간단하게 이벤트 로직을 적용시켜 볼 것이다.
PushEvent 생성
우선 PushEvent라는 알림 이벤트 그 자체를 생성한다. 이 객체에게 필요한 정보는 두 가지다.
- 알림 메시지를 만들기 위해 필요한 엔티티 객체
- 알림이 어떤 상황에서 일어났는지
- 알림 구독 상황에서 발행한 이벤트인지, 챌린지 도전 관련 알림 이벤트인지 판별하여 Object 타입인 엔티티를 적절한 타입으로 다운 캐스팅할 필요가 있다.
@Getter
public class PushEvent {
private final Object entity;
private final PushCase pushCase;
public PushEvent(Object entity, PushCase pushCase) {
this.entity = entity;
this.pushCase = pushCase;
}
}
이 클래스는 알림이라는 이벤트가 발생했을 때 정보를 전달해주는 매개체라고 생각하면 된다.
ApplicationEventPublisher로 이벤트 발행
아까 알림을 보내기 위해 다른 서비스 클래스와 의존을 맺고 알림을 직접 보내기까지 했었지만 지금은 단순히 ApplicationEventPublisher에게 위에서 만든 이벤트에 정보를 담아 이벤트를 발행하기만 하면 된다.
다른 서비스 클래스와 의존도 사라지고 알림 이벤트 발행도 한 줄에 끝났다.
이전 코드와는 다르게 알림을 누가 어떻게 보내는지에 대해 자세히 알고 있는 것이 아니라 알림에 대한 이벤트가 일어났다는 정보만 이 안에 담겨 있다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PushSubscriptionService {
private final PushSubscriptionRepository pushSubscriptionRepository;
private final MemberService memberService;
private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public void subscribe(TokenPayload tokenPayload, SubscriptionRequest subscriptionRequest) {
Member member = memberService.search(tokenPayload);
PushSubscription subscription = pushSubscriptionRepository.findByEndpoint(subscriptionRequest.endpoint)
.map(pushSubscription -> pushSubscription.updateMember(member))
.orElseGet(() -> pushSubscriptionRepository.save(subscriptionRequest.toEntity(member)));
applicationEventPublisher.publishEvent(new PushEvent(subscription, PushCase.SUBSCRIPTION));
}
의존과 결합이 줄어든 건 좋지만 이 이벤트는 누가 수신하며 알림은 어떻게 보내야 할까? 해답은 @EventListener에 있다.
@EventListener로 이벤트 수신
위의 알림 이벤트를 처리하기 위해선 @EventListener 어노테이션과 PushEvent 타입을 매개 변수로 받는 메서드를 만들면 된다.
여러 비즈니스 흐름에서 발행된 PushEvent라는 이벤트들을 이 메서드가 처리할 수 있게 된다.
이렇게 알림 이벤트의 발생과 이벤트 처리를 나눌 수 있었다.
@Component
@RequiredArgumentConstructor
public class PushEventListener {
private final WebPushService webPushService;
private final PushNotificationService pushNotificationService;
@EventListener
public void handle(PushEvent event) {
// 알림 보내는 로직 실행
}
}
다양한 알림 전송 전략
그런데 아직 해결되지 않는 문제가 있다. 알림을 보내야 하는 상황들에 대해 생각해 보자.
알림 구독, 챌린지 인증, 댓글 등 이미 3가지나 있고 서비스가 커짐에 따라 더 늘어날 수도 있다.
그럼 PushEventListener 클래스가 알림을 보내기 위해 PushEvent 안에 있는 PushCase에 따라 분기 처리를 하며 많은 로직을 다 가지고 있어야 할까?
@EventListener
public void handle(PushEvent event) {
PushCase pushCase = event.getPushCase();
if (pushCase == PushCase.SUBSCRIPTION) {
PushSubscription subscription = (PushSubscription)event.getEntity();
// ...
}
if (pushCase == PushCase.COMMENT) {
Comment comment = (Comment)event.getEntity();
// ...
}
// ...
}
이벤트를 이용해 알림의 발행과 알림 처리를 나눈 것이 옵저버 패턴이었다면 이번엔 전략 패턴을 적용해 보고자 한다.
전략 패턴 적용
우선 PushStrategy라는 인터페이스를 설계한다.
- push() - 알림을 보내는 메서드
- getPushCase() - 어떤 알림인지 get 하는 메서드
- buildNotification() - 알림 엔티티를 생성하는 메서드
public interface PushStrategy<T> {
void push(T entity);
PushCase getPushCase();
PushNotification buildNotification(T entity);
}
그리고 위 인터페이스를 구현한 여러 구현체들을 모두 스프링 빈으로 만든다.
@Component
public class ChallengePushStrategy implements PushStrategy<Challenge> {...}
@Component
public class SubscriptionPushStrategy implements PushStrategy<Subscription> {...}
@Component
public class CommentPushStrategy implements PushStrategy<Comment> {...}
위 구현체들은 모두 자신이 어떤 PushCase인지 알고 있으며 각 상황에 맞는 엔티티를 전달받아 알림 메시지를 만들고 PushNotification을 생성해서 알림까지 보내는 자신만의 구현 전략을 가지고 있다.
이제 이 같은 타입의 빈들을 PushEventListener에서 사용해 보자.
@Component
public class PushEventListener {
private final Map<PushCase, PushStrategy> pushStrategies;
public PushEventListener(List<PushStrategy> pushStrategies) {
this.pushStrategies = pushStrategies.stream()
.collect(toMap(
PushStrategy::getPushCase,
pushStrategy -> pushStrategy)
);
}
@EventListener
public void handle(PushEvent event) {
pushStrategies.get(event.getPushCase())
.push(event.getEntity());
}
}
- 우선 @AutoWired가 생략된 생성자에 List<PushStrategy> 타입으로 PushStrategy 타입의 모든 빈들을 주입받는다.
- PushStrategy의 getPushCase() 메서드를 통해 Map<PushCase, PushStrategy> 타입으로 전략들을 세팅해 준다.
- 알림 이벤트가 발행되어 handle() 메서드가 실행되면 PushEvent 안에 PushCase 정보로 알맞은 전략을 꺼내와 엔티티를 전달하며 알림 전송 로직을 실행시킨다.
이렇게 함으로써 각 알림 전략들에 대해서는 위 클래스에선 숨길 수 있고 다양한 알림 구현체들이 추가되더라도 이 클래스에는 변화가 생기지 않게 되었다. (OCP 만족)
특정 상황의 알림 로직을 고칠 때에도 XXXPushStrategy 구현체만 손보면 된다.
마치며..
위 방법들이 최선의 방법이라고는 단정 짓지 못하겠다. 더 좋은 방법이 있을 수도 있지만 프로젝트를 진행하며 고민하고 설계한 부분이기에 기록으로 남기고자 한다. 객체 지향에 대해 고민하는 시간을 가질 수 있어서 재밌는 시간이기도 했다!
참고
https://atoz-develop.tistory.com/entry/Spring-ApplicationEventPublisher를-이용한-이벤트-프로그래밍
'우아한테크코스 > 프로젝트-SMODY' 카테고리의 다른 글
운영 서버에서 예외 발생 시 자동 깃헙 이슈 생성 (0) | 2022.10.12 |
---|---|
[Spring] 동시성과 예외, 그리고 트랜잭션 (0) | 2022.10.01 |
[Spring] 알림 기능 비동기 처리하기 (0) | 2022.09.17 |
이벤트 트랜잭션 분리와 테스트에서의 @Transactional 문제 (0) | 2022.08.30 |
[JPA] 조회 쿼리 N+1 문제 해결 (0) | 2022.07.10 |