[Spring | 댕댕살롱] FCM 푸시 알림 기능 구현
안녕하세요 오랜만입니다.
최근에 제가 듣고 있는 유레카에서 진행한 최종 융합 프로젝트에 집중하느라 주기적으로 풀고 작성했던 알고리즘 글을 제외하면 엄청 오랜만에 글을 쓰는 것 같습니다.
이번 프로젝트를 통해 배운 것도 많고 느낀 점도 많아서 하나하나 천천히 정리해보려고 합니다.
프로젝트
12월 24일 프로젝트 발표와 함께 유레카 수료도 했고
11월 12일 ~ 12월 24일 (약 6주) 동안 진행했던 프로젝트에서 저는 견적, 알림, 결제, Ai 기능을 맡았습니다.
이번 글에서는 알림 기능 구현 과정과 고민했던 점에 대해 작성해보려합니다.
Ureca Dangdang Salon
Ureca Dangdang Salon has 2 repositories available. Follow their code on GitHub.
github.com
위는 프로젝트 깃헙 링크이고 먼저 저희 프로젝트 반려견 미용 중계 서비스에 대해 간단히 설명하자면
사용자는 원하는 반려견 미용 서비스에 대한 견적 요청을 진행하며 미용사는 이를 바탕으로 견적서를 제출합니다. 이를 통해 사용자와 미용사가 쉽게 연결될 수 있으며 사용자는 다양한 견적서를 비교하여 합리적인 가격으로 적합한 반려견 미용 서비스를 선택할 수 있는 플랫폼을 제공합니다.
알림 서비스
여기서 제가 구현해야하는 알림 서비스는
- 알림 조건:
- 고객:
- 새로운 견적서 도착 (푸시) 알림.
- 예약일 하루 전 (푸시, 이메일) 알림.
- 리뷰 작성 (푸시) 알림.
- 미용사:
- 새로운 견적 요청 도착 (푸시) 알림.
- 결제 완료 (푸시) 알림.
- 추가 기능:
- FCM 구독 알림
- 이벤트 1시간 전(푸시) 알림
- 고객:
위 조건에 맞게 고객과 미용사에 맞는 알림이 잘 가도록 설계해야했습니다.
알림 기능 구현이 처음이였고 어떻게 구현해야 할 지 고민하던 중 Web Socket과 SSE로 추렸는데
저희 프로젝트에서는 서버에서 알림 정보만 클라이언트에 보내주면 되기 때문에
양방향 통신인 Web Socket보다는 단반향 통신 SSE로 구현하면 되겠다라는 생각을 가졌습니다.
하지만 멘토링에서 멘토님께 알림 구현에 대해서 물어봤고
FCM(Firebase Cloud Messaging)에 대해 알게 되었고 SSE와 비교를 해보고 FCM이 이번 프로젝트에서 좀 더 적합하다고 생각하고 FCM으로 실시간 알림 기능을 구현을 하게 되었습니다.
FCM 선택 이유
첫 번째 이유는 SSE와 달리 FCM은 백그라운드에서도 알림을 지원하기 때문입니다.
저희 서비스에서는 새로운 견적서나 견적 요청에 대한 알림이 백그라운드에서도 사용자에게 전달되어야 서비스의 완성도가 높아질 것이라고 판단했습니다.
두 번째 이유는 이번 프로젝트에서 프론트엔드와 협업하여 웹앱 형태로 개발하기로 결정했기 때문입니다.
휴대폰에서도 알림을 쉽게 확인할 수 있는 기능이 필요하다고 생각했으며, FCM은 안드로이드, iOS, 웹 등 다양한 플랫폼 사용자에게 메시지를 보낼 수 있는 완전한 기능을 제공한다는 점에서 적합한 선택이라고 판단했습니다.
FCM 전체 플로우
- 클라이언트 -> firebase : 토큰 요청 (firebase가 클라이언트를 식별하는 수단)
- 클라이언트 -> 서버 : 토큰 전달
- 서버 : user_id와 FCM 토큰을 맵핑해서 DB에 저장
- 서버에서 이벤트 발생시 알림을 전달해줄 클라이언트의 FCM 토큰을 DB에서 가져옵니다
- 서버에서 해당 FCM 토큰과 알림을 firebase에 전달
- firebase에서 해당 FCM 토큰에 해당하는 클라리언트한테 알림을 전달 (푸쉬알림)
FCM 알림 기능 구현의 경우 프론트와의 협업이 중요했습니다
구현 순서
- 파이어베이스 회원가입 후 프로젝트 생성
- 웹 앱 등록
- 프로젝트 설정 → 서비스 계정 → 새 비공개 키 생성
스프링 프로젝트에서 gradle을 추가해줍니다.
implementation 'com.google.firebase:firebase-admin:9.4.1'
비공개 키 static 폴더 아래에 .json으로 파일 저장 (현재 인코딩해서 환경 변수 사용으로 바꿈)
이 과정에서 비공개 키는 그대로 깃헙에 올리면 보안 문제가 생겨서 알아보는데
관련 글이 거의 없어서 어려움이 있었는데 .json 파일을 base64 인코딩하여 저장해 초기화할 때
디코딩해주는 방법으로 해결했습니다.
그 결과 github secret에 쉽게 저장할 수 있었습니다.
FCM 초기화 코드
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Base64;
@Slf4j
@Component
public class FCMInitializer {
@Value("${firebase.service-account-key}")
private String base64EncodedKey;
@PostConstruct
public void initialize() {
try {
if (base64EncodedKey == null || base64EncodedKey.isEmpty()) {
throw new IllegalArgumentException("Firebase Service Account Key가 설정되지 않았습니다.");
}
// Base64 디코딩
byte[] decodedKey = Base64.getDecoder().decode(base64EncodedKey);
// Firebase 초기화
try (InputStream credentialsStream = new ByteArrayInputStream(decodedKey)) {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(credentialsStream))
.build();
if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(options);
log.info("FirebaseApp initialization complete");
}
}
} catch (Exception e) {
log.error("FirebaseApp 초기화 중 오류 발생", e);
}
}
}
메시지 보내기
public void sendNotificationWithData(String token, String title, String body, String type, Long referenceId) {
try {
// 메시지 구성
Message message = Message.builder()
.setToken(token)
.setNotification(Notification.builder()
.setTitle(title) // 알림 제목
.setBody(body) // 알림 내용
.build())
.putData("type", type)
.putData("referenceId", String.valueOf(referenceId))
.build();
// 메시지 전송
String response = FirebaseMessaging.getInstance().send(message);
log.info("FCM 알림 전송 성공" + response);
} catch (FirebaseMessagingException e) {
log.error("FCM 알림 전송에 실패했습니다.", e);
}
}
초반에 작성했던 코드입니다.
생각보다 간단하게 구현할 수 있었습니다.
이렇게 백엔드만 구현해서 테스트해봤을 때는 로그도 잘 찍히고 FCM 알림 전송을 성공한 걸 확인할 수 있었습니다.
백그라운드 알림 트러블 슈팅
문제는 프론트와 합치면서 생겼습니다.
백그라운드 알림이 1번 와야하는데 계속 2번 오는 버그가 있었습니다.
이 문제는 백엔드 문제인지 프론트 문제인지 명확하지도 않고 관련 문제를 해결한 글이 거의 없어서 고치는데 어려움을 겪었습니다.
일단 백그라운드 알림을 받기 위해서는 프론트에서 서비스 워커를 생성해줘야합니다.
근데 그 서비스 워커가 2개인 것을 확인하고 서비스 워커가 2개여서 알림이 2번 오는 버그가 생겼다고 생각해
프론트분한테 부탁해 서비스 워커를 1개로 만들었는데도 문제가 해결되지 않았습니다.
서비스 워커는 위 사진처럼 관리자 도구 Application에서 확인할 수 있습니다.
계속 열심히 찾아본 결과 FCM에서 paload에 notification 항목에 데이터를 보낼 경우 자동으로 알림 객체를 감지하여 알림 메시지를 기본으로 생성해준다는 사실을 알았습니다.
// 메시지 구성 (data 전용)
Message message = Message.builder()
.setToken(token)
.putData("title", title) // 알림 제목
.putData("body", body) // 알림 내용
.putData("type", type)
.putData("referenceId", String.valueOf(referenceId))
.build();
필요한 데이터는 putData로 보낼 수 있다고 해서
예를 들어 견적서 알림이면 type은 estimate, referenceId는 1로 프론트에서 무슨 알림인지 쉽게 구분가능하고
해당 상세 페이지로 이동할 수 있게 데이터를 넘겨주고 있었는데 알림 메시지 구성도 setNotification -> putData로 바꾸고
프론트에서도 알림 데이터를 data로 받아서 처리하도록 바꿔주니 문제를 해결할 수 있었습니다.
(해결하기 힘들었다. 하루 걸렸..)
사용자에 맞게 알림 보내기
import com.dangdangsalon.domain.estimate.request.entity.EstimateRequest;
import com.dangdangsalon.domain.groomerprofile.entity.GroomerProfile;
import com.dangdangsalon.domain.notification.service.NotificationService;
import com.dangdangsalon.domain.notification.service.RedisNotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class GroomerEstimateRequestNotificationService {
private final NotificationService notificationService;
private final RedisNotificationService redisNotificationService;
@Async
public void sendNotificationToGroomer(EstimateRequest estimateRequest, GroomerProfile groomerProfile) {
Long userId = groomerProfile.getUser().getId();
List<String> fcmTokens = notificationService.getFcmTokens(userId);
String title = "새로운 견적 요청";
String body = "새로운 견적 요청이 도착했습니다. 확인하세요.";
boolean isNotificationSent = false;
for (String fcmToken : fcmTokens) {
if (notificationService.sendNotificationWithData(fcmToken, title, body, "견적 요청", estimateRequest.getId())) {
isNotificationSent = true;
}
}
if (isNotificationSent) {
redisNotificationService.saveNotificationToRedis(userId, title, body, "견적 요청", estimateRequest.getId());
}
}
}
위 코드처럼 유저 아이디를 통해 fcm 토큰을 조회해서 비동기로 푸시알림을 보내고
알림 정보는 Redis에 저장했습니다.
(Redis 활용 이유는 다음 글에서 작성하겠습니다.)
추가 구현으로 구현한 알림 기능을 제외하면 똑같은 구조로 보내면 됐기 때문에
하나 구현해 두면 나머지는 알림 내용만 바꾸면 되기 때문에 크게 어렵지 않았습니다.
하지만 리뷰 작성 알림은 배달 앱처럼 30분 정도 지나면 리뷰 작성해달라는 알림이 갈도록 만들고 싶었습니다.
미용사가 미용 완료 버튼을 누르고 30분 뒤 알림이 가도록 설계했습니다.
@Transactional
public void updateEstimateStatus(Long estimateId){
Estimate estimate = estimateRepository.findById(estimateId)
.orElseThrow(() -> new IllegalArgumentException("견적서를 찾을 수 없습니다: " + estimateId));
estimate.updateStatus(EstimateStatus.ACCEPTED);
if (estimate.getStatus() == EstimateStatus.ACCEPTED) {
Long userId = estimate.getEstimateRequest().getUser().getId();
notificationService.scheduleReviewNotification(userId, estimateId);
}
}
public void scheduleReviewNotification(Long userId, Long estimateId) {
String key = "review_notification:" + estimateId;
ReviewNotificationDto reminderData = ReviewNotificationDto.builder()
.userId(userId)
.estimateId(estimateId)
.scheduledTime(LocalDateTime.now().plusMinutes(30).toString()) // 미용사가 미용완료 누르고 30분 뒤
.build();
try {
redisTemplate.opsForValue().set(key, new ObjectMapper().writeValueAsString(reminderData));
} catch (JsonProcessingException e) {
throw new RuntimeException("알림 예약 데이터 저장 중 오류 발생", e);
}
}
먼저 미용사가 '미용 완료' 버튼을 클릭하면, 해당 시간과 30분 후 시간을 Redis에 저장하고
알림 전송에 필요한 정보를 함께 저장하는 기능을 구현했습니다.
@Transactional
@Scheduled(cron = "0 * * * * ?")
public void sendReviewReminders() {
Set<String> keys = redisTemplate.keys("review_notification:*");
if (keys != null) {
for (String key : keys) {
try {
String jsonData = redisTemplate.opsForValue().get(key);
ReviewNotificationDto reminderData = objectMapper.readValue(jsonData, ReviewNotificationDto.class);
LocalDateTime scheduledTime = LocalDateTime.parse(reminderData.getScheduledTime());
if (LocalDateTime.now().isAfter(scheduledTime)) {
Long userId = reminderData.getUserId();
Long estimateId = reminderData.getEstimateId();
Estimate estimate = estimateRepository.findWithEstimateById(estimateId)
.orElseThrow(() -> new IllegalArgumentException("견적서가 없습니다: " + estimateId));
String title = "리뷰 작성 요청";
String body = estimate.getGroomerProfile().getName() + "님에 대한 리뷰를 작성해주세요!";
List<String> fcmTokens = notificationService.getFcmTokens(userId);
boolean isNotificationSent = false;
for (String fcmToken : fcmTokens) {
if (notificationService.sendNotificationWithData(fcmToken, title, body, "REVIEW_REQUEST", estimateId)) {
isNotificationSent = true;
}
}
// Redis에 알림 데이터 저장 (한 번만 저장)
if (isNotificationSent) {
redisNotificationService.saveNotificationToRedis(userId, title, body, "REVIEW_REQUEST", estimateId);
}
// Redis에서 예약 데이터 삭제
redisTemplate.delete(key);
}
} catch (Exception e) {
log.error("리뷰 작성 알림 전송 중 오류 발생: " + key, e);
}
}
}
}
그 뒤 1분마다 스케줄러를 돌려서 리뷰 작성 알림을 보내고 redis 데이터는 삭제하는 로직을 구현했습니다.
위 코드들을 보면 "왜 FCM 토큰을 리스트로 받지?"라는 의문이 생길 수 있습니다.
이번 프로젝트에서 FCM 알림 기능을 구현하며, 특히 FCM 토큰 관리에 대해 고민했던 부분들을 설명드리겠습니다
FCM 토큰 관리
저장 장소
처음에는 FCM 토큰이 자주 변경되고 수명이 짧다고 생각해서 Redis에 저장했습니다.
하지만 FCM 토큰은 앱을 삭제하거나 디바이스를 변경하지 않는 이상 자주 바뀌지 않는다는 사실을 공식 문서를 통해 알게 되었습니다.
그 후 MySQL에 저장해야 할지 고민하던 중 멘토링을 받았고 멘토님께서 MySQL에서 관리하는 것이 더 적합하다고 조언해 주셔서 최종적으로 MySQL을 선택하게 되었습니다.
한 사용자가 여러 디바이스에서 로그인했을 때의 경우
저장 방식을 결정한 후 가장 먼저 고려한 것은 한 사용자가 여러 디바이스에서 로그인했을 때의 경우였습니다.
저희 사이트는 웹앱 형태로 제공되며, 사용자가 컴퓨터와 휴대폰에서 동시에 로그인할 수 있습니다.
이때 FCM 토큰은 디바이스마다 각각 생성되므로, 컴퓨터와 휴대폰 모두에서 알림을 받을 수 있도록 하는 것이 중요하다고 생각했습니다.
(추후 알게된 사실은 브라우저마다 토큰이 다르게 생성되는 걸 확인할 수 있었습니다.)
이를 위해 위 사진처럼 1:N 관계로 한 사용자가 여러 개의 FCM 토큰을 가질 수 있도록 테이블을 설계했습니다.
그 결과
위 사진처럼 컴퓨터와 휴대폰에서 동시에 알림이 잘 오는 것을 확인할 수 있었습니다.
한 사용자가 동일한 디바이스에서 여러 계정으로 로그인하는 경우
두 번째로 고려한 것은 한 사용자가 동일한 디바이스에서 여러 계정으로 로그인하는 경우였습니다.
이 상황에서 잘못 설계하면 같은 FCM 토큰이 서로 다른 userId와 함께 저장되어 저희 서비스에서 동일한 디바이스에서
사용자와 미용사 두 가지 계정으로 로그인을 했을 경우 알림이 분리되지 않고 사용자와 미용사의 알림이 섞여서 전달되는 문제가 생길 우려도 있었습니다.
이 경우를 방지하기 위해
@Transactional
public void saveOrUpdateFcmToken(Long userId, String token) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId));
Optional<FcmToken> existingToken = fcmTokenRepository.findByFcmToken(token);
// 한 사람이 똑같은 디바이스로 여러 계정으로 로그인 시 기존 FCM 토큰 삭제 후 저장 로직(한 사람이 여러 계정 사용 가능)
if (existingToken.isPresent()) {
FcmToken tokenToUpdate = existingToken.get();
if (!tokenToUpdate.getUser().getId().equals(userId)) {
// 다른 사용자와 연결된 경우 삭제
fcmTokenRepository.delete(tokenToUpdate);
} else {
// 동일한 사용자와 연결된 경우 갱신
tokenToUpdate.updateTokenLastUserAt();
return;
}
}
// 새로운 토큰 생성 및 저장
FcmToken newToken = FcmToken.builder()
.fcmToken(token)
.user(user)
.lastUserAt(LocalDateTime.now())
.build();
fcmTokenRepository.save(newToken);
}
위처럼 똑같은 토큰이 있을경우 기존 토큰은 삭제 후 저장했습니다.
그 결과 마지막으로 로그인한 계정에 대해서만 알림이 가는 것을 확인할 수 있었습니다.
비활성 토큰 관리
마지막으로는 비활성 토큰 관리였습니다.
FCM 등록 토큰 관리를 위한 권장사항 | Firebase Cloud Messaging
2024년 데모 데이에서, Firebase를 사용하여 AI 기반 앱을 빌드하고 실행하는 방법에 관한 데모를 시청하세요. 지금 시청하기 의견 보내기 FCM 등록 토큰 관리를 위한 권장사항 컬렉션을 사용해 정리
firebase.google.com
위 공식 문서를 참고하여 이 부분을 구현해보았습니다.
위에서 언급한 FCM 테이블에 최근 사용 시간이라는 컬럼이 포함된 이유가 궁금하셨을 겁니다.
저희는 사용자가 로그인할 때 FCM 토큰을 클라이언트에서 서버로 전송하도록 구현했으며
이 과정에서 최근 사용 시간을 업데이트하도록 설정했습니다.
또한 60일 동안 최근 사용 시간이 업데이트되지 않은 토큰은 웹사이트를 더 이상 사용하지 않는
비활성 토큰으로 판단하여 데이터베이스에서 삭제하도록 처리했습니다.
/**
* 비활성 토큰 삭제 (60일 이상 업데이트되지 않은 경우)
*/
@Transactional
@Scheduled(cron = "0 0 0 * * ?") // 매일 자정 실행
public void removeInactiveTokens() {
List<FcmToken> inactiveTokens = fcmTokenRepository.findAll().stream()
.filter(token -> Duration.between(token.getLastUserAt(), LocalDateTime.now()).toDays() > 60)
.toList();
fcmTokenRepository.deleteAll(inactiveTokens);
}
이러한 형식으로 토큰의 신선도를 관리했습니다.
현재 최종 코드
@Transactional
public boolean sendNotificationWithData(String token, String title, String body, String type, Long referenceId) {
FcmToken fcmToken = fcmTokenRepository.findByFcmToken(token)
.orElseThrow(() -> new IllegalArgumentException("FCM 토큰을 찾을 수 없습니다: " + token));
User user = fcmToken.getUser();
if (Boolean.FALSE.equals(user.getNotificationEnabled())) {
log.info("알림 비활성화 상태로 알림 전송 건너뜀: " + user.getId());
return false; // 알림 비활성화 시 false 반환
}
// 메시지 구성 (data 전용)
Message message = Message.builder()
.setToken(token)
.putData("title", title) // 알림 제목
.putData("body", body) // 알림 내용
.putData("type", type)
.putData("referenceId", String.valueOf(referenceId))
.build();
try {
// 메시지 전송
String response = FirebaseMessaging.getInstance().send(message);
log.info("FCM 알림 전송 성공: " + response);
return true;
} catch (FirebaseMessagingException e) {
// 오류에 따라 FCM 토큰 삭제 처리
if (e.getMessagingErrorCode().equals(MessagingErrorCode.INVALID_ARGUMENT)) {
log.error("FCM 토큰이 유효하지 않습니다.", e);
deleteFcmToken(token);
} else if (e.getMessagingErrorCode().equals(MessagingErrorCode.UNREGISTERED)) {
log.error("FCM 토큰이 재발급 이전 토큰입니다.", e);
deleteFcmToken(token);
} else {
log.error("알림 전송 중 오류 발생", e);
}
return false;
}
}
@Transactional
public void saveOrUpdateFcmToken(Long userId, String token) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId));
Optional<FcmToken> existingToken = fcmTokenRepository.findByFcmToken(token);
// 한 사람이 똑같은 디바이스로 여러 계정으로 로그인 시 기존 FCM 토큰 삭제 후 저장 로직(한 사람이 여러 계정 사용 가능)
if (existingToken.isPresent()) {
FcmToken tokenToUpdate = existingToken.get();
if (!tokenToUpdate.getUser().getId().equals(userId)) {
// 다른 사용자와 연결된 경우 삭제
fcmTokenRepository.delete(tokenToUpdate);
} else {
// 동일한 사용자와 연결된 경우 갱신
tokenToUpdate.updateTokenLastUserAt();
return;
}
}
// 새로운 토큰 생성 및 저장
FcmToken newToken = FcmToken.builder()
.fcmToken(token)
.user(user)
.lastUserAt(LocalDateTime.now())
.build();
fcmTokenRepository.save(newToken);
}
public List<String> getFcmTokens(Long userId) {
return fcmTokenRepository.findByUserId(userId).stream()
.map(FcmToken::getFcmToken)
.toList();
}
public void deleteFcmToken(String token) {
fcmTokenRepository.deleteByFcmToken(token);
}
/**
* 비활성 토큰 삭제 (60일 이상 업데이트되지 않은 경우)
*/
@Transactional
@Scheduled(cron = "0 0 0 * * ?") // 매일 자정 실행
public void removeInactiveTokens() {
List<FcmToken> inactiveTokens = fcmTokenRepository.findAll().stream()
.filter(token -> Duration.between(token.getLastUserAt(), LocalDateTime.now()).toDays() > 60)
.toList();
fcmTokenRepository.deleteAll(inactiveTokens);
}
현재 코드는 토큰과 상관없이 서비스 안에서 알림을 끄고 킬 수 있어야 했기 때문에
알림 활성, 비활성을 체크 후 메시지를 보냈습니다.
유효하지 않은 토큰의 경우 MessagingErrorCode.INVALID_ARGUMENT 에러
재발급 이전의 토큰의 경우 MessagingErrorCode.UNREGISTERED 에러가 발생하고
이 두 경우는 데이터베이스에서 바로 삭제시켜줬습니다.
한 사용자가 여러 토큰을 가질 수 있기 때문에 조회는 List로 받았습니다.
이렇게 구현하여 FCM을 통한 푸시 알림은 잘 작동하는 것을 확인할 수 있었습니다.
정리
생각보다 백엔드에서 FCM 푸시 알림 구현은 간단했지만 토큰 관리에 신경을 많이 써야했고
프론트에서 토큰을 전달 받아야 했기 때문에 프론트와의 소통이 매우 중요했습니다.
또한 관련 글이 많지 않아 문제가 발생했을 때 어려움이 있었습니다.
토큰 관리에 관한 블로그 글들도 거의 비슷해서 공식 문서와 GPT를 활용하며 구현했는데
더 나은 방법이 있을 수도 있을 것 같습니다.
푸시 알림 기능을 처음 구현해보았고 여러 가지를 고민하며 구현에 성공해서 재미있었습니다.
다음 글에서는 푸시 알림을 Redis에 저장하여 알림 리스트를 보여주는 로직, 이메일 알림,
FCM 구독 기능(topic) 알림, 이벤트 1시간 전 알림에 대해 이어서 작성할 예정입니다.