본문 바로가기
Today I Learned 2024. 7. 15.

(24.07.15)[14주차] 프로젝트 중 동시성 제어를 위한 Redisson 활용

많은 요청이 한번에 들어올 경우 DB에 CRUD간 데이터의 일관성, 무결성, 정합성의 DB 데이터 특성을 보호하기 위해서 동시성을 제어할 필요가 있다.

 

쓰레드나 프로세스가 동시에 데이터에 접근하는 것을 방지하기 위해 여러가지 방법이 있지만, Synchronized나 DB Lock은 DB또는 서버등의 환경이 분리됨에 따라 동시성 제어가 불가능할 수 밖에 없다.

 

따라서 DB단이 아닌 싱글 쓰레드인 메모리와 같은 Redis단에서 DB 접근에 관하여 제어를 하면서 동시성 제어를 해야한다.

 

이러한 Redis단에서의 분산된 Lock, Distributed Lock 중 Redisson API를 통해 임계영역을 직접적으로 Redis가 관리할 수 있도록 협의를 하고 구현을 진행함에 있어서 그 과정을 정리했다.

 


Synchronized 의 한계

  • Server가 여러대일 때, 아무리 synchronized 가 되더라도 EC2 가 여러개라도 DB 자원이기 때문에 여러 Server에서 동시에 작동이 되어버림
    • DB 입장에서는 Server갯수만큼 들어오는게됨
  • 하나씩 실행시키기 때문에 매우 느린 속도와 성능

→ 현업에서 많이 쓰이지 않은 부분


Synchronized

  • method 또는 class 위에 선언하면, 단일 쓰레드만 접근 가능
  • 한계점: 서버가 여러대면 동시성 제어가 불가능
    • ex) 여러개의 쓰레드가 있을 경우 A 서버 A Syncnronized Method 접근 (단 1개만) B 서버 A Syncnronized Method 접근 (단 1개만) C 서버 A Syncnronized Method 접근 (단 1개만)
    • → 결국 A Syncnronized Method 에 3개가 동시에 접근을 한 상황 : 동시성 제어 실패
  • 현업에서 따라서 많이 안쓰이는 동시성 제어 방법

DB Lock

  • 한계점: 성능 저하, DB가 여러대면 동시성 제어가 불가능할 수 있음 (Synchronized의 여러 서버 문제와 같음)

Pessimistic Lock 비관적 락

  • Shared Lock, Exclusive Lock ( x-lock)
  • DB 레코드에 직접적으로 잠금을 걸어서 다른 Transaction의 접근을 제한

Optimistic Lock 낙관적 락

  • Version 컬럼을 통해 데이터가 수정되었느지 확인→수정 되었으면(version이 다르다면) rollback 및 예외를 발생 시키고 catch 후 재시도

분산 Distributed Lock

  • DB 단이 아닌 DB에 접근을 할 수 있도록 Critical Section 임계 영역에서 Lock을 부여해서 권한을 제어하는 방식
    • 임계 영역을 Redis가 관리
      • 메모리 기반의 락으로 위의 동시성 제어 문제를 해결한 Lock
      • 싱글 쓰레드 Atomic 으로 멀티쓰레드인 DB와 구별됨

Lettuce

  • Spin Lock(Lock 요청을 계속 돌면서 Redis에게 요청) 방식
  • 한계점: Redis 부하 집중
    • 따라서 많이 사용하지 않는 방식

Redisson

  • pub/sub 방식
    • Lock 이 없을 경우 대기하다가 누군가 lock.unlock()=Lock획득 가능 시점 → Redisson이 Client들에게 획득가능을 알리는 방식
    • Lock을 가지고 싶은 Java 쓰레드(sub)에게 알려주는(pub) 방식
  • Lease Time (최대 대기시간) 설정 가능
    • 모두 무한 대기를 하는 Dead Lock 상태 방지

RedisTemplate

  • Redis 의 싱글 쓰레드 특성을 활용
    • 동시다발적인 요청도 어차피 쓰레드가 하나이기 때문에 어쩔 수 없이 순차적으로 처리하면서 동시성 처리

Redisson 적용

Redisson Client 주입 Config 설정

package com.eight.palette.global.config;

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

@Configuration
public class RedissonConfig {

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

