Redis 동시성

Junyoung·2024년 8월 13일

이전 포스팅
DB 단계에서 Lock 설정을 통한 비관적락을 통해 병렬 요청으로 인한 동시성을 처리했다.

  1. Lock을 확인하러 가는 과정에 DB에 접근이 불가피하다.

  2. 복잡한 서비스에서 컬럼들에 무자비하게 Lock을 건다면 데드락 상황이 발생할수 있다.

  3. 컬럼이 아닌 로직상에서 Lock을 설정할 수 없다.

이처럼 낙관적락은 데이터의 정합성을 보장하나, 문제점이 여전히 존재한다.

이러한 문제점을 해결하고자 분산락을 적용 해보고자 한다.


분산락이란?
여러 서버에서 공유된 데이터를 효과적으로 제어하기 위해서 사용하는 방법.

즉 데이터의 정합성을 기본적으로 보장하고, 추가적인 로직 설계가 가능하다.

그러면 ? 낙관적락이랑 뭐가 다를까 ?
분산락을 학습하며, 낙관적락이랑 매우 유사하다는 생각이 지속해서 들었다.

개인적인 생각으로는 꼭 컬럼이 아닌 상태나 상황에 Lock 설정이 가능하다.

무엇보다, 여러개의 애플리케이션 서버와 여러개의 DB가 존재할때 발생할수 있는 데드락을 방지할수 있다는 큰 장점이 있다고 생각한다.

위 그림처럼 여러개의 서버 & 여러개의 DB가 존재할때

1번 스프링 서버에서 1번 2번 DB에 순차 접근하고

2번 스프링 서버에서 2번 1번 DB에 순차 접근한다면 ?

같은 member PK 에 연결된 데이터들에 접근한다면?

비관적락의 경우 각자의 트랜잭션에서
1번 스프링서버는 1번 DB의 Lock을 가지고 2번 DB에 접근
2번 스프링서버는 2번 DB의 Lock을 가지고 1번 DB에 접근
-> 데드락 현상이 발생한다 ! ! !


이때 Redis에서 member PK를 가지고 Lock을 관리한다면?

Redis는 싱글 스레드 스택으로 Lock을 관리한다면 1번 스프링 서버의 요청이 끝난뒤에
2번 스프링서버의 로직이 실행된다.


Redisson

그렇다면 어떤 라이브러리 ?

  • Lettuce
    공식적으로 분산락 기능을 제공하지 않으므로, 이를 직접 구현해야 합니다. Lettuce의 락 획득 방식은 스핀락(spin lock)으로, 락을 획득하지 못할 경우 Redis에 반복적으로 요청을 보내는 방식입니다. 이로 인해 Redis에 부하가 발생할 수 있는 단점이 있습니다.

  • Redisson
    락 획득 시 스핀락 대신 pub/sub 방식을 사용합니다. 이 방식에서는 락이 해제될 때, subscribe된 클라이언트에게 락 획득 시도를 알리는 메시지를 보내므로, 락 획득 실패 시 Redis에 지속적인 요청을 보내지 않게 되어 부하를 줄일 수 있습니다. 또한, Redisson은 RLock이라는 인터페이스를 제공해 락을 보다 쉽게 활용할 수 있습니다.


RedissonConfig

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

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

    @Bean
    public RedissonClient redissonClient() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost:6379");
        return Redisson.create(config);
    }
}

PeopleService

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class PeopleService {
    final private PeopleRepository peopleRepository;
    final private RedissonClient redissonClient;

    long waitTime = 5L;
    long leaseTime = 3L;
    TimeUnit timeUnit = TimeUnit.SECONDS;

    @Transactional
    public int plusNumber() {
        // 락 이름 배정
        String lockName = "jun";
        RLock rLock = redissonClient.getLock(lockName);

        try {
            boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
            if(!available){
                throw new RuntimeException();
            }
            //=== 락 획득 후 로직 수행 ===
            People people = peopleRepository.findPeopleByName("jun");
            people.setCount(people.getCount() + 5);
            return peopleRepository.findPeopleByName("jun").getCount();
            // === 로직 수행 완료 ===
        } catch (InterruptedException e) {
            //락을 얻으려고 시도하다가 인터럽트를 받았을 때 발생하는 예외
            throw new RuntimeException(e);
        } finally{
            try{
                rLock.unlock();
            }catch (RuntimeException e){
                //이미 종료된 락일 때 발생하는 예외
                throw new RuntimeException();
            }
        }
    }

    @Transactional(readOnly = true)
    public int getNumber() {
        return peopleRepository.findPeopleByNameWithoutLock("jun").getCount();
    }
}

