본문 바로가기

공부 정리/DB

낙관적 잠금과 비관적 잠금으로 동시성 해결하기

반응형

개요


동시성 문제를 이전 시간에 정리했는데, 결국 동시성 문제를 어떻게 해결할 것인가가 다음 관심사입니다. 따라서, 이번 시간에는 동시성 문제를 해결하기 위한 데이터베이스의 잠금 모델 2가지 낙관적 잠금과 비관적 잠금을 알아보려고 합니다. 낙관적 잠금은 상황을 낙관적으로, 비관적 잠금은 비관적으로 본다고 접근하면 됩니다.

 

 

비관적 잠금(Pessimistic Lock)


 비관적 잠금은 자원 경쟁을 비관적으로 보기 때문에, 다중 트랜잭션이 데이터를 동시에 수정할 것이라고 가정합니다.

 

따라서 하나의 트랜잭션이 데이터를 읽는 시점에서 락(Lock)을 걸고, 조회 또는 갱신 처리가 완료될 때까지 유지합니다. 조회 때 락 잠금을 획득하는 방법은 대표적으로 SELECT.. FOR UPDATE 입니다. SELECT...FOR UPDATE로 조회된 해당 로우는 잠금을 획득하여 해당 트랜잭션이 끝나기 전까지 다른 트랜잭션이 접근하지 못하고 대기해야 합니다.

 

장점은 트랜잭션의 동시 접근을 확실하게 방지할 수 있습니다.

단점은 트랜잭션이 완료되기 전까지 다른 트랜잭션이 접근하지 못해서 동시성이 떨어져 대기가 길어지고 성능이 떨어질 수 있습니다. 또한 자원을 점유한 채, 서로의 자원을 요청하는 데드락 상황이 발생할 수 있습니다.

 

아래처럼, 특정 고객에게 포인트를 적립해 주는 상황을 가정해보겠습니다. SELECT...FOR UPDATE로 고객번호가 CUST_NUM인 로우가 선택되었다면, 다른 어떤 트랜잭션도 해당 로우에 접근할 수 없습니다.

 

SELECT 적립포인트, 방문횟수, 최근방문일시, 구매실적 FROM 고객
WHERE 고객번호 = :CUST_NUM FOR UPDATE;

-- 새로운 적립 포인트 계산

UPDATE 고객 SET 적립포인트 = :적립포인트 WHERE 고객번호 = :CUST_NUM

 

 

다음과 같이 조건을 넣어주면, 무작정 대기하지 않고 대기시간을 줄 수 있습니다.

NO WIAT의 경우 잠금을 획득하지 못하면 바로 예외를 발생합니다.

WAIT 3은 3초 이후에 예외가 발생해서 "다른 사용자에 의해 변경중이므로 다시 시도하십시오"와 같은 예외 처리를 할 수 있습니다.

 

FOR UPDATE NOWAIT --> 대기없이 Exception (ORA-00054)을 던짐
FOR UPDATE WAIT 3 --> 3초 대기 후 Exception (ORA-30006)을 던짐

 

 

낙관적 잠금(Optimistic Lock)


낙관적 잠금은 자원 경쟁을 낙관적으로 바라보기 때문에, 다중 트랜잭션이 데이터를 동시에 수정하지 않는다고 가정합니다.

 

따라서 데이터를 읽을 때는 락(Lock)을 설정하지 않습니다. 하지만, 이것이 트랜잭션에 의해 잘못된 갱신을 알아서 방지하지 않습니다. 읽은 시점에서 락을 사용하지는 않았지만, 데이터를 수정하는 시점에서 앞에 읽은 데이터가 다른 사용자에 의해 변경되었는지 검사해야합니다.

 

 장점은 읽기에서 락 잠금을 사용하지 않기 때문에 성능이 좋습니다. 동시 업데이트가 없는 경우에 이 방법을 사용하면 비관적 잠금보다 빠르게 조회 및 업데이트를 할 수 있습니다.

 단점은 여러 트랜잭션이 작업 중에 하나의 트랜잭션이 로우를 변경시키면, 다른 트랜잭션의 작업이 거부되어 오류 처리 혹은 재시도를 처리해야 합니다.

 

