본문 바로가기
프로젝트

GiftFunding) Redisson을 이용한 동시성 이슈 제어

by son_i 2024. 5. 30.
728x90

기본적으로 웹 환경에서는 동시에 여러 요청들이 들어올 수 있고, 스프링 같은 멀티스레드 환경에서는 여러 스레드가 한 자원을 공유할 수 있어 데이터 정합성 문제가 발생할 수 있다.

 

- 친구요청 (A->B)

  친구요청 건(A) 사용자 조회 -> 친구 요청을 받은(B) 사용자 조회 -> 친구 테이블에서 A->B가 친구인지 / 친구 요청을 걸었는지 조회 -> 친구 테이블에서 B->A가 건 요청이 있는지 조회 -> 모든 예외에 걸리지 않으면 친구 테이블에 A->B에게 요청을 건 정보를 저장.

 

ex)  B->A요청이 진행되고 있는 상태에서 A->B 요청이 실행되면 전자 요청이 없는 것으로 조회가 되기 때문에 A->B요청이 정상적인게 되어버림.


Redis를 활용한 분산락을 사용해 동시성 제어를 할 수 있다.

분산락
: 분산된 서버 또는 DB환경에서도 동시성을 제어할 수 있는 방법. 
반드시 Redis를 활용해 분산락을 구현할 필요는 없다. MySQL의 네임드 락을 통해도 구현할 수 있긴 하다.
후자가 메모리 자원을 추가로 사용할 필요가 없는 장점도 가지고 있다. 
그러나 기본적으로 디스크를 사용하는 DB보다 메모리를 사용하는 Redis가 더 빠르게 락 취득 및 해제 할 수 있다.

 

 

Lettuce vs Redisson

Redis 클라이언트로 Lettuce와 Redisson이 존재한다.

 

Lettuce

Spring Data Redis를 사용하면 기본적으로 지원하는 클라이언트는  Lettuce라 좀 더 사용하기 편함.

 

하지만 Lettuce로 분산락을 구현하려면 스핀락의 형태로 구현해야하는 단점이 있음.

락이 이미 사용중이면 락을 획득하기 위해 계속해서 SETNX(SET if Not eXists)라는 명령으로 Redis에 요청을 보낸다. 

 

*SETNX : 특정 키값이 존재하지 않을 경우에 set하라는 명령어. 특정 키에 대해 SETNX명령을 사용하면 값이 없을 때만 세팅하는 , 락을 획득하는 효과를 낼 수 있음.

 

단점

1. 당연히 Redis에 많은 부하를 가하게 한다. 이를 방지하기 위해 Thread.sleep을 통해 락 획득 요청 사이마다 부하를 줄여줘야하고 sleep을 통해 줄여줘도 많은 부하가 감.

2. 스핀락으로 구현하게 될 경우 락의 타임아웃 구현이 자체적으로 존재하지 않기 때문에 코드상에서 직접 구현해야한다.

 -> 구현 안 해주면 락을 영원히 반환 안 할 수도, 락을 획득하지 못해 무한루프를 돌 수도 있음.

 


 Redisson

Redisson은 Lettuce, Jedis와 달리 RLock이라는 Lock 전용 객체 제공.

Redisson은 Lock에 타임아웃을 명시하여 무한정 대기상태로 빠질 수 있는 위험이 없음.

또한 스핀락(Spin Lock)을 사용하지 않고 pub sub 기능을 사용.

  - 락이 해제되면 락을 subscribe(구독)하는 클라이언트들에게 채널로 락이 해제되었다는 신호를 보냄

  - 따라서 락을 subscribe하는 클라이언트들은 락 획득 요청을 redis로 보내지 않고 해제 신호를 받을 때 락을 시도함. 따라서 별도의 retry 로직이 필요없음. 

 

Redisson을 이용하면 Lettuce의 단점이었던 부하 타임아웃을 해결할 수 있다.

 

Redis는 메세지 브로커의 역할을 할 수 있다. (메세지에 대한 publish와 subscribe기능을 지원)
Redisson은 이 기능을 통해 락을 획득 / 해제 하는 로직을 구현하고 있다.

 

*주의 : leaseTime을 잘못 잡으면 작업 도중 Lock이 해제될 수도 있음. => IllgalMonitorStateException

 

* 동작 원리 : 동시에 5개의 스레드가 락 획득을 위해 경합하는 상황

  1. 스레드 1이 락을 획득하고 로직을 처리.

  2. 나머지 2,3,4,5 스레드는 락 획득을 위해 특정 채널을 subscribe하고 있음.

  3. 스레드 1의 로직 처리가 완료되면 락 해제. 

  4. 해제되었다는 메세지를 대기 스레드들이 subscribe하고있는 채널에 publish.

  5. 대기 스레드 2,3,4,5 중 하나가 락을 획득하고 위 과정 반복.

 

-> 이를 통해 키 획득을 위해 계속해서 요청을 보내지 않는 부하 측면에서의 장점이 있음.

 

또, Redisson에서 제공하는 lock 관련 기능은 락의 타임아웃도 구현해놨음.

 

유일한 단점은 Lettuce보다 사용이 어렵다는 것. 그래도 해보겠음


Redisson을 활용한 분산 락 구현

1. 의존성 설정

이미 나는 refresh token에 레디스를 사용하고 있어서 있지만 Spring Data Redis는 기본 클라이언트로 Lettuce를 사용하기 때문에 Redisson 추가적인 의존성을 추가해줘야함. 

 

기존 있던 거

	implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

변경 redisson-spring-boot-starter은 Spring Data Redis의 기능들을 포함하고 있기 때문에 위에 거 없애도 됨.

https://github.com/redisson/redisson/blob/master/redisson-spring-data/README.md

 

