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
3 changes: 2 additions & 1 deletion src/main/java/com/wellmeet/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public enum ErrorCode {
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."),
CORS_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, "CORS Origin 은 적어도 한 개 있어야 합니다"),
WEB_PUSH_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "웹 푸시 전송에 실패했습니다."),
SENDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "알림을 발송할 수 없습니다.");
SENDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "알림을 발송할 수 없습니다."),
TEMPLATE_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "알림 템플릿을 찾을 수 없습니다.");

private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.wellmeet.notification.template;

import com.wellmeet.notification.consumer.dto.NotificationType;
import java.util.Map;
import java.util.Objects;

public interface NotificationTemplate {

boolean supports(NotificationType type);

NotificationTemplateData create(Map<String, Object> payload);

default String getStringOrDefault(Map<String, Object> payload, String key, String defaultValue) {
Object value = payload.get(key);
return Objects.toString(value, defaultValue);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.wellmeet.notification.template;

public record NotificationTemplateData(
String title,
String body,
String url,
boolean requireInteraction
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.wellmeet.notification.template;

import com.wellmeet.exception.ErrorCode;
import com.wellmeet.exception.WellMeetNotificationException;
import com.wellmeet.notification.consumer.dto.NotificationType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class NotificationTemplateFactory {

private final List<NotificationTemplate> templates;

public NotificationTemplateData createTemplateData(NotificationType type, Map<String, Object> payload) {
NotificationTemplate template = templates.stream()
.filter(low -> low.supports(type))

Choose a reason for hiding this comment

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

medium

람다 표현식의 파라미터 이름 low는 의미가 명확하지 않습니다. 가독성을 높이기 위해 template과 같이 더 설명적인 이름으로 변경하는 것이 좋습니다.

Suggested change
.filter(low -> low.supports(type))
.filter(template -> template.supports(type))

.findFirst()
.orElseThrow(() -> new WellMeetNotificationException(ErrorCode.TEMPLATE_NOT_FOUND));
Comment on lines +18 to +21

Choose a reason for hiding this comment

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

medium

현재 구현은 createTemplateData가 호출될 때마다 템플릿 리스트를 순회하여 적절한 템플릿을 찾습니다. 템플릿 수가 많아지면 성능 저하의 원인이 될 수 있습니다. Map<NotificationType, NotificationTemplate>을 사용하여 템플릿을 캐싱하면 O(1) 시간 복잡도로 조회가 가능합니다. 빈이 초기화될 때 @PostConstruct 어노테이션을 사용한 메서드나 생성자에서 맵을 미리 구성해두는 방식을 고려해 보세요. 이렇게 하면 성능과 확장성을 개선할 수 있습니다.


return template.create(payload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wellmeet.notification.template.impl;

import com.wellmeet.notification.consumer.dto.NotificationType;
import com.wellmeet.notification.template.NotificationTemplate;
import com.wellmeet.notification.template.NotificationTemplateData;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class ReservationCanceledTemplate implements NotificationTemplate {

@Override
public boolean supports(NotificationType type) {
return NotificationType.RESERVATION_CANCELED == type;
}

@Override
public NotificationTemplateData create(Map<String, Object> payload) {
String restaurantName = getStringOrDefault(payload, "restaurantName", "식당");
String reservationTime = getStringOrDefault(payload, "reservationTime", "");
String reservationId = getStringOrDefault(payload, "reservationId", "");
Comment on lines +19 to +21

Choose a reason for hiding this comment

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

medium

페이로드에서 값을 가져오기 위해 사용되는 키 문자열들("restaurantName", "reservationTime", "reservationId")이 하드코딩되어 있습니다. 이러한 '매직 스트링'은 오타에 취약하고 유지보수를 어렵게 만듭니다. 이 키들을 별도의 상수 클래스(e.g., NotificationPayloadKeys)에 public static final String 상수로 정의하여 사용하는 것을 권장합니다. 이렇게 하면 타입 안정성을 높이고 코드의 일관성을 유지할 수 있습니다. 이 제안은 다른 템플릿 구현체에도 동일하게 적용됩니다.


String title = "예약이 취소되었습니다";
String body = String.format("%s 예약이 취소되었습니다. 예약 시간: %s",
restaurantName, reservationTime);
String url = "/reservations/" + reservationId;

return new NotificationTemplateData(title, body, url, false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wellmeet.notification.template.impl;

import com.wellmeet.notification.consumer.dto.NotificationType;
import com.wellmeet.notification.template.NotificationTemplate;
import com.wellmeet.notification.template.NotificationTemplateData;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class ReservationConfirmedTemplate implements NotificationTemplate {

@Override
public boolean supports(NotificationType type) {
return NotificationType.RESERVATION_CONFIRMED == type;
}

@Override
public NotificationTemplateData create(Map<String, Object> payload) {
String restaurantName = getStringOrDefault(payload, "restaurantName", "식당");
String reservationTime = getStringOrDefault(payload, "reservationTime", "");
String reservationId = getStringOrDefault(payload, "reservationId", "");

String title = "예약이 확정되었습니다";
String body = String.format("%s 예약이 확정되었습니다. 예약 시간: %s",
restaurantName, reservationTime);
String url = "/reservations/" + reservationId;

return new NotificationTemplateData(title, body, url, false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wellmeet.notification.template.impl;

import com.wellmeet.notification.consumer.dto.NotificationType;
import com.wellmeet.notification.template.NotificationTemplate;
import com.wellmeet.notification.template.NotificationTemplateData;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class ReservationCreatedTemplate implements NotificationTemplate {

@Override
public boolean supports(NotificationType type) {
return NotificationType.RESERVATION_CREATED == type;
}

@Override
public NotificationTemplateData create(Map<String, Object> payload) {
String restaurantName = getStringOrDefault(payload, "restaurantName", "식당");
String reservationTime = getStringOrDefault(payload, "reservationTime", "");
String reservationId = getStringOrDefault(payload, "reservationId", "");

String title = "새로운 예약이 접수되었습니다";
String body = String.format("%s에 새로운 예약이 접수되었습니다. 예약 시간: %s",
restaurantName, reservationTime);
String url = "/reservations/" + reservationId;

return new NotificationTemplateData(title, body, url, true);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.wellmeet.notification.template.impl;

import com.wellmeet.notification.consumer.dto.NotificationType;
import com.wellmeet.notification.template.NotificationTemplate;
import com.wellmeet.notification.template.NotificationTemplateData;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class ReservationReminderTemplate implements NotificationTemplate {

@Override
public boolean supports(NotificationType type) {
return NotificationType.RESERVATION_REMINDER == type;
}

@Override
public NotificationTemplateData create(Map<String, Object> payload) {
String restaurantName = getStringOrDefault(payload, "restaurantName", "식당");
String reservationTime = getStringOrDefault(payload, "reservationTime", "");
String reservationId = getStringOrDefault(payload, "reservationId", "");
String minutesBefore = getStringOrDefault(payload, "minutesBefore", "30");

String title = "곧 예약 시간입니다";
String body = String.format("%s 예약 시간이 %s분 남았습니다. 예약 시간: %s",
restaurantName, minutesBefore, reservationTime);
String url = "/reservations/" + reservationId;

return new NotificationTemplateData(title, body, url, true);
}
Comment on lines +17 to +30
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

알림 데이터 구성이 적절합니다.

예약 리마인더 알림의 제목, 본문, URL이 올바르게 구성되었고, requireInteraction=true 설정도 사용자가 반드시 확인해야 하는 리마인더 특성에 적합합니다.

단, 빈 문자열 기본값 처리를 확인해주세요. reservationTimereservationId가 비어있을 경우 사용자 경험이 저하될 수 있습니다.

다음 스크립트로 페이로드 검증 로직이 upstream에 존재하는지 확인할 수 있습니다:


🏁 Script executed:

#!/bin/bash
# 알림 전송 시 페이로드 필수 필드 검증이 있는지 확인

# NotificationTemplateFactory를 사용하는 코드 검색
rg -n -C5 "createTemplateData|NotificationTemplateFactory" --type=java

# 페이로드 생성 및 검증 로직 검색
rg -n -C5 "restaurantName|reservationTime|reservationId" --type=java -g "!**/template/**"

Length of output: 6016


페이로드 필수 필드 검증 필요
현재 payload에 reservationTime과 reservationId에 대한 검증 없이 빈 문자열 기본값이 적용되어, 잘못된 알림이 전송될 우려가 있습니다. createTemplateData 호출 전 null/empty 검사 및 누락 시 예외 처리나 명확한 기본값 로직을 추가하세요.

🤖 Prompt for AI Agents
In
src/main/java/com/wellmeet/notification/template/impl/ReservationReminderTemplate.java
around lines 17 to 30, validate that payload contains non-null, non-empty
reservationTime and reservationId before building NotificationTemplateData; if
either is missing or empty, throw an IllegalArgumentException (or a
domain-specific exception) with a clear message indicating the missing field(s)
so the caller can handle the error, or alternatively return a failed
Result/Optional per project conventions — do the check immediately after
extracting values (or validate payload first), and do not rely on empty-string
defaults for these required fields.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wellmeet.notification.template.impl;

import com.wellmeet.notification.consumer.dto.NotificationType;
import com.wellmeet.notification.template.NotificationTemplate;
import com.wellmeet.notification.template.NotificationTemplateData;
import java.util.Map;
import org.springframework.stereotype.Component;

@Component
public class ReservationUpdatedTemplate implements NotificationTemplate {

@Override
public boolean supports(NotificationType type) {
return NotificationType.RESERVATION_UPDATED == type;
}

@Override
public NotificationTemplateData create(Map<String, Object> payload) {
String restaurantName = getStringOrDefault(payload, "restaurantName", "식당");
String reservationTime = getStringOrDefault(payload, "reservationTime", "");
String reservationId = getStringOrDefault(payload, "reservationId", "");

String title = "예약 정보가 변경되었습니다";
String body = String.format("%s 예약 정보가 변경되었습니다. 변경된 예약 시간: %s",
restaurantName, reservationTime);
String url = "/reservations/" + reservationId;

return new NotificationTemplateData(title, body, url, false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.wellmeet.notification.webpush.dto.SubscribeRequest;
import com.wellmeet.notification.webpush.dto.SubscribeResponse;
import com.wellmeet.notification.webpush.dto.TestPushRequest;
import com.wellmeet.notification.webpush.dto.UnsubscribeRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -31,14 +30,6 @@ public SubscribeResponse subscribe(
return webPushService.subscribe(userId, subscribeRequest);
}

@PostMapping("/test-push")
public void testPush(
@Valid @RequestBody TestPushRequest testPushRequest,
@RequestParam String userId
) {
webPushService.sendTestPush(userId, testPushRequest);
}

@DeleteMapping("/unsubscribe")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void unsubscribe(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@
import com.wellmeet.notification.webpush.domain.PushSubscription;
import com.wellmeet.notification.webpush.dto.SubscribeRequest;
import com.wellmeet.notification.webpush.dto.SubscribeResponse;
import com.wellmeet.notification.webpush.dto.TestPushRequest;
import com.wellmeet.notification.webpush.dto.UnsubscribeRequest;
import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository;
import com.wellmeet.notification.webpush.sender.WebPushSender;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,7 +18,6 @@
public class WebPushService {

private final PushSubscriptionRepository pushSubscriptionRepository;
private final WebPushSender webPushSender;

@Transactional
public SubscribeResponse subscribe(String userId, SubscribeRequest request) {
Expand All @@ -40,15 +37,6 @@ public SubscribeResponse subscribe(String userId, SubscribeRequest request) {
return new SubscribeResponse(savedSubscription);
}

public void sendTestPush(String userId, TestPushRequest request) {
List<PushSubscription> subscriptions = pushSubscriptionRepository.findByUserId(userId);
if (subscriptions.isEmpty()) {
throw new WellMeetNotificationException(ErrorCode.SUBSCRIPTION_NOT_FOUND);
}

subscriptions.forEach(subscription -> webPushSender.send(subscription, request));
}

@Transactional
public void unsubscribe(String userId, UnsubscribeRequest request) {
if (!pushSubscriptionRepository.existsByUserIdAndEndpoint(userId, request.endpoint())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import com.wellmeet.notification.Sender;
import com.wellmeet.notification.consumer.dto.NotificationMessage;
import com.wellmeet.notification.domain.NotificationChannel;
import com.wellmeet.notification.template.NotificationTemplateData;
import com.wellmeet.notification.template.NotificationTemplateFactory;
import com.wellmeet.notification.webpush.domain.PushSubscription;
import com.wellmeet.notification.webpush.dto.TestPushRequest;
import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository;
import java.io.IOException;
import java.security.GeneralSecurityException;
Expand All @@ -29,6 +30,7 @@ public class WebPushSender implements Sender {

private final PushSubscriptionRepository pushSubscriptionRepository;
private final PushService pushService;
private final NotificationTemplateFactory templateFactory;
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
Expand All @@ -53,39 +55,21 @@ public void send(NotificationMessage message) {
}

private Map<String, Object> getNotificationPayload(NotificationMessage message) {
Map<String, Object> notificationPayload = new HashMap<>();
notificationPayload.put("title", "WellMeet 알림");
notificationPayload.put("body", message.getPayload());
notificationPayload.put("icon", "/icon-192x192.png");
notificationPayload.put("badge", "/badge-72x72.png");
notificationPayload.put("vibrate", new int[]{100, 50, 100});
notificationPayload.put("requireInteraction", false);

Map<String, Object> defaultData = new HashMap<>();
defaultData.put("url", "/notifications");
defaultData.put("timestamp", System.currentTimeMillis());
notificationPayload.put("data", defaultData);
return notificationPayload;
}

public void send(PushSubscription subscription, TestPushRequest request) {
Keys keys = new Keys(subscription.getP256dh(), subscription.getAuth());
Subscription sub = new Subscription(subscription.getEndpoint(), keys);
Map<String, Object> notificationPayload = getNotificationPayload(request);
webPushSend(notificationPayload, sub);
}
NotificationTemplateData templateData = templateFactory.createTemplateData(
message.getNotification().getType(),
message.getPayload()
);

private Map<String, Object> getNotificationPayload(TestPushRequest request) {
Map<String, Object> notificationPayload = new HashMap<>();
notificationPayload.put("title", request.title());
notificationPayload.put("body", request.body());
notificationPayload.put("title", templateData.title());
notificationPayload.put("body", templateData.body());
notificationPayload.put("icon", "/icon-192x192.png");
notificationPayload.put("badge", "/badge-72x72.png");
notificationPayload.put("vibrate", new int[]{100, 50, 100});
notificationPayload.put("requireInteraction", false);
notificationPayload.put("requireInteraction", templateData.requireInteraction());

Map<String, Object> defaultData = new HashMap<>();
defaultData.put("url", "/notifications");
defaultData.put("url", templateData.url());
defaultData.put("timestamp", System.currentTimeMillis());
notificationPayload.put("data", defaultData);
return notificationPayload;
Comment on lines 63 to 75

Choose a reason for hiding this comment

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

medium

알림 페이로드를 구성하는 데 사용되는 여러 문자열 키("title", "body", "icon" 등)와 값("/icon-192x192.png" 등)이 하드코딩되어 있습니다. 이러한 값들을 상수로 정의하면 코드의 가독성과 유지보수성을 향상시킬 수 있습니다. 예를 들어, WebPushPayloadConstants와 같은 별도의 클래스나 인터페이스에 상수로 관리하는 것을 고려해 보세요.

Expand Down
Loading