Lock 이란?
고립성(Isolation)을 보장하기 위한 방법
데이터 항목에 상호 배타적으로 접근이 되도록 하는 것
Lock을 사용함으로 해당 Lock을 가지고 있는 Transaction(사용자, 요청)만이 해당 데이터에 접근을 할 수 있다
Lock 과 Transaction
그러면 Lock과 Transaction의 차이는 무엇이 있는지 알아보면
Transaction은 논리적 작업 단위를 이루는 연산들의 집합(All or Nothing - Atomicity 보장) 이고
Lock은 Transaction이 동일한 데이터 갱신 작업을 할 수 없도록 하도록 구현한 방법 중 하나이다
단순히 SELECT * FROM MEMBER;
와 같이 조회 만 하는 경우에는 일반적으로 데이터베이스 시스템은 Lock을 걸지 않는다
또한 UPDATE MEMBER SET NAME='NEW_NAME' WHERE ID = 1;
과 같이 수정을 하는 경우 수정 중인 record에 대해서 Lock을 걸어서 다른 Transaction이 동시에 수정을 못하도록 방지하고 Commit 시점에 해제가 된다 이때 수정을 할 다른 Transaction은 대기 중인 상태에 있다가 해제 될 때 접근이 가능해진다
Lock
모든 트랜잭션은 동시성 제어 관리 모듈에게 적절한 모드의 락에 대한 허락 요청(request)이 있어야지 연산을 수행 할 수 있다
종류
- 공유(shared) 락 : S-Lock mode
락을 가지고 있지 않은 트랜잭션 : 읽기 가능, 갱신 불가
SELECT 중 다른 Transaction과 충돌하지 않고 데이터 공유가 필요한 시점에 사용
BEGIN; SELECT * FROM MEMBER WHERE ID = 1 FOR SHARE; -- 데이터 읽기 COMMIT;
- 독점적(exclusive) 락 : X-Lock mode
UPDATE, DELETE 시 사용
락을 가지고 있지 않은 트랜잭션 : 읽기, 갱신 모두 불가
BEGIN; SELECT * FROM MEMBER WHERE ID = 1 FOR UPDATE; -- 데이터 수정 COMMIT;
S-Lock | X-Lock | |
READ | O | X |
WRITE | X | X |
Lock 사용 시 발생할 수 있는 문제점
A, B에 값을 넣는 것이 하나로 관리가 되어져야 할 경우
T1에서 한번에 관리가 되어야 하는 연산이 각각의 Lock으로 관리가 되어 풀리는 직후 T2에서 값을 가져다가 사용해서 서로 다른 값을 반환하게 된다
T1 | T2 |
lock-x(B); read(B); B := B-50; write(B); unlock-x(B) |
|
lock-s(B); read(B); unlock-s(B); lock-s(A); read(A); unlock-s(A); print(A + B) |
|
lock-x(A); read(A); A := A+50; write(A); unlock-x(A); print(A + B) |
DeadLock(교착 상태)이 발생할 경우
T1은 T2가 가진 A에 대해서 Lock을 요청하고 T2는 T1이 가진 B에 대해서 Lock을 요청중이기 때문에 서로 무한정 대기(교착 상태)를 하게 된다
DeadLock 해결 방법
- DeadLock이 발생하지 않도록 DeadLock 예방 규약 사용
DeadLock에 빠질 확률이 상대적으로 높을 때 사용
- 가능하면 Lock을 요청하는 순서에 있어서 대기 사이클이 생기지 않도록 요청 불가능 하면 필요한 Lock을 한번에 요청
- 필요한 Lock이 무엇인지 알기 어렵거나 상당수의 데이터에 Lock이 걸려있을 수 있는 단점이 있다
- DeadLock이 발생 할 것 같으면 대기 대신 Transaction Rollback 실행
- 두 개의 Transaction 중 하나를 Rollback하고 선점하고 있던 Lock을 또 다른 Transaction에 부여하는 방법
- 어떤 Transaction을 Rollback해야 하는지 결정하는 방법으로는 TimeStamp 기법을 사용
- 만약 Transaction이 Rollback되면 재시작 할 때 예전 TimeStamp를 그대로 이용
- 시간 초과를 기반으로 특정 시간만큼 대기 후 Rollback
- 가능하면 Lock을 요청하는 순서에 있어서 대기 사이클이 생기지 않도록 요청 불가능 하면 필요한 Lock을 한번에 요청
- 시스템이 DeadLock에 빠질 수 있도록 하고 DeadLock 탐지와 DeadLock 복구 기법을 사용
DeadLock에 빠질 확률이 상대적으로 낮을 때 사용
시스템의 상태를 확인하는 알고리즘이 DeadLock이 발생했는지 알기 위해 주기적으로 실행되어야 한다- DeadLock 탐지
- 대기 그래프라고 불리는 방향성 그래프를 이용하여 표현 가능
- 그래프 내에 사이클이 생긴다면 DeadLock 발생한 것
T1은 T2가 가진 데이터 항목을 대기 중 T2은 T3가 가진 데이터 항목을 대기 중 T3은 T1가 가진 데이터 항목을 대기 중
- DeadLock 복구
- 희생자 선택
- 어떤 Transaction이 Rollback되어야 하는지 선택
- 다음 기준을 가지고 가능한 최소 비용이 드는 Transaction을 선택
- 얼마나 오래 실행이 되었는지
- Commit되기 까지 얼마나 많은 연산이 남아있는지
- 접근한 데이터 항목이 얼마나 많은지
- Commit되기 위해 얼마나 더 많은 데이터가 필요한지
- 얼마나 많은 Transaction들이 Rollback에 포함되는지
- Rollback
- 전체 Rollback, 부분 Rollback중 선택
- 기아
- Rollback을 진행하다가 같은 Transaction이 반복적으로 Rollback이 계속 될 수 있는 상황인 경우 최소 비용 기준에 Rollback 횟수를 포함시켜 선택을 할 수 있다
- 희생자 선택
- DeadLock 탐지
기아 상태
T1 | T2 | T3 | ... | Tn |
lock-s(A); -- 작업 |
||||
lock-x(A); | lock-s(A); -- 작업 |
... | lock-s(A); -- 작업 |
|
unlock-s(A); |
위와 같이 계속해서 하나의 데이터 레코드에 대해 공유 락을 요청하는 경우 독점 락을 요청하는 Transaction에 대해서는 무한정 대기 상태가 이루어지는 상태를 기아 상태라고 한다
이럴 경우 동시성 제어 모듈에서는 아래 두가지 경우에 한해 Lock을 허용해준다
- 특정 모드 M과 충돌되는 Lock 모드로 항목 Q에 Lock을 걸고 있는 Transaction이 없는 경우
- Tn보다 먼저 항목 Q에 Lock을 요청해서 기다리고 있는 Transaction이 없는 경우
JPA Lock
낙관적(optimistic) Lock
- Transaction의 충돌이 발생하지 않는다고 가정하는 방법으로 JPA에서 제공하는 버전 관리 기능(
@Vesion
)을 사용한다- 엔티티의 값을 수정하면 버전 정보도 같이 수정된다
- 단 연관관계의 주인 필드를 수정할 때만 버전이 증가
- 애플리케이션 단에서 제공해주는 Lock이다
Mode
- None : 엔티티 수정 시 버전을 증가, 데이터베이스의 버전 값이 현재 버전 값이 아니면 예외를 발생시킨다
- Optimistic : 엔티티 조회만 하더라도 버전을 확인, Transaction Commit 시점에 버전이 다르면 예외를 발생시킨다
- Optimistic_force_increment : 엔티티 조회만 하더라도 버전을 증가, 데이터베이스의 버전이 엔티티의 버전과 다르면 예외를 발생시킨다
비관적(pessimistic) Lock
- Transaction의 충돌이 발생한다고 가정하고 우선 Lock을 거는 방법이다
- 앞서 알아봤던 데이터베이스에서 제공해주는 락 사용한다
- 락을 획득할 때 까지 트랜잭션이 무한정 대기(기아상태 해결 위해 타임아웃 시간 지정 가능)
Mode
- PESSIMISTIC_WRITE : select for update 구문을 사용하여 데이터베이스에 X-Lock을 걸어둔다
- PESSIMISTIC_READ : 반복 읽기만 하고 수정하지 않는 용도로 S-Lock을 걸어둔다
- PESSIMISTIC_FORCE_INCREMENT → 낙관적 락과 동일하게
@Version
을 사용해서 버전 정보도 같이 증가시킨다
Code Test
환경: 총 20명의 사람이 접근, 랜덤하게 0.1초 이내의 시간동안 잠시 멈추도록 하여 접근 시간 조절
@BeforeEach
void init() {
executeCount = 20;
executorService = Executors.newFixedThreadPool(10);
countDownLatch = new CountDownLatch(executeCount);
successCount = new AtomicInteger();
failCount = new AtomicInteger();
}
private void executeThread(Runnable service) throws InterruptedException {
for (int i = 0; i < executeCount; i++) {
executorService.execute(() -> {
try {
Thread.sleep(new Random().nextLong(100));
service.run();
successCount.getAndIncrement();
System.out.println("쿠폰 발급 성공");
} catch (Exception e) {
failCount.getAndIncrement();
System.out.println(e.getMessage());
}
countDownLatch.countDown();
});
}
countDownLatch.await();
}
private void checkResult(int successCreateCouponCount) {
System.out.println("발급한 쿠폰 개수 : " + successCount.get());
System.out.println("실패한 횟수 : " + failCount.get());
assertThat(successCount.get() + failCount.get()).isEqualTo(executeCount);
assertThat(successCount.get()).isEqualTo(successCreateCouponCount);
assertThat(failCount.get()).isEqualTo(executeCount - successCreateCouponCount);
}
Lock 적용 없을 때
아무런 접근에 대한 제한이 없기 때문에 쿠폰을 발급하는 과정에서 10개만이 발급이 되어야 하는데 동시에 접근이 가능하여 10개가 넘는 수의 쿠폰이 발급된다
@Entity
public class Coupon {
@Id
@GeneratedValue
private Lond id;
private int count;
public void releaseCoupon() {
if (count <= 0) {
throw new IllegalStateException("이미 모든 쿠폰 발급 완료되었습니다");
}
this.count--;
}
}
@Test
void 동시_접근으로_인해_발급한_쿠폰_개수가_매번_달라진다_발급_쿠폰_수_10개_이상() throws InterruptedException {
couponCount = 10;
couponId = couponService.create(new Coupon("축구 결승에는 치킨이지", couponCount));
executeThread(() -> couponService.getCoupon(couponId));
checkResult(0);
}
public class CouponSerivce {
public void getCoupon(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("해당 쿠폰이 없습니다"));
coupon.releaseCoupon();
}
}
낙관적 락을 적용했을 때
JPA에서 낙관적 락은 버전 정보를 이용하여 최초 커밋 만을 인정하기 때문에 발급된 쿠폰의 수가 10개 이하로 나온다
@Entity
public class OptimisticCoupon {
@Id
@GeneratedValue
private Long id;
private String name;
private int count;
@Version
private int version;
}
@Test
void 최초_커밋만_인정으로_인해_발급한_쿠폰_개수가_매번_달라진다_발급_쿠폰_수_10개_이하() throws InterruptedException {
couponCount = 10;
couponId = couponService.create(new OptimisticCoupon("축구 결승에는 치킨이지", couponCount));
executeThread(() -> couponService.getWithOptimisticCoupon(couponId));
checkResult(0);
}
public class CouponService {
public void getWithOptimisticCoupon(Long couponId) {
OptimisticCoupon coupon = optimisticCouponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("해당 쿠폰이 없습니다"));
coupon.releaseCoupon();
}
}
비관적 락을 적용했을 때
데이터의 접근 시작 부터 Lock을 걸어서 사용하기 때문에 다른 스레드에 의해 수정되는 일이 없어 정해진 수량 만큼 쿠폰이 발급된다
@Entity
public class PessimisticCoupon {
@Id
@GeneratedValue
private Long id;
private String name;
private int count;
}
@Test
void 비관적락을_사용해서_모든_쿠폰이_안정적으로_발급된다() throws InterruptedException {
couponCount = 10;
couponId = couponService.create(new PessimisticCoupon("축구 결승에는 치킨이지", couponCount));
executeThread(() -> couponService.getWithPessimisticCoupon(couponId));
checkResult(couponCount);
}
public interface PessimisticCouponRepository extends JpaRepository<PessimisticCoupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<PessimisticCoupon> findWithPessimisticLockById(Long id);
}
public class CouponService {
public void getWithPessimisticCoupon(Long couponId) {
PessimisticCoupon coupon = pessimisticCouponRepository.findWithPessimisticLockById(couponId)
.orElseThrow(() -> new IllegalArgumentException("해당 쿠폰이 없습니다"));
coupon.releaseCoupon();
}
}