커머스 플랫폼 (2)

ejoo·2024년 7월 3일

6/27 ~ 7/11
동시성 (DB Lock)
Github


2-2 시스템 요구사항

지난번에 커머스 플랫폼의 주문 플로우를 비슷하게 구현하였습니다.

해당 상점에서 다음과 같은 문제가 발생하였습니다.
1) 재고는 한정적인데 여러 사용자가 몰릴 경우 재고가 전부 소진되어도 주문이 되는 현상이 발생합니다.

해당 문제를 해결하면서 왜 이런 문제가 발생하였는지 고민해보세요.

기존에 작업했던 commerce-server에서 작업을 진행해주세요.

Hint!

예전에 공유드린 커리큘럼에서 이 문제에 대한 내용이 나옵니다. 키워드를 찾아서 학습하세요.

제출 결과물
1) 코드를 작성한 GitHub Repository 주소
2) 이 문제 해결 경험을 적은 발표 자료

주의사항!
요구사항에 적히지 않은 문제에 대해서는 스스로 정의하고 분석을 진행해주세요.


재고는 한정적인데 여러 사용자가 몰릴 경우 재고가 전부 소진되어도 주문이 되는 현상

  • 주문 생성 시 다른 트랜잭션이 동시에 접근할 수 있다.

해결 방법
어떤 사용자가 데이터를 읽기/쓰기 할 때, Lock을 걸어 다른 사용자들은 데이터를 동시에 수정할 수 없게 한다.

Lock 종류

낙관적 락 (Optimistic Lock)

  • 대부분의 트랜잭션이 충돌하지 않는다고 가정
  • DB의 Lock을 사용하지 않고 버전 관리를 통해 애플리케이션 레벨에서 처리
  • 트랜잭션 커밋 전에는 트랜잭션 충돌 감지 불가
    (충돌이 감지되었을 때 특정 예외가 발생하며 예외에 대한 후처리 가능)

낙관적 락의 흐름

데이터를 읽을 때 버전 번호를 함께 읽음
데이터를 변경할 때 읽어온 버전 번호가 현재 버전 번호와 일치하는지 확인
- 일치하면 데이터 업데이트, 버전 번호 증가
- 다르면 트랜잭션을 롤백하거나 다시 시도
재고가 4개 있는 상품 1개 주문이 동시에 10개의 주문이 들어왔을 때,
첫번째 주문만 수락되고 나머지 9개의 주문은 재고 부족으로 실패한다.

낙관적 락이 적합한 환경

  • 데이터 충돌이 발생할 가능성이 낮은 경우
  • 충돌이 발생해도 비교적 간단히 해결 가능한 경우
  • 동시에 여러 사용자가 동일한 데이터를 읽고, 수정하지 않는 경우

비관적 락 (Pessimistic Lock)

  • 모든 트랜잭션은 충돌이 발생한다고 가정
  • DB의 Lock을 사용하여 정합성을 맞추는 방법
  • 트랜잭션 커밋 전, 데이터를 수정하는 시점에 미리 트랜잭션 충돌을 감지

공유 락 (Shared Lock)

  • 트랜잭션의 읽기 O, 쓰기 X, 삭제 X
  • 다른 트랜잭션의 Shared Lock은 허용하지만 Exclusive Lock은 허용하지 않음
  • 여러 트랜잭션이 동시에 동일한 데이터를 읽을 수 있도록 허용
  • 조회한 데이터가 트랜잭션 내내 변경되지 않음을 보장

베타 락 (Exclusive Lock)

  • 락을 획득한 트랜잭션 외 다른 트랜잭션의 읽기 X, 쓰기 X, 삭제 X
  • 다른 트랜잭션의 Shared Lock, Exclusvie Lock을 허용하지 않음
  • 락을 소유한 트랜잭션만이 해당 데이터를 수정할 수 있음
  • 트랜잭션은 해당 데이터를 독점

비관적 베타 락의 흐름

트랜잭션이 시작되면 데이터에 대한 락 설정
락이 걸린 상태에서 데이터를 읽고 변경 작업 수행
작업이 완료되면 락 해제
재고가 4개 있는 상품 1개 주문이 동시에 10개의 주문이 들어왔을 때,
앞 4개의 주문은 수락되고 나머지 6개의 주문은 재고 부족으로 실패한다.

비관적 락이 적합한 환경

  • 여러 사용자가 동시에 동일한 데이터에 접근하는 경우
  • 데이터 수정이 경쟁적이고 충돌이 자주 발생하는 경우 (베타 락)
  • 재고 수량이나 금액과 같이 데이터 일관성이 매우 중요한 경우 (베타 락)

문제 해결 계획

동시에 여러 사용자가 재고에 접근하여 수정하려 하기 때문에 비관적 락을 사용
베타 락을 사용하면 락을 소유한 트랜잭션이 데이터를 독점하여 읽기, 쓰기가 가능
데이터의 무결성과 일관성을 보장하고, 사용자 경험을 향상시킴

교착상태 (DeadLock)

두 개 이상의 트랜잭션이 서로 상대방이 점유하는 자원을 기다려 무한 대기하는 상황

트랜잭션 A: 테이블1의 1번 데이터에 lock을 획득
트랜잭션 B: 테이블2의 1번 데이터에 lock을 획득
트랜잭션 A: 테이블2의 1번 데이터에 lock 획득 시도(실패 - 대기)
트랜잭션 B: 테이블1의 1번 데이터에 lock 획득 시도(실패 - 대기)

