문제 상황과 원인
누군가 팔로우 신청을 걸면 팔로우 신청을 받은 사용자에게 push 알림을 보내야하는 상황이 있었다.
기존의 방식은 팔로우를 거는 메인 로직과 알림을 보내는 로직이 결합되어있어 여러 문제가 있었다.
@Transactional
public FriendResponseDto requestFriend(FriendRequestDto friendRequestDto, String accessToken) {
/* 팔로우 신청 메인 로직 */
Authentication authentication = getAuthByAccessToken(accessToken);
User to_user = userRepository.findByEmail(friendRequestDto.getEmail())
.orElseThrow(CUserNotFoundException::new);
User from_user = userRepository.findById(Long.parseLong(authentication.getName()))
.orElseThrow(CUserNotFoundException::new);
if (friendRepository.findByRequest(from_user, to_user, Status.ONGOING).isPresent() ||
friendRepository.findByRequest(from_user, to_user, Status.ACCEPT).isPresent()) {
throw new CFriendRequestOnGoingException();
}
Friend friendRequest = friendRequestDto.toEntity(from_user, to_user, Status.ONGOING);
/* 푸시 알림 전송 로직*/
FCMUserToken fcmEntity = fcmRepository.findByUserId(toUser)
.orElseThrow(CFCMTokenNotFoundException::new);
String fcmToken = fcmEntity.getFireBaseToken();
Message message = getFriendRequestPushMessage(fromUser.getName(), fcmToken);
firebaseMessaging.send(message);
return mapFriendEntityToFriendResponseDTO(friendRepository.save(friendRequest));
}
- push 알림을 보내는 부분은 여러 도메인에서 반복적으로 등장할 수 있다. 여러 도메인에서 사용해야하는데, 코드가 여기저기 산발적으로 흩어져있었다.
- 만약 push 알림 쪽에서 예외가 발생했을 때 메인 로직도 같이 rollback 되는 현상이 발생한다.
여러 요구사항들
앞선 문제들을 해결하기 전에, 먼저 고려해야하는 사항들이 있었다.
- 외부 기술을 의존하고 있는 경우, 외부 기술에 장애가 발생했을 시 가급적 우리 서비스의 운영에 큰 영향이 없을 것
- 푸시 알림 로직은 여러 도메인에서 공동으로 사용될 수 있다.
- 푸시 알림의 성공 여부가 메인 로직에 영향을 미치면 안 된다.
의존성 분리를 위한 spring event 도입
spring 에서는 spring event라는 장치를 통해서 도메인간 의존성을 분리시킬 수 있다.
동작 과정: 특정 event를 구독 -> event 발행 -> 구독 대상은 해당 event를 듣고(listen)있다가 발행되면 로직 수행
특정 이벤트에 필요한 정보들을 조합하여 'event' 로 만들고, event publisher를 통해 발행해주기만 하면 메인 로직에서 그 이벤트에 관련된 코드들을 신경 쓸 필요 없이 개발이 가능하다.
event 관련된 코드들은 event listener에 작업해주면 되므로, 추후에 메인 로직이나 서브 로직에서 변경 사항이 생겼을 시 서로 영향을 주지 않으면서 수정이 가능하다는 장점이 있다.
1. event 클래스 정의
@Getter
@AllArgsConstructor
public class FriendRequestPushEvent {
private Friend friend;
}
2. 메인 로직에서 eventPublisher를 통해 event 발행
@Service
@RequiredArgsConstructor
public class FriendService {
private final UserRepository userRepository;
private final FriendRepository friendRepository;
private final ApplicationEventPublisher eventPublisher; // (1) event publisher 추가
private final JwtProvider jwtProvider;
@Transactional
public FriendResponseDto requestFriend(FriendRequestDto friendRequestDto, String accessToken) {
Authentication authentication = getAuthByAccessToken(accessToken);
User to_user = userRepository.findByEmail(friendRequestDto.getEmail())
.orElseThrow(CUserNotFoundException::new);
User from_user = userRepository.findById(Long.parseLong(authentication.getName()))
.orElseThrow(CUserNotFoundException::new);
if (friendRepository.findByRequest(from_user, to_user, Status.ONGOING).isPresent() ||
friendRepository.findByRequest(from_user, to_user, Status.ACCEPT).isPresent()) {
throw new CFriendRequestOnGoingException();
}
Friend friendRequest = friendRequestDto.toEntity(from_user, to_user, Status.ONGOING);
eventPublisher.publishEvent(new FriendRequestPushEvent(friendRequest)); // (2) event 발행
return mapFriendEntityToFriendResponseDTO(friendRepository.save(friendRequest));
}
}
3. 발행된 event를 처리할 eventListener 정의
@Slf4j
@Component
@RequiredArgsConstructor
public class PushEventListener {
private final FirebaseMessaging firebaseMessaging;
private final FCMRepository fcmRepository;
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // (1) event listener
public void sendFriendRequestPushMessage(FriendRequestPushEvent friendRequestPushEvent) {
User toUser = friendRequestPushEvent.getFriend().getRequest_to();
User fromUser = friendRequestPushEvent.getFriend().getRequest_from();
FCMUserToken fcmEntity = fcmRepository.findByUserId(toUser)
.orElseThrow(CFCMTokenNotFoundException::new);
String fcmToken = fcmEntity.getFireBaseToken();
try {
Message message = getFriendRequestPushMessage(fromUser.getName(), fcmToken);
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
log.error("PUSH NOTIFICATION ERROR: {}", e.getMessage());
}
}
}
spring event를 적용하기 전에 생각해봐야할 포인트들
- publisher를 통해 뭔가 event를 발행하는 코드의 흐름 때문에 오해하기 쉽지만, 기본적으로 동기적으로 진행된다.
- 트랜잭션과 함께 사용해야한다면, 트랜잭션의 커밋 전/후, 완료 또는 롤백에 따라 동작을 고려해야한다.
위의 특징들 때문에 작업의 종류에 따라서 적용하는 방법이 달라질 수 있다.
우리 프로젝트에서 요구사항은 다음과 같았다.
- 팔로우 신청과 푸시 알림을 보내는 로직은 서로 연관되어있는 로직이라 보기는 어렵다.
- 팔로우 신청 작업의 관점에서는 굳이 알림 전송을 완벽히 보낼 때까지 기다릴 필요는 없다.
- 팔로우 신청이 실패하면 알림은 전송되면 안 되지만, 반대로 알림 전송이 실패한다고 팔로우 신청까지 취소되는 상황이 발생해서는 안 된다.
즉, 이러한 작업의 특징을 고려해봤을 때 event listener를 통해 수행되어야하는 작업은 부모 트랜잭션을 고려하면서, 비동기적으로 수행되어야 한다는 결론이 나왔다.
Transaction 전파 레벨과 @TransactionalEventListener
진행중인 트랜잭션에서 다른 트랜잭션이 참여할 때의 합류조건을 설정하는 방법으로 spring 에서는 transactional 전파 레벨을 제공한다. 또, 부모 트랜잭션의 진행 완료 여부를 통해 event를 실행하는 @TransactionalEventListener라는 게 있다.
@Slf4j
@Component
@RequiredArgsConstructor
public class PushEventListener {
private final FirebaseMessaging firebaseMessaging;
private final FCMRepository fcmRepository;
@Async // (3) 다음의 작업들을 비동기적으로 수행
@Transactional(propagation = Propagation.REQUIRES_NEW) // (1) 새로운 transaction으로 진행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // (2) 부모 transaction이 커밋된 이후에 작업 수행
public void sendFriendRequestPushMessage(FriendRequestPushEvent friendRequestPushEvent) {
User toUser = friendRequestPushEvent.getFriend().getRequest_to();
User fromUser = friendRequestPushEvent.getFriend().getRequest_from();
FCMUserToken fcmEntity = fcmRepository.findByUserId(toUser)
.orElseThrow(CFCMTokenNotFoundException::new);
String fcmToken = fcmEntity.getFireBaseToken();
try {
Message message = getFriendRequestPushMessage(fromUser.getName(), fcmToken);
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e) {
log.error("PUSH NOTIFICATION ERROR: {}", e.getMessage());
}
}
}
- 트랜잭션 전파레벨 중 REQUIRES_NEW 옵션은 부모 트랜잭션에 합류하는 것이 아닌 별도의 트랜잭션을 생성해서 진행하는 옵션이다. 이를 통해 푸시 알림의 실패 여부가 메인 로직까지 전파되지 않도록 방지할 수 있다.
- AFTER_COMMIT 옵션을 통해 부모 트랜잭션이 commit 된 이후에 작업을 수행시킬 수 있다. 이를 통해 팔로우 신청이 성공적으로 완료된 이후에 알림 전송을 보낼 수 있도록 만들 수 있다.
- (1), (2) 작업을 비동기적으로 수행할 수 있도록 만들어준다. 이를 통해 팔로우 신청 기능이 굳이 푸시 알림 발송이 끝날 때까지 대기하고 있을 필요가 없어졌다.
결론
spring event와 트랜잭션 전파 옵션, TransactionalEventListener를 통해 기존에 강하게 결합되어있던 팔로우 신청 로직과 푸시 알림 로직을 느슨하게 분리시킬 수 있는 효과를 얻게 되었다..!
Reference
https://mangkyu.tistory.com/292
[Spring] 스프링에서 이벤트의 발행과 구독 방법과 주의사항, 이벤트 사용의 장/단점과 사용 예시
이벤트(Event)는 매우 유용하지만 상당히 간과되는 기능 중 하나입니다. 작년에 아마존 CTO는 이벤트 드리븐 아키텍처로 가야 한다고 기조 연설을 하기도 했는데, 이번에는 스프링 프레임워크에서
mangkyu.tistory.com
https://kth990303.tistory.com/387
[Spring] REQUIRES_NEW 옵션만으론 자식이 롤백될 때 부모도 롤백된다
두 개 이상의 트랜잭션이 합류할 경우, 트랜잭션 전파레벨 옵션 설정으로 트랜잭션을 관리할 수 있다. 특히, 트랜잭션 전파레벨 중 REQUIRED, REQUIRES_NEW는 실제로도 꽤나 자주 쓰이는 옵션이라 알아
kth990303.tistory.com
https://kth990303.tistory.com/385
[Spring] @Transactional의 전파 레벨에 대해 알아보자
스프링에선 진행중인 트랜잭션에서 다른 트랜잭션이 참여할 때의 합류조건을 설정할 수 있는 Propagation 옵션이 존재한다. DB에서 자체적으로 제공해주는 트랜잭션 격리레벨과 다르게 전파레벨은
kth990303.tistory.com
https://tecoble.techcourse.co.kr/post/2022-11-14-spring-event/
스프링 이벤트 적용기
상황 스모디 프로젝트를 하면서 랭킹 기능을 도입하기로 했습니다. 랭킹 기능은 유저가 활동을 했을 때, 활동에 따라 랭킹 점수를 부여해야 합니다. 저희 서비스는 챌린지에 도전하고 매일 챌린
tecoble.techcourse.co.kr
'Project > Voice Pocket' 카테고리의 다른 글
[Voice Pocket] 더블 클릭 이슈 해결기 (0) | 2024.03.01 |
---|---|
[Voice Pocket] Message Queue 뒷 단으로 보낸 작업의 결과를 확인하는 법 (0) | 2024.03.01 |
[Voice Pocket] TTS 작업 속도 개선기 (0) | 2024.03.01 |
[Voice Pocket] spring boot에서 celery 활용하기 (1) | 2024.03.01 |
[Voice Pocket] 프로젝트 소개 (0) | 2024.02.29 |