Java & Spring

게시글의 좋아요를 업데이트할 때 발생할 수 있는 동시성 이슈

ju_young 2024. 3. 20. 21:28
728x90

 

위는 ERD의 일부만 캡처한 이미지로 article에는 좋아요 수를 나타내는 like_count 속성이 있고 각 좋아요의 정보를 담는 article_like 테이블이 있다.

 

게시글의 좋아요를 업데이트하는 경우는 추가할 때, 혹은 삭제할 때 뿐이다. 당연히 기본 값은 0이 되어야할 것이다.

좋아요 추가

좋아요를 추가할 때를 기준으로 테스트를 해볼 것이기 때문에 추가하는 코드를 살펴보자. 편의를 위해 추가적인 메소드 코드와 엔티티 코드는 생략한다.

@Transactional
public void addArticleLike(Long articleId, Long userAccountId) {  
    // 좋아요 추가
    articleLikeRepository.save(ArticleLike.of(userAccountId, articleId)); 
    // 좋아요 수 + 1 
    Article article = articleService.getArticleEntity(articleId);  
    article.setLikeCount(article.getLikeCount() + 1);  
}

DB에서 발생할 수 있는 문제

JPA Transaction은 Isolation Level이 Default로 Isolation.DEFAULT가 설정되어있다. Isolation.DEFAULT는 DB의 기본 Isolation Level을 적용한다. PostgreSQL을 사용하기 때문에 PostgreSQL은 READ_COMMITTED가 Default로 설정되어있는 것을 확인할 수 있었다.

 

그렇다면 READ_COMMITTED일 경우 발생할 수 있는 문제가 뭘까?

 

먼저 READ_COMMITTED는 Non-repeatable Read, Phantom Read를 허용한다. 그리고 MVCC가 적용된다.

PostgreSQL에서는 서로 다른 두 트랜잭션이 READ_COMMITTED을 가질 때 Lost Update와 Write Skew 문제가 발생할 수 있다.

Lost Update

x=50, y=10이고 두 트랜잭션 모두 read committed level을 가질때 다음과 같은 동작을 수행할 경우 transaction1에서 x를 10으로 변경한 부분이 lost된다.

 

transaction1(read committed) transaction2(read committed)
read(x) => 50  
write(x=10)  
  read(x) => 50
  write(x=80)
raad(y) => 10  
write(y=50)  
commit  
  write(x=80)
  commit

위와 같은 lost update 문제를 해결하기위해 transaction2의 level을 repeatable read로 변경할 수 있다. 그러면 아래처럼 transaction2가 rollback된다.

 

repeatable read는 같은 데이터에 먼저 update한 transaction이 commit을 하면 나중에 update를 시도한 transaction은 rollback이 되기 때문이다.

 

transaction1(read committed) transaction2(repeatable read)
read(x) => 50  
write(x=10)  
  read(x) => 50
  write(x=80)
raad(y) => 10  
write(y=50)  
commit  
  rollback

transaction2가 먼저 시작할 때도 마찬가지로 lost update 문제가 발생하기 때문에 transaction1의 level도 repeatable read로 변경하면 해결할 수 있다. 따라서 Lost Update 문제를 해결하기위해 트랜잭션에 Repeatable Read를 적용할 수 있다. (추가로 Locking Read를 적용할 수도 있다.)

테스트

Jmeter를 사용하여 Thread Group을 4개 정도로 만들고 각각 10번 요청하도록 설정했다.


그리고 코드에서는 중간에 Thread.sleep을 추가해서 동시성 문제가 발생할 수 있도록 했다.

@Transactional
public void addArticleLike(Long articleId, Long userAccountId) {  
    // 좋아요 추가
    articleLikeRepository.save(ArticleLike.of(userAccountId, articleId)); 
    try {  
        Thread.sleep((long)(Math.random() * 1000));  
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }
    // 좋아요 수 + 1 
    Article article = articleService.getArticleEntity(articleId);  
    article.setLikeCount(article.getLikeCount() + 1);  
    try {  
        Thread.sleep((long)(Math.random() * 1000));  
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }
}

 

총 10 * 4 번의 요청으로 좋아요를 추가하기 때문에 게시글의 좋아요 수는 40이 추가되어야한다. 하지만 테스트 결과 8 개만 추가된 것을 확인 할 수 있었다.

JPA에서 발생할 수 있는 문제

앞서 설명한 Lost Update 문제와 테스트 결과를 통해 Isolation Level을 Repeatable Read로 변경하면 되겠다고 생각할 수 있다.

Repeatable Read

실제로 Repeatable Read로 변경해서 테스트해보면 CannotAcquireLockException이 발생하고 Rollback하는 것을 확인할 수 있다. 즉, 실패한 요청은 Retry하도록 처리해야한다.

@Transactional(isolation = Isolation.REPEATABLE_READ)
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
...
2024-03-20 20:31:10.769 DEBUG 4667 --- [io-8080-exec-37] o.s.orm.jpa.JpaTransactionManager        : Rolling back JPA transaction on EntityManager [SessionImpl(67096665<open>)]
2024-03-20 20:31:10.770 DEBUG 4667 --- [io-8080-exec-37] o.s.orm.jpa.JpaTransactionManager        : Closing JPA EntityManager [SessionImpl(67096665<open>)] after transaction

