From 2a30238e872856f831e0c2476f0b896d09464326 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Wed, 27 May 2026 15:26:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=8A=B8=EB=9E=98=ED=82=B9=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20data-only=20=EC=A0=84=EC=86=A1=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semosan/api/common/fcm/FcmService.java | 24 +++-- .../AsyncNotificationDispatcher.java | 13 ++- .../dispatcher/FcmPayloadPolicy.java | 19 ++++ .../notification/enums/NotificationType.java | 4 +- .../AsyncNotificationDispatcherTest.java | 99 +++++++++++++++++++ 5 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java create mode 100644 src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java diff --git a/src/main/java/com/semosan/api/common/fcm/FcmService.java b/src/main/java/com/semosan/api/common/fcm/FcmService.java index b6f3791d..682455fe 100644 --- a/src/main/java/com/semosan/api/common/fcm/FcmService.java +++ b/src/main/java/com/semosan/api/common/fcm/FcmService.java @@ -13,15 +13,23 @@ @Service public class FcmService { - public String sendMessage(String token, String title, String body, Map data) throws FirebaseMessagingException { - Notification notification = Notification.builder() - .setTitle(title) - .setBody(body) - .build(); - + public String sendMessage( + String token, + String title, + String body, + Map data, + boolean dataOnly + ) throws FirebaseMessagingException { Message.Builder builder = Message.builder() - .setToken(token) - .setNotification(notification); + .setToken(token); + + if (!dataOnly) { + Notification notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + builder.setNotification(notification); + } if (data != null && !data.isEmpty()) { builder.putAllData(data); diff --git a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java index 961a09ea..1e379900 100644 --- a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java +++ b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java @@ -22,6 +22,7 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher { private final FcmService fcmService; private final FcmTokenService fcmTokenService; private final ObjectMapper objectMapper; + private final FcmPayloadPolicy fcmPayloadPolicy; /** * @Async 가 같은 클래스에 있는 함수를 호출 할 때는 비동기로 작동하지 않으니 주의해야함 @@ -31,10 +32,11 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher { @Async("notificationTaskExecutor") public void dispatch(NotificationDispatchCommand cmd) { Map dataPayload = buildDataPayload(cmd); + boolean dataOnly = fcmPayloadPolicy.isDataOnly(cmd.type()); for (String token : cmd.tokens()) { try { - fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload); + fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload, dataOnly); } catch (FirebaseMessagingException e) { handleSendError(token, e); } catch (Exception e) { @@ -45,7 +47,16 @@ public void dispatch(NotificationDispatchCommand cmd) { private Map buildDataPayload(NotificationDispatchCommand cmd) { Map data = new HashMap<>(); + if (cmd.extras() != null) { + cmd.extras().forEach((key, value) -> { + if (key != null && value != null) { + data.put(key, String.valueOf(value)); + } + }); + } data.put("type", cmd.type().name()); + data.put("title", cmd.title()); + data.put("body", cmd.body()); data.put("notificationId", String.valueOf(cmd.notificationId())); try { data.put("extras", objectMapper.writeValueAsString(cmd.extras())); diff --git a/src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java b/src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java new file mode 100644 index 00000000..b0986bb5 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java @@ -0,0 +1,19 @@ +package com.semosan.api.domain.notification.dispatcher; + +import com.semosan.api.domain.notification.enums.NotificationType; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class FcmPayloadPolicy { + + private static final Set DATA_ONLY_TYPES = Set.of( + NotificationType.TRACKING_PHOTO_MILESTONE, + NotificationType.TRACKING_SUMMIT_REACHED + ); + + public boolean isDataOnly(NotificationType type) { + return DATA_ONLY_TYPES.contains(type); + } +} diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index 3eeea59b..9c688c1c 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -17,7 +17,7 @@ public enum NotificationType { /** * 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도. - * iOS 배너 노출을 위해 notification.title 에 앱 이름을 명시한다. + * 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다. */ TRACKING_PHOTO_MILESTONE( "SEMOSAN", @@ -28,7 +28,7 @@ public enum NotificationType { /** * 코스 거리 50% 도달 시 정상 인증 유도. * 진짜 정상 좌표가 식별 불가해 코스 절반 지점을 "정상" 근처로 간주하는 임시 정책. - * iOS 배너 노출을 위해 notification.title 에 앱 이름을 명시한다. + * 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다. */ TRACKING_SUMMIT_REACHED( "SEMOSAN", diff --git a/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java new file mode 100644 index 00000000..89ef266a --- /dev/null +++ b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java @@ -0,0 +1,99 @@ +package com.semosan.api.domain.notification.dispatcher; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.semosan.api.common.fcm.FcmService; +import com.semosan.api.domain.notification.enums.NotificationType; +import com.semosan.api.domain.notification.service.FcmTokenService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AsyncNotificationDispatcherTest { + + @Mock + private FcmService fcmService; + + @Mock + private FcmTokenService fcmTokenService; + + @Test + @SuppressWarnings("unchecked") + void dispatchSendsTrackingNotificationAsDataOnlyWithTitleBodyAndDistance() throws Exception { + AsyncNotificationDispatcher dispatcher = dispatcher(); + NotificationDispatchCommand command = new NotificationDispatchCommand( + 1L, + 10L, + NotificationType.TRACKING_PHOTO_MILESTONE, + "SEMOSAN", + "500m 돌파! 인증 사진을 남겨보세요!", + Map.of("distance", 500), + List.of("token-1") + ); + + dispatcher.dispatch(command); + + ArgumentCaptor> dataCaptor = ArgumentCaptor.forClass(Map.class); + verify(fcmService).sendMessage( + eq("token-1"), + eq("SEMOSAN"), + eq("500m 돌파! 인증 사진을 남겨보세요!"), + dataCaptor.capture(), + eq(true) + ); + assertThat(dataCaptor.getValue()) + .containsEntry("type", "TRACKING_PHOTO_MILESTONE") + .containsEntry("title", "SEMOSAN") + .containsEntry("body", "500m 돌파! 인증 사진을 남겨보세요!") + .containsEntry("distance", "500") + .containsEntry("notificationId", "1"); + } + + @Test + @SuppressWarnings("unchecked") + void dispatchKeepsGeneralNotificationPayload() throws Exception { + AsyncNotificationDispatcher dispatcher = dispatcher(); + NotificationDispatchCommand command = new NotificationDispatchCommand( + 2L, + 10L, + NotificationType.COMMUNITY_COMMENT, + "새 댓글이 달렸어요", + "푸름: 확인했어요", + Map.of("actorName", "푸름", "commentPreview", "확인했어요"), + List.of("token-1") + ); + + dispatcher.dispatch(command); + + ArgumentCaptor> dataCaptor = ArgumentCaptor.forClass(Map.class); + verify(fcmService).sendMessage( + eq("token-1"), + eq("새 댓글이 달렸어요"), + eq("푸름: 확인했어요"), + dataCaptor.capture(), + eq(false) + ); + assertThat(dataCaptor.getValue()) + .containsEntry("type", "COMMUNITY_COMMENT") + .containsEntry("title", "새 댓글이 달렸어요") + .containsEntry("body", "푸름: 확인했어요"); + } + + private AsyncNotificationDispatcher dispatcher() { + return new AsyncNotificationDispatcher( + fcmService, + fcmTokenService, + new ObjectMapper(), + new FcmPayloadPolicy() + ); + } +} From 0786ad3f20caa33b7dd16aa021c8fd2ac627dfb8 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Wed, 27 May 2026 15:31:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20FCM=20data=20payload=20extras=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatcher/AsyncNotificationDispatcher.java | 9 --------- .../dispatcher/AsyncNotificationDispatcherTest.java | 8 ++++---- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java index 1e379900..2fb30e7e 100644 --- a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java +++ b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java @@ -1,7 +1,5 @@ package com.semosan.api.domain.notification.dispatcher; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.MessagingErrorCode; import com.semosan.api.common.fcm.FcmService; @@ -21,7 +19,6 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher { private final FcmService fcmService; private final FcmTokenService fcmTokenService; - private final ObjectMapper objectMapper; private final FcmPayloadPolicy fcmPayloadPolicy; /** @@ -58,12 +55,6 @@ private Map buildDataPayload(NotificationDispatchCommand cmd) { data.put("title", cmd.title()); data.put("body", cmd.body()); data.put("notificationId", String.valueOf(cmd.notificationId())); - try { - data.put("extras", objectMapper.writeValueAsString(cmd.extras())); - } catch (JsonProcessingException e) { - log.error("extras 직렬화 실패", e); - data.put("extras", "{}"); - } return data; } diff --git a/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java index 89ef266a..ee481549 100644 --- a/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java +++ b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java @@ -1,6 +1,5 @@ package com.semosan.api.domain.notification.dispatcher; -import com.fasterxml.jackson.databind.ObjectMapper; import com.semosan.api.common.fcm.FcmService; import com.semosan.api.domain.notification.enums.NotificationType; import com.semosan.api.domain.notification.service.FcmTokenService; @@ -55,7 +54,8 @@ void dispatchSendsTrackingNotificationAsDataOnlyWithTitleBodyAndDistance() throw .containsEntry("title", "SEMOSAN") .containsEntry("body", "500m 돌파! 인증 사진을 남겨보세요!") .containsEntry("distance", "500") - .containsEntry("notificationId", "1"); + .containsEntry("notificationId", "1") + .doesNotContainKey("extras"); } @Test @@ -85,14 +85,14 @@ void dispatchKeepsGeneralNotificationPayload() throws Exception { assertThat(dataCaptor.getValue()) .containsEntry("type", "COMMUNITY_COMMENT") .containsEntry("title", "새 댓글이 달렸어요") - .containsEntry("body", "푸름: 확인했어요"); + .containsEntry("body", "푸름: 확인했어요") + .doesNotContainKey("extras"); } private AsyncNotificationDispatcher dispatcher() { return new AsyncNotificationDispatcher( fcmService, fcmTokenService, - new ObjectMapper(), new FcmPayloadPolicy() ); } From 7ee5071fbbe75803cf8d23c4be188354f5fd4f6d Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Wed, 27 May 2026 15:31:55 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20FCM=20data-only=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EC=9D=84=20=EC=95=8C=EB=A6=BC=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AsyncNotificationDispatcher.java | 4 +--- .../dispatcher/FcmPayloadPolicy.java | 19 ------------------- .../notification/enums/NotificationType.java | 17 +++++++++++++---- .../AsyncNotificationDispatcherTest.java | 3 +-- 4 files changed, 15 insertions(+), 28 deletions(-) delete mode 100644 src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java diff --git a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java index 2fb30e7e..a8cf1c2b 100644 --- a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java +++ b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java @@ -19,7 +19,6 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher { private final FcmService fcmService; private final FcmTokenService fcmTokenService; - private final FcmPayloadPolicy fcmPayloadPolicy; /** * @Async 가 같은 클래스에 있는 함수를 호출 할 때는 비동기로 작동하지 않으니 주의해야함 @@ -29,11 +28,10 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher { @Async("notificationTaskExecutor") public void dispatch(NotificationDispatchCommand cmd) { Map dataPayload = buildDataPayload(cmd); - boolean dataOnly = fcmPayloadPolicy.isDataOnly(cmd.type()); for (String token : cmd.tokens()) { try { - fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload, dataOnly); + fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload, cmd.type().isDataOnly()); } catch (FirebaseMessagingException e) { handleSendError(token, e); } catch (Exception e) { diff --git a/src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java b/src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java deleted file mode 100644 index b0986bb5..00000000 --- a/src/main/java/com/semosan/api/domain/notification/dispatcher/FcmPayloadPolicy.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.semosan.api.domain.notification.dispatcher; - -import com.semosan.api.domain.notification.enums.NotificationType; -import org.springframework.stereotype.Component; - -import java.util.Set; - -@Component -public class FcmPayloadPolicy { - - private static final Set DATA_ONLY_TYPES = Set.of( - NotificationType.TRACKING_PHOTO_MILESTONE, - NotificationType.TRACKING_SUMMIT_REACHED - ); - - public boolean isDataOnly(NotificationType type) { - return DATA_ONLY_TYPES.contains(type); - } -} diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index 9c688c1c..205f5c94 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -12,7 +12,8 @@ public enum NotificationType { COMMUNITY_COMMENT( "새 댓글이 달렸어요", "{actorName}: {commentPreview}", - Set.of("actorName", "commentPreview") + Set.of("actorName", "commentPreview"), + false ), /** @@ -22,7 +23,8 @@ public enum NotificationType { TRACKING_PHOTO_MILESTONE( "SEMOSAN", "{distance}m 돌파! 인증 사진을 남겨보세요!", - Set.of("distance") + Set.of("distance"), + true ), /** @@ -33,17 +35,20 @@ public enum NotificationType { TRACKING_SUMMIT_REACHED( "SEMOSAN", "정상에 도착했나요? 정상 인증하기!", - Set.of() + Set.of(), + true ); private final String titleTemplate; private final String bodyTemplate; private final Set requiredKeys; + private final boolean dataOnly; - NotificationType(String titleTemplate, String bodyTemplate, Set requiredKeys) { + NotificationType(String titleTemplate, String bodyTemplate, Set requiredKeys, boolean dataOnly) { this.titleTemplate = titleTemplate; this.bodyTemplate = bodyTemplate; this.requiredKeys = requiredKeys; + this.dataOnly = dataOnly; } /** @@ -69,6 +74,10 @@ public String formatBody(Map params) { return format(bodyTemplate, params); } + public boolean isDataOnly() { + return dataOnly; + } + private static String format(String template, Map params) { if (params == null || params.isEmpty()) return template; String result = template; diff --git a/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java index ee481549..eb70de24 100644 --- a/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java +++ b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java @@ -92,8 +92,7 @@ void dispatchKeepsGeneralNotificationPayload() throws Exception { private AsyncNotificationDispatcher dispatcher() { return new AsyncNotificationDispatcher( fcmService, - fcmTokenService, - new FcmPayloadPolicy() + fcmTokenService ); } }