Spring & JPA

[고대로] Redisson을 통한 분산 락 적용

진진리 2025. 3. 5. 20:48

기존 배포 서버를 1개 사용하고 있었지만 최근 ECS로 배포 방법을 변경함에 따라 여러 컨테이너가 같은 DB 데이터에 접근할 수 있게 되었다. (사실 기존에도 개발용 서버를 배포하였기 때문에 상황은 동일하다...)

 

서버가 늘어날 수록 같은 데이터를 수정하는 스케줄러가 동시에 여러 개 시작되어 문제가 발생할 수 있기 때문에 이를 예방하기 위해 분산 락을 적용해야 할 필요성을 느꼈다.

 

특정 쿼리로 인해 발생할 수 있는 동시성 문제를 해결한다기보다 매일 정해진 스케줄러로 인한 문제를 예방하기 위한 것이므로 특정 서버 1개만 스케줄러를 실행해야 하는 상황이다. 즉 네임드 락을 적용하고자 한다.

 

Spring Data JPA를 사용하는 방법과 Redis를 사용하는 방법이 있는데, 기존에 Redis도 ECS 서비스로 운영하고 있기 때문에 Redis를 사용하는 방법을 선택했다.

 

 

1. 의존성 추가

dependencies {
    // Redisson
    implementation 'org.redisson:redisson-spring-boot-starter:3.27.0'
}

 

 

2. Redisson 빈 등록 (RedissonConfig)

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Value("${spring.data.redis.password}")
    private String password;
    
    // 중략

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://" + host + ":" + port)
            .setPassword(password);
        return Redisson.create(config);
    }
}

 

기존에 사용하던 RedisConfig에 RedissonClient를 추가한다.

 

3. 분산 락을 위한 RedisLockUtil

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisLockUtil {

    private final RedissonClient redissonClient;

    /**
     * 분산 락을 활용하여 특정 작업 실행
     *
     * @param lockKey   락 키 (ex: "scheduler_lock")
     * @param waitTime  락을 기다리는 시간 (초)
     * @param leaseTime 락 유지 시간 (초)
     * @param task      실행할 작업 (람다식으로 전달)
     */
    public <T> void executeWithLock(String lockKey, long waitTime, long leaseTime, Supplier<T> task) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            if (lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) {
                try {
                    task.get();
                } finally {
                    lock.unlock();
                }
            } else {
                log.info("다른 서버에서 실행 중이므로 종료");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

 

코드 중복을 막고자 분산 락 관련 메소드가 있는 RedisLockUtil을 추가한다.

 

4. 스케줄러에 적용

@Slf4j(topic = "Operating Time Scheduler")
@Component
@RequiredArgsConstructor
public class OperatingScheduler {
    private final OperatingService operatingService;
    private final RedisLockUtil redisLockUtil;
    
	@Scheduled(cron = "0 0 0 * * *") // 매일 자정마다
	@EventListener(ApplicationReadyEvent.class)
	public void updateOperatingTime() {
    	setState();
    	log.info("운영 시간 업데이트");

    	redisLockUtil.executeWithLock("lock", 1, 300, () -> {
        	operatingService.updateOperatingTime(dayOfWeek, isHoliday, isVacation, isEvenWeek);
        	return null;
    	});
	}
}

 

기존에 사용하던 스케줄러 코드에 분산 락을 적용해준다.

 

5. 테스트 코드

  • 락 획득 여부 확인
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class RedissonLockTest {

    @Autowired
    private RedissonClient redissonClient;

    @Test
    public void testLockAcquisition() throws InterruptedException {
        String lockKey = "test_lock";
        RLock lock = redissonClient.getLock(lockKey);

        // 락을 획득할 수 있는지 확인
        boolean isLocked = lock.tryLock(1, 5, TimeUnit.SECONDS);
        assertThat(isLocked).isTrue(); // 정상적으로 락을 획득해야 함

        // 락 해제
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

 

  • 다중 스레드 테스트
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class RedissonMultiThreadTest {

    @Autowired
    private RedissonClient redissonClient;

    private final String lockKey = "test_lock";

    @Test
    public void testConcurrentLocking() throws InterruptedException {
        int threadCount = 5;
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.execute(() -> {
                RLock lock = redissonClient.getLock(lockKey);
                try {
                    if (lock.tryLock(1, 5, TimeUnit.SECONDS)) {
                        System.out.println("--- 락 획득: " + Thread.currentThread().getName());
                        Thread.sleep(1000); // 1초 동안 락 유지
                    } else {
                        System.out.println("--- 락 획득 실패: " + Thread.currentThread().getName());
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    if (lock.isHeldByCurrentThread()) {
                        lock.unlock();
                        System.out.println("--- 락 해제: " + Thread.currentThread().getName());
                    }
                    latch.countDown();
                }
            });
        }

        latch.await(); // 모든 스레드가 종료될 때까지 대기
        executorService.shutdown();
    }
}

'Spring & JPA' 카테고리의 다른 글

고급 매핑 - 1. 상속 관계 매핑  (0) 2025.03.19
연관관계  (0) 2025.03.17
[고대로] static 변수와 영속성 컨텍스트  (0) 2024.08.17
[고대로] 운영 시간 관리  (0) 2024.08.17
알람 기능 구현 방법  (0) 2024.05.05