동시성 이슈 해결방법
이번 시간에는 Database Lock을 활용한 해결방법을 알아보자.
해결법2. Database ( MySQL )
데이터베이스의 락을 이용해 동시성 문제를 해결해보자
1. Pessimistic Lock (비관적 락)
자원 요청에 따른 동시성문제가 발생할 것이라고 예상하고 미리 락을 걸어버리는 방법론
- 트랜잭션의 충돌이 발생한다고 가정합니다.
- 하나의 트랜잭션이 자원에 접근시 락을 걸고, 다른 트랜잭션은 접근하지 못하도록 한다.
- 데이터베이스의 Shared Lock(공유, 읽기 잠금)이나 Exclusive Lock(배타, 쓰기잠금)을 사용한다.
SELECT c1 FROM WHERE c1 = 10 FOR UPDATE
(배타 잠금)- 이는 레코드 락으로 인덱스 레코드에 락을 건다고 한다.
- Shared Lock의 경우, 다른 트랜잭션에서 읽기만 가능하다.
- Exclusive lock 적용이 불가능하다. ( 읽는 동안 변경하는 것을 막기 위해서 ! )
- Exclusive lock의 경우, 다른 트랜잭션에서 읽기,쓰기 모두 불가능하다.
- Shared, Excluisive lock 적용이 추가로 불가능하다. ( 쓰는 동안 읽거나, 다른 쓰기를 막기 위해서 ! )
- DB에 id가 1이고 name이 Yu인 컬럼이 있다고 가정해보자
- 트랜잭션 A가 Shared Lock으로 SELECT을 수행할 때 트랜잭션 B가 SELECT로 조회는 가능하다.
- 하지만 트랜잭션 A가 커밋되기 전에 트랜잭션 B의 update 요청은 Shared Lock에 의해 Blocking 되어 대기하고 있다가 트랜잭션 A가 커밋이 된다면 update를 실행하게 된다.
장점
- 충돌이 자주 발생하는 환경에 대해서는 롤백의 횟수를 줄일 수 있어 성능에서 유리하다.
- 데이터 무결성을 보장하는 수준이 높다.단점
- 데이터 자체에 락을 걸어버리기 때문에 동시성이 떨어져 성능 손해를 보게 된다.
- 특히, 읽기가 많이 이루어지는 데이터베이스의 경우에는 손해가 크다.
- 서로 자원이 필요한 경우에, 락으로 인해 데드락이 일어날 가능성이 있다.
2. Optimistic Lock ( 낙관적 락 )
자원에 락을 걸어서 선점하지 말고, 동시성 문제가 발생하면 그때가 가서 처리 하자는 방법론>
- 트랜잭션의 충돌이 발생하지 않는다고 가정합니다.
- 충돌이 나는 것을 막지 않고, 충돌이 난것을 감지하면 그때 처리한다.
- 그렇기 때문에, 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다.
- 일반적으로 version의 상태를 보고 충돌을 확인하고, 충돌이 확인된 경우 롤백을 진행한다.
- version말고도 hashcode, timestamp를 이용해서 확인도 가능하다.
- DB단에서 해결하는 것이 아닌 어플리케이션단에서 처리한다.
- DB에 id가 1이고 name이 Yu, version이 1인 컬럼이 있다고 가정해보자
- 트랜잭션 A,B가 id가 1인 row를 SELECT한다. ( name = Yu, version = 1 )
- 이후 트랜잭션 B가 해당 row 값을 갱신한다. ( name = Hwan, version = 2 )
- 이 상황에서 만약 트랜잭션 A가 row값을 갱신하려고 한다면 version이 다르기 때문에 해당 row를 갱신하지 못하게 된다. ( 이미 version이 2로 갱신되었기 때문에 )
장점
- 충돌이 안난다는 가정하에, 동시 요청에 대해서 처리 성능이 좋다.단점
- 잦은 충돌이 일어나는 경우 롤백처리에 대한 비용이 많이 들어 오히려 성능에서 손해를 볼 수 있다.
- 롤백 처리를 구현하는 게 복잡할 수 있다.
- 개발자가 수동으로 해줘야 한다.
3. Named Lock
- 이름과 함께 lock을 획득한다.
- 해당 lock은 다른 세션에서 획득 및 해제가 불가능하다.
- 주의할 점으로는 트랜잭션이 종료될 때 해당 락이 자동으로 해제되지 않기 때문에 수동으로 해제를 하거나 선점시간이 끝나야 해제가 된다.
- pessimistic lock과 유사하지만 pessimistic lock은 row나 테이블단위로 락을 걸지만 named lock은 이름으로 락을 건다.
정리
- 낙관적 락은 락을 미리 걸지 않기 때문에 성능적으로 비관적 락보다 더 좋다. ( 충돌이 많이 나지 않는다면 )
- 하지만 충돌이 많이 예상된다면 비용이 많이 들어가는 단점이 있다.
- 그렇기 때문에 비관적락은 데이터의 무결성이 중요하고, 충돌이 많이 발생하여 잦은 롤백으로 인한 효율성 문제가 발생하는 것이 예상되는 시나리오에 좋다.
- 낙관적락은 실제로 데이터 충돌이 자주 일어나지 않을 것이라고 예상되는 시나리오에 좋다.
해결법2 - 적용
1. Pessimistic Lock (비관적 락)
- JPA에서는
@Lock
어노테이션을 이용해 비관적 락을 쉽게 구현할 수 있다.- PESSIMISTIC_READ
- Shared Lock을 획득하고 데이터가 update, delete 되는 것을 방지한다.
- PESSIMISTIC_WRITE
- Exclusive Lock을 획득하고 데이터를 다른 트랜잭션에서 read,update,delete하는 것을 방지한다.
- PESSIMISTIC_FORCE_INCREMENT
PESSIMISTIC_WRITE
와 유사하지만@Version
이 지정된 Entity와 협력하기 위해 도입되어PESSIMISTIC_FORCE_INCREMENT
락을 획득할 시 버전이 업데이트 된다.
- PESSIMISTIC_READ
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
@Service
public class PessimisticLockStockService {
private final StockRepository stockRepository;
public PessimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
- 테스트가 통과하는 것을 볼 수 있고 쿼리를 보면
for update
가 붙는 것을 확인할 수 있다. - PESSIMISTIC_READ라면? ( Shared Lock )
- 테스트가 실패하며
for share
가 붙는 것을 확인할 수 있다. - 쓰기는 불가능하지만 읽는 것 까진 가능하기 때문에 처음 발생한 문제가 발생하여 테스트가 실패하게 된다.
- update시 blocking 되지만 조회한 stock의 수량이 update 되기 전 일 수 있기 때문에
- 테스트가 실패하며
결론
- 잦은 충돌이 일어난다면 낙관적 락보다 성능이 우수하며 데이터 무결성을 보장하지만 별도의 락을 잡기 때문에 성능 감소가 있을 수 있다.
2. Optimistic Lock ( 낙관적 락 )
JPA에서 낙관적 락을 사용하기 위해서는
version
컬럼을 추가하고@Version
어노테이션을 붙여야 한다.비관적 락과 동일하게
@Lock
어노테이션을 사용한다.- NONE
- 락 옵션을 적용하지 않아도 엔티티에
@Version
이 적용된 필드가 있다면 낙관적 락이 적용된다. - 암시적 잠금
@Version
이 붙은 필드가 존재하거나@OptimisticLocking
어노테이션이 설정되어 있을 경우 자동적으로 잠금이 실행된다. ( JPA가 해줌 )- 추가로 삭제 쿼리가 발생할 시 암시적으로 해당 row에 대한 Exclusive Lock을 건다.
- 락 옵션을 적용하지 않아도 엔티티에
- OPTIMISTIC
- 읽기시에도 낙관적 락이 걸린다.
- 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
- 이를 통해 dirty read와 non-repeatable read를 방지한다.
- OPTIMISTIC_FORCE_INCREMENT
- 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다.
- 논리적인 단위의 엔티티 묶음을 관리할 수 있다.
- 예를 들어, 양방향 연관관계에서 주인인 엔티티만 변경했을 때 주인이 아닌 엔티티는 변경되지 않았지만 논리적으로 변경되었으므로 버전을 증가시켜준다.
- READ, WRITE
- READ는 OPTIMISTIC와 같고 WRITE는 OPTIMISTIC_FORCE_INCREMENT와 같다.
- JPA 1.0의 호환성을 유지하기 위해 존재한다.
- NONE
발생하는 예외
- javax.persistence.OptimisticLockException(JPA 예외)
- org.hibernate.StaleObjectStateException(하이버네이트 예외)
- org.springframework.orm.ObjectOptimisticLockingFailureException(스프링 예외 추상화)
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
// ...
}
@Lock(value = LockModeType.OPTIMISTIC)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);
@Service
public class OptimisticLockStockService {
private final StockRepository stockRepository;
public OptimisticLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
- 서비스 로직까지는 위와 동일하지만 비관적 락은 충돌이 발생했을 때 직접 재시도 로직을 구현해야하기 때문에 Facade 패턴을 사용해서 구현한다.
@Service
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
this.optimisticLockStockService = optimisticLockStockService;
}
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
- optimisticLockStockService의 decrease 메서드를 호출하며 충돌이 일어날 경우 ( 예외가 발생한 경우 ) 50밀리초 이후 재시도 하도록 한다.
- 테스트가 통과하는 것을 볼 수 있고 update 쿼리를 보면 version이 조건으로 붙는 것을 확인할 수 있다.
- 또한,
ObjectOptimisticLockingFailureException
이 발생하는 것을 확인할 수 있다. @Lock
어노테이션이 없다면?@Version
필드가 있기 때문에 자동적으로 낙관적 락이 적용된다.
- NONE이라면?
- 마찬가지로 통과한다.
결론
- 별도의 락을 잡지 않으므로 낙관적 락보다 성능적으로 우수하지만 충돌이 일어날 경우 개발자가 직접 재시도 로직을 구현해야 한다.
- 또한, 충돌이 빈번하게 일어난다면 낙관적 락을 이용하는 것이 성능상 이점이 있다.
- 즉, 충돌이 빈번하게 일어나지 않는다면 비관적 락을 사용하고 빈번하게 일어난다면 낙관적 락을 사용하는 것이 좋다.
3. Named Lock
- 테이블 자체에 Lock을 거는 것이 아닌 별도의 공간에 Lock을 걸게 된다.
- 한 세션이 Lock을 획득한다면 다른 세션은 해당 세션이 Lock을 해제한 이후 획득할 수 있다.
- get_lock(str, timeout)
- A 세션이 1이라는 문자로 1000초동안 잠금 획득을 시도한다.
- 만약 A 세션이 획득을 성공하면 B세션은 동일한 이름의 잠금을 획득할 수 없다.
- get_lock을 이용한 잠금은 트랜잭션이 커밋되거나 롤백되어도 해제되지 않는다.
- get_lock의 결과값은 1, 0, null을 반환한다.
- 1 : 잠금 획득 성공
- 0 : timeout 초 동안 잠금 획득 실패
- null : 잠금 획득 중 에러가 발생했을 때
- release_lock(str)
- 이름의 잠금의 해제한다.
- 결과값으로 1,0,null을 반환한다.
- 1 : 잠금 해제 성공
- 0 : 잠금이 해제되지는 않았지만, 현재 쓰레드에서 획득한 잠금이 아닌 경우
- null : 잠금이 존재하지 않을 때
- 현재는 예제이니 동일한 DataSource를 사용하지만 실제로 사용할 경우 분리하여 사용해야한다.
- 같은 DataSource를 사용한다면 ConnectionPool이 부족할 수 있기 때문에 다른 서비스에 영향을 끼칠 수 있기 때문이다.
- 별도의 JDBC를 사용하거나 등의 방법이 있다.
public interface LockRepository extends JpaRepository<Stock, Long> {
@Query(value = "SELECT GET_LOCK(:key, 3000)", nativeQuery = true)
void getLock(@Param("key") String key);
@Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);
}
- lock을 얻기 위해 nativeQuery를 사용한다.
@Component
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final NamedLockStockService namedLockStockService;
public NamedLockStockFacade(LockRepository lockRepository, NamedLockStockService namedLockStockService) {
this.lockRepository = lockRepository;
this.namedLockStockService = namedLockStockService;
}
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
namedLockStockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
- 락을 얻고 해제하는 로직을 위해 Facade 패턴을 사용해서 구현한다.
@Service
public class NamedLockStockService {
private final StockRepository stockRepository;
public NamedLockStockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("재고가 존재하지 않습니다."));
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
- 여기서 주의해야할 점이 StockService의 경우 부모의 트랜잭션과 별도로 실행되어야 하기 때문에 propagation을 REQUIRES_NEW로 설정해야 한다.
- GET_LOCK()을 수행하는 쿼리가 실행 되고 트랜잭션이 종료되게 되면 pool에서 얻어온
spring:
datasource:
# ...
hikari:
maximum-pool-size: 40
- 또한, 같은 DataSource를 사용하기 때문에 커넥션 풀을 늘려줘야 한다.
- 테스트가 통과하는 것을 볼 수 있고 get_lock, release_lock 쿼리가 나가는 것을 볼 수 있다.
결론
- Named Lock은 주로 분산 락을 구현할 때 사용한다.
- 비관적 락은 timeout을 구현하기 어렵지만 Named Lock은 쉽게 구현이 가능하다.
- 그외에도 데이터 정합성을 맞춰야 할 때 사용할 수 도 있다.
- 하지만 이 방법은 트랜잭션 종료 시 락 해제와 세션 관리를 잘 해줘야 하기 때문에 주의해야 하며 실제로 구현방법이 복잡할 수 있다.
- 데이터베이스 Lock에 대해 어떤 원리로 이루어지고 어떻게 동작하는 지 깊게는 아직 모르겠지만 어느정도 흐름을 알 수 있었다. Lock에 대해 깊게 파보고 정리하는 시간을 가져봐야 겠다.
Reference
'Back-End > 강의' 카테고리의 다른 글
[재고시스템으로 알아보는 동시성이슈 해결방법] 3. Redis Distributed Lock (0) | 2022.08.30 |
---|---|
[재고시스템으로 알아보는 동시성이슈 해결방법] 2. Application Level에서의 해결법 (0) | 2022.08.26 |
[재고시스템으로 알아보는 동시성이슈 해결방법] 1. 들어가며 (1) | 2022.08.24 |
댓글