Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b5aebff
feat: PushSubscriptionJpaRepository 및 PushSubscriptionRepository에 del…
GoGradually Jan 19, 2026
1f491eb
feat: PushSubscriptionJpaRepository 및 PushSubscriptionRepository에 del…
GoGradually Jan 19, 2026
900553b
feat: PushSubscriptionRepositoryAdapter에 deleteByTokens 메서드 추가
GoGradually Jan 19, 2026
67ab83a
feat: PushSendResult 클래스 추가 및 결과 처리 메서드 구현
GoGradually Jan 19, 2026
0c757cf
feat: sendPushMessage 메서드의 반환 타입을 PushSendResult로 변경
GoGradually Jan 19, 2026
6aa26f0
feat: sendPushMessage 메서드의 반환 타입을 PushSendResult로 변경
GoGradually Jan 19, 2026
ee0e4b2
feat: UpcomingScheduleNotificationEntity에 schedule_start_time 인덱스 추가
GoGradually Jan 19, 2026
ba187da
feat: NotificationDispatchItem 레코드 추가
GoGradually Jan 19, 2026
74d499e
feat: NotificationDispatchQueryRepository 인터페이스 추가
GoGradually Jan 19, 2026
928a668
feat: NotificationDispatchQueryRepositoryAdapter 추가 및 알림 토큰 집계 기능 구현
GoGradually Jan 19, 2026
d345d28
feat: 알림 배치 처리 로직 개선 및 푸시 토큰 관리 기능 추가
GoGradually Jan 19, 2026
a2a7e58
feat: NotificationDispatchScheduler에서 토큰 삭제 집합을 HashSet으로 변경
GoGradually Jan 19, 2026
6a9e4ec
feat: PushTokenCleanupService 추가 및 푸시 토큰 삭제 기능 구현
GoGradually Jan 19, 2026
09b6897
feat: PushTokenCleanupService를 사용하여 푸시 토큰 삭제 로직 개선
GoGradually Jan 19, 2026
8f93b53
feat: 알림 토큰 조회 메서드 이름 변경 및 일관성 개선
GoGradually Jan 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package me.pinitnotification.application.notification;

import me.pinitnotification.domain.notification.UpcomingScheduleNotification;

import java.util.List;

