Skip to content

Commit 23bccf0

Browse files
authored
hotfix: EXPO 알림 타임아웃 제한 증가 및 재시도 추가 (#400)
* fix: EXPO 알림 타임아웃 재시도 추가 * refactor: 롬복 기반 생성자 어노테이션으로 교체 * fix: EXPO 알림 실패 원인 로그를 예외별로 구체화 * fix: pre-push 포맷 대상 경로를 절대경로로 정규화 * chore: 코드 포맷팅
1 parent d578c5b commit 23bccf0

4 files changed

Lines changed: 200 additions & 77 deletions

File tree

scripts/code-formatting.sh

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pre_format_snapshot=""
1414

1515
resolve_target_java_file() {
1616
local input_file="$1"
17+
local candidate_path=""
1718
local resolved_file=""
1819
local matched_files=""
1920
local match_count
@@ -24,11 +25,11 @@ resolve_target_java_file() {
2425
esac
2526

2627
if [ -f "$input_file" ]; then
27-
resolved_file="$input_file"
28+
candidate_path="$input_file"
2829
elif [ -f "$invocation_dir/$input_file" ]; then
29-
resolved_file="$invocation_dir/$input_file"
30+
candidate_path="$invocation_dir/$input_file"
3031
elif [ -f "$repo_root/$input_file" ]; then
31-
resolved_file="$repo_root/$input_file"
32+
candidate_path="$repo_root/$input_file"
3233
else
3334
matched_files="$(git ls-files -- "$input_file" "*/$input_file" 2>/dev/null || true)"
3435
if [ -z "$matched_files" ]; then
@@ -42,7 +43,11 @@ resolve_target_java_file() {
4243
return 1
4344
fi
4445

45-
resolved_file="$repo_root/$matched_files"
46+
candidate_path="$repo_root/$matched_files"
47+
fi
48+
49+
if [ -n "$candidate_path" ]; then
50+
resolved_file="$(cd "$(dirname "$candidate_path")" && pwd -P)/$(basename "$candidate_path")"
4651
fi
4752

4853
case "$resolved_file" in
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package gg.agit.konect.domain.notification.service;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
import org.springframework.beans.factory.annotation.Qualifier;
7+
import org.springframework.http.HttpEntity;
8+
import org.springframework.http.HttpHeaders;
9+
import org.springframework.http.HttpMethod;
10+
import org.springframework.http.MediaType;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.retry.annotation.Recover;
13+
import org.springframework.retry.annotation.Retryable;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.web.client.HttpStatusCodeException;
16+
import org.springframework.web.client.ResourceAccessException;
17+
import org.springframework.web.client.RestClientException;
18+
import org.springframework.web.client.RestTemplate;
19+
20+
import lombok.extern.slf4j.Slf4j;
21+
22+
@Slf4j
23+
@Component
24+
public class ExpoPushClient {
25+
26+
private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
27+
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "default_notifications";
28+
29+
private final RestTemplate expoRestTemplate;
30+
31+
public ExpoPushClient(@Qualifier("expoRestTemplate") RestTemplate expoRestTemplate) {
32+
this.expoRestTemplate = expoRestTemplate;
33+
}
34+
35+
@Retryable(maxAttempts = 2)
36+
public void sendNotification(Integer receiverId, List<String> tokens, String title, String body,
37+
Map<String, Object> data) {
38+
List<ExpoPushMessage> messages = tokens.stream()
39+
.map(token -> new ExpoPushMessage(token, title, body, data, DEFAULT_NOTIFICATION_CHANNEL_ID))
40+
.toList();
41+
42+
HttpHeaders headers = new HttpHeaders();
43+
headers.setContentType(MediaType.APPLICATION_JSON);
44+
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
45+
46+
HttpEntity<List<ExpoPushMessage>> entity = new HttpEntity<>(messages, headers);
47+
ResponseEntity<ExpoPushResponse> response = expoRestTemplate.exchange(
48+
EXPO_PUSH_URL,
49+
HttpMethod.POST,
50+
entity,
51+
ExpoPushResponse.class
52+
);
53+
54+
if (!response.getStatusCode().is2xxSuccessful()) {
55+
throw new IllegalStateException(
56+
"Expo push response not successful: receiverId=%d, status=%s"
57+
.formatted(receiverId, response.getStatusCode())
58+
);
59+
}
60+
61+
ExpoPushResponse responseBody = response.getBody();
62+
if (responseBody == null || responseBody.data() == null) {
63+
throw new IllegalStateException(
64+
"Expo push response body missing: receiverId=%d".formatted(receiverId)
65+
);
66+
}
67+
68+
for (int i = 0; i < responseBody.data().size(); i += 1) {
69+
ExpoPushTicket ticket = responseBody.data().get(i);
70+
if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) {
71+
continue;
72+
}
73+
String token = i < tokens.size() ? tokens.get(i) : "unknown";
74+
log.error(
75+
"Expo 푸시 발송 실패: receiverId={}, token={}, status={}, message={}, details={}",
76+
receiverId,
77+
token,
78+
ticket.status(),
79+
ticket.message(),
80+
ticket.details()
81+
);
82+
}
83+
84+
log.debug("알림 발송 완료: receiverId={}, tokenCount={}", receiverId, tokens.size());
85+
}
86+
87+
@Recover
88+
public void sendNotificationRecover(HttpStatusCodeException e, Integer receiverId, List<String> tokens,
89+
String title,
90+
String body,
91+
Map<String, Object> data) {
92+
log.error(
93+
"알림 재시도 후에도 HTTP 오류로 발송에 실패했습니다: receiverId={}, tokenCount={}, statusCode={}, responseBody={}",
94+
receiverId,
95+
tokens.size(),
96+
e.getStatusCode(),
97+
e.getResponseBodyAsString(),
98+
e
99+
);
100+
}
101+
102+
@Recover
103+
public void sendNotificationRecover(ResourceAccessException e, Integer receiverId, List<String> tokens,
104+
String title,
105+
String body,
106+
Map<String, Object> data) {
107+
Throwable rootCause = e.getMostSpecificCause();
108+
log.error(
109+
"알림 재시도 후에도 연결 문제로 발송에 실패했습니다: receiverId={}, tokenCount={}, rootCauseType={}, rootCauseMessage={}",
110+
receiverId,
111+
tokens.size(),
112+
rootCause.getClass().getSimpleName(),
113+
rootCause.getMessage(),
114+
e
115+
);
116+
}
117+
118+
@Recover
119+
public void sendNotificationRecover(IllegalStateException e, Integer receiverId, List<String> tokens, String title,
120+
String body,
121+
Map<String, Object> data) {
122+
log.error(
123+
"알림 재시도 후에도 Expo 응답이 비정상이라 발송에 실패했습니다: receiverId={}, tokenCount={}, message={}",
124+
receiverId,
125+
tokens.size(),
126+
e.getMessage(),
127+
e
128+
);
129+
}
130+
131+
@Recover
132+
public void sendNotificationRecover(RestClientException e, Integer receiverId, List<String> tokens, String title,
133+
String body,
134+
Map<String, Object> data) {
135+
log.error(
136+
"알림 재시도 후에도 Rest 클라이언트 오류로 발송에 실패했습니다: receiverId={}, tokenCount={}, exceptionType={}, message={}",
137+
receiverId,
138+
tokens.size(),
139+
e.getClass().getSimpleName(),
140+
e.getMessage(),
141+
e
142+
);
143+
}
144+
145+
@Recover
146+
public void sendNotificationRecover(Exception e, Integer receiverId, List<String> tokens, String title, String body,
147+
Map<String, Object> data) {
148+
log.error(
149+
"알림 재시도 후에도 예기치 못한 오류로 발송에 실패했습니다: receiverId={}, tokenCount={}, exceptionType={}, message={}",
150+
receiverId,
151+
tokens.size(),
152+
e.getClass().getSimpleName(),
153+
e.getMessage(),
154+
e
155+
);
156+
}
157+
158+
private record ExpoPushMessage(String to, String title, String body, Map<String, Object> data, String channelId) {
159+
}
160+
161+
private record ExpoPushResponse(List<ExpoPushTicket> data) {
162+
}
163+
164+
private record ExpoPushTicket(String status, String message, Map<String, Object> details) {
165+
}
166+
}

src/main/java/gg/agit/konect/domain/notification/service/NotificationService.java

Lines changed: 10 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
import gg.agit.konect.domain.user.model.User;
2929
import gg.agit.konect.domain.user.repository.UserRepository;
3030
import gg.agit.konect.global.exception.CustomException;
31+
import lombok.RequiredArgsConstructor;
3132
import lombok.extern.slf4j.Slf4j;
3233

3334
@Service
3435
@Slf4j
36+
@RequiredArgsConstructor
3537
@Transactional(readOnly = true)
3638
public class NotificationService {
3739

@@ -47,20 +49,7 @@ public class NotificationService {
4749
private final NotificationMuteSettingRepository notificationMuteSettingRepository;
4850
private final RestTemplate restTemplate;
4951
private final ChatPresenceService chatPresenceService;
50-
51-
public NotificationService(
52-
UserRepository userRepository,
53-
NotificationDeviceTokenRepository notificationDeviceTokenRepository,
54-
NotificationMuteSettingRepository notificationMuteSettingRepository,
55-
RestTemplate restTemplate,
56-
ChatPresenceService chatPresenceService
57-
) {
58-
this.userRepository = userRepository;
59-
this.notificationDeviceTokenRepository = notificationDeviceTokenRepository;
60-
this.notificationMuteSettingRepository = notificationMuteSettingRepository;
61-
this.restTemplate = restTemplate;
62-
this.chatPresenceService = chatPresenceService;
63-
}
52+
private final ExpoPushClient expoPushClient;
6453

6554
public NotificationTokenResponse getMyToken(Integer userId) {
6655
NotificationDeviceToken token = notificationDeviceTokenRepository.getByUserId(userId);
@@ -337,66 +326,14 @@ public void sendClubApplicationRejectedNotification(Integer receiverId, Integer
337326
}
338327

339328
private void sendNotification(Integer receiverId, String title, String body, String path) {
340-
try {
341-
List<String> tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId);
342-
if (tokens.isEmpty()) {
343-
log.debug("No device tokens found for user: receiverId={}", receiverId);
344-
return;
345-
}
346-
347-
Map<String, Object> data = buildData(null, path);
348-
349-
List<ExpoPushMessage> messages = tokens.stream()
350-
.map(token -> new ExpoPushMessage(token, title, body, data, DEFAULT_NOTIFICATION_CHANNEL_ID))
351-
.toList();
352-
353-
HttpHeaders headers = new HttpHeaders();
354-
headers.setContentType(MediaType.APPLICATION_JSON);
355-
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
356-
357-
HttpEntity<List<ExpoPushMessage>> entity = new HttpEntity<>(messages, headers);
358-
ResponseEntity<ExpoPushResponse> response = restTemplate.exchange(
359-
EXPO_PUSH_URL,
360-
HttpMethod.POST,
361-
entity,
362-
ExpoPushResponse.class
363-
);
364-
365-
if (!response.getStatusCode().is2xxSuccessful()) {
366-
log.error(
367-
"Expo push response not successful: receiverId={}, status={}",
368-
receiverId,
369-
response.getStatusCode()
370-
);
371-
return;
372-
}
373-
374-
ExpoPushResponse responseBody = response.getBody();
375-
if (responseBody == null || responseBody.data == null) {
376-
log.error("Expo push response body missing: receiverId={}", receiverId);
377-
return;
378-
}
379-
380-
for (int i = 0; i < responseBody.data.size(); i += 1) {
381-
ExpoPushTicket ticket = responseBody.data.get(i);
382-
if (ticket == null || "ok".equalsIgnoreCase(ticket.status())) {
383-
continue;
384-
}
385-
String token = i < tokens.size() ? tokens.get(i) : "unknown";
386-
log.error(
387-
"Expo push failed: receiverId={}, token={}, status={}, message={}, details={}",
388-
receiverId,
389-
token,
390-
ticket.status(),
391-
ticket.message(),
392-
ticket.details()
393-
);
394-
}
395-
396-
log.debug("Notification sent: receiverId={}, tokenCount={}", receiverId, tokens.size());
397-
} catch (Exception e) {
398-
log.error("Failed to send notification: receiverId={}", receiverId, e);
329+
List<String> tokens = notificationDeviceTokenRepository.findTokensByUserId(receiverId);
330+
if (tokens.isEmpty()) {
331+
log.debug("No device tokens found for user: receiverId={}", receiverId);
332+
return;
399333
}
334+
335+
Map<String, Object> data = buildData(null, path);
336+
expoPushClient.sendNotification(receiverId, tokens, title, body, data);
400337
}
401338

402339
private Map<String, Object> buildData(Map<String, String> data, String path) {

src/main/java/gg/agit/konect/global/config/RestTemplateConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public class RestTemplateConfig {
1515

1616
private static final Integer CONNECT_TIMEOUT = 5000;
1717
private static final Integer READ_TIMEOUT = 5000;
18+
private static final Integer EXPO_CONNECT_TIMEOUT = 10000;
19+
private static final Integer EXPO_READ_TIMEOUT = 10000;
1820

1921
@Bean
2022
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
@@ -28,4 +30,17 @@ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
2830
.additionalMessageConverters(new StringHttpMessageConverter(UTF_8))
2931
.build();
3032
}
33+
34+
@Bean("expoRestTemplate")
35+
public RestTemplate expoRestTemplate(RestTemplateBuilder restTemplateBuilder) {
36+
return restTemplateBuilder
37+
.requestFactory(() -> {
38+
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
39+
factory.setConnectTimeout(EXPO_CONNECT_TIMEOUT);
40+
factory.setReadTimeout(EXPO_READ_TIMEOUT);
41+
return new BufferingClientHttpRequestFactory(factory);
42+
})
43+
.additionalMessageConverters(new StringHttpMessageConverter(UTF_8))
44+
.build();
45+
}
3146
}

0 commit comments

Comments
 (0)