기존 로직에 Lock을 얻어오는 로직과
Lock을 얻지 못 했을때 락 획득을 시도하는 로직을 추가했다.

헐~!~!~!~!

왜? 5000이 아니지?

오류없이 1000개의 요청이 완료되었지만 결과는 /2 밖에 안되는것을 볼 수 있다.

왜? 그 럴 까 ?

연속된 request에서 +5가 되지 않은 상태로 같은 2450의 결과를 응답으로 받은것을 확인할 수 있다.


락을 돌려준 후 커밋을 진행하기 때문인가?

커밋을 진행하는 동안 조회를 한다면 ?

그러면 락을 놓기전에 트랜잭션 처리를 반드시 해야한다.

그래야 데이터의 정합성을 지킬수있다.

import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Slf4j
public class PeopleService {
    final private PeopleRepository peopleRepository;
    final private RedissonClient redissonClient;

    long waitTime = 5L;
    long leaseTime = 3L;
    TimeUnit timeUnit = TimeUnit.SECONDS;

    @Transactional
    public int plusNumber() {
        // 락 이름 배정
        String lockName = "jun";
        RLock rLock = redissonClient.getLock(lockName);

        try {
            boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
            if (!available) {
                throw new RuntimeException("Unable to acquire lock");
            }

            // 트랜잭션 커밋 후 락 해제를 위해 등록
            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
                @Override
                public void afterCommit() {
                    rLock.unlock();
                    log.info("락 반납했다 ~ ");
                }

                @Override
                public void afterCompletion(int status) {
                    if (rLock.isLocked() && rLock.isHeldByCurrentThread()) {
                        rLock.unlock();
                    }
                }
            });
            log.info("락 획득했다 ~ ");
            // === 락 획득 후 로직 수행 ===
            People people = peopleRepository.findPeopleByName("jun");
            people.setCount(people.getCount() + 5);
            return peopleRepository.findPeopleByName("jun").getCount();
            // === 로직 수행 완료 ===

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Transactional(readOnly = true)
    public int getNumber() {
        return peopleRepository.findPeopleByNameWithoutLock("jun").getCount();
    }
}

"TransactionSynchronizationManager" 를 활용하여, 트랜잭션 commit 이후에 Lock을 반납하도록 설정했다.


트랜잭션 커밋 이후에 Lock을 반납 하는것을 로그로써 확인했습니다.

원하던 결과인 5000을 조회해올수 있었습니다.

야호~


시간은 약 8000ms가 소요됬다...

앞선 비관적 락의 소요 시간은 약 4000ms였다.

약 2배의 시간 차이를 보인다.

이처럼 시간 소요가 큰 비관적 락보다 더 많은 시간이 소요된다.


결론

동시성을 해결하고 데이터의 정합성 보장될수록, 로직에는 더 많은 비용과 시간이 소요된다.

따라서 정답이 존재하기 보단, 상황에 맞는 설계가 정답이라고 생각합니다.

많은 서비스가 클라우드에 배포되고 있다. 서버의 갯수는 scale out 되고, 더 많은 트래픽이 발생하고 있는 만큼 동시성문제는 정말 중요하다고 생각합니다.

참조 -

[Redis] Redisson 분산 락을 간단하게 적용해보기

쇼핑몰 재고관리 - 동시성 문제 해결방법 탐구: 낙관락&비관락, 분산락(네임드락, Redisson)

Redis를 활용한 동시성 이슈 제어 : Lettuce vs Redisson

profile
라곰

0개의 댓글