public record NotificationDispatchItem(
UpcomingScheduleNotification notification,
List<String> tokens
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package me.pinitnotification.application.notification;

import java.time.Instant;
import java.util.List;

public interface NotificationDispatchQueryRepository {
List<NotificationDispatchItem> findDueNotificationsWithTokens(Instant now);
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 메서드 이름 findDueNotificationsWithTokens가 실제 동작을 정확히 설명하지 못합니다. 이 메서드는 토큰이 있는 알림만 반환하는 것이 아니라, 토큰이 없는 알림도 함께 반환합니다.

영향: 메서드 이름이 오해를 불러일으킬 수 있으며, 개발자가 토큰이 있는 알림만 반환될 것으로 잘못 이해할 수 있습니다.

수정 제안: 메서드 이름을 findDueNotificationsWithAggregatedTokens 또는 findAllDueNotificationsWithTokens로 변경하여 모든 만료된 알림을 반환하되 토큰을 집계한다는 의미를 명확히 하세요.

Suggested change
List<NotificationDispatchItem> findDueNotificationsWithTokens(Instant now);
List<NotificationDispatchItem> findAllDueNotificationsWithTokens(Instant now);

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,39 @@
package me.pinitnotification.application.notification;

import me.pinitnotification.application.push.PushSendResult;
import me.pinitnotification.application.push.PushService;
import me.pinitnotification.domain.notification.UpcomingScheduleNotification;
import me.pinitnotification.domain.notification.UpcomingScheduleNotificationRepository;
import me.pinitnotification.domain.push.PushSubscription;
import me.pinitnotification.domain.push.PushSubscriptionRepository;
import me.pinitnotification.application.push.PushService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

@Service
public class NotificationDispatchScheduler {
private static final Logger log = LoggerFactory.getLogger(NotificationDispatchScheduler.class);

private final UpcomingScheduleNotificationRepository notificationRepository;
private final NotificationDispatchQueryRepository dispatchQueryRepository;
private final PushSubscriptionRepository pushSubscriptionRepository;
private final PushService pushService;
private final Clock clock;

public NotificationDispatchScheduler(UpcomingScheduleNotificationRepository notificationRepository,
NotificationDispatchQueryRepository dispatchQueryRepository,
PushSubscriptionRepository pushSubscriptionRepository,
PushService pushService,
Clock clock) {
this.notificationRepository = notificationRepository;
this.dispatchQueryRepository = dispatchQueryRepository;
this.pushSubscriptionRepository = pushSubscriptionRepository;
this.pushService = pushService;
this.clock = clock;
Expand All @@ -38,27 +42,37 @@ public NotificationDispatchScheduler(UpcomingScheduleNotificationRepository noti
@Scheduled(cron = "0 */10 * * * *")
@Transactional
public void dispatchDueNotifications() {
OffsetDateTime now = OffsetDateTime.now(clock);
List<UpcomingScheduleNotification> dueNotifications = notificationRepository.findAll().stream()
.filter(notification -> notification.isDue(now))
.toList();
Instant now = Instant.now(clock);
List<NotificationDispatchItem> dispatchItems = dispatchQueryRepository.findDueNotificationsWithTokens(now);

if (dueNotifications.isEmpty()) {
if (dispatchItems.isEmpty()) {
return;
}

dueNotifications.forEach(this::sendNotificationToOwner);
notificationRepository.deleteAllInBatch(dueNotifications);
Set<String> tokensToDelete = new LinkedHashSet<>();
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 여러 알림에서 동일한 무효 토큰이 발견될 경우, Set이 중복을 제거하므로 문제가 없지만, LinkedHashSet을 사용하는 명시적인 이유가 없습니다.

영향: LinkedHashSet은 삽입 순서를 유지하지만, 토큰 삭제 시 순서가 중요하지 않으므로 불필요한 오버헤드가 발생합니다.

수정 제안: 순서가 중요하지 않다면 일반 HashSet을 사용하여 성능을 최적화하세요.

Copilot generated this review using guidance from repository custom instructions.
dispatchItems.forEach(item -> sendNotificationToOwner(item, tokensToDelete));
notificationRepository.deleteAllInBatch(dispatchItems.stream().map(NotificationDispatchItem::notification).toList());

if (!tokensToDelete.isEmpty()) {
pushSubscriptionRepository.deleteByTokens(tokensToDelete);
}
Comment on lines 53 to 57
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 배치 삭제를 수행하는 deleteAllInBatchdeleteByTokens 메서드 호출 사이에 트랜잭션 일관성 문제가 발생할 수 있습니다. 알림은 삭제되었지만 무효 토큰 삭제가 실패하면 데이터 불일치가 발생합니다.

영향: 예외 발생 시 알림은 삭제되었지만 무효한 토큰은 남아있어, 다음 배치 실행 시 해당 토큰으로 푸시를 보내려는 시도가 반복될 수 있습니다.

수정 제안:

  1. 토큰 삭제를 먼저 수행하고 알림 삭제를 나중에 수행하거나
  2. 예외 처리 및 재시도 로직을 추가하여 트랜잭션의 원자성을 보장하세요.
  3. 또는 두 작업을 하나의 트랜잭션으로 묶고 rollback 정책을 명확히 하세요.

Copilot generated this review using guidance from repository custom instructions.
}


private void sendNotificationToOwner(UpcomingScheduleNotification notification) {
List<PushSubscription> subscriptions = pushSubscriptionRepository.findAllByMemberId(notification.getOwnerId());
if (subscriptions.isEmpty()) {
private void sendNotificationToOwner(NotificationDispatchItem dispatchItem, Set<String> tokensToDelete) {
UpcomingScheduleNotification notification = dispatchItem.notification();
List<String> tokens = dispatchItem.tokens();

if (tokens.isEmpty()) {
log.info("No push tokens for owner; skip sending. ownerId={}, scheduleId={}", notification.getOwnerId(), notification.getScheduleId());
return;
}

subscriptions.forEach(subscription -> pushService.sendPushMessage(subscription.getToken(), notification));
tokens.forEach(token -> {
PushSendResult result = pushService.sendPushMessage(token, notification);
if (result.shouldDeleteToken()) {
tokensToDelete.add(token);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package me.pinitnotification.application.push;

public record PushSendResult(
boolean success,
boolean invalidToken
) {
public static PushSendResult successResult() {
return new PushSendResult(true, false);
}

public static PushSendResult invalidTokenResult() {
return new PushSendResult(false, true);
}

public static PushSendResult failedResult() {
return new PushSendResult(false, false);
}

public boolean shouldDeleteToken() {
return invalidToken;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public interface PushService {
void subscribe(Long memberId, String deviceId, String token);

void unsubscribe(Long memberId, String deviceId, String token);
void sendPushMessage(String token, Notification notification);

PushSendResult sendPushMessage(String token, Notification notification);

boolean isSubscribed(Long memberId, String deviceId);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.pinitnotification.domain.push;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand All @@ -12,5 +13,7 @@ public interface PushSubscriptionRepository {

void deleteByToken(String token);

Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 인터페이스의 새로운 메서드에 대한 문서화가 없습니다. 특히 배치 처리의 목적과 파라미터 요구사항이 명시되어 있지 않습니다.

영향: 도메인 계층의 API 사용자가 이 메서드를 어떻게 사용해야 하는지, 어떤 동작을 기대할 수 있는지 알기 어렵습니다.

수정 제안: JavaDoc을 추가하여 메서드의 목적, 파라미터 요구사항, 반환값, 예외 상황 등을 문서화하세요.

Suggested change
/**
* 주어진 푸시 토큰 컬렉션에 해당하는 모든 {@link PushSubscription} 엔티티를 일괄 삭제합니다.
* <p>
* 구현체에 따라 메서드는 데이터베이스 트랜잭션 내에서 실행될 있으며,
* 전달된 토큰 실제로 존재하지 않는 토큰은 무시될 있습니다.
*
* @param tokens 삭제 대상이 되는 푸시 토큰들의 컬렉션
* <ul>
* <li>{@code null} 이면 구현체에 따라 {@link IllegalArgumentException} 런타임 예외가 발생할 있습니다.</li>
* <li> 컬렉션인 경우 일반적으로 아무 삭제도 수행하지 않습니다.</li>
* </ul>
* @throws RuntimeException 구현체에 따라 데이터 액세스 오류, 트랜잭션 오류 등이 발생할 경우 런타임 예외를 던질 있습니다.
*/

Copilot uses AI. Check for mistakes.
void deleteByTokens(Collection<String> tokens);

void deleteByMemberIdAndDeviceId(Long memberId, String deviceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MessagingErrorCode;
import lombok.extern.slf4j.Slf4j;
import me.pinitnotification.application.push.PushSendResult;
import me.pinitnotification.application.push.PushService;
import me.pinitnotification.domain.notification.Notification;
import me.pinitnotification.domain.push.PushSubscription;
Expand Down Expand Up @@ -34,20 +35,21 @@ public FcmService(FirebaseMessaging firebaseMessaging,
}

@Override
public void sendPushMessage(String token, Notification notification) {
public PushSendResult sendPushMessage(String token, Notification notification) {
log.info("publish token: {}", token);
Message message = Message.builder()
.setToken(token)
.putAllData(notification.getData())
.build();
try {
firebaseMessaging.send(message);
return PushSendResult.successResult();
} catch (FirebaseMessagingException e) {
log.error(e.getMessage(), e);
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED || e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
// Todo 토큰 삭제 방식 변경 필요
pushSubscriptionRepository.deleteByToken(token);
return PushSendResult.invalidTokenResult();
}
return PushSendResult.failedResult();
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package me.pinitnotification.infrastructure.persistence.notification;

import me.pinitnotification.application.notification.NotificationDispatchItem;
import me.pinitnotification.application.notification.NotificationDispatchQueryRepository;
import me.pinitnotification.domain.notification.UpcomingScheduleNotification;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.*;

@Repository
public class NotificationDispatchQueryRepositoryAdapter implements NotificationDispatchQueryRepository {
private static final String FIND_DUE_WITH_TOKENS_SQL = """
SELECT n.public_id,
n.owner_id,
n.schedule_id,
n.schedule_title,
n.schedule_start_time,
n.idempotent_key,
ps.token
FROM upcoming_schedule_notification n
LEFT JOIN push_subscription ps ON ps.member_id = n.owner_id
WHERE n.schedule_start_time IS NOT NULL
AND n.schedule_start_time <= ?
Comment on lines +24 to +25
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: SQL의 WHERE 절에서 n.schedule_start_time IS NOT NULL을 체크하지만, Entity에서 해당 필드가 nullable = false로 정의되어 있습니다.

영향: 불필요한 NULL 체크로 인해 쿼리가 복잡해지고 성능에 약간의 영향을 미칠 수 있습니다. 데이터베이스 제약 조건과 코드의 일관성이 떨어집니다.

수정 제안: Entity의 nullable 설정과 SQL 쿼리를 일치시키세요. 필드가 NOT NULL이라면 IS NOT NULL 체크를 제거하거나, NULL을 허용해야 한다면 Entity 정의를 수정하세요.

Suggested change
WHERE n.schedule_start_time IS NOT NULL
AND n.schedule_start_time <= ?
WHERE n.schedule_start_time <= ?

Copilot uses AI. Check for mistakes.
""";

private final JdbcClient jdbcClient;

public NotificationDispatchQueryRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}

@Override
public List<NotificationDispatchItem> findDueNotificationsWithTokens(Instant now) {
List<DispatchRow> rows = jdbcClient.sql(FIND_DUE_WITH_TOKENS_SQL)
.param(now.toString())
Comment on lines +36 to +37
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: now.toString()를 SQL 파라미터로 사용하면 Instant가 ISO-8601 형식으로 변환되지만, 데이터베이스의 문자열 비교 방식에 따라 예상치 못한 결과가 발생할 수 있습니다.

영향: schedule_start_time이 VARCHAR로 저장되어 있어 문자열 비교가 수행되는데, 타임존 정보가 포함된 ISO-8601 문자열(예: "2024-06-01T10:00:00Z")과 데이터베이스에 저장된 형식이 다를 경우 정확한 비교가 불가능합니다.

수정 제안:

  1. 데이터베이스 컬럼을 TIMESTAMP 타입으로 변경하거나
  2. 파라미터 바인딩 시 적절한 타입 변환을 명시적으로 수행해야 합니다 (예: .param(Timestamp.from(now)) 또는 데이터베이스가 지원하는 형식으로 변환)

Copilot generated this review using guidance from repository custom instructions.
.query((rs, rowNum) -> new DispatchRow(
UUID.fromString(rs.getString("public_id")),
rs.getLong("owner_id"),
rs.getLong("schedule_id"),
rs.getString("schedule_title"),
rs.getString("schedule_start_time"),
rs.getString("idempotent_key"),
rs.getString("token")
))
.list();

if (rows.isEmpty()) {
return List.of();
}

Map<UUID, DispatchAccumulator> aggregated = new LinkedHashMap<>();
for (DispatchRow row : rows) {
DispatchAccumulator accumulator = aggregated.computeIfAbsent(
row.notificationId,
id -> new DispatchAccumulator(
toDomain(row),
new ArrayList<>()
)
);
if (row.token != null) {
accumulator.tokens.add(row.token);
}
Comment on lines +62 to +64
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 토큰 중복이 발생할 수 있는 상황에 대한 처리가 없습니다. LEFT JOIN으로 인해 같은 토큰이 여러 번 추가될 수 있습니다.

영향: 동일한 member_id를 가진 push_subscription이 여러 개 있을 경우(같은 사용자가 여러 기기에서 같은 토큰을 사용하는 경우는 드물지만), 중복 토큰이 리스트에 포함되어 동일한 푸시 메시지가 여러 번 전송될 수 있습니다.

수정 제안: 토큰을 List 대신 Set으로 수집하거나, 중복을 방지하는 로직을 추가하세요. 예를 들어 accumulator.tokens가 Set이거나, 추가 전에 contains 체크를 수행할 수 있습니다.

Copilot generated this review using guidance from repository custom instructions.
}

return aggregated.values().stream()
.map(accumulator -> new NotificationDispatchItem(accumulator.notification, List.copyOf(accumulator.tokens)))
Comment on lines +67 to +68
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: List.copyOf(accumulator.tokens)를 사용하여 불변 리스트를 생성하는 것은 좋지만, 이미 내부적으로 mutable ArrayList를 사용하고 있어 방어적 복사의 의미가 제한적입니다.

영향: 성능상 약간의 오버헤드가 발생합니다. 만약 tokens가 Set으로 관리된다면 중복 제거와 불변성을 동시에 보장할 수 있습니다.

수정 제안: accumulator.tokens를 Set으로 변경하고 List.copyOf(accumulator.tokens) 또는 Set.copyOf()를 사용하여 일관성을 유지하세요.

Copilot uses AI. Check for mistakes.
.toList();
}

private UpcomingScheduleNotification toDomain(DispatchRow row) {
return new UpcomingScheduleNotification(
row.notificationId,
row.ownerId,
row.scheduleId,
row.scheduleTitle,
row.scheduleStartTime,
row.idempotentKey
);
}

private record DispatchRow(
UUID notificationId,
Long ownerId,
Long scheduleId,
String scheduleTitle,
String scheduleStartTime,
String idempotentKey,
String token
) {
}

private static class DispatchAccumulator {
private final UpcomingScheduleNotification notification;
private final List<String> tokens;

private DispatchAccumulator(UpcomingScheduleNotification notification, List<String> tokens) {
this.notification = notification;
this.tokens = tokens;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
uniqueConstraints = {
@UniqueConstraint(name = "uk_schedule_owner", columnNames = {"schedule_id", "owner_id"}),
@UniqueConstraint(name = "uk_idempotent_key", columnNames = {"idempotent_key"})
},
indexes = {
@Index(name = "idx_upcoming_start_time", columnList = "schedule_start_time")
}
Comment on lines +19 to 21
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 데이터베이스 인덱스가 추가되었으나, 실제 쿼리에서 해당 인덱스가 효과적으로 사용될 수 있는지 검증이 필요합니다. schedule_start_time이 VARCHAR 타입이고 문자열 비교를 수행하는 경우 인덱스 활용이 제한될 수 있습니다.

영향: 알림 수가 많아질수록 인덱스가 제대로 활용되지 않으면 쿼리 성능이 저하될 수 있습니다.

수정 제안:

  1. EXPLAIN PLAN을 사용하여 인덱스가 실제로 사용되는지 확인하세요.
  2. 가능하다면 schedule_start_time 컬럼을 TIMESTAMP 타입으로 변경하여 인덱스 활용도를 높이세요.

Copilot uses AI. Check for mistakes.
)
@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand All @@ -12,5 +13,7 @@ public interface PushSubscriptionJpaRepository extends JpaRepository<PushSubscri

void deleteByToken(String token);

void deleteByTokenIn(Collection<String> tokens);

void deleteByMemberIdAndDeviceId(Long memberId, String deviceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import me.pinitnotification.domain.push.PushSubscriptionRepository;
import org.springframework.stereotype.Repository;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -39,6 +40,14 @@ public void deleteByToken(String token) {
jpaRepository.deleteByToken(token);
}

@Override
public void deleteByTokens(Collection<String> tokens) {
if (tokens == null || tokens.isEmpty()) {
return;
}
jpaRepository.deleteByTokenIn(tokens);
}
Comment on lines +43 to +49
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제점: 새로 추가된 배치 처리 메서드에 대한 JavaDoc이나 주석이 없습니다. 특히 Collection<String> tokens 파라미터가 null이거나 빈 컬렉션일 때의 동작이 명시되어 있지 않습니다.

영향: API 사용자가 이 메서드의 동작을 이해하기 어렵고, null 안전성에 대한 가정을 할 수 없습니다.

수정 제안:

  • 메서드의 목적과 동작을 설명하는 JavaDoc을 추가하세요.
  • null이나 빈 컬렉션 처리 방식을 문서화하세요.
  • @param@throws를 사용하여 파라미터와 예외 상황을 명시하세요.

Copilot uses AI. Check for mistakes.

@Override
public void deleteByMemberIdAndDeviceId(Long memberId, String deviceId) {
jpaRepository.deleteByMemberIdAndDeviceId(memberId, deviceId);
Expand Down
Loading
Loading