데블 아니고 데블리

운동,햄버거, 개발 좋아요

🐷💻📝

항해99 취업 리부트 코스 학습일지

[항해99 취업 리부트 코스 학습일지] 2024.05.13.(월) WIL 동시성처리 (2) Lock 비관적 락, 분산락

데블아니고데블리 2024. 5. 7. 11:16

1편에서는 동시성 처리를 왜 하는지, 그러면 어떤 방법을 적용할 수 있는지 알아보았다(1편 보고 오기!)

https://devdevleyy.tistory.com/47

 

[항해99 취업 리부트 코스 학습일지] 2024.05.06.(월) WIL 동시성처리 (1)

WIL 이라고 하고 TIL 몰아쓰기라고 생각된다TIL을 쓸 기회가 있었는데, 내용 정리가 명확하지 않아 WIL로 정리하면 좋겠다는 생각이 들었다1편은 왜 해야 하는지(목적)을 자세하게 써 보고 2편에는

devdevleyy.tistory.com

그리고... SQL 결과지에서 item_id 값은 눈감아주세요.. 매번 테스트때마다 10000건 넘게 보내고.. 10번만 테스트해도 데이터 십만건이니까... 지워도.. 생기니까요.. 눈감고 모르는척...해주시길 바랍니다

[동시성 처리 : lock]

1. JPA, Optimistic Locking(낙관적락)

낙관적락은 패쓰.. 하려고 했지만 개인적인 궁금함(학습목적)에 따라 작성해 보려고 한다

2. MySQL, Pessimistic Locking(비관적락)

3. Redis 활용 Distributed Locking(분산락)

 

[낙관적 락 적용하기]

@Version 어노테이션을 사용한 트랜젝션 충돌을 체크하는 방법이고, 최초 커밋만 인정하는 방식이다.

트렌젝션이 커밋되면 @Version칼럼 값을 데이터베이스와 비교한다, 동일하지 않을 때 예외처리.. 그러하기 때문에 트랜젝션을 커밋하기 전까지 트랜젝션이 충돌하는 지 알 수 없다..

 

낙관적 락을 사용하기 위해 Items entity에 @Version과 version 필드를 추가해준다.

@PrePersist는 재고 차감하는 로직인데, 영속화 하기 전 차감 가능한 재고가 있는지 확인하도록 했다.. 그리고 history table도 만들어 재고가 어떻게 차감이 되는지도 확인해 보려고 한다

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
@Data
@Table(name = "Items")
public class Items {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long itemId;

    private Long userId;

    // 상품 상세 아이디
    private Long productDetailId;
    // 수량
    private int stock;

    @Version
    private int version;

    // 영속화 전 수행하는 로직
    @PrePersist
    public void prePersist() {
        decrease();
    }

    private void decrease() {
        if(stock > 0) {
            stock --;
        }
    }
}

 

컨트롤러와 서비스 레이어는 다음과 같다

//Controller
@PostMapping("/api/v1/optimistic/request")
    public CommonResponse<?> save(@RequestBody LockDto dto) {
        try {
            itemService.orderRequest(dto);
            return CommonResponse.ok("주문 요청 성공");
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage(),e);
        }
    }
    
//Service
@Transactional
    public void orderRequest(LockDto dto) {
        // 상품을 조회한다
        Items items = itemsRepository.findById(dto.getItemId())
                .orElseThrow(() -> new RuntimeException("Item not found"));

        // 재고 확인
        if (items.getStock() < dto.getStock()) {
            throw new RuntimeException("Stock limit exceeded");
        }

        // 상품 히스토리 테이블에 구입한 사람들 저장한다
        ItemHistory history = ItemHistory.builder()
                .userId(dto.getUserId())
                .stock(items.getStock())
                .itemId(dto.getItemId())
                .build();
        historyRepository.save(history);
        items.prePersist();

        itemsRepository.save(items);
    }

 

테스트는 Jmeter로 했고, 500개의 쓰레드, 10초로 정하고 테스트를 해 보았다

Number of Threads(users): 500명

Ramp-up period(second) : 10

Loop Count : 1

 

그래서 결과는 ? Items 테이블은 재고가 -가 나지 않았고, history table도 10개~1개까지 딱 10개가 차감이 되었다 

 

그렇다면 조금 더 치열한 상황을 만들어서 테스트를 해 보려고 한다

Number of Threads(users): 1000명

Ramp-up period(second) : 60초

10000개의 쓰레드가 60초에 걸처 진행했더니 5개에서 멈춰버렸다

 

