TroubleShooting [ Concorence Issue ] [ SemiTest2 ]

개요

SemiTest2에서 발생한 문제이다. 여러건의 테스트를 진행중 병렬로 요청을 보냈을 때 동시에 한 데이터에 접근하게 되어 NPE가 발생하는 상황이었다.

원인 분석

  1. 동시성 문제로 추측
    1. 다건의 병렬 조회 ( 이상 없음 )

      → JUnit Test코드로 확인

          @Test
          void 다건_동시_조회() {
              IntStream.range(1,10000)
                      .parallel()
                      .forEach(x->{
                          Optional<StockRedis> byId = repository.findById(1L);
                          System.out.println("*******************optional get 하기 전에 ");
                          System.out.println("*******************optionalById : " + byId);
                          System.out.println("*******************optional get 한다먄 ");
                          System.out.println("*******************optionalById.get() : " + byId.get());
                          System.out.println("******************************");
                          System.out.println("******************************");
                          System.out.println("******************************");
                          System.out.println("******************************");
      
                      });
          }
      
      

      → 이후 로그에서 에러를 찾았으나 에러가 없었다.

    2. 다건의 삽입 실행 ( 이상 발견 )

      → JUnit Test코드로 확인

          @Test
          void 다건_동시_재고변경() {
              IntStream.range(1,10000)
                      .parallel()
                      .forEach(x->{
      
                          System.out.println("***************Start**************");
      
                          System.out.println("*******************Stock data 조회");
                          Optional<StockRedis> byId = repository.findById(1L);
      
                          System.out.println("*******************Stock data get 및 재고변경");
                          StockRedis stock;
                          if(x % 2 == 1){
                              stock = byId.get().increaseInventory(1);
                          } else {
                              stock = byId.get().decreaseInventory(1);
                          }
      
                          System.out.println("*******************Stock data 저장 ");
                          repository.save(stock);
      
                          System.out.println("***************END**************");
                      });
          }
      
      
      • 에러로그

      같은 에러로그를 발생시켰다. 기본적으로 병렬 요청 처리라서 확신할 수는 없지만 에러로그가 저장로직에서 발생한 것으로 보인다. ( Print문으로 체크 )

      예상 에러 상황

      • 공통 사용 함수
      Function<Long, StockRedis> Stock조회 = id -> repository.findById(id).get();
      Runnable 조회함수 = () -> repository.findById(1L);
      Consumer<StockRedis> 삽입함수 = stock -> repository.save(stock);
      
      • 동시 조회와 삽입 ( 이상 없음 )

        • 테스트 코드
      • 동시 삽입 ( 이상 없음 )

        • 테스트 코드
      • 갑변경 삽입 ( 이상 발견 )

        • 테스트 코드

        같은 데이터를 병렬적으로 변경하고 처리하다보니 발생한 동시성 문제이다.

      Redis 락 설정 - Redisson -

      Luttuce와 Redisson

      분산락으로 해결하는 동시성 문제(이론편)

      [Redis] Redisson 분산 락을 간단하게 적용해보기

      풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

      레디스의 삽입 동시성 문제

      WebFlux가 단인 스레드로 동작하긴 해도 내부적 일처리 자체는 멀티 스레드를 사용하기 때문에 redis를 사용하는데 동시성 문제가 있었다. 하나의 데이터에 여러 스레드가 접근하면서 NullPointException 이 발생했다.

      Redisson을 사용하게 된 이유

      Redis의 클라이언트 종류는 Lettuce와 Ledis, Jedis 등이 있다. 이전에 공부했을때 Luttuce를 자주 사용한다는 걸 알고있어서 그렇게 구현했었는데 분산락을 적용하다보니 성능적으로 Redisson이 낫다고 판단하여 Redisson을 사용하게 됐다.

      Redisson와 Luttuce의 락방식

      Lettuce는 기본적으로 스핀락 방식을 사용한다. 스핀락이라는게 지속적으로 락이 끝났는지를 체크하는 것이다. 그래서 많은 요청을 처리하는 것을 목적으로 하는 나에게 적절치 않았다. 반면 Redisson은 pub/sub구조를 활용한다. 락이 걸려있다면 해당 락이 끝날 때 까지 sub하고, lock이 끝나면 pub 받는 구조로 비교적 락을 획득하는 구간에서의 성능적 이점이 있었다.

      적용 과정

      Redisson을 적용한다는 의사결정은 금방 했고, 적용과정에서는 약간의 난관이 있긴 했지만 그리 오래걸리진 않았다. 가장 마켓 컬리가 좋은 레퍼런스를 제공하고 있어 대부분의 내용을 그 블로그에서 가져왔다.

      RedissonConfig 설정

      package com.example.fluxexample.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 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;
          }
      }
      
      

      DistributedLock 어노테이션 정의

      package com.example.fluxexample.aop;
      
      import java.lang.annotation.ElementType;
      import java.lang.annotation.Retention;
      import java.lang.annotation.RetentionPolicy;
      import java.lang.annotation.Target;
      import java.util.concurrent.TimeUnit;
      
      /**
       * Redisson Distributed Lock annotation
       * */
      @Target(ElementType.METHOD)
      @Retention(RetentionPolicy.RUNTIME)
      public @interface DistributedLock {
      
          /**
           * 락의 이름
           * */
          String key();
      
          /**
           * 락의 시간 단위
           * */
          TimeUnit timeUnit() default TimeUnit.SECONDS;
      
          /**
           * 락을 기다리는 시간 (default - 2s)
           * 락 획득을 위해 waitTime 만큼 대기한다.
           * */
          long waitTime() default 2L;
      
          /**
           * 락 임대 시간 (default - 3s)
           * 락을 획득한 이후 leaseTime이 지나면 락을 해제한다.
           * */
          long leaseTime() default 3L;
      }
      
      

      DistributedLockAOP 설정

      package com.example.fluxexample.aop;
      
      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;
      
      @Aspect
      @Component
      @RequiredArgsConstructor
      @Slf4j
      public class DistributedLockAop {
          private static final String REDISSON_LOCK_PREFIX = "REDISSON_LOCK";
      
          private final RedissonClient redissonClient;
          private final AopForTransaction aopForTransaction;
      
          @Around("@annotation(com.example.fluxexample.aop.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 +
                      distributedLock.key();
              RLock rLock = redissonClient.getLock(key);
              try{
                  boolean available = rLock.tryLock(distributedLock.waitTime(),distributedLock.leaseTime(),distributedLock.timeUnit());
                  if(!available){
                      return false;
                  }
                  return aopForTransaction.proceed(joinPoint);
              } catch (InterruptedException e){
                  throw new InterruptedException();
              } finally {
                  try{
                      rLock.unlock();
                  } catch (IllegalMonitorStateException e){
                      log.info("Redisson Lock Already UnLock [ serviceName : {}, key : {} ]", method.getName(),key);
                  }
              }
          }
      }
      
      

      어노테이션 적용

      package com.example.fluxexample.service;
      
      import com.example.fluxexample.aop.DistributedLock;
      import com.example.fluxexample.config.StockMapper;
      import com.example.fluxexample.entity.AbstractStock;
      import com.example.fluxexample.entity.StockRedis;
      import com.example.fluxexample.redis.StockRedisRepository;
      import com.example.fluxexample.repository.StockRepository;
      import lombok.RequiredArgsConstructor;
      import org.springframework.stereotype.Service;
      
      @RequiredArgsConstructor
      @Service
      public class StockServiceImpl implements StockService{
      
          private final StockRedisRepository stockRedisRepository;
          private final StockRepository stockRepository;
      
          @Override
          public StockRedis lookUpStock(Long productId){
              return stockRedisRepository.findByProductId(productId);
          }
      
          @Override
          @DistributedLock(key="#productId")
          public StockRedis decreaseStock(Long productId, Integer amount) {
              StockRedis stockRedis = stockRedisRepository.findByProductId(productId);
              stockRedis = stockRedis.decreaseInventory(amount);
              return stockRedisRepository.save(stockRedis);
          }
      
          @Override
          @DistributedLock(key="#productId")
          public StockRedis increaseStock(Long productId, Integer amount) {
              StockRedis stockRedis = stockRedisRepository.findByProductId(productId);
              stockRedis = stockRedis.increaseInventory(amount);
              return stockRedisRepository.save(stockRedis);
          }
      
          public AbstractStock findInDbms(Long productId){
              return stockRepository.findByProductId(productId);
          }
      
      }