ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Redis Lock을 활용한 동시성 제어
    Spring/Trouble Shooting 2024. 11. 17. 17:11

    프로젝트를 진행하면서 찜 목록을 관리하는 기능에서 사용하는 유저의 ID와 찜 대상의 ID를 저장하는 테이블이 있습니다.

    제공되는 API는 찜 상태를 추가/생성, 삭제 하는 형태로 단일 API로 제공하였는데,

    유저의 ID와 찜 대상의 ID 이 중복된 상태로 저장되는 문제가 발생하였습니다.

    케이스 1
    케이스 2

    제일 좌측에서 1, 2번째가 레코드의 생성 시기인데요.

    케이스 1의 경우 0.2초 사이에 중복으로 요청을 할 경우 발생

    케이스 2의 경우 0.001초 사이에 중복으로 요청을 할 경우 발생 하였습니다.

     

    서버에서 동시성을 제어하지 못했기 때문에 발생한 문제였으므로 이를 해결해보고자 합니다.

    기존 플로우 차트

    문제가 발생한 플로우입니다.
    요청에 대상 AnimalHospital에 대해 조회 후 현재 유저와 매핑되는 Favorite가 있는지 조회합니다.
    만약 레코드가 있다면 삭제, 레코드가 없다면 생성 후 true,false 를 반환합니다.

     

    그렇다면 어디서 문제가 발생한 것일까? 
    AnimalHospitalFavorite 조회 : 여러 스레드가 동시에 조회할 경우, 일관되지 않은 결과를 얻을  있습니다.
     상태 확인 : 조회  상태를 확인하는 동안 다른 스레드가 상태를 변경할  있습니다.
     삭제 또는   생성 : 여러 스레드가 동시에 삭제 또는 생성을 시도할 경우, 데이터 불일치가 발생할  있습니다.

    한 사용자가 동시에 같은 병원을 찜 API를 요청  , "찜이 없음" 확인하고 새로운 찜을 생성하려 있습니다.

    이로 인해 중복 데이터가 생성될 있습니다.

    동시성 문제 발생 가능 영역

    그렇다면 유저가 관여하여 문제가 발생할 수 있는 부분은 회색 네모 내의 로직이라 볼 수 있습니다.

    그럼 API가 들어올 때 Favorite 값에 대한 조회에 락을 걸어주면 해결되는 것 아닌가?

    조회 이후 삭제 또는 생성 과정을 통해  레코드를 생성, 삭제 하는 Insert, Hard Delete 방식이므로 Phantom Read 문제에 대해 고려를 해야합니다.

    Phantom Read : 다른 트랜잭션에서 Insert시 이전 조회에서 나오지 않았던 새로운 데이터가 이후 조회에서 조회되는 현상.



    https://softmoca.tistory.com/345

     

    주문 재고 요청시 동시성 제어테스트 [트랜잭션/격리수준/LOCK/REDIS]

    https://softmoca.tistory.com/346현재 포스팅은 위 포스팅으로 이어 진다.동시성 제어가 필요한 이유현재 kwangsang에서는 마감 시간이 가까워 졌을 때 떨이로 상품을 내놓는 점주 분들과 마감 할인 상품에

    softmoca.tistory.com

    해당 블로그 글을 참고해서 생각을 해봤습니다.

    1. 읽기 커밋 (Read Committed): 수준은 더티 리드는 방지하지만 팬텀 리드를 방지 X
    2. 반복 가능한 읽기 (Repeatable Read): 수준은 더티 리드와 비반복 읽기는 방지하지만 팬텀 리드 방지 X
    3. 직렬화 (Serializable): 모든 동시성 문제(더티 리드, 비반복 읽기, 팬텀 리드) 방지하지만, 성능 저하가 발생

    결국 새로운 레코드에 대한 락을 걸어주기에는 3번 Serializable 방식을 사용해야 하는데 찜 기능 하나에서 이전 트랜잭션을 기다려야한다...? 성능 저하 문제가 발목을 잡았습니다.

    그렇다면 Serializable 방식의 수준의 동시성을 제어할 수 있으면서 성능을 고려할 수 있는 방식은 무엇인가?
    바로 Redis Lock 제어 방식이 있습니다.

     

    API 요청시 바로 락을 획득하는 과정을 통해 동일한 리소스에 대해 하나의 접근만 허용할 수 있으므로 문제를 해결할 수 있습니다.

     

    레디스 락 적용 후 플로우 차트

     

    그렇다면 프로젝트 내에 코드로는 어떻게 적용을 했는지 기록을 해두자면

     

    멀티 모듈 환경으로 Redis 관련 기능을 따로 분리를 할 필요가 있었습니다.

    라이브러리는 버전을 참고해서 적용하면 됩니다.

    https://github.com/redisson/redisson

     

    GitHub - redisson/redisson: Redisson - Valkey and Redis Java client. Complete Real-Time Data Platform. Sync/Async/RxJava/Reactiv

    Redisson - Valkey and Redis Java client. Complete Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Valkey and Redis based Java objects and services: Set, Multimap, SortedSet, Map, L...

    github.com

    implementation 'org.redisson:redisson-spring-boot-starter:3.39.0'

    저는 3.39.0 버전을 사용했습니다.

    @Service
    public class RedisLockService {
        private final RedissonClient redissonClient;
    
        public RedisLockService(RedissonClient redissonClient) {
            this.redissonClient = redissonClient;
        }
    
        public boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit) {
            RLock lock = redissonClient.getLock(lockKey);
            try {
                return lock.tryLock(waitTime, leaseTime, timeUnit);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
    
        public void unlock(String lockKey) {
            RLock lock = redissonClient.getLock(lockKey);
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    이렇게 RedissonClient를 사용하여 락을 얻는 시도를 하는 tryLock 메소드와 락을 해제하는 unLock 메소드를 분리하였습니다.

    public Boolean toggleAnimalHospitalFavorite(Long userInfoId, Long animalHospitalId) {
        String lockKey = "animalHospitalFavorite:" + userInfoId + ":" + animalHospitalId;
        boolean locked = redisLockService.tryLock(lockKey, 5, 10, TimeUnit.SECONDS);
        if (!locked) {
            throw new FailedToAcquireLock();
        }
    
        try {
            //.. 찜 생성, 해제 기존 메소드
            }
        } finally {
            redisLockService.unlock(lockKey);
        }
    }

    락을 획득하기 위한 Key를 생성하고 락 획득의 여부를 체크하여 기존의 코드를 try-finally로 감싸서 프로젝트 코드에 적용할 수 있었습니다.

    댓글

Designed by black7375.