동시성 문제 해결 해보기. Feat DB Lock

BaekGwa·2024년 8월 27일
0

Spring

목록 보기
1/9
post-thumbnail

동시성 문제

동시성 문제란?

여러 스레드 혹은 프로세스가 동시에 같은 자원에 접근하거나 수정할려고 할 때, 예상치 못한 결과를 발생하는 문제를 말합니다.


동시성 문제 해결

이번에는, 3번 DB 락 전략을 사용하여 회피하는 방법에 대해서 알아보고자 합니다.


예제 코드 작성

예제에서 사용한 기술 스택

  • 프레임워크 : Spring Boot
  • DB(Access) : MySQL, Hibernate/JPA
  • 기타 라이브러리 : Lombok

실습에 사용한 코드

Github

예제 목표

  • 쇼핑몰을 운영합니다. 쇼핑몰에는 물건을 구입할 수 있으며, 이는 API를 통해 구매 할 수 있습니다.
  • 회원 기능, 보안 정책, Admin 관리 등의 내용은 본 글의 목표와는 조금 벗어나는 점이 있어, 개발을 진행하지 않습니다. (물품 등록 등, 필요에 의해서 조금씩 발생할 수 있습니다.)

Test 코드 작성 / V1

  • 먼저 동시성 이슈따위는 고려하지 않고, 코드를 작성 하였다.
package BaekGwa.ConcurrencyIssue.domain.item.service;

//imports

@SpringBootTest
class ItemServiceImplV1Test {

    @Autowired
    private ItemServiceImplV1 itemService;

    @Autowired
    private ItemRepository itemRepository;

    @BeforeEach
    public void init() {
        NewItem newItem = new NewItem("상품A", 1000L, 100L);
        itemService.RegisterItem(newItem);
    }

    @AfterEach
    public void clear() {
        itemRepository.deleteAll();
    }

    @Test
    void 단일_구매_요청() {
        BuyItem ItemA = new BuyItem("상품A", 1L);
        Boolean isSuccess = itemService.BuyItem(ItemA);

        Item findItem = itemRepository.findAllByName("상품A");

        assertEquals(isSuccess, true);
        assertEquals(99, findItem.getStock());
    }

    @Test
    void 다중_구매_요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService es = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            es.submit(() -> {
                BuyItem ItemA = new BuyItem("상품A", 1L);
                Boolean isSuccess = itemService.BuyItem(ItemA);
                assertEquals(isSuccess, true);
            });
        }

        es.shutdown();
        es.awaitTermination(10, TimeUnit.SECONDS);

        Item findItem = itemRepository.findAllByName("상품A");
        assertEquals(0, findItem.getStock());
    }
}
  • 구매는 itemServiceBuyItem 메서드로 실행 된다
  • 다중 구매 요청은, ExecutorService로 구현 하였고, 100개의 Thread(유저)가 동일 상품을 1개씩 구매하는 것으로 처리 하였다.
  • 실행 결과는 다음과 같이, Fail되었다.

V1 문제점 및 해결 방법 제시

  • 당연히, 해당 코드는 멀티스레드에서 동시에 접근하기에 올바르지 않은 코드로 작성하였기 때문이다.
  • 이를 해결 하기 위해서는 가장 간편하게 application service 메서드에 synchronized키워드를 사용해서, 여러 스레드에서 한번에 접근 할 수 없는 메서드로 만들어 주면 된다.

V1 문제 해결.

  • 위에서 소개한 방법대로 아래처럼 해결을 해보고자 한다.
    • 실제 코드는 위에 소개된 Github 코드를 참조해주세요.
	@Transactional
    @Override
    public synchronized Boolean buyItem(BuyItem buyItem) {

        try {
            //검증 로직
            
            //금액 검증
            
            //상품 검증
            
            //서비스 로직
            
            return true;

        } catch (Exception e) {
            log.error("구매 실패. Message = {}", e.getMessage() + e.getCause());
            return false;
        }
    }
  • 하지만 이 코드를 실행 하여도, 동일한 문제가 발생하는데, 이는 @Transactional 의 비밀에 숨겨져 있다.

