이전 포스팅에서 좋아요 동시 요청에서 데드락이 걸린 것에 대해 분석해 보았다.
원인 요약
좋아요를 저장하는 과정에서 like 테이블에 board 외래키가 있었고, 외래키로 인해 board의 레코드가 잠김(slock).
정확히는 mysql에서는 인덱스 락인데 편의상 레코드 락이라고 하겠다.
- 트랜잭션1: 좋아요 추가(board id = 1 레코드 slock)
- 트랜잭션2: 좋아요 추가(board id = 1 레코드 slock)
- 트랜잭션1: board id = 1 레코드 좋아요 수 update(xlock 획득하기 위해 대기)
- 트랜잭션2: board id = 1 레코드 좋아요 수 update(xlock 획득하기 위해 대기)
이 상황이라면 좋아요를 누르는 동시에 해당 게시물을 수정하는 일이 생긴다면 이때도 데드락 발생 가능
Locking Mechanism
데이터베이스 동시성 Locking 메커니즘을 두개로 나뉜다고 한다.
1. Optimistic Lock
레코드를 읽고 버전을 기록하고 쓰기 작업 전에 처음에 레코드를 읽었을 때의 버전과 일치하는지 확인한다.
낙관락은 소프트웨어적 락이다. 어플리케이션에서 오류를 발생하여 개발자가 이 오류에 대한 처리를 해줘야한다.
- Spring JPA에서 제공하는 @Version을 사용하면 `ObjectOptimisticLockingFailureException` 오류를 발생한다
쉽게 말하면 일단 레코드에 접근해보고 버전이 내가 처음에 확인한 버전과 다르면 오류를 내는 것이다.
따라서 트랜잭션이 commit하기 전까지 충돌이 발생했는지 알 수 없다.
동시성은 좋으나, 데이터 무결성 보장 수준이 낮고 Versioning, Exception 처리에 대한 추가 개발이 필요하다.
2. Pessimistic Lock
수행하기 전에 잠금을 획득하여 충돌을 방지하는 것을 목표로 하며, 하나의 트랜잭션만 리소스에 액세스하고 수정할 수 있도록 허용한다.
비관락은 데이터베이스 빌트인 Lock이다. 개발자가 관여하지 않고 데이터베이스가 알아서 처리해준다.
쉽게 말하면 접근한 레코드 일단 Lock 걸고 다른 작업에 대한 접근을 막는 것이다.
- Table Lock: 동시성 수준 낮고, 트랜잭션 제어가 간단
- Record Lock: 동시성 수준 높고, 트랜잭션 제어가 복잡
- Shared Lock: 다중 읽기 가능 / 쓰기 접근 불가
- Exclusive Lock: 모든 lock 접근 불가
데이터 무결성 보장 수준은 높으나, 동시성 떨어지고 데드락 발생 위험성이 있다.
해결 방법
위에서 Locking Mechanism을 살펴보았다. 아래와 순서와 같이 적용하면서 해결해보았다.
- 낙관락 적용(@Version)
- 코드 순서 변경(board레코드에 먼저 좋아요 수 반영 후 좋아요 테이블에 저장)
- 누적 쿼리로 해결하기
- 비관락(@LockModeType.PESSIMISTIC_WRITE)
1. 낙관락 적용(@Version) ❌
1.1 JPA 사용하여 낙관락 적용(더티체킹)
JPA에서 @Version은 영속성 컨텍스트를 사용하여 더티체킹을 통해 이루어진다
JPA에서 @Version 어노테이션을 사용하면 알아서 version 체크를 해준다.
데드락이 걸리는 Board 엔티티에 아래와 같이 version 필드를 추가하고 테스트 해보았다.
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
@Getter
private Long id;
//version 추가
@Version
@Getter
private int version;
@JoinColumn(name = "member_id", nullable = false)
@ManyToOne
private Member member;
private String title;
@ColumnDefault("0")
@Getter
private int likes;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
public Board(String title, String content, Member member) {
this.title = title;
this.content = content;
this.member = member;
}
public void updateLike() {
likes++;
}
}
결과: 실패
데드락 발생
update 구문이 나가기 전에 version을 검사해야하는데 update 나가면서 version을 검사해서 version 충돌이 발생한다고 한다.
여기서 JPA 동작 과정에 대해 이해할 필요가 있다.
💡 영속성 컨텍스트와 쓰기 지연 SQL 저장소
JPA의 가장 큰 특징으로는 영속성이 있다.
- 영속성(Persistence): 매번 데이터베이스에 접근하지 않고 EntityManager를 통해 메모리(영속성 컨텍스트) 상에 작업 후 트랜잭션이 커밋되는 시점에 데이터베이스에 반영하는 구조
- 영속성 컨텍스트(Persistence Context): 1차 캐시(영속성 컨텍스트)에 없는 엔티티를 조회하면 데이터베이스에 직접 접근해서 조회하고, 1차 캐시에 있다면 데이터베이스까지 가지 않는다. -> 트랜잭션을 시작하고 종료할 때까지만 1차 캐시 유효
- 쓰기 지연 SQL 저장소: 트랜잭션 내에서 발생하는 update, delete, insert 쿼리문을 모아두었다가 commit될때 flush 되면서 한꺼번에 DB에 쿼리를 동작하게 하여 DB접근
JPA는 쓰기지연 SQL 저장소라는게 있어서 board의 좋아요 수를 update하는 쿼리를 보내도 바로 DB에 가지 않는다. 트랜잭션이 끝나야 DB에 쿼리가 날라간다.
락 같은 경우도 트랜잭션이 끝나야지만 풀리게 된다.
이렇게 되면 락을 거는 시간이 늘어나서 데드락이 발생하는가 보다 했다. ➡️ 코드 순서 영향이었다
2. 코드 순서 변경 (2.2 성공) ⭕️
2.1 더티체킹 사용
현재 좋아요 추가 코드를 보면 아래와 같다.
@Transactional
public void saveLike_DeadLock(Long id, Member member) {
Board findBoard = getBoardById(id);
Like like = likeRepository.findByMemberAndBoard(member, findBoard)
.orElse(Like.builder()
.board(findBoard)
.member(member).build());
likeRepository.save(like);
findBoard.updateLike();
}
- Like 테이블에 먼저 저장
- Board의 좋아요 수 업데이트
Like테이블에 저장할 때 외래키 제약으로 Board에 slock이 걸린다.
➡️ Board 에 좋아요 수 업데이트 라인을 먼저 수행하면 되지 않을까?
변경된 코드는 아래와 같다.
@Transactional
public void saveLike_optimisticLock(Long id, Member member) {
Board findBoard = getBoardById(id);
Like like = likeRepository.findByMemberAndBoard(member, findBoard)
.orElse(Like.builder()
.board(findBoard)
.member(member).build());
findBoard.updateLike();
likeRepository.save(like);
}
그래도 데드락 발생
이유는?
Board와 Like를 같은 트랜잭션에서 조작하고, 쓰기 지연 SQL 저장소에 적재되다가 commit 후 flush() 와 동시에 쓰기 지연 SQL을 내부 순서에 맞게 처리
Hibernate JavaDocs에 따르면 SQL 작업 순서는 다음과 같다.
- insert
- updates
- deletions of collections elements
- inserts of th collection elemets
- deletes
위 순서에 따르면 코드 라인 순서를 변경해도 hibernate의 처리 방식에 의해 Like insert 가 먼저 수행되게 된다.
그래서 update를 강제로 먼저 실행하기 위해 JPQL로 바로 쿼리를 날리도록 수정했다.
1.2 JPQL + 낙관락 (수동제어) ⭕️
BoardRepository에 아래 코드를 추가했다.
@Modifying
@Query("update Board b set b.likes = :count, b.version = :version + 1 where b.id = :boardId and b.version = :version")
int updateLikesWithOptimisticLock(@Param("count") int count, @Param("boardId") Long boardId, @Param("version") int version);
데드락 발생은 해결하였으나 여전히 Board에 좋아요 수가 제대로 반영되지 않고 있다.
아마도 JPQL을 사용하여 version을 검사하므로 JPA에서 오류를 던지지 않고 처음 version이 동일한 것만 좋아요수에 반영하고, Likes 테이블에는 3명의 유저 모두가 들어간 것이라고 예상하고 있다.
JPQL을 사용해서 version을 수동으로 확인하고 있기 때문에 오류를 자동으로 발생해주지 않는다.
버전 불일치할 경우 직접 예외를 발생시켜서 Likes 테이블에도 저장하지 못하도록 해야한다.
코드는 아래와 같이 수정
@Transactional
public void saveLike_jpqlAndOptimisticLock(Long id, Member member) {
Board findBoard = boardRepository.getReferenceById(id);
int likeCnt = findBoard.getLikes() + 1;
int updatedCount = boardRepository.updateLikesWithOptimisticLock(likeCnt, findBoard.getId(), findBoard.getVersion());
if (updatedCount == 0) {
throw new OptimisticLockingFailureException("Board version mismatch");
}
Like like = likeRepository.findByMemberAndBoard(member, findBoard)
.orElse(Like.builder()
.board(findBoard)
.member(member).build());
likeRepository.save(like);
}
결과는 성공
이 솔루션이 부적절한 이유
예외를 개발자가 따로 처리해야한다..
3. 누적 쿼리로 해결 ⭕️
@Modifying
@Query("update Board b set b.likes = b.likes + 1 where b.id = :id")
void incrementLike(@Param("id") Long id);
이렇게 하면 락을 적용하지 않아도 된다.
- 락을 걸지 않아도 요청 온 수만큼 counting 된다.
- 단순한 수치 증가이므로 쿼리 자체가 가볍다. 전에는 select 후, 좋아요 계산, update 흐름보다 빠르다
하지만 단점도 있다.
- Board 엔티티에 좋아요 수를 업데이트하지 않아서 다른 로직에서 board.getLikes() 했을 때 DB 값과 불일치
JPA는 영속성 컨텍스트라는 메모리르 사용해서 DB에서 값을 불러오고 캐시로 사용한다. 만일 DB와 값이 불일치하게 되면 예상치못한 문제가 발생할 수 있으므로 영속성 컨텍스트와 DB는 일치해야한다.
일치하도록 하는 방법은 flush()를 해주는 것이다. 이 작업이 많이 일어나면 JPA의 장점을 최대한 활용하지 못할 것이라고 생각된다.
예를 들어서
- 더티체킹도 1차 캐시에 엔티티가 있어야 가능
- 성능 최적화: 조회를 캐시에서 한다.
- 지연 로딩과 즉시 로딩 지원을 활용하지 못함. 등등..
4. 비관락 적용 ⭕️
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from Board b where b.id = :id")
Optional<Board> findByIdForUpdate(@Param("id") Long id);
board 조회하는 쿼리에 비관락을 걸어서 다른 트랜잭션이 조회/읽기를 시도할 때 락이 풀릴 때까지 대기해야한다.
정상적으로 데이터가 들어온다.
Ref