diff --git a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java new file mode 100644 index 00000000..50e28017 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java @@ -0,0 +1,166 @@ +package gg.agit.konect.domain.notification.service; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class ExpoPushClient { + + private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; + private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications"; + + private final RestTemplate expoRestTemplate; + + public ExpoPushClient(@Qualifier("expoRestTemplate") RestTemplate expoRestTemplate) { + this.expoRestTemplate = expoRestTemplate; + } + + @Retryable(maxAttempts = 2) + public void sendNotification(Integer receiverId, List tokens, String title, String body, + Map data) { + List messages = tokens.stream() + .map(token -> new ExpoPushMessage(token, title, body, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) + .toList(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + + HttpEntity> entity = new HttpEntity<>(messages, headers); + ResponseEntity response = expoRestTemplate.exchange( + EXPO_PUSH_URL, + HttpMethod.POST, + entity, + ExpoPushResponse.class + ); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new IllegalStateException( + "Expo push response not successful: receiverId=%d, status=%s" + .formatted(receiverId, response.getStatusCode()) + ); + } + + ExpoPushResponse responseBody = response.getBody(); + if (responseBody == null || responseBody.data() == null) { + throw new IllegalStateException( + "Expo push response body missing: receiverId=%d".formatted(receiverId) + ); + } + + for (int i = 0; i < responseBody.data().size(); i += 1) { + ExpoPushTicket ticket = responseBody.data().get(i); + if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { + continue; + } + String token = i < tokens.size() ? tokens.get(i) : "unknown"; + log.error( + "Expo 푸시 발송 실패: receiverId={}, token={}, status={}, message={}, details={}", + receiverId, + token, + ticket.status(), + ticket.message(), + ticket.details() + ); + } + + log.debug("알림 발송 완료: receiverId={}, tokenCount={}", receiverId, tokens.size()); + } + + @Recover + public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List tokens, + String title, + String body, + Map data) { + log.error( + "알림 재시도 후에도 HTTP 오류로 발송에 실패했습니다: receiverId={}, tokenCount={}, statusCode={}, responseBody={}", + receiverId, + tokens.size(), + e.getStatusCode(), + e.getResponseBodyAsString(), + e + ); + } + + @Recover + public void sendNotificationRecover(ResourceAccessException e, Integer receiverId, List tokens, + String title, + String body, + Map data) { + Throwable rootCause = e.getMostSpecificCause(); + log.error( + "알림 재시도 후에도 연결 문제로 발송에 실패했습니다: receiverId={}, tokenCount={}, rootCauseType={}, rootCauseMessage={}", + receiverId, + tokens.size(), + rootCause.getClass().getSimpleName(), + rootCause.getMessage(), + e + ); + } + + @Recover + public void sendNotificationRecover(IllegalStateException e, Integer receiverId, List tokens, String title, + String body, + Map data) { + log.error( + "알림 재시도 후에도 Expo 응답이 비정상이라 발송에 실패했습니다: receiverId={}, tokenCount={}, message={}", + receiverId, + tokens.size(), + e.getMessage(), + e + ); + } + + @Recover + public void sendNotificationRecover(RestClientException e, Integer receiverId, List tokens, String title, + String body, + Map data) { + log.error( + "알림 재시도 후에도 Rest 클라이언트 오류로 발송에 실패했습니다: receiverId={}, tokenCount={}, exceptionType={}, message={}", + receiverId, + tokens.size(), + e.getClass().getSimpleName(), + e.getMessage(), + e + ); + } + + @Recover + public void sendNotificationRecover(Exception e, Integer receiverId, List tokens, String title, String body, + Map data) { + log.error( + "알림 재시도 후에도 예기치 못한 오류로 발송에 실패했습니다: receiverId={}, tokenCount={}, exceptionType={}, message={}", + receiverId, + tokens.size(), + e.getClass().getSimpleName(), + e.getMessage(), + e + ); + } + + private record ExpoPushMessage(String to, String title, String body, Map data, String channelId) { + } + + private record ExpoPushResponse(List data) { + } + + private record ExpoPushTicket(String status, String message, Map details) { + } +} diff --git a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java index 27c11e52..c144733b 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java @@ -28,10 +28,12 @@ import gg.agit.konect.domain.user.model.User; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Service @Slf4j +@RequiredArgsConstructor @Transactional(readOnly = true) public class NotificationService { @@ -47,20 +49,7 @@ public class NotificationService { private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final RestTemplate restTemplate; private final ChatPresenceService chatPresenceService; - - public NotificationService( - UserRepository userRepository, - NotificationDeviceTokenRepository notificationDeviceTokenRepository, - NotificationMuteSettingRepository notificationMuteSettingRepository, - RestTemplate restTemplate, - ChatPresenceService chatPresenceService - ) { - this.userRepository = userRepository; - this.notificationDeviceTokenRepository = notificationDeviceTokenRepository; - this.notificationMuteSettingRepository = notificationMuteSettingRepository; - this.restTemplate = restTemplate; - this.chatPresenceService = chatPresenceService; - } + private final ExpoPushClient expoPushClient; public NotificationTokenResponse getMyToken(Integer userId) { NotificationDeviceToken token = notificationDeviceTokenRepository.getByUserId(userId); @@ -337,66 +326,14 @@ public void sendClubApplicationRejectedNotification(Integer receiverId, Integer } private void sendNotification(Integer receiverId, String title, String body, String path) { - try { - List tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId); - if (tokens.isEmpty()) { - log.debug("No device tokens found for user: receiverId={}", receiverId); - return; - } - - Map data = buildData(null, path); - - List messages = tokens.stream() - .map(token -> new ExpoPushMessage(token, title, body, data, DEFAULT_NOTIFICATION_CHANNEL_ID)) - .toList(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - - HttpEntity> entity = new HttpEntity<>(messages, headers); - ResponseEntity response = restTemplate.exchange( - EXPO_PUSH_URL, - HttpMethod.POST, - entity, - ExpoPushResponse.class - ); - - if (!response.getStatusCode().is2xxSuccessful()) { - log.error( - "Expo push response not successful: receiverId={}, status={}", - receiverId, - response.getStatusCode() - ); - return; - } - - ExpoPushResponse responseBody = response.getBody(); - if (responseBody == null || responseBody.data == null) { - log.error("Expo push response body missing: receiverId={}", receiverId); - return; - } - - for (int i = 0; i < responseBody.data.size(); i += 1) { - ExpoPushTicket ticket = responseBody.data.get(i); - if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) { - continue; - } - String token = i < tokens.size() ? tokens.get(i) : "unknown"; - log.error( - "Expo push failed: receiverId={}, token={}, status={}, message={}, details={}", - receiverId, - token, - ticket.status(), - ticket.message(), - ticket.details() - ); - } - - log.debug("Notification sent: receiverId={}, tokenCount={}", receiverId, tokens.size()); - } catch (Exception e) { - log.error("Failed to send notification: receiverId={}", receiverId, e); + List tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId); + if (tokens.isEmpty()) { + log.debug("No device tokens found for user: receiverId={}", receiverId); + return; } + + Map data = buildData(null, path); + expoPushClient.sendNotification(receiverId, tokens, title, body, data); } private Map buildData(Map data, String path) { diff --git a/src/main/java/gg/agit/konect/global/config/RestTemplateConfig.java b/src/main/java/gg/agit/konect/global/config/RestTemplateConfig.java index 24812c4b..90803565 100644 --- a/src/main/java/gg/agit/konect/global/config/RestTemplateConfig.java +++ b/src/main/java/gg/agit/konect/global/config/RestTemplateConfig.java @@ -15,6 +15,8 @@ public class RestTemplateConfig { private static final Integer CONNECT_TIMEOUT = 5000; private static final Integer READ_TIMEOUT = 5000; + private static final Integer EXPO_CONNECT_TIMEOUT = 10000; + private static final Integer EXPO_READ_TIMEOUT = 10000; @Bean public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { @@ -28,4 +30,17 @@ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { .additionalMessageConverters(new StringHttpMessageConverter(UTF_8)) .build(); } + + @Bean("expoRestTemplate") + public RestTemplate expoRestTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .requestFactory(() -> { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(EXPO_CONNECT_TIMEOUT); + factory.setReadTimeout(EXPO_READ_TIMEOUT); + return new BufferingClientHttpRequestFactory(factory); + }) + .additionalMessageConverters(new StringHttpMessageConverter(UTF_8)) + .build(); + } }