Optimistic vs. Pessimistic locking

 

 

 낙관적 잠금의 경우 UPDATE가 실패해도 자동으로 예외를 던지지 않습니다. 단순히 0개의 로우를 업데이트하며, 개발자가 직접 예외처리를 해주어야 합니다.

 

비교 후 저장하기 - CAS(Compare And Set)


 특정 고객을 SELECT 하고 UPDATE를 하고 나서, SQL%ROWCOUNT를 통해서 변경이 되었는지 확인합니다. 0이라는 의미는 아무것도 UPDATE가 되지 않았음을 의미합니다. 즉, 다른 곳에서 이미 업데이트를 했으므로, '다른 사용자에 의해 변경되었습니다'라는 처리를 합니다.

 

하지만 SELECT 절이 많아질수록 작업해야 하는 코드 양이 길어집니다. 내가 SELECT 한 순간의 정보가 최신임을 확인해야 하기 때문입니다.

 

SELECT 적립포인트, 방문횟수, 최근방문일시, 구매일시 INTO :A, :B, :C, :D
  FROM 고객
 WHERE 고객번호 = :CUST_NUM;

 --새로운 적립포인트 계산

UPDATE 고객 SET 적립포인트 = :적립포인트
 WHERE 고객번호 = :CUST_NUM
   AND 적립포인트 = :A
   AND 방문일시 = :B
   AND 최근방문일시 = :C
   AND 구매실적 = :D

IF SQL%ROWCOUNT = 0 THEN

  ALTER( '다른 사용자에 의해 변경되었습니다.');

END IF;

 

 

변경 일시 칼럼으로 관리하기


 최종 변경 일시(MOD_DT) 칼럼을 통해서 해당 로우의 갱신 여부를 판단합니다. 최종 변경 일시로 UPDATE문을 간단하게 만들 수 있습니다.

 

혹은 다중 트랜잭션이 동시에 SELECT로 특정 고객을 조회를 할 때, 무조건 1개만 락을 잠금 해서 동작할 수 있도록 SELECT..FOR UPDATE NOWAIT를 추가할 수도 있습니다. 즉, 현재 내가 조회하고 싶은 고객번호가 다른 트랜잭션에서 이미 잠금 되어 있다면 바로 예외를 던져 불필요한 대기를 제거합니다.

 

하지만, INSERT/UPDATE/DELETE를 할 때마다 변경 일시를 추가해야 하는 번거로움이 있습니다.

 

SELECT 적립포인트, 방문횟수, 최근방문일시, 구매실적, 변경일시
  INTO :A, :B, :C, :D, :MOD_DT
  FROM 고객
 WHERE 고객번호 = :CUST_NUM;

 --새로운 적립포인트 계산

--다른 트랜잭션에 의해 설정된 Lock 때문에 동시성이 저하되는 것을 예방할 수 있다.
SELECT *
  FROM 고객
 WHERE 고객번호 = :CUST_NUM
   AND 변경일시 = :MOD_DT
   FOR UPDATE NOWAIT; -- NOWAIT

UPDATE 고객 SET 적립포인트 = :적립포인트
 WHERE 고객번호 = :CUST_NUM
   AND 변경일시 = :MOD_DT;

IF SQL%ROWCOUNT = 0 THEN

  ALTER( '다른 사용자에 의해 변경되었습니다.');

END IF;

 

 

재고 차감 어떻게 할래? 신청 마감 어떻게 할래?


 재고 차감 시, 동시에 차감하다가 재고가 마이너스가 된다면, 신청의 정원이 초과되어서 신청이 된다면 어떻게 해야 할까요? 동시성 문제로 발생할 수 있는 해당 문제들을 어떻게 해결해야 할까요?

 

 스터디 신청의 경우를 예로 들어보겠습니다. 예를 들어 몇 자리 안 남은 스터디 신청을 20명이 동시에 한다면 어떻게 될까요? 스터디 신청을 낙관적 잠금으로 해결한다면 하나의 트랜잭션만 특정 로우들에 접근 권한이 있습니다. 따라서 해당 로우에 접근하는 다른 트랜잭션들은 "자리가 없으니 잠시 후 다시 시도해주세요"라는 문구를 던지거나, 재시도를 해야 합니다, 하지만 전자는, 자리가 있음에도 다시 시도해야 하는 불편함이 있고, 후자는 하나의 트랜잭션 이외에 모든 다른 트랜잭션이 또다시 경합에 들어가고 경쟁이 불가피합니다.

 

 따라서, 조금 대기를 하더라도 다시 시도하라는 문구 안내가 없으며 재시도 없이 한 번에 처리가 가능한 비관적 잠금을 추천합니다. 예를 들어, 몇 자리 안 남은 스터디를 한 번에 20명이 동시에 신청하려고 하더라도 순차적으로 대기했다가 처리가 가능합니다. 

 

 

