[Redis] 분산락 통한 동시성 제어

KyuWon Kim·2023년 5월 3일
0
post-thumbnail

문제 상황

리액트 컴포넌트가 두 번 렌더링되면서 거의 동시에 서버로 두 개의 요청이 도착했다. 그러나 나의 멀티스레드 서버는 이를 처리하지 못해 회원가입이 두 번 발생하는 문제가 생겼다. 싱글 스레드로 변경하면 서버 한 대로 충분한 성능이 보장될지 의문이 들었다. 그래서 찾아낸 해결책은 레디스(Redis) 분산 락이었다. 이제 분산 락에 대해 알아보자.

분산락

간단히 말하자면, 분산 락은 여러 서버에서 공유된 데이터를 관리하기 위해 사용되는 기술이다. 일반적으로 마이크로서비스 아키텍처(MSA)에서 하나의 자원에 대한 접근을 제어하기 위해 동시성 제어를 위한 락이 사용된다. 예를 들어, 서버 1과 서버 2가 동일한 재고에 접근하려 할 때, 서버 1이 락을 성공적으로 얻으면 서버 2는 락이 해제될 때까지 접근할 수 없는 방식으로 작동한다.

다른 대안은 무엇이 있을까?

1. 자바 @Synchronized

멀티스레드 환경에서 사용되는 방법으로, 공유된 자원에 대한 접근을 제한하는 방식이다. 이 방식은 한 번에 하나의 자원만 접근이 허용된다. 하지만 이 방법은 하나의 프로세스 내에서만 작동하므로 MSA 서버에서는 사용할 수 없다.

2. Redis lettuce

스핀락을 제공하는데, 스핀락은 락 점유에 실패하면 계속해서 점유 시도를 반복하는 방식이다. 이 방식은 부하를 유발하기 때문에 선호되지 않는다.

Redisson 분산락

메시지 브로커(Message Broker) 기능을 사용하여 특정 채널에 구독(Subscribe)하고 있다. 따라서 락이 해제되었다는 메시지가 발행되면 즉시 락 점유를 시도하도록 하여 스핀락의 단점을 보완한다 (부하 감소). 이러한 기능은 레디스(Redis)에 이미 구현되어 있다.

의존성 추가

// redis
implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'

테스트 코드

@DisplayName("회원가입 중복요청 테스트")
@SpringBootTest
public class RedisServiceTest extends TestSupport {

    @Autowired
    private AuthService authService;
    @Autowired
    private UserRepository userRepository;

    @Test
    void 같은_socialId_요청() throws Exception {
        // given
        int threadCount = 2;

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

        // when
        IntStream.range(0, threadCount).forEach(e -> executorService.submit(() -> {
                    try {
                        authService.loginUser("deviceToken", "tempSocialId", SocialType.KAKAO);
                    } catch (Exception ex) {
                        System.out.println(ex);
                    } finally {
                        countDownLatch.countDown();
                    }
                }
        ));

        countDownLatch.await();

        // then
        int count = userRepository.findAll().size();
        assertThat(count).isEqualTo(1);

        // 테스트 후처리
        List<User> all = userRepository.findAll();
        for(User u : all) {
            System.out.println("existing user " + u.getUserId());
        }

        userRepository.deleteAll();

    }

    @Test
    void 다른_socialId_요청() throws Exception {
        // given
        int threadCount = 2;

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

        // when
        IntStream.range(0, threadCount).forEach(e -> executorService.submit(() -> {
                    try {
                        authService.loginUser("deviceToken", "tempSocialId"+e, SocialType.KAKAO);
                    } catch (Exception ex) {
                        System.out.println(ex);
                    } finally {
                        countDownLatch.countDown();
                    }
                }
        ));

        countDownLatch.await();

        // then
        int count = userRepository.findAll().size();
        assertThat(count).isEqualTo(threadCount);

        // 테스트 후처리
        List<User> all = userRepository.findAll();
        for(User u : all) {
            System.out.println("existing user " + u.getUserId());
        }

        userRepository.deleteAll();

    }
}

코드

사용자 요청이 오면 사용자 고유 번호로 redis에 락을 걸어둔다.
락이 걸려진 상태에서 요청이 처리되는 사이에 같은 사용자의 요청이 오면 그 요청은 대기하다가 락 실패가 되게 한다. 즉, leaseTime(락 임대 시간) > waitTime(락 대기 시간) 로 설정한다.

@Configuration
public class RedissonConfig {
    @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() {
        RedissonClient redisson = null;
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        redisson = Redisson.create(config);
        return redisson;
    }
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;


    @Around("@annotation(cmc.utils.redisson.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);     // (1)

        String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key());    // (2)

        RLock rLock = redissonClient.getLock(key);    // (3)

        try {
            boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());    // (4)
            if (!available) {
                return false;
            }

            log.info("get lock success {}" , key);
            return aopForTransaction.proceed(joinPoint);    // (5)
        } catch (Exception e) {
            log.warn("get lock fail {}" , key);
            Thread.currentThread().interrupt();
            throw new InterruptedException();
        } finally {
            rLock.unlock();    // (6)
        }
    }
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
    private static final String REDISSON_KEY_PREFIX = "RLOCK_";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;


    @Around("@annotation(cmc.utils.redisson.DistributeLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        DistributeLock distributeLock = method.getAnnotation(DistributeLock.class);     // (1)

        String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key());    // (2)

        RLock rLock = redissonClient.getLock(key);    // (3)

        try {
            boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit());    // (4)
            if (!available) {
                return false;
            }

            log.info("get lock success {}" , key);
            return aopForTransaction.proceed(joinPoint);    // (5)
        } catch (Exception e) {
            log.warn("get lock fail {}" , key);
            Thread.currentThread().interrupt();
            throw new InterruptedException();
        } finally {
            rLock.unlock();    // (6)
        }
    }
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
    String key(); // (1)

    TimeUnit timeUnit() default TimeUnit.MILLISECONDS; // (2)

    long waitTime() default 300L; // (3)

    long leaseTime() default 500L; // (4)
}
@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}
public class CustomSpringELParser {
    public static Object getDynamicValue(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);
    }
}

코드 출처

분산락 코드를 결국엔 거둬낸 이유

락의 임대 시간(leaseTime)을 0.5초로 임의로 설정했다. 그러나 중복 요청이 아닌, 실제로 유의미한 두 개의 요청이 들어온다면 어떨까? 서버에서는 이를 구별할 방법이 없다. 현재 서버는 0.5초 이내에 두 개 이상의 요청이 들어오면 중복 요청으로 판단하고 있다. 이를 구별할 수 없는 상황이므로, 정상 작동을 위해 일단 분산 락 코드를 제거한 상태다. 추후 다시 시도해보고 수정할 필요가 있다.

  • 수정사항 1. 동시성 제어가 정말 필요한 api 에만 걸기
  • 수정사항 2. 락을 통해 요청을 무시하는 방안 말고 이어서 실행하는 방안 고민해보기 - 대신 leaseTime 안에 처음에 온 요청이 해결되도록 시간을 정해야한다. 문제는 이 시간에 대한 통계값이라도 있으면 좋을 것 같다.
profile
블로그 이전 https://kkyu0718.tistory.com/

0개의 댓글