second lost update

추가로 DB 트랜잭션으로 해결할 수 없는 문제가 있다. 예를 들어 사용자 A와 B가 동시에 제목이 같은 게시글을 수정한다고 하자. 둘이 동시에 내용을 수정하는 중에 사용자 A가 먼저 수정을 완료하고 이후 사용자 B가 수정을 완료했다. 그러면 결과적으로 사용자 B가 수정한 사항만 남게 된다. 이러한 문제를 second lost udpate라고 부른다.

 

이럴 경우 기본으로 마지막 커밋만 인정한다. 하지만 상황에 따라 최초 커밋만 인정하기도 한다.

낙관적 락

JPA를 사용할 때 READ COMMITTED + 낙관적 락 전략을 추천한다고 한다. 바로 앞서 설명한 second lost update를 예방하기 위해서라고 한다.

@Version

낙관적 락을 적용하기 위해서는 @Version을 사용해야한다. 예시로 엔티티에 다음과 같이 version을 추가해주면 된다.

@Getter  
@ToString(callSuper = true)  
@Table(name = "article")  
@Entity  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
@EqualsAndHashCode(onlyExplicitlyIncluded = true)  
public class Article {
    ...
    @Setter  
    @Column(nullable = false, columnDefinition = "int4 default 0")  
    private Integer likeCount;  

    @Version  
    private Integer version;
    ...
}

 

이렇게 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용된다. 그리고 Lock 옵션이 NONE으로 설정된 것과 같다.

@Lock(LockModeType.NONE)

 

이 version이라는 필드는 엔티티를 수정할 때 마다 하나씩 증가한다. 그리고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다. 예를 들어 트랜잭션 1이 조회한 엔티티를 수정하고 있는데 트랜잭션 2에서 같은 엔티티를 수정하고 커밋해서 버전이 증가해버리면 트랜잭션 1이 커밋할 때 버전 정보가 다르므로 예외가 발생한다.

테스트

실제로 테스트를 돌려보면 다음과 같이 ObjectOptimisticLockingFailureException 예외가 발생하는 것을 확인할 수 있다.

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update article set modified_at=?, article_category=?, article_file_id=?, article_type=?, content=?, is_free=?, like_count=?, title=?, user_account_id=?, version=? where id=? and version=?; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update article set modified_at=?, article_category=?, article_file_id=?, article_type=?, content=?, is_free=?, like_count=?, title=?, user_account_id=?, version=? where id=? and version=?

Update Query

앞서 테스트하고 살펴본 것을 보면 Lost Update가 발생하는 이유는 같은 데이터를 읽고 수정해서 update하기 때문이라고 생각할 수 있다.

 

그렇다면 데이터를 읽지 않고 Update 쿼리를 날리면 어떨까?

 

@Modifying  
@Query("update Article a set a.likeCount = a.likeCount + 1 where a.id = ?1")  
void addArticleLikeCount(Long articleId);

 

결과를 보면 테스트를 여러번 해봐도 정상적으로 값이 수정되는 것을 확인할 수 있었다. (이때 낙관적 락은 적용하지 않았고 READ_COMMITTED를 가진다)

 

예외가 발생하고 Rollback이 되던 앞선 방법과 달리 처리가 단순화된 모습이다.

 

Update 쿼리는 atomic한게 맞을까?

MySQL(InnoDB)에서 업데이트 쿼리의 실행 계획을 살펴보면 다음과 같다.


select_type을 보면 SIMPLE 이라고 되어있다. 공식문서에 따르면 SIMPLE은 UNION 또는 subquery를 사용하지 않는 간단한 SELECT를 의미한다고 한다.

 

하지만 구글링을 통해 Stack overflow, MySQL munual, MySQL forum 등을 살펴본 결과 update query를 실행하는 동안 자동으로 lock이 걸린다라는 결론이 나온다. 어떤 사람은 모든 DML에는 암시적으로 트랜잭션이 걸린다고도 표현했다. 따라서 MySQL에서는 "Update 쿼리가 atomic하다"라고 말할 수 있을 것 같다.

 

PostgreSQL의 실행 계획은 다음과 같다.


Index Scan 후 Update를 하는 것으로 확인 할 수 있다.

 

PostgreSQL도 구글링을 통해 by default query is wrapped in a transaction라는 답변과 MySQL과 마찬가지로 EveryDML (update, insert, delete) is atomic라는 답변을 확인 할 수 있었다. 즉, PostgreSQL도 atomic하다라고 말할 수 있을 것 같다.

 

(참고한 글들은 모두 아래의 reference에 추가했다.)

정리

  • Isolation Level을 Repeatable Read로 변경하거나 Locking Read(For Update)를 적용할 수 있다.
  • 낙관적 락(@Version) + READ COMMITTED 전략을 적용할 수 있다.
  • 단순히 Update 쿼리를 수행할 수 있다.
728x90