[Spring] Redis(Redisson) 분산락을 활용하여 동시성 문제 해결하기

Kai·2024년 2월 25일
1

Redis

목록 보기
4/4

📌 글에서 사용한 코드 : 깃헙

☕ 개요


이번 글에서는 지난 글에 이어서 동시성 문제를 해결하는 방법에 대해서 이야기해보겠다.
지난 글에서는 Pessimistic Lock, Optimistic Lock을 사용하는 방법에 대해서 알아보았고, 각각이 갖는 한계점에 대해서도 알아보았다.
그래서 이번에는 위 방법들의 한계를 해결할 수 있는 Redis를 이용한 분산락을 구현해보도록 하겠다.


📌 Redisson


Redis에 접근할 수 있는 다양한 클라이언트들이 존재한다. 대표적으로 Jedis, Lettuce, Redisson등이 있다. 나는 그 중에서도 Redisson을 사용해서 분산락을 구현해보도록 하겠다.

Redisson은 비교적 합리적인 방식으로 Lock 획득 재시도 기능이 구현되어 있다.
Lettuce는 '스핀락'이라고 불리는 일종의 폴링 기법을 활용해서 Lock 획득을 재시도하는 한편 Redisson은 Redis의 Pub/sub 기능을 사용해서 Lock 획득을 재시도한다.
즉, Lock획득에 실패하면, Redisson은 특정 채널을 구독하고, Lock이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock획득을 시도하는 것이다. 이는 Lock이 획득될 때까지 계속 Lock획득 요청하는 Lettuce보다 효율적이고, Redis서버에도 부하를 덜 주는 방법이라고 볼 수 있다.

자 이제 Redisson으로 분산락을 구현해보도록 하자.


📒 설정


build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-web'

	implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' // ⭐ Redisson

	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

다른 패키지들은 참고용으로 정리하였고, redisson 스타터 라이브러리를 설치해주자.

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() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost:6379");
        return Redisson.create(config);
    }

}

Redis서버가 localhost:6379에 떠 있다는 가정하에 위와 같이 RedissonClient를 Bean으로 등록해주자.


🔨 기본 구성 요소 생성


Entity

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Entity
@NoArgsConstructor
public class Ticket {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Setter
    private Long quantity;
    
    public static Ticket create(Long quantity) {
        Ticket entity = new Ticket();
        entity.setQuantity(quantity);
        return entity;
    }

    public void decrease(Long quantity) {
        long q = this.quantity - quantity;
        this.quantity = q < 0 ? 0L : q;
    }

}

공연 티켓 또는 영화 티켓과 같은 표를 의미하는 Ticket 엔티티를 만들어주었다.

Repository

import org.springframework.data.jpa.repository.JpaRepository;

public interface TicketRepository extends JpaRepository<Ticket, Long> {}

JpaRepository를 상속받은 기본적인 기능을 갖고 있는 Repository를 만들어주었다.

Service

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import study.springredislock.common.RedissonLock;

@Slf4j
@Transactional
@Service
@RequiredArgsConstructor
public class TicketService {

    private final TicketRepository ticketRepository;

    public void ticketing(Long ticketId, Long quantity) {
        Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
        ticket.decrease(quantity);
        ticketRepository.saveAndFlush(ticket);
    }

    public void redissonTicketing(Long ticketId, Long quantity) {
        Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
        ticket.decrease(quantity);
        ticketRepository.saveAndFlush(ticket);
    }

}

티켓의 수량을 감소하는 역할을 하는 메서드를 갖고있는 서비스를 만들어주었다.
redissonTicketing 메서드는 분산락과 관련된 세팅이 완료되면 아래에서 수정해주겠다.


🤓 분산락 AOP


분산락과 관련된 기능은 AOP로 구현한다. (선택사항)

분산락 어노테이션

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {

    String value(); // Lock의 이름 (고유값)
    long waitTime() default 5000L; // Lock획득을 시도하는 최대 시간 (ms)
    long leaseTime() default 2000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)

}

먼저, 분산락의 사용여부 또는 부가 설정을 할 수 있는 어노테이션을 만들어주자.

