@Transactional
public void updateLikeCount(long productId) {
Product foundProduct = getProduct(productId);
int likeCount = foundProduct.getLikeCount();
int newLikeCount = likeCount + 1;
foundProduct.setLikeCount(newLikeCount);
}
getProduct() 메서드를 호출하여 productId에 해당하는 제품을 검색한다.likeCount)를 가져온다.newLikeCount를 계산한다.likeCount를 newLikeCount로 업데이트한다.웹 애플리케이션은 멀티스레드 환경에서 동작하기 때문에 다음과 같은 동시성 이슈가 발생할 수 있습니다.
여러 스레드(사용자)에서 동시에 updateLikeCount() 메서드를 호출할 경우, 같은 제품에 대한 좋아요 증가 요청이 동시에 처리되고 이로인해 예상치 못한 결과를 얻을 수 있습니다. 이를 Race Conditon이라고 합니다.
예를들어 현재 좋아요 수가 5인 게시물에 두 사용자가 동일한 상품에 좋아요를 동시에 눌렀다면, 두 스레드가 같은 likeCount(좋아요)를 읽고 각각 1을 증가시킵니다. 이는 실제로 7이 될 수도 있고 6이 될수도 있을 것입니다.
이런 Race Conditon은 멀티스레드 환경에서 공유 자원에 대한 동시접근으로 인해 발생하는 문제이기 때문에 개발자가 결과를 예상할 수 없습니다.
동시에 여러 요청이 처리될 때 각각의 요청은 현재 likeCount를 읽고 수정하므로 데이터 일관성이 깨지는 문제가 발생할 수 있습니다. 만약 하나의 스레드에서 likeCount를 5로 읽고 증가시키기 전에 다른 스레드가 이미 6으로 증가시킨 경우 데이터 일관성이 깨지게 됩니다.
Race Condition을 해결하고 이러한 문제를 방지하기 위한 여러 가지 방법이 있습니다.
낙관적 락은 트랜잭션 내에서 race condition이 거의 일어나지 않을것이라고 낙관적으로 가정하고, 충돌이 일어날 경우 예외를 발생시키는 방식으로 동시성 이슈를 해결합니다.
충돌이 거의 발생하기 않는다고 가정하기 때문에, race condition이 발생하면 예외가 떨어지고 충돌난 트랜잭션 중 하나만 성공시킨다는 특징이 있습니다.
데이터베이스의 Lock 기능 사용하지 않고 애플리케이션 레벨에서 락을 걸기 때문에 성능이 잘 나옵니다.
비관적 락은 충돌이 자주 발생한다고 비관적으로 가정하여 데이터베이스 테이블에 락을 거는 방식입니다.
비관적 락을 사용하는 경우 트랜잭션은 데이터를 읽거나 수정하기 전에 해당 데이터에 대한 락을 설정합니다.
이렇게 되면 다른 트랜잭션은 해당 데이터를 읽거나 쓸수 없고 대기해야하고, 시스템 성능에 영향을 줄 수 있습니다.
synchronized 키워드를 사용하여 동기화를 한다면 race condition을 방지할 수 있지만 성능 저하를 가져올 수 있습니다.
여러 스레드가 synchronized 키워드가 붙은 메서드 또는 코드 블록을 기다려야 하기 때문에 병렬처리가 제한됩니다.
또한 과도한 동기화는 데드락과 같은 문제가 발생할 수 있습니다.
무엇보다 synchronized 키워드를 사용하여 동기화하는 것은 지양해야 한다고 알고있습니다.
이런 동시성 이슈를 해결하기 위해 낙관적락 방식 선택했습니다.
낙관락을 통해 해당 문제를 해결하기 위해서 @OptimisticeLocking 어노테이션을 사용해야합니다.
낙관적 락은 여러 트랜잭션 간의 충돌을 최소화하고 데이터 일관성을 유지하기 위한 방법으로 데이터의 버전 관리를 통해 동시 업데이트 문제를 처리합니다.
@OptimisticeLocking 어노테이션은 이런 낙관적 락 기능을 엔티티 클래스에 추가히기 위해 사용합니다.
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@OptimisticLocking
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 다른 필드
@Version
private Long version;
}
@Version 어노테이션은 엔티티 클래스 내에 존재하는 version 필드를 엔티티의 버전 관리 필드로 지정한다. 이 필드는 엔티티 상태가 변경될 때마다 자동으로 증가하거나 변경된다.version 필드를 기반으로 현재 버전과 업데이트 시점의 버전을 비교한다. 만약 버전이 일치하지 않으면 업데이트가 실패하고 org.hibernate.StaleStateException 예외가 발생한다.결과적으로 동시성문제를 해결하기 위해 낙관적 락을 사용하면 여러 트랜잭션이 동시에 같은 엔티티를 업데이트 하더라도 버전 관리 필드를 통해 충돌을 감지하고 예외를 발생시켜 데이터 일관성을 유지할 수 있습니다.
이는 데이터베이스의 락을 최소화하여 성능을 떨어트리지 않고 데이터 무결성을 보장하는 효과적인 방법이라고 생각합니다.
테스트코드로는 동시성 재현이 어렵기 때문에 쉘스크립트를 짜서 동시 실행 시키는 방식으로 테스트를 진행했습니다.
유실되는 개수를 정확하게 추측할 수 없기 때문에 동시성 이슈 관련 실패하는 상황을 재현하기 어렵기 때문에 테스트코드로 Assertion을 하기 어렵다고 판단했습니다.
결과적으로 테스트코드 보다는 단순하게 쉘스트립트 curl 명령어로 로컬에서 동시에 API를 찌르는 방식으로 테스트를 진행하여 OptimisticLocking을 재연했습니다.
URL="http://localhost:8080/products/1/likes"
# 동시에 보낼 요청의 개수 설정
CONCURRENT_REQUESTS=2
# 여러 개의 요청을 동시에 보내는 반복문
for ((i=1; i<=$CONCURRENT_REQUESTS; i++))
do
# 백그라운드에서 curl 명령어를 실행하여 요청을 보냄
curl -X POST $URL &
done
# 모든 백그라운드 작업이 완료될 때까지 대기
wait