낙관적 잠금 코드(JPA)


//Study.java
public void apply(User user) {
    if(this.applyCount + 1 > this.capacity) {
    	throw new StudySizeFullException("스터디 신청자 정원이 마감되었습니다");
    }
    this.applyCount += 1;
    users.add(user);
    user.addStudy(this);
}

//StudyService.java
public void apply(Long studyId, User user) {
    Study study = studyRepository.findByIdForUpdate(studyId);
    
    study.apply(user);
}

//StudyRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Study s where s.id = :id")
Study findByIdForUpdate(Long Id);

 

  • Study.java

스터디 지원하는 apply 함수가 있습니다. 지원한 회원(User)의 정보를 추가합니다. 만약 현재 지원자에서 1명을 더했을 때, 정원(capacity)을 초과한다면 "스터디 신청자 정원이 마감되었습니다"라는 메시지와 함께 예외를 던집니다. 그렇지 않다면

지원자 수를 1 증가시키고 스터디에 회원을 추가합니다.

 

  • StudyServaice.java

스터디를 조회하고, 지원을 추가합니다. 조회하는 메서드명이 findByForUpdate로 만든 이유는, 해당 조회에서 락을 걸기 때문입니다. 락을 건 상태에서, apply를 호출하여 스터디 신청을 추가합니다

 

  • StudyRepository.java

스터디를 조회할 때, 조건절에 넣은 id에 해당하는 스터디에 락을 겁니다. 이는 @Lock을 사용하여 가능하며, 비관적 잠금을 사용하기 때문에 LockModeType.PESSIMISTIC_WRITE를 사용합니다.

 

 

 

exclusive lock vs shared lock


락은 크게 2가지가 있습니다: 공유 락(shared lock)과 배타 락(exclusive lock)


공유 락을 가지고 있으면, 읽기는 되지만 쓰기는 되지 않습니다. 

 

하지만 배타 락이 있다면 수정과 삭제가 모두 가능합니다. 

또한 SELECT ... FOR UPDATE 를 통해 배타 락을 획득할 수 있습니다.

 


JPA 명세는 3가지 비관적인 락 방법을 가지고 있습니다.

 

  • PESSIMISTIC_READ

공유 락을 획득하며, dirty read를 방지할 때 사용합니다. 하지만 수정과 삭제가 불가능합니다

 

  • PESSIMISTIC_WRITE

배타 락을 획득할 수 있고, 다른 트랜잭션의 데이터의 읽기, 수정, 삭제를 제한합니다
주의할 것은, MVCC를 구현하는 일부 데이터베이스 시스템들은 이미 차단된 데이터를 조회하도록 허용합니다.

 

  • PESSIMISTIC_FORCE_INCREMENT

PESSIMISTIC_WRITE와 비슷한데, version 값을 사용합니다.
@Version을 엔티티의 변수에 선언하면 됩니다. 수정 시, 해당 version의 값이 증가합니다.

3가지 락 방식은 트랜잭션이 커밋되거나 롤백될 때까지 유지됩니다. 한 번에 하나의 락만 획득할 수 있습니다

 

 

 

* 출처

https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking

https://www.ibm.com/docs/ko/rational-clearquest/9.0.0?topic=clearquest-optimistic-pessimistic-record-locking

http://wiki.gurubee.net/pages/viewpage.action?pageId=21626883

반응형

'공부 정리 > DB' 카테고리의 다른 글

JDBC 의 역사  (0) 2022.09.01
DB의 인덱스와 B-tree, B+tree  (0) 2022.08.24
트랜잭션 격리 수준  (0) 2022.08.12
고급매핑 - 상속관계  (0) 2022.07.07
JDBC란?  (0) 2022.07.05