Skip to content

Conversation

@hyeonda02
Copy link
Member

@hyeonda02 hyeonda02 commented Jan 3, 2026

👀 관련 이슈

기존 @scheduled 알림 스케줄러에서 DB 상태 변경과 외부 알림 발송 로직이 하나의 트랜잭션 안에서 실행되고 있었습니다.

이 구조에서 알림 발송 중 예외가 발생할 경우, Hibernate Session이 오염된 상태로 추가적인 DB flush가 시도되어 아래와 같은 런타임 오류가 발생하는 것을 확인했습니다.

[   scheduling-1] org.hibernate.AssertionFailure           : HHH000099: an assertion failure occurred (this may indicate a bug in Hibernate, but is more likely due to unsafe use of the session): org.hibernate.AssertionFailure: null id in umc.teumteum.server.domain.notification.entity.Notification entry (don't flush the Session after an exception occurs)
2026-01-04T04:17:28.861+09:00 ERROR 1 --- [   scheduling-1] o.s.s.s.TaskUtils$LoggingErrorHandler    : Unexpected error occurred in scheduled task

org.hibernate.AssertionFailure: null id in umc.teumteum.server.domain.notification.entity.Notification entry (don't flush the Session after an exception occurs)
        at org.hibernate.event.internal.DefaultFlushEntityEventListener.checkId(DefaultFlushEntityEventListener.java:86) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.internal.DefaultFlushEntityEventListener.getValues(DefaultFlushEntityEventListener.java:182) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.internal.DefaultFlushEntityEventListener.onFlushEntity(DefaultFlushEntityEventListener.java:141) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.internal.AbstractFlushingEventListener.flushEntities(AbstractFlushingEventListener.java:269) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.internal.AbstractFlushingEventListener.flushEverythingToExecutions(AbstractFlushingEventListener.java:90) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.internal.AbstractFlushingEventListener.flushEverythingToExecutions(AbstractFlushingEventListener.java:84) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1429) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at org.hibernate.internal.SessionImpl.flush(SessionImpl.java:1415) ~[hibernate-core-6.6.18.Final.jar!/:6.6.18.Final]
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
        at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:320) ~[spring-orm-6.2.8.jar!/:6.2.8]
        at jdk.proxy2/jdk.proxy2.$Proxy196.flush(Unknown Source) ~[na:na]
        at org.springframework.data.jpa.repository.query.JpaQueryExecution$ModifyingExecution.doExecute(JpaQueryExecution.java:264) ~[spring-data-jpa-3.5.1.jar!/:3.5.1]
        at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:93) ~[spring-data-jpa-3.5.1.jar!/:3.5.1]
        at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:160) ~[spring-data-jpa-3.5.1.jar!/:3.5.1]
        at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:148) ~[spring-data-jpa-3.5.1.jar!/:3.5.1]
        at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) ~[spring-data-commons-3.5.1.jar!/:3.5.1]
        at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) ~[spring-data-commons-3.5.1.jar!/:3.5.1]
        at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:170) ~[spring-data-commons-3.5.1.jar!/:3.5.1]

✨ 작업한 내용

  • 알림 선점(DB 상태 변경) 과 알림 발송 로직을 분리
  • DB 선점 로직은 트랜잭션으로 보호
  • 외부 알림 발송 및 후속 상태 변경은 별도 메서드에서 처리하도록 구조 개선

🌀 PR Point

x

🍰 참고사항

비즈니스 로직 변경은 없습니다!

📷 스크린샷 또는 GIF

기능 스크린샷

Summary by CodeRabbit

릴리즈 노트

  • 버그 수정
    • 리마인더 알림 전송 실패 시 상태 추적 및 오류 처리 개선
    • 알림 처리 안정성 강화로 더욱 신뢰할 수 있는 리마인더 서비스 제공

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 3, 2026

📝 Walkthrough

Walkthrough

