[Spring] Redis(Redisson) 분산락을 활용한 동시성 문제 해결

김강욱·2024년 5월 30일
0

Project-Evertrip

목록 보기
17/19
post-thumbnail

이번 포스팅에서는 Redisson을 사용하여 분산락을 구현해보는 시간을 가지도록 하겠습니다.


🎈 분산락(Distributed Lock)이란?

동일한 자원에 대해 여러 스레드가 동시에 접근하면서 발생하는 동시성 문제를 해결하는 방법 중 하나가 바로 분산락입니다.

즉, 경쟁 상황에서 하나의 공유자원에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성(atomic)을 보장하는 기법입니다.

분산락은 특정 자원에 대해 락을 요청하고, 락을 성공적으로 획득한 프로세스만 자원에 접근하도록 합니다. 자원에 대한 작업이 완료되면 해당 락을 해제하여 다른 프로세스가 자원에 접근할 수 있도록 합니다.



📌 Redisson이란?

Redis에서 분산락을 구현하기 위해 다양한 구현체를 제공하는데(Ex Jedis, Lettuce, Redisson 등) 그 중 하나가 바로 Redisson입니다.

RedissonJava에서 사용되는 Redis 클라이언트입니다. Redisson은 비교적 합리적인 방식으로 Lock 획득 재시도 기능이 구현되어있습니다.

Lettuce스핀락이라고 불리는 일종의 폴링 기법을 활용해서 Lock 획득을 재시도하는 한편 RedissonRedis PUB/SUB 기능을 사용해서 Lock 획득을 재시도합니다.

스핀락(spin lock) 방식 - 사진출처 https://wildeveloperetrain.tistory.com/280

즉, Lock 획득에 실패하면, Redisson은 특정 채널을 구독하고, Lock이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock 획득을 시도합니다. 결과적으로 Lock이 획득될 때까지 계속 Lock 획득을 요청하는 Lettuce보다 효율적이고, Redis 서버에도 부하를 덜 주는 방법이라고 볼 수 있습니다.



😁 분산락 적용해보기

이번 EverTrip 프로젝트에서 게시글의 조회수 증가 로직에 분산락을 적용해보도록 하겠습니다.

build.gradle 설정

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

Redisson 스타터 라이브러리를 의존성 받아옵니다.

RedissonConfig

@Configuration
public class RedissonConfig {

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient(@Value("${spring.data.redis.host}") String host,
                                         @Value("${spring.data.redis.port}") String port,
                                         @Value("${spring.data.redis.password}") String password) {

      
        Config config = new Config();
        config.useSingleServer()
                .setAddress(REDISSON_HOST_PREFIX + host + ":" + port)
                .setPassword(password);


        return Redisson.create(config);
    }

}

Redis 서버의 호스트 주소, 포트 번호, 비밀번호를 설정한 RedissonClient를 스프링 빈으로 등록해줍니다.

Service