데드락 예방을 위해 보통 타임아웃을 설정 - 일정 시간이 지나도 락을 획득하지 못하면 트랜잭션 중단
현재 프로젝트에는 단일 자원에만 락을 사용하기 때문에 데드락 발생 가능성은 낮음

실습

Lock을 걸지 않은 상태, 비관적 락과 베타 락을 건 상태 비교

Lock 을 걸지 않은 상태

현재 열라면 재고는 10개다.

curl
계획했을 당시에는 테스트 코드로 동시 요청을 보내려고 했는데, curl을 사용해서 더 쉽게 동시에 API 요청을 보낼 수 있다는 것을 알게 되어 curl을 사용했다.

아무것도 하지 않은 상태에서 열라면 5개입 상품 2개 주문을 동시에 다섯번 요청해봤다.
재고는 10개 이기 때문에 첫 번째 주문만 받을 수 있다.

curl -d '{ "contact": 1012345678, "address": "엔터팰리스3차", "orderItemList": [ { "itemId": 1, "quantity": 2 } ] }' -H "Content-Type: application/json" -X POST localhost:8090/v1/orders & \
curl -d '{ "contact": 1012345678, "address": "엔터팰리스3차", "orderItemList": [ { "itemId": 1, "quantity": 2 } ] }' -H "Content-Type: application/json" -X POST localhost:8090/v1/orders & \
curl -d '{ "contact": 1012345678, "address": "엔터팰리스3차", "orderItemList": [ { "itemId": 1, "quantity": 2 } ] }' -H "Content-Type: application/json" -X POST localhost:8090/v1/orders & \
curl -d '{ "contact": 1012345678, "address": "엔터팰리스3차", "orderItemList": [ { "itemId": 1, "quantity": 2 } ] }' -H "Content-Type: application/json" -X POST localhost:8090/v1/orders & \
curl -d '{ "contact": 1012345678, "address": "엔터팰리스3차", "orderItemList": [ { "itemId": 1, "quantity": 2 } ] }' -H "Content-Type: application/json" -X POST localhost:8090/v1/orders &

하지만 실행 결과 5개 요청이 모두 수락되었다.

Lock 설정

  • JPA Repository를 사용한 경우는 @Lock 어노테이션 사용
  • QueryDSL을 사용한 경우는 setLockMode() 메서드 사용

JPA Lock 옵션

락 모드타입설명
낙관적 락OPTIMISTIC낙관적 Lock 사용
낙관적 락OPTIMISTIC_FORCE_INCREMENT낙관적 Lock + 버전 정보 강제 증가
비관적 락PESSIMISTIC_READ비관적 Lock, 읽기 Lock 사용
비관적 락PESSIMISTIC_WRITE비관적 Lock, 쓰기 Lock 사용
비관적 락PESSIMISTIC_FORCE_INCREMENT비관적 Lock + 버전 정보 강제 증가
기타NONE엔티티에 @Version이 있으면 낙관적 Lock을 적용함
기타READ하위 호환을 위한 것으로 OPTIMISTIC와 같음
기타WRITE하위 호환을 위한 것으로 OPTIMISTIC_FORCE_INVREMENT와 같음

QueryDSL에 setLockMode() 메서드를 추가하면 된다.

    public Product findProductWithLock(Long id) {
        return queryFactory.selectFrom(product)
                .where(product.id.eq(id))
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .fetchOne();
    }

.setLockMode(LockModeType.PESSIMISTIC_WRITE)

이번엔 재고를 20개로 두었다. 2개의 주문만 수락되어야 한다.

재고 부족 예외가 3번 발생했다.

미리 생성해둔 주문 5개를 제외하고 2개의 주문이 새로 생성되었다.

데드락 예방 - 타임아웃 설정

JPA Hint를 통해 QueryDSL에서 타임아웃을 설정할 수 있다.

    public Product findProductWithLock(Long id) {
        return queryFactory.selectFrom(product)
                .where(product.id.eq(id)
                        .and(product.deletedAt.isNull()))
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .setHint("javax.persistence.lock.timeout", 10000) // 10초
                .fetchOne();
    }

setHint() 메서드를 사용해 락 타임아웃을 설정한다.


다음공부
동시성과 병렬성
저 방법으로 타임아웃 실행 안됨 이거 적용했더니 됐음

참고
낙관 락 vs 비관 락 실무에서 구체적인 예시를 들어주실 수 있을까요?
상품 주문 동시성 문제 해결하기 - DeadLock, 낙관적 락(Optimistic Lock) & 비관적 락(Pessimistic Lock)
동시성 문제 해결하기 V2 - 비관적 락(Pessimistic Lock)
비관적 락은 무엇이고 왜/언제 사용할까?
[JPA] 비관적락을 사용해 동시성 문제 해결하기 (curl command로 동시요청)
[데이터베이스] MySQL의 Lock과 트랜잭션 모델
🐧 CURL 명령어 사용법 💯 완전 총정리
공유락(Shared Lock) & 배타락(Exclusive Lock)
재고시스템으로 알아보는 동시성이슈 해결방법
Spring 동시성 문제(데이터 정합성) 뿌셔보기~
JPA PESSIMISTIC_READ does not time out in the specified period

profile
안녕하세요

0개의 댓글