JPA(ORM)의 영속성컨텍스트에서 더티체킹이 좋은걸까? Lock을 통해 해결해보자(MVCC를 지원하는 DB를 사용할 때)
해당 글은 innodb에서 lock이 어떻게 동작하는지 어느 정도 알고 있는 상태에서 읽으면 좋을 것 같습니다!
제목에 JPA가 들어가서 주제가 JPA에 한정적으로 보일수 있겠다. 하지만 실제 해당 이슈는 JPA 뿐만 아니라 모든 곳에서 발생이 가능하다.
하지만 JPA에서는 dirty checking이라는 기능을 지원해주므로 더욱 조심해야 한다고 생각해서 제목으로 지어봤다.
본론으로 들어가자면 해당 상황이 위험한 이유는 lock과 관련이 있다. 그중에서 Lost Update 문제이다. (저희 서비스는 JPA와 MVCC를 지원하는 Mysql Innodb를 사용합니다.)
QA를 진행하다 다른 팀에서 데드락이슈를 맞이했다. 우리 팀은 괜찮을까 테스트해보다가 다른 이슈를 맞이했다. 바로 동시성 이슈였는데 해당 부분을 트러블슈팅한 기록을 남긴다.
LOST Update 문제
lost update problem은 MVCC를 지원하는 데이터베이스에서 발생할 수 있는 현상이다. 필자의 서비스는 JPA와 MySql을 사용한다. 따라서 해당 문제가 발생했는데 이해가 쉽도록 아래 그림을 보면서 설명하겠다.
처음에 Giron이 SELECT할 때는 lock이 걸리지 않는 상황이다. 따라서 다른 트랜잭션인 Crew도 마찬가지로 SELECT가 가능하다. 그렇다면 각 트랜잭션에선 SELECT한 객체를 가지고 있을 것이다.
이때, Giron이 UPDATE 쿼리를 날리면 views가 6으로 수정이 된다. 이때는 Lock이 걸려서 Crew는 수정할 수 없다. 그리고 Giron이 COMMIT을 하게 되면서 Lock이 풀리고 Crew가 UPDATE를 한다.
여기서 Lost Update가 발생한다. 바로 업데이트 이전의 객체를 가진 Crew가 UPDATE를 했으므로 views는 다시 6이 된다. 이후 COMMIT이 된다면 views는 6이 된다.
즉, 2번 게시글을 조회했으므로 조회수는 5→7이 될것을 기대했지만 6이 된 것이다.
테스트로 확인해보자!
@Test
void 게시물_조회수_증가_동시성_테스트() throws InterruptedException {
ArticleRequest articleRequest = new ArticleRequest("질문합니다.", "내용입니다~!", Category.QUESTION.getValue(),
List.of("Spring"), false);
ArticleIdResponse savedArticle = articleService.save(new LoginMember(member.getId()), articleRequest);
final var numberOfThreads = 10;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
executorService.execute(() -> {
articleService.getOne(new GuestMember(), savedArticle.getId());
latch.countDown();
});
executorService.execute(() -> {
articleService.getOne(new GuestMember(), savedArticle.getId());
latch.countDown();
});
Thread.sleep(1000);
final var article = articleRepository.findById(savedArticle.getId())
.orElseThrow(() -> new RuntimeException("Not Found"));
//정상 경우 2번 조회했으므로 2가 나와야한다. 하지만 1이 나온다.
assertThat(article.getViews()).isEqualTo(2);
}
결과
Hibernate:
update
article
set
updated_at=?,
category=?,
content=?,
is_anonymous=?,
member_id=?,
title=?,
views=?
where
id=?
Hibernate:
update
article
set
updated_at=?,
category=?,
content=?,
is_anonymous=?,
member_id=?,
title=?,
views=?
where
id=?
> binding parameter [1] as [TIMESTAMP] - [2022-10-16T15:26:34.941859400]
> binding parameter [2] as [VARCHAR] - [QUESTION]
> binding parameter [3] as [VARCHAR] - [내용입니다~!]
> binding parameter [4] as [BOOLEAN] - [false]
> binding parameter [5] as [BIGINT] - [1]
> binding parameter [6] as [VARCHAR] - [질문합니다.]
> binding parameter [7] as [INTEGER] - [1]
> binding parameter [8] as [BIGINT] - [1]
> binding parameter [1] as [TIMESTAMP] - [2022-10-16T15:26:34.940859800]
> binding parameter [2] as [VARCHAR] - [QUESTION]
> binding parameter [3] as [VARCHAR] - [내용입니다~!]
> binding parameter [4] as [BOOLEAN] - [false]
> binding parameter [5] as [BIGINT] - [1]
> binding parameter [6] as [VARCHAR] - [질문합니다.]
> binding parameter [7] as [INTEGER] - [1]
> binding parameter [8] as [BIGINT] - [1]
실제 비동기로 돌면서 update가 1 만 된 경우를 볼 수 있다.
해결 방법
- 비관적 락
- 동시성 이슈가 잦을 경우 사용하는 게 성능상 더 좋다.
- 낙관적 락
- 동시성 이슈가 발생할 경우가 적을 경우 사용하는게 성능상 좋다.
- 커밋 시점에 알고 롤백이 진행되므로 동시성이 이슈가 많은 경우에 사용하면 오히려 성능이 안 좋아진다.
비관적 락 X-Lock
비관적 락을 다음과 같이 적용하면 해결이 된다. 하지만 해당 로직이 수행하는 동안 다른 트랜잭션은 대기해야 하므로 성능상 좋지 않다.
비관적 락 - S-Lock
share mod로 쿼리가 나간 것을 확인할 수 있다. 하지만 데드락이 발생한다. 해당 원인은 s-lock과 x-lock사이의 관계를 이해하면 알 수 있다. 오늘의 주제가 아니므로 설명은 넘어가겠다.
간략히 설명하지만 s-lock이 걸린 상황에서 x-lock이 발생해서 서로 lock이 풀릴 때까지 기다려서 발생한 데드락이다.
낙관적 락
//Article.java
@Version
private Long version;
article도메인에 Version을 추가해줬다. 이제 UPDATE할 때, where절에 version을 같이 비교해서 만약 version이 일치하지 않는다면 OptimisticLockingFailureException 이 발생한다. 해당 예외가 발생하면 다시 요청을 하면 되는 방식이다.
결과
낙관적 락은 커밋 시점에 알게 되어 롤백하는 방식이다. 따라서 동시성 이슈가 잦은 곳에서 적용할 경우 매번 트랜잭션이 다 타고 커밋 시점에 알게 되어 롤백하므로 성능상 좋지 않다. 따라서 동시성 이슈가 적은 곳에서 사용하면 좋다.
또한 낙관적 락을 사용하면 동시성 이슈가 발생해서 예외가 터지면 다시 시도하라는 메시지를 줄 수 있을 것 같다.
반면에 비관적 락을 이용하면 트랜잭션이 기다렸다가 처리가 되므로 사용자에게 다시 시도하라는 메시지 없이 자동으로 처리가 될 것이다. 대신 락이 걸려서 처리가 느릴 수는 있겠다.
이와 같은 조회에서 동시성 문제가 생길 수 있었다. 해당 이슈가 조회여서 동시성 제어에 중요성을 못 느낄 수 있지만 아래와 같이 흔한 로직에서도 발생한다.
vote exists ? update : save
필자의 서비스에는 해당 로직이 있다. 간략히 설명하자면 투표를 할 때, 기존의 투표가 있으면 새로운 투표 항목을 찍는 걸로 바꿔주고 이전 투표 항목의 투표수는 감소 그리고 새롭게 투표한 항목의 투표수를 증가하는 로직이다. 만약 기존 투표가 없는 최초의 투표라면 해당 투표의 총개수를 1 증가하는 로직이다.
흔한 로직인데 여기에도 마찬가지의 문제가 발생한다. 먼저 조회를 하고 insert or update를 하기 때문에 Lost update가 발생할 수 있다.
테스트로 확인해보자!
@Test
void updateConcurrencyTest() throws InterruptedException {
Member other = memberRepository.save(new Member("다른이", "gittt", "avater.url"));
LoginMember loginMember1 = new LoginMember(member.getId());
LoginMember loginMember2 = new LoginMember(other.getId());
final var numberOfThreads = 10;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
executorService.execute(() -> {
voteService.doVote(discussionArticle.getId(), loginMember1,
new SelectVoteItemIdRequest(voteItems.get(0).getId()));
latch.countDown();
});
executorService.execute(() -> {
voteService.doVote(discussionArticle.getId(), loginMember2,
new SelectVoteItemIdRequest(voteItems.get(0).getId()));
latch.countDown();
});
Thread.sleep(100);
VoteItem voteItem = voteItemRepository.findById(1L)
.orElseThrow(() -> new RuntimeException("Not Found"));
//정상 경우 loginMember1, 2가 각각 투표했으므로 총 투표수는 2여야 한다.
assertThat(voteItem.getAmount().getValue()).isEqualTo(2);
}
결과
마찬가지로 총 투표수는 2가 되어야 하는데 1이 되었다. 조회수는 투표수에 비해 크게 안 중요하다고 할 수 있지만 내가 투표한 투표가 사라진다는 건… 해결해야 할 필요가 있다.
비관적 락 X-Lock
마찬가지로 비관적 락을 걸면 해결이 될 것이라고 생각을 했다.
결과
하지만 데드락이 발생했다.
innodb에서는 무결성 참조 원칙으로 인해 부모 테이블에서 변경이 일어나면 외래키가 맺어진 자식 테이블의 칼럼에는 S-Lock이 걸린다. 따라서 아래의 로직에서 문제가 발생한 것이다. voteHistory에서 insert가 되고 foreignKey가 맺어진 voteItem에서 update가 발생하기 때문이다.
해당 이슈 덕분에 투표를 기록하는 엔티티인 VoteHistory엔티티에서 외래키를 제거하는 방향으로 진행하였고 비관적 X-Lock이 정상 동작한 것을 확인할 수 있었다.
낙관적 Lock
Version을 명시했지만 여전히 데드락이 발생한다.
OptimisticLockException가 발생하기 전에 DeadLock이 발생하는 것이다. 원인은 앞선 외래키가 걸려있기 때문에 발생한 것이었다. 마찬가지로 외래키를 제거해주니 정상적으로 OptimisticLockException이 발생했다.
@Version을 사용할 때 주의사항
엔티티에 @Version을 추가하고 테스트를 진행했는데 모두 깨진 상황이 발생했다. @Version 이 올라간 후부터 영속성컨텍스트에 올라가지 않아 진 것이다.
원인 save메서드 내부의 isNew()메서드가 원하는 대로 작동하지 않았다.
isNew()
- @Id가 null이면 true가 된다.
- 만약 @Version이 있다면 @Id 는 무시되고 Version이 null이어야 true가 된다.
- 또는 primitive타입이여도 true가 된다. id도 마찬가지이다.
- Persistable interface를 구현한다.
- isNew()를 재정의하여 사용할 수 있다. 이러면 나머지 @Id 나 @Version 의 값은 무시된다.
즉, Persistable interface → @Version → @Id 순서로 정해진다.
지금까지 동시성에 대한 생각 없이 작성했던 코드들에 대해서 돌아보는 계기가 되었다. 또한
해당 이슈를 통해서 JPA와 같은 ORM의 어두운 면을 다시금 상기할 수 있는 것 같다. 물론 ORM이 아니라고 발생하지 않는다는 보장은 없지만 단순히 더티 체킹은 편하네~ 하면서 사용하면 안 된다는 것을 깨달았다. 모든 기술에는 트레이드 오프가 있으므로 열심히 공부하고 잘 선택해야겠다.
Reference
MySQL :: MySQL 5.7 Reference Manual :: 14.7.1 InnoDB Locking
A beginner's guide to database locking and the lost update phenomena - Vlad Mihalcea