From 9f617fb3ebc0467bd3b6a0038c49aa2cdbf28612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 18 Mar 2026 11:30:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20EXPO=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/service/ExpoPushClient.java | 97 +++++++++++++++++++ .../service/NotificationService.java | 71 +++----------- .../global/config/RestTemplateConfig.java | 15 +++ 3 files changed, 123 insertions(+), 60 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java 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..3fbec942 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java @@ -0,0 +1,97 @@ +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.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 push failed: receiverId={}, token={}, status={}, message={}, details={}", + receiverId, + token, + ticket.status(), + ticket.message(), + ticket.details() + ); + } + + log.debug("Notification sent: receiverId={}, tokenCount={}", receiverId, tokens.size()); + } + + @Recover + public void sendNotificationRecover(Exception e, Integer receiverId, List tokens, String title, String body, + Map data) { + log.error("Failed to send notification after retry: receiverId={}, tokenCount={}", receiverId, tokens.size(), 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..3b57b2c1 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 @@ -47,19 +47,22 @@ public class NotificationService { private final NotificationMuteSettingRepository notificationMuteSettingRepository; private final RestTemplate restTemplate; private final ChatPresenceService chatPresenceService; + private final ExpoPushClient expoPushClient; public NotificationService( UserRepository userRepository, NotificationDeviceTokenRepository notificationDeviceTokenRepository, NotificationMuteSettingRepository notificationMuteSettingRepository, RestTemplate restTemplate, - ChatPresenceService chatPresenceService + ChatPresenceService chatPresenceService, + ExpoPushClient expoPushClient ) { this.userRepository = userRepository; this.notificationDeviceTokenRepository = notificationDeviceTokenRepository; this.notificationMuteSettingRepository = notificationMuteSettingRepository; this.restTemplate = restTemplate; this.chatPresenceService = chatPresenceService; + this.expoPushClient = expoPushClient; } public NotificationTokenResponse getMyToken(Integer userId) { @@ -337,66 +340,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(); + } } From 7f6a2417a41fe83e01da3a3ec991b252c5b3f6d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 18 Mar 2026 11:33:08 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EB=A1=AC=EB=B3=B5=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=EC=9C=BC=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) 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 3b57b2c1..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 { @@ -49,22 +51,6 @@ public class NotificationService { private final ChatPresenceService chatPresenceService; private final ExpoPushClient expoPushClient; - public NotificationService( - UserRepository userRepository, - NotificationDeviceTokenRepository notificationDeviceTokenRepository, - NotificationMuteSettingRepository notificationMuteSettingRepository, - RestTemplate restTemplate, - ChatPresenceService chatPresenceService, - ExpoPushClient expoPushClient - ) { - this.userRepository = userRepository; - this.notificationDeviceTokenRepository = notificationDeviceTokenRepository; - this.notificationMuteSettingRepository = notificationMuteSettingRepository; - this.restTemplate = restTemplate; - this.chatPresenceService = chatPresenceService; - this.expoPushClient = expoPushClient; - } - public NotificationTokenResponse getMyToken(Integer userId) { NotificationDeviceToken token = notificationDeviceTokenRepository.getByUserId(userId); From 3798d65bb48c7df9a4ed9818d169597c610451c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 18 Mar 2026 11:40:57 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20EXPO=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=9B=90=EC=9D=B8=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=98=88=EC=99=B8=EB=B3=84=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/service/ExpoPushClient.java | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) 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 index 3fbec942..9cc3fc79 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java @@ -12,6 +12,9 @@ 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; @@ -68,7 +71,7 @@ public void sendNotification(Integer receiverId, List tokens, String tit } String token = i < tokens.size() ? tokens.get(i) : "unknown"; log.error( - "Expo push failed: receiverId={}, token={}, status={}, message={}, details={}", + "Expo 푸시 발송 실패: receiverId={}, token={}, status={}, message={}, details={}", receiverId, token, ticket.status(), @@ -77,13 +80,76 @@ public void sendNotification(Integer receiverId, List tokens, String tit ); } - log.debug("Notification sent: receiverId={}, tokenCount={}", receiverId, tokens.size()); + 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("Failed to send notification after retry: receiverId={}, tokenCount={}", receiverId, tokens.size(), e); + 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) { From 66759178f16582dd81bc5f7017062fdf59c4214b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 18 Mar 2026 11:46:24 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20pre-push=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EC=A0=88?= =?UTF-8?q?=EB=8C=80=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/code-formatting.sh | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/code-formatting.sh b/scripts/code-formatting.sh index 933ccf37..c08562f9 100755 --- a/scripts/code-formatting.sh +++ b/scripts/code-formatting.sh @@ -14,6 +14,7 @@ pre_format_snapshot="" resolve_target_java_file() { local input_file="$1" + local candidate_path="" local resolved_file="" local matched_files="" local match_count @@ -24,11 +25,11 @@ resolve_target_java_file() { esac if [ -f "$input_file" ]; then - resolved_file="$input_file" + candidate_path="$input_file" elif [ -f "$invocation_dir/$input_file" ]; then - resolved_file="$invocation_dir/$input_file" + candidate_path="$invocation_dir/$input_file" elif [ -f "$repo_root/$input_file" ]; then - resolved_file="$repo_root/$input_file" + candidate_path="$repo_root/$input_file" else matched_files="$(git ls-files -- "$input_file" "*/$input_file" 2>/dev/null || true)" if [ -z "$matched_files" ]; then @@ -42,7 +43,11 @@ resolve_target_java_file() { return 1 fi - resolved_file="$repo_root/$matched_files" + candidate_path="$repo_root/$matched_files" + fi + + if [ -n "$candidate_path" ]; then + resolved_file="$(cd "$(dirname "$candidate_path")" && pwd -P)/$(basename "$candidate_path")" fi case "$resolved_file" in From d115da0fff992c5764d7d4fa28d505f614dab14d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Wed, 18 Mar 2026 11:46:42 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/service/ExpoPushClient.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 index 9cc3fc79..50e28017 100644 --- a/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java +++ b/src/main/java/gg/agit/konect/domain/notification/service/ExpoPushClient.java @@ -33,7 +33,8 @@ public ExpoPushClient(@Qualifier("expoRestTemplate") RestTemplate expoRestTempla } @Retryable(maxAttempts = 2) - public void sendNotification(Integer receiverId, List tokens, String title, String body, Map data) { + 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(); @@ -84,7 +85,8 @@ public void sendNotification(Integer receiverId, List tokens, String tit } @Recover - public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List tokens, String title, + public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List tokens, + String title, String body, Map data) { log.error( @@ -98,7 +100,8 @@ public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverI } @Recover - public void sendNotificationRecover(ResourceAccessException e, Integer receiverId, List tokens, String title, + public void sendNotificationRecover(ResourceAccessException e, Integer receiverId, List tokens, + String title, String body, Map data) { Throwable rootCause = e.getMostSpecificCause();