알람 리마인더 스케줄러에 새로운 공개 메서드 processNotifications()를 추가했습니다. 이 메서드는 대상 ID 목록을 받아 처리 상태의 리마인더를 조회하고 알림을 발송하며, 실패 시 상태를 실패로 업데이트합니다. 기존 remindAlarm() 메서드는 이제 락 획득 후 이 새 메서드를 호출합니다.

Changes

Cohort / File(s) 변경 내용
알림 처리 로직 확장
src/main/java/umc/teumteum/server/global/scheduler/RemindAlarmScheduler.java
새로운 processNotifications(List<Long> targetIds) 메서드 추가: PROCESSING 상태의 리마인더 조회, 알림 발송, 실패 시 상태 업데이트. remindAlarm() 메서드에서 락 획득 후 호출하도록 수정.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Suggested labels

✨ Feature

Poem

🐰 락을 걸고 알림을 보내네,
실패해도 괜찮아, 상태 저장하지.
스케줄러의 새 손길이 더해져,
리마인더는 더욱 튼튼해졌어요! 💌✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 변경사항의 핵심을 명확하게 반영함. 스케줄러의 트랜잭션 분리를 통해 JPA flush 오류를 방지하려는 목적이 간결하고 구체적으로 표현됨.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

github-actions bot commented Jan 3, 2026

Test Results

0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit bd6a861.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/main/java/umc/teumteum/server/global/scheduler/RemindAlarmScheduler.java (3)

26-48: 트랜잭션 분리가 실제로 이루어지지 않음 (PR 목적 미달성)

PR 설명에 따르면 DB 선점 로직과 알림 발송 로직을 별도 트랜잭션으로 분리하여 JPA flush 오류를 방지하는 것이 목적입니다. 하지만 현재 구조에서는:

  • remindAlarm()@Transactional로 트랜잭션을 시작
  • processNotifications()를 동일 메서드 내에서 직접 호출
  • Spring의 기본 트랜잭션 전파(REQUIRED)에 따라 processNotifications()remindAlarm()의 트랜잭션에 참여

결과적으로 여전히 모든 로직(DB 선점 + 알림 발송 + 실패 처리)이 단일 트랜잭션에서 실행되어, 알림 발송 중 예외 발생 시 Hibernate Session 오염 문제가 여전히 발생할 수 있습니다.

🔎 트랜잭션 분리를 위한 해결 방안

방안 1: Self-injection 패턴 사용 (권장)

@Slf4j
@Component
@RequiredArgsConstructor
public class RemindAlarmScheduler {

    private final ScheduleReminderRepository scheduleReminderRepository;
    private final NotificationUseCases notificationUseCases;
+   private final RemindAlarmScheduler self; // Self-injection

    @Transactional
    @Scheduled(fixedRate = 60000, zone = "Asia/Seoul")
    public void remindAlarm() {
        LocalDateTime now = LocalDateTime.now();

        List<ScheduleReminder> targets = scheduleReminderRepository.findDueWithScheduleAndUser(
            AlarmStatus.ACTIVE, DispatchStatus.PENDING, now, PageRequest.of(0, 100)
        );

        if (targets.isEmpty())
            return;

        List<Long> targetIds = targets.stream().map(ScheduleReminder::getId).toList();

        int lockedCount = scheduleReminderRepository.updateDispatchStatus(targetIds,
            DispatchStatus.PENDING, DispatchStatus.PROCESSING);
        if (lockedCount == 0) return;

-       processNotifications(targetIds);
+       self.processNotifications(targetIds); // 프록시를 통한 호출로 새 트랜잭션 시작
    }

+   @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processNotifications(List<Long> targetIds) {
        // ... 기존 로직
    }
}

방안 2: 별도 서비스로 분리

알림 발송 로직을 별도의 @Service 컴포넌트로 분리하여 트랜잭션 경계를 명확히 구분합니다.

@Service
@RequiredArgsConstructor
public class NotificationProcessor {
    
    private final ScheduleReminderRepository scheduleReminderRepository;
    private final NotificationUseCases notificationUseCases;
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processNotifications(List<Long> targetIds) {
        // 기존 processNotifications 로직
    }
}