왜 그럴까..?

클라이언트가 커넥션을 통해 데이터베이스에 접근했지만.. 락을 얻을 수 없어 데드락으로 롤백되는 경우는.. 데드락이 발생한다.

또한 트랜젝션 시 버전이 맞지 않아 에러처리가 되어 계속 돌고 돈다..

특히 자원(재고)가 한정적인 상황, 요청을 선착순으로 처리해야 하는 상황에서는 맞지 않는 방법이라고 생각해서..이번 과제에서는 선택하지 않았다

[비관적 락 적용하기]

트렌젝션이 시작될 때 데이터에 lock을 걸어 정합성을 맞추는 방법

나는 mysql을 사용했다

//Controller
@PostMapping("/api/v1/optimistic/request")
    public CommonResponse<?> forPessimistic(@RequestBody LockDto dto) {
        try {
            itemService.forPessimistic(dto);
            return CommonResponse.ok("주문 요청 성공");
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage(),e);
        }
    }
    
//service
public void forPessimistic(LockDto dto) {
        Items items = itemsRepository.findById2(dto.getItemId())
                .orElseThrow(() -> new RuntimeException("Item not found"));

        // 재고 확인
        if (items.getStock() < dto.getStock()) {
            throw new RuntimeException("Stock limit exceeded");
        }

        ItemHistory history = ItemHistory.builder()
                .userId(dto.getUserId())
                .stock(items.getStock())
                .itemId(dto.getItemId())
                .build();
        historyRepository.save(history);
        items.prePersist();
        itemsRepository.save(items);
    }
    
    // repository
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select i from Items i where i.itemId = :itemId")
    Optional<Items> findById2(@Param("itemId") Long itemId);

 

이렇게 코드를 작성하게 된다면 결과는 하나씩 예약이 되는 예쁜 결과가 나올 수 있습니다.

+-------+-----------------+-----+-------+
|item_id|product_detail_id|stock|user_id|
+-------+-----------------+-----+-------+
|1      |12               |0    |null   |
|99975  |12               |9    |19     |
|99976  |12               |8    |6      |
|99977  |12               |7    |36     |
|99978  |12               |6    |39     |
|99979  |12               |5    |11     |
|99980  |12               |4    |26     |
|99981  |12               |3    |13     |
|99982  |12               |2    |52     |
|99983  |12               |1    |66     |
|99984  |12               |0    |60     |
+-------+-----------------+-----+-------+

하지만 질문이 생기는 것 같아요..

itemsRepository.findById2(1L).orElse(null);

@Query("select i from Items i where i.itemId = :itemId") 같은 쿼리가 날아가는 것 같은데 무슨 차이가 있을까..

그 비밀은 @Lock(LockModeType.PESSIMISTIC_WRITE) 에 있습니다.

 

@Query와 @Lock을 함께 사용하면 이런 쿼리가 나갑니다.. 

Hibernate:
select i1_0.item_id, i1_0.product_detail_id, i1_0.stock, i1_0.user_id
from items i1_0
where i1_0.item_id = 12 
for update

 

뒤에 for update가 들어가있져..

이건 동시성 제어를 위해 특정 row에 비관적락을 DB에서 걸어주는 것입니다.

나 수정중이니까 끝나고 줄 서!

안전성에 있어서는 엄청 좋지만, 대신 DB까지 다녀오느라고 좀 늦죠..

 

Lock Mode 종류

  • LockModeType.PESSIMISTIC_WRITE
    일반적인 옵션. 데이터베이스에 쓰기 락
    다른 트랜잭션에서 읽기도 쓰기도 못함. (배타적 잠금)
  • LockModeType.PESSIMISTIC_READ
    반복 읽기만하고 수정하지 않는 용도로 락을 걸 때 사용
    다른 트랜잭션에서 읽기는 가능함. (공유 잠금)
  • LockModeType.PESSINISTIC_FORCE_INCREMENT
    Version 정보를 사용하는 비관적 락

 

이후 해볼 것: 

  • 시간조건 비교(1초에 몇 건 처리, 총 완료된 시간)

[분산락 사용하기]

분산락에는 1편에 서술한 대로 Lettuce 와 Redisson 방식이 있는데, 크게는 레디스에 부하를 얼마나 주나? 의 차이였던 것 같습니다.

저는 러닝커브는 좀 있지만, wait time과 lease time을 유동적으로 관리가 가능해 Redission을 사용해 보도록 할 것이다