부록) @Transactional 과 synchronized 키워드 사용의 문제점

  • 해당 부분은, 이 글에 같이 적기 생각보다 많은 것 같아, 추후에 작성하여 링크 대체 하도록 한다.

V2 문제 해결 방법 고민

  • 앞서 살펴 보았듯, @Transactionalsynchronized 키워드를 병행하여 사용에 문제가 발생하여 다른 방법을 찾아야 된다.
    • 물론, 두개를 그대로 사용하여 회피하는 방법 또한 존재 한다. (궁금하시면 댓글 부탁드립니다...)
  • 해당 문제는 ReentrantLock() 같은 방법을 사용해도 어쩔 수 없다.
  • 따라서, 앞서 소개한대로 DB 락 전략을 사용하여 해결 해보도록 하겠다.

DB Lock 전략

  • 기존에는 Application에 Lock을 걸어 메서드 자체에 접근을 막았다면, DB에 Lock을 걸어 해결하는 방법이다.
  • 크게 두가지 종료의 Lock이 존재한다.
    • Pessimistic Lock (비관적 락)
    • Optimistic Lock (낙관적 락)

  • 비관적? 낙관적? 이 문장이 조금 헷갈릴 수도 있는데, 이렇게 이해하면 편하다
    • Q) 동시성 문제가 자주 발생할 것 같나요??
    • A1) 네, 자주 발생할 것 같습니다. (비관적으로 생각, 비관적 락 선택)
    • A2) 아니요, 가끔 발생할 것 같습니다. (낙관적으로 생각, 낙관적 락 선택)
  • 이러한 기준은, Application 단의 동시성 문제 해결에도 동일하게 적용할 수 있습니다. 링크 내용의 마지막쯤 보시면 됩니다
  • 여튼, 먼저 Pessimistic Lock에 대해서 먼저 확인해 보겠습니다.

Pessimistic Lock / 비관적 락

Pessimistic Lock

  • 실제로 DB에 Lock(자물쇠)를 걸어, 실행하고 있는 사용자(트랜잭션) 말고는, 접근 할 수 없도록 한 것이다.
  • exclusive lock 을 걸게 된다.

사용방법

  • Hibernate/JPA를 사용하여 개발을 하면, 간단하게 비관적 락을 적용할 수 있다.
  • 다음과 같이 @Lock(LockModeType.PESSIMISTIC_WRITE) Annnotaion을 JPA 메서드에 걸어주면 됩니다.
@Repository
public interface ItemRepository extends JpaRepository<Item, Long> {

    ~~기존코드~~

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Item findUseLockByName(String name);
}

주의 사항!!

  • 해당 메서드를 사용하는 곳에는 @Transactional을 통한 트랜젝션 관리가 필요합니다.
  • 비관적 락은, 트랜잭션범위에서 획득, 유지, 반납을 진행하기 때문에 트랜잭션설정은 필수 입니다.

결과


👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏


Optimistic Lock / 낙관적 락

Optimistic Lock

  • version 이라는 Column을 사용하여 동시성 문제를 해결한다.
  • 기본 원리는 version의 초기값은 0이며, Update가 실시 될때마다, version의 값을 1씩 증가한다. 아래 사진 참조
  • 업데이트를 하는 시점에서, 내가가지고 있는 version 값과, DB의 version값이 다를 경우, Exception을 발생시켜, 테이터 정합성 문제 혹은, 동시성 문제가 발생 했다는 신호를 주게 된다.
    • OptimisticLockException 이 발생 된다.
  • 개발자는, 해당 OptimisticLockException이 나올 경우, 무한 반복 시켜, 문제를 동시성 문제가 발생하지 않을때까지 무한 반복 하도록 한다.
  • 이때, Facade Pattern을 사용하는 경우도 많다.

version = update 횟수

사용방법

  • Jpa 기준으로, 사용중인 Entity에 version column을 추가한다.
  • 또한, @Version Annotation을 걸어주어, update를 할 시, 자동적으로 해당 컬럼이 Hibernate에 의해 자동으로 증가 되도록 한다.
@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "item")
public class Item {

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

    //이름
    @Column(nullable = false, name = "name")
    private String name;

    //가격
    @Column(nullable = false, name = "price")
    private Long price;

    //재고 수량
    @Column(nullable = false, name = "stock")
    private Long stock;

