Redis 분산 락(Distribution Lock)으로 동시성 문제 해결하기(+ AOP)

2024. 8. 18. 01:21·Backend/프로젝트
목차
  1. Redis를 활용한 분산 락(Distributed Lock) 구현하기
  2. Gradle 설정 추가
  3. RedissonConfig.java
  4. DistributedLock.java
  5. DistributedLockKeyGenerator.java
  6. DistributedLockAop.java
  7. AopForTransaction.java
  8. 테스트
반응형

Redis를 활용한 분산 락(Distributed Lock) 구현하기

분산 락은 분산된 서버에서 공유 자원에 동시에 접근하는 것을 제어하기 위해 사용되는 방식으로, 락에 대한 정보를 어딘가에 공통적으로 보관하고 여러대의 서버들은 이 공통된 보관소를 바라보며 임계 영역에 접근할 수 있는지 확인하게 되는데, 공통된 보관소로는 주로 Redis, Zookeeper, MySQL 등이 사용됩니다. 그 중 Redis를 사용해 분산 락을 구현해보도록 하겠습니다.

 

Lettuce vs Redisson

Redis를 활용한 분산 락에 구현되는 대표적인 RedisClient는 Lettuce와 Redisson이 존재합니다.

  • Lettuce : Spin Lock 방식으로 락에 대한 획득 시도를 지속적으로 수행하며 락에 대한 타임아웃 지정을 할 수 없다.
  • Redisson : pub/sub 방식으로 동작하며 락에 타임 아웃을 지정할 수 있어 레디스 서버의 부하를 줄여줄 수 있고, 별도의 Lock 인터페이스를 제공해 편하게 분산 락을 구현할 수 있다.

이러한 특징을 고려해 Redisson을 이용해 동시성 문제를 해결해보겠습니다.

+분산락을 사용하는 비즈니스에 Redisson의 락을 획득하고 풀어주는 코드들이 추가된다는 단점을 해결하고, 재사용성을 높이기 위해 AOP를 이용해 어노테이션 기반으로 분산락을 구현해보겠습니다.  

 

Gradle 설정 추가

implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'

Redisson을 사용하기 위해 의존성을 추가해줍니다.

 

RedissonConfig.java

@Configuration
public class RedissonConfig {

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

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

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

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

RedissonClient 사용을 위해 Config를 빈으로 등록해줍니다.

 

DistributedLock.java

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 3L;
}

분산락을 통해 동시성 문제를 해결할 메서드에 붙일 어노테이션을 만들어줍니다.

  • key : 락의 이름
  • waitTime : 락을 획득하기까지 대기하는 최대 시간(시간을 초과하면 false 반환)
  • leaseTime : 락의 타임아웃을 설정한다.(시간이 지나면 락이 만료되어 해제됨)
  • timeUnit : 락 설정에 사용될 시간 타입
  • @Target(ElementType.METHOD) : 어노테이션이 적용될 수 있는 대상을 지정
  • @Retention(RetentionPolicy.RUNTIME) : 어노테이션이 유지되는 시점

 

DistributedLockKeyGenerator.java

public class DistributedLockKeyGenerator {
    public static Object generate(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for(int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }
        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

Lock에 사용할 키를 생성하는 클래스로 전달받은 메서드 이름과, 파싱한 값을 통해서 분산 락에 이용할 키 값을 생성합니다.

 

DistributedLockAop.java

@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(kr.doridos.dosticket.global.redis.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

        String key = REDISSON_LOCK_PREFIX + DistributedLockKeyGenerator.generate(signature.getParameterNames(),
                joinPoint.getArgs(), distributedLock.key());
        RLock rLock = redissonClient.getLock(key);

        try {
            if (!rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit())) {
                throw new LockFailException(ErrorCode.LOCK_ACQUISITION_FAILED);
            }
            return aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            throw new LockInterruptedException(ErrorCode.LOCK_INTERRUPTED);
        } finally {
            rLock.unlock();
        }
    }
}

어노테이션을 통해 수행할 AOP 클래스로 어노테이션의 파라미터 값을 가져와 분산락 획득을 시도하고 메서드를 실행합니다.

  1. Lock에 사용될 키를 생성한다.
  2. RLock 인스턴스를 가져온다.
  3. waitTime까지 락 획득을 시도하고 leaseTime이 지나면 잠금을 해제한다.
  4. 종료시 무조건 락을 해제한다.
@Around : 메서드 실행 전 후 특정 로직을 실행할 수 있는 어드바이스를 정의한다. 여기서는 @DistributedLock이 적용된 메서드의 실행 전 후 에 락을 설정하고 해제하는 역할을 수행
ProceedingJointPoint : 대상 메서드에 접근할 수 있도록 해주는 객체
    @DistributedLock(key = "#request.seatIds")
    public RegisterReservationResponse registerReservation(final Long userId, final ReservationRequest request) {
        final List<ScheduleSeat> seats = scheduleSeatRepository.findAllById(request.getSeatIds());
        validateSeatsSize(request.getSeatIds(), seats);
        validateSeatsIsReserve(seats);

        seats.forEach(ScheduleSeat::reserveSeatStatus);

        final Reservation reservation = Reservation.builder()
                .scheduleId(request.getScheduleId())
                .ticketId(request.getTicketId())
                .seats(seats)
                .userId(userId)
                .build();
        reservationRepository.save(reservation);
        return RegisterReservationResponse.of(reservation);
    }