저는 https://helloworld.kurly.com/blog/distributed-redisson-lock/ 컬리블로그 를 참고해 작성했어요

Redisson 라이브러리를 사용하기 위해 의존성을 추가합니다

// boot 3.2.8 기준
implementation 'org.redisson:redisson-spring-boot-starter:3.23.5'

 

application.properties 에 관련 설정들을 합니다

# redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.profiles.active=local

 

redission을 사용할 config 파일을 만들어 줍니다

import org.redisson.config.Config;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
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 = "localhost";

    @Value("${spring.data.redis.port}")
    private int redisPort = 6379;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }
}

 

config 파일은 자주 만들어 봐서.. 이제는 눈에 익지 않았나 싶습니다 간단하게 코드 설명을 하면

1. @Bean 으로 스프링에게 알려주기

2. Redisson Client 객체를 생성하는 메서드, redis 서버와 연결 및 통신하기

3.useSingleServer(); Redis 클라이언트가 단일 redis 서버와 연결할 수 있도록 설정, 이 메서드를 통해 단일 서버에 대한 구성을 지정하기

4. setAddress로 연결할 redis 서버의 주소 설정

해서 인스턴스 만들면 끝입니다.

 

그리고 제가 redisson을 선택한 이유이기도 하죠! 커스텀입니다! 커스텀을 하기 위한.. 어노테이션 만들어 보는 두둥!

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributionLock {
    // 락 설정할것 여기에 두기
    //key : 락의 이름
    String key();

    // 시간단위, MILLISECONDS, SECONDS, MINUTE..)
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    //락을 획득하기 위한 대기 시간
    long waitTime() default 5L;

    //락을 임대하는 시간
    long leaseTime() default 3L;
}

뒤에 더 설명하게 될 것이지만

1. @Target : @DistributionLock 주석이 메서드에만 적용될 수 있음을 나타낸다

2. @Retention(RetentionPolicy.RUNTIME): 리플렉션 엑세스를 위한 로직

3. public @interface DistributionLock : 어노테이션 이름 정하기

그 후에 키 이름, 대기, 임대시간(락이 걸린 후 내꺼다! 하는 점유시간) 을 커스텀 할 수 있습니다.

 

다음으로 실제 lock을 점유하게 되는 클래스, 분산 락을 구현하기 위한 Aspect 클래스 코드입니다

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

    private static final String REDISSON_KEY_PREFIX = "LOCK";
    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(org.daitem_msa.msa_order.common.redisson.DistributionLock)")
    public void lock(ProceedingJoinPoint joinPoint) throws Throwable {
    	//조인포인트(AOP 실행 시점)에서 MethodSignature 객체 반환, 메서드 이름, 반환타입, 매개변수 정보 얻기
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 그 중 매서드 정보 얻기
        Method method = signature.getMethod();
        //락 얻고 시작
        DistributionLock distributionLock = method.getAnnotation(DistributionLock.class);
        // 키 설정
        String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributionLock.key());
        // 락 이름으로 된 Rlock 인스턴스를 가지고 오기
        RLock lock = redissonClient.getLock(key); 

        try {
            boolean available = lock.tryLock(distributionLock.waitTime(), distributionLock.leaseTime(), distributionLock.timeUnit());  // (2)
           //waittime까지 획득 시도, leaseTime까지 기다리고 락 해제
           if (!available) {
                log.info("Lock 획득을 못했어요" , key);
                return;
            }
            log.info("락 걸리고 로직 시작합니다");
            //@DistributionLock 어노테이션이 선언된 메서드 별도의 트랜젝션으로 시작
            //AOP에서 트랜젝션 분리를 위한 클래스
            aopForTransaction.proceed(joinPoint);
        } catch (InterruptedException e) {
            log.info("에러" + e.getMessage());
            throw e;
        } finally {
        	//트렌젝션 종료 시 무조건 락을 해제
            log.info("락 해제");
            lock.unlock();
        }
    }
}

 

여기서 더 공부를 해야 할 것은 "Joint point 와 AOP" 이 부분은 추후에 더욱 깊게 다뤄보도록 하겠습니다.~~

@Aspect : 싱글톤 형태의 객체로 객체화, AOP의 기본 모듈, 어드바이스 + 조인포인트

@Around : 해당 메서드(org.daitem_msa.msa_order.common.redisson.DistributionLock) 메서드의 실행 전과 후에를 둘러싼 advice 를 정의

정도만 기억해 주세요

 

그리고 실제로 일어나는 서비스 코드입니다. 동일하지만 한번 더 남겨놓는걸로

