
멀티 스레드 환경의 서버를 만들다보면, 동시성 이슈가 발생하는 상황을 만나볼 때가 있다.
실제로 동시성 이슈를 만났던 적이 있는데, 그에 대해서는 여기 정리되어있다.
이런 동시 요청이 동일 테이블의 동일 row에 대해 발생했을 때 데드락이 발생하는 경우가 있다.
데드락이란 교착 상태를 의미하며 무한히 다음 자원을 기다리게 되는 상태를 말한다.
실제로 좋아요 개수 수정 기능을 구현하고자 했을 때 다음과 같은 상황으로 데드락이 발생했다.

이렇게 각 트랜잭션이 서로 배타 락을 획득하려고 하므로 서로를 기다리며 데드락이 발생한다.
이 때 배타 락이란, 읽기, 쓰기 모두 불가능한 것을 뜻한다. 공유 락은 읽기는 가능하지만 쓰기는 불가능한 것을 뜻함
이러한 상황을 어떻게 해결할 수 있을까?
공유되고 있는 자원에 접근 제한을 두는 기능인 락을 통해 이를 해결할 수 있다.
락에 대해 알아보기 전에, 동시에 들어온 요청에서 어떤 문제가 발생할 수 있는지 살펴보자.
트랜잭션 1이 수정하는 중에 트랜잭션 2가 수정을 시작했을 때, 1이 커밋하고 다음에 2가 커밋하면 1의 수정사항과 관련 없이 마지막 커밋내용인 2의 내용만 반영되는 문제이다. (트랜잭션 1이 분실되었다.)
이 문제의 해결 방법으로는 다음의 3가지 방법이 존재한다.
JPA는 데이터베이스에 대한 동시 접근으로부터 무결성을 유지할 수 있게 해주는 동시성 제어 매커니즘을 지원한다. 이 매커니즘에는 낙관적 락과 비관적 락이 존재한다.
락에 대해서 이해하려면 트랜잭션 격리 수준을 알아야하는데, 격리 수준에 관해서는 여기서 확인해볼 수 있다. JPA에서는 기본적으로 격리 레벨을 READ COMMITED로 가정한다.
@VersionJPA에서는 @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을 걸어서 해결하는 방식과 격리수준에 대해서도 한번 알아보겠습니다~!