[SPRING] 동시성이슈 해결방법

2024. 3. 14. 11:37Web/spring-boot

Inflearn "재고시스템으로 알아보는 동시성 이슈 해결방법" 과 컬리 기술블로그를 보고 정리한 글입니다.

 

1. 동시성 문제

@Getter
@Entity
@NoArgsConstructor
public class Stock {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private Long productId;
    private Long quantity;
    
    public Stock(final Long id, final Long quantity) {
        this.id = id;
        this.quantity = quantity;
    }

    public void decrease(final Long quantity) {
        if (this.quantity - quantity < 0) {
            throw new RuntimeException("재고 부족");
        }

        this.quantity = this.quantity - quantity;
    }
}
@Service
@RequiredArgsConstructor
public class StockService {

    private final StockRepository stockRepository;

    public void decrease(final Long id, final Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }
}
@SpringBootTest
@ActiveProfiles(value = "test")
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @Test
    public void 재고감소_동시성100명_테스트() throws InterruptedException {
        Stock stock = stockRepository.save(new Stock(1L, 100L));
        final int numberOfThreads = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.submit(() -> {
                try {
                    // 분산락 적용 메서드 호출 (락의 key는 쿠폰의 name으로 설정)
                    stockService.decrease(1L, 1L);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        Stock afterStock = stockRepository.findById(1L).orElseThrow();

        assertThat(afterStock.getQuantity()).isEqualTo(0L);
    }

}

테스트 케이스 실패

  • 테스트 케이스 실패
    • CountDownLatch 를 이용하여, 멀티스레드 100개의 재고를 감소시키도록 호출한 뒤 재고가 0이 되는지 확인한 결과 의외의 값이 나오게 된다.
    • 이유는 레이스 컨디션이 일어나기 때문이다. (2개 이상의 스레드가 데이터를 공유할 수 있고, 동시에 변경 할때 발생하는 문제

2. 해결방법

  1. Syncronized 이용

  1. 자바에서 제공하는 Syncronized를 명시해주면 하나의 스레드만 접근가능
  2. 멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주기 위해서 자바에서 제공하는 키워드
  3. 공유되는 데이터의 Thread-safe를 하기위해서, syncronized로 스레드 간 동기화를 시켜 thread-safe 하게 만들어준다
public synchronized void decrease(final Long id, final Long quantity) {
   Stock stock = stockRepository.findById(id).orElseThrow();
   stock.decrease(quantity);
   stockRepository.saveAndFlush(stock);
}

테스트 성공

  • Syncronized 단점
    • 자바의 syncronized는 하나의 프로세스 안에서만 보장한다.
    • 즉, 서버가 1대일 경우에만 유효하고, 서버가 2대이상인 경우에는 데이터 접근을 막을수가 없다.

  2. DataBase 이용하기

  • Pessimistic Lock (비관적 락)
    1. 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
    2. 트랜잭션이 시작될 때 동시성 문제가 발생할 것이라고 예상하고 락을 거는 방법
    3. Transaction 1 이 데이터를 가져올때 Lock을 걸어버리면 다른쪽에서 Transaction 1 이 끝날때까지 접근하지 못하는 방법

 

 

public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(final Long id);
}

  • Pessimistic Lock (비관적 락) 장점
    • 충돌이 빈번하게 일어난다면 롤백의 횟수를 줄일수 있기 때문에, Optimistic Lock 보다는 성능이 좋다
    • 비관적 락을 통해 데이터를 제어하기 때문에 데이터 정합성을 어느정도 보장할 수 있습니다.
  • Pessimistic Lock (비관적 락) 단점
    • 데이터 자체에 별도의 락을 잡기 때문에 동시성이 떨어져 성능 저하
    • 읽기가 많이 이루어지는 데이터베이스의 경우 손해가 크다
    • 서로 자원이 필요한 경우, 락이 걸려 있으므로 데드락이 일어날 가능성이 있다.

 

  • Optimistic Lock (낙관적 락)
    • 실제로 Lock을 이용하지 않고 Version을 이용함으로써 정합성을 맞추는 방법
    • 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하며 업데이트를 진행
    • 자원에 Lock을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때가서 처리하는 낙관적 락 방식
public interface StockRepository extends JpaRepository<Stock, Long> {
    @Lock(value = LockModeType.OPTIMISTIC)
    @Query("select s from Stock s where s.id = :id")
    Stock findByWithOptimisticLock(final Long id);
}

  • Optimistic Lock (낙관적 락) 장점
    • 충돌이 안난다는 가정하에, 별도의 락을 잡지 않으므로 Pessimistic Lock 보다는 성능적으로 이점을 가집니다.
  • Optimistic Lock (낙관적 락) 단점
    • 업데이트가 실패 했을 때, 재시도 로직을 개발자가 직접 작성해 주어야합니다.
    • 충돌이 빈번하게 일어나거나 예상되면, 롤백처리를 해주어야하기 때문에 Perssimistic Lock이 성능이 좋을수도있다.

3. Redis 이용해보기

  • Redisson 사용하기(의존성 추가)
	implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
@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://";

    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);

        return Redisson.create(config);
    }
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributedLock {
    /**
     * 락의 이름
     */
    String key();

    /**
     * 락의 시간 단위
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 락을 기다리는 시간 (default - 5s)
     * 락 획득을 위해 waitTime 만큼 대기한다
     */
    long waitTime() default 4L;

    /**
     * 락 임대 시간 (default - 3s)
     * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
     */
    long leaseTime() default 3L;
}
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(com.example.javaconcurrency.aop.DistributedLock)")
    private 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 + this.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), 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 ex) {
            throw new InterruptedException();
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException ex) {
                log.info("Redisson Lock Already UnLock serviceName: {}, key: {}", method.getName(), key);
            }
        }
    }

    private 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);
    }
}
  • Lock을 사용할 메소드에 커스텀한 어노테이션을 선언하여 AOP로 처리하도록 구성하였다.
    1. 락의 이름으로 RLock 인스턴스를 가져온다. ( redissonClient.getLock(key) )
    2. 정의된 waitTime까지 획득을 시도한다, 정의된 leaseTime이 지나면 잠금을 해제한다. ( rLock.tryLock() )
    3. DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다. ( AopForTransaction )
    4. 종료 시 무조건 락을 해제한다. ( rLock.unlock() )
@Component
public class AopForTransaction {
    /**
     * AOP에서 별도의 트랜잭션 분리를 위한 메서드
     * REQUIRES_NEW : 부모 트랜잭션과 관계없이 별도의 트랜잭션으로 동작
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

@DistributedLock 이 선언된 메서드는 Propagation.REQUIRES_NEW 옵션을 지정해 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정

반드시 트랜잭션 커밋 이후 락이 해제되게끔 처리