Blog

Redis Lock으로 찜 토글 요청의 동시 접근 제어하기

같은 찜이 두 번 저장됐습니다

찜 API는 하나였습니다. 이미 찜한 병원이면 취소하고 아니면 새로 추가하는 방식이었습니다. 찜 테이블에는 회원 ID와 병원 ID를 저장했습니다.

그런데 같은 회원과 대상의 조합이 중복 저장됐습니다.

약 0.2초 간격으로 중복 생성된 찜 레코드

약 0.001초 간격으로 중복 생성된 찜 레코드

생성 시각을 보면 첫 사례는 약 0.2초, 두 번째 사례는 약 0.001초 차이가 납니다.

조회와 저장 사이에 다른 요청이 들어오면

병원과 찜을 조회한 뒤 생성 또는 삭제하는 기존 흐름

먼저 AnimalHospital을 조회하고 현재 회원에게 연결된 Favorite가 있는지 확인했습니다. 레코드가 있으면 삭제하고 없으면 생성한 뒤 true 또는 false를 반환했습니다.

어디서 중복이 생길 수 있을까요? 한 요청이 “찜이 없다”고 확인하고 아직 저장하지 않은 사이에 다른 요청도 같은 조회를 할 수 있습니다. 그러면 두 요청 모두 찜을 새로 만들어야 한다고 판단합니다. 각 요청만 놓고 보면 문제가 없어 보이지만 동시에 실행되면 같은 찜이 두 번 저장됩니다.

찜 조회와 상태 판단 및 변경이 이어지는 동시성 제어 대상 영역

그럼 조회할 때 락을 걸면 될까요?

조회한 찜이 있으면 그 행을 잠그는 쪽으로 생각할 수 있습니다. 그런데 이 기능은 찜이 없을 때 새로 생성합니다. 아직 생성되지 않은 찜은 어떻게 보호해야 할까요?

이때 트랜잭션 격리 수준과 락을 다룬 글을 읽으며 팬텀 읽기도 함께 봤습니다. 다른 트랜잭션이 행을 추가하면 같은 조건으로 다시 조회할 때 그 행이 새로 나타나는 현상입니다.

다만 “없는 찜을 어떻게 보호할까”는 팬텀 읽기 하나로 설명하기에는 부족합니다. “없음을 확인하고 생성하기” 사이에 다른 요청이 끼어드는 것이 핵심입니다. 없는 행을 보호하려면 반드시 Serializable을 써야 하는 것도 아닙니다. MySQL InnoDB의 격리 수준 문서처럼 DB와 조회 방식에 따라 잠금 동작이 달라집니다.

격리 수준에 따라 막아 주는 현상과 비용이 다릅니다.

  • Read Committed는 더티 읽기를 막지만 비반복 읽기나 팬텀 읽기까지 모두 막지는 못합니다.
  • Repeatable Read는 더티 읽기와 비반복 읽기를 막습니다. 팬텀 읽기의 실제 동작은 DB 구현과 조회 방식도 봐야 합니다.
  • Serializable은 직렬 실행과 같은 결과를 보장하도록 제어하므로 대기나 트랜잭션 실패에 따른 비용을 고려해야 합니다.

당시에는 Serializable로 처리하면 찜 요청이 앞선 트랜잭션을 기다리는 비용이 걱정됐습니다. 그래서 회원과 병원 조합을 키로 삼아 같은 찜을 다루는 작업끼리 Redis 락을 잡도록 했습니다.

Redis 락은 같은 키와 규칙을 사용하는 요청끼리만 접근을 조정합니다. DB의 Serializable 격리 수준을 대신하지는 못하고 락을 기다리는 비용도 남습니다. 회원·병원 조합에 DB의 유일성 제약을 두는 방법도 검토해야 합니다. 이 락 코드만으로 중복이 완전히 막힌다고 보기는 어렵습니다.

회원과 병원 조합의 Redis 락을 획득한 뒤 찜을 변경하는 흐름

Redisson 락을 별도 서비스로 뒀습니다

멀티 모듈 프로젝트라 Redis 관련 기능은 별도 서비스로 분리했습니다. 당시에는 Redisson 3.39.0을 사용했습니다.

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

락 획득과 해제를 서비스로 분리했습니다. 인터럽트가 발생하면 상태를 복구하고 실패를 반환합니다. 해제할 때는 현재 스레드가 락을 소유하는지 확인합니다.

Java
@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();
        }
    }
}

호출부는 회원 ID와 병원 ID를 조합한 키를 만들었습니다. 최대 5초 동안 획득을 기다리고 획득한 락의 임대 시간은 10초로 지정했습니다. 아래는 원문에 남긴 발췌 코드입니다. 기존 찜 로직과 일부 구문이 생략되어 있어서 독립적으로 컴파일되지는 않습니다.

이 락은 10초 뒤에 풀립니다. DB 작업이 그보다 오래 걸리면 첫 요청이 끝나기 전에 다음 요청이 들어올 수도 있습니다. Redisson 락 문서의 임대 시간 설명도 볼 필요가 있습니다.

아래 finally에서 락을 푸는 시점도 중요합니다. 락을 획득하고 해제하는 사이에 커밋까지 끝나는지 확인해야 합니다. DB 커밋이 끝나기 전에 락을 풀면 다른 요청이 아직 반영되지 않은 상태를 읽을 수 있습니다.

Java
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);
    }
}

락을 얻지 못하면 예외를 던지고, 얻었다면 기존 로직을 실행한 뒤 finally에서 해제하도록 감쌌습니다.