Dev Log
동시성을 위한 여러가지 기법 정리
DevHyo
2021. 9. 26. 00:21
1. 비관적 락
- select ~ for update 구문을 사용한다.
- 한 Row의 Lock을 획득하기 하기 위해, 무한정 기다리는 이슈가 발생할 수 있고, 서비스 장애를 야기할 수 있다. (Timeout 설정이 없음)
- REPEATABLE READ 격리 수준을 사용하고 있다면, 갱신 분실의 문제(Lost Update)가 발생할 수도 있다.
- RDMBS에서 제공하는 Lock 기능을 사용한다.
- 언두 영역(InnoDB 스토리지 엔진에 내부에 있는) 레코드에 잠금을 걸 수 없고, 테이블 레코드에 잠금을 건다.
2. JPA에서 지원하는 @Version
- @Version을 명시하여, 수정이 일어나는 시점에 Version이 일치하지 않으면, 예외를 발생시킨다.
- REPEATABLE READ 격리 수준에서도 갱신 분실의 문제(Lost Update)를 예방할 수 있다.
- Version의 최초 Commit만 인정하고, 이를 통해 갱신 분실의 문제(Lost Update)를 예방한다.
- 최초 Commit이 아닌 나머지 다른 트랜잭션의 경우, ObjectOptimisticLockingException이 발생한다.
- ObjectOptimisticLockingException은 RuntimeException을 상속받고 있기 때문에 해당 트랜잭션에서 Exception이 발생되면, Rollback 된다.
- ObjectOptimisticLockingException 발생된 트랜잭션의 경우, 이후의 작업을 진행해야 한다면, 추가적인 구현이 필요하다. (Retry 처리)
- JPA 특정 기술에 의존한다. (다른 ORM을 사용하게 되면, 이를 구현해야 함.)
- @Version의 경우 DB Lock을 사용하는 방식이 아니다.
- 데이터를 수정하고, 삭제하는 경우에 주로 쓴다.
- Application 수준에서 처리된다.
- 동시에 여러명이 접근하는 경우 한 명을 제외한 나머지는 작업이 충돌하고, 실패한다.
- 문제는 충돌이 마지막에 감지 된다는 점
- 사용자 입장에서는 시스템에 대한 신뢰가 떨어진다.
- 위의 작성한대로 이 후의 작업을 진행해야 한다면, 추가적인 구현이 필요 (Retry)
- 비관적 락의 경우에는 초기에 실패하기 때문에 사용자가 큰 거부감 없이 받아 들일 수 있다.
3. 분산 락
- 공통된 저장소를 사용하여, 자원이 사용 중인지 체크하고, 획득하고, 반납하면서 분산된 서버의 동기화된 처리를 지원한다.
- 갱신 분실의 문제(Lost Update)를 예방한다.
- Lock이 사용 중이 아니라면, Timeout을 걸고 Lock을 획득한다.
- Lock이 사용 중이라면, 일정 시간 대기하였다가 Lock을 획득하고, 작업을 수행한다.
- Lock을 획득하기 위해 대기하고 있는데 일정 시간이 지나면, Exception 처리를 해야 한다.
- Lock을 획득하고 어떠한 문제로 인해 애플리케이션 서버가 죽었다면, Lock은 반드시 반납되어야 한다.
(대기하고 있는 여러 서버들이 본인의 작업을 수행해야 하기 때문에 Lock은 획득 가능한 상태여야 한다.) - 동시 요청이 와도, Lock을 통해 순서대로 작업이 진행되고, 별도의 로직이 필요하지 않다.
(JPA @Version의 경우, Exception이 발생한 트랜잭션의 경우, 자신의 작업 수행을 위해 Retry를 구현해야 한다.) - Redisson, MySQL 엔진의 유저 락을 사용해서 구현할 수 있다.
3.1 MySQL 유저 락을 활용한 분산 락
- GET_LOCK(문자열, Timeout)
- Timeout(초) 동안 잠금을 획득한다.
- 결과 값 1 : 잠금 획득 성공
- 결과 값 0 : Timeout 초 동안, 잠금 획득 실패
- 결과 값 null : 잠금 획득 중 에러 발생
- 한 트랜잭션에서 잠금을 획득했으면, 다른 트랜잭션에서는 잠금을 획득할 수 없다.
- 각 트랜잭션에서 동시에 잠금을 획득하기 위해서 대기할 때, 대기 순서는 보장되지 않는다.
- IS_FREE_LOCK(문자열)
- 입력한 문자열에 해당하는 잠금이 획득 가능한지 확인한다.
- 결과 값 1 : 입력한 이름의 잠금이 없을 때
- 결과 값 0 : 입력한 이름의 잠금이 있을 때
- 결과 값 null : 에러 발생(ex : 잘못된 인자)
- RELEASE_LOCK(문자열)
- 입력받은 문자열의 잠금을 해제한다.
- 결과 값 1 : 잠금 해제 성공
- 결과 값 0 : 잠금 해제 실패
- 결과 값 null : 잠금이 존재하지 않음
- IS_FREE_LOCK(문자열)를 계속해서 체크하는 스핀 락을 애플리케이션 서버에서 구현해야 한다.
- 락을 획득한 트랜잭션을 제외하고, 대기하고 있는 트랜잭션에서는 자신의 작업을 처리하기 위해 계속해서 락 획득 여부를 체크해야 함
- 스핀 락으로 애플리케이션에서 처리한다면? Loop를 돌면서, MySQL에 IS_FREE_LOCK(문자열)을 계속해서 요청하여, 락의 획득 가능 여부를 체크한다.
- 스핀 락은 MySQL에 계속해서 IS_FREE_LOCK(문자열)을 요청하기 때문에, DB 부하가 심해진다.
- 비즈니스 로직의 DB Connection Pool과 Lock을 획득하는 DB Connection Pool을 분리해야 함.
- @Transaction안에서 MySQL의 유저 락을 사용하다가 다른 인프라를 사용하게 되면, @Transaction안의 비즈니스 로직 부분도 변경해야 하기 때문에 별개의 작업으로 분류해야 한다.
- Lock은 트랜잭션 Commit / Rollback 여부와 상관없이 작동한다. Rollback 한다고 해서 Lock이 풀리지는 않는다.
3.2 Redisson을 활용한 분산 락
- Lettuce와 비슷하게 Netty를 사용하여 non-blocking I/O를 사용
- Redisson의 특이한 점은 직접 레디스의 명령어를 제공하지 않고, Bucket이나 Map 같은 자료구조나 Lock 같은 특정한 구현체의 형태로 제공
- Lock에 Timeout이 구현되어 있음
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException
- waitTime 만큼 시간이 지나면, Lock 획득 실패를 알려줌
- leaseTime만큼 시간이 지나면, Lock이 만료되어 사라지기 때문에 특정 서버에서 Lock을 해제하지 않더라도, 다른 서버 또는 스레드에서 Lock을 획득할 수 있음
- 스핀 락을 사용하지 않고, Redis의 Pub/Sub 기능을 사용하여 Redis의 부하를 줄인다.
- tryLock()을 호출하여, Lock을 획득한다.
- Lock을 획득하지 못한 작업에서는 Pub/Sub을 활용하여, Lock이 해제되었다는 메시지가 오면, 대기를 풀고 Lock 획득을 시도한다. Lock 획득에 실패하면, 다시 Lock 해제 메시지를 기다린다. 이 과정을 Timeout 까지 반복한다.
- Timeout이 지나면, 최종적으로 Lock 획득에 실패했음을 알린다.
- Redisson에서 Lock 획득 가능 여부와 Lock 획득은 원자적 단위로 수행되고 있다.
- Redission에서 Lock 해제와 Pub/Sub 알림은 원자적 단위로 수행되고 있다.