-
Redis Lock을 활용한 동시성 제어Spring/Trouble Shooting 2024. 11. 17. 17:11
프로젝트를 진행하면서 찜 목록을 관리하는 기능에서 사용하는 유저의 ID와 찜 대상의 ID를 저장하는 테이블이 있습니다.
제공되는 API는 찜 상태를 추가/생성, 삭제 하는 형태로 단일 API로 제공하였는데,
유저의 ID와 찜 대상의 ID 이 중복된 상태로 저장되는 문제가 발생하였습니다.
제일 좌측에서 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해당 블로그 글을 참고해서 생각을 해봤습니다.
1. 읽기 커밋 (Read Committed): 이 수준은 더티 리드는 방지하지만 팬텀 리드를 방지 X
2. 반복 가능한 읽기 (Repeatable Read): 이 수준은 더티 리드와 비반복 읽기는 방지하지만 팬텀 리드 방지 X
3. 직렬화 (Serializable): 모든 동시성 문제(더티 리드, 비반복 읽기, 팬텀 리드)를 방지하지만, 성능 저하가 발생
결국 새로운 레코드에 대한 락을 걸어주기에는 3번 Serializable 방식을 사용해야 하는데 찜 기능 하나에서 이전 트랜잭션을 기다려야한다...? 성능 저하 문제가 발목을 잡았습니다.그렇다면 Serializable 방식의 수준의 동시성을 제어할 수 있으면서 성능을 고려할 수 있는 방식은 무엇인가?
바로 Redis Lock 제어 방식이 있습니다.API 요청시 바로 락을 획득하는 과정을 통해 동일한 리소스에 대해 하나의 접근만 허용할 수 있으므로 문제를 해결할 수 있습니다.
그렇다면 프로젝트 내에 코드로는 어떻게 적용을 했는지 기록을 해두자면
멀티 모듈 환경으로 Redis 관련 기능을 따로 분리를 할 필요가 있었습니다.
라이브러리는 버전을 참고해서 적용하면 됩니다.https://github.com/redisson/redisson
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로 감싸서 프로젝트 코드에 적용할 수 있었습니다.