From b16da1d9ea5a362918e6ea9da374760c124eabbc Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 4 Mar 2026 19:08:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=95=A0=EC=9D=B8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=8F=20SSE=20=EC=95=8C=EB=A6=BC=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE emitter 콜백에서 race condition 방지 (remove(userId) → remove(userId, emitter)) - discountRate 서버 검증 추가 (10/20/30/40 외 값 400 에러 반환) - markAllAsRead N+1 개선 (벌크 UPDATE 쿼리로 변경) - INVALID_DISCOUNT_RATE 에러 코드 추가 (ALERT-002) Co-Authored-By: Claude Sonnet 4.6 --- .../notification/repository/NotificationRepository.java | 9 +++++++++ .../domain/notification/service/NotificationService.java | 8 +++----- .../notification/service/NotificationSseService.java | 8 ++++---- .../domain/pricealert/service/PriceAlertService.java | 9 +++++++++ .../ongil/backend/global/common/exception/ErrorCode.java | 1 + 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/notification/repository/NotificationRepository.java b/src/main/java/com/ongil/backend/domain/notification/repository/NotificationRepository.java index 0e8ff55..ed20f50 100644 --- a/src/main/java/com/ongil/backend/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/ongil/backend/domain/notification/repository/NotificationRepository.java @@ -1,8 +1,12 @@ package com.ongil.backend.domain.notification.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ongil.backend.domain.notification.entity.Notification; @@ -13,4 +17,9 @@ public interface NotificationRepository extends JpaRepository notifications = notificationRepository - .findByUserIdAndIsReadFalseOrderByNotifiedAtDesc(userId); - - notifications.forEach(Notification::markAsRead); + notificationRepository.markAllAsRead(userId, LocalDateTime.now()); } } diff --git a/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java b/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java index 5cb4a94..fce7f65 100644 --- a/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java +++ b/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java @@ -31,19 +31,19 @@ public SseEmitter subscribe(Long userId) { SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); emitters.put(userId, emitter); - // 연결 종료 시 제거 + // 연결 종료 시 제거 (value 지정으로 새로 등록된 emitter를 잘못 제거하는 race condition 방지) emitter.onCompletion(() -> { - emitters.remove(userId); + emitters.remove(userId, emitter); log.info("SSE 연결 종료 - userId: {}", userId); }); emitter.onTimeout(() -> { - emitters.remove(userId); + emitters.remove(userId, emitter); log.info("SSE 타임아웃 - userId: {}", userId); }); emitter.onError(e -> { - emitters.remove(userId); + emitters.remove(userId, emitter); log.error("SSE 에러 - userId: {}", userId, e); }); diff --git a/src/main/java/com/ongil/backend/domain/pricealert/service/PriceAlertService.java b/src/main/java/com/ongil/backend/domain/pricealert/service/PriceAlertService.java index a24fc13..75d8ff8 100644 --- a/src/main/java/com/ongil/backend/domain/pricealert/service/PriceAlertService.java +++ b/src/main/java/com/ongil/backend/domain/pricealert/service/PriceAlertService.java @@ -1,5 +1,7 @@ package com.ongil.backend.domain.pricealert.service; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,7 @@ import com.ongil.backend.domain.user.repository.UserRepository; import com.ongil.backend.global.common.exception.EntityNotFoundException; import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.common.exception.ValidationException; import lombok.RequiredArgsConstructor; @@ -30,9 +33,15 @@ public class PriceAlertService { * 사용자가 상품 상세 화면에서 원하는 할인가를 선택하여 DB에 저장 * 실제 알림 발송은 PriceAlertScheduler가 주기적으로 가격을 확인하여 처리 */ + private static final List ALLOWED_DISCOUNT_RATES = List.of(10, 20, 30, 40); + @Transactional public PriceAlert createOrUpdatePriceAlert(Long userId, PriceAlertRequest request) { + if (!ALLOWED_DISCOUNT_RATES.contains(request.getDiscountRate())) { + throw new ValidationException(ErrorCode.INVALID_DISCOUNT_RATE); + } + User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java index a73010c..d1dc070 100644 --- a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java +++ b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java @@ -88,6 +88,7 @@ public enum ErrorCode { // PRICE_ALERT PRICE_ALERT_NOT_FOUND(HttpStatus.NOT_FOUND, "설정된 가격 알림이 없습니다.", "ALERT-001"), + INVALID_DISCOUNT_RATE(HttpStatus.BAD_REQUEST, "할인율은 10, 20, 30, 40 중 하나여야 합니다.", "ALERT-002"), // FILE / S3 FILE_IS_EMPTY(HttpStatus.BAD_REQUEST, "파일이 비어 있습니다.", "FILE-001"), From f21badc19b5ad592c1ff64c2029bffbdc0504ffd Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Wed, 4 Mar 2026 19:18:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20SSE=20IOException=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20race=20condition=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitters.remove(userId) → remove(userId, emitter)로 통일 subscribe()와 sendNotification()의 IOException catch 블록 누락 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../domain/notification/service/NotificationSseService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java b/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java index fce7f65..38f427a 100644 --- a/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java +++ b/src/main/java/com/ongil/backend/domain/notification/service/NotificationSseService.java @@ -53,7 +53,7 @@ public SseEmitter subscribe(Long userId) { .name("connect") .data("SSE 연결 성공")); } catch (IOException e) { - emitters.remove(userId); + emitters.remove(userId, emitter); log.error("SSE 초기 이벤트 전송 실패 - userId: {}", userId, e); } @@ -76,7 +76,7 @@ public void sendNotification(Long userId, NotificationResponse notification) { .data(notification)); log.info("SSE 알림 전송 성공 - userId: {}, notificationId: {}", userId, notification.getNotificationId()); } catch (IOException e) { - emitters.remove(userId); + emitters.remove(userId, emitter); log.error("SSE 알림 전송 실패 - userId: {}", userId, e); } }