분산락 로직

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(study.springredislock.common.RedissonLock)")
    public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock annotation = method.getAnnotation(RedissonLock.class);
        String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());

        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!lockable) {
                log.info("Lock 획득 실패={}", lockKey);
                return;
            }
            log.info("로직 수행");
            joinPoint.proceed();
        } catch (InterruptedException e) {
            log.info("에러 발생");
            throw e;
        } finally {
            log.info("락 해제");
            lock.unlock();
        }

    }

}

재시도 로직은 Redisson에서 알아서 처리하므로 직접 구현해줄 필요 없다. Lock 획득 시도, Lock 획득 성공 후 처리, Lock 획득 실패 시 로직만 구현해주면 된다.

Lock의 고유 키 값(lockKey)은 메서드 이름{ticketId}로 하였다.

커스텀 스프링 표현식 Parser

import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        SpelExpressionParser 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);
    }

}

어노테이션에서 Spring 표현식을 좀 더 디테일하게 사용할 수 있도록 커스텀 파서를 만들어주었다.
특별히 커스텀할 필요가 없다면, 기본적으로 제공되는 SpelExpressionParser를 통해서 스프링 표현식을 파싱해도 무방하다.

Service 수정 : 분산락 적용

	@RedissonLock(value = "#ticketId")
    public void redissonTicketing(Long ticketId, Long quantity) {
        Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
        ticket.decrease(quantity);
        ticketRepository.saveAndFlush(ticket);
    }

위에서 만들어준 Service의 메서드 중, redissonTicketing에 분산락을 적용할 수 있는 어노테이션을 붙여주자.


🧐 테스트 코드


import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
@SpringBootTest
class TicketServiceTest {

    @Autowired
    TicketService ticketService;

    @Autowired
    TicketRepository ticketRepository;

    private Long TICKET_ID = null;
    private final Integer CONCURRENT_COUNT = 100;

    @BeforeEach
    public void before() {
        log.info("1000개의 티켓 생성");
        Ticket ticket = Ticket.create(1000L);
        Ticket saved = ticketRepository.saveAndFlush(ticket);
        TICKET_ID = saved.getId();
    }

    @AfterEach
    public void after() {
        ticketRepository.deleteAll();
    }

    private void ticketingTest(Consumer<Void> action) throws InterruptedException {
        Long originQuantity = ticketRepository.findById(TICKET_ID).orElseThrow().getQuantity();

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

        for (int i = 0; i < CONCURRENT_COUNT; i++) {
            executorService.submit(() -> {
                try {
                    action.accept(null);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        Ticket ticket = ticketRepository.findById(TICKET_ID).orElseThrow();
        assertEquals(originQuantity - CONCURRENT_COUNT, ticket.getQuantity());
    }

    @Test
    @DisplayName("동시에 100명의 티켓팅 : 동시성 이슈")
    public void badTicketingTest() throws Exception {
        ticketingTest((_no) -> ticketService.ticketing(TICKET_ID, 1L));
    }

    @Test
    @DisplayName("동시에 100명의 티켓팅 : 분산락")
    public void redissonTicketingTest() throws Exception {
        ticketingTest((_no) -> ticketService.redissonTicketing(TICKET_ID, 1L));
    }

}

1000개의 티켓을 만들고, 100개의 요청이 동시에 주어졌을 때 100개의 티켓이 잘 감소됐는지를 확인하는 테스트이다.

분산락을 적용하지 않은 테스트 코드에서는 동시성 문제에 의해서 예상한 결과가 나오지 않았고, 분산락을 적용한 테스트 코드는 당연하게도 예상한 대로 잘 티켓이 감소된 것을 확인할 수 있다.

또, 서로 다른 쓰레드가 Lock을 주고 받는(?) 모습 또한 확인할 수 있다.


☕ 마무리


이번 글에서는 Redis를 활용해서 분산락을 구현해보았다.
컬리에서 매우 매우 좋은 글을 써줘서 사실상 거의 그대로 따라해보면서 구현해보았다. ㅎㅎ

컬리, 감사하모니카 🫡


🙏 참고 (Shout out to Kurly)


0개의 댓글