cron을 이용하여 5초마다 해당 메서드가 실행되는 지 테스트
@Scheduled(cron = "0/5 * * * * *")
private void updateOrderStatus(){
log.info("Scheduled Test");
}
5초 간격으로 해당 메서드 실행 확인
주문 테이블의 주문 상태가 PURCHASE(구매)인 것만 추출
- 주문일로부터 24시간 이내라면 아무것도 하지 않음
- 주문일로부터 24시간 경과라면 OrderStatus를 구매(PURCHASE) → 구매 확정(CONFIRM)으로 변경
다음과 같이 코딩 시 에러 발생
@Scheduled(cron = "0/5 * * * * *")
private void updateOrderStatus(){
log.info("Scheduled execute");
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
System.out.println(allList);
}
org.hibernate.LazyInitializationException: could not initialize proxy [io.spring.playheaven.member.entity.Member#1] - no Session
구글링 시 @Transactional 어노테이션을 추가하면 해결이 된다고 함
- @Transactional 어노테이션은 private 메서드에 사용 불가
→ 이것은 @Transactional이 Proxy로 동작을 하기 때문이라고 함 - 대충 구글링을 해봤지만 바로 이해할 수 없었기에 추후 @Transaction의 동작 과정에 대해 다시 찾아봐야할 것
@Scheduled(cron = "0/5 * * * * *")
@Transactional
protected void updateOrderStatus(){
log.info("Scheduled execute");
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
System.out.println(allList);
}
왜 @Transactional 어노테이션을 사용하지 않으면 에러가 발생했을까?
- org.hibernate.LazyInitializationException: could not initialize proxy [io.spring.playheaven.member.entity.Member#1] - no Session 에러는 Hibernate에서 발생하는 에러로 지연 로딩이 동작하는 중에 필요한 데이터가 Hibernate 세션 밖에서 접근될 때 발생한다고 함
- JPA에서 Session은 영속성 컨텍스트를 의미
지연 로딩(Lazy Loading)의 개념
- JPA에서 @ManyToOne 관계를 FetchType.LAZY로 설정하면, 연관된 엔티티는 실제로 필요할 때까지 로딩되지 않는 대신 프록시 객체가 반환되고, 해당 객체에 접근하려는 시점에 데이터베이스에서 실제 데이터를 조회
- 이 과정에서 Hibernate는 영속성 컨텍스트(Hibernate Session)가 필요
왜 @Transactional 어노테이션을 추가하면 해결이 될까?
- @Transactional을 추가하면 해당 메소드가 트랜잭션 범위 내에서 실행
- 트랜잭션이 시작되면 Hibernate는 세션을 열고 이 세션을 통해 영속성 컨텍스트가 관리
- 메소드가 끝날 때까지 영속성 컨텍스트는 열려있기 때문에 지연 로딩이 필요한 시점에 데이터베이스에서 데이터를 조회 가능
@Scheduled 메소드에서 @Transactional이 중요한 이유
- @Scheduled로 실행되는 메소드는 기본적으로 트랜잭션 범위 밖에서 실행
즉, 영속성 컨텍스트가 존재하지 않으므로 지연 로딩이 필요한 시점에 세션 사용 불가 - 따라서 LazyInitializationException이 발생
하지만, 아래 블로그의 내용에 의하면 조회 로직에 @Transactional을 적용하는 것은 권장되지 않는다고 함
블로그 작성자는 아래 2가지 방법을 이용하여 지연 로딩의 문제점을 해결
- FetchType을 EAGER로 변경
- QueryDSL 사용
https://velog.io/@yarogono/JPA-could-not-initialize-proxy-no-Session-%EC%97%90%EB%9F%AC
LazyInitializationException 에러에 대해 ChatGPT에 물어봤을 때에는 아래 3가지 해결책을 제시하였음
- Transactional의 사용
- FetchType을 EAGER로 변경
- @EntityGraph의 사용
@EntityGraph를 사용하게 되면 특정 쿼리에서만 연관된 엔티티를 즉시 로딩할 수 있도록 설정이 가능하다고 함
@Scheduled(cron = "0/5 * * * * *")
private void updateOrderStatus(){
log.info("Scheduled execute");
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
System.out.println(allList);
}
@EntityGraph(attributePaths = {"member"})
List<Order> findAllByOrderStatus(OrderStatus orderStatus);
위에서 참조한 블로그의 QueryDSL와 ChatGPT의 해결방안인 @EntityGraph 두 개 모두 Fetch Join이라는 것을 사용하는 것 같고, Fetch Join 사용 시 N + 1 문제를 해결할 수 있다는 것 같음
이후 N+1, fetch join 등을 연관지어 알아보도록 하고 현재는 스케쥴 사용에 초점을 맞추기로 함
단순 조회일 경우 EntityGraph 또는 QueryDSL을 사용해보았을테지만, 조회 한 뒤 24시간이 경과된 주문들은 주문 상태를 '구매 확정'으로 변경이 필요하기 때문에 @Transactional을 사용하여 Dirty Check
Duration
- 현재 시점을 기준으로 주문 시점으로부터 얼마나 시간이 경과했는 지 확인 필요
- Duration 클래스의 between 메서드를 이용하면 두 개의 시간의 차이가 저장이 됨
- 더 오래된 시간을 앞에 넣을 경우 마이너스로 표시
- 테스트를 위하여 주문 번호 1번은 24시간이 경과된 값으로 createAt을 수정
@Scheduled(cron = "0/5 * * * * *")
@Transactional
protected void updateOrderStatus(){
log.info("Scheduled execute");
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
for(Order order : allList){
log.info("주문 번호 = {}", order.getOrderId());
log.info("주문 시점 = {}", order.getCreateAt());
LocalDateTime createAt = order.getCreateAt();
LocalDateTime now = LocalDateTime.now();
Duration duration = Duration.between(createAt, now);
log.info("Duration = {}", duration);
log.info("Duration.toHours = {}", duration.toHours());
}
}
duration.toHours()를 사용하여 24시간 이상인 것들의 Status를 변경
- 값을 변경해야하기 때문에 Setter가 필요하지만 OrderStatus만 변경을 하기 때문에 모든 Setter를 만드는 것은 옳지 않아 해당 필드 위에 Setter 선언
@Setter
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
@Scheduled(cron = "0/5 * * * * *")
@Transactional
protected void updateOrderStatus(){
log.info("Scheduled execute");
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
for(Order order : allList){
LocalDateTime createAt = order.getCreateAt();
LocalDateTime now = LocalDateTime.now();
Duration duration = Duration.between(createAt, now);
if(duration.toHours() >= 24)
order.setOrderStatus(OrderStatus.CONFIRM);
System.out.println(order);
}
}
updateOrderStatus() 메서드 1회 실행
- 24시간이 경과되었던 주문번호 1번의 상태값이 CONFIRM으로 변경
updateOrderStatus() 메서드 2회 실행
- 2번째 스케쥴러가 실행되면 구매 상태(PURCHASE)만 조회하기 때문에 주문 번호 1번은 조회되지 않음
- 24시간이 경과된 주문 건이 있을 경우 위처럼 update 쿼리 발생
Database
최종 코드
@Scheduled(cron = "* 0/5 * * * *")
@Transactional
protected void updateOrderStatus(){
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
allList.stream()
.filter(order -> Duration.between(order.getCreateAt(), LocalDateTime.now()).toHours() >= 24)
.forEach(order -> order.setOrderStatus(OrderStatus.CONFIRM));
}
위와 같이 초에 *을 넣게 되면, 16:00:00 ~ 16:00:59까지 계속 반복이 발생되어 5분 단위로 0초일 때 한 번만 실행되도록 변경
@Scheduled(cron = "0 0/5 * * * *")
@Transactional
protected void updateOrderStatus(){
List<Order> allList = orderRepository.findAllByOrderStatus(OrderStatus.PURCHASE);
allList.stream()
.filter(order -> Duration.between(order.getCreateAt(), LocalDateTime.now()).toHours() >= 24)
.forEach(order -> order.setOrderStatus(OrderStatus.CONFIRM));
}
'Language > Spring' 카테고리의 다른 글
[JWT] JWT 등장 배경, Access Token, Refresh Token (0) | 2024.08.27 |
---|---|
[Authentication] Cookie, Session, JWT Token (0) | 2024.08.26 |
[Spring Boot] 정해진 시간마다 동작하는 Scheduler (0) | 2024.08.19 |
[Spring Boot] Redis를 이용한 이메일 인증 리팩토링 (0) | 2024.08.13 |
레디스(Redis) 개념 및 설치 (0) | 2024.08.13 |