본문 바로가기

Language/Spring

[Spring Boot] 일정 주기마다 업데이트 프로젝트 적용해보기

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가지 방법을 이용하여 지연 로딩의 문제점을 해결

  1. FetchType을 EAGER로 변경
  2. QueryDSL 사용

https://velog.io/@yarogono/JPA-could-not-initialize-proxy-no-Session-%EC%97%90%EB%9F%AC

 

LazyInitializationException 에러에 대해 ChatGPT에 물어봤을 때에는 아래 3가지 해결책을 제시하였음

  1. Transactional의 사용
  2. FetchType을 EAGER로 변경
  3. @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));
}