redisson/redisson-spring-data/README.md at master · redisson/redisson

Redisson - Easy Redis Java client with features of In-Memory Data Grid. Sync/Async/RxJava/Reactive API. Over 50 Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, De...

github.com

위 링크를 참조해 버전을 맞춰준다.

//Redisson
	implementation("org.redisson:redisson-spring-boot-starter:3.27.2")

 

2. RedissonClient 빈 등록

@Configuration
public class RedissonRepositoryConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

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

    @Bean
    public RedissonClient redissonClient() {

        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);

        return Redisson.create(config);
    }
}

 

락을 사용하는 부분들은 락 획득 - 비즈니스 로직 - 락 해제가 반복됨.

getLock(); //락 획득

try {
  //비즈니스 로직
} finally {
	//락 해제
}

 

그래서 이 부분은 AOP의 @Around로 해결하면 좋을 것 같음 !

Around는 ProceedingJoinPoint.proceed()를 제공해서 proceed 실행 전 / 후에 동작할 로직을 작성할 수 있기 때문이다 !

분산락 관련 기능은 AOP로 작성 !

 

3. 커스텀 어노테이션 RedissonLock 생성

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RedissonLock {
    long waitTime() default 5000; // Lock 획득을 시도하는 최대시간(ms)
    long leaseTime() default 5000L; //Lock 획득 후 점유하는 최대시간(ms)
}

 

4. AOP가 적용될 FriendController의 friendRequest() 메소드에 커스텀 어노테이션 붙여줌

@PostMapping("/request")
    @RedissonLock
    public ResponseEntity<FriendRequest.Response> friendRequest(
        @RequestBody FriendRequest.Request request,
        @AuthenticationPrincipal UserAdapter userAdapter) {

        return ResponseEntity.ok(friendService.request(request, userAdapter));
    }

 

 

5. LockService 클래스 생성 (lock(), unlock() 메소드)

@Slf4j
@Service
@RequiredArgsConstructor
public class LockService {
    private final RedissonClient redissonClient;
    
    public void lock(String email) {
        RLock lock = redissonClient.getLock(email);
        log.debug("Trying lock for email : {}", email);
        
        try {
            boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
            
            if (!isLock) {
                log.error("==== Lock acquisition failed ====");
                throw new FriendException(ErrorType.FRIEND_REQUEST_LOCK);
            }
        } catch (FriendException e) {
            throw e;
        } catch (Exception e) {
            log.error("Redis lock failed", e);
        }
    }
    
    public void unlock(String email) {
        log.debug("Unlock for email : {}", email);
        redissonClient.getLock(email).unlock();
    }
}

 

 - Redis에 존재하는 RLock 객체로 락을 컨트롤한다.

    RLock을 얻기 위해서는 RedissonClient.getLock() 메소드를 호출해야함.

  + RedissonClient는 getSpinLock을 통해 Lettuce에서 나왔던 스핀락을 얻을 수도 있음.

 

재시도 로직은 Redisson에서 알아서 처리하므로 직접 구현해 줄 필요 없다.

Lock 획득 시도, Lock 획득 성공 후 처리, Lock 획득 실패 시 로직만 구현해주면 된다.

 

* 여기서 Lock의 키 값을 뭘로 해줘야 할 지에 대해 고민이 생겼다.

FriendRequest.Request에는 email 필드가 있고 이 이메일은 요청을 걸 대상 (A->B 의 경우 B)의 이메일이 넘어온다. 이 값을 키 값으로 했을 경우 B->A 요청에서 A의 이메일이 넘어왔을 때는 락을 얻지 못 해도 아무런 제약이 없을 것이다.

락의 키 값으로 구분해서 락을 얻는 건 줄 ? 알았는데 흠 . . 그냥 그 메소드에 걸려있는 거니까 상관이 없나 ????

 

6. LockAopAspect 클래스 생성

@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
public class LockAopAspect {
    private final RedissonClient redissonClient;
    private final LockService lockService;

    @Around("@annotation(com.soeun.GiftFunding.aop.RedissonLock) && args(request)")
    public Object aroundMethod(
            ProceedingJoinPoint pjp,
            FriendRequest.Request request
    ) throws Throwable {
        lockService.lock(request.getEmail());

        try {
            return pjp.proceed();

        } finally {
            lockService.unlock(request.getEmail());
        }
    }
}

어노테이션이 붙은 friendRequest() 메소드 실행 전/후에 락을 획득하고 해제하는 로직이 @Around를 통해 가능해진다.

 

@Aspect : Aspect 역할을 할 클래스 선언

 

@Component : 해당 Aspect를 스프링 Bean으로 등록해서 사용하기 위해 선언

 

@Around : Advice 어노테이션으로 괄호 안의 전 후에 처리할 로직을 만들어줘야 함.

 => 어떤 지점에서 Aspect를 발생시킬 것인지 Pointcut을 지정해줘야한다.

 

* Around Advice는 ProceedingJoinPoint를 파라미터로 받아 실행할 메소드의 시점을 조절하거나, 각종 정보들을 받아올 수 있음.

 

  - @annotation(~~) : Around 괄호 안에 만든 어노테이션을 패키지 이름까지 넣어줌.

      커스텀 어노테이션이 붙은 메소드의 전, 후에 @Around 가 붙은 메소드가 실행됨. 

  - args(request) : request를 인자로 가져와서 UseBalance, CancelBalance의 request에서 email을 가져올 수 있도록 함.

 

 

pjp.proceed() : AOP를 걸어줬던 부분 (@RedissonLock이 걸린 부분) 을 동작시킴.

 

finally로 AOP 걸어준 부분이 성공하든 실패하든 Lock을 해제해서 반납하도록 함.