RabbitMQ 비동기 처리와 데드레터 처리

진주원(JooWon Jin)·2024년 4월 21일
0

TWTW

목록 보기
8/8
post-thumbnail

문제 상황 & 접근


  1. FCM 오류 처리

그룹 초대 , 친구 요청 서비스를 수행하는데 있어 ‘알림 전송 서비스’를 구현할 때 FCM을 사용했다.
1. FCM 자체의 성능 개선을 위해 RabbitMQ을 사용함.
2. FCM 요청에 있어 오류가 발생할 경우 처리를 위해 데드레터 처리

RabbitMQ를 통한 비동기 전송

기존 Spring 서버에서 FCM 까지의 메시지 전송동기방식이기 때문에 짧은 시간에 많은 요청이 수행되는 경우 성능상 개선 필요, 메시지 큐를 사용하여 비동기 방식으로 처리

Message Type 정의

  • 이길저길 내에서 FCM을 통한 알림 전송은 크게 4가지 유형이 있다.
public enum NotificationType {
    FRIEND_REQUEST, // 친구 요청
    GROUP_REQUEST, // 그룹 참여 요청
    DESTINATION_CHANGE, // 목적지 변경
    PLAN_REQUEST // 만남 참여 요청
}

NotificationRequest 정의

    public NotificationRequest(
            final String deviceToken, final String title, final String body, final String id) {
        this.deviceToken = deviceToken;
        this.title = title;
        this.body = body;
        this.id = id;
    }

FcmProducer : RabbitMQ에 알림 메시지 저장

알림관련 정보 즉, rabbitmq로 발행할 메시지 정보들을 db에 저장한 이후 rabbitmq로 알림 발송을 위한 메시지를 발행한다.

@Component
@RequiredArgsConstructor
public class FcmProducer {

    private final RabbitTemplate rabbitTemplate;
    private final NotificationRepository notificationRepository;
    private final NotificationMapper notificationMapper;
		
		// 알림 전송 (DB 저장 후 큐로 전송)
    public void sendNotification(final NotificationRequest request, final NotificationType type) {
        final UUID id =
                notificationRepository
                        .save(
                                new Notification(
                                        request.getTitle(),
                                        request.getBody(),
                                        request.getId(),
                                        request.getDeviceToken(),
                                        type))
                        .getId();

        request.setNotificationId(id.toString());

        sendToQueue(request);
    }

		// 알림 메시지를 큐로 전송
    private void sendToQueue(final NotificationRequest request) {
        rabbitTemplate.convertAndSend(
                RabbitMQConstant.NOTIFICATION_EXCHANGE.getName(),
                RabbitMQConstant.NOTIFICATION_ROUTING_KEY.getName(),
                request);
    }
}

FcmCosumer : 큐에 저장된 메시지를 FCM 전송

  1. FCM을 통해 알림을 전송
  2. DB에 저장된 알림 Entity 조회
  3. 엔티티 존재 여부에 따른 처리
    • 존재하는 ID의 경우 : complete() 메서드를 통해 해당 메시지의 전송 완료 처리
    • 존재하지 않는 경우 : nack을 통해 재시도하지 않는다.
    @Transactional
    @RabbitListener(queues = "notification.queue")
    public void sendNotification(
            final NotificationRequest request,
            final Channel channel,
            @Header(AmqpHeaders.DELIVERY_TAG) final long tag)
            throws FirebaseMessagingException, IOException {
        firebaseMessaging.send(request.toMessage());

        final Optional<Notification> notification =
                notificationRepository.findById(UUID.fromString(request.getNotificationId()));

        if (notification.isPresent()) {
            notification.get().complete();
            return;
        }
        channel.basicNack(tag, false, false);
    }

(적용) 만남 초대 서비스에서의 사용

    @Transactional
    public PlanResponse invitePlan(PlanMemberRequest request) {
        Member member = authService.getMemberByJwt();
        Plan plan = getPlanEntity(request.getPlanId());
        plan.addMember(member);
				
				// 만남 초대 알림 전송
        sendRequestNotification(member.getDeviceTokenValue(), plan.getName(), plan.getId());

        return planMapper.toPlanResponse(plan);
    }

    private void sendRequestNotification(
            final String deviceToken, final String planName, final UUID id) {
        fcmProducer.sendNotification(
                new NotificationRequest(
                        deviceToken,
                        NotificationTitle.PLAN_REQUEST_TITLE.getName(),
                        NotificationBody.PLAN_REQUEST_BODY.toNotificationBody(planName),
                        id.toString()),
                NotificationType.PLAN_REQUEST);
    }

데드레터 전략 수립

데드 레터(Dead Letter)

소프트웨어 시스템에서 오류로 인해 처리할 수 없는 메시지

Dead Letter Queue

데드 레터 메시지를 임시로 저장하는 특수 유형의 메시지 큐

  • 기본적인 Message Queue는 FIFO 방식이다. 다음 메시지를 처리하기 위해서는 이전의 메시지들이 어떤 형태로든 처리되어야 한다.
  • 앞의 메시지에 장애가 발생할 경우 DLQ를 통해 유연한 처리가 가능하도록 해야 한다.

데드레터 파악 & 재시도 전략

  • 여러 단계에 걸친 RabbitMQ를 통한 로직은 없었으며, 로깅을 수행한 이후 개발자의 Slack으로 알림을 전송한다.
  • DB에 데드레터 처리 여부 및 요청 데이터를 갖는 테이블을 추가하여 상태 필드로 처리 여부 확인 및 RabbitMQ 장애를 파악한다.
    • RabbitMQ로 보내기 직전 DB에 데이터를 저장한다.
    • RabbitMQ로 처리 완료되면 해당 row의 상태를 업데이트한다.
  • 3초 간격으로 최대 2번 재시도한다.
    • Firebase 관련 로직이기에 치명적으로 오랜 기간 장애가 발생할 가능성은 적다고 판단해 일시적 오류를 고려하여 재시도 하도록 설정했다.

데드레터 발생 시 Slack 알림 처리

  1. 알림을 FCM으로 전송하는 RabbitMQ에서 오류 발생 시 DLQ로 메시지 전송

  1. 데드레터 발생 시 로그를 남기고 슬랙으로 예외 상황 정보를 전송

데드레터 발생 시 슬랙 알림

개선해볼 사항

메시지 유실 상황 고려

현재는 알림 Entity의 상태를 실패로 미리 저장해둔 다음 성공시 상태를 성공으로 업데이트
DB를 확인했을 때 일정 시간(대략 30초) 성공으로 업데이트되지 않으면 실패했다고 인지

관리자 권한 API

메시지가 유실된 경우 관리자 권한의 API를 생성하여 유실된 알림 재전송 구현

  • 모든 실패 상태의 알림을 발송하기에는 현재진행 중인 알림이 있을 수 있기에 id를 직접 받아 처리
  • 직접 관리자가 확인하여 수동으로 처리해야하기에 이 부분에 대한 개선 방안이 있을지 고민 중

관리자 권한 재전송 API

알림 발송 로직

이러한 수동적인 방법 말고 메시지 유실 자체를 방지하는 방법은?

  • 서칭해본 결과 saga 혹은 outbox 패턴 등이 있다고 하지만 러닝커브가 심할 것으로 예상되며 미숙한 숙련도에서 사용하기에 적합하지 않다고 판단했다.
  • 이후 역량을 더욱 키운 후에 시스템의 안정성을 위해 고려해보고 싶다.
profile
Young , Wild , Free

0개의 댓글