멀티 스레드 환경의 서버를 만들다보면, 동시성 이슈가 발생하는 상황을 만나볼 때가 있다.
실제로 동시성 이슈를 만났던 적이 있는데, 그에 대해서는 여기 정리되어있다.
이런 동시 요청이 동일 테이블의 동일 row에 대해 발생했을 때 데드락이 발생하는 경우가 있다.
데드락이란 교착 상태를 의미하며 무한히 다음 자원을 기다리게 되는 상태를 말한다.
실제로 좋아요 개수 수정 기능을 구현하고자 했을 때 다음과 같은 상황으로 데드락이 발생했다.
이렇게 각 트랜잭션이 서로 배타 락을 획득하려고 하므로 서로를 기다리며 데드락이 발생한다.
이 때 배타 락이란, 읽기, 쓰기 모두 불가능한 것을 뜻한다. 공유 락은 읽기는 가능하지만 쓰기는 불가능한 것을 뜻함
이러한 상황을 어떻게 해결할 수 있을까?
공유되고 있는 자원에 접근 제한을 두는 기능인 락을 통해 이를 해결할 수 있다.
락에 대해 알아보기 전에, 동시에 들어온 요청에서 어떤 문제가 발생할 수 있는지 살펴보자.
트랜잭션 1이 수정하는 중에 트랜잭션 2가 수정을 시작했을 때, 1이 커밋하고 다음에 2가 커밋하면 1의 수정사항과 관련 없이 마지막 커밋내용인 2의 내용만 반영되는 문제이다. (트랜잭션 1이 분실되었다.)
이 문제의 해결 방법으로는 다음의 3가지 방법이 존재한다.
JPA는 데이터베이스에 대한 동시 접근으로부터 무결성을 유지할 수 있게 해주는 동시성 제어 매커니즘을 지원한다. 이 매커니즘에는 낙관적 락과 비관적 락이 존재한다.
락에 대해서 이해하려면 트랜잭션 격리 수준을 알아야하는데, 격리 수준에 관해서는 여기서 확인해볼 수 있다. JPA에서는 기본적으로 격리 레벨을 READ_UNCOMMITTED 정도로 가정한다.
@Version
JPA에서는 @Version
어노케이션을 통해 낙관적 락을 처리할 수 있다. 버전 관리용 필드를 통해 트랜잭션 내에서 처음 조회되었을 때의 버전과 이후 커밋될 때의 버전을 비교한다.
@Version
이 적용 가능한 타입은 Long, Integer, Short, Timestamp 이다.
아래와 같이 엔티티에 버전 관리용 필드를 만들어 적용한다.
@Getter
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/*
생략
*/
@Version
private int version;
}
트랜잭션 2의 입장에서 처음 조회했던 1이 아니라 2로 변경되어 기존에 조회한 버전과 다르므로 업데이트가 실패한다.
이 때 update 쿼리는 다음과 같다.
update product
set
# 수정할 필드
version = 2 (이전 버전에서 +1)
where
id = ?
and version = 1
이렇게 쿼리가 발생하고, 업데이트할 대상을 찾지 못해 예외가 발생한다.
락 옵션을 지정하지 않고 엔티티에 @Version을 적용하면 기본으로 적용되는 락 옵션이다.
NONE 옵션에서는 엔티티를 조회할 때 버전 체크를 하지 않는다. Optimistic 옵션에서는 조회 시에 버전을 체크한다.
==================Product Optimistic===================
Hibernate:
select
p1_0.id,
p1_0.brand,
p1_0.details,
p1_0.heart_amount,
p1_0.image_url,
p1_0.discount,
p1_0.price,
p1_0.title,
p1_0.version
from
product p1_0
where
p1_0.id=?
Hibernate:
select
version as version_
from
product
where
id=?
=======================================================
==================Product None=========================
Hibernate:
select
p1_0.id,
p1_0.brand,
p1_0.details,
p1_0.heart_amount,
p1_0.image_url,
p1_0.discount,
p1_0.price,
p1_0.title,
p1_0.version
from
product p1_0
where
p1_0.id=?
=======================================================
None은 수정 시점까지의 일관성을 보장한다면, Optimistic은 트랜잭션이 끝날 때까지 일관성을 보장한다.
비관적 락이라 하면 일반적으로 이 옵션을 뜻한다. 데이터베이스에 쓰기 락을 걸 때 사용한다. 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.
select for update
를 사용해서 락을 건다.
select for update
란, 데이터 수정하려고 SELECT 하는 중이니 다른 트랜잭션은 데이터에 대한 수정할 수 없음을 나타낸다. 좀 더 딱딱한 표현으로는 동시성 제어를 위하여 특정 데이터(ROW)에 대해 배타 락을 거는 기능이다.
데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다.
select for share
을 사용해서 락을 건다. (공유 락)버전 정보를 사용해 버전 정보를 강제로 증가시키는 비관적 락이다. 하이버네이트는 nowait를 지원하는 데이터베이스에 대해서 for update nowait옵션을 적용한다.
지원하지 않는다면 for update 사용.
for update nowait [= WAIT 0]
, LOCK을 획득하지 못하면 ORA-00054와 함께 바로 실패한다. (FOR UPDATE WAIT 0 도 같이 동작한다)
앞서 발생한 동시성 이슈을 비관적 락을 통해 트랜잭션 쿼리 순서를 조정해 해결할 수 있었다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findById(final Long productId);
@Lock
어노테이션을 통해 메서드를 오버라이딩한다.
public Product getProductById(final Long productId) {
return productJpaRepository.findById(productId)
.orElseThrow(() -> new NotFoundException(ErrorType.PRODUCT_NOT_FOUND_EXCEPTION));
}
public void decreaseHeart(final Long productId) {
getProductById(productId).decreaseHeart();
}
public void increaseHeart(final Long productId) {
getProductById(productId).increaseHeart();
}
비관적 락을 걸어준 트랜잭션을 사용해 decreaseHeart
와 increaseHeart
를 구현한다.
public HeartCreateResponse createHeart(final Long memberId, final Long productId) {
try {
Heart newHeart = create(memberId, productId);
productService.increaseHeart(productId);
return HeartCreateResponse.of(newHeart);
} catch (DataIntegrityViolationException e) {
throw new ConflictException(ErrorMessage.HEART_EXISTED_EXCEPTION);
}
}
public HeartDeleteResponse deleteHeart(final Long memberId, final Long productId) {
productService.decreaseHeart(productId);
delete(memberId, productId);
return HeartDeleteResponse.of(null);
}
위와 같이 구현한 후 API에 동시 요청을 보내면 다음과 같은 순서로 쿼리가 날라가고, 앞서 발견했던 데드락 상황을 해결할 수 있다.
낙관적 락과 비관적 락에 대해 알아보고, 겪었던 데드락에 대해 해결할 수 있었다.
하지만 락을 적용할 경우 동시성을 제어할 수 있지만 트랜잭션이 길어지면 기다리는 시간이 늘어나기 때문에 성능 저하가 발생할 수도 있다.
락을 통해 트랜잭션을 제어하고자 할 때, 성능에 대한 오버헤드도 고려하며 사용해야 한다.
자세히 설명해주신글 잘봤습니다!
@Transactional 어노테이션 붙이면 서비스에서 처리하는 요청에 대해 만능인줄 알았는데 이러한 트레이드오프에 대해서 생각해보지는 못했네요!
트랜잭션을 레포범위로 줄이고 Lock을 걸어서 해결하는 방식과 격리수준에 대해서도 한번 알아보겠습니다~!