그리고 RemindAlarmScheduler에서 주입받아 사용:

@Component
@RequiredArgsConstructor
public class RemindAlarmScheduler {
    private final NotificationProcessor notificationProcessor;
    
    @Transactional
    @Scheduled(fixedRate = 60000, zone = "Asia/Seoul")
    public void remindAlarm() {
        // ... 락 획득 로직
        notificationProcessor.processNotifications(targetIds);
    }
}

Based on coding guidelines: 트랜잭션 관리 - @transactional이 필요한 메소드에 누락되지 않았는지, DB 일관성, 롤백 정책이 올바른지 검토


49-66: 트랜잭션 관리 누락

processNotifications() 메서드가 DB 조회(line 52)와 업데이트(lines 62-64)를 수행하지만 @Transactional 어노테이션이 없습니다.

현재는 remindAlarm()의 트랜잭션에 참여하므로 문제가 없어 보이지만, 트랜잭션 분리가 제대로 구현되면 이 메서드는 독립적인 트랜잭션 관리가 필요합니다. 특히 실패 시 상태를 FAILED로 업데이트하는 로직(lines 62-64)은 원자적으로 처리되어야 합니다.

🔎 제안 수정
+   @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processNotifications(List<Long> targetIds) {
        // ... 기존 로직
    }

Based on coding guidelines: 트랜잭션 관리 - @transactional이 필요한 메소드에 누락되지 않았는지 확인


56-65: 예외 처리 개선 권장

현재 예외 처리 로직은 기본적으로 적절하나, 다음 개선사항을 고려해 주세요:

  1. 상태 업데이트 실패 처리: Lines 62-64의 updateDispatchStatusByIds() 호출 자체가 실패할 경우 처리가 없습니다. 이 경우 리마인더가 PROCESSING 상태로 남아 재처리되지 않을 수 있습니다.

  2. 모니터링 강화: 실패한 알림 건수를 메트릭으로 수집하면 운영 중 문제를 조기에 발견할 수 있습니다.

🔎 개선 제안
    try{
        notificationUseCases.notifyReminder(locked);
    } catch(Exception e){
        log.error("리마인드 알림 발송 실패. locked count={}", locked.size(), e);
-       // PROCESSING 상태인 리마인더들 -> FAILED로 변경
-       List<Long> lockedIds = locked.stream().map(ScheduleReminder::getId).toList();
-       scheduleReminderRepository.updateDispatchStatusByIds(
-               lockedIds, DispatchStatus.PROCESSING, DispatchStatus.FAILED
-       );
+       try {
+           // PROCESSING 상태인 리마인더들 -> FAILED로 변경
+           List<Long> lockedIds = locked.stream().map(ScheduleReminder::getId).toList();
+           int failedCount = scheduleReminderRepository.updateDispatchStatusByIds(
+                   lockedIds, DispatchStatus.PROCESSING, DispatchStatus.FAILED
+           );
+           log.info("실패 상태 업데이트 완료. count={}", failedCount);
+       } catch (Exception updateException) {
+           log.error("실패 상태 업데이트 중 오류 발생. PROCESSING 상태로 남은 리마인더가 있을 수 있음", updateException);
+           // 모니터링 알림 또는 메트릭 수집 고려
+       }
    }

Based on coding guidelines: 예외 처리 - 예외가 적절히 처리되었는지 확인, 공통 예외 처리 모듈 활용 여부 검토

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 150b42d and bd6a861.

📒 Files selected for processing (1)
  • src/main/java/umc/teumteum/server/global/scheduler/RemindAlarmScheduler.java
