SpringBoot @Scheduled의 문제점과 해결책

지혜·2025년 2월 24일
0

💡 https://github.com/H5-dev-project/newsfeed

기한이 만료된 스토리를 체크해서 자동 비공개처리를 진행했다. 이를 위해 @scheduled 어노테이션을 이용했다.
하지만 이후 튜터님께 피드백 받을 때 @scheduled을 이용하는 개발 방식이 싱글스레드에서는 문제가 없지만 이후 멀티 스레드 환경에서는 문제가 발생할 수 있다는 이야기를 해주셨다. 그래서 이에 대해 좀 더 알아보고자 한다.

@Transactional
    @Scheduled(cron = "0 * * * * ?") // 분당 한번씩 체크크
    public void isVisibilityExpire(){
        LocalDateTime now = LocalDateTime.now();
        List<Story> expiredStories = storyRepository.findByVisibilityEndBeforeAndVisibilityType(now, 1);

        for(Story story : expiredStories){
            story.changeVisibilityType(0);
        }

        storyRepository.saveAll(expiredStories);
    }

📝 @Scheduled

1. 동작원리

Spring은 단일스레드 기반의 스케줄러를 이용, 특정 주기에 맞춰 메서드를 실행하는 방식으로 진행

스케줄러 실행 흐름

  • @Scheduled가 선언된 메서드를 Spring 컨테이너가 감지
  • 내부적으로 TaskScheduler가 Runnable 형태의 작업으로 등록
  • 설정된 주기에 맞춰 Runnable이 실행됨
  • 으로 싱글 스레드(Single Thread)에서 실행
  • 완료되면, 다음 실행 시간에 맞춰 다시 실행

결론

@Scheduled은 단일 스레드를 기반으로 이용하기 때문에 멀티 스레드 환경에서 실행을 시키게 된다면 각각의 서버에서 별도로 @Scheduled를 실행하기 때문에 동일한 작업이 여러번 실행되는 동시성 문제가 발생할 가능성이 있음!

2. 발생할 문제

  • 중복 실행 문제

    • 여러 대의 서버에서 동일한 애플리케이션을 동시에 실행중이라면 스케줄링 작업이 동시에 실행될 가능성 존재.
    • ex) storyRepository.findByVisibilityEndBeforeAndVisibilityType(now, 1) 조회 후, saveAll() 전에 동일한 작업이 다른 서버에서 실행된다면 동일한 Story 엔티티가 중복 업데이트될 가능성이 존재하여 불필요한 업데이트나 충돌이 발생할 수 있음.
  • 경쟁 상태(Race Condition)

    • 조회부터 saveAll이 마무리 될 때 까지 다른 트랜잭션에서 Story 데이터를 변경할 수 있음.
    • ex) A서버에서 Story를 조회 후 업데이트 전에 B서버에서 같은 엔티티를 업데이트 한다면 예상치 못한 오류 발생 가능성 존재
  • Phantom Read (팬텀 리드)

    • 트랜잭션이 실행되는 동안, 다른 서버에서 다른 트랜잭션을 실행시켜 Story엔티티를 삭제하거나 변경할 경우 예상과 다른 결과가 조회될 가능성이 있음.

3. 해결 방법

그럼 @schedule 어노테이션을 이용하면서 위와 같은 문제를 해결할 방법은 뭐가 있을까?

1️⃣ 스케줄링 작업을 하나의 서버만 수행하도록 막기

  • @SchedulerLock 이용하기
    • 멀티서버에서 동시에 @Scheduled 작업을 수행할 수 있기 때문에 발생하는 문제이기 때문에 를 이용하여 하나의 서버만 실행하도록 보장할것
    • ⚠️ 단점 : 성능 문제가 발생할 가능성이 존재
  @Transactional
      @Scheduled(cron = "0 * * * * ?") // 분당 한번씩 체크크
      @SchedulerLock(name = "isVisibilityExpire", lockAtLeastFor = "1m", lockAtMostFor = "2m")
      public void isVisibilityExpire(){
          LocalDateTime now = LocalDateTime.now();
          List<Story> expiredStories = storyRepository.findByVisibilityEndBeforeAndVisibilityType(now, 1);

          for(Story story : expiredStories){
              story.changeVisibilityType(0);
          }

          storyRepository.saveAll(expiredStories);
      }
  • 비관적 락(Pessimistic Lock) 사용
    • 멀티스레드 환경에서 동시 업데이트 충돌을 방지하려면 비관적 락을 사용할 수도 있어.
    • 한 번에 하나의 트랜잭션만 해당 데이터를 조회 및 수정할 수 있도록 강제하므로, 동시 업데이트 충돌을 방지할 수 있음.
    • ⚠️ 단점: 데이터가 많아질 경우 성능 저하가 발생할 수 있음.