    @Version
    private Long version;

    public void changeStock(Long amount){
        this.stock = this.stock - amount;
    }
}
package BaekGwa.ConcurrencyIssue.domain.item.service;

imports~~

@Service
@RequiredArgsConstructor
@Slf4j
public class ItemServiceImplV5 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Boolean RegisterItem(NewItem newItem) {
    	~~~~~~~
    }

    @Transactional
    @Override
    public Boolean buyItem(BuyItem buyItem) throws InterruptedException {
        while (true) {
            try {
                //검증 로직
                
                //금액 검증
                
                //상품 검증
                
                //서비스 로직
                
                return true;

            } catch (OptimisticLockException e) {
                Thread.sleep(50);
            } catch (Exception e) {
                log.error("구매 실패. Message = {}", e.getMessage() + e.getCause());
                return false;
            }
        }
    }
}

주의사항

  • @Version Annotation에 의해 version column이 관리가 된다. hibernate 에 의해서 관리가 되는 것이므로, 다음과 같이 새로운 객체를 생성해서 save를 하려고 하면, 다음과 같은 Detached Entity Error 발생한다.
	@Transactional
    @Override
    public Boolean buyItem(BuyItem buyItem) throws InterruptedException {
        while (true) {
            try {
            	~~~~
                
                //저장 로직
                itemRepository.save(Item
                        .builder()
                        .id(findItem.getId())
                        .name(findItem.getName())
                        .stock(findItem.getStock() - buyItem.getAmount())
                        .price(findItem.getPrice())
                        .build());

  • 영속성 관리 대상이 아닌, 객체(Entity)를 업데이트 하려고 할때 발생하는 문제이다.
  • 따라서, Setter를 사용하거나, 기능에 알맞은 메서드를 생성해서 기존 영속성에서 관리되고있는 객체의 값을 변경하여 수정 하도록 한다.
~~~
                findItem.changeStock(buyItem.getAmount());
                itemRepository.save(findItem);
~~~

결과

👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏👏


Pessimistic(비관) VS Optimistic(낙관)

  • 소개한 두가지 락 전략을 비교해보고, 어떤 상황에서 사용할지, 어떤 특징이 있는지 알아 보자.

비관적 락 (Pessimistic Lock)

  • 특징
    • 실시간 충돌 방지: 데이터에 접근하는 동안 락을 걸어 충돌을 방지합니다.
    • 락 유지: 데이터에 락을 걸면 다른 트랜잭션은 락이 해제될 때까지 기다려야 합니다.
    • 성능: 높은 동시성 환경에서는 성능 저하가 발생할 수 있습니다. 여러 트랜잭션이 대기 상태가 될 수 있습니다.
  • 장점
    • 데이터 무결성 보장: 충돌을 방지하여 데이터 무결성을 보장합니다.
    • 복잡한 동시성 문제 처리: 동시성 문제가 발생할 가능성이 높은 상황에서 안정적인 동작을 보장합니다.
  • 단점
    • 성능 저하: 락을 걸면 다른 트랜잭션이 대기해야 하므로 성능이 저하될 수 있습니다.
    • 데드락 위험: 여러 트랜잭션이 서로 락을 기다리며 교착 상태에 빠질 수 있습니다.

낙관적 락 (Optimistic Lock)

  • 특징
    • 충돌 검출: 데이터 수정 시 충돌 여부를 확인하고, 충돌이 발생하면 롤백하거나 재시도합니다.
    • 락 비사용: 데이터 읽기 시 락을 걸지 않으므로 성능이 더 나을 수 있습니다.
    • 성능: 성능이 더 좋을 수 있지만, 충돌 발생 시 추가적인 처리 로직이 필요합니다.
  • 장점
    • 성능: 데이터 읽기 시 락을 걸지 않으므로 높은 성능을 유지할 수 있습니다.
    • 교착 상태 방지: 락을 사용하지 않기 때문에 교착 상태의 위험이 줄어듭니다.
  • 단점
    • 충돌 처리 복잡성: 충돌이 발생하면 롤백하고 재시도하는 로직이 필요합니다.
    • 데이터 무결성: 충돌 처리 로직이 잘못될 경우 데이터 무결성이 깨질 수 있습니다.

비관적 락과 낙관적 락의 선택 기준

  • 동시성 수준:
    • 비관적 락: 동시성 문제가 자주 발생할 것으로 예상되는 상황.
    • 낙관적 락: 동시성 문제가 가끔 발생할 것으로 예상되는 상황.
  • 성능 요구 사항:
    • 비관적 락: 성능 저하가 허용되는 상황, 데이터 무결성이 중요할 때.
    • 낙관적 락: 성능이 중요하고 데이터 충돌 가능성이 낮을 때.
  • 시스템의 복잡성:
    • 비관적 락: 복잡한 동시성 문제를 처리할 수 있는 시스템에서 사용.
    • 낙관적 락: 간단한 데이터 접근 패턴을 가진 시스템에서 사용.

부록) JPA의 LockModeType 정리 해보기

  • Repository 단에, @Lock Annotation에 LockModeType을 넣어주는데, 이것들은 어떤 것인지 궁금해져서 조사를 해보았다.

  • NONE
    • 락을 사용하지 않습니다.
  • READ
    • 설명 : 읽기 락을 설정합니다. 데이터가 읽히는 동안 다른 트랜잭션에서 데이터의 수정은 허용되지 않습니다. 그러나 데이터의 읽기는 허용됩니다.
    • 용도 : 데이터를 읽는 동안 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 할 때 사용됩니다. (일부 JPA 구현에서는 지원되지 않을 수 있습니다.)
  • WRITE
    • 설명 : 쓰기 락을 설정합니다. 데이터가 읽히는 동안 다른 트랜잭션에서 해당 데이터를 읽거나 수정할 수 없습니다.
    • 용도 : 데이터를 수정하는 동안 다른 트랜잭션이 해당 데이터에 접근할 수 없도록 할 때 사용됩니다.
  • OPTIMISTIC
    • 설명 : 낙관적 락을 설정합니다. 데이터에 버전 필드를 사용하여 충돌을 감지합니다. 데이터가 수정될 때 버전이 변경되며, 트랜잭션이 커밋될 때 버전 값이 변경되었으면 예외를 발생시킵니다.
    • 용도 : 데이터 충돌이 드물고 성능이 중요한 상황에서 사용됩니다. 데이터의 버전 관리를 통해 충돌을 검출합니다.
  • OPTIMISTIC_FORCE_INCREMENT
    • 설명 : 낙관적 락을 사용하되, 데이터의 버전 필드를 강제로 증가시킵니다. 이는 데이터가 읽히더라도 버전이 증가하도록 하여 데이터 수정이 일어난 것으로 간주합니다.
    • 용도 : 데이터 수정 시 버전 증가가 필수적이고, 충돌 감지를 더 강력하게 적용할 필요가 있는 경우에 사용됩니다.
  • PESSIMISTIC_READ
    • 설명 : 비관적 읽기 락을 설정합니다. 데이터가 읽히는 동안 다른 트랜잭션에서 해당 데이터를 수정할 수 없도록 합니다. 그러나 다른 트랜잭션에서도 데이터를 읽을 수 있습니다.
    • 용도 : 데이터 읽기 시 다른 트랜잭션의 수정이 발생하지 않도록 보장할 필요가 있을 때 사용됩니다.
  • PESSIMISTIC_WRITE
    • 설명 : 비관적 쓰기 락을 설정합니다. 데이터가 읽히는 동안 다른 트랜잭션에서 해당 데이터의 읽기 및 수정을 모두 차단합니다.
    • 용도 : 데이터를 수정하는 동안 다른 트랜잭션이 해당 데이터에 접근하지 못하도록 할 때 사용됩니다.
  • PESSIMISTIC_FORCE_INCREMENT
    • 설명 : 비관적 락을 사용하면서도 데이터의 버전을 강제로 증가시킵니다. 데이터가 수정될 때 버전이 증가하며, 이는 강제로 트랜잭션의 수정이 일어난 것으로 간주됩니다.
    • 용도 : 비관적 락을 사용하되, 데이터 수정 시 버전 필드를 강제로 업데이트하여 데이터 충돌 감지를 보강할 필요가 있는 경우에 사용됩니다.
profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글