-
실시간 할인 알림 구현기 — Spring SSE + Scheduler프로젝트, 트러블슈팅 2026. 3. 4. 19:05

시작하면서
온길 프로젝트에서 "원하는 가격이 되면 알림받기" 기능을 구현했다. 사용자가 관심 상품의 할인율을 설정해두면, 실제 가격이 목표가 이하로 떨어졌을 때 실시간으로 알림을 받는 기능이다.
구현하면서 가장 고민했던 부분은 두 가지였다. 하나는 주기적으로 가격을 체크하는 스케줄러, 다른 하나는 조건을 만족했을 때 실시간으로 클라이언트에 알림을 전달하는 방식이었다. 이 글은 그 과정을 정리한 것이다.
전체 흐름은 이렇다.
[사용자] 할인율 선택 및 알림 등록 ↓ [DB] PriceAlert 저장 ↓ [스케줄러] 30초마다 가격 체크 ↓ (목표가 이하 도달 시) [DB] Notification 저장 + [SSE] 실시간 푸시 ↓ [클라이언트] 알림 수신
도메인 설계
엔티티를 두 개로 분리했다.
- PriceAlert: 사용자의 알림 설정 (목표가, 활성 여부)
- Notification: 실제로 발송된 알림 이력 (메시지, 읽음 여부, 이동 URL)
하나로 합칠 수도 있었지만, "설정"과 "이력"은 역할이 다르다. PriceAlert는 스케줄러가 매 30초마다 참조하는 설정 데이터고, Notification은 발송 이후 사용자가 읽고 삭제하는 이력 데이터다. 분리하는 게 훨씬 자연스럽다.
PriceAlert 엔티티
@Entity @Table(name = "price_alerts") public class PriceAlert extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "price_alert_id") private Long id; @Column(name = "target_price", nullable = false) private Integer targetPrice; // 목표 가격 @Column(name = "is_active", nullable = false) private Boolean isActive = true; // 알림 활성화 여부 @Column(name = "is_notified", nullable = false) private Boolean isNotified = false; // 알림 발송 여부 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id", nullable = false) private Product product; public void markAsNotified() { this.isNotified = true; } public void deactivate() { this.isActive = false; } }두 개의 boolean 플래그로 상태를 관리한다.
isActive isNotified 의미
isActive isNotified true false 활성 상태, 아직 알림 미발송 → 스케줄러 처리 대상 true true 활성 상태, 알림 이미 발송 완료 false - 비활성 (재설정으로 대체된 건) Notification 엔티티
@Entity @Table(name = "notifications") public class Notification extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "notification_id") private Long id; @Column(nullable = false) private String message; // "오버핏 니트이(가) 47,500원으로 할인되었습니다!" @Column(nullable = false) private String targetUrl; // "/products/1" @Column(nullable = false) private Boolean isRead = false; // 읽음 여부 private LocalDateTime readAt; // 읽은 시각 @Column(nullable = false) private LocalDateTime notifiedAt; // 알림 발송 시각 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id") private Product product; public void markAsRead() { this.isRead = true; this.readAt = LocalDateTime.now(); } }
1단계: 알림 설정 API
사용자는 상품 상세 페이지에서 원하는 할인율(10%, 20%, 30%, 40%)을 선택한다.
public class PriceAlertRequest { @NotNull private Long productId; @NotNull @Min(10) @Max(40) private Integer discountRate; // 10, 20, 30, 40 중 선택 }@Transactional public PriceAlert createOrUpdatePriceAlert(Long userId, PriceAlertRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); Product product = productRepository.findById(request.getProductId()) .orElseThrow(() -> new EntityNotFoundException(ErrorCode.PRODUCT_NOT_FOUND)); // 기존 활성 건이 있으면 비활성화 (재설정) priceAlertRepository .findByUserIdAndProductIdAndIsActiveTrue(userId, request.getProductId()) .ifPresent(PriceAlert::deactivate); // 목표 가격 계산: 현재 판매가 기준으로 할인율 적용 int targetPrice = product.getEffectivePrice() * (100 - request.getDiscountRate()) / 100; PriceAlert priceAlert = PriceAlert.builder() .targetPrice(targetPrice) .isActive(true) .user(user) .product(product) .build(); return priceAlertRepository.save(priceAlert); }기존 알림을 UPDATE하지 않고, 비활성화 후 새로 만드는 방식을 택했다. 이력을 보존하면서도 "현재 활성 알림"을 isActive=true인 건으로 단순하게 조회할 수 있기 때문이다.
effectivePrice는 상품에 이미 할인가가 있으면 할인가를, 없으면 원가를 사용한다. 이미 할인 중인 상품은 할인된 가격 기준으로 추가 할인율을 적용해 목표가를 계산한다.
Spring Scheduler란?
Spring Scheduler는 특정 메서드를 일정 주기로 자동 실행해주는 기능이다. 별도의 스레드 관리 없이 @Scheduled 어노테이션 하나로 주기적 작업을 등록할 수 있다.
@Scheduled(fixedRate = 30000) // 30초마다 실행 (이전 실행 시작 기준) @Scheduled(fixedDelay = 30000) // 30초마다 실행 (이전 실행 종료 기준) @Scheduled(cron = "0 0 9 * * *") // 매일 오전 9시 실행 public void someTask() { ... }fixedRate는 이전 실행이 끝나지 않아도 정해진 시간이 되면 다음 실행을 시작한다. fixedDelay는 이전 실행이 완전히 끝난 뒤 지정한 시간만큼 기다렸다가 실행한다. 처리 시간이 길어질 수 있는 작업이라면 fixedDelay가 더 안전하다.
활성화는 메인 클래스에 @EnableScheduling만 붙이면 된다.
@SpringBootApplication @EnableScheduling public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
2단계: 스케줄러로 가격 체크
N+1 방지 — Fetch Join
스케줄러는 모든 활성 알림을 순회하기 때문에, user와 product를 Fetch Join으로 한 번에 가져와야 한다. Lazy Loading을 그대로 쓰면 alert 수만큼 추가 쿼리가 발생하는 N+1 문제가 생긴다.
// PriceAlertRepository @Query("SELECT pa FROM PriceAlert pa " + "JOIN FETCH pa.user " + "JOIN FETCH pa.product " + "WHERE pa.isActive = true AND pa.isNotified = false") List<PriceAlert> findActiveAlertsWithUserAndProduct();isActive=true AND isNotified=false 조건으로 처리 대상만 가져온다. 이미 알림을 보낸 건은 처음부터 조회에서 제외된다.
스케줄러 구현
@Slf4j @Component @RequiredArgsConstructor public class PriceAlertScheduler { private final PriceAlertRepository priceAlertRepository; private final NotificationRepository notificationRepository; private final NotificationSseService notificationSseService; @Scheduled(fixedRate = 30000) // 30초마다 실행 @Transactional public void checkPriceAlerts() { List<PriceAlert> alerts = priceAlertRepository.findActiveAlertsWithUserAndProduct(); for (PriceAlert alert : alerts) { Integer currentPrice = alert.getProduct().getEffectivePrice(); Integer targetPrice = alert.getTargetPrice(); if (currentPrice <= targetPrice) { // 1. 재발송 방지: isNotified = true alert.markAsNotified(); // 2. 알림 이력 DB 저장 Notification notification = Notification.builder() .message(alert.getProduct().getName() + "이(가) " + String.format("%,d", currentPrice) + "원으로 할인되었습니다!") .targetUrl("/products/" + alert.getProduct().getId()) .notifiedAt(LocalDateTime.now()) .user(alert.getUser()) .product(alert.getProduct()) .build(); Notification savedNotification = notificationRepository.save(notification); // 3. SSE로 실시간 전송 Long userId = alert.getUser().getId(); NotificationResponse response = NotificationConverter.toResponse(savedNotification); notificationSseService.sendNotification(userId, response); log.info("할인 알림 발송 - userId: {}, productId: {}, currentPrice: {}, targetPrice: {}", userId, alert.getProduct().getId(), currentPrice, targetPrice); } } } }처리 순서는 세 단계다.
- 재발송 방지: markAsNotified()로 isNotified = true 변경 → 다음 30초 스케줄 실행 시 이 건은 조회 대상에서 제외됨
- 이력 저장: Notification 엔티티를 DB에 저장 → 사용자가 나중에 알림 목록에서 확인 가능
- 실시간 전송: SSE 연결이 열려 있으면 즉시 클라이언트로 push
@Scheduled(fixedRate = 30000)은 이전 실행 시작 시점으로부터 30초 후에 다시 실행된다. 처리에 30초가 넘게 걸리면 다음 실행이 겹칠 수 있으므로, 처리량이 많아지면 이전 실행 종료 시점 기준인 fixedDelay로 바꾸는 것도 방법이다.
SSE란?
SSE(Server-Sent Events)는 서버가 클라이언트에게 단방향으로 데이터를 실시간 전송하는 HTTP 기반 기술이다. 클라이언트가 서버에 한 번 연결을 열어두면, 서버가 원할 때 데이터를 push할 수 있다.
클라이언트 ──── GET /subscribe ────▶ 서버 ◀───── event: data ─────── ◀───── event: data ─────── ◀───── event: data ─────── (연결 유지 중...)일반 HTTP 요청은 요청 → 응답이 끝나면 연결이 닫힌다. SSE는 응답을 끊지 않고 유지하면서 서버가 계속 데이터를 보낼 수 있다.
WebSocket과 비교하면 SSE는 서버 → 클라이언트 단방향만 지원하지만, 일반 HTTP 위에서 동작해서 별도 프로토콜 업그레이드 없이 쓸 수 있고 구현도 훨씬 간단하다. 알림처럼 서버가 일방적으로 데이터를 보내는 경우에 적합하다.
Spring에서는 SseEmitter로 구현한다.
// 구독: SseEmitter를 반환하면 연결 유지 @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe() { SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30분 타임아웃 // emitter를 어딘가에 저장해두고 나중에 데이터 전송 return emitter; } // 전송: 저장해둔 emitter로 데이터 push emitter.send(SseEmitter.event() .name("price-alert") .data(notificationResponse));
3단계: SSE로 실시간 알림 전송
SSE 서비스 구현
@Slf4j @Service public class NotificationSseService { // userId → SseEmitter 매핑 (Thread-safe) private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>(); private static final Long SSE_TIMEOUT = 30 * 60 * 1000L; // 30분 public SseEmitter subscribe(Long userId) { // 기존 연결이 있으면 정리 후 새로 생성 (재구독 처리) SseEmitter existingEmitter = emitters.remove(userId); if (existingEmitter != null) { existingEmitter.complete(); } SseEmitter emitter = new SseEmitter(SSE_TIMEOUT); emitters.put(userId, emitter); // 연결 종료/타임아웃/에러 시 Map에서 자동 제거 // remove(userId, emitter): key + value 둘 다 일치할 때만 삭제 (race condition 방지) emitter.onCompletion(() -> emitters.remove(userId, emitter)); emitter.onTimeout(() -> emitters.remove(userId, emitter)); emitter.onError(e -> emitters.remove(userId, emitter)); // 연결 직후 더미 이벤트 전송 (503 방지) try { emitter.send(SseEmitter.event() .name("connect") .data("SSE 연결 성공")); } catch (IOException e) { emitters.remove(userId); } return emitter; } public void sendNotification(Long userId, NotificationResponse notification) { SseEmitter emitter = emitters.get(userId); if (emitter == null) { // 앱 화면 밖에 있거나 SSE 연결이 없는 경우 → DB에는 저장됨 return; } try { emitter.send(SseEmitter.event() .name("price-alert") .data(notification)); } catch (IOException e) { emitters.remove(userId); } } }구현하면서 신경 쓴 부분이 세 가지 있다.
ConcurrentHashMap 사용: 스케줄러 스레드와 HTTP 요청 스레드가 동시에 emitters에 접근한다. 일반 HashMap을 쓰면 멀티스레드 환경에서 데이터가 꼬일 수 있으니 Thread-safe한 ConcurrentHashMap을 사용했다.
onCompletion 콜백 race condition 방지: 처음엔 콜백에 emitters.remove(userId)를 썼는데 문제가 있었다. complete()를 호출하면 onCompletion 콜백이 즉시 실행되지 않고 비동기로 예약된다. 그 사이에 새 emitter가 Map에 등록될 수 있고, 뒤늦게 실행된 콜백이 새로 등록한 emitter까지 지워버리는 상황이 생긴다.
remove(userId, emitter)를 쓰면 key와 value가 둘 다 일치할 때만 삭제한다. 콜백이 뒤늦게 실행되더라도 Map에 이미 새 emitter가 들어있으면 삭제하지 않는다.
연결 직후 더미 이벤트 전송: SSE 구독 직후 아무 이벤트도 보내지 않으면 일부 브라우저/프록시에서 연결을 끊거나 503 에러가 발생할 수 있다. "connect" 이벤트를 즉시 전송해 연결이 살아있음을 확인한다.
SSE 없을 때도 DB 저장은 유지: 클라이언트가 SSE를 구독 중이지 않더라도 Notification은 DB에 저장된다. 사용자가 나중에 앱을 열면 GET /api/notifications로 놓친 알림을 확인할 수 있다.
구독 API
@GetMapping(value = "/api/notifications/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public ResponseEntity<SseEmitter> subscribe(@AuthenticationPrincipal Long userId) { SseEmitter emitter = notificationSseService.subscribe(userId); return ResponseEntity.ok(emitter); }MediaType.TEXT_EVENT_STREAM_VALUE("text/event-stream")가 SSE의 Content-Type이다. 클라이언트는 이 엔드포인트에 HTTP GET 요청을 보내고 연결을 유지한다.
4단계: 알림 목록 조회 및 읽음 처리
@Service @RequiredArgsConstructor public class NotificationService { private final NotificationRepository notificationRepository; // 읽지 않은 알림 목록 조회 (최신순) @Transactional(readOnly = true) public List<NotificationResponse> getUnreadNotifications(Long userId) { return notificationRepository .findByUserIdAndIsReadFalseOrderByNotifiedAtDesc(userId) .stream() .map(NotificationConverter::toResponse) .toList(); } // 읽지 않은 알림 개수 (뱃지용) @Transactional(readOnly = true) public long getUnreadCount(Long userId) { return notificationRepository.countByUserIdAndIsReadFalse(userId); } // 개별 알림 읽음 처리 @Transactional public void markAsRead(Long userId, Long notificationId) { Notification notification = notificationRepository.findById(notificationId) .orElseThrow(() -> new EntityNotFoundException(ErrorCode.NOTIFICATION_NOT_FOUND)); // 본인 알림인지 검증 if (!notification.getUser().getId().equals(userId)) { throw new EntityNotFoundException(ErrorCode.NOTIFICATION_NOT_FOUND); } notification.markAsRead(); } // 전체 읽음 처리 @Transactional public void markAllAsRead(Long userId) { notificationRepository .findByUserIdAndIsReadFalseOrderByNotifiedAtDesc(userId) .forEach(Notification::markAsRead); } }markAsRead에서 존재하지 않는 알림과 권한 없는 알림 모두 NOTIFICATION_NOT_FOUND를 던진다. 다른 사람의 알림 ID가 존재하는지 여부를 응답 차이로 알 수 없도록 하기 위해서다.
전체 API 목록은 아래와 같다.
Method Path 설명
POST /api/price-alerts 할인 알림 설정/재설정 GET /api/price-alerts/{productId} 현재 활성 알림 조회 GET /api/notifications/subscribe SSE 구독 (롱커넥션) GET /api/notifications 읽지 않은 알림 목록 GET /api/notifications/count 미읽음 알림 개수 (뱃지) PATCH /api/notifications/{id}/read 개별 알림 읽음 처리 PATCH /api/notifications/read-all 전체 알림 읽음 처리
알려진 한계점
멀티 인스턴스 환경
ConcurrentHashMap<Long, SseEmitter>은 JVM 메모리 안에 저장된다. 서버가 여러 대로 스케일 아웃되면 문제가 생긴다.
사용자 A → 서버 1에 SSE 구독 스케줄러 → 서버 2에서 실행 서버 2에는 A의 emitter가 없음 → SSE 전송 실패Redis Pub/Sub을 도입하면 해결할 수 있다. 모든 서버 인스턴스가 Redis 채널을 구독하고, 알림이 발생하면 Redis로 브로드캐스트한다. 각 서버는 자신의 emitter Map에 해당 userId가 있을 때만 전송하면 된다.
스케줄러 중복 실행
서버가 여러 대면 모든 인스턴스에서 스케줄러가 동시에 실행된다. isNotified = true 업데이트에 낙관적 락을 적용하거나, ShedLock 같은 분산 락 라이브러리를 사용하면 중복 발송을 방지할 수 있다.
스케줄러 트랜잭션 범위
현재 checkPriceAlerts() 전체가 하나의 @Transactional이다. 알림 A 처리 중 예외가 발생하면 알림 B, C의 처리까지 모두 롤백된다. 각 alert를 별도 트랜잭션으로 처리하면 더 안전하다.
마치며
- 사용자는 할인율(10~40%)을 선택해 목표가를 설정
- PriceAlertScheduler가 30초마다 목표가 도달 여부를 체크
- 조건 충족 시 Notification DB 저장 + SSE 실시간 push
- SSE 연결이 없을 때도 DB 이력은 유지 → 나중에 목록 조회 가능
- isNotified 플래그로 중복 발송 방지
단순해 보이지만 N+1 문제, Thread-safe 연결 관리, 재구독 처리, SSE 더미 이벤트 등 신경 써야 할 부분이 꽤 있었다. 스케일 아웃 환경에서의 한계는 Redis Pub/Sub과 분산 락으로 해결할 수 있지만, 현재 단일 서버 환경에서는 이 정도 구현으로도 충분하다.
'프로젝트, 트러블슈팅' 카테고리의 다른 글
Spring Boot + WebSocket으로 실시간 조회 인원 구현하기 (Part 1 - 개념 편) (0) 2026.03.05 Spring Boot + OpenAI API로 의류 소재 설명 자동 생성하기 (0) 2026.03.05 도움돼요 토글, 비관적 락 대신 DB 원자적 UPDATE를 선택한 이유 (0) 2026.03.04 Redis 캐시에 빈 배열이 영구 저장되는 버그 트러블슈팅 (0) 2026.03.04 복합 유니크 제약조건으로 중복 데이터 막기 (0) 2026.03.01