사이드 프로젝트로 멘토링을 구하는 서비스를 만들고 있다. 모바일과 연동이 필요한 기능이 있었는데, 멘토링을 제안하거나 수락, 거절하게 되면 보낸 사람에게 푸시 메시지를 보내는 구조였다.
푸시 메시지는 firebase에서 제공하는 클라우드 메시징 기능을 사용하여 보내기로 했다. 메시지를 보내기 위해선 상대방의 디바이스 토큰이 필요했기 때문에 조회 로직이 반드시 들어가야 했다.
(fcm: Firebase Cloude Messaging)
그런데 코드를 작성하면서 이상한 점이 들었다.
문제인식
@Transactional
public void requestTutoring(RequestTutoringDto dto, TokenAccountInfo token) {
Tutoring tutoring =
Tutoring.builder()
.tutoringName(dto.getTutoringName())
.tutorId(dto.getTutorId())
.tuteeId(dto.getTuteeId())
.build();
tutoring.requestTutoring(RoleType.fromString(token.getAuthority()));
tutoringRepository.save(tutoring);
FcmToken fcmToken = fcmTokenRepository.findByAccountId(token.getAccountId());
fcmCloudeMessenger.sendToMessage(fcmToken, "튜터링 제안", token.getAccoutId() + "님이 튜터링을 제안하였습니다.");
}
- 튜터링을 생성하여 리포지터리에 넣는 메서드다. 그런데 fcmToken을 조회하여 cloundMessenger에게 메시지를 보내는 로직이 추가되어 책임이 늘어났다.
- 즉, 튜터링과 fcm 관련 로직간에 강결합이 발생했다.
어떻게 하면 좋을지 궁리하다 마침 JPA 강의에서 이벤트가 나온게 기억나 프로젝트에 적용해보려 한다.
이벤트를 왜 사용하지
이벤트의 사전적 의미는 어떤 사건이 발생했음을 의미한다. 스프링에서 이벤트는 해당 도메인의 상태가 변경되거나 하는 등의 행동을 전파하기 위해 사용된다.
튜터링을 처리하는 비즈니스 로직에서 fcm을 처리하는건 알 필요가 없다. 따라서 이번 단계에서는 튜터링 제안을 이벤트로 하여 fcm 처리 로직을 이벤트 리스너에서 구현하는걸 목적으로 삼았다.
이벤트는 어떻게 동작하지
ApplicationContext에 event publisher로 이벤트를 발행시키면 등록된 ApplicationEventListener나 @EventListener 메서드가 붙은 것들 중에서 인자 타입을 확인하고 맞는 객체를 전달하는게 추상화된 프로세스다.
이를테면, 튜터링 객체가 생성되었을 때 '튜터링 제안'이라는 이벤트를 publishing하면 application context가 등록된 이벤트 리스너들을 뒤져보고 인자 타입이 맞는 것에 전달하는 것이다.
이벤트 정의
@Getter
public class TutoringPublishedEvent extends ApplicationEvent {
private final Tutoring tutoring;
private final RoleType currentRole;
private String sendToAccountId;
private String fromToAccountId;
private String title;
private String body;
public TutoringPublishedEvent(Object source) {
super(source);
this.tutoring = (Tutoring) source;
this.currentRole = tutoring.getCurrentUserRole();
setSendToAccountId();
setPushMessage();
}
private void setSendToAccountId() {
if (currentRole == RoleType.ROLE_TUTOR) {
this.sendToAccountId = tutoring.getTuteeId().getAccountId();
this.fromToAccountId = tutoring.getTutorId().getAccountId();
} else if (currentRole == RoleType.ROLE_TUTEE) {
this.sendToAccountId = tutoring.getTutorId().getAccountId();
this.fromToAccountId = tutoring.getTuteeId().getAccountId();
} else {
this.sendToAccountId = null;
}
}
private void setPushMessage(){
TutoringStat stat = tutoring.getStat();
if(TutoringStat.WAITING_ACCEPT == stat){
title = fromToAccountId + "님이 튜터링을 제안하였습니다.";
body = "튜터링 이름: " + tutoring.getTutoringName();
}
else if(TutoringStat.LEARNING == stat){
title = fromToAccountId + "님이 튜터링 제안을 수락하였습니다.";
body = "이제부터 학습을 시작합니다!";
}
else if(TutoringStat.CANCEL == stat){
title = fromToAccountId + "님이 튜터링 제안을 거절하거나 취소하였습니다.";
body = "아쉽네요 ㅠㅠ";
}
else if(TutoringStat.COMPLETE_TUTORING == stat){
title = fromToAccountId + "님과의 튜터링이 종료되었습니다.";
body = "수고 많았습니다!";
}
}
}
- 도메인 이벤트를 구현하기 위해선 ApplicationEvent라는 추상 클래스를 상속받아야 한다.
- ApplicationEvent 클래스는 이벤트 객체를 직렬화 하기 위함과 timestamp를 제공하고 있다.
이벤트 리스너 정의
@Component
@RequiredArgsConstructor
public class FcmEventListener {
private final Logger logger = LoggerFactory.getLogger(FcmEventListener.class);
private final FirebaseCloudMessenger firebaseCloudMessenger;
private final JpaFcmTokenRepository fcmTokenRepository;
@EventListener
public void onApplicationEvent(TutoringPublishedEvent event) throws IOException {
String sendToAccountId = event.getSendToAccountId();
if(sendToAccountId == null || sendToAccountId.isEmpty()){
logger.info("[FcmEventListener] sendToAccountId is Null or Empty");
return;
}
String sendToToken =
fcmTokenRepository
.findFcmTokenByAccountId(sendToAccountId)
.orElseThrow(
() ->
new MediateNotFoundException(
String.format("[%s] 푸시 메시지를 받을 ID가 없습니다.", sendToAccountId)))
.getFcmToken();
firebaseCloudMessenger.sendMessageTo(sendToToken, event.getTitle(), event.getBody());
}
}
- 이벤트 리스너는 Bean으로 등록되어야 하므로 @Component를 달았다.
- @EventListener를 달아 ApplicationContext에 알려준다.
이벤트 적용으로 바뀐 기존 코드
public class Tutoring extends AbstractAggregateRoot<Tutoring> {
...
@Transient
private RoleType currentUserRole;
public void requestTutoring(RoleType roleType){
if(this.stat != TutoringStat.WAITING_ACCEPT){
throw new MediateIllegalStateException("수락 대기 중 상태가 아닙니다.");
}
this.currentUserRole = roleType;
publish();
}
// 이벤트 발행 메서드
private Tutoring publish() {
this.registerEvent(new TutoringPublishedEvent(this));
return this;
}
}
@Transactional
public void requestTutoring(RequestTutoringDto dto, TokenAccountInfo token) {
Tutoring tutoring =
Tutoring.builder()
.tutoringName(dto.getTutoringName())
.tutorId(dto.getTutorId())
.tuteeId(dto.getTuteeId())
.build();
tutoring.requestTutoring(RoleType.fromString(token.getAuthority()));
tutoringRepository.save(tutoring);
}
- AbstractAggregateRoot 클래스는 내부적으로 도메인 이벤트를 쌓아 놓다가 리포지토리에서 save를 하면 전부 보내도록 추상화 해놓았다.
개선된 점
- 튜터링 제안 로직에서 fcm 처리 로직을 분리했다.
한계
사실 내가 적용한 코드는 아직 문제가 있다.
- 트랜잭션이 중복되었다. 즉, 튜터링 제안 트랜잭션 내부에서 fcm 조회 트랜잭션이 돌기 때문에 조회에서 예외가 발생하면 전부 롤백된다.
- 동일 스레드에서 수행되기 때문에 이벤트 리스너에서 지연되면 결국 응답도 지연된다. 즉, 코드 상으로만 분리됐다.
한 번에 다 적용하기엔 도메인 이벤트 내용이 어렵고 방대해서.. 조금씩 개선해보려 한다.
'Spring' 카테고리의 다른 글
ArgumentResolver로 토큰에서 값 추출하기 (0) | 2022.02.10 |
---|---|
[Spring Boot + Security] @PreAuthorize로 토큰 확인하기 (0) | 2022.01.28 |
SpringBoot 중요한 설정 숨기기 (0) | 2022.01.12 |
SpringSecurity + CustomAuthenticationProvider 만들기 (0) | 2022.01.11 |
Filter, Interceptor, AOP 차이에 대한 정리 (0) | 2021.11.22 |