@DistributionLock(key = "#dto.userId")
    public void newOrderAdd(NewOrderSaveDto dto) {
        System.out.println("요청 : " + dto.getUserId());

        Items items = itemsRepository.findById2(1L).orElse(null);
        int totalAmount = dto.getStock();  // 주문 수량 가져오기

        if(items.getStock() > 0) {
            Items log = Items.builder()
                    .userId(dto.getUserId())
                    .productDetailId(12L)
                    .stock(items.getStock() - totalAmount)
                    .build();
            itemsRepository.save(log);
            items.setStock(log.getStock());

            items.setStock(log.getStock());
            itemsRepository.save(items);
        }
        System.out.println("남은 재고 : " + items.getStock());
    }

 

여기서 @DistributionLock 어노테이션을 사용할 때 key 값이 있어야 그 값으로 락을 식별하기 때문에 유일한 값이여야 해요

사실 여기서 오점인 부분은 user 가 한번만 시도한다는 가정이였을 때 가능한 것이고, 여러번 시도한다면 락을 획득하지 못한 경우

DistributionLockAspect 클래스에서 수정이 들어가야 겠죠??

그리고 정말 정말 중요한 

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

AOP 에서 트랜젝션 분리를 위한 클래스입니다.

트랜젝션 커밋 후 락이 해제되는것이 동시성 처리의 핵심인데요...!!

(TODO): 사진으로 정리

1편에서 말했던 레이스 컨디션이 발생했기 때문인데요,, 두 사용자가 동시에

 

그래서 결과는?  분산 처리가 잘 된 것을 확인할 수 있습니다..!(같은 시나리오 조건 안에서)

+-------+-----------------+-----+-------+
|item_id|product_detail_id|stock|user_id|
+-------+-----------------+-----+-------+
|1      |12               |0    |null   |
|100263 |12               |9    |165    |
|100264 |12               |8    |16     |
|100265 |12               |7    |79     |
|100266 |12               |6    |68     |
|100267 |12               |5    |66     |
|100268 |12               |4    |109    |
|100269 |12               |3    |160    |
|100270 |12               |2    |9      |
|100271 |12               |1    |184    |
|100272 |12               |0    |98     |
+-------+-----------------+-----+-------+

 


추가로 공부했던 내용: 같은 로직과 시나리오로 @Synchronized 를 적용했을때는 어떻게 될까?

+-------+-----------------+-----+-------+
|item_id|product_detail_id|stock|user_id|
+-------+-----------------+-----+-------+
|1      |12               |0    |null   |
|99584  |12               |8    |16     |
|99585  |12               |8    |50     |
|99586  |12               |8    |10     |
|99587  |12               |7    |60     |
|99588  |12               |7    |53     |
|99589  |12               |6    |39     |
|99590  |12               |6    |52     |
|99591  |12               |5    |66     |
|99592  |12               |5    |7      |
|99593  |12               |4    |24     |
|99594  |12               |4    |33     |
|99595  |12               |3    |20     |
|99596  |12               |3    |23     |
|99597  |12               |2    |30     |
|99598  |12               |2    |65     |
|99599  |12               |1    |57     |
|99600  |12               |1    |56     |
|99601  |12               |0    |5      |
|99602  |12               |0    |31     |
|99603  |12               |-1   |17     |
|99604  |12               |-1   |46     |
+-------+-----------------+-----+-------+

 

아무것도 처리를 하지 않을 때 보다는... row 가 줄었다고는 하지만 완전히 해결되지는 못했다

왜 이럴까? 고민을 해 보니

@Transactional@Synchronized 의 역할을 생각해 보면 좋을 것 같다

@Transactional : 트랜잭션 범위 안에서 실행되기 때문에, 여러 사용자가 동시에 해당 메서드를 호출할 때 각각 별도의 트랜젝션을 만든다

@Synchronized  : 메서드 블록에 동기화 락을 적용하여 여러 스레드가 해당 영역에 접근하는 것 방지한다, 한번에 하나의 스레드만 접근 가능

 

따라서 이 두 가지 기능을 함께 사용하는 경우, 여러 사용자가 동시에 해당 메서드에 접근하더라도 하나의 스레드만 해당 메서드에 접근할 수 있지만, 각 스레드는 독립적인 트랜잭션 범위를 가지게 됩니다(아까랑 똑같은.. 상황). 따라서 동시성 이슈는 여전히 발생할 수 있습니다... 더욱 깊은 공부를 해보자