일정 생성 시 EventReminder(일정 알림)를 함께 생성하고, 1분마다 스케줄링을 실행하여 notifyTime이 현재 시간과 일치하는 컬럼을 찾아 SSE를 통해 실시간 알림을 보내는 기능을 구현 중이다.
@Transactional
@Scheduled(fixedRate = 60000) // 1분마다 실행
트랜젝션과 스케줄링을 함께 사용하면 충돌이 발생할 가능성이 있다.
@Transactional이 걸린 checkEventReminder() 메서드 내에서 eventReminderRepository.findByNotifyTimeBetween()를 호출하면, 조회된 EventReminder 엔티티가 영속성 컨텍스트 안에 존재한다.
하지만, processEventReminder()를 호출할 때 EventReminder가 Lazy Fetch 전략이면 세션이 종료된 후에 프록시 객체를 로드하려다가LazyInitializationException이 발생할 수 있다.
그래서 fetchAndProcessReminders 메서드를 분리하고 트랜젝션을 적용했다.
@Override
@Scheduled(fixedRate = 60000) // 1분마다 실행
public void checkEventReminder() {
LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
LocalDateTime start = now.minusSeconds(30);
LocalDateTime end = now.plusSeconds(30);
int pageSize = 100;
Pageable pageable = PageRequest.of(0, pageSize);
Page<EventReminder> eventRemindersPage;
do {
eventRemindersPage = fetchAndProcessReminders(start, end, pageable);
pageable = pageable.next(); // 다음 페이지로 이동
} while (eventRemindersPage.hasNext()); // 다음 데이터가 있을 경우 계속 조회
}
@Transactional // 루프 내부에서 트랜잭션을 시작
public Page<EventReminder> fetchAndProcessReminders(LocalDateTime start, LocalDateTime end, Pageable pageable) {
Page<EventReminder> eventRemindersPage = eventReminderRepository.findByNotifyTimeBetween(start, end, pageable);
List<EventReminder> eventReminders = eventRemindersPage.getContent();
log.info("발송할 알림 개수: {}", eventReminders.size());
eventReminders.forEach(this::processEventReminder);
return eventRemindersPage;
}
이렇게 트랜젝션과 스케줄링을 분리했는데, LazyInitializationException 문제가 발생했다.
❗️오류 문구 : org.hibernate.LazyInitializationException: could not initialize proxy
💡오류 원인:
@Transactional이 적용된 fetchAndProcessReminders() 안에서 eventReminderRepository.findByNotifyTimeBetween()이 실행됨.
그러나 processEventReminder()에서 Lazy Loading된 Event를 조회하려고 할 때 트랜잭션이 끝났기 때문에 예외가 발생했다.
@EntityGraph(attributePaths = {"event"}) @Query("SELECT er FROM EventReminder er WHERE er.notifyTime BETWEEN :start AND :end") Page<EventReminder> findByNotifyTimeBetween(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end, Pageable pageable);
❓선택 이유
fetchAndProcessReminders() 실행 시 eventReminderRepository.findByNotifyTimeBetween()에서
Event 엔티티까지 함께 조회되므로 LazyInitializationException이 발생하지 않음.
-> 추가적으로 페이징 처리를 하고 있으므로 fetch join보다 @EntityGraph사용이 낫다고 판단함
@Query(value = "SELECT er FROM EventReminder er JOIN FETCH er.event WHERE er.notifyTime BETWEEN :start AND :end",
countQuery = "SELECT count(er) FROM EventReminder er WHERE er.notifyTime BETWEEN :start AND :end")
Page<EventReminder> findByNotifyTimeBetween(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
Pageable pageable);
❗️페이징을 사용할 경우, fetch join은 제약이 있다.
JPA에서는 fetch join과 Pageable을 함께 사용할 경우, countQuery가 자동으로 생성되지 않으므로 따로 지정해줘야 한다.
동적 쿼리가 필요하거나, 복잡한 조건이 들어간다면 fetch join(QueryDSL 포함) 사용이 유리하다.
@Transactional
public Page<EventReminder> fetchAndProcessReminders(LocalDateTime start, LocalDateTime end, Pageable pageable) {
Page<EventReminder> eventRemindersPage = eventReminderRepository.findByNotifyTimeBetween(start, end, pageable);
List<EventReminder> eventReminders = eventRemindersPage.getContent();
// Lazy-Loaded 연관 엔티티 강제 초기화
eventReminders.forEach(er -> Hibernate.initialize(er.getEvent()));
log.info("발송할 알림 개수: {}", eventReminders.size());
eventReminders.forEach(this::processEventReminder);
return eventRemindersPage;
}
❗️@EntityGraph, fetch join보다 성능이 떨어지고 유지보수성이 낮음
| 해결 방법 | 장점 | 단점 |
|---|---|---|
✅ @EntityGraph (선택한 방법) | Lazy 로딩된 연관 엔티티를 함께 로딩하여 LazyInitializationException 방지 | 일부 복잡한 연관 관계에서는 성능 저하 가능 |
| Fetch Join (JPQL) | 즉시 로딩을 통해 LazyInitializationException 방지 | 페이징과 함께 사용할 경우 countQuery를 별도로 명시해야 함 |
| Hibernate.initialize() | 필요한 시점에서 Lazy 로딩된 엔티티를 강제 초기화 가능 | 성능 저하 및 유지보수성 낮음 |