ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] 다중 인스턴스에서 @Scheduled 문제와 ShedLock 사용하기
    legacy/Spring 2024. 7. 11. 13:43

     

    1. 실행 환경

    • Java 17
    • Gradle 8.5
    • SpringBoot 3.2.3

     

    2. 기존 코드

    우리 프로젝트의 경우 게시물(Section)에 좋아요 기능과 관련한 API를 Redis를 사용하여 처리하고 있다. Redis에서 (key: SectionId, value:listof(userId))로 관리하고 있다.

    30초 간격으로 스케줄링을 통해 Redis에 좋아요 정보를 DB의 Likes 테이블로 옮긴다.

    // 30초마다 Redis 좋아요 기록을 DB의 Likes 테이블에 저장한다.
    @Scheduled(fixedDelay = 1000L * 30)
    @Transactional
    public void saveLikes() {
    
        log.info("스케줄링 호츌");
    
        // 정규식에 해당하는 모든 key를 조회한다.
        Set<String> keys = redisTemplate.keys("section:*:like");
    
        if (keys == null || keys.isEmpty()) {
            return;
        }
    
        for (String key : keys) {
            // key에서 sectionId를 추출한다.
            Long sectionId = Long.parseLong(key.split(":")[1]);
            // key에 저장된 모든 value를 추출한다.
            Set<String> userIds = redisTemplate.opsForSet().members(key);
            for (String userId : userIds) {
                User user = getUser(Long.parseLong(userId)); // 좋아요를 누른 사용자
                Section section = getSection(sectionId); // 좋아요를 누른 회고 카드
    
                // 새로운 좋아요 기록을 Likes 테이블에 저장 및 알림을 생성한다.
                addLikeAndNotification(sectionId, user, section);
            }
    
            /**
             * Redis의 Key에 저장되지 않은 value들을 Likes 테이블에서 삭제한다.
             */
            List<Likes> likes = likesRepository.deleteBySectionIdAndUserIdNotIn(sectionId,
                userIds.stream().map(Long::parseLong).toList());
            likes.forEach(this::deleteNotification); // 알림을 지운다.
        }
    }

     

     

    2. 두 대의 인스턴스 실행하기

    8080 포트와 8081 포트를 사용하는 두 대의 인스턴스를 실행해보았다.

    사진을 보면 한 가지 문제점이 존재하는데 “스케줄링 호출”이 두 번이 발생한다. Redis에 저장된 좋아요 기록을 DB로 옮기는 것은 1번이면 충분하다. Scale-Out이 커질수록 스케줄링이 중복으로 호출되는 횟수도 증가할 것이다.

    왼쪽 : 8080포트 / 오른쪽 : 8081 포트

    3. ShedLock을 사용하여 스케줄링 Lock 걸기

    3-1. ShedLock이란?

    ShedLock이란 여러 대의 인스턴스가 존재할 때, 동일한 스케줄링이 중복으로 수행되지 않도록 Lock을 걸어주는 라이브러리이다. 예를 들어, 인스턴스 A, B가 존재할 때 A에서 스케줄링이 실행되면 Lock이 걸리기 때문에 B에서 동일한 스케줄링을 실행할 수 없다.

     

    3-2. 테이블 생성

    RDB를 사용하는 경우 ShedLock을 위한 테이블 생성이 필요하다.

    • name: 스케줄링을 식별하기 위한 name
    • lock_until: 잠금이 유효한 시간
    • locked_at: 잠금이 설정된 시간
    • locked_by: 잠금을 설정한
    CREATE TABLE shedlock (
      name VARCHAR(64),
      lock_until TIMESTAMP(3) NULL,
      locked_at TIMESTAMP(3) NULL,
      locked_by VARCHAR(255),
      PRIMARY KEY (name)
    )

     

    3-3. 의존성 추가

    Java 17을 사용하는 경우 5.10.0 버전을 사용하고, 17 이전은 4.44.0 버전을 사용한다.

    // shed lock
    implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.0'
    implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.0'

     

    3-4. 설정 파일 생성

    • @EnableSchedulerLock : ShedLock의 스케줄러 잠금 기능 활성화
      • defaultLockAtMostFor : 잠금이 유지되는 최대 시간을 설정한다. 코드의 경우 “PT30S”를 설정했기 때문이 Lock이 30초 동안 유지된다. 30초 이후에는 Lock이 해제된다.
    • LockProvider : ShedLock 인터페이스
    • JdbcTemplateLockProvider : DB를 사용하는 경우 JdbcTemplateLockProvider를 사용한다.
    • withJdbcTemplate() : JdbcTemplate을 사용하여 DB 작업을 수행한다.
    • usingDbTime() : 서버의 시간이 아닌 DB의 시간을 사용하도록 설정한다. Lock을 DB 시간을 기준으로 사용하기 위해 사용된다.
    import javax.sql.DataSource;
    import net.javacrumbs.shedlock.core.LockProvider;
    import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
    import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.jdbc.core.JdbcTemplate;
    
    @Configuration
    @EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
    public class SchedulerConfig {
    
        @Bean
        public LockProvider lockProvider(DataSource dataSource) {
            return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                    .withJdbcTemplate(new JdbcTemplate(dataSource))
                    .usingDbTime()
                    .build()
            );
        }
    }

    참고로 잠금 시간을 설정할 때 ISO 8601 표기법이 사용된다. PT15S는 Period of Time 15 Seconds를 의미한다.

    • ISO 8601 시간 표기법
      • H : 시간(hours)
        • ex) PT1H : 1시간
      • M : 분(minutes)
        • ex) PT30M : 30분
      • S : 초(seconds)
        • ex) PT45S : 45초

     

    3-3. 비즈니스 로직 수정

    기존의 비즈니스 로직에 @SchedulerLock 애너테이션을 사용한다. name 필드는 스케줄링 작업을 식별하기 위해 사용되는데, 여러 대의 서버에서 동일한 스케줄링을 수행하지 않도록 한다. 애너테이션에서 name 필드는 DB에서 식별자로 사용하는 필드명과 동일해야 한다.

    // 30초마다 Redis 좋아요 기록을 DB의 Likes 테이블에 저장한다.
    @Scheduled(fixedDelay = 1000L * 30)
    @Transactional
    @SchedulerLock(name = "SchedulerLock", lockAtLeastFor = "PT15S",lockAtMostFor = "PT30S")
    public void saveLikes() {
    
        log.info("스케줄링 호츌");
    
        // 정규식에 해당하는 모든 key를 조회한다.
        Set<String> keys = redisTemplate.keys("section:*:like");
    
        if (keys == null || keys.isEmpty()) {
            return;
        }
    
        for (String key : keys) {
            // key에서 sectionId를 추출한다.
            Long sectionId = Long.parseLong(key.split(":")[1]);
            // key에 저장된 모든 value를 추출한다.
            Set<String> userIds = redisTemplate.opsForSet().members(key);
            for (String userId : userIds) {
                User user = getUser(Long.parseLong(userId)); // 좋아요를 누른 사용자
                Section section = getSection(sectionId); // 좋아요를 누른 회고 카드
    
                // 새로운 좋아요 기록을 Likes 테이블에 저장 및 알림을 생성한다.
                addLikeAndNotification(sectionId, user, section);
            }
    
            /**
             * Redis의 Key에 저장되지 않은 value들을 Likes 테이블에서 삭제한다.
             */
            List<Likes> likes = likesRepository.deleteBySectionIdAndUserIdNotIn(sectionId,
                userIds.stream().map(Long::parseLong).toList());
            likes.forEach(this::deleteNotification); // 알림을 지운다.
        }
    }

     

    4. ShedLock을 사용하여 다중 인스턴스 실행

    2개의 인스턴스를 사용하고 있으나 스케줄링은 한 개의 인스턴스에서만 실행되고 있다. Lock을 먼저 획득한 인스턴스에서 스케줄링이 실행된다.

     

    특이한 점은 Lock 설정 및 해제를 반복하면서 새로운 row가 생기는 것이 아닌, 기존의 row에서 계속 update가 발생한다.

    기존의 row에서 update가 발생한다.

     


     

     

    참고

    https://dkswnkk.tistory.com/731

    https://roopredev.tistory.com/4

    https://minhye0k.github.io/shedlock-을-이용해-spring-scheduler-중복-실행을-멈춰보자

     

Designed by Tistory.