🧰 Additional context used
📓 Path-based instructions (1)
src/**

⚙️ CodeRabbit configuration file

src/**: 다음 항목들을 꼼꼼하게 검토해줘.

  1. 예외 처리
  • 예외가 적절히 처리되었는지 확인해줘. (try-catch, throws, ExceptionAdvice)
  • 공통 예외 처리 모듈(예: GlobalHandler, ApiResponse 등)을 잘 활용했는지 확인.
  • RuntimeException을 남발하지 않고, 의미 있는 커스텀 예외를 사용하는지 검토.
  • 예외 메시지에 민감 정보(DB 정보, 사용자 정보 등)가 노출되지 않게 했는지 점검.
  1. 코드 품질 & 가독성
  • 메소드/클래스가 단일 책임 원칙(SRP)에 맞게 구성되어 있는지.
  • 중복 코드가 있는 경우, 유틸/공통 컴포넌트로 추출 가능한지.
  • 의미 있는 변수명과 메소드명을 사용했는지.
  • 매직 넘버, 하드코딩된 값이 존재하는지 점검.
  1. 성능 및 효율성
  • 불필요한 DB 쿼리 호출, N+1 문제 가능성이 있는지 확인.
  • Stream, loop, recursion 사용 시 시간복잡도/메모리 효율성을 고려했는지.
  • 캐시 적용 가능성이 있거나, 과도한 연산이 반복되는 구간이 있는지.
  1. 트랜잭션 관리
  • @transactional이 필요한 메소드에 누락되지 않았는지.
  • 읽기 전용 트랜잭션(readOnly = true)을 적절히 사용했는지.
  • DB 일관성, 롤백 정책이 올바른지 검토.
  1. 입력 검증 및 보안
  • @Valid, Bean Validation 등을 통한 입력값 검증이 되어 있는지.
  • 비밀번호, 토큰 등 민감한 정보가 로깅되지 않는지.
  1. 테스트
  • 단위 테스트가 충분히 작성되었는지, 핵심 로직의 검증이 누락되지 않았는지.
  • Mocking을 통한 독립 테스트 구조를 유지했는지.
  • 경계값 테스트, 예외 케이스 테스트가 포함되어 있는지.
  1. 구조 및 설계
  • Controller, Service, Repository 등 계층 구조가 올바르게 나뉘어 있는지.
  • DTO, Entity, Domain 객체 간 변환 로직이 명확하고 중복되지 않는지.
  • Config 클래스에서 Bean 등록이 과도하거나 순환 참조 위험이 없는지.

Files:

  • src/main/java/umc/teumteum/server/global/scheduler/RemindAlarmScheduler.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Continuous Integration

processNotifications(targetIds);

}
public void processNotifications(List<Long> targetIds) {
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

메서드 가시성 검토 필요

processNotifications() 메서드가 public으로 선언되어 있습니다.

  • 트랜잭션 분리를 위해 self-injection 패턴을 사용할 경우 public이어야 하므로 현재 선언은 적절합니다.
  • 하지만 외부에서 이 메서드를 직접 호출하는 것은 의도되지 않은 동작을 유발할 수 있습니다.
  • 만약 별도 서비스로 분리하는 방안을 선택한다면, 이 메서드는 외부 노출을 제한하는 것이 좋습니다.

권장사항:

  • Self-injection 패턴 사용 시: 현재처럼 public 유지하되, JavaDoc으로 "스케줄러 내부 전용" 명시
  • 별도 서비스 분리 시: 외부 컴포넌트에서 호출 가능하도록 설계하거나, 접근 제한 고려

Based on coding guidelines: 구조 및 설계 - 계층 구조가 올바르게 나뉘어 있는지 확인

🤖 Prompt for AI Agents
In src/main/java/umc/teumteum/server/global/scheduler/RemindAlarmScheduler.java
around line 49, the processNotifications(List<Long> targetIds) method is
declared public but is intended only for scheduler/self-invocation; if you keep
the self-injection / transaction-proxy approach, leave it public and add a
JavaDoc comment above the method clearly stating "scheduler-internal only — do
not call from other components" (and optionally annotate internal use),
otherwise if you refactor this logic into a separate service change the method
visibility to package-private (or protected) and expose a controlled public API
on the new service instead so external callers cannot directly invoke the
scheduler internals.

@hyeonda02 hyeonda02 merged commit 2c7cd06 into develop Jan 3, 2026
4 checks passed
@hyeonda02 hyeonda02 deleted the hotfix/remind-alram branch January 3, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants