woowacourse-teams / 2022-mo-rak

🥳 모락: 모임을 즐겁게, 편하게!
https://mo-rak.com
50 stars 6 forks source link

feat: 레디스 분산락을 이용하여 동시성을 제어한다. #613

Closed leo0842 closed 1 year ago

leo0842 commented 1 year ago

Close #612

배경 지식

분산락

DB에서 제공하는 락은 보통 레코드나 테이블과 같은 자원에 대해 락을 걸지만 분산락에서는 로직, API 등과 같은 자원에 접근하려는 대상에 대해 락을 건다.

네임드락

MySQL 에서 지원하는 분산락이다. select get_lock(), select release_lock() 메서드를 통해 네임드 락을 설정할 수 있다. 락에 대한 정보는 DB 내부 다른 테이블에 저장된다. 추가 리소스를 소모할 필요없이 기존에 존재하는 MySQL DB로 분산락을 설정할 수 있다는 장점이 있지만, 락을 획득하고 제거하는 쿼리가 매번 발생하여 DB에 불필요한 부하가 발생할 수 있다. 또한 DB 서버가 분산된다면 락 정보를 일치시키기 위한 추가 로직이 발생한다.

레디스

Redis는 "키-값" 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈 소스 기반의 비관계형 데이터베이스 관리 시스템이다. 저장소, 캐시, 메세지 브로커 등으로 사용되며, 보통 메모리 캐싱 저장소로 활용된다. 기본적으로 디스크를 사용하는 DB보다 메모리를 사용하는 Redis가 더 빠르게 락을 획득 및 해제할 수 있고 휘발되기때문에 가볍다는 장점이 있다. 스프링에서 활용할 수 있는 레디스 라이브러리로 Lettuce 와 Redisson 이 있다.

  1. Lettuce Lettuce 는 spring redis 의 기본 구현체라서 쉽게 이용할 수 있는 장점이 있다. 하지만 Lettuce 에서는 분산 락을 구현하려면 반드시 스핀 락의 형태로 구현해야 한다는 단점이 있다.

  2. Redisson Redisson은 기본 spring redis 에 추가적인 의존성이 필요하다. 하지만 Lettuce처럼 스핀 락으로 락 획득 요청을 보내지 않고 메시지 브로커 기능을 통해 락을 획득하는 로직을 구현하고 있어 Lettuce 가 가지는 단점을 해결할 수 있다.

결론: 레디스 분산락을 이용하고, 레디스 라이브러리로는 Redisson 을 이용하였다.

상세 내용

  1. facade 패턴

락을 획득하고 해제하는 로직이 서비스 계층 메서드 내에 있으면 트랜잭션 내에서 락을 해제하게 된다.

image

이로 인해 커밋 시점이 락 해제 이전에 있어 동시성 문제가 여전히 발생하기때문에, 트랜잭션 외부에서 락 획득 로직과 해제 로직이 실행되도록 facade 클래스를 만들어 주었다.

@Service
@RequiredArgsConstructor
public class AppointmentFacade {

    private final RedissonClient redissonClient;

        public void selectAvailableTimesWithFirstComeRedis(String teamCode, long memberId, String appointmentCode,
                                                       List<AvailableTimeRequest> requests) {
            RLock lock = redissonClient.getLock(String.format("firstCome:%s", appointmentCode));
            try {
                appointmentService.selectAvailableTimesWithFirstCome(teamCode, memberId, appointmentCode, requests);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                lock.unlock();
            }
      }
}

하지만 기능이 추가될수록 같은 facade 가 계속 추가되기때문에 확장에 불리하다. 그래서 AOP 를 적용해보았다.

  1. AOP 적용
@Component
@RequiredArgsConstructor
@Aspect
@Order(value = 1)
public class DistributedLockAspect {

    @Around("distributedLockPointCut()")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        String code = (String) joinPoint.getArgs()[2];
        RLock rLock = redissonClient.getLock(String.format("lock:%s", code));
        DistributedLock lock = getDistributedLock(joinPoint);
        try {
            boolean available = rLock.tryLock(lock.waitTime(), lock.leaseTime(), lock.timeUnit());
            return joinPoint.proceed();
        } catch (final Exception e) {
            Thread.currentThread().interrupt();
            throw new InterruptedException();
        } finally {
            rLock.unlock();
        }
    }
}

락을 획득하는 로직과 해제하는 로직을 어드바이스에서 실행하도록 하였다. 그리고 포인트컷으로 생성한 DistributedLock 어노테이션이 있는 메서드를 지정하였다.

@Order 어노테이션은 빈 등록 순서를 지정할 수 있다. 서비스 계층 메서드 실행 시 트랜잭션과 분산락 로직이 AOP 로 같이 실행되는데, 트랜잭션보다 범위를 크게 하기 위해 먼저 빈으로 등록되도록 하였다.

cjlee38 commented 1 year ago

아 그리고 한가지 더 고민해볼 점은, 비관락을 사용했을 때 의 단점은 성능저하가 크게 일어난다는 점인데요. 그 이유는 하나의 자원에 만약 100명이 접근한다면, 1명이 자원을 점유하고 있는 동안 99명은 해당 자원에대한 접근이 불가능하고, 대기해야하는 문제 때문인 것으로 알고있어요.( 자체적인 오버헤드에 대해서는 잘 모르겠네요. 거의 없다시피 할 것 같은데)

그런데 같은 맥락을 분산락에도 적용할 수 있지 않나 싶어서요. 분산락도 다른 요청이 처리되지 못하고 대기되도록 만들고, 심지어 메소드와 같은 행위 단위로 락을 잡다보니, 락을 잡는 행위와 같은 오버헤드를 차치하고나면 결국 같은 맥락으로 성능저하가 발생하지 않을까요?

그렇다면 동시성 관점에서 언제 비관락을 사용하고, 언제 분산락을 사용해야 할까요? 그리고 이 count 를 해결하는 과정에 있어 분산락을 사용한 이유는 무엇인가요 ? 이 부분을 알려주시면 좋을 것 같습니다!

leo0842 commented 1 year ago

아 그리고 한가지 더 고민해볼 점은, 비관락을 사용했을 때 의 단점은 성능저하가 크게 일어난다는 점인데요. 그 이유는 하나의 자원에 만약 100명이 접근한다면, 1명이 자원을 점유하고 있는 동안 99명은 해당 자원에대한 접근이 불가능하고, 대기해야하는 문제 때문인 것으로 알고있어요.( 자체적인 오버헤드에 대해서는 잘 모르겠네요. 거의 없다시피 할 것 같은데)

그런데 같은 맥락을 분산락에도 적용할 수 있지 않나 싶어서요. 분산락도 다른 요청이 처리되지 못하고 대기되도록 만들고, 심지어 메소드와 같은 행위 단위로 락을 잡다보니, 락을 잡는 행위와 같은 오버헤드를 차치하고나면 결국 같은 맥락으로 성능저하가 발생하지 않을까요?

그렇다면 동시성 관점에서 언제 비관락을 사용하고, 언제 분산락을 사용해야 할까요? 그리고 이 count 를 해결하는 과정에 있어 분산락을 사용한 이유는 무엇인가요 ? 이 부분을 알려주시면 좋을 것 같습니다!

저도 분산락을 적용하기 전에 비관락을 고려하였습니다! 현재 발생하는 동시성 문제가 Check-Then-Act 패턴 에 해당하므로 check(select) 시에 통제를 해야하는 상황이고, 로직 시작에서 select 를 하기때문에 다른 쓰레드에서 대기해야 하는 상황이 필연적으로 발생한다고 생각했습니다. 적은 리소스로 쉽게 해결할 수 있는 비관락을 적용하지 않은 이유는 크게 2가지 문제입니다.

  1. 말씀해주신대로 만약 동시에 100명이 요청한다면 DB 서버에 부하가 발생합니다. 이 경우 현재 hikari db connection 수가 10개인데, 레디스의 분산락을 적용한다면 커넥션을 물기 전에 메모리에서 락을 잡고 있기때문에 1개만 커넥션을 물고 있는 상태이고, 99개는 어플리케이션 단에서 대기중입니다. 하지만 비관락은 10개 모두 커넥션을 물고 있고 9개가 db 커넥션을 문 상태로 대기하기때문에 db 서버에 부하가 크게 발생하게 됩니다. 또한 이 상태에서 다른 api 로 요청이 온다면 이미 모두 10개의 커넥션을 물고 있기때문에 이와 연관없는 api 에서도 커넥션을 얻기 위해 대기를 하게 됩니다. 하지만 레디스 분산락이라면 나머지 99개는 어플리케이션 단에서 대기중이기때문에 커넥션에 여유가 있어 다른 api 에서도 커넥션을 얻을 수 있기때문에 더 효율적이라고 판단하였습니다.

  2. 동시에 요청하지 않더라도 레코드에 배타락을 쥐고 있기때문에 다른 쿼리에서 해당 레코드에 연산을 할 수 없습니다. 투표 진행 api 에서 1번 투표에 대해 배타락을 쥐고 있을 때, 다른 api 에서 1번 투표의 제목을 수정하는 쿼리가 발생하는 요청이 발생할 수 있습니다. 이 때 제목을 수정하는 요청은 업데이트하는 요청이기때문에 배타락을 획득하려고 시도하게 되고 투표 진행 api 에서 쥐고 있는 배타락에 의해 대기하게 됩니다. 이러한 상황은 앞선 예시와 마찬가지로 투표 진행 api 에 의해 이와 연관없는 다른 api 가 대기를 하게 되는 상황이 발생하게 됩니다. 하지만 레디스 분산락을 이용하면 해당 레코드에 대해 배타락을 획득하지 않기때문에 투표 진행 api 와 투표 제목 변경 api 가 동시에 진행될 수 있습니다(물론 두 api 모두 update 시에는 배타락을 획득하기 위해 하나의 쓰레드는 대기하는 상황이 발생할 수 있습니다).

위와 같은 상황을 고려하여 비관락 대신 레디스 분산락을 적용하였습니다.

제가 판단하는 비관락을 사용해야 하는 상황은 결제 등과 같이 레코드에 대해 엄격한 통제가 이루어져야 하는 상황이거나, 새로운 리소스를 소모할 필요 없이 db 서버의 성능이 우수하다면 비관락을 사용하는 것이 좋다고 생각합니다!

cjlee38 commented 1 year ago

아 그리고 한가지 더 고민해볼 점은, 비관락을 사용했을 때 의 단점은 성능저하가 크게 일어난다는 점인데요. 그 이유는 하나의 자원에 만약 100명이 접근한다면, 1명이 자원을 점유하고 있는 동안 99명은 해당 자원에대한 접근이 불가능하고, 대기해야하는 문제 때문인 것으로 알고있어요.( 자체적인 오버헤드에 대해서는 잘 모르겠네요. 거의 없다시피 할 것 같은데) 그런데 같은 맥락을 분산락에도 적용할 수 있지 않나 싶어서요. 분산락도 다른 요청이 처리되지 못하고 대기되도록 만들고, 심지어 메소드와 같은 행위 단위로 락을 잡다보니, 락을 잡는 행위와 같은 오버헤드를 차치하고나면 결국 같은 맥락으로 성능저하가 발생하지 않을까요? 그렇다면 동시성 관점에서 언제 비관락을 사용하고, 언제 분산락을 사용해야 할까요? 그리고 이 count 를 해결하는 과정에 있어 분산락을 사용한 이유는 무엇인가요 ? 이 부분을 알려주시면 좋을 것 같습니다!

저도 분산락을 적용하기 전에 비관락을 고려하였습니다! 현재 발생하는 동시성 문제가 Check-Then-Act 패턴 에 해당하므로 check(select) 시에 통제를 해야하는 상황이고, 로직 시작에서 select 를 하기때문에 다른 쓰레드에서 대기해야 하는 상황이 필연적으로 발생한다고 생각했습니다. 적은 리소스로 쉽게 해결할 수 있는 비관락을 적용하지 않은 이유는 크게 2가지 문제입니다.

  1. 말씀해주신대로 만약 동시에 100명이 요청한다면 DB 서버에 부하가 발생합니다. 이 경우 현재 hikari db connection 수가 10개인데, 레디스의 분산락을 적용한다면 커넥션을 물기 전에 메모리에서 락을 잡고 있기때문에 1개만 커넥션을 물고 있는 상태이고, 99개는 어플리케이션 단에서 대기중입니다. 하지만 비관락은 10개 모두 커넥션을 물고 있고 9개가 db 커넥션을 문 상태로 대기하기때문에 db 서버에 부하가 크게 발생하게 됩니다. 또한 이 상태에서 다른 api 로 요청이 온다면 이미 모두 10개의 커넥션을 물고 있기때문에 이와 연관없는 api 에서도 커넥션을 얻기 위해 대기를 하게 됩니다. 하지만 레디스 분산락이라면 나머지 99개는 어플리케이션 단에서 대기중이기때문에 커넥션에 여유가 있어 다른 api 에서도 커넥션을 얻을 수 있기때문에 더 효율적이라고 판단하였습니다.
  2. 동시에 요청하지 않더라도 레코드에 배타락을 쥐고 있기때문에 다른 쿼리에서 해당 레코드에 연산을 할 수 없습니다. 투표 진행 api 에서 1번 투표에 대해 배타락을 쥐고 있을 때, 다른 api 에서 1번 투표의 제목을 수정하는 쿼리가 발생하는 요청이 발생할 수 있습니다. 이 때 제목을 수정하는 요청은 업데이트하는 요청이기때문에 배타락을 획득하려고 시도하게 되고 투표 진행 api 에서 쥐고 있는 배타락에 의해 대기하게 됩니다. 이러한 상황은 앞선 예시와 마찬가지로 투표 진행 api 에 의해 이와 연관없는 다른 api 가 대기를 하게 되는 상황이 발생하게 됩니다. 하지만 레디스 분산락을 이용하면 해당 레코드에 대해 배타락을 획득하지 않기때문에 투표 진행 api 와 투표 제목 변경 api 가 동시에 진행될 수 있습니다(물론 두 api 모두 update 시에는 배타락을 획득하기 위해 하나의 쓰레드는 대기하는 상황이 발생할 수 있습니다).

위와 같은 상황을 고려하여 비관락 대신 레디스 분산락을 적용하였습니다.

제가 판단하는 비관락을 사용해야 하는 상황은 결제 등과 같이 레코드에 대해 엄격한 통제가 이루어져야 하는 상황이거나, 새로운 리소스를 소모할 필요 없이 db 서버의 성능이 우수하다면 비관락을 사용하는 것이 좋다고 생각합니다!

👍 👍 👍 👍 완벽한 설명 감사드립니다 :)

seong-wooo commented 1 year ago

전 이걸 이제야 이해했네요

참고하면 더 좋을 것 같은 자료 https://helloworld.kurly.com/blog/distributed-redisson-lock/