public PostResponseDto getPostDetailV2(Long postId, Long memberId) {
        // 레디스에 해당 post가 존재할 시 레디스 정보를 넘겨주고 없을 시 실제 DB 조회 후 레디스에 저장
        PostResponseDto postDetail = postCacheService.getPostDetailUsingCacheable(postId);

        // 조회수(제일 최신)는 Redis에서 조회해서 postDetail의 조회수에 넣어줍니다.
        Long views = postCacheService.getViews(postId).longValue();

        // 방문자 리스트에 해당 사용자가 존재하지 않을 시 방문자 리스트에 추가해주고 조회수 1 증가 시켜주기
        if (!redisForCacheService.isMember(ConstantPool.CacheName.VIEWERS + ":" + postId, memberId.toString())) {
            // Redis에 방문자 명단 추가
            redisForCacheService.addToset(ConstantPool.CacheName.VIEWERS + ":" + postId, memberId.toString());

            // Redisson 락킹 적용
            String lockKey = "lock:viewCount:"+postId;
            RLock lock = redissonClient.getLock(lockKey);

            try {
                // 락을 얻기 위한 재시도(Lock 획득을 시도하는 최대 시간, 락을 획득한 후 점유하는 최대 시간, 시간 단위)
                if (lock.tryLock(5, 2, TimeUnit.SECONDS)) {
                    try {
                        // 수동으로 cacheManager를 통해 redis에 조회수 +1 증가 시켜주기
                        String viewsCacheKey = postId.toString();
                        Cache viewsCache = cacheManager.getCache(ConstantPool.CacheName.VIEWS);
                        Cache.ValueWrapper valueWrapper = viewsCache.get(postId.toString());
                        Long currentViews = ((Integer) valueWrapper.get()).longValue();
                        viewsCache.put(viewsCacheKey, currentViews + 1L);
                        views = currentViews+1L;
                    } finally {
                        lock.unlock();
                    }
                } else {
                    // 락을 얻지 못한 경우 처리
                    log.warn("Could not acquire lock for key: {}", lockKey);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                log.error("Could not acquire lock for key: {}",lockKey);
                throw new ApplicationException(ErrorCode.INTERNAL_SERVER_ERROR);
            }
        }

        postDetail.setView(views);
        return postDetail;
    }

방문자 리스트에 조회를 하는 사용자가 존재하지 않을 시 방문자 리스트에 추가하고 조회수를 1 증가시키는 부분에 Redisson 락킹을 적용한 코드입니다.

게시글의 id를 사용하여 lockKey를 생성하고 RedissonClientgetLock 메서드를 통해 RedissonLock 객체를 얻어옵니다. 해당 Lock 객체의 tryLock, unlock 메서드를 사용하여 락을 획득, 해제할 수 있습니다.

tryLock 메서드의 파라미터는 Lock 획득을 시도하는 최대 시간, Lock을 획득한 후 점유하는 최대 시간, 시간 단위를 넣어줍니다.

락이 걸려있는 동안 다른 사용자는 해당 락이 해제될 때까지 대기하며 락 획득 재시도를 하게 되므로 동시성 문제를 제어할 수 있게 됩니다.



📝 테스트 해보기

분산락이 제대로 적용됐는지 테스트를 해보도록 하겠습니다.

PostServiceTest

@SpringBootTest
@Transactional
public class PostServiceTest {

    @Autowired
    private PostService postService;

    @Autowired
    private RedisForCacheService redisForCacheService;

    @Autowired
    private PostCacheService postCacheService;

    @Autowired
    private CacheManager cacheManager;

    private Long postId;

    @BeforeEach
    public void setUp() {
        postId = 1L;

        // 초기 조회수 설정
        Cache viewsCache = cacheManager.getCache(ConstantPool.CacheName.VIEWS);
        if (viewsCache != null) {
            viewsCache.put(postId.toString(), 0L);
        }

        // 초기 방문자 리스트 비우기
        redisForCacheService.deleteSet(ConstantPool.CacheName.VIEWERS + ":" + postId);
    }

//    @AfterEach
//    public void tearDown() {
//        postId = 1L;
//
//        // 조회수 삭제
//        Cache viewsCache = cacheManager.getCache(ConstantPool.CacheName.VIEWS);
//        viewsCache.evict(postId.toString());
//        
//
//        // 방문자 리스트 비우기
//        redisForCacheService.deleteSet(ConstantPool.CacheName.VIEWERS + ":" + postId);
//    }

    @Test
    public void testConcurrentViewCountIncrement() throws InterruptedException, ExecutionException, TimeoutException {
        int numberOfThreads = 100; // 동시 요청 스레드 수
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        // 여러 스레드에서 동시에 요청 실행
        Future<Void>[] futures = new Future[numberOfThreads];
        for (int i = 0; i < numberOfThreads; i++) {
            int memberId = i + 1;
            Callable<Void> task = () -> {
                postService.getPostDetailV2(postId, (long) memberId);
                return null;
            };
            futures[i] = executorService.submit(task);
        }

        // 결과 대기 및 확인
        for (Future<Void> future : futures) {
            future.get(5, TimeUnit.SECONDS);
        }

        executorService.shutdown();

        // 최종 조회수 확인
        Long finalViews = postCacheService.getViews(postId).longValue();
        assertEquals(numberOfThreads, finalViews); // 모든 요청이 들어와서 조회수가 정확히 증가했는지 확인
    }


}

100개의 스레드에서 동시에 postService.getPostDetailV2(postId, (long) memberId) 메서드를 호출하여 테스트를 진행하였습니다.

테스트 결과 정상적으로 동작하는 것을 확인할 수 있었습니다. Redis에 있는 방문자 리스트와 조회수도 정상인지 확인해보겠습니다.

조회수는 100, 방문자 리스트는 1부터 100까지 정상적으로 들어와있는 것을 확인할 수 있었습니다.



☕ 마치며

이번 포스팅에서는 Redisson을 활용한 분산락을 적용해보았습니다. 개인적으로 동시성 문제를 해결하는 것은 매우 중요하다고 생각하기에 이번 포스팅을 작성하며 매우 유익한 시간을 가졌던 것 같습니다.

참고 자료
Jan92님 블로그의 Redisson 분산락을 사용하는 이유와 기본적인 사용 방법 편
Kai님 블로그의 [Spring] Redis(Redisson) 분산락을 활용하여 동시성 문제 해결하기 편

profile
TO BE DEVELOPER

0개의 댓글

관련 채용 정보