Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions app/config/src/main/java/kr/spot/config/AsyncConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,15 @@ public Executor imageUploadExecutor() {
executor.initialize();
return executor;
}

@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("notification-");
executor.initialize();
return executor;
}
}
8 changes: 8 additions & 0 deletions common/api/src/main/java/kr/spot/code/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ public enum ErrorStatus implements BaseErrorCode {
_REVIEW_ACCESS_DENIED(403, "REVIEW403", "해당 스터디에 속하는 회고가 아닙니다."),
_ALREADY_REACTED(400, "REVIEW4000", "이미 반응을 누른 회고입니다."),
_REACTION_NOT_FOUND(404, "REVIEW4040", "반응을 찾을 수 없습니다."),

// 알림 관련
_NOTIFICATION_NOT_FOUND(404, "NOTIFICATION404", "알림을 찾을 수 없습니다."),
_INVALID_NOTIFICATION_TYPE(400, "NOTIFICATION4000", "유효하지 않은 알림 유형입니다."),
_NOTIFICATION_PAYLOAD_MISSING_MEMBER_ID(400, "NOTIFICATION4001", "알림 페이로드에 memberId가 필요합니다."),
_NOTIFICATION_PAYLOAD_MISSING_STUDY_ID(400, "NOTIFICATION4002", "알림 페이로드에 studyId가 필요합니다."),
_NOTIFICATION_TEMPLATE_NOT_FOUND(500, "NOTIFICATION5000", "알림 템플릿을 찾을 수 없습니다."),
_PUSH_NOTIFICATION_FAILED(500, "NOTIFICATION5001", "푸시 알림 발송에 실패했습니다."),
;

private final int httpStatus;
Expand Down
6 changes: 6 additions & 0 deletions modules/notification-api/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
plugins {
id "java"
}

dependencies {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package kr.spot.event;

import java.time.LocalDateTime;
import java.util.Map;

/**
* 알림 요청 이벤트.
* 각 도메인 서비스에서 이 이벤트를 발행하면, Notification 도메인에서 처리합니다.
*
* @param type 알림 유형 (문자열로 전달, Notification 도메인에서 NotificationType으로 변환)
* @param payload 알림 생성에 필요한 데이터 (템플릿 렌더링 및 수신자 결정에 사용)
* @param scheduledAt 발송 예정 시각 (null이면 즉시 발송)
* @param referenceId 연관 엔티티 ID (딥링크용)
* @param imageUrl 알림 이미지 URL (optional)
*/
public record NotificationRequestedEvent(
String type,
Map<String, Object> payload,
LocalDateTime scheduledAt,
Long referenceId,
String imageUrl
) {

/**
* 즉시 발송 알림 생성
*/
public static NotificationRequestedEvent immediate(
String type,
Map<String, Object> payload,
Long referenceId,
String imageUrl
) {
return new NotificationRequestedEvent(type, payload, LocalDateTime.now(), referenceId, imageUrl);
}

/**
* 즉시 발송 알림 생성 (이미지 없음)
*/
public static NotificationRequestedEvent immediate(
String type,
Map<String, Object> payload,
Long referenceId
) {
return immediate(type, payload, referenceId, null);
}

/**
* 예약 발송 알림 생성
*/
public static NotificationRequestedEvent scheduled(
String type,
LocalDateTime scheduledAt,
Map<String, Object> payload,
Long referenceId,
String imageUrl
) {
return new NotificationRequestedEvent(type, payload, scheduledAt, referenceId, imageUrl);
}

/**
* 예약 발송 알림 생성 (이미지 없음)
*/
public static NotificationRequestedEvent scheduled(
String type,
LocalDateTime scheduledAt,
Map<String, Object> payload,
Long referenceId
) {
return scheduled(type, scheduledAt, payload, referenceId, null);
}
}
1 change: 1 addition & 0 deletions modules/notification/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies {

implementation project(":modules:shared")
implementation project(":modules:study-api")
implementation project(":modules:notification-api")

implementation "org.springframework.boot:spring-boot-starter-data-jpa"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.spot.application.dispatch;

import kr.spot.domain.Notification;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* 푸시 알림을 실제로 발송하지 않는 더미 구현체. 개발/테스트 환경 또는 FCM 연동 전에 사용합니다.
*/
@Slf4j
@Component
public class NoOpPushNotificationClient implements PushNotificationClient {

@Override
public void send(Notification notification) {
log.info("[NoOp] Push notification would be sent: memberId={}, type={}, title={}",
notification.getMemberId(),
notification.getType(),
notification.getTitle()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package kr.spot.application.dispatch;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.time.LocalDateTime;
import java.util.List;
import kr.spot.domain.Notification;
import kr.spot.domain.enums.NotificationStatus;
import kr.spot.infrastructure.jpa.NotificationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationDispatchScheduler {

private static final int BATCH_SIZE = 100;

private final NotificationRepository notificationRepository;
private final NotificationSender notificationSender;
private final String serverId = resolveServerId();

private static String resolveServerId() {
try {
String hostname = InetAddress.getLocalHost().getHostName();
return hostname.length() > 50 ? hostname.substring(0, 50) : hostname;
} catch (UnknownHostException e) {
return "server-" + System.currentTimeMillis();
}
}

/**
* 발송 대상 알림을 선점하고 비동기로 발송합니다. 10초마다 실행됩니다.
*/
@Scheduled(fixedDelay = 10_000)
@Transactional
public void dispatch() {
LocalDateTime now = LocalDateTime.now();

// 1. 발송 대상 선점 (FOR UPDATE SKIP LOCKED)
int pickedCount = notificationRepository.pickPendingNotifications(
serverId, now, BATCH_SIZE
);

if (pickedCount == 0) {
return;
}

log.info("Picked {} notifications for dispatch", pickedCount);

// 2. 선점한 알림 조회
List<Notification> notifications = notificationRepository
.findByPickedByAndDispatchStatus(serverId, NotificationStatus.PROCESSING);

// 3. 비동기로 발송 처리
for (Notification notification : notifications) {
notificationSender.sendAsync(notification.getId());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package kr.spot.application.dispatch;

import java.time.Duration;
import java.time.LocalDateTime;
import kr.spot.domain.Notification;
import kr.spot.domain.enums.NotificationStatus;
import kr.spot.infrastructure.jpa.NotificationRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationSender {

private final PushNotificationClient pushClient;
private final NotificationRepository notificationRepository;

@Async("notificationExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendAsync(long notificationId) {
Notification notification = notificationRepository.findById(notificationId)
.orElse(null);

if (notification == null) {
log.warn("Notification not found: id={}", notificationId);
return;
}

// 발송 전 상태 재확인 (다른 서버가 이미 처리했을 수 있음)
if (notification.getDispatchStatus() != NotificationStatus.PROCESSING) {
log.info("Notification {} already processed, status={}",
notificationId, notification.getDispatchStatus());
return;
}

try {
pushClient.send(notification);

// 성공
notification.markAsSent(LocalDateTime.now());
notificationRepository.save(notification);

log.debug("Notification sent successfully: id={}, memberId={}",
notificationId, notification.getMemberId());

} catch (Exception e) {
handleFailure(notification, e);
}
}

private void handleFailure(Notification notification, Exception e) {
log.warn("Failed to send notification: id={}, error={}",
notification.getId(), e.getMessage());

if (notification.canRetry()) {
// 재시도 예약 (Exponential Backoff)
Duration backoff = calculateBackoff(notification.getRetryCount() + 1);
notification.scheduleRetry(LocalDateTime.now().plus(backoff));

log.info("Notification {} scheduled for retry #{} at {}",
notification.getId(),
notification.getRetryCount(),
notification.getNextRetryAt());
} else {
// 최종 실패
notification.markAsFailed(truncateError(e.getMessage()));

log.error("Notification {} permanently failed after {} retries",
notification.getId(),
notification.getRetryCount());
}

notificationRepository.save(notification);
}

private Duration calculateBackoff(int retryCount) {
// 1분, 5분, 30분
return switch (retryCount) {
case 1 -> Duration.ofMinutes(1);
case 2 -> Duration.ofMinutes(5);
default -> Duration.ofMinutes(30);
};
}

private String truncateError(String message) {
if (message == null) {
return "Unknown error";
}
return message.length() > 500 ? message.substring(0, 500) : message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package kr.spot.application.dispatch;

import kr.spot.domain.Notification;

/**
* 푸시 알림 발송 클라이언트 인터페이스.
* FCM, APNs 등의 구현체가 이 인터페이스를 구현합니다.
*/
public interface PushNotificationClient {

/**
* 푸시 알림을 발송합니다.
*
* @param notification 발송할 알림
* @throws Exception 발송 실패 시
*/
void send(Notification notification) throws Exception;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ public class CleanupNotificationOnMemberWithdrawn {

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handle(MemberWithdrawnEvent event) {
notificationRepository.deleteByTargetTargetMemberId(event.memberId());
notificationRepository.deleteByMemberId(event.memberId());
}
}
Loading