[Spring | 댕댕살롱] FCM 푸시 알림 추가 기능 구현(topic, kafka 활용)
[Spring | 댕댕살롱] FCM 푸시 알림 기능 구현
안녕하세요 오랜만입니다. 최근에 제가 듣고 있는 유레카에서 진행한 최종 융합 프로젝트에 집중하느라 주기적으로 풀고 작성했던 알고리즘 글을 제외하면 엄청 오랜만에 글을 쓰는 것 같습니
dlalstn1023.tistory.com
(고객과 미용사 푸시 알림은 위 글에 정리해두었습니다)
- 알림 조건:
- 고객:
- 새로운 견적서 도착 (푸시) 알림.
- 예약일 하루 전 (푸시, 이메일) 알림.
- 리뷰 작성 (푸시) 알림.
- 미용사:
- 새로운 견적 요청 도착 (푸시) 알림.
- 결제 완료 (푸시) 알림.
- 추가 기능:
- FCM 구독 알림
- 이벤트 1시간 전(푸시) 알림
- 고객:
이번엔 추가 기능으로 구현했던 FCM 구독 알림과 이벤트 1시간 전(푸시) 알림에 구현 과정을 정리해 보려고합니다.
FCM 주제 구독 메시징
FCM의 개별 토큰 알림 기능을 구현한 후 FCM을 활용하여 추가적으로 어떤 기능을 구현할 수 있을지 찾아보던 중
주제별 메시지 전송 기능에 대해 알게 되었습니다.
웹/JavaScript에서 주제로 메시지 보내기 | Firebase Cloud Messaging
2024년 데모 데이에서, Firebase를 사용하여 AI 기반 앱을 빌드하고 실행하는 방법에 관한 데모를 시청하세요. 지금 시청하기 의견 보내기 웹/JavaScript에서 주제로 메시지 보내기 컬렉션을 사용해 정
firebase.google.com
위 공식 문서를 확인하시면 주제로 메시지를 보내는 것이 무엇인지 확인할 수 있습니다.
이 기능을 프로젝트에서 어떻게 활용하면 좋을 지가 첫번째 고민이였습니다.
이벤트 쪽에 붙여서 구독을 한 사람들한테만 이벤트 시작 전 알림을 주는 방식과
콘테스트 쪽에 붙여서 구독을 하면 게시글이 올라올 때 새로운 게시글이 올라왔다는 알림을 보내주는 방식
2가지 의견을 냈고 조원들과 상의하여 콘테스트 쪽에 구독 알림 기능을 추가하였습니다.
위 화면에 새 글 알림이라는 기능을 추가하여 구독한 사용자들에게 귀여운 강아지 사진들이 올라오면 푸시 알림을 통해 바로 확인할 수 있도록 만들었습니다.
구독 및 구독 해제
구현은 크게 어렵지 않았습니다.
새 글 알림 활성화 클릭 시 클라이언트에서 토큰을 받아 구독을 해주었고
비활성화 클릭 시 똑같이 토큰을 받아 구독을 해제 시켜줬습니다.
/**
* 특정 주제에 FCM 토큰을 구독
*/
public void subscribeToTopic(String fcmToken, String topic) {
try {
FirebaseMessaging.getInstance().subscribeToTopic(List.of(fcmToken), topic);
log.info("토큰이 {} 주제에 성공적으로 구독되었습니다.", topic);
} catch (FirebaseMessagingException e) {
log.error("주제 구독 중 오류 발생: {}", e.getMessage(), e);
throw new RuntimeException("주제 구독에 실패했습니다.", e);
}
}
/**
* 특정 주제에서 FCM 토큰을 구독 해제
*/
public void unsubscribeFromTopic(String fcmToken, String topic) {
try {
FirebaseMessaging.getInstance().unsubscribeFromTopic(List.of(fcmToken), topic);
log.info("토큰이 {} 주제에서 성공적으로 구독 해제되었습니다.", topic);
} catch (FirebaseMessagingException e) {
log.error("주제 구독 해제 중 오류 발생: {}", e.getMessage(), e);
throw new RuntimeException("주제 구독 해제에 실패했습니다.", e);
}
}
메시지 보내기
메시지 보내는 코드도 개별 토큰으로 알림보내는 코드에서 토큰 대신 Topic을 보내면 되기 때문에
어렵지 않았습니다.
// 주제로 메시지 보내기
public void sendNotificationToTopic(String topic, String title, String body) {
Message message = Message.builder()
.setTopic(topic)
.putData("title", title) // 알림 제목
.putData("body", body) // 알림 내용
.build();
try {
String response = FirebaseMessaging.getInstance().send(message);
log.info("주제 '{}'로 메시지 전송 성공: {}", topic, response);
} catch (FirebaseMessagingException e) {
log.error("주제 '{}'로 메시지 전송 실패: {}", topic, e.getMessage(), e);
throw new RuntimeException("메시지 전송 실패", e);
}
}
트러블 슈팅
백엔드에서 구현 자체는 어렵지 않았지만 문제는 프론트엔드와 연동하는 과정에서 발생했습니다.
큰 문제는 아니었지만 위에 있는 사진처럼 새 글 알림을 표현하려면 프론트엔드에 전달할 적절한 상태값이 필요했습니다.
처음 구현할 때는 단순히 구독을 설정하고 메시지가 정상적으로 전송되는 로그만 확인했을 뿐이었습니다.
문제를 해결하기 위해 topic 테이블을 하나 만들어줬습니다.
추후 콘테스트 뿐만아니라 다른 구독 알림 기능을 구현할 가능성을 열어두고
1:N 관계로 사용자가 여러 구독을 할 수 있도록 설계했습니다.
@Transactional
public void subscribeToTopicInApp(String fcmToken, String topicName, Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다: " + userId));
subscribeToTopic(fcmToken, topicName);
Topic topic = topicRepository.findByTopicNameAndUser(topicName, user)
.orElse(Topic.builder()
.topicName(topicName)
.subscribe(true)
.user(user)
.build());
topic.updateSubscribe(true);
topicRepository.save(topic);
}
@Transactional
public void unsubscribeFromTopicInApp(String fcmToken, String topicName, Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다: " + userId));
unsubscribeFromTopic(fcmToken, topicName);
Topic topic = topicRepository.findByTopicNameAndUser(topicName, user)
.orElseThrow(() -> new IllegalArgumentException("구독 중인 주제를 찾을 수 없습니다."));
topic.updateSubscribe(false);
}
public boolean isSubscribed(String topicName, Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다: " + userId));
return topicRepository.findByTopicNameAndUser(topicName, user)
.map(Topic::getSubscribe)
.orElse(false);
}
처음 구독 시 데이터베이스에 저장해주었고 구독 해제 시에는 상태값을 false로 변경해주었습니다.
isSubscribed 함수를 통해 프론트엔드에게 적절한 상태값을 전달해주었습니다.
그 결과 화면과 같이 새 글 알림을 구현할 수 있었습니다.
개선 사항
앞서 작성한 글에서 언급했듯이 한 사용자가 여러 개의 토큰을 가질 수 있도록 FCM 테이블을 User 테이블과 1:N 관계로 설계했습니다. 이 덕분에 여러 디바이스에서 알림이 정상적으로 전달되는 것을 확인할 수 있었습니다.
하지만 이번에는 FCM 테이블과 Topic 테이블 간의 관계를 연결하지 않아 문제가 발생했습니다.
구독 알림의 경우 한 디바이스에서 구독을 설정하면 다른 디바이스로 로그인했을 때도 동일한 구독 상태로 나타나는 상황이 발생한 것입니다.
이에 따라 테이블 관계를 연결하여 구독 알림이 디바이스별로 구분되어 각 디바이스에 맞는 알림이 전달되도록 개선할 예정입니다.
이벤트 1시간 전 알림 (kafka 활용)
마지막으로, 이벤트 시작 1시간 전에 알림을 구현해야 했습니다. 단순히 FCM 푸시 알림을 보내는 수준을 넘어서, 이 기능에 추가적으로 적용할 수 있는 기술적인 요소를 고민해 보았습니다.
현재 채팅 시스템에서 Kafka를 활용하고 있는 점을 고려하여, 이벤트 알림 시스템에도 Kafka를 도입해보기로 했습니다. 이를 통해 다음과 같은 상황을 해결하고자 했습니다.
상황 가정
우리 서비스가 폭발적으로 성장하여 사용자 수가 급증하고 모든 사용자에게 이벤트 알림을 보낸다고 가정해 보았습니다. 기존 방식대로 모든 알림을 한꺼번에 전송할 경우 시스템에 큰 부하가 발생할 가능성이 큽니다.
Kafka를 활용한 해결책
Kafka를 사용하면 메시지를 안전하고 효율적으로 비동기 방식으로 처리할 수 있습니다. 이벤트 알림 메시지를 Kafka 토픽으로 발행하고 이를 여러 소비자가 구독하여 병렬로 처리함으로써 서버 부하를 분산시킬 수 있습니다. 또한 메시지 처리 실패 시에도 재처리가 가능해 안정성이 향상됩니다.
이와 같이 Kafka를 활용하면 대규모 사용자 알림 전송에 있어서 성능과 안정성을 동시에 확보할 수 있습니다.
시스템 설계 흐름
- Scheduler
스케줄러가 정해진 시간(이벤트 1시간 전)에 데이터베이스를 조회하여 필요한 이벤트 데이터(쿠폰 정보, 사용자 정보 등)를 가져옵니다. - Database 조회
데이터베이스(CouponEvent, FCM)에서 알림을 보낼 데이터를 추출합니다. 이 데이터는 이벤트 정보를 포함합니다. - Kafka Producer
데이터베이스에서 조회한 이벤트 데이터를 Kafka Producer를 통해 event-alerts 토픽에 발행합니다. - Kafka Consumer
Kafka의 Consumer가 event-alerts 토픽을 구독하고 해당 데이터를 소비합니다. - FCM Service
Consumer에서 처리된 데이터를 기반으로 FCM(Firebase Cloud Messaging) 서비스를 호출하여 푸시 알림을 준비합니다. - Push 알림 발송
최종적으로 사용자에게 FCM을 통해 알림이 전송됩니다. 알림은 사용자 앱에 표시되어 이벤트 참여를 유도합니다.
구현
@Scheduled(cron = "0 0 * * * *")
public void sendCouponNotifications() {
log.info("스케줄러 시작");
LocalDateTime now = LocalDateTime.now();
log.info("현재 시간 {}", now);
LocalDateTime oneHourLater = now.plusHours(1);
CouponEvent upcomingEvent = couponEventRepository.findUpcomingEvent(now, oneHourLater);
if (upcomingEvent == null) {
log.info("현재 1시간 이내에 시작하는 이벤트가 없습니다.");
return;
}
log.info("조회된 이벤트: {}", upcomingEvent.getStartedAt());
// FCM 토큰
List<String> fcmTokens = fcmTokenRepository.findAllByUserRole(Role.ROLE_USER);
if (fcmTokens.isEmpty()) {
log.warn("알림을 전송할 FCM 토큰이 없습니다.");
return;
}
// 이벤트 알림 생성 및 전송
String message = String.format("'%s' 이벤트가 1시간 후에 시작됩니다!", upcomingEvent.getName());
EventNotificationDto batchNotification = EventNotificationDto.builder()
.fcmToken(fcmTokens)
.title("쿠폰 이벤트 알림")
.message(message)
.referenceId(upcomingEvent.getId())
.build();
producer.sendEventNotification(batchNotification);
log.info("이벤트 '{}'에 대한 알림을 모든 사용자에게 일괄 전송했습니다.", upcomingEvent.getName());
}
import com.dangdangsalon.domain.notification.dto.EventNotificationDto;
import lombok.RequiredArgsConstructor;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class EventNotificationProducer {
private final KafkaTemplate<String, EventNotificationDto> kafkaTemplate;
public void sendEventNotification(EventNotificationDto notification) {
kafkaTemplate.send("event-alerts", notification);
}
}
import com.dangdangsalon.domain.notification.dto.EventNotificationDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class EventNotificationConsumer {
private final NotificationService notificationService;
@KafkaListener(topics = "event-alerts", groupId = "event-alerts-group")
public void consumeEventNotification(EventNotificationDto notification) {
// 여러 FCM 토큰에 대해 알림 한꺼번에 처리
if (notification.getFcmToken() != null && !notification.getFcmToken().isEmpty()) {
notification.getFcmToken().forEach(token -> {
try {
notificationService.sendNotificationWithData(
token,
notification.getTitle(),
notification.getMessage(),
"event",
notification.getReferenceId()
);
} catch (Exception e) {
log.error("토큰 {} 알림 전송 실패: {}", token, e.getMessage());
}
});
log.info("이벤트 알림을 {} 개의 토큰에 전송 완료", notification.getFcmToken().size());
} else {
log.warn("알림을 위한 FCM 토큰이 없습니다.");
}
}
}
코드를 살펴보면, 저희 서비스에서는 이벤트가 정각에 열리도록 설계되어 있습니다. 이를 기반으로 1시간마다 스케줄러를 실행하여 시작 예정인 이벤트가 있는지 데이터베이스에서 조회했습니다.
만약 시작 예정 이벤트가 있다면, 모든 사용자 토큰을 가져오고 이벤트 정보를 포함하여 Kafka Producer를 통해 event-alerts 토픽에 발행합니다. 이후, Kafka Consumer가 해당 event-alerts 토픽을 구독하고, 발행된 데이터를 소비하여 알림 처리를 진행합니다.
이 과정은 Kafka를 통해 비동기적으로 처리되기 때문에 대규모 사용자 알림 시스템에서도 안정성과 성능을 유지할 수 있습니다.
트러블 슈팅
첫 번째로, Kafka를 처음 도입하면서 발생한 어려움이 있었습니다. 기존에 사용해본 경험이 없었기 때문에
Kafka에 대한 기초적인 개념부터 공부해야 했고 설치 및 실행 과정까지 생각보다 많은 시간이 소요되었습니다.
두 번째로는 이벤트 조회 과정에서 배포 환경에서만 발생하는 버그가 있었습니다.
이벤트 데이터를 조회하는 단계에서 예상대로 데이터가 조회되지 않는 문제가 발생했고
원인을 거의 하루 동안 찾지 못해 많은 어려움을 겪었습니다.
문제를 분석하던 중 조회 시 테스트를 위해 플러스 시간을 10시간으로 설정해본 결과 배포된 데이터베이스의 시간이 Asia/Seoul이 아닌 다른 시간대로 설정되어 있다는 것을 발견했습니다.
(분명히 배포 당시 타임존을 설정했다고 생각했지만, 제대로 적용되지 않았던 것으로 보였습니다.)
이 문제는 AWS RDS의 타임존을 Asia/Seoul로 수정한 후 해결되었으며 이후 이벤트 조회가 정상적으로 동작하는 것을 확인할 수 있었습니다.
개선 사항
Kafka에 대해 진짜 기초만 해본 것이라 부족한 점이 많아 Kafka에 대해 더 깊이 공부한 후, 현재 작성한 코드를 다시 살펴보면 리팩토링이 필요한 부분들이 보일 것으로 생각됩니다.
또한 FCM 알림의 경우 한 번에 최대 500개의 토큰을 처리할 수도 있다고 알고 있는데 이을 활용하면 성능을 더욱 향상시킬 수 있을 것으로 보입니다. 현재는 개별적으로 처리하고 있지만 토큰을 500개씩 묶어서 전송하는 방식으로 개선한다면 전송 속도와 리소스 사용 효율을 높일 수 있을 것입니다.
정리
이번 글을 마지막으로 총 3개의 글을 통해 이번 프로젝트에서 구현했던 푸시 알림 시스템에 대해 정리해보았습니다.
프로젝트 동안 나름 열심히 고민하며 구현했고 현재는 잘 동작하고 있지만,글을 정리하며 돌아보니 몇 가지 아쉬운 점도 보였습니다. 특히 보완이 필요한 부분들이 명확히 드러나 리팩토링을 진행할 계획입니다.
처음으로 푸시 알림 기능을 구현하면서 많은 걱정을 했었지만 열심히 노력해서 결과적으로는 성공적으로 구현할 수 있었습니다. 앞으로도 개선과 학습을 통해 더욱 완성도 높은 시스템을 만들어가고 싶습니다.
이메일 알림의 경우 생각보다 간단해서 추후 시간이 된다면 간단하게 정리하도록 하겠습니다.