비즈니스 로직에 해당 어노테이션을 적용해줍니다.

 

AopForTransaction.java

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

새로운 트랜잭션을 만들어 로직을 진행하는 클래스입니다. 해당 AOP를 통해서 트랜잭션이 커밋된 후 락이 해제되도록 처리됩니다.

 

트랜잭션 커밋 후 락이 해제되어야하는 이유는 무엇일까요?

  1. 트랜잭션 1이 락을 획득하고 좌석을 조회한다.
  2. 비즈니스로직이 실행되고 락이 반납된다.
  3. 트랜잭션 1이 커밋되기 전에 트랜잭션 2가 반납된 락을 획득한다.
  4. 한 좌석 2개의 예매가 생성되는 대참사가 일어난다.

위와 같이 락의 해제 시점이 트랜잭션 커밋 시점보다 빠른 상황이 발생하게 된다면 데이터 정합성이 깨지는 상황이 발생할 수 있습니다. 그렇기 때문에 분산락을 통해 동작하는 로직을 새로운 트랜잭션 내에서 수행하도록 변경하게 되면 락을 반납하기 전 트랜잭션의 커밋이 이루어져 데이터의 정합성을 지킬 수 있습니다.

테스트

100개의 스레드가 동시에 1번 좌석에 예매를 시도한다고 가정하고 테스트를 진행해보도록 하겠습니다.

 @Test
    void 티켓예매시_좌석_동시성_테스트를_진행한다() throws InterruptedException {
        List<Long> seatIds = List.of(1L);
        ReservationRequest request = new ReservationRequest(1L, 1L, seatIds);

        int threadCount = 100;

        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    reservationService.registerReservation(1L, request);
                }  catch (Exception e) {
                    e.printStackTrace();
                } finally{
                    latch.countDown();
                }
            });
        }
        executorService.shutdown();
        latch.await();

        List<Reservation> reservations = reservationRepository.findAll();
        Assertions.assertThat(reservations.size()).isEqualTo(1);
    }

테스트 코드를 돌려보면 정상적으로 한 건의 예매만 성공하는 것을 볼 수 있습니다.

 

참고

https://helloworld.kurly.com/blog/distributed-redisson-lock/

반응형

'Backend > 프로젝트' 카테고리의 다른 글

nGrinder Docker 설치 및 사용방법  (1) 2024.10.25
[스프링] 토스 간편결제 기능 구현하기  (0) 2024.09.10
스프링에서 동시성 문제 해결하기(낙관적 락, 비관적 락)  (0) 2024.08.14
QueryDsl을 활용한 카테고리별 조회(페이징) 기능 만들기  (0) 2024.07.30
Github Actions를 이용한 CI 구성  (0) 2024.07.10
  1. Redis를 활용한 분산 락(Distributed Lock) 구현하기
  2. Gradle 설정 추가
  3. RedissonConfig.java
  4. DistributedLock.java
  5. DistributedLockKeyGenerator.java
  6. DistributedLockAop.java
  7. AopForTransaction.java
  8. 테스트
'Backend/프로젝트' 카테고리의 다른 글
  • nGrinder Docker 설치 및 사용방법
  • [스프링] 토스 간편결제 기능 구현하기
  • 스프링에서 동시성 문제 해결하기(낙관적 락, 비관적 락)
  • QueryDsl을 활용한 카테고리별 조회(페이징) 기능 만들기
여포개발자
여포개발자
여포개발자
어제보다 오늘 더
여포개발자
전체
오늘
어제
  • 분류 전체보기 (140)
    • Backend (41)
      • 프로젝트 (18)
      • MSA 전환 (10)
      • spring (6)
      • JPA (7)
    • JAVA (11)
    • Kotlin 정리 (11)
    • 알고리즘 (59)
      • 프로그래머스 LV0 (5)
      • 프로그래머스 LV1 (12)
      • 프로그래머스 LV2 (17)
      • 프로그래머스 LV3 (8)
      • 백준 (14)
      • 소프티어 (3)
    • 네트워크 (3)
    • Docker (3)
    • SQL (5)
    • Kafka (5)
    • 일상 (1)
    • .NET (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • #프로그래머스
  • 프로그래머스LV1
  • 자바 #백준
  • Kotlin
  • 모니터링
  • 백준
  • JPA
  • TroubleShooting #JPA
  • docker #MySQL
  • 네트워크
  • #프로그래머스 #자바
  • #JAVA #프로그래머스
  • 프로그래머스
  • #JAVA #프로그래머스 #LV1 #모두화이팅
  • java
  • JAVA #프로그래머스 #LV0
  • #JAVA #프로그래머스 #LV1
  • 오블완
  • Spring
  • HTTP
  • docker
  • #JPA #JAVA
  • 티스토리챌린지
  • 프로젝트
  • MSA
  • Kotiln

최근 댓글

최근 글

반응형
hELLO· Designed By정상우.v4.5.2
여포개발자
Redis 분산 락(Distribution Lock)으로 동시성 문제 해결하기(+ AOP)
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.