    @Value("${spring.data.redis.port}")
    private String redisPort;
    
    @Value("${spring.data.redis.password}")
    private String password;

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

    public static final Long WAIT_TIME = 30L;

    public static final Long LEASE_TIME = 10L;

    **@Bean
    public RedissonClient redissonClient() {

        Config config = new Config();

        config.useSingleServer()
                .setAddress(REDISSON_PREFIX + redisHost + ":" + redisPort)
                .setPassword(password);

        return Redisson.create(config);**

    }

}
  • Config Redisson 에서 설정을 담당하는 객체
    • application.yml 또는 application.properties에서 연결된 Redis의 Host, Port, Password(있다면) 곳에서 가지고 와서 설정이 가능
  • Redisson을 create() 메서드를 통해서 config 객체로 RedissonClient생성

Redisson 동시성 제어를 위한 적용

@Service
@RequiredArgsConstructor
public class CardService {

    private final CardRepository cardRepository;
    private final ColumnsRepository columnsRepository;
    private final RedissonClient redissonClient;

    **private static final String LOCK_KEY = "cardLock";**

    public CardResponseDto createCard(Long columnId, CardRequestDto requestDto) {

        **RLock lock = redissonClient.getFairLock(LOCK_KEY);**

        try {
            boolean isLocked = lock.**tryLock**(RedissonConfig.WAIT_TIME, RedissonConfig.LEASE_TIME, TimeUnit.SECONDS);
            if (isLocked) {
                try {
                    
                    ...

                    Card card = Card.builder()
                            .title(requestDto.getTitle())
                            .content(requestDto.getContent())
                            .deadLineDate(requestDto.getDeadLineDate())
                            .worker(requestDto.getWorker())
                            .columnInfo(columnInfo)
                            .status(Card.Status.ACTIVE)
                            .position(position)
                            .build();

                    cardRepository.**save**(card);

                    return new CardResponseDto(card);
                } **finally** {
                    **lock.unlock();**
                }
            } else {
                throw new BadRequestException("다시 시도해 주세요.(Lock 얻기 실패)");
            }
        } catch (InterruptedException e) {
            **Thread.currentThread().interrupt()**;
            throw new BadRequestException("다시 시도해 주세요.(작업 실패)");
        }

    }
  • getFairLock(LOCK_KEY) 을 통해서 미리 지정한 Lock Key(아무거나 지정해도 가능)로 Redis Fair Lock을 일단 생성 → RLock 객체가 이를 구현
    • 생성된 이 Lock이 순서대로 Redis가 Lock을 획득할 수 있도록 진행
    lock.lock();
    try {
        // 로직 진행
    } finally {
        lock.unlock();
    }
    
    • 기본적으로 RLock 객체를 lock() unlock() 을 시킬 수 있음
  • tryLock() 메서드로 sub입장에서 Lock을 부여받기 위해 대기하는 시간, Lock을 보유하기 위한 시간 을 설정이 가능
    • 일련의 이러한 상황은 프로세스 간 또는 요청 간 Lock을 무한히 가지거나 무한히 기다리게되는 교착 상태인 Dead Lock 상태를 막을 수 있도록 함
  • unlock() 을 위해서는 로직이 진행된 후ㅜ 무조건 진행시킬 수 있도록 try-finally 문 을 ****사용
  • Thread.currentThread().interrupt();
    • 위에서는 구현이 되어있지 않지만, 현재 쓰레드가 방해될 경우 intrerrupt 상태로 만들어 이 상태를 따로 처리해야하지만, 편의상 위 로직에서는 예외를 던지면서 종료하도록 함
    • 장기적으로 이러한 쓰레드가 지속적으로 진행이될 때 사용이 되는 방식

테스트

  • Card 객체를 save 메서드를 병렬로 실행을 가정
    • Position 필드는 특정 칼럼과 연관관계가 있는 DB상 모든 Card  데이터의 수 +1 이므로 데이터가 추가가 될 때마다 +1 되면서 position 이 부여가 되어야함

Redisson 미적용

  • 순서대로 Card를 생성되었다면 position 필드가 순서대로 1 부터 Position이 적용되어야했으나 마구잡이로 씹히거나 중복이 되어서 저장

Redisson 적용

  • 시간순으로 순서대로 Position이 등록이 되는 것으로 볼 수 있음