낙관적 락으로 동시성이슈 해결

김태현·2023년 9월 21일

문제상황

@Transactional
public void updateLikeCount(long productId) {
    Product foundProduct = getProduct(productId);

    int likeCount = foundProduct.getLikeCount();
    int newLikeCount = likeCount + 1;

    foundProduct.setLikeCount(newLikeCount);
}

코드의 동작

  1. getProduct() 메서드를 호출하여 productId에 해당하는 제품을 검색한다.
  2. 검색된 제품의 현재 좋아요 수(likeCount)를 가져온다.
  3. 현재 좋아요 수를 1증가시킨 newLikeCount를 계산한다.
  4. 검색된 제품의 likeCountnewLikeCount로 업데이트한다.
  5. JPA의 더티체킹 기능을 통해 UPDATE 쿼리를 날린다.

위 코드에서 발생할 수 있는 문제

웹 애플리케이션은 멀티스레드 환경에서 동작하기 때문에 다음과 같은 동시성 이슈가 발생할 수 있습니다.

Race Condition

여러 스레드(사용자)에서 동시에 updateLikeCount() 메서드를 호출할 경우, 같은 제품에 대한 좋아요 증가 요청이 동시에 처리되고 이로인해 예상치 못한 결과를 얻을 수 있습니다. 이를 Race Conditon이라고 합니다.

예를들어 현재 좋아요 수가 5인 게시물에 두 사용자가 동일한 상품에 좋아요를 동시에 눌렀다면, 두 스레드가 같은 likeCount(좋아요)를 읽고 각각 1을 증가시킵니다. 이는 실제로 7이 될 수도 있고 6이 될수도 있을 것입니다.

이런 Race Conditon은 멀티스레드 환경에서 공유 자원에 대한 동시접근으로 인해 발생하는 문제이기 때문에 개발자가 결과를 예상할 수 없습니다.

데이터 일관성 문제

동시에 여러 요청이 처리될 때 각각의 요청은 현재 likeCount를 읽고 수정하므로 데이터 일관성이 깨지는 문제가 발생할 수 있습니다. 만약 하나의 스레드에서 likeCount를 5로 읽고 증가시키기 전에 다른 스레드가 이미 6으로 증가시킨 경우 데이터 일관성이 깨지게 됩니다.

해결방법

Race Condition을 해결하고 이러한 문제를 방지하기 위한 여러 가지 방법이 있습니다.

Optimistic Locking

낙관적 락은 트랜잭션 내에서 race condition이 거의 일어나지 않을것이라고 낙관적으로 가정하고, 충돌이 일어날 경우 예외를 발생시키는 방식으로 동시성 이슈를 해결합니다.

충돌이 거의 발생하기 않는다고 가정하기 때문에, race condition이 발생하면 예외가 떨어지고 충돌난 트랜잭션 중 하나만 성공시킨다는 특징이 있습니다.

데이터베이스의 Lock 기능 사용하지 않고 애플리케이션 레벨에서 락을 걸기 때문에 성능이 잘 나옵니다.

Pessimistice Locking

비관적 락은 충돌이 자주 발생한다고 비관적으로 가정하여 데이터베이스 테이블에 락을 거는 방식입니다.

비관적 락을 사용하는 경우 트랜잭션은 데이터를 읽거나 수정하기 전에 해당 데이터에 대한 락을 설정합니다.

이렇게 되면 다른 트랜잭션은 해당 데이터를 읽거나 쓸수 없고 대기해야하고, 시스템 성능에 영향을 줄 수 있습니다.

동기화(Synchronziation)

synchronized 키워드를 사용하여 동기화를 한다면 race condition을 방지할 수 있지만 성능 저하를 가져올 수 있습니다.

여러 스레드가 synchronized 키워드가 붙은 메서드 또는 코드 블록을 기다려야 하기 때문에 병렬처리가 제한됩니다.

또한 과도한 동기화는 데드락과 같은 문제가 발생할 수 있습니다.

무엇보다 synchronized 키워드를 사용하여 동기화하는 것은 지양해야 한다고 알고있습니다.

문제해결

이런 동시성 이슈를 해결하기 위해 낙관적락 방식 선택했습니다.

낙관락을 통해 해당 문제를 해결하기 위해서 @OptimisticeLocking 어노테이션을 사용해야합니다.

낙관적 락은 여러 트랜잭션 간의 충돌을 최소화하고 데이터 일관성을 유지하기 위한 방법으로 데이터의 버전 관리를 통해 동시 업데이트 문제를 처리합니다.

@OptimisticeLocking 어노테이션은 이런 낙관적 락 기능을 엔티티 클래스에 추가히기 위해 사용합니다.

@OptimisticLocking 기능

  1. 특정 필드를 엔티티의 버전 관리 필드로 지정합니다. 해당 필드는 엔티티의 상태가 변경될 때마다 자동으로 증가합니다.
  2. 엔티티를 업데이트 할 때 버전이 일치 하지 않으면 충돌이 감지되어 예외를 발생시킵니다. 이를 통해 다른 트랜잭션에서 해당 엔티티를 수정한 경우 변경을 감지하고 처리할 수 있게 되는 것입니다.

코드설명

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@OptimisticLocking
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // 다른 필드

    @Version
    private Long version;
}
  • @OptimisticLocking 어노테이션: 클래스 레벨에 해당 어노테이션을 적용하면 이 엔티티는 낙관적 락을 사용하여 동시성 문제를 해결하도록 설정된다.
  • @Version 어노테이션: @Version 어노테이션은 엔티티 클래스 내에 존재하는 version 필드를 엔티티의 버전 관리 필드로 지정한다. 이 필드는 엔티티 상태가 변경될 때마다 자동으로 증가하거나 변경된다.
  • 동시 업데이트 충돌 처리: 엔티티의 상태를 업데이트할 때, JPA는 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
  • 좋아요를 업데이트 하는 엔드포인트에 동시에 2개의 요청을 보내면 SELECT 쿼리, UPDATE 쿼리가 각각 2번씩 나가고
  • StaleStateException이 발생합니다.
  • 데이터베이스에는 하나의 좋아요 카운트만 증가합니다.

https://github.com/f-lab-edu/commerce-market/pull/53

profile
안녕하세요. Java&Spring 기반 백엔드 개발자 김태현입니다.

0개의 댓글