3️⃣ 쿼리 단에서 바로 업데이트 (JPQL)

  • 기존 조회 -> 업데이트 방식에서 한 번의 쿼리로 바로 update 해버리는 방식으로 변경
    • JPA의 변경 감지를 사용하지 않고 직접 업데이트하기 때문에, 동시성 문제를 줄이고 성능도 향상됨.
    • ⚠️ 단점: 변경된 데이터를 엔티티 객체로 다루지 못하고, 직접 DB에서 변경되기 때문에 추적이 어려울 수 있음.
  @Modifying
  @Query("UPDATE Story s 
            SET s.visibilityType = 0 
            WHERE s.visibilityEnd < :now AND s.visibilityType = 1")
  int updateExpiredStories(@Param("now") LocalDateTime now);

🤔 명시적 업데이트와 쿼리단에서 바로 업데이트 차이점은 뭘까?

여기서 명시적 업데이트와 쿼리단에서 직접 업데이트를 실행할 때의 차이점이 뭔지 궁금해졌다.
결론적으로 가장 큰 차이는 명시적 업데이트의 경우 list에 존재하는 엔티티 개수별로 업데이트가 진행된다는 점이고, 쿼리단에서 실행할 경우에는 하나의 쿼리로 모든 데이터를 업데이트 할 수 있다는 점이다.

1️⃣ findByVisibilityEndBeforeAndVisibilityType() + saveAll()

1. 동작 방식

  • SELECT 문을 실행하여 조건 visibilityEnd < now AND visibilityType = 1에 포함되는 모든 데이터를 조회함.
  • 조회한 엔티티 리스트의 visibilityType0으로 변경하고 saveAll로 업데이트
  • saveAll()이 실행될 경우 각각의 개별적인 update 쿼리가 실행됨.

2. 장점

  • 메모리에서 엔티티를 변경 후 저장하기 때문에 JPA를 활용할 수 있음
  • 로직에서 엔티티를 활용하므로 업데이트 전 추가적인 검증 로직을 적용하기 수월함.

3. 단점

  • SELECT 후 LIST에 포함된 엔티티 개수만큼 UPDATE문을 실행하기 때문에 대량의 데이터를 처리할 경우 성능 저하 발생

2️⃣ @Modifying + @Query (JPQL 직접 실행)

  • 동작 방식
    • update문 하나의 쿼리로 모든 데이터를 업데이트
    • select 하지 않고 바로 update하기 때문에 보다 효율적.
  • 장점
    • 하나의 쿼리로 모든 데이터 처리가 가능하므로 성능이 뛰어남
    • select를 진행하지 않으므로 메모리 사용량이 줄어듬
  • 단점
    • JPA를 사용할 수 없음
    • 쿼리를 바로 실행하기 때문에 추가적인 검증 로직을 수행하기 어려움

💡 따라서 상황에 따라 적절한 방식을 이용해야 한다. 지금 내 코드에서는 조회 후 saveAll이 아닌 바로 update해주는 방식이 더욱 효율적!

📝 @Scheduled이 없다면?

위와 같이 코드를 수정한다면 멀티스레드에서 문제를 해결할 수 있을것 같다. 그러나 하나의 서버에서 실행하도록 강제한다는 점에서 성능에 그다지 좋지 않을것 같았다. 그렇다면 @Scheduled없이 자동 업데이트를 하는 방법에 대해 알아보고자 한다.

@Scheduled 없이 해결하는 3가지 방법

1. DB에서 직접 Scheduled Task 실행

  • DB자체에서 주기적인 update 실행
  • 장점
    • 멀티 서버 환경에서도 중복 실행 문제가 발생하지 않음
    • 성능의 뛰어남
    • 운영이 간단함
  • 단점
    • DB에 의존적
    • 사이드 이펙트가 발생하므로 자바단에서 관리가 어려움

2. 메시지 큐 (RabbitMQ, Kafka, Redis Stream) 활용

  • 만료될 Story의 정보를 메시지 큐에 넣고 비동기적으로 처리

  • ex) visibilityEnd와 시간이 가까워지면 queue에 Story Id 값을 넣고, worker 서비스에서 업데이트

  • 장점

    • 멀티 서버 환경에서도 중복 실행 문제가 발생하지 않음
    • 확장성이 뛰어남
    • 이벤트 기반 아키텍처에 적합
  • 단점

    • 메시지 큐 설정이 필요(Kafka, Redis 등 추가적인 설정 필요)
    • 아키텍쳐가 복잡함

3. 배치 처리 (Spring Batch)

  • 대량의 데이터를 일정 기간마다 처리해야하는 경우 사용

  • Spring Batch Job을 설정

  • 장점

    • 대량의 데이터 처리에 최적화 (대규모 업데이트 작업에 유리)
    • 멀티 서버에서도 단일 실행 보장 가능 (Quartz Scheduler 또는 ShedLock 조합 가능)
    • 트랜잭션 관리 및 오류 복구 기능이 강력함
  • 단점

    • 설정이 복잡함 (Job 설정, Step 관리 필요)
    • 단순한 업데이트 처리에는 과한 솔루션일 수 있음

결론
공부는 하면 할수록 알아가야할 것이 많은 것 같다. 추후에는 Kafka, Redis 등에 대해서도 추가적인 공부가 필요할 것 같다.

0개의 댓글

관련 채용 정보