diff --git a/Dockerfile b/Dockerfile index 64429288..e7595bf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # 1. Base image -FROM openjdk:17-jdk-slim +FROM openjdk:21-jdk-slim # 2. Add JAR file ARG JAR_FILE=build/libs/ctc.jar diff --git a/build.gradle b/build.gradle index 1d1c1434..bf274e80 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(21) } } @@ -60,6 +60,10 @@ dependencies { annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + // Spring Integration + implementation 'org.springframework.boot:spring-boot-starter-integration' + implementation 'org.springframework.integration:spring-integration-core' } def querydslDir = "$buildDir/generated/querydsl" diff --git a/src/main/java/com/trinity/ctc/domain/category/entity/Category.java b/src/main/java/com/trinity/ctc/domain/category/entity/Category.java index 7f154cb3..c170d21e 100644 --- a/src/main/java/com/trinity/ctc/domain/category/entity/Category.java +++ b/src/main/java/com/trinity/ctc/domain/category/entity/Category.java @@ -2,14 +2,18 @@ import com.trinity.ctc.domain.restaurant.entity.RestaurantCategory; import com.trinity.ctc.domain.user.entity.UserPreferenceCategory; -import jakarta.persistence.*; - +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import java.util.ArrayList; import java.util.List; - import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; @Entity @NoArgsConstructor diff --git a/src/main/java/com/trinity/ctc/domain/fcm/entity/Fcm.java b/src/main/java/com/trinity/ctc/domain/fcm/entity/Fcm.java index 698073e0..2d68b210 100644 --- a/src/main/java/com/trinity/ctc/domain/fcm/entity/Fcm.java +++ b/src/main/java/com/trinity/ctc/domain/fcm/entity/Fcm.java @@ -38,4 +38,10 @@ public Fcm(String token, LocalDateTime registeredAt, LocalDateTime expiresAt, Us this.expiresAt = expiresAt; this.user = user; } + + public void renewFcmToken(String newToken, LocalDateTime registeredAt, LocalDateTime expiresAt) { + this.token = newToken; + this.registeredAt = registeredAt; + this.expiresAt = expiresAt; + } } diff --git a/src/main/java/com/trinity/ctc/domain/fcm/repository/FcmRepository.java b/src/main/java/com/trinity/ctc/domain/fcm/repository/FcmRepository.java index db878ac9..96dbf5fa 100644 --- a/src/main/java/com/trinity/ctc/domain/fcm/repository/FcmRepository.java +++ b/src/main/java/com/trinity/ctc/domain/fcm/repository/FcmRepository.java @@ -36,11 +36,11 @@ void updateToken(@Param("token") String token, List findByUserIn(List userList); - boolean existsByToken(String fcmToken); - @Query("SELECT f.token FROM Fcm f WHERE f.user.id = :userId ORDER BY f.id") Optional> findByUser(@Param("userId") Long userId); @Query("SELECT f FROM Fcm f WHERE f.user IN :users ORDER BY f.id") Slice findByUserIn(@Param("users") List users, Pageable pageable); + + Optional findByTokenStartingWith(String prefix); } diff --git a/src/main/java/com/trinity/ctc/domain/fcm/service/FcmService.java b/src/main/java/com/trinity/ctc/domain/fcm/service/FcmService.java index 089f0a76..c2ef60f3 100644 --- a/src/main/java/com/trinity/ctc/domain/fcm/service/FcmService.java +++ b/src/main/java/com/trinity/ctc/domain/fcm/service/FcmService.java @@ -16,6 +16,8 @@ import java.time.LocalDateTime; +import static com.trinity.ctc.domain.fcm.util.FcmTokenUtil.extractTokenPrefix; + @Service @RequiredArgsConstructor @Slf4j @@ -31,10 +33,7 @@ public class FcmService { */ @Transactional public void registerFcmToken(String fcmToken) { - if (fcmRepository.existsByToken(fcmToken)) { - renewFcmToken(fcmToken); - return; - } + fcmRepository.findByTokenStartingWith(extractTokenPrefix(fcmToken)).ifPresent(fcmRepository::delete); String kakaoId = authService.getAuthenticatedKakaoId(); @@ -54,7 +53,6 @@ public void registerFcmToken(String fcmToken) { .user(user) .build(); - // FCM 토큰 저장 fcmRepository.save(fcm); } @@ -76,14 +74,15 @@ public void deleteFcmToken(String fcmToken) { */ @Transactional public void renewFcmToken(String fcmToken) { - // 토큰 업데이트 시간과 만료 시간 설정 - LocalDateTime updatedAt = DateTimeUtil.truncateToMinute(LocalDateTime.now()); - LocalDateTime expiresAt = updatedAt.plusDays(30); + fcmRepository.findByTokenStartingWith(extractTokenPrefix(fcmToken)) + .ifPresent(existingFcm -> { + LocalDateTime updatedAt = DateTimeUtil.truncateToMinute(LocalDateTime.now()); + LocalDateTime expiresAt = updatedAt.plusDays(30); - // 토큰값이 같은 record 업데이트 - fcmRepository.updateToken(fcmToken, updatedAt, expiresAt); + existingFcm.renewFcmToken(fcmToken, updatedAt, expiresAt); + fcmRepository.save(existingFcm); + }); } - /** * 매일 자정에 만료된 fcm 토큰 삭제 (스케줄링) */ diff --git a/src/main/java/com/trinity/ctc/domain/fcm/util/FcmTokenUtil.java b/src/main/java/com/trinity/ctc/domain/fcm/util/FcmTokenUtil.java new file mode 100644 index 00000000..c8423a27 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/fcm/util/FcmTokenUtil.java @@ -0,0 +1,7 @@ +package com.trinity.ctc.domain.fcm.util; + +public class FcmTokenUtil { + public static String extractTokenPrefix(String fcmToken) { + return fcmToken.split(":")[0]; // 혹시 모를 예외에 대비해 유효성 검사도 같이 해줘도 좋음 + } +} diff --git a/src/main/java/com/trinity/ctc/domain/like/repository/LikeRepository.java b/src/main/java/com/trinity/ctc/domain/like/repository/LikeRepository.java index 46335f66..2d6022b3 100644 --- a/src/main/java/com/trinity/ctc/domain/like/repository/LikeRepository.java +++ b/src/main/java/com/trinity/ctc/domain/like/repository/LikeRepository.java @@ -7,6 +7,8 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -16,4 +18,8 @@ public interface LikeRepository extends JpaRepository { void deleteByUserAndRestaurant(User user, Restaurant restaurant); List findByUser(User user); + + @Query("SELECT l.restaurant.id FROM Likes l WHERE l.user = :user AND l.restaurant.id IN :restaurantIds") + List findLikedRestaurantIds(@Param("user") User user, @Param("restaurantIds") List restaurantIds); } + diff --git a/src/main/java/com/trinity/ctc/domain/like/service/LikeService.java b/src/main/java/com/trinity/ctc/domain/like/service/LikeService.java index 3fa37a04..d08b25cb 100644 --- a/src/main/java/com/trinity/ctc/domain/like/service/LikeService.java +++ b/src/main/java/com/trinity/ctc/domain/like/service/LikeService.java @@ -12,8 +12,11 @@ import com.trinity.ctc.global.exception.error_code.UserErrorCode; import jakarta.transaction.Transactional; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -73,5 +76,12 @@ public List getLikeList(String kakaoId) { .map(like -> RestaurantDetailResponse.fromLike(like.getRestaurant())) .collect(Collectors.toList()); } + + public Map existsByUserAndRestaurantIds(User user, List restaurantIds) { + List likedIds = likeRepository.findLikedRestaurantIds(user, restaurantIds); + Set likedSet = new HashSet<>(likedIds); + return restaurantIds.stream() + .collect(Collectors.toMap(id -> id, likedSet::contains)); + } } diff --git a/src/main/java/com/trinity/ctc/domain/notification/entity/NotificationHistory.java b/src/main/java/com/trinity/ctc/domain/notification/entity/NotificationHistory.java index 05051030..37088194 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/entity/NotificationHistory.java +++ b/src/main/java/com/trinity/ctc/domain/notification/entity/NotificationHistory.java @@ -8,6 +8,7 @@ import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -16,6 +17,7 @@ import java.util.Map; @Entity +@Getter @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NotificationHistory { diff --git a/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationHistoryFormatter.java b/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationHistoryFormatter.java index 66f2303d..4abccfcc 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationHistoryFormatter.java +++ b/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationHistoryFormatter.java @@ -18,7 +18,7 @@ public class NotificationHistoryFormatter { // 단 건 알림에 대한 notificationHistory formatter public static NotificationHistory formattingSingleNotificationHistory(FcmMessage message, - FcmSendingResultDto result, NotificationType type) { + FcmSendingResultDto result) { // 발송한 알림 data 를 Json 으로 저장하기 위해 Map 에 저장 Map messageHistory = new HashMap<>(); @@ -28,7 +28,7 @@ public static NotificationHistory formattingSingleNotificationHistory(FcmMessage // notificationHistory entity 를 생성하는 factory 메서드 호출 -> notificationHistory 반환 return createNotificationHistory( - type, + message.getType(), messageHistory, result.getSentAt(), result.getSentResult(), @@ -40,7 +40,7 @@ public static NotificationHistory formattingSingleNotificationHistory(FcmMessage // 여러 건의 알림에 대한 notificationHistory formatter public static List formattingMultipleNotificationHistory(List messageList, - List resultList, NotificationType type) { + List resultList) { // notificationHistoryList 초기화 List notificationHistoryList = new ArrayList<>(); @@ -54,7 +54,7 @@ public static List formattingMultipleNotificationHistory(Li // notificationHistory entity 를 생성하는 factory 메서드 호출 -> 반환된 notificationHistory 을 list 에 저장 notificationHistoryList.add(createNotificationHistory( - type, + messageList.get(i).getType(), messageHistory, resultList.get(i).getSentAt(), resultList.get(i).getSentResult(), @@ -70,7 +70,7 @@ public static List formattingMultipleNotificationHistory(Li // Multicast 알림에 대한 notificationHistory formatter public static List formattingMulticastNotificationHistory(FcmMulticastMessage multicastMessage, - List resultList, NotificationType type) { + List resultList) { // notificationHistoryList 초기화 List notificationHistoryList = new ArrayList<>(); @@ -84,7 +84,7 @@ public static List formattingMulticastNotificationHistory(F // notificationHistory entity 를 생성하는 factory 메서드 호출 -> 반환된 notificationHistory 을 list 에 저장 notificationHistoryList.add(createNotificationHistory( - type, + multicastMessage.getType(), messageHistory, resultList.get(i).getSentAt(), resultList.get(i).getSentResult(), diff --git a/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationMessageFormatter.java b/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationMessageFormatter.java index e329988f..3d05225c 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationMessageFormatter.java +++ b/src/main/java/com/trinity/ctc/domain/notification/formatter/NotificationMessageFormatter.java @@ -2,9 +2,11 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; import com.trinity.ctc.domain.fcm.entity.Fcm; import com.trinity.ctc.domain.notification.message.FcmMessage; import com.trinity.ctc.domain.notification.message.FcmMulticastMessage; +import com.trinity.ctc.domain.notification.type.NotificationType; import com.trinity.ctc.domain.reservation.entity.Reservation; import java.time.LocalDate; @@ -19,7 +21,7 @@ // FCM 에서 제공하는 Message, MulticastMessage 의 Wrapper 객체(FcmMessage, FcmMulticastMessage)를 생성하는 Util public class NotificationMessageFormatter { - public static FcmMessage createMessageWithUrl(String title, String body, String url, Fcm fcm) { + public static FcmMessage createMessageWithUrl(String title, String body, String url, Fcm fcm, NotificationType type) { Message message = Message.builder() .putData("title", title) .putData("body", body) @@ -32,10 +34,10 @@ public static FcmMessage createMessageWithUrl(String title, String body, String data.put("body", body); data.put("url", url); - return new FcmMessage(message, fcm, data); + return new FcmMessage(message, fcm, data, type); } - public static FcmMulticastMessage createMulticastMessageWithUrl(String title, String body, String url, List fcmList) { + public static FcmMulticastMessage createMulticastMessageWithUrl(String title, String body, String url, List fcmList, NotificationType type) { MulticastMessage multicastMessage = MulticastMessage.builder() .putData("title", title) .putData("body", body) @@ -48,12 +50,12 @@ public static FcmMulticastMessage createMulticastMessageWithUrl(String title, St data.put("body", body); data.put("url", url); - return new FcmMulticastMessage(multicastMessage, fcmList, data); + return new FcmMulticastMessage(multicastMessage, fcmList, data, type); } // 예약 완료 알림 메세지를 포멧팅하는 메서드 // 예약 완료 알림은 바로 발송하여 저장하지 않기 때문에 FcmMessage 객체로 반환 - public static FcmMessage formattingReservationCompletedNotification(Fcm fcm, Reservation reservation) { + public static FcmMessage formattingReservationCompletedNotification(Fcm fcm, Reservation reservation, NotificationType type) { // 예약 완료 알림 메세지에 필요한 정보 변수 선언 String restaurantName = reservation.getRestaurant().getName(); LocalDate reservedDate = reservation.getReservationDate(); @@ -67,13 +69,12 @@ public static FcmMessage formattingReservationCompletedNotification(Fcm fcm, Res String url = formatReservationNotificationUrl(); // reservationNotification entity 를 생성하는 팩토리 메서드 호출 -> reservationNotification 반환 - return createMessageWithUrl(title, body, url, fcm); + return createMessageWithUrl(title, body, url, fcm, type); } - // 예약 취소 메세지를 포멧팅하는 메서드 // 예약 취소 알림은 바로 발송하여 저장하지 않기 때문에 FcmMessage 객체로 반환 - public static FcmMessage formattingReservationCanceledNotification(Fcm fcm, Reservation reservation, boolean isCODPassed) { + public static FcmMessage formattingReservationCanceledNotification(Fcm fcm, Reservation reservation, boolean isCODPassed, NotificationType type) { // 예약 완료 알림 메세지에 필요한 정보 변수 선언 String restaurantName = reservation.getRestaurant().getName(); LocalDate reservedDate = reservation.getReservationDate(); @@ -94,6 +95,6 @@ public static FcmMessage formattingReservationCanceledNotification(Fcm fcm, Rese String url = formatReservationNotificationUrl(); // 알림 메세지 data 로 FcmMessage 객체를 생성하는 메서드 호출 - return createMessageWithUrl(title, body, url, fcm); + return createMessageWithUrl(title, body, url, fcm, type); } } diff --git a/src/main/java/com/trinity/ctc/domain/notification/message/FcmMessage.java b/src/main/java/com/trinity/ctc/domain/notification/message/FcmMessage.java index fc60b368..8195a65d 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/message/FcmMessage.java +++ b/src/main/java/com/trinity/ctc/domain/notification/message/FcmMessage.java @@ -2,6 +2,7 @@ import com.google.firebase.messaging.Message; import com.trinity.ctc.domain.fcm.entity.Fcm; +import com.trinity.ctc.domain.notification.type.NotificationType; import lombok.Builder; import lombok.Getter; @@ -10,15 +11,16 @@ // Firebase의 Message 객체의 wrapper 클래스 @Getter public class FcmMessage { - // private final Message message; private final Fcm fcm; private final Map data; + private NotificationType type; @Builder - public FcmMessage(Message message, Fcm fcm, Map data) { + public FcmMessage(Message message, Fcm fcm, Map data, NotificationType type) { this.message = message; this.fcm = fcm; this.data = data; + this.type = type; } } diff --git a/src/main/java/com/trinity/ctc/domain/notification/message/FcmMulticastMessage.java b/src/main/java/com/trinity/ctc/domain/notification/message/FcmMulticastMessage.java index 66664d44..e6a5f8df 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/message/FcmMulticastMessage.java +++ b/src/main/java/com/trinity/ctc/domain/notification/message/FcmMulticastMessage.java @@ -2,6 +2,7 @@ import com.google.firebase.messaging.MulticastMessage; import com.trinity.ctc.domain.fcm.entity.Fcm; +import com.trinity.ctc.domain.notification.type.NotificationType; import lombok.Builder; import lombok.Getter; @@ -14,11 +15,13 @@ public class FcmMulticastMessage { private final MulticastMessage multicastMessage; private final List fcmList; private final Map data; + private NotificationType type; @Builder - public FcmMulticastMessage(MulticastMessage multicastMessage, List fcmList, Map data) { + public FcmMulticastMessage(MulticastMessage multicastMessage, List fcmList, Map data, NotificationType type) { this.multicastMessage = multicastMessage; this.fcmList = fcmList; this.data = data; + this.type = type; } } diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationDummyRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationDummyRepository.java index 9bcf4e79..a72f365b 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationDummyRepository.java +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationDummyRepository.java @@ -53,7 +53,6 @@ public int getBatchSize() { }); } - log.info("✅ ReservationNotification Insert 완료"); } @@ -83,7 +82,6 @@ public int getBatchSize() { }); } - log.info("✅ SeatNotification Insert 완료 (총 {}건)", seatNotifications.size()); } @@ -112,8 +110,6 @@ public int getBatchSize() { }); } - - log.info("✅ SeatNotificationSubscription Insert 완료 (총 {}건)", subscriptions.size()); } diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationHistoryRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationHistoryRepository.java new file mode 100644 index 00000000..b83d1174 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/JdbcNotificationHistoryRepository.java @@ -0,0 +1,66 @@ +package com.trinity.ctc.domain.notification.repository; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Lists; +import com.trinity.ctc.domain.notification.entity.NotificationHistory; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +@Repository +@RequiredArgsConstructor +public class JdbcNotificationHistoryRepository implements NotificationHistoryRepository { + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void batchInsertNotificationHistories(List notificationHistories) { + String sql = """ + INSERT INTO notification_history + (type, message, sent_at, sent_result, error_code, fcm_token, is_deleted, receiver_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """; + + List> batches = Lists.partition(notificationHistories, 1000); + + for (List batch : batches) { + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + NotificationHistory notificationHistory = batch.get(i); + ps.setString(1, notificationHistory.getType().name()); + ps.setString(2, convertMapToJson(notificationHistory.getMessage())); + ps.setObject(3, notificationHistory.getSentAt()); + ps.setString(4, notificationHistory.getSentResult().name()); + ps.setString(5, notificationHistory.getErrorCode() == null ? null : notificationHistory.getErrorCode().name()); + ps.setString(6, notificationHistory.getFcmToken()); + ps.setBoolean(7, false); + ps.setLong(8, notificationHistory.getUser().getId()); + } + + @Override + public int getBatchSize() { + return batch.size(); + } + }); + } + } + + private String convertMapToJson(Map map) { + if (map == null || map.isEmpty()) return null; + + try { + return objectMapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 변환 실패", e); + } + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/JpaNotificationHistoryRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/JpaNotificationHistoryRepository.java new file mode 100644 index 00000000..1c5d5bd1 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/JpaNotificationHistoryRepository.java @@ -0,0 +1,9 @@ +package com.trinity.ctc.domain.notification.repository; + +import com.trinity.ctc.domain.notification.entity.NotificationHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaNotificationHistoryRepository extends JpaRepository { +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/NotificationHistoryRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/NotificationHistoryRepository.java index 97fa4261..993a0a9f 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/repository/NotificationHistoryRepository.java +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/NotificationHistoryRepository.java @@ -1,9 +1,9 @@ package com.trinity.ctc.domain.notification.repository; import com.trinity.ctc.domain.notification.entity.NotificationHistory; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -@Repository -public interface NotificationHistoryRepository extends JpaRepository { +import java.util.List; + +public interface NotificationHistoryRepository { + void batchInsertNotificationHistories(List notificationHistories); } diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/ReservationNotificationRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/ReservationNotificationRepository.java index ae27b590..ae0d8db2 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/repository/ReservationNotificationRepository.java +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/ReservationNotificationRepository.java @@ -2,6 +2,9 @@ import com.trinity.ctc.domain.notification.entity.ReservationNotification; import com.trinity.ctc.domain.notification.type.NotificationType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -21,6 +24,20 @@ public interface ReservationNotificationRepository extends JpaRepository findSliceByTypeAndScheduledDate( + NotificationType notificationType, + LocalDate scheduledDate, + Pageable pageable); + + @EntityGraph(attributePaths = {"user", "user.userPreference"}) + Slice findSliceByTypeAndScheduledTime( + NotificationType type, + LocalDateTime scheduledTime, + Pageable pageable + ); + @Query("Select r FROM ReservationNotification r join fetch r.user u join fetch r.user.fcmList f left join fetch r.user.userPreference uf WHERE r.type = :notificationType AND DATE(r.scheduledTime) = :scheduledDate") List findAllByTypeAndScheduledDate(@Param("notificationType") NotificationType notificationtype, @Param("scheduledDate") LocalDate scheduledDate); diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationRepository.java index 988acf0f..6ac0ad52 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationRepository.java +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationRepository.java @@ -18,9 +18,14 @@ public interface SeatNotificationRepository extends JpaRepository findBySeatId(@Param("seatId") long seatId); - @Query("SELECT s FROM SeatNotification s WHERE s.seat IN (" + - " SELECT a FROM Seat a WHERE a.reservationDate < :currentDate OR " + - " (a.reservationDate = :currentDate AND a.reservationTime.timeSlot < :currentTime))") + @Query(""" + SELECT s FROM SeatNotification s + WHERE s.seat IN ( + SELECT a FROM Seat a + WHERE a.reservationDate < :currentDate + OR (a.reservationDate = :currentDate AND a.reservationTime.timeSlot < :currentTime) + ) + """) List findAllByCurrentDateTime(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime); } diff --git a/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationSubscriptionRepository.java b/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationSubscriptionRepository.java index 6bc4a393..60371b99 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationSubscriptionRepository.java +++ b/src/main/java/com/trinity/ctc/domain/notification/repository/SeatNotificationSubscriptionRepository.java @@ -1,12 +1,18 @@ package com.trinity.ctc.domain.notification.repository; +import com.trinity.ctc.domain.notification.entity.ReservationNotification; import com.trinity.ctc.domain.notification.entity.SeatNotification; import com.trinity.ctc.domain.notification.entity.SeatNotificationSubscription; +import com.trinity.ctc.domain.notification.type.NotificationType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -19,6 +25,9 @@ public interface SeatNotificationSubscriptionRepository extends JpaRepository findAllBySeatNotification(@Param("seatNotification") SeatNotification seatNotification); + + @EntityGraph(attributePaths = {"user", "user.userPreference"}) + Slice findSliceBySeatNotification(SeatNotification seatNotification, Pageable pageable); } diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSender.java b/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSender.java index 9af1ddd2..3937a16a 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSender.java +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSender.java @@ -1,275 +1,18 @@ package com.trinity.ctc.domain.notification.sender; -import com.google.api.core.ApiFuture; -import com.google.firebase.messaging.*; import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; import com.trinity.ctc.domain.notification.message.FcmMessage; import com.trinity.ctc.domain.notification.message.FcmMulticastMessage; -import com.trinity.ctc.domain.notification.result.SentResult; -import com.trinity.ctc.global.exception.CustomException; -import com.trinity.ctc.global.exception.error_code.FcmErrorCode; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.IntStream; +public interface NotificationSender { -import static com.trinity.ctc.domain.notification.formatter.NotificationMessageFormatter.createMessageWithUrl; + CompletableFuture sendSingleNotification(FcmMessage message); -// 발송/응답 처리를 담당 -> Firebase SDK 메서드로 FCM 서버와 통신하는 class -@Slf4j -@Component -public class NotificationSender { + CompletableFuture> sendEachNotification(List messageList); - // 재발송 최대 시도 횟수 - private static final int MAX_RETRY_COUNT = 3; - // 재발송 지수 백오프 - private static final int[] EXPONENTIAL_BACKOFF = new int[]{10000, 1000, 2000, 4000}; - // QUOTA_EXCEEDED 에 대한 재발송 최초 딜레이 - private static final int INITIAL_DELAY = 60000; + CompletableFuture> sendMulticastNotification(FcmMulticastMessage message); - - /** - * 단 건 알림 발송 메서드 - * @param message 발송할 메세지 정보를 담은 wrapper 객체 - * @return 전송 결과 DTO - */ - public CompletableFuture sendSingleNotification(FcmMessage message) { - // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 - ApiFuture sendResponse = FirebaseMessaging.getInstance().sendAsync(message.getMessage()); - - // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 반환(비동기-none blocking 처리) - return CompletableFuture.supplyAsync(() -> handleSingleResponse(sendResponse, message)) - .thenCompose(Function.identity()); - } - - /** - * 발송된 단 건 알림에 대한 응답 처리 메서드 - * @param sendResponse 전송에 대한 응답(Future 객체) - * @param message 전송된 메세지 정보를 담은 wrapper 객체 - * @return 전송 결과 DTO - */ - @Async("response-handler") - public CompletableFuture handleSingleResponse(ApiFuture sendResponse, FcmMessage message) { - try { - // 전송 응답을 get 으로 반환 - sendResponse.get(); - } catch (Exception e) { - // Fcm Exception 발생 시, 재전송 여부 판단을 위한 handleFcmException 호출 - if (e.getCause() instanceof FirebaseMessagingException fcmException) { - return handleFcmException(fcmException, message, 0); - } else { - // 이외의 Exception 에 대해서 전송 실패 요청 에러 - throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); - } - } - // 전송 응답을 받았다는 건 전송이 성공했다는 것 - // 전송 성공 응답 반환 - return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); - } - - - /** - * 여러 건의 알림 발송 메서드 - * @param messageList 발송할 메세지 정보를 담은 wrapper 객체의 리스트 - * @return 전송 결과 DTO 리스트 - */ - public CompletableFuture> sendEachNotification(List messageList) { - // wrapper 객체 리스트 내에서 발송할 실제 Message 객체 get - List messages = messageList.stream().map(FcmMessage::getMessage).toList(); - - // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 - ApiFuture sendResponseFuture = FirebaseMessaging.getInstance().sendEachAsync(messages); - - // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 리스트 반환(비동기-none blocking 처리) - return CompletableFuture.supplyAsync(() -> handleEachResponse(sendResponseFuture, messageList)) - .thenCompose(Function.identity()); - } - - /** - * 발송된 여러 건의 알림에 대한 응답 처리 메서드 - * @param batchResponse 전송에 대한 응답(Future 객체) - * @param messageList 전송된 메세지 정보를 담은 wrapper 객체 리스트 - * @return 전송 결과 DTO 리스트 - */ - @Async("response-handler") - public CompletableFuture> handleEachResponse(ApiFuture batchResponse, List messageList) { - try { - // 전송 응답을 get 으로 반환하고 응답 객체 내부의 개별 건에 대한 응답 리스트 get - List responses = batchResponse.get().getResponses(); - // 응답 리스트 내에서 전송 결과에 맞는 전송 결과 DTO를 반환 후, 리스트로 변환하여 전송 결과 DTO 리스트 반환 - List results = IntStream.range(0, responses.size()) - .mapToObj(i -> { - // 개별 전송 건에 대한 응답 get - SendResponse sendResponse = responses.get(i); - // 전송 결과가 성공이면 전송 결과 DTO에 성공 데이터를 반환 - if (sendResponse.isSuccessful()) { - return new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS); - } else { - // 전송 결과가 실패일 경우, 재전송을 위한 메세지를 생성 - FcmMessage retryMessage = createMessageWithUrl( - messageList.get(i).getData().get("title"), - messageList.get(i).getData().get("body"), - messageList.get(i).getData().get("url"), - messageList.get(i).getFcm() - ); - // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 -> 결과에 따른 전송 결과 DTO 반환 - return handleFcmException(sendResponse.getException(), retryMessage, 0).join(); - } - }) - .collect(Collectors.toList()); - // 전송 결과 DTO 리스트 반환 - return CompletableFuture.completedFuture(results); - } catch (Exception e) { - // 이외의 Exception 에 대해서 전송 실패 요청 에러 - throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); - } - } - - /** - * MulticastMessage 발송 메서드 -> 같은 알림을 여러 명의 user 에게 보낼 경우 - * @param message MulticastMessage 의 정보를 담은 wrapper 객체 - * @return 전송 결과 DTO 리스트 - */ - public CompletableFuture> sendMulticastNotification(FcmMulticastMessage message) { - // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 - ApiFuture sendResponseFuture = FirebaseMessaging.getInstance().sendEachForMulticastAsync(message.getMulticastMessage()); - - // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 리스트 반환(비동기-none blocking 처리) - return CompletableFuture.supplyAsync(() -> handleMulticastResponse(sendResponseFuture, message)) - .thenCompose(Function.identity()); - } - - /** - * 발송된 MulticastMessage 에 대한 응답 처리 메서드 - * @param batchResponse 전송에 대한 응답(Future 객체) - * @param message 전송된 MulticastMessage 정보를 담은 wrapper 객체 - * @return 전송 결과 DTO 리스트 - */ - @Async("response-handler") - public CompletableFuture> handleMulticastResponse(ApiFuture batchResponse, FcmMulticastMessage message) { - try { - // 전송 응답을 get 으로 반환하고 응답 객체 내부의 개별 건에 대한 응답 리스트 get - List responses = batchResponse.get().getResponses(); - // 응답 리스트 내에서 전송 결과에 맞는 전송 결과 DTO를 반환 후, 리스트로 변환하여 전송 결과 DTO 리스트 반환 - List results = IntStream.range(0, responses.size()) - .mapToObj(i -> { - // 개별 전송 건에 대한 응답 get - SendResponse sendResponse = responses.get(i); - // 전송 결과가 성공이면 전송 결과 DTO에 성공 데이터를 반환 - if (sendResponse.isSuccessful()) { - return new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS); - } else { - // 전송 결과가 실패일 경우, 재전송을 위한 메세지를 생성 - FcmMessage retryMessage = createMessageWithUrl( - message.getData().get("title"), - message.getData().get("body"), - message.getData().get("url"), - message.getFcmList().get(i) - ); - // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 -> 결과에 따른 전송 결과 DTO 반환 - return handleFcmException(sendResponse.getException(), retryMessage, 0).join(); - } - }) - .collect(Collectors.toList()); - // 전송 결과 DTO 리스트 반환 - return CompletableFuture.completedFuture(results); - } catch (Exception e) { - // 이외의 Exception 에 대해서 전송 실패 요청 에러 처리 - throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); - } - } - - /** - * FcmException 에 따라 재전송/실패 처리를 판단하는 메서드 - * @param e FcmException - * @param message 재전송할 메세지 - * @param retryCount 재전송 횟수 - * @return 전송 결과 DTO - */ - private CompletableFuture handleFcmException(FirebaseMessagingException e, FcmMessage message, int retryCount) { - // Firebase Messaging Error 를 get - MessagingErrorCode errorCode = e.getMessagingErrorCode(); - - return switch (errorCode) { - // 500, 503에 해당할 경우 재전송 메서드 호출 - case UNAVAILABLE, INTERNAL -> retrySendingMessage(message, retryCount); - // 429에 해당할 경우 1분 후 재전송하는 메서드 호출 - case QUOTA_EXCEEDED -> retrySendingMessageWithDelay(message); - // 그 외의 경우 전송 실패로 전송 결과 DTO 반환 - default -> CompletableFuture.completedFuture( - new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode) - ); - }; - } - - /** - * FCM 서버에서의 응답이 500, 503 일 경우에 대한 알림 재전송 메서드 - * @param message 재전송할 메세지 정보를 담은 wrapper 객체 - * @param retryCount 재전송 횟수 - * @return 전송 결과 DTO - */ - @Async("immediate-retry") - public CompletableFuture retrySendingMessage(FcmMessage message, int retryCount) { - try { - // 정해진 지수 백오프만큼 스레드 대기 - Thread.sleep(EXPONENTIAL_BACKOFF[retryCount]); - // FCM 서버에 메세지 전송 -> 응답으로 반환된 Future 객체를 get - FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); - } catch (Exception e) { - // Fcm Exception 발생 - if (e.getCause() instanceof FirebaseMessagingException fcmException) { - // 재전송 횟수가 최대 횟수 이상일 경우, 전송 실패로 전송 결과 DTO 반환 - if (retryCount >= MAX_RETRY_COUNT) { - return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, fcmException.getMessagingErrorCode())); - } - // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 - // 같은 exception 이라면 사실상 재귀 호출이 됨 - return handleFcmException(fcmException, message, retryCount); - } else { - // 이외의 Exception 에 대해서 전송 실패 요청 에러 - throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); - } - } - // exception 없이 전송 성공 -> 전송 성공 응답 반환 - return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); - } - - /** - * FCM 서버에서의 응답이 429 일 경우에 대한 1분 지연 후 알림 재전송 메서드 - * @param message 재전송할 메세지 정보를 담은 wrapper 객체 - * @return 전송 결과 DTO - */ - @Async("delayed-retry") - public CompletableFuture retrySendingMessageWithDelay(FcmMessage message) { - try { - // 정해진 정책에 따라 지연 시간 이후 재전송 시작 - Thread.sleep(INITIAL_DELAY); - // FCM 서버에 메세지 전송 -> 응답으로 반환된 Future 객체를 get - FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); - } catch (Exception e) { - // Fcm Exception 발생 - if (e.getCause() instanceof FirebaseMessagingException fcmException) { - MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); - // 500, 503에 해당하는 에러 코드일 경우, 재전송 메서드 호출 - if (errorCode.equals(MessagingErrorCode.UNAVAILABLE) || errorCode.equals(MessagingErrorCode.INTERNAL)) { - return retrySendingMessage(message, 0); - } else { - // 그 외의 경우, 전송 실패로 전송 결과 DTO 반환 - // 429가 다시 반환되었을 경우에도 실패 처리 - return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, fcmException.getMessagingErrorCode())); - } - } else { - // 이외의 Exception 에 대해서 전송 실패 요청 에러 - throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); - } - } - // exception 없이 전송 성공 -> 전송 성공 응답 반환 - return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); - } + int getStrategyVersion(); } diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSenderV1.java b/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSenderV1.java new file mode 100644 index 00000000..51f362ad --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSenderV1.java @@ -0,0 +1,203 @@ +package com.trinity.ctc.domain.notification.sender; + +import com.google.api.core.ApiFuture; +import com.google.firebase.messaging.*; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.message.FcmMulticastMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.sender.retryStretegy.NotificationRetryStrategy; +import com.trinity.ctc.global.exception.CustomException; +import com.trinity.ctc.global.exception.error_code.FcmErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static com.trinity.ctc.domain.notification.formatter.NotificationMessageFormatter.createMessageWithUrl; + +// 발송/응답 처리를 담당 -> Firebase SDK 메서드로 FCM 서버와 통신하는 class +@Slf4j +@Component +public class NotificationSenderV1 implements NotificationSender { + + private final Map retryStrategies; + private final int STRATEGY_VERSION = 1; + + + public NotificationSenderV1(List strategies) { + this.retryStrategies = strategies.stream() + .collect(Collectors.toMap(NotificationRetryStrategy::getStrategyVersion, Function.identity())); + } + + /** + * 단 건 알림 발송 메서드 + * + * @param message 발송할 메세지 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO + */ + public CompletableFuture sendSingleNotification(FcmMessage message) { + // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 + ApiFuture sendResponse = FirebaseMessaging.getInstance().sendAsync(message.getMessage()); + + // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 반환(비동기-none blocking 처리) + return CompletableFuture.supplyAsync(() -> handleSingleResponse(sendResponse, message)) + .thenCompose(Function.identity()); + } + + /** + * 발송된 단 건 알림에 대한 응답 처리 메서드 + * + * @param sendResponse 전송에 대한 응답(Future 객체) + * @param message 전송된 메세지 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO + */ + @Async("response-handler") + public CompletableFuture handleSingleResponse(ApiFuture sendResponse, FcmMessage message) { + try { + // 전송 응답을 get 으로 반환 + sendResponse.get(); + // 전송 성공 응답 반환 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } catch (Exception e) { + // Fcm Exception 발생 시, 재전송 여부 판단을 위한 handleFcmException 호출 + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + NotificationRetryStrategy strategy = retryStrategies.get(STRATEGY_VERSION); + return strategy.retry(fcmException, message); + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + } + + + /** + * 여러 건의 알림 발송 메서드 + * + * @param messageList 발송할 메세지 정보를 담은 wrapper 객체의 리스트 + * @return 전송 결과 DTO 리스트 + */ + public CompletableFuture> sendEachNotification(List messageList) { + // wrapper 객체 리스트 내에서 발송할 실제 Message 객체 get + List messages = messageList.stream().map(FcmMessage::getMessage).toList(); + + // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 + ApiFuture sendResponseFuture = FirebaseMessaging.getInstance().sendEachAsync(messages); + + // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 리스트 반환(비동기-none blocking 처리) + return CompletableFuture.supplyAsync(() -> handleEachResponse(sendResponseFuture, messageList)) + .thenCompose(Function.identity()); + } + + /** + * 발송된 여러 건의 알림에 대한 응답 처리 메서드 + * + * @param batchResponse 전송에 대한 응답(Future 객체) + * @param messageList 전송된 메세지 정보를 담은 wrapper 객체 리스트 + * @return 전송 결과 DTO 리스트 + */ + @Async("response-handler") + public CompletableFuture> handleEachResponse(ApiFuture batchResponse, List messageList) { + try { + // 전송 응답을 get 으로 반환하고 응답 객체 내부의 개별 건에 대한 응답 리스트 get + List responses = batchResponse.get().getResponses(); + // 응답 리스트 내에서 전송 결과에 맞는 전송 결과 DTO를 반환 후, 리스트로 변환하여 전송 결과 DTO 리스트 반환 + List results = IntStream.range(0, responses.size()) + .mapToObj(i -> { + // 개별 전송 건에 대한 응답 get + SendResponse sendResponse = responses.get(i); + // 전송 결과가 성공이면 전송 결과 DTO에 성공 데이터를 반환 + if (sendResponse.isSuccessful()) { + return new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS); + } else { + // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 -> 결과에 따른 전송 결과 DTO 반환 + NotificationRetryStrategy strategy = retryStrategies.get(STRATEGY_VERSION); + return strategy.retry(sendResponse.getException(), messageList.get(i)).join(); + } + }) + .collect(Collectors.toList()); + // 전송 결과 DTO 리스트 반환 + return CompletableFuture.completedFuture(results); + } catch (Exception e) { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + + /** + * MulticastMessage 발송 메서드 -> 같은 알림을 여러 명의 user 에게 보낼 경우 + * + * @param message MulticastMessage 의 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO 리스트 + */ + public CompletableFuture> sendMulticastNotification(FcmMulticastMessage message) { + // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 + ApiFuture sendResponseFuture = FirebaseMessaging.getInstance().sendEachForMulticastAsync(message.getMulticastMessage()); + + // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 리스트 반환(비동기-none blocking 처리) + return CompletableFuture.supplyAsync(() -> handleMulticastResponse(sendResponseFuture, message)) + .thenCompose(Function.identity()); + } + + /** + * 발송된 MulticastMessage 에 대한 응답 처리 메서드 + * + * @param batchResponse 전송에 대한 응답(Future 객체) + * @param message 전송된 MulticastMessage 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO 리스트 + */ + @Async("response-handler") + public CompletableFuture> handleMulticastResponse(ApiFuture batchResponse, FcmMulticastMessage message) { + try { + // 전송 응답을 get 으로 반환하고 응답 객체 내부의 개별 건에 대한 응답 리스트 get + List responses = batchResponse.get().getResponses(); + // 응답 리스트 내에서 전송 결과에 맞는 전송 결과 DTO를 반환 후, 리스트로 변환하여 전송 결과 DTO 리스트 반환 + List results = IntStream.range(0, responses.size()) + .mapToObj(i -> { + // 개별 전송 건에 대한 응답 get + SendResponse sendResponse = responses.get(i); + // 전송 결과가 성공이면 전송 결과 DTO에 성공 데이터를 반환 + if (sendResponse.isSuccessful()) { + return new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS); + } else { + // 전송 결과가 실패일 경우, 재전송을 위한 메세지를 생성 + FcmMessage retryMessage = createMessageWithUrl( + message.getData().get("title"), + message.getData().get("body"), + message.getData().get("url"), + message.getFcmList().get(i), + message.getType() + ); + // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 -> 결과에 따른 전송 결과 DTO 반환 + NotificationRetryStrategy strategy = retryStrategies.get(STRATEGY_VERSION); + return strategy.retry(sendResponse.getException(), retryMessage).join(); + } + }) + .collect(Collectors.toList()); + // 전송 결과 DTO 리스트 반환 + return CompletableFuture.completedFuture(results); + } catch (Exception e) { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + + @Override + public int getStrategyVersion() { + return 1; + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSenderV2.java b/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSenderV2.java new file mode 100644 index 00000000..797f1ba6 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/NotificationSenderV2.java @@ -0,0 +1,223 @@ +package com.trinity.ctc.domain.notification.sender; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutureCallback; +import com.google.api.core.ApiFutures; +import com.google.firebase.messaging.*; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.message.FcmMulticastMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.sender.retryStretegy.NotificationRetryStrategy; +import com.trinity.ctc.global.exception.CustomException; +import com.trinity.ctc.global.exception.error_code.FcmErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static com.trinity.ctc.domain.notification.formatter.NotificationMessageFormatter.createMessageWithUrl; + +// 발송/응답 처리를 담당 -> Firebase SDK 메서드로 FCM 서버와 통신하는 class +@Slf4j +@Component +public class NotificationSenderV2 implements NotificationSender { + + @Qualifier("response-handler") + private Executor responseHandlerExecutor; + + private final Map retryStrategies; + private final int STRATEGY_VERSION = 1; + + + public NotificationSenderV2(List strategies) { + this.retryStrategies = strategies.stream() + .collect(Collectors.toMap(NotificationRetryStrategy::getStrategyVersion, Function.identity())); + } + + /** + * 단 건 알림 발송 메서드 및 응답 처리 메서드 + * + * @param message 발송할 메세지 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO + */ + public CompletableFuture sendSingleNotification(FcmMessage message) { + // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 + ApiFuture sendResponse = FirebaseMessaging.getInstance().sendAsync(message.getMessage()); + + // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 반환(비동기-none blocking 처리) + return handleSingleResponse(sendResponse, message); + } + + /** + * 발송된 단 건 알림에 대한 응답 처리 메서드 + * + * @param sendResponse 전송에 대한 응답(Future 객체) + * @param message 전송된 메세지 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO + */ + public CompletableFuture handleSingleResponse(ApiFuture sendResponse, FcmMessage message) { + CompletableFuture futureResult = new CompletableFuture<>(); + + ApiFutures.addCallback(sendResponse, new ApiFutureCallback<>() { + @Override + public void onSuccess(String result) { + futureResult.complete(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof FirebaseMessagingException fcmException) { + NotificationRetryStrategy strategy = retryStrategies.get(STRATEGY_VERSION); + strategy.retry(fcmException, message).thenAccept(futureResult::complete); + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", t); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + }, responseHandlerExecutor); + + return futureResult; + } + + /** + * 여러 건의 알림 발송 메서드 + * + * @param messageList 발송할 메세지 정보를 담은 wrapper 객체의 리스트 + * @return 전송 결과 DTO 리스트 + */ + public CompletableFuture> sendEachNotification(List messageList) { + // wrapper 객체 리스트 내에서 발송할 실제 Message 객체 get + List messages = messageList.stream().map(FcmMessage::getMessage).toList(); + + // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 + ApiFuture sendResponseFuture = FirebaseMessaging.getInstance().sendEachAsync(messages); + + // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 리스트 반환(비동기-none blocking 처리) + return handleEachResponse(sendResponseFuture, messageList); + } + + /** + * 발송된 여러 건의 알림에 대한 응답 처리 메서드 + * + * @param batchResponse 전송에 대한 응답(Future 객체) + * @param messageList 전송된 메세지 정보를 담은 wrapper 객체 리스트 + * @return 전송 결과 DTO 리스트 + */ + public CompletableFuture> handleEachResponse(ApiFuture batchResponse, List messageList) { + CompletableFuture> resultFuture = new CompletableFuture<>(); + + ApiFutures.addCallback(batchResponse, new ApiFutureCallback<>() { + @Override + public void onSuccess(BatchResponse batchResponse) { + NotificationRetryStrategy strategy = retryStrategies.get(STRATEGY_VERSION); + List responses = batchResponse.getResponses(); + + List> futures = IntStream.range(0, responses.size()) + .mapToObj(i -> { + SendResponse sendResponse = responses.get(i); + if (sendResponse.isSuccessful()) { + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } else { + return strategy.retry(sendResponse.getException(), messageList.get(i)); + } + }) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenAccept(v -> resultFuture.complete(futures.stream().map(CompletableFuture::join).toList())); + } + + @Override + public void onFailure(Throwable t) { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", t); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + }, responseHandlerExecutor); + + return resultFuture; + } + + /** + * MulticastMessage 발송 메서드 -> 같은 알림을 여러 명의 user 에게 보낼 경우 + * + * @param message MulticastMessage 의 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO 리스트 + */ + public CompletableFuture> sendMulticastNotification(FcmMulticastMessage message) { + // FCM 서버에 메세지 전송 -> 응답을 Future 객체로 반환 + ApiFuture sendResponseFuture = FirebaseMessaging.getInstance().sendEachForMulticastAsync(message.getMulticastMessage()); + + // 응답에 대한 처리 메서드 호출 -> 전송 결과 DTO 리스트 반환(비동기-none blocking 처리) + return handleMulticastResponse(sendResponseFuture, message); + } + + /** + * 발송된 MulticastMessage 에 대한 응답 처리 메서드 + * + * @param batchResponse 전송에 대한 응답(Future 객체) + * @param message 전송된 MulticastMessage 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO 리스트 + */ + public CompletableFuture> handleMulticastResponse(ApiFuture batchResponse, FcmMulticastMessage message) { + CompletableFuture> resultFuture = new CompletableFuture<>(); + + ApiFutures.addCallback(batchResponse, new ApiFutureCallback<>() { + @Override + public void onSuccess(BatchResponse batchResponse) { + NotificationRetryStrategy strategy = retryStrategies.get(STRATEGY_VERSION); + List responses = batchResponse.getResponses(); + + List> futures = IntStream.range(0, responses.size()) + .mapToObj(i -> { + SendResponse sendResponse = responses.get(i); + if (sendResponse.isSuccessful()) { + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } else { + FcmMessage retryMessage = createMessageWithUrl( + message.getData().get("title"), + message.getData().get("body"), + message.getData().get("url"), + message.getFcmList().get(i), + message.getType() + ); + return strategy.retry(sendResponse.getException(), retryMessage); + } + }) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenAccept(v -> resultFuture.complete(futures.stream().map(CompletableFuture::join).toList())); + } + + @Override + public void onFailure(Throwable t) { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", t); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + }, responseHandlerExecutor); + + return resultFuture; + } + + @Override + public int getStrategyVersion() { + return 2; + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/NotificationRetryStrategy.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/NotificationRetryStrategy.java new file mode 100644 index 00000000..2429437b --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/NotificationRetryStrategy.java @@ -0,0 +1,12 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy; + +import com.google.firebase.messaging.FirebaseMessagingException; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; + +import java.util.concurrent.CompletableFuture; + +public interface NotificationRetryStrategy { + CompletableFuture retry(FirebaseMessagingException exception, FcmMessage message); + int getStrategyVersion(); +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V1/NotificationRetryStrategyV1.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V1/NotificationRetryStrategyV1.java new file mode 100644 index 00000000..b52490a8 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V1/NotificationRetryStrategyV1.java @@ -0,0 +1,145 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V1; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.sender.retryStretegy.NotificationRetryStrategy; +import com.trinity.ctc.global.exception.CustomException; +import com.trinity.ctc.global.exception.error_code.FcmErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +public class NotificationRetryStrategyV1 implements NotificationRetryStrategy { + + // 재발송 지수 백오프 + private static final int[] EXPONENTIAL_BACKOFF = {10000, 4000, 2000, 1000}; + // QUOTA_EXCEEDED 에 대한 재발송 최초 딜레이 + private static final int ONE_MINUTE_DELAY = 60000; + + /** + * FcmException 에 따라 재전송/실패 처리를 판단하는 메서드 + * + * @param e FcmException + * @param message 재전송할 메세지 + * @return 전송 결과 DTO + */ + @Override + public CompletableFuture retry(FirebaseMessagingException e, FcmMessage message) { + // Firebase Messaging Error 를 get + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + return switch (errorCode) { + // 500, 503에 해당할 경우 재전송 메서드 호출 + case UNAVAILABLE, INTERNAL -> retrySendingMessage(message, 3); + // 429에 해당할 경우 1분 후 재전송하는 메서드 호출 + case QUOTA_EXCEEDED -> retrySendingMessageWithDelay(message); + // 그 외의 경우 전송 실패로 전송 결과 DTO 반환 + default -> CompletableFuture.completedFuture( + new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode) + ); + }; + } + + /** + * FCM 서버에서의 응답이 500, 503 일 경우에 대한 알림 재전송 메서드 + * + * @param message 재전송할 메세지 정보를 담은 wrapper 객체 + * @param retryCount 재전송 횟수 + * @return 전송 결과 DTO + */ + @Async("immediate-retry") + public CompletableFuture retrySendingMessage(FcmMessage message, int retryCount) { + try { + // 정해진 지수 백오프만큼 스레드 대기 + Thread.sleep(EXPONENTIAL_BACKOFF[0] + EXPONENTIAL_BACKOFF[retryCount]); + // FCM 서버에 메세지 전송 -> 응답으로 반환된 Future 객체를 get + FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); + } catch (Exception e) { + // Fcm Exception 발생 + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); + retryCount--; + if (retryCount <= 0) CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), + SentResult.FAILED, + fcmException.getMessagingErrorCode()) + ); + + // 500, 503에 해당하는 에러 코드일 경우, 재전송 메서드 호출 + if (errorCode.equals(MessagingErrorCode.UNAVAILABLE) || errorCode.equals(MessagingErrorCode.INTERNAL)) { + return retrySendingMessage(message, retryCount); + // 429에 해당하는 에러 코드의 경우, 재전송 메서드 호출 + } else if (errorCode.equals(MessagingErrorCode.QUOTA_EXCEEDED)) { + return retrySendingMessageWithDelay(message); + // 그 이외의 에러 코드의 경우, 실패 결과 반환 + } else { + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), + SentResult.FAILED, + fcmException.getMessagingErrorCode()) + ); + } + // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 + // 같은 exception 이라면 사실상 재귀 호출이 됨 + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + // exception 없이 전송 성공 -> 전송 성공 응답 반환 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } + + /** + * FCM 서버에서의 응답이 429 일 경우에 대한 1분 지연 후 알림 재전송 메서드 + * + * @param message 재전송할 메세지 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO + */ + @Async("delayed-retry") + public CompletableFuture retrySendingMessageWithDelay(FcmMessage message) { + try { + // 정해진 정책에 따라 지연 시간 이후 재전송 시작 + Thread.sleep(ONE_MINUTE_DELAY); + // FCM 서버에 메세지 전송 -> 응답으로 반환된 Future 객체를 get + FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); + } catch (Exception e) { + // Fcm Exception 발생 + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); + // 500, 503에 해당하는 에러 코드일 경우, 재전송 메서드 호출 + if (errorCode.equals(MessagingErrorCode.UNAVAILABLE) || errorCode.equals(MessagingErrorCode.INTERNAL)) { + return retrySendingMessage(message, 3); + } else { + // 그 외의 경우, 전송 실패로 전송 결과 DTO 반환 + // 429가 다시 반환되었을 경우에도 실패 처리 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), + SentResult.FAILED, + fcmException.getMessagingErrorCode()) + ); + } + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + // exception 없이 전송 성공 -> 전송 성공 응답 반환 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } + + @Override + public int getStrategyVersion() { + return 1; + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V2/NotificationRetryStrategyV2.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V2/NotificationRetryStrategyV2.java new file mode 100644 index 00000000..81789753 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V2/NotificationRetryStrategyV2.java @@ -0,0 +1,146 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V2; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.sender.retryStretegy.NotificationRetryStrategy; +import com.trinity.ctc.global.exception.CustomException; +import com.trinity.ctc.global.exception.error_code.FcmErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Component +public class NotificationRetryStrategyV2 implements NotificationRetryStrategy { + + // 재발송 지수 백오프 + private static final int[] EXPONENTIAL_BACKOFF = {10000, 4000, 2000, 1000}; + // QUOTA_EXCEEDED 에 대한 재발송 최초 딜레이 + private static final int ONE_MINUTE_DELAY = 60000; + + /** + * FcmException 에 따라 재전송/실패 처리를 판단하는 메서드 + * + * @param e FcmException + * @param message 재전송할 메세지 + * @return 전송 결과 DTO + */ + @Override + public CompletableFuture retry(FirebaseMessagingException e, FcmMessage message) { + // Firebase Messaging Error 를 get + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + return switch (errorCode) { + // 500, 503에 해당할 경우 재전송 메서드 호출 + case UNAVAILABLE, INTERNAL -> retrySendingMessage(message, 3); + // 429에 해당할 경우 1분 후 재전송하는 메서드 호출 + case QUOTA_EXCEEDED -> retrySendingMessageWithDelay(message); + // 그 외의 경우 전송 실패로 전송 결과 DTO 반환 + default -> CompletableFuture.completedFuture( + new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode) + ); + }; + } + + /** + * FCM 서버에서의 응답이 500, 503 일 경우에 대한 알림 재전송 메서드 + * + * @param message 재전송할 메세지 정보를 담은 wrapper 객체 + * @param retryCount 재전송 횟수 + * @return 전송 결과 DTO + */ + @Async("retry-thread") + public CompletableFuture retrySendingMessage(FcmMessage message, int retryCount) { + try { + // 정해진 지수 백오프만큼 스레드 대기 + Thread.sleep(EXPONENTIAL_BACKOFF[0] + EXPONENTIAL_BACKOFF[retryCount]); + // FCM 서버에 메세지 전송 -> 응답으로 반환된 Future 객체를 get + FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); + } catch (Exception e) { + + // Fcm Exception 발생 + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); + retryCount--; + if (retryCount <= 0) CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), + SentResult.FAILED, + fcmException.getMessagingErrorCode()) + ); + + // 500, 503에 해당하는 에러 코드일 경우, 재전송 메서드 호출 + if (errorCode.equals(MessagingErrorCode.UNAVAILABLE) || errorCode.equals(MessagingErrorCode.INTERNAL)) { + return retrySendingMessage(message, retryCount); + // 429에 해당하는 에러 코드의 경우, 재전송 메서드 호출 + } else if (errorCode.equals(MessagingErrorCode.QUOTA_EXCEEDED)) { + return retrySendingMessageWithDelay(message); + // 그 이외의 에러 코드의 경우, 실패 결과 반환 + } else { + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), + SentResult.FAILED, + fcmException.getMessagingErrorCode()) + ); + } + // FcmException 에 따라 재전송/실패 처리를 판단하는 handleFcmException 메서드 호출 + // 같은 exception 이라면 사실상 재귀 호출이 됨 + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + // exception 없이 전송 성공 -> 전송 성공 응답 반환 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } + + /** + * FCM 서버에서의 응답이 429 일 경우에 대한 1분 지연 후 알림 재전송 메서드 + * + * @param message 재전송할 메세지 정보를 담은 wrapper 객체 + * @return 전송 결과 DTO + */ + @Async("retry-thread") + public CompletableFuture retrySendingMessageWithDelay(FcmMessage message) { + try { + // 정해진 정책에 따라 지연 시간 이후 재전송 시작 + Thread.sleep(ONE_MINUTE_DELAY); + // FCM 서버에 메세지 전송 -> 응답으로 반환된 Future 객체를 get + FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); + } catch (Exception e) { + // Fcm Exception 발생 + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); + // 500, 503에 해당하는 에러 코드일 경우, 재전송 메서드 호출 + if (errorCode.equals(MessagingErrorCode.UNAVAILABLE) || errorCode.equals(MessagingErrorCode.INTERNAL)) { + return retrySendingMessage(message, 3); + } else { + // 그 외의 경우, 전송 실패로 전송 결과 DTO 반환 + // 429가 다시 반환되었을 경우에도 실패 처리 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), + SentResult.FAILED, + fcmException.getMessagingErrorCode()) + ); + } + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + // exception 없이 전송 성공 -> 전송 성공 응답 반환 + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS)); + } + + @Override + public int getStrategyVersion() { + return 2; + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/NotificationRetryStrategyV3.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/NotificationRetryStrategyV3.java new file mode 100644 index 00000000..9c5ee9a1 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/NotificationRetryStrategyV3.java @@ -0,0 +1,51 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V3; + +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.sender.retryStretegy.NotificationRetryStrategy; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +@Component +@AllArgsConstructor +public class NotificationRetryStrategyV3 implements NotificationRetryStrategy { + + private final RetryDelayQueueProcessor delayQueueProcessor; + + /** + * FcmException 에 따라 재전송/실패 처리를 판단하는 메서드 + * 전송 실패 시, 모든 기록 저장 + * @param e FcmException + * @param message 재전송할 메세지 + * @return 전송 결과 DTO + */ + @Override + public CompletableFuture retry(FirebaseMessagingException e, FcmMessage message) { + // Firebase Messaging Error 를 get + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + switch (errorCode) { + // 500, 503에 해당할 경우 + case UNAVAILABLE, INTERNAL -> { + delayQueueProcessor.enqueue(message, 3, 10000); + } + // 429에 해당할 경우 + case QUOTA_EXCEEDED -> { + delayQueueProcessor.enqueue(message, 1, 50000); + + } + } + return CompletableFuture.completedFuture(new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode)); + } + + @Override + public int getStrategyVersion() { + return 3; + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/RetryDelayQueueProcessor.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/RetryDelayQueueProcessor.java new file mode 100644 index 00000000..3fd55a1d --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/RetryDelayQueueProcessor.java @@ -0,0 +1,84 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V3; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.service.NotificationHistoryService; +import com.trinity.ctc.global.exception.CustomException; +import com.trinity.ctc.global.exception.error_code.FcmErrorCode; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.DelayQueue; + +import static com.trinity.ctc.domain.notification.formatter.NotificationHistoryFormatter.formattingSingleNotificationHistory; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RetryDelayQueueProcessor { + + private static final int RETRY_DELAY = 10000; + + private final NotificationHistoryService notificationHistoryService; + private final DelayQueue queue = new DelayQueue<>(); + + @PostConstruct + public void init() { + Thread.ofVirtual() + .name("retry-queue-consumer-", 0) + .start(this::consume); + } + + public void enqueue(FcmMessage message, int retryCount, long delayMillis) { + if (retryCount <= 0) return; + queue.put(new RetryMessageV3(message, retryCount, delayMillis + RETRY_DELAY)); + } + + private void consume() { + while (true) { + try { + RetryMessageV3 task = queue.take(); + FcmMessage message = task.getFcmMessage(); + int retryCount = task.getRetryCount(); + + try { + FirebaseMessaging.getInstance().sendAsync(message.getMessage()).get(); + FcmSendingResultDto resultDto = new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS); + notificationHistoryService.saveSingleNotificationHistory( + formattingSingleNotificationHistory(message, resultDto)); + } catch (Exception e) { + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); + + if (errorCode == MessagingErrorCode.UNAVAILABLE || errorCode == MessagingErrorCode.INTERNAL) { + int nextRetryCount = retryCount - 1; + long delayMillis = (long) Math.pow(2, 3 - nextRetryCount) * 1000; + enqueue(message, nextRetryCount, delayMillis + RETRY_DELAY); + } + + FcmSendingResultDto failResult = new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode); + notificationHistoryService.saveSingleNotificationHistory( + formattingSingleNotificationHistory(message, failResult)); + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + } catch (Exception ex) { + // TODO: 예외 처리 로직 개선 필요 -> DLQueue 에러 핸들링 + log.error("❌ 소비 중 예외 발생", ex); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/RetryMessageV3.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/RetryMessageV3.java new file mode 100644 index 00000000..31b3e9b6 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V3/RetryMessageV3.java @@ -0,0 +1,29 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V3; + +import com.trinity.ctc.domain.notification.message.FcmMessage; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.concurrent.Delayed; +import java.util.concurrent.TimeUnit; + + +@Getter +@AllArgsConstructor +public class RetryMessageV3 implements Delayed { + + private final FcmMessage fcmMessage; + private final int retryCount; + private final long startTimeMillis; + + @Override + public long getDelay(TimeUnit unit) { + long delay = startTimeMillis - System.currentTimeMillis(); + return unit.convert(delay, TimeUnit.MILLISECONDS); + } + + @Override + public int compareTo(Delayed other) { + return Long.compare(this.getDelay(TimeUnit.MILLISECONDS), other.getDelay(TimeUnit.MILLISECONDS)); + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/NotificationRetryStrategyV4.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/NotificationRetryStrategyV4.java new file mode 100644 index 00000000..053eba21 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/NotificationRetryStrategyV4.java @@ -0,0 +1,58 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V4; + +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.message.FcmMessage; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.sender.retryStretegy.NotificationRetryStrategy; +import lombok.RequiredArgsConstructor; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.MessageChannel; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +@Component +@RequiredArgsConstructor +public class NotificationRetryStrategyV4 implements NotificationRetryStrategy { + + private final MessageChannel retryInputChannel; + + @Override + public CompletableFuture retry(FirebaseMessagingException e, FcmMessage message) { + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + int retryCount; + long retryDelay; + switch (errorCode) { + case UNAVAILABLE, INTERNAL -> { + retryCount = 3; + retryDelay = 10000 + (long) Math.pow(2, 0) * 1000; + } + case QUOTA_EXCEEDED -> { + retryCount = 1; + retryDelay = 60000; + } + default -> { + return CompletableFuture.completedFuture( + new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode) + ); + } + } + + RetryMessageV4 retryMessage = new RetryMessageV4(message, retryCount, errorCode); + retryInputChannel.send(MessageBuilder.withPayload(retryMessage).setHeader("delay", retryDelay).build()); + + return CompletableFuture.completedFuture( + new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED, errorCode) + ); + } + + + @Override + public int getStrategyVersion() { + return 4; + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryGateway.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryGateway.java new file mode 100644 index 00000000..358a6f11 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryGateway.java @@ -0,0 +1,11 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V4; + +import org.springframework.integration.annotation.Gateway; +import org.springframework.integration.annotation.MessagingGateway; +import org.springframework.messaging.Message; + +@MessagingGateway +public interface RetryGateway { + @Gateway(requestChannel = "retryInputChannel") + void retry(Message message); +} \ No newline at end of file diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryMessageV4.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryMessageV4.java new file mode 100644 index 00000000..ebf8cb3a --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryMessageV4.java @@ -0,0 +1,8 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V4; + +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.message.FcmMessage; + +public record RetryMessageV4(FcmMessage message, int retryCount, MessagingErrorCode errorCode) { + +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryV4IntegrationConfig.java b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryV4IntegrationConfig.java new file mode 100644 index 00000000..420ba480 --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/notification/sender/retryStretegy/V4/RetryV4IntegrationConfig.java @@ -0,0 +1,117 @@ +package com.trinity.ctc.domain.notification.sender.retryStretegy.V4; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.MessagingErrorCode; +import com.trinity.ctc.domain.notification.dto.FcmSendingResultDto; +import com.trinity.ctc.domain.notification.result.SentResult; +import com.trinity.ctc.domain.notification.service.NotificationHistoryService; +import com.trinity.ctc.global.exception.CustomException; +import com.trinity.ctc.global.exception.error_code.FcmErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.annotation.IntegrationComponentScan; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.integration.channel.QueueChannel; +import org.springframework.integration.config.EnableIntegration; +import org.springframework.integration.dsl.IntegrationFlow; +import org.springframework.integration.scheduling.PollerMetadata; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.support.PeriodicTrigger; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.Executor; + +import static com.trinity.ctc.domain.notification.formatter.NotificationHistoryFormatter.formattingSingleNotificationHistory; + +@Slf4j +@Configuration +@EnableIntegration +@IntegrationComponentScan +@RequiredArgsConstructor +public class RetryV4IntegrationConfig { + + private final NotificationHistoryService notificationHistoryService; + + @Bean(name = "retryInputChannel") + public DirectChannel retryInputChannel() { + return new DirectChannel(); + } + + @Bean(name = "retrySendingChannel") + public QueueChannel retrySendingChannel() { + return new QueueChannel(); + } + + @Bean(name = PollerMetadata.DEFAULT_POLLER) + public PollerMetadata defaultPoller(@Qualifier("response-handler") Executor executor) { + PollerMetadata poller = new PollerMetadata(); + poller.setTrigger(new PeriodicTrigger(Duration.ofMillis(500))); + poller.setMaxMessagesPerPoll(10); + poller.setTaskExecutor(executor); + return poller; + } + + @Bean + public IntegrationFlow retryFlow() { + return IntegrationFlow.from("retryInputChannel") + .route( + RetryMessageV4::errorCode, + mapping -> mapping + .channelMapping(MessagingErrorCode.UNAVAILABLE, "retrySendingChannel") + .channelMapping(MessagingErrorCode.INTERNAL, "retrySendingChannel") + .channelMapping(MessagingErrorCode.QUOTA_EXCEEDED, "retrySendingChannel") + ) + .get(); + } + + @Bean + public IntegrationFlow RetrySendingFlow(@Qualifier("retrySendingChannel") MessageChannel retryChannel) { + return IntegrationFlow.from(retryChannel) + .handle(Message.class, (message, headers) -> { + RetryMessageV4 payload = (RetryMessageV4) message.getPayload(); + int retryCount = payload.retryCount(); + + try { + FirebaseMessaging.getInstance().sendAsync(payload.message().getMessage()).get(); + ; + FcmSendingResultDto success = new FcmSendingResultDto(LocalDateTime.now(), SentResult.SUCCESS); + notificationHistoryService.saveSingleNotificationHistory(formattingSingleNotificationHistory(payload.message(), success)); + } catch (Exception e) { + if (e.getCause() instanceof FirebaseMessagingException fcmException) { + MessagingErrorCode errorCode = fcmException.getMessagingErrorCode(); + + FcmSendingResultDto fail = new FcmSendingResultDto(LocalDateTime.now(), SentResult.FAILED); + notificationHistoryService.saveSingleNotificationHistory(formattingSingleNotificationHistory(payload.message(), fail)); + + int nextRetryCount = retryCount - 1; + if (nextRetryCount <= 0) { + return null; + } + + if (errorCode == MessagingErrorCode.UNAVAILABLE || errorCode == MessagingErrorCode.INTERNAL) { + RetryMessageV4 nextRetry = new RetryMessageV4(payload.message(), nextRetryCount, errorCode); + long delayMillis = (long) Math.pow(2, 3 - nextRetryCount) * 1000; + return MessageBuilder.withPayload(nextRetry) + .setHeader("delay", delayMillis) + .build(); + } + } else { + // TODO: 예외 처리 로직 개선 필요 -> FirebaseMessagingException 이외의 에러 핸들링 + log.error("❌ 처리되지 않은 에러: ", e); + // 현재는 FirebaseMessagingException 이외의 Exception 에 대해서 일괄 전송 실패 요청 에러 + throw new CustomException(FcmErrorCode.SENDING_REQUEST_FAILED); + } + } + return null; + }) + .bridge() + .get(); + } +} diff --git a/src/main/java/com/trinity/ctc/domain/notification/service/ConfirmationNotificationService.java b/src/main/java/com/trinity/ctc/domain/notification/service/ConfirmationNotificationService.java index 37e29caa..fcc6c262 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/service/ConfirmationNotificationService.java +++ b/src/main/java/com/trinity/ctc/domain/notification/service/ConfirmationNotificationService.java @@ -3,8 +3,7 @@ import com.trinity.ctc.domain.fcm.entity.Fcm; import com.trinity.ctc.domain.notification.entity.NotificationHistory; import com.trinity.ctc.domain.notification.message.FcmMessage; -import com.trinity.ctc.domain.notification.sender.NotificationSender; -import com.trinity.ctc.domain.notification.type.NotificationType; +import com.trinity.ctc.domain.notification.sender.NotificationSenderV1; import com.trinity.ctc.domain.reservation.entity.Reservation; import com.trinity.ctc.domain.reservation.repository.ReservationRepository; import com.trinity.ctc.domain.user.entity.User; @@ -37,14 +36,15 @@ public class ConfirmationNotificationService { private final UserRepository userRepository; private final ReservationRepository reservationRepository; private final NotificationHistoryService notificationHistoryService; - private final NotificationSender notificationSender; + private final NotificationSenderV1 notificationSenderV1; + /** * 예약 완료 알림 전송 메서드 * @param userId 사용자 ID * @param reservationId 예약 ID */ - @Async("reservation-completed-notification") + @Async("confirmation-notification") @Transactional(readOnly = true) public void sendReservationCompletedNotification(Long userId, Long reservationId) { // 사용자 조회, 해당하는 사용자가 없으면 404 반환 @@ -59,9 +59,9 @@ public void sendReservationCompletedNotification(Long userId, Long reservationId // 사용자의 FCM 토큰 별로 메세지 전송 for (Fcm fcm : tokenList) { // 전송을 위한 Message 의 Wrapper 객체 포멧팅 - FcmMessage message = formattingReservationCompletedNotification(fcm, reservation); + FcmMessage message = formattingReservationCompletedNotification(fcm, reservation, RESERVATION_COMPLETED); // 전송 메서드 호출 후 반환된 전송 응답을 list 에 저장 - resultList.add(sendSingleNotification(message, RESERVATION_COMPLETED)); + resultList.add(sendSingleNotification(message)); } // 전송 결과로 반환된 각 future 의 전달이 완료될 때까지 기다린 후 알림 history 리스트로 반환 @@ -79,7 +79,7 @@ public void sendReservationCompletedNotification(Long userId, Long reservationId * @param reservationId 예약 ID * @param isCODPassed 예약시점에 따른 예약 비용 반환 여부(정책) */ - @Async("reservation-canceled-notification") + @Async("confirmation-notification") @Transactional(readOnly = true) public void sendReservationCanceledNotification(Long userId, Long reservationId, boolean isCODPassed) { @@ -95,9 +95,9 @@ public void sendReservationCanceledNotification(Long userId, Long reservationId, // 사용자의 FCM 토큰 별로 메세지 전송 for (Fcm fcm : tokenList) { // 전송을 위한 Message 의 Wrapper 객체 포멧팅 - FcmMessage message = formattingReservationCanceledNotification(fcm, reservation, isCODPassed); + FcmMessage message = formattingReservationCanceledNotification(fcm, reservation, isCODPassed, RESERVATION_CANCELED); // 전송 메서드 호출 후 반환된 전송 응답을 list 에 저장 - resultList.add(sendSingleNotification(message, RESERVATION_CANCELED)); + resultList.add(sendSingleNotification(message)); } // 전송 결과로 반환된 각 future 의 전달이 완료될 때까지 기다린 후 알림 history 리스트로 반환 @@ -112,14 +112,13 @@ public void sendReservationCanceledNotification(Long userId, Long reservationId, /** * 단 건의 메세지 발송 메서드를 호출하고 반환된 결과를 처리하는 내부 메서드 * @param message 발송할 Message 의 Wrapper 객체 - * @param type 알림 타입 * @return 알림 history */ - private CompletableFuture sendSingleNotification(FcmMessage message, NotificationType type) { + private CompletableFuture sendSingleNotification(FcmMessage message) { // 단 건의 메세지를 발송하는 메서드 호출 - return notificationSender.sendSingleNotification(message) + return notificationSenderV1.sendSingleNotification(message) // 반환된 전송 결과 dto 와 Message data 로 알림 History 객체를 생성하는 메서드 호출 - .thenApplyAsync(result -> formattingSingleNotificationHistory(message, result, type)); + .thenApplyAsync(result -> formattingSingleNotificationHistory(message, result)); } } diff --git a/src/main/java/com/trinity/ctc/domain/notification/service/NotificationHistoryService.java b/src/main/java/com/trinity/ctc/domain/notification/service/NotificationHistoryService.java index b8d30464..55fcfe7f 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/service/NotificationHistoryService.java +++ b/src/main/java/com/trinity/ctc/domain/notification/service/NotificationHistoryService.java @@ -1,6 +1,7 @@ package com.trinity.ctc.domain.notification.service; import com.trinity.ctc.domain.notification.entity.NotificationHistory; +import com.trinity.ctc.domain.notification.repository.JpaNotificationHistoryRepository; import com.trinity.ctc.domain.notification.repository.NotificationHistoryRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,15 +19,40 @@ @EnableAsync @RequiredArgsConstructor public class NotificationHistoryService { + private final JpaNotificationHistoryRepository jpaNotificationHistoryRepository; private final NotificationHistoryRepository notificationHistoryRepository; + @Transactional(propagation = REQUIRES_NEW) + public void saveNotificationHistory(List list) { + if (list.size() <= 50) { + saveFewNotificationHistory(list); + } else { + saveBatchNotificationHistory(list); + } + } + + @Transactional + public void saveSingleNotificationHistory(NotificationHistory history) { + jpaNotificationHistoryRepository.save(history); + } + /** * 전송된 알림 히스토리를 전부 history 테이블에 저장하는 메서드 + * 소량의 발송 건에 대해 jpa saveAll로 저장 + * @param notificationHistoryList 알림 history 리스트 + */ + @Async("save-notification-history") + public void saveFewNotificationHistory(List notificationHistoryList) { + jpaNotificationHistoryRepository.saveAll(notificationHistoryList); + } + + /** + * 전송된 알림 히스토리를 전부 history 테이블에 저장하는 메서드 + * 대량의 발송 건에 대해 jdbc batch insert 로 저장 * @param notificationHistoryList 알림 history 리스트 */ - @Transactional(propagation = REQUIRES_NEW) @Async("save-notification-history") - public void saveNotificationHistory(List notificationHistoryList) { - notificationHistoryRepository.saveAll(notificationHistoryList); + public void saveBatchNotificationHistory(List notificationHistoryList) { + notificationHistoryRepository.batchInsertNotificationHistories(notificationHistoryList); } } diff --git a/src/main/java/com/trinity/ctc/domain/notification/service/ReservationNotificationService.java b/src/main/java/com/trinity/ctc/domain/notification/service/ReservationNotificationService.java index f4243f7e..569509b8 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/service/ReservationNotificationService.java +++ b/src/main/java/com/trinity/ctc/domain/notification/service/ReservationNotificationService.java @@ -6,7 +6,7 @@ import com.trinity.ctc.domain.notification.entity.ReservationNotification; import com.trinity.ctc.domain.notification.message.FcmMessage; import com.trinity.ctc.domain.notification.repository.ReservationNotificationRepository; -import com.trinity.ctc.domain.notification.sender.NotificationSender; +import com.trinity.ctc.domain.notification.sender.NotificationSenderV1; import com.trinity.ctc.domain.notification.type.NotificationType; import com.trinity.ctc.domain.reservation.entity.Reservation; import com.trinity.ctc.domain.reservation.repository.ReservationRepository; @@ -17,6 +17,9 @@ import com.trinity.ctc.global.exception.error_code.UserErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; @@ -46,14 +49,17 @@ public class ReservationNotificationService { private final UserRepository userRepository; private final ReservationRepository reservationRepository; private final ReservationNotificationRepository reservationNotificationRepository; - private final NotificationSender notificationSender; + private final NotificationSenderV1 notificationSenderV1; private final NotificationHistoryService notificationHistoryService; + // 알림 목록 조회 시, 한 번에 가져오는 slice의 크기 + private final int SLICES_PER_PAGE = 5000; // 발송할 알림의 batch-size(Firebase Messaging service 에서 send 메서드의 요청으로 보낼 수 있는 최대 건수) private final int BATCH_SIZE = 500; /** * 예약 이벤트 발생 시, 예약 확인 알림(당일 알림, 1시간 전 알림)을 포멧팅하여 DB에 저장하는 메서드 + * * @param userId 사용자 ID * @param reservationId 예약 정보 ID */ @@ -74,6 +80,7 @@ public void registerReservationNotification(long userId, long reservationId) { /** * 예약 취소 시, 해당 예약에 대한 알림 data 를 삭제하는 메서드 + * * @param reservationId 예약 ID */ @Transactional @@ -83,85 +90,97 @@ public void deleteReservationNotification(Long reservationId) { /** * 매일 8시에 당일 예약 알림을 보내는 로직을 처리하는 메서드 + * * @param scheduledDate 당일 날짜(yyyy-MM-dd) */ - @Async("daily-reservation-notification") public void sendDailyNotification(LocalDate scheduledDate) { - log.info("✅✅✅ 당일 예약 알림 발송 시작!"); + log.info("✅✅✅ 당일 예약 알림 처리 시작!"); long startTime = System.nanoTime(); // 시작 시간 측정 - // 알림 타입과 오늘 날짜로 당일 예약 알림 정보 가져오기 - List reservationNotificationList = reservationNotificationRepository - .findAllByTypeAndScheduledDate(DAILY_NOTIFICATION, scheduledDate); - // 해당 하는 당일 예약 알림이 없을 시, return - if (reservationNotificationList.isEmpty()) return; + int page = 0; + Slice slice; - // 알림 발송 메서드 호출 -> 알림 history List를 비동기로 반환하는 Future 객체 리스트 반환 - List>> resultList = sendNotification(reservationNotificationList, DAILY_NOTIFICATION); + // slice-size 별로 발송 처리 + do { + Pageable pageable = PageRequest.of(page, SLICES_PER_PAGE); + // 알림 타입과 오늘 날짜로 당일 예약 알림 정보 가져오기 + slice = reservationNotificationRepository + .findSliceByTypeAndScheduledDate(DAILY_NOTIFICATION, scheduledDate, pageable); - long endTime = System.nanoTime(); // 종료 시간 측정 - long elapsedTimeTosend = endTime - startTime; // 발송까지의 경과 시간 - log.info("✅✅✅ 당일 예약 알림 발송 실행 시간: {} ms", elapsedTimeTosend / 1_000_000); + List reservationNotificationList = slice.getContent(); - // 전송 결과로 반환된 각 future 의 전달이 완료될 때까지 기다린 후 알림 history 리스트로 반환 - List notificationHistoryList = resultList.stream() - .map(CompletableFuture::join) - .flatMap(List::stream) // 여러 리스트를 하나로 합침 - .toList(); + if (reservationNotificationList.isEmpty()) break; - long elapsedTimeToResponse = endTime - startTime; // 응답 반환까지의 경과 시간 - log.info("✅✅✅ 당일 예약 알림 응답 시간: {} ms", elapsedTimeToResponse / 1_000_000); + reservationNotificationList.forEach(n -> { + n.getUser().getFcmList(); + }); + + sendNotification(reservationNotificationList, DAILY_NOTIFICATION); + + page++; + } while (slice.hasNext()); + + long endTimeToProcess = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToProcess = endTimeToProcess - startTime; // 발송 처리까지의 경과 시간 + log.info("✅✅✅ 당일 예약 알림 처리 시간: {} ms", elapsedTimeToProcess / 1_000_000); - // 전송된 알림의 히스토리를 전부 history 테이블에 저장하는 메서드 호출 - notificationHistoryService.saveNotificationHistory(notificationHistoryList); // 해당 날짜에 스케줄된 당일 예약 알림을 table에서 삭제하는 메서드 호출 deleteDailyNReservationNotification(); } /** * 예약 1시간 전 알림을 보내는 메서드 + * * @param scheduledTime 스케줄된 시간(yyyy-MM-dd HH:mm) */ - @Async("hourly-reservation-notification") public void sendHourBeforeNotification(LocalDateTime scheduledTime) { - log.info("✅✅✅ 한시간 전 예약 알림 발송 시작!"); + log.info("✅✅✅ 한 시간 전 예약 알림 처리 시작!"); long startTime = System.nanoTime(); // 시작 시간 측정 - // 알림 타입과 현재 시간으로 보낼 예약 1시간 전 알림 정보 가져오기 - List reservationNotificationList = reservationNotificationRepository - .findAllByTypeAndScheduledTime(BEFORE_ONE_HOUR_NOTIFICATION, scheduledTime); - // 해당 하는 당일 예약 알림이 없을 시, return - if (reservationNotificationList.isEmpty()) return; + int page = 0; + Slice slice; - // 알림 발송 메서드 호출 -> 알림 history List를 비동기로 반환하는 Future 객체 리스트 반환 - List>> resultList = sendNotification(reservationNotificationList, BEFORE_ONE_HOUR_NOTIFICATION); + // slice-size 별로 발송 처리 + do { + Pageable pageable = PageRequest.of(page, SLICES_PER_PAGE); + slice = reservationNotificationRepository.findSliceByTypeAndScheduledTime( + BEFORE_ONE_HOUR_NOTIFICATION, scheduledTime, pageable + ); - long endTime = System.nanoTime(); // 종료 시간 측정 - long elapsedTimeTosend = endTime - startTime; // 발송까지의 경과 시간 - log.info("✅✅✅ 한시간 전 예약 알림 발송 실행 시간: {} ms", elapsedTimeTosend / 1_000_000); + List reservationNotificationList = slice.getContent(); - // 전송 결과로 반환된 각 future 의 전달이 완료될 때까지 기다린 후 알림 history 리스트로 반환 - List notificationHistoryList = resultList.stream() - .map(CompletableFuture::join) - .flatMap(List::stream) // 여러 리스트를 하나로 합침 - .toList(); + if (reservationNotificationList.isEmpty()) break; - long elapsedTimeToResponse = endTime - startTime; // 응답 반환까지의 경과 시간 - log.info("✅✅✅ 한시간 전 예약 알림 응답 시간: {} ms", elapsedTimeToResponse / 1_000_000); + reservationNotificationList.forEach(n -> { + n.getUser().getFcmList(); + }); + + sendNotification(reservationNotificationList, BEFORE_ONE_HOUR_NOTIFICATION); + + page++; + } while (slice.hasNext()); + + long endTimeToProcess = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToProcess = endTimeToProcess - startTime; // 발송 처리까지의 경과 시간 + log.info("✅✅✅ 한 시간 전 예약 알림 처리 시간: {} ms", elapsedTimeToProcess / 1_000_000); - // 전송된 알림의 히스토리를 전부 history 테이블에 저장하는 메서드 호출 - notificationHistoryService.saveNotificationHistory(notificationHistoryList); // 해당 시간에 스케줄된 한시간 전 예약 알림을 table에서 삭제하는 메서드 deleteHourlyNReservationNotification(scheduledTime); } /** * 예약 알림 별 발송할 Message 객체를 생성하고, batch-size 별로 발송하는 메서드 + * * @param reservationNotificationList 예약 알림 리스트 - * @param type 알림 타입 + * @param type 알림 타입 * @return 알림 history List를 비동기로 반환하는 CompletableFuture 객체 리스트 */ - private List>> sendNotification(List reservationNotificationList, NotificationType type) { + @Async("reservation-notification") + public void sendNotification(List reservationNotificationList, NotificationType type) { + + log.info("✅✅✅ 예약 알림 발송 시작!"); + long startTime = System.nanoTime(); // 시작 시간 측정 + // 반환 리스트 초기화 List>> resultList = new ArrayList<>(); // FcmMessage 리스트 초기화 @@ -170,7 +189,7 @@ private List>> sendNotification(List // 예약 알림 리스트 내의 data 와 각 알림 별 수신자의 Fcm 토큰으로 FcmMessage 생성 -> 리스트에 추가 for (ReservationNotification notification : reservationNotificationList) { for (Fcm fcm : notification.getUser().getFcmList()) { - FcmMessage message = createMessageWithUrl(notification.getTitle(), notification.getBody(), notification.getUrl(), fcm); + FcmMessage message = createMessageWithUrl(notification.getTitle(), notification.getBody(), notification.getUrl(), fcm, type); messageList.add(message); } } @@ -178,27 +197,32 @@ private List>> sendNotification(List // batch-size(500)으로 발송할 메세지 리스트 파티셔닝 List> batches = Lists.partition(messageList, BATCH_SIZE); - int batchCount = batches.size(); // 배치 작업 전체 수 - int sendingCount = 0; // 발송된 배치 수 측정 - // 파티셔닝된 배치 별로 발송 로직 수행 for (List batch : batches) { - log.info("✅ 알림 발송 시작 Batch {}", sendingCount); - // 어러 건의 알림 발송 메서드 호출 - CompletableFuture> sendingResult = notificationSender.sendEachNotification(batch) + CompletableFuture> sendingResult = notificationSenderV1.sendEachNotification(batch) // 비동기로 발송 결과와 fcm, 알림 타입에 맞춰 알림 history List 를 생성하는 메서드 호출 -> 발송 응답 수신 시, List를 반환 - .thenApplyAsync(resultDtoList -> formattingMultipleNotificationHistory(batch, resultDtoList, type)); + .thenApplyAsync(resultDtoList -> formattingMultipleNotificationHistory(batch, resultDtoList)); // 반환할 결과 리스트에 추가 resultList.add(sendingResult); - - sendingCount++; // 발송된 배치 수 ++ - log.info("✅ 알림 발송 완료 Batch {}", sendingCount); - if (sendingCount == batchCount) log.info("✅✅ 전송완료!!!!!!!!!!!!!"); // if(발송 수 == 총 배치 작업 수) } - // 알림 history List를 비동기로 반환하는 CompletableFuture 객체 리스트 반환 - return resultList; + long endTimeToSend = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToSend = endTimeToSend - startTime; // 발송까지의 경과 시간 + log.info("✅✅✅ 예약 알림 발송 실행 시간: {} ms", elapsedTimeToSend / 1_000_000); + + // 전송 결과로 반환된 각 future 의 전달이 완료될 때까지 기다린 후 알림 history 리스트로 반환 + List notificationHistoryList = resultList.stream() + .map(CompletableFuture::join) + .flatMap(List::stream) // 여러 리스트를 하나로 합침 + .toList(); + + long endTimeToResponse = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToResponse = endTimeToResponse - startTime; // 응답 반환까지의 경과 시간 + log.info("✅✅✅ 예약 알림 응답 시간: {} ms", elapsedTimeToResponse / 1_000_000); + + // 전송된 알림의 히스토리를 전부 history 테이블에 저장하는 메서드 호출 + notificationHistoryService.saveNotificationHistory(notificationHistoryList); } /** @@ -211,6 +235,7 @@ private void deleteDailyNReservationNotification() { /** * 발송한 1시간 전 예약 알림을 삭제하는 내부 메서드 + * * @param scheduledTime 발송 스케줄 시간 */ private void deleteHourlyNReservationNotification(LocalDateTime scheduledTime) { diff --git a/src/main/java/com/trinity/ctc/domain/notification/service/SeatNotificationService.java b/src/main/java/com/trinity/ctc/domain/notification/service/SeatNotificationService.java index d600a03f..4612e514 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/service/SeatNotificationService.java +++ b/src/main/java/com/trinity/ctc/domain/notification/service/SeatNotificationService.java @@ -10,7 +10,7 @@ import com.trinity.ctc.domain.notification.message.FcmMulticastMessage; import com.trinity.ctc.domain.notification.repository.SeatNotificationRepository; import com.trinity.ctc.domain.notification.repository.SeatNotificationSubscriptionRepository; -import com.trinity.ctc.domain.notification.sender.NotificationSender; +import com.trinity.ctc.domain.notification.sender.NotificationSenderV1; import com.trinity.ctc.domain.notification.type.NotificationType; import com.trinity.ctc.domain.reservation.repository.ReservationRepository; import com.trinity.ctc.domain.reservation.status.ReservationStatus; @@ -25,6 +25,9 @@ import com.trinity.ctc.global.kakao.service.AuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; @@ -57,8 +60,10 @@ public class SeatNotificationService { private final NotificationHistoryService notificationHistoryService; private final AuthService authService; - private final NotificationSender notificationSender; + private final NotificationSenderV1 notificationSenderV1; + // 알림 목록 조회 시, 한 번에 가져오는 slice의 크기 + private final int SLICES_PER_PAGE = 5000; // 발송할 알림의 batch-size(Firebase Messaging service 에서 send 메서드의 요청으로 보낼 수 있는 최대 건수) private final int BATCH_SIZE = 500; @@ -175,43 +180,72 @@ public SubscriptionListResponse getSeatNotifications() { @Async("empty-seat-notification") @Transactional(readOnly = true) public void sendSeatNotification(long seatId) { + log.info("✅✅✅ 빈자리 알림 처리 시작!"); + long startTime = System.nanoTime(); // 시작 시간 측정 + + int page = 0; + Slice slice; + + // 빈자리 알림 정보 조회, 없을 시 return + SeatNotification seatNotification = seatNotificationRepository.findBySeatId(seatId).orElse(null); + if(seatNotification == null) return; + + // slice-size 별로 발송 처리 + do { + Pageable pageable = PageRequest.of(page, SLICES_PER_PAGE); + // 빈자리 알림에 대한 구독자 리스트 조회 -> user 를 fetch join + // 없을 시 return + slice = seatNotificationSubscriptionRepository.findSliceBySeatNotification(seatNotification, pageable); + List seatNotificationSubscriptionList = slice.getContent(); + if (seatNotificationSubscriptionList.isEmpty()) return; + + // 빈자리 알림 구독 리스트에서 userList를 get + List userList = seatNotificationSubscriptionList.stream().map(SeatNotificationSubscription::getUser).toList(); + // userList에서 각 user의 Fcm을 get -> list로 변환 + List fcmList = userList.stream().map(User::getFcmList).flatMap(List::stream).toList(); + + sendNotification(seatNotification, fcmList, SEAT_NOTIFICATION); + + page++; + } while (slice.hasNext()); + + long endTimeToProcess = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToProcess = endTimeToProcess - startTime; // 발송 처리까지의 경과 시간 + log.info("✅✅✅ 빈자리 알림 처리 시간: {} ms", elapsedTimeToProcess / 1_000_000); + } + + /** + * 발송할 MulticastMessage 객체를 생성하고 발송하는 메서드 + * @param seatNotification 빈자리 알림 entity + * @param fcmList 빈자리 알림 구독자 들의 Fcm list + */ + @Transactional(readOnly = true) + public void sendNotification(SeatNotification seatNotification, List fcmList, NotificationType type) { log.info("✅✅✅ 빈자리 알림 발송 시작!"); long startTime = System.nanoTime(); // 시작 시간 측정 - // 전송 메서드의 반환값인 알림 history List 를 저장할 list 초기화 - List>> resultList = new ArrayList<>(); - // 빈자리 알림 정보 조회, 없을 시 404 반환 - SeatNotification seatNotification = seatNotificationRepository.findBySeatId(seatId) - .orElseThrow(() -> new CustomException(NotificationErrorCode.NOT_FOUND)); - // 빈자리 알림 구독자 리스트 조회 -> user와 fcm을 fetch join - List seatNotificationSubscriptionList = seatNotificationSubscriptionRepository.findAllBySeatNotification(seatNotification); - // 없을 시 404 반환 - if (seatNotificationSubscriptionList.isEmpty()) throw new CustomException(NotificationErrorCode.NO_SUBSCRIPTION); - // 빈자리 알림 구독 리스트에서 userList를 get - List userList = seatNotificationSubscriptionList.stream().map(SeatNotificationSubscription::getUser).toList(); - // userList에서 각 user의 Fcm을 get -> list로 변환 - List fcmList = userList.stream().map(User::getFcmList).flatMap(List::stream).toList(); // batch-size(500)으로 발송 대상 FCM 리스트 파티셔닝 List> batches = Lists.partition(fcmList, BATCH_SIZE); - int batchCount = batches.size(); // 배치 작업 전체 수 - int sendingCount = 0; // 발송된 배치 수 측정 + // 전송 메서드의 반환값인 알림 history List 를 저장할 list 초기화 + List>> resultList = new ArrayList<>(); // 파티셔닝된 배치 별로 발송 로직 수행 for (List batch : batches) { - log.info("✅ 알림 발송 시작 Batch {}", sendingCount); - + // 발송할 MulticasyMessage 정보를 담은 Wrapper 객체 생성 + FcmMulticastMessage multicastMessage = createMulticastMessageWithUrl( + seatNotification.getTitle(), seatNotification.getBody(), seatNotification.getUrl(), batch, type); + // MulticastMessage 발송 메서드 호출 + CompletableFuture> sendingResult = notificationSenderV1.sendMulticastNotification(multicastMessage) + // 비동기로 알림 정보와 발송 결과, 알림 타입에 맞춰 알림 history List 를 생성하는 메서드 호출 -> 발송 응답 수신 시, List를 반환 + .thenApplyAsync(resultDtoList -> formattingMulticastNotificationHistory(multicastMessage, resultDtoList)); // 알림 발송 메서드 호출 -> 반환값 resultList에 추가(반환 타입: CompletableFuture>) - resultList.add(sendNotification(seatNotification, batch, SEAT_NOTIFICATION)); - - sendingCount++; // 발송된 배치 수 ++ - log.info("✅ 알림 발송 완료 Batch {}", sendingCount); - if (sendingCount == batchCount) log.info("✅✅ 전송완료!!!!!!!!!!!!!"); // if(발송 수 == 총 배치 작업 수) + resultList.add(sendingResult); } - long endTime = System.nanoTime(); // 종료 시간 측정 - long elapsedTimeTosend = endTime - startTime; // 발송까지의 경과 시간 - log.info("✅✅✅ 당일 예약 알림 발송 실행 시간: {} ms", elapsedTimeTosend / 1_000_000); + long endTimeToSend = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToSend = endTimeToSend - startTime; // 발송까지의 경과 시간 + log.info("✅✅✅ 빈자리 알림 발송 실행 시간: {} ms", elapsedTimeToSend / 1_000_000); // 전송 결과로 반환된 각 future 의 전달이 완료될 때까지 기다린 후 알림 history 리스트로 반환 List notificationHistoryList = resultList.stream() @@ -219,30 +253,14 @@ public void sendSeatNotification(long seatId) { .flatMap(List::stream) // 여러 리스트를 하나로 합침 .toList(); - long elapsedTimeToResponse = endTime - startTime; // 응답 반환까지의 경과 시간 - log.info("✅✅✅ 당일 예약 알림 응답 시간: {} ms", elapsedTimeToResponse / 1_000_000); + long endTimeToResponse = System.nanoTime(); // 종료 시간 측정 + long elapsedTimeToResponse = endTimeToResponse - startTime; // 응답 반환까지의 경과 시간 + log.info("✅✅✅ 빈자리 알림 응답 시간: {} ms", elapsedTimeToResponse / 1_000_000); // 전송된 알림의 히스토리를 전부 history 테이블에 저장하는 메서드 호출 notificationHistoryService.saveNotificationHistory(notificationHistoryList); } - /** - * 발송할 MulticastMessage 객체를 생성하고 발송하는 메서드 - * @param seatNotification 빈자리 알림 entity - * @param batch batch-size 별로 나눈 Fcm list - * @return 알림 history List를 비동기로 반환하는 CompletableFuture 객체 리스트 - */ - @Transactional(readOnly = true) - public CompletableFuture> sendNotification(SeatNotification seatNotification, List batch, NotificationType type) { - // 발송할 MulticasyMessage 정보를 담은 Wrapper 객체 생성 - FcmMulticastMessage multicastMessage = createMulticastMessageWithUrl( - seatNotification.getTitle(), seatNotification.getBody(), seatNotification.getUrl(), batch); - // MulticastMessage 발송 메서드 호출 - return notificationSender.sendMulticastNotification(multicastMessage) - // 비동기로 알림 정보와 발송 결과, 알림 타입에 맞춰 알림 history List 를 생성하는 메서드 호출 -> 발송 응답 수신 시, List를 반환 - .thenApplyAsync(resultList -> formattingMulticastNotificationHistory(multicastMessage, resultList, type)); - } - /** * 날짜/시간이 지난 자리에 대한 빈자리 알림 메세지/빈자리 알림 신청에 대한 데이터 삭제 메서드 * @param currentDate 현재 날짜 diff --git a/src/main/java/com/trinity/ctc/domain/notification/type/NotificationType.java b/src/main/java/com/trinity/ctc/domain/notification/type/NotificationType.java index a92527f8..53f2f2a5 100644 --- a/src/main/java/com/trinity/ctc/domain/notification/type/NotificationType.java +++ b/src/main/java/com/trinity/ctc/domain/notification/type/NotificationType.java @@ -1,8 +1,5 @@ package com.trinity.ctc.domain.notification.type; -import lombok.Getter; - -@Getter public enum NotificationType { SEAT_NOTIFICATION, DAILY_NOTIFICATION, diff --git a/src/main/java/com/trinity/ctc/domain/reservation/dto/ReservationAvailabilityResponse.java b/src/main/java/com/trinity/ctc/domain/reservation/dto/ReservationAvailabilityResponse.java index e22cb0fe..416bbf97 100644 --- a/src/main/java/com/trinity/ctc/domain/reservation/dto/ReservationAvailabilityResponse.java +++ b/src/main/java/com/trinity/ctc/domain/reservation/dto/ReservationAvailabilityResponse.java @@ -1,14 +1,15 @@ package com.trinity.ctc.domain.reservation.dto; +import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Getter; -import java.time.LocalDate; - @Getter @AllArgsConstructor public class ReservationAvailabilityResponse { + private LocalDate date; private boolean available; + private Long restaurantId; } diff --git a/src/main/java/com/trinity/ctc/domain/reservation/repository/ReservationRepository.java b/src/main/java/com/trinity/ctc/domain/reservation/repository/ReservationRepository.java index 5ba05ba5..e5762822 100644 --- a/src/main/java/com/trinity/ctc/domain/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/trinity/ctc/domain/reservation/repository/ReservationRepository.java @@ -2,6 +2,9 @@ import com.trinity.ctc.domain.reservation.entity.Reservation; import com.trinity.ctc.domain.reservation.status.ReservationStatus; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,26 +12,22 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDate; -import java.time.LocalTime; -import java.util.List; - @Repository public interface ReservationRepository extends JpaRepository { @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END FROM Reservation r " + - "WHERE r.user.id = :userId " + - "AND r.restaurant.id = :restaurantId " + - "AND r.reservationDate = :selectedDate " + - "AND r.reservationTime.timeSlot = :reservationTime " + - "AND r.seatType.id = :seatTypeId " + - "AND r.status IN (:statuses)") + "WHERE r.user.id = :userId " + + "AND r.restaurant.id = :restaurantId " + + "AND r.reservationDate = :selectedDate " + + "AND r.reservationTime.timeSlot = :reservationTime " + + "AND r.seatType.id = :seatTypeId " + + "AND r.status IN (:statuses)") boolean existsByReservationDataV1(@Param("userId") Long userId, - @Param("restaurantId") Long restaurantId, - @Param("selectedDate") LocalDate selectedDate, - @Param("reservationTime") LocalTime reservationTime, - @Param("seatTypeId") Long seatTypeId, - @Param("statuses") List statuses); + @Param("restaurantId") Long restaurantId, + @Param("selectedDate") LocalDate selectedDate, + @Param("reservationTime") LocalTime reservationTime, + @Param("seatTypeId") Long seatTypeId, + @Param("statuses") List statuses); @Query(value = """ SELECT EXISTS ( @@ -42,35 +41,35 @@ AND r.status IN (:statuses) ) """, nativeQuery = true) boolean existsReservationNative( - @Param("userId") Long userId, - @Param("restaurantId") Long restaurantId, - @Param("selectedDate") LocalDate selectedDate, - @Param("reservationTime") LocalTime reservationTime, - @Param("seatTypeId") Long seatTypeId, - @Param("statuses") List statuses + @Param("userId") Long userId, + @Param("restaurantId") Long restaurantId, + @Param("selectedDate") LocalDate selectedDate, + @Param("reservationTime") LocalTime reservationTime, + @Param("seatTypeId") Long seatTypeId, + @Param("statuses") List statuses ); @Query("SELECT r FROM Reservation r " + - "WHERE r.user.kakaoId = :kakaoId " + - "AND r.status IN (:statuses)") + "WHERE r.user.kakaoId = :kakaoId " + + "AND r.status IN (:statuses)") List findByKakaoIdAndStatusIn(@Param("kakaoId") Long kakaoId, - @Param("statuses") List statuses); + @Param("statuses") List statuses); @Query("SELECT r FROM Reservation r " + - "JOIN FETCH r.restaurant rest " + - "JOIN FETCH r.user u " + - "JOIN FETCH r.reservationTime rt " + - "JOIN FETCH r.seatType st " + - "WHERE r.user.id = :userId") + "JOIN FETCH r.restaurant rest " + + "JOIN FETCH r.user u " + + "JOIN FETCH r.reservationTime rt " + + "JOIN FETCH r.seatType st " + + "WHERE r.user.id = :userId") Slice findAllByUserId(@Param("userId") Long userId, Pageable pageable); @Query("SELECT r FROM Reservation r " + - "JOIN FETCH r.restaurant rest " + - "JOIN FETCH r.user u " + - "JOIN FETCH r.reservationTime rt " + - "JOIN FETCH r.seatType st " + - "WHERE r.user.kakaoId = :kakaoId") + "JOIN FETCH r.restaurant rest " + + "JOIN FETCH r.user u " + + "JOIN FETCH r.reservationTime rt " + + "JOIN FETCH r.seatType st " + + "WHERE r.user.kakaoId = :kakaoId") Slice findAllByKakaoId(@Param("kakaoId") Long kakaoId, Pageable pageable); } diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantCategoryName.java b/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantCategoryName.java new file mode 100644 index 00000000..e90fb5bf --- /dev/null +++ b/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantCategoryName.java @@ -0,0 +1,18 @@ +package com.trinity.ctc.domain.restaurant.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "식당 카테고리 이름") +public class RestaurantCategoryName { + + @Schema(description = "식당 ID", example = "1") + private Long restaurantId; + + @Schema(description = "식당 카테고리 이름", example = "한식") + private String categoryName; + +} diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantPreviewResponse.java b/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantPreviewResponse.java index a13c1cbb..9ddf92ff 100644 --- a/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantPreviewResponse.java +++ b/src/main/java/com/trinity/ctc/domain/restaurant/dto/RestaurantPreviewResponse.java @@ -2,13 +2,10 @@ import com.trinity.ctc.domain.reservation.dto.ReservationAvailabilityResponse; import com.trinity.ctc.domain.restaurant.entity.Restaurant; -import com.trinity.ctc.domain.restaurant.entity.RestaurantImage; +import com.trinity.ctc.domain.restaurant.entity.RestaurantCategory; import com.trinity.ctc.domain.user.entity.User; import io.swagger.v3.oas.annotations.media.Schema; - import java.util.List; -import java.util.stream.Collectors; - import lombok.Builder; import lombok.Getter; @@ -30,7 +27,7 @@ public class RestaurantPreviewResponse { private double rating; @Schema(description = "식당 카테고리", example = "한식") - private String category; + private List category; @Schema(description = "식당 위치", example = "경기 성남시 분당구 대왕판교로606번길 58 판교푸르지오월드마크 2033호") private String location; @@ -54,22 +51,19 @@ public class RestaurantPreviewResponse { private List reservation; // 날짜별 예약 가능 여부 리스트로 변경 - public static RestaurantPreviewResponse fromEntity(User user, Restaurant restaurant, boolean isWishlisted, List reservation) { + + public static RestaurantPreviewResponse fromEntity(User user, Restaurant restaurant, boolean isWishlisted, List reservation, List rcList) { return RestaurantPreviewResponse.builder() .userName(user.getNickname()) .restaurantId(restaurant.getId()) .name(restaurant.getName()) .rating(restaurant.getRating()) - .category(restaurant.getRestaurantCategoryList().stream() - .map(rc -> rc.getCategory().getName()) - .collect(Collectors.joining(", "))) + .category(restaurant.getCategories(rcList))//rclist에서 restaurantId가 같은 카테고리 이름들을 가져와야함 .location(restaurant.getAddress()) .operatingDays(restaurant.getExpandedDays()) .operatingHours(restaurant.getOperatingHour()) - .imageUrls(restaurant.getImageUrls().stream() - .map(RestaurantImage::getUrl) - .collect(Collectors.toList())) + .imageUrls(restaurant.getRestaurantImageUrls()) .averagePrice(restaurant.getAveragePrice()) .isWishlisted(isWishlisted) .reservation(reservation) diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/entity/Restaurant.java b/src/main/java/com/trinity/ctc/domain/restaurant/entity/Restaurant.java index fc4acd0a..f23cbcc1 100644 --- a/src/main/java/com/trinity/ctc/domain/restaurant/entity/Restaurant.java +++ b/src/main/java/com/trinity/ctc/domain/restaurant/entity/Restaurant.java @@ -2,6 +2,7 @@ import com.trinity.ctc.domain.like.entity.Likes; import com.trinity.ctc.domain.reservation.entity.Reservation; +import com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName; import com.trinity.ctc.domain.seat.entity.Seat; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -17,6 +18,8 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; @Getter @Entity @@ -42,6 +45,7 @@ public class Restaurant { private int averagePrice; @OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) private List imageUrls = new ArrayList<>(); @OneToMany(mappedBy = "restaurant") @@ -54,9 +58,11 @@ public class Restaurant { private List seatList = new ArrayList<>(); @OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) private List restaurantCategoryList = new ArrayList<>(); @OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) private List menus = new ArrayList<>(); @Builder @@ -97,10 +103,12 @@ public Restaurant addMenuList(List menus) { return this; } - public List getCategories() { + public List getCategories(List rcList) { List categories = new ArrayList<>(); - for (RestaurantCategory restaurantCategory : restaurantCategoryList) { - categories.add(restaurantCategory.getCategoryName()); + for (RestaurantCategoryName rc : rcList) { + if (rc.getRestaurantId().equals(this.id)) { + categories.add(rc.getCategoryName()); + } } return categories; } diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantCategoryRepository.java b/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantCategoryRepository.java index 6a4d6067..a884a162 100644 --- a/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantCategoryRepository.java +++ b/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantCategoryRepository.java @@ -1,10 +1,20 @@ package com.trinity.ctc.domain.restaurant.repository; +import com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName; import com.trinity.ctc.domain.restaurant.entity.RestaurantCategory; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @Repository public interface RestaurantCategoryRepository extends JpaRepository { + @Query(""" + SELECT new com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName(rc.restaurant.id,rc.category.name) + FROM RestaurantCategory rc + JOIN rc.category + WHERE rc.restaurant.id IN :restaurantIds +""") + List findAllWithCategoryByRestaurantIds(List restaurantIds); } diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantRepository.java b/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantRepository.java index 660ac663..2f8c84d7 100644 --- a/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantRepository.java +++ b/src/main/java/com/trinity/ctc/domain/restaurant/repository/RestaurantRepository.java @@ -1,8 +1,10 @@ package com.trinity.ctc.domain.restaurant.repository; import com.trinity.ctc.domain.restaurant.entity.Restaurant; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,12 +16,23 @@ public interface RestaurantRepository extends JpaRepository { @Query("SELECT r FROM Restaurant r JOIN r.restaurantCategoryList rc WHERE rc.category.id = :categoryId") Page findByCategory(@Param("categoryId") Long categoryId, Pageable pageable); - @Query("SELECT r FROM Restaurant r " + - "JOIN r.menus m " + - "JOIN r.restaurantCategoryList rc " + - "JOIN rc.category c " + - "WHERE ((r.name) LIKE (CONCAT('%', :keyword, '%')) " + - "OR (m.name) LIKE (CONCAT('%', :keyword, '%')) " + - "OR (c.name) LIKE (CONCAT('%', :keyword, '%'))) ") - Page searchRestaurants(@Param("keyword") String keyword, Pageable pageable); + + @Query(""" + SELECT r.id FROM Restaurant r + WHERE r.name LIKE %:keyword% + OR EXISTS ( + SELECT 1 FROM Menu m + WHERE m.restaurant = r AND m.name LIKE %:keyword% + ) + OR EXISTS ( + SELECT 1 FROM RestaurantCategory rc + JOIN rc.category c + WHERE rc.restaurant = r AND c.name LIKE %:keyword% + ) +""") + Slice searchRestaurantIds(@Param("keyword") String keyword, Pageable pageable); + + @Query("SELECT r FROM Restaurant r WHERE r.id IN :ids") + Slice findAllByIdIn(@Param("ids") List ids); + } \ No newline at end of file diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantRecommendationService.java b/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantRecommendationService.java index 7f5c7611..aa4c889f 100644 --- a/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantRecommendationService.java +++ b/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantRecommendationService.java @@ -90,7 +90,7 @@ public List getRecommendedRestaurants(String kakaoId) boolean isWishlisted = likeRepository.existsByUserAndRestaurant(user, restaurant); - return RestaurantPreviewResponse.fromEntity(user, restaurant, isWishlisted, List.of()); + return RestaurantPreviewResponse.fromEntity(user, restaurant, isWishlisted, List.of(), List.of()); }) .collect(Collectors.toList()); } diff --git a/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantService.java b/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantService.java index 9e7738b2..05fb64c9 100644 --- a/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantService.java +++ b/src/main/java/com/trinity/ctc/domain/restaurant/service/RestaurantService.java @@ -2,33 +2,40 @@ import com.trinity.ctc.domain.category.entity.Category; import com.trinity.ctc.domain.category.repository.CategoryRepository; -import com.trinity.ctc.domain.like.repository.LikeRepository; +import com.trinity.ctc.domain.like.service.LikeService; import com.trinity.ctc.domain.reservation.dto.ReservationAvailabilityResponse; +import com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName; import com.trinity.ctc.domain.restaurant.dto.RestaurantDetailResponse; import com.trinity.ctc.domain.restaurant.dto.RestaurantPreviewRequest; import com.trinity.ctc.domain.restaurant.dto.RestaurantPreviewResponse; import com.trinity.ctc.domain.restaurant.entity.Restaurant; +import com.trinity.ctc.domain.restaurant.entity.RestaurantCategory; +import com.trinity.ctc.domain.restaurant.repository.RestaurantCategoryRepository; import com.trinity.ctc.domain.restaurant.repository.RestaurantRepository; import com.trinity.ctc.domain.search.sorting.SortingStrategy; import com.trinity.ctc.domain.search.sorting.SortingStrategyFactory; +import com.trinity.ctc.domain.seat.dto.AvailableSeatPerDay; import com.trinity.ctc.domain.seat.service.SeatService; -import com.trinity.ctc.domain.user.dto.CustomUserDetails; import com.trinity.ctc.domain.user.entity.User; import com.trinity.ctc.domain.user.repository.UserRepository; import com.trinity.ctc.global.exception.CustomException; import com.trinity.ctc.global.exception.error_code.RestaurantErrorCode; import com.trinity.ctc.global.exception.error_code.UserErrorCode; +import com.trinity.ctc.global.util.validator.SeatAvailabilityValidator; +import java.time.LocalDate; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,8 +48,9 @@ public class RestaurantService { private final RestaurantRepository restaurantRepository; private final CategoryRepository categoryRepository; private final UserRepository userRepository; - private final LikeRepository likeRepository; private final SeatService seatService; + private final LikeService likeService; + private final RestaurantCategoryRepository restaurantCategoryRepository; @Transactional(readOnly = true) public List getAllRestaurants() { @@ -79,23 +87,49 @@ public List getRestaurantsByCategory(String kakaoId, Pageable pageable = PageRequest.of(request.getPage() - 1, 30, sort); Page restaurants = restaurantRepository.findByCategory(categoryId, pageable); - - return convertToRestaurantDtoList(restaurants, user); + List restaurantList = restaurants.getContent(); + return convertTorestaurantDtoList(restaurantList, user); } - public List convertToRestaurantDtoList(Page restaurants, User user) { - List result = restaurants.stream() + public List convertTorestaurantDtoList(List restaurantList, User user) { + List restaurantIds = restaurantList.stream().map(Restaurant::getId).collect(Collectors.toList()); + Map wishMap = likeService.existsByUserAndRestaurantIds(user, restaurantIds); + Map> rawSeatMap = seatService.findAvailableSeatsGrouped(restaurantIds, LocalDate.now(), LocalDate.now().plusDays(14)); + + Map> reservationMap = rawSeatMap.entrySet().stream() + .collect(Collectors.toMap( + Entry::getKey, + entry -> processAvailabilityPerRestaurant(entry.getValue()) + )); + + List rcList = restaurantCategoryRepository.findAllWithCategoryByRestaurantIds(restaurantIds); + + return restaurantList.stream() .map(restaurant -> { - boolean isWishlisted = likeRepository.existsByUserAndRestaurant(user, restaurant); // -> id값만으로 가능 + Long restaurantId = restaurant.getId(); + boolean isWishlisted = wishMap.getOrDefault(restaurantId, false); + List reservation = reservationMap.getOrDefault(restaurantId, Collections.emptyList()); + + return RestaurantPreviewResponse.fromEntity(user, restaurant, isWishlisted, reservation, rcList); + }) + .collect(Collectors.toList()); + } - // 14일간 날짜별 예약 가능 여부 조회 - List reservation = seatService - .getAvailabilityForNext14Days(restaurant.getId()); // -> id값만으로 가능 + private List processAvailabilityPerRestaurant(List seats) { + Map> byDate = seats.stream() + .collect(Collectors.groupingBy(AvailableSeatPerDay::getReservationDate)); - log.info("reservation 사이즈: {}", reservation.size()); - return RestaurantPreviewResponse.fromEntity(user,restaurant, isWishlisted, reservation); + return IntStream.range(0, 14) + .mapToObj(i -> { + LocalDate date = LocalDate.now().plusDays(i); + List seatList = byDate.getOrDefault(date, Collections.emptyList()); + boolean isAvailable = SeatAvailabilityValidator.isAnySeatAvailableForSearch(seatList, isToday(date)); + return new ReservationAvailabilityResponse(date, isAvailable, null); }) .collect(Collectors.toList()); - return result; + } + + private boolean isToday(LocalDate date) { + return LocalDate.now().equals(date); } } \ No newline at end of file diff --git a/src/main/java/com/trinity/ctc/domain/search/controller/SearchController.java b/src/main/java/com/trinity/ctc/domain/search/controller/SearchController.java index 05582e21..354f9f26 100644 --- a/src/main/java/com/trinity/ctc/domain/search/controller/SearchController.java +++ b/src/main/java/com/trinity/ctc/domain/search/controller/SearchController.java @@ -10,9 +10,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; - import java.util.List; - import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -34,6 +32,13 @@ public class SearchController { private final SearchService searchService; + @PostMapping("/noauthenticaiton") + public ResponseEntity> getRestaurantsBySearchForTest( + @RequestBody RestaurantPreviewRequest request, + @RequestParam String keyword) { + return ResponseEntity.ok(searchService.searchForTest(request, keyword)); + } + @PostMapping @Operation( summary = "키워드 검색", diff --git a/src/main/java/com/trinity/ctc/domain/search/service/SearchService.java b/src/main/java/com/trinity/ctc/domain/search/service/SearchService.java index a8eb17ba..db9310d6 100644 --- a/src/main/java/com/trinity/ctc/domain/search/service/SearchService.java +++ b/src/main/java/com/trinity/ctc/domain/search/service/SearchService.java @@ -23,6 +23,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +37,23 @@ public class SearchService { private final UserRepository userRepository; private final SearchRepository searchRepository; + @Transactional + public List searchForTest(RestaurantPreviewRequest request, String keyword) { + SortingStrategy sortingStrategy = SortingStrategyFactory.getStrategy(request.getSortType()); + Sort sort = sortingStrategy.getSort(); + Pageable pageable = PageRequest.of(request.getPage() - 1, 30, sort); + + Slice idPage = restaurantRepository.searchRestaurantIds(keyword, pageable); + Slice restaurants = restaurantRepository.findAllByIdIn(idPage.getContent()); + List restaurantList = restaurants.getContent(); + + User user = userRepository.findById(1L) + .orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND)); + saveSearchHistory(1L, keyword); + return restaurantService.convertTorestaurantDtoList(restaurantList, user); + } + + @Transactional public List search(String kakaoId, RestaurantPreviewRequest request, String keyword) { Optional userOptional = userRepository.findByKakaoId(Long.valueOf(kakaoId)); @@ -47,12 +65,16 @@ public List search(String kakaoId, RestaurantPreviewR Pageable pageable = PageRequest.of(request.getPage() - 1, 30, sort); - Page restaurants = restaurantRepository.searchRestaurants(keyword, pageable); + Slice idPage = restaurantRepository.searchRestaurantIds(keyword, pageable); + Slice restaurants = restaurantRepository.findAllByIdIn(idPage.getContent()); + List restaurantList = restaurants.getContent(); + User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND)); saveSearchHistory(userId, keyword); - return restaurantService.convertToRestaurantDtoList(restaurants, user); + + return restaurantService.convertTorestaurantDtoList(restaurantList, user); } public List getSearchHistory(String kakaoId) { diff --git a/src/main/java/com/trinity/ctc/domain/seat/repository/JdbcSeatBatchRepository.java b/src/main/java/com/trinity/ctc/domain/seat/repository/JdbcSeatBatchRepository.java index 3d4a3b2b..908c1f93 100644 --- a/src/main/java/com/trinity/ctc/domain/seat/repository/JdbcSeatBatchRepository.java +++ b/src/main/java/com/trinity/ctc/domain/seat/repository/JdbcSeatBatchRepository.java @@ -55,6 +55,4 @@ public void batchInsertSeats(List seats, int batchSize) { throw e; // 예외 발생 시 스택 트레이스 출력 } } - - } diff --git a/src/main/java/com/trinity/ctc/domain/seat/repository/SeatRepository.java b/src/main/java/com/trinity/ctc/domain/seat/repository/SeatRepository.java index f350f56d..89e744d9 100644 --- a/src/main/java/com/trinity/ctc/domain/seat/repository/SeatRepository.java +++ b/src/main/java/com/trinity/ctc/domain/seat/repository/SeatRepository.java @@ -16,6 +16,16 @@ @Repository public interface SeatRepository extends JpaRepository { + @Query("SELECT new com.trinity.ctc.domain.seat.dto.AvailableSeatPerDay( " + + "sa.restaurant.id, sa.reservationDate, sa.availableSeats, sa.reservationTime.timeSlot) " + + "FROM Seat sa " + + "WHERE sa.restaurant.id IN :restaurantIds " + + "AND sa.reservationDate BETWEEN :startDate AND :endDate") + List findAvailableSeatsForRestaurants(@Param("restaurantIds") List restaurantIds, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); + + @Query("SELECT sa FROM Seat sa " + "WHERE sa.restaurant.id = :restaurantId " + "AND sa.reservationTime.timeSlot > :targetTime " + diff --git a/src/main/java/com/trinity/ctc/domain/seat/service/SeatService.java b/src/main/java/com/trinity/ctc/domain/seat/service/SeatService.java index baf2772c..d57fd68e 100644 --- a/src/main/java/com/trinity/ctc/domain/seat/service/SeatService.java +++ b/src/main/java/com/trinity/ctc/domain/seat/service/SeatService.java @@ -2,7 +2,6 @@ import static com.trinity.ctc.global.util.validator.DateTimeValidator.isToday; -import com.trinity.ctc.domain.reservation.dto.ReservationAvailabilityResponse; import com.trinity.ctc.domain.seat.dto.AvailableSeatPerDay; import com.trinity.ctc.domain.seat.dto.GroupedDailyAvailabilityResponse; import com.trinity.ctc.domain.seat.dto.GroupedSeatResponse; @@ -18,7 +17,6 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -58,26 +56,6 @@ public GroupedDailyAvailabilityResponse getAvailableSeatsDay(Long restaurantId, * @param restaurantId * @return 날짜별 예약 가능 여부 리스트 (ReservationAvailabilityDto 형태) */ - @Transactional(readOnly = true) - public List getAvailabilityForNext14Days(Long restaurantId) { - LocalDate today = LocalDate.now(); - return IntStream.range(0, 14) - .mapToObj(i -> processAvailability(restaurantId, today.plusDays(i))) - .collect(Collectors.toList()); - } - - public ReservationAvailabilityResponse processAvailability(Long restaurantId, LocalDate targetDate) { - List availableSeatList = fetchAvailableSeatsForSearch(restaurantId, targetDate); - - long startTime = System.nanoTime(); - boolean isAvailable = SeatAvailabilityValidator.isAnySeatAvailableForSearch(availableSeatList, isToday(targetDate)); - long endTime = System.nanoTime(); - - log.info("SeatAvailabilityValidator.isAnySeatAvailable 실행 시간: {}ms", (endTime - startTime) / 1_000_000); - - return new ReservationAvailabilityResponse(targetDate, isAvailable); //날짜와 available 여부 반환 - } - /* 내부 메서드 */ @@ -88,11 +66,6 @@ public ReservationAvailabilityResponse processAvailability(Long restaurantId, Lo * @param selectedDate * @return 특정 식당, 날짜의 예약가능데이터 */ - private List fetchAvailableSeatsForSearch(Long restaurantId, LocalDate selectedDate) { - List availableSeatList = seatRepository.findAvailableSeatsForDate(restaurantId, selectedDate); - - return availableSeatList; - } private List fetchAvailableSeatsEntity(Long restaurantId, LocalDate selectedDate) { return seatRepository.findAvailableSeatsForDateEntity(restaurantId, selectedDate); @@ -130,4 +103,9 @@ private GroupedTimeSlotResponse createGroupedTimeSlotResponse(LocalTime timeslot return GroupedTimeSlotResponse.fromGroupedSeats(DateTimeUtil.formatToHHmm(timeslot), isAvailable, groupedSeatResponses); } + + public Map> findAvailableSeatsGrouped(List restaurantIds, LocalDate startDate, LocalDate endDate) { + List all = seatRepository.findAvailableSeatsForRestaurants(restaurantIds, startDate, endDate); + return all.stream().collect(Collectors.groupingBy(AvailableSeatPerDay::getRestaurantId)); + } } \ No newline at end of file diff --git a/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationListResponse.java b/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationListResponse.java index 36cae7f0..b6a51dd8 100644 --- a/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationListResponse.java +++ b/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationListResponse.java @@ -1,7 +1,10 @@ package com.trinity.ctc.domain.user.dto; import com.trinity.ctc.domain.reservation.entity.Reservation; +import com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName; +import com.trinity.ctc.domain.restaurant.entity.RestaurantCategory; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.data.domain.Slice; @@ -19,10 +22,11 @@ public class UserReservationListResponse { @Schema(description = "예약 목록") private final List reservations; - public static UserReservationListResponse from(Slice reservations) { + public static UserReservationListResponse from(Slice reservations, List rcList) { + List responseList = reservations.getContent().stream() - .map(UserReservationResponse::from) - .toList(); + .map(reservation -> UserReservationResponse.from(reservation, rcList)) + .toList(); return new UserReservationListResponse(reservations.hasNext(), responseList); } diff --git a/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationResponse.java b/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationResponse.java index 193a2446..292cb335 100644 --- a/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationResponse.java +++ b/src/main/java/com/trinity/ctc/domain/user/dto/UserReservationResponse.java @@ -2,16 +2,16 @@ import com.trinity.ctc.domain.reservation.entity.Reservation; import com.trinity.ctc.domain.reservation.status.ReservationStatus; +import com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName; import com.trinity.ctc.domain.seat.dto.SeatTypeInfoResponse; import com.trinity.ctc.global.util.formatter.DateTimeUtil; import com.trinity.ctc.global.util.validator.DateTimeValidator; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; -import java.util.List; - @Getter @Schema(description = "사용자 예약 하나의 정보") @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -44,11 +44,11 @@ public class UserReservationResponse { @Schema(description = "좌석타입", example = "{ \"minCapacity\": 1, \"maxCapacity\": 2 }") private final SeatTypeInfoResponse seatType; - public static UserReservationResponse from(Reservation reservation) { + public static UserReservationResponse from(Reservation reservation, List rcList) { return new UserReservationResponse( reservation.getId(), reservation.getRestaurant().getName(), - reservation.getRestaurant().getCategories(), + reservation.getRestaurant().getCategories(rcList), reservation.getRestaurant().getRestaurantImageUrls(), DateTimeUtil.formatToDate(reservation.getReservationDate()), DateTimeUtil.formatToHHmm(reservation.getReservationTime().getTimeSlot()), diff --git a/src/main/java/com/trinity/ctc/domain/user/entity/User.java b/src/main/java/com/trinity/ctc/domain/user/entity/User.java index dbc9c4fb..149b9e42 100644 --- a/src/main/java/com/trinity/ctc/domain/user/entity/User.java +++ b/src/main/java/com/trinity/ctc/domain/user/entity/User.java @@ -15,6 +15,8 @@ import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.BatchSize; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -52,6 +54,7 @@ public class User { private Boolean isDeleted = false; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @Fetch(FetchMode.SUBSELECT) private List fcmList = new ArrayList<>(); @OneToMany(mappedBy = "user") diff --git a/src/main/java/com/trinity/ctc/domain/user/service/UserService.java b/src/main/java/com/trinity/ctc/domain/user/service/UserService.java index d30d3fc6..416b50e1 100644 --- a/src/main/java/com/trinity/ctc/domain/user/service/UserService.java +++ b/src/main/java/com/trinity/ctc/domain/user/service/UserService.java @@ -4,6 +4,9 @@ import com.trinity.ctc.domain.category.repository.CategoryRepository; import com.trinity.ctc.domain.reservation.entity.Reservation; import com.trinity.ctc.domain.reservation.repository.ReservationRepository; +import com.trinity.ctc.domain.restaurant.dto.RestaurantCategoryName; +import com.trinity.ctc.domain.restaurant.entity.RestaurantCategory; +import com.trinity.ctc.domain.restaurant.repository.RestaurantCategoryRepository; import com.trinity.ctc.domain.user.dto.OnboardingRequest; import com.trinity.ctc.domain.user.dto.ReissueTokenRequest; import com.trinity.ctc.domain.user.dto.UserDetailResponse; @@ -19,6 +22,8 @@ import com.trinity.ctc.global.util.common.SortOrder; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -39,6 +44,7 @@ public class UserService { private final ReservationRepository reservationRepository; private final AuthService authService; private final TokenService tokenService; + private final RestaurantCategoryRepository restaurantCategoryRepository; /** * 온보딩 요청 DTO의 정보로 user entity를 build 후 저장하는 메서드 @@ -117,7 +123,16 @@ public UserReservationListResponse getUserReservations(int page, int size, Strin Long kakaoId = Long.parseLong(authService.getAuthenticatedKakaoId()); Slice reservations = reservationRepository.findAllByKakaoId(kakaoId, pageRequest); - return UserReservationListResponse.from(reservations); + + List restaurantIds = reservations.getContent().stream() + .map(res -> res.getRestaurant().getId()) + .distinct() + .toList(); + + List rcList = restaurantCategoryRepository + .findAllWithCategoryByRestaurantIds(restaurantIds); + + return UserReservationListResponse.from(reservations, rcList); } /** diff --git a/src/main/java/com/trinity/ctc/global/config/SecurityConfig.java b/src/main/java/com/trinity/ctc/global/config/SecurityConfig.java index 826aba21..38d2bfe9 100644 --- a/src/main/java/com/trinity/ctc/global/config/SecurityConfig.java +++ b/src/main/java/com/trinity/ctc/global/config/SecurityConfig.java @@ -59,8 +59,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, UserRepository .csrf(AbstractHttpConfigurer::disable) // POST 테스트 시 CSRF 비활성화 .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/login", "/api/token", "/api/token/reissue", "/users/kakao/login", "/api/fcmTokens/register", "/api/fcmTokens/delete", "/api/data/**", "trigger/notifications/**").permitAll() - .requestMatchers("/api/users/onboarding/**").hasRole("TEMPORARILY_UNAVAILABLE") + .requestMatchers("/api/login", "/api/token", "/api/token/reissue", "/users/kakao/login", "/api/fcmTokens/register", "/api/fcmTokens/delete", "/api/data/**", "trigger/notifications/**","/api/search/noauthenticaiton").permitAll() + .requestMatchers("/api/users/onboarding").hasRole("TEMPORARILY_UNAVAILABLE") .requestMatchers("/api/**", "/api/logout", "/users/kakao/logout").hasRole("AVAILABLE") .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/api-docs/**", "/v3/api-docs/**").permitAll() .anyRequest().authenticated() // 그 외 경로는 인증 필요 diff --git a/src/main/java/com/trinity/ctc/global/config/TreadPoolConfig.java b/src/main/java/com/trinity/ctc/global/config/TreadPoolConfig.java index 44dcec7e..01721b01 100644 --- a/src/main/java/com/trinity/ctc/global/config/TreadPoolConfig.java +++ b/src/main/java/com/trinity/ctc/global/config/TreadPoolConfig.java @@ -18,12 +18,12 @@ public Executor reservationEventListener() { } - @Bean(name = "reservation-completed-notification") - public Executor reservationCompletedNotificationTaskExecutor() { + @Bean(name = "confirmation-notification") + public Executor confirmationNotificationTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); - executor.setThreadNamePrefix("reservation-complete-notification-"); + executor.setThreadNamePrefix("confirmation-notification-"); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setThreadPriority(7); @@ -31,40 +31,12 @@ public Executor reservationCompletedNotificationTaskExecutor() { return executor; } - @Bean(name = "reservation-canceled-notification") - public Executor reservationCanceledNotificationTaskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - - executor.setCorePoolSize(2); - executor.setThreadNamePrefix("reservation-canceled-notification-"); - executor.setWaitForTasksToCompleteOnShutdown(true); - executor.setThreadPriority(7); - - executor.initialize(); - return executor; - } - - @Bean(name = "daily-reservation-notification") - public Executor dailyReservationNotificationTaskExecutor() { + @Bean(name = "reservation-notification") + public Executor reservationNotificationTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); - executor.setThreadNamePrefix("daily-reservation-notification-"); - executor.setAllowCoreThreadTimeOut(true); - executor.setKeepAliveSeconds(60); - executor.setWaitForTasksToCompleteOnShutdown(true); - executor.setThreadPriority(6); - - executor.initialize(); - return executor; - } - - @Bean(name = "hourly-reservation-notification") - public Executor hourlyTaskExecutor() { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - - executor.setCorePoolSize(10); - executor.setThreadNamePrefix("daily-reservation-notification-"); + executor.setThreadNamePrefix("reservation-notification-"); executor.setAllowCoreThreadTimeOut(true); executor.setKeepAliveSeconds(60); executor.setWaitForTasksToCompleteOnShutdown(true); @@ -75,7 +47,7 @@ public Executor hourlyTaskExecutor() { } @Bean(name = "empty-seat-notification") - public Executor seatTaskExecutor() { + public Executor seatNotificationTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); @@ -88,7 +60,7 @@ public Executor seatTaskExecutor() { } @Bean(name = "response-handler") - public Executor singleResponseTaskExecutor() { + public Executor sendingResponseTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(20); @@ -129,4 +101,54 @@ public Executor saveNotificationHistoryTaskExecutor() { executor.initialize(); return executor; } + + @Bean(name = "notification-thread") + public Executor processTaskExecutor() { + + ThreadFactory factory = Thread.ofVirtual() + .name("notification-vt-", 0) + .factory(); + + return Executors.newThreadPerTaskExecutor(factory); + } + + @Bean(name = "sending-thread") + public Executor sendingTaskExecutor() { + + ThreadFactory factory = Thread.ofVirtual() + .name("sending-vt-", 0) + .factory(); + + return Executors.newThreadPerTaskExecutor(factory); + } + + @Bean(name = "response-thread") + public Executor responseTaskExecutor() { + + ThreadFactory factory = Thread.ofVirtual() + .name("response-vt-", 0) + .factory(); + + return Executors.newThreadPerTaskExecutor(factory); + } + + @Bean(name = "retry-thread") + public Executor retryTaskExecutor() { + + ThreadFactory factory = Thread.ofVirtual() + .name("retry-vt-", 0) + .factory(); + + return Executors.newThreadPerTaskExecutor(factory); + } + + @Bean(name = "save-thread") + public Executor saveTaskExecutor() { + + ThreadFactory factory = Thread.ofVirtual() + .name("save-vt-", 0) + .factory(); + + return Executors.newThreadPerTaskExecutor(factory); + } } diff --git a/src/main/java/com/trinity/ctc/global/util/formatter/JsonUtil.java b/src/main/java/com/trinity/ctc/global/util/formatter/JsonUtil.java index 5e7e23cd..e5602680 100644 --- a/src/main/java/com/trinity/ctc/global/util/formatter/JsonUtil.java +++ b/src/main/java/com/trinity/ctc/global/util/formatter/JsonUtil.java @@ -14,17 +14,15 @@ import java.util.Map; -@RequiredArgsConstructor -@Converter -@Slf4j /** * JSON 데이터 타입(DB)과 Map 자료형을 서로 변환해주는 컨버터 * JSON 데이터를 문자열로만 전달하는 것이 아니라 내부의 값을 사용해야 할 경우 * * 사용 예시. NotificationHistory Entity의 message column - * @Convert(converter = JsonUtil.class) - * private Map message; */ +@RequiredArgsConstructor +@Converter +@Slf4j public class JsonUtil implements AttributeConverter, String> { private final ObjectMapper objectMapper; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 54e6a5b2..fc43618a 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,15 +1,20 @@ +server: + tomcat: + max-threads: 500 # 동시 처리 쓰레드 수 + max-connections: 10000 # 최대 연결 수 + spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${LOCAL_DATASOURCE_URL} username: ${LOCAL_DATASOURCE_USERNAME} password: ${LOCAL_DATASOURCE_PASSWORD} -# hikari: -# maximum-pool-size: 50 + hikari: + maximum-pool-size: 100 # minimum-idle: 10 # idle-timeout: 60000 # max-lifetime: 1800000 -# connection-timeout: 30000 + connection-timeout: 30000 --- spring: jpa: @@ -17,7 +22,7 @@ spring: ddl-auto: update properties: hibernate: - show_sql: true +# show_sql: true format_sql: true generate_statistics: true # Hibernate 통계 활성화 order_updates: true # 동일한 엔티티 INSERT 문 정렬 최적화 @@ -40,7 +45,7 @@ spring: --- logging: level: -# org.hibernate.SQL: DEBUG # SQL 쿼리 출력 + org.hibernate.SQL: DEBUG # SQL 쿼리 출력 org.hibernate.type.descriptor.sql.BasicBinder: TRACE # 바인딩 파라미터 로그 org.hibernate.engine.jdbc.batch.internal.BatchingBatch: DEBUG # 배치 쿼리 로그 com.zaxxer.hikari: DEBUG # HikariCP 상태 출력