본문 바로가기
공부/프로젝트

JPA에서 낙관적 락을 적용해보자

by JERO__ 2022. 10. 27.

문제이슈

내편(내 마음을 편지로) 서비스에서 롤링페이퍼의 메세지에 좋아요를 누를 수 있는 기능이 있습니다.

- 메세지 좋아요에 엄청난 연타를 누르거나

- PC, 모바일에서 좋아요를 동시에 눌렀을 때

다음과같은 이슈가 발생하였습니다.

 

중복된 데이터가 쌓였습니다!

  • 메세지 좋아요 테이블에는 같은 member_id, message_id가 공존하면 안됩니다.

기존 코드

public MessageLikeResponseDto likeMessage(Long memberId, Long rollingpaperId, Long messageId) {

    // 메세지 좋아요 대상 "메세지"를 찾는다.
    final Message message = messageRepository.findById(messageId)
            .orElseThrow(() -> new NotFoundMessageException(messageId));
            
    // "메세지 좋아요" 테이블에 존재하면 에러를 던진다.
    if (messageLikeRepository.existsByMemberIdAndMessageId(memberId, messageId)) {
        throw new InvalidLikeMessageException(messageId, messageId);
    }
    
    // 메세지 좋아요 추가
    message.like();
    messageLikeRepository.save(new MessageLike(memberId, rollingpaperId, messageId));
    
    return new MessageLikeResponseDto(message.getLikes(), true);
}
  1. 메세지 좋아요 대상 "메세지"를 찾습니다.
  2. "메세지 좋아요" 테이블에 기록이 존재하면 에러를 던집니다.
  3. 메세지 좋아요를 추가한다.

어떤 점이 문제점이었을까요?

 

문제원인

시퀀스 Tx1 Tx2
1 메시지 A 찾음  
2   메시지 A찾음
3 메세지 좋아요 기록 존재하지 않는가? true  
4   메세지 좋아요 기록 존재하지 않는가? true
5 메세지 좋아요 저장  
6   메세지 좋아요 저장
7 Tx1 Commit  
8   Tx2 Commit

 

원인은 바로 3, 4번에 있습니다.

두 개의 트랜잭션이 검사를 했을 때 기록이 존재하지않는가? 에서 둘다 TRUE를 반환했기 때문이었습니다.

해결방법

기존 격리수준은 repeatable read이며 MySQL을 사용하고 있었습니다.

기존 격리수준을 변경하지 않고 격리성을 증진할 수 있는 방법으로 우리는 락(Lock) 기능을 사용할 수 있습니다.

락은 트랜잭션을 순차적으로 처리하도록 도와주고, 데이터의 일관성을 유지할 수 있도록 도와줍니다.

락에는 낙관적 락, 비관적 락이 있는데 어떤 것을 적용해야 할까요?

낙관적 락 vs 비관적 락

  • 낙관적 락은 대부분의 경우 트랜잭션 충돌이 일어나지 않는다는 낙관적 가정을 하는 기법입니다. DB 자체에서 제공하는 것이 아닌 JPA 즉 어플리케이션에서 제공하는 기법입니다. 또한 낙관적 락은 충돌을 가정하지 않아 커밋 된 이후에 충돌여부를 알 수 있습니다.
  • 비관적 락은 대부분의 경우 트랜젝션에서 충돌이 발생할 것이라 가정하고, 일단 락을 걸고 보는 기법이다. 비관적 락은 데이터베이스의 락 알고리즘을 통해 구현된다.

메세지 좋아요 충돌(엄청난 연타…이슈)은 대부분의 경우 트랜잭션 충돌이 발생하지 않는다로 보아 낙관적 락을 적용하기로 결정하였습니다.

JPA에서 낙관적락

JPA를 사용하고 있고 @Version 어노테이션을 사용하면 낙관적락을 적용할 수 있습니다.

낙관적 락은 어떻게 진행될까요? 버전 관리 기능을 사용하면 다음의 순서로 진행됩니다.

  1. 엔티티를 조회
  2. 데이터 수정 또는 저장
  3. version 비교
    • 맞다면 트랜잭션 커밋
    • 다르다면 롤백

적용해봅시다!

  1. 엔티티 클래스@Version 어노테이션을 추가해 버전 관리 기능을 하도록 도와줍니다.
  2. 낙관적 락 옵션으로 가장 많이 사용하는 두가지입니다.
  • NONE: 락 옵션을 적용하지 않아도 @Version만 있으면 낙관적 락이 적용됩니다.
    • 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경되지 않아야 함
    • 조회 시점부터 수정 시점까지를 보장
    • race condition을 예방
  • OPTIMISTIC: 조회만 해도 버전을 체크합니다.
    • 용도: 조회한 엔티티는 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않아야 함
    • 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장
    • 동작: 트랜잭션을 커밋할 때 버전 정보를 조회해서 SELECT, 현재 엔티티의 버전과 같은지 검증함 만약 같지 않으면 예외 발생, 트랜잭션을 커밋할 때 SELECT 쿼리로 조회해서 처음에 조회한 엔티티의 버전정보와 비교함, 엔티티를 수정하지 않고 단순히 조회만 해도 버전을 확인

메세지를 조회한 시점부터 버전을 체크하기 위해 OPTIMISTIC을 적용하였습니다.

적용한 소스코드

  1. 엔티티에 version 추가
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Message extends BaseEntity {

		// ...

    @Version
    private Integer version;
}
  1. Repository에 OPTIMISTIC 적용
public interface MessageRepositoryCustom {

    @Lock(LockModeType.OPTIMISTIC)
    Optional<Message> findByIdForUpdate(Long id);
}

낙관적 락을 적용한 뒤 트랜잭션 변화

시퀀스 Tx1 Tx2
1 Tx1 start  
2   Tx2 start
3 메시지 A 찾음, version = 0  
4   메시지 A찾음, version = 0
5 메세지 좋아요 기록 존재하지 않는가? true  
6   메세지 좋아요 기록 존재하지 않는가? true
7 좋아요 수 증가, 메세지 좋아요 저장  
8   좋아요 수 증가, 메세지 좋아요 저장
9 version == 0 true, Tx1 Commit, version += 1  
10   version == 0 false, Tx2 Rollback

낙관적 락을 걸어 중복되는 수정으로 부터 일관성을 유지할 수 있게 되었습니다.

 

참고

- https://velog.io/@recordsbeat/JPA%EC%97%90%EC%84%9C-Write-Skew-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0-locking-%EC%A0%84%EB%9E%B5

- https://effectivesquid.tistory.com/entry/Optimistic-Lock%EA%B3%BC-Pessimistic-Lock

- https://sup2is.github.io/2020/10/22/jpa-optimistic-lock-and-pessimistic-lock.html 

'공부 > 프로젝트' 카테고리의 다른 글

restdocs를 사용해보자  (0) 2022.07.29
Flyway를 사용하는 이유  (0) 2022.07.29
젠킨스 pipeline를 설정해보자  (0) 2022.07.26
[지원플랫폼] 플래닝포커를 해보자  (0) 2022.07.25
DB 서버를 연동해보자  (0) 2022.07.25

댓글