[SPRING] 동시성이슈 해결방법
2024. 3. 14. 11:37ㆍWeb/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 이용
- 자바에서 제공하는 Syncronized를 명시해주면 하나의 스레드만 접근가능
- 멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주기 위해서 자바에서 제공하는 키워드
- 공유되는 데이터의 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 (비관적 락)
- 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법
- 트랜잭션이 시작될 때 동시성 문제가 발생할 것이라고 예상하고 락을 거는 방법
- 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로 처리하도록 구성하였다.
-
- 락의 이름으로 RLock 인스턴스를 가져온다. ( redissonClient.getLock(key) )
- 정의된 waitTime까지 획득을 시도한다, 정의된 leaseTime이 지나면 잠금을 해제한다. ( rLock.tryLock() )
- DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다. ( AopForTransaction )
- 종료 시 무조건 락을 해제한다. ( 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 옵션을 지정해 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정
반드시 트랜잭션 커밋 이후 락이 해제되게끔 처리
'Web > spring-boot' 카테고리의 다른 글
JWT 와 RTR기법 (0) | 2024.03.20 |
---|---|
WireMock을 이용하여 서버간 통신 테스트 (0) | 2024.02.19 |
MapStruct 편리한 객체 간 맵핑 (0) | 2024.02.14 |
Flyway로 데이터베이스 형상관리를 해보자 (0) | 2024.02.13 |
Fixture Monkey로 테스트에서 객체를 자동으로 생성해보자 (1) | 2024.02.13 |