배경
게시글 좋아요 로직에 문제가 발생했습니다. 텀을 두고 좋아요를 눌렀을 때 좋아요, 좋아요 취소에는 전혀 문제가 발생하지 않습니다. 동시에 많은 작업이 있을 때 동시성 문제가 발생합니다.
좋아요 개수 문제, LikeEntity 중복값 저장 2가지 문제에 대해 다루겠습니다.
제가 처음 작성한 로직은 게시글 좋아요 버튼을 누르면 Like Entity save 메서드가 실행이 되고, Like Entity에 값이 존재하면 delete가 되는 로직입니다.
원인 분석 먼저 하겠습니다.
원인 분석
MySQL 8.0 (innoDB)이며, 트랜잭션 격리 레벨은 디폴트값인 Repeatable Read입니다.
게시글A를 찾는 SELECT 과정은 Lock을 얻는 과정이 발생하지 않기 때문에 두 트랜잭션 모두 같은 게시글A에 접근합니다.
따라서 동시 접근이 가능한 쿼리이기 때문에 광클하는 경우 두 트랜잭션 모두 true이기 때문에 DB에 저장됩니다.
코드를 보겠습니다.
코드
PostEntity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class BoardPost extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_post_id")
private Long id;
private String title;
private String contents;
private int viewCount;
private int likeCount;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_category_id")
private BoardCategory boardCategory;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToMany(mappedBy = "boardPost")
private List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "boardPost")
private List<Like> likes = new ArrayList<>();
@Builder
public BoardPost(String title, String contents, int viewCount, int likeCount, BoardCategory boardCategory, User user) {
this.title = title;
this.contents = contents;
this.viewCount = viewCount;
this.likeCount = likeCount;
this.boardCategory = boardCategory;
this.user = user;
}
public void plusBoardPostViewCount() {
this.viewCount++;
}
public void updateBoardPost(BoardPostUpdateRequestDto updateDto, BoardCategory boardCategory) {
this.title = updateDto.getTitle();
this.contents = updateDto.getContents();
this.boardCategory = boardCategory;
}
public void addLikeCount(int likeCount) {
this.likeCount += likeCount;
}
}
LikeEntity
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Like extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "like_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_post_id")
private BoardPost boardPost;
@Builder
public Like(User user, BoardPost boardPost) {
this.user = user;
this.boardPost = boardPost;
}
}
LikeController
@PostMapping("/api/user/boards/categories/{postid}/like")
public ResponseEntity<Void> likeNotice(Principal principal,
@PathVariable Long postid) throws InterruptedException {
likeService.like(principal, postid);
return ResponseEntity.ok().build();
}
LikeService
@Transactional
public String like(Principal principal, Long postid) {
Optional<BoardPost> foundBoardPost = boardPostRepository.findById(postid);
if (foundBoardPost.isEmpty()) throw new CommonException("해당 공지사항이 존재하지 않습니다.");
BoardPost boardPost = foundBoardPost.get();
log.info("좋아요 기능");
Optional<User> loginUser = userRepository.findByLoginId(principal.getName());
User user = loginUser.get();
Optional<Like> foundLike = null;
foundLike = likeRepository.findByUserAndBoardPost(user, boardPost);
if (foundLike.isEmpty()) {
Like like = Like.builder()
.user(user)
.boardPost(boardPost)
.build();
likeRepository.save(like);
boardPost.addLikeCount(1);
return "좋아요!";
} else {
likeRepository.deleteByUserAndBoardPost(user, boardPost);
boardPost.addLikeCount(-1);
return "좋아요 취소!";
}
}
좋아요 여부를 확인 후, save를 하고 boardPost.addLikeCount()를 합니다.
좋아요 개수가 중요한 게시판이라고 가정한다면 이런식으로 조작되어서는 안 된다고 판단했습니다.
먼저 Lock을 이용해 동시성 이슈 문제룰 해결하겠습니다.
낙관적 락과 비관적 락이 존재하며 비관적 락은 성능이 저하 되고 게시글 자체 Lock을 걸기 때문에 적합하지 않다고 판단하여 낙관적 락을 이용하였습니다.
해결 방법 - 낙관적 락
낙관적 락을 걸게 되면 Version을 업데이트 하면서 커밋을 하기 때문에 동시에 작업할 경우 Version이 다르면 다시 select를 하기 때문에 DB에 같은 좋아요가 2개 이상 쌓이지 않고 하나만 save가 됩니다.
애플리케이션 단에서 처리하는 것이므로 비관적 락에 비해 성능 저하가 일어나지 않습니다.
물론 트래픽이 엄청 몰리는 상황이라고 가정한다면 비관적 락을 이용하는 것이 성능에 더 좋겠지만, 그정도 규모 사이트가 이니기 때문에 낙관적 락이 적합하다고 판단했습니다.
Version 추가 및 Repository 메서드 생성
낙관적 락을 사용하기 위한 BoardPost 엔티티에 Version과 Repository 메서드 생성하겠습니다.
@Version
private Long version;
@Lock(LockModeType.OPTIMISTIC)
@Query("select p " +
"from BoardPost p " +
"where p.id =:postId")
BoardPost findByIdWithOptimisticLock(@Param("postId") Long postId);
Version을 기준으로 수정되는 작업이기 때문에 기존 로직에 비하면 당연히 시간이 더 오래걸릴 수 밖에 없습니다.
이렇게 작성하면 끝난줄 알았는데 엄청난 문제가 발생했습니다.
Jmeter를 이용하여 스레드 100개로 동시 요청을 보냈을 때, 데드락이 발생합니다.
데드락 (Deadlock)이란?
두 개 이상의 트랜잭션들이 동시에 진행될 때 서로가 서로에 대한 락을 소유한 상태로 대기 상태로 빠져서 더이상 진행하지 못하는 상황을 데드락(Deadlock)이라고 합니다.
원인 분석
현재 저희 서비스에서는 boardPost.addLikeCount() 로직에 의해 데드락이 발생합니다.
BoardPost 엔티티에서 addLikeCount()를 이용하여 업데이트할 때 락경합이 발생했습니다.
MySQL InnoDB Lock & Deadlock 이해하기 — Steemit
이 블로그를 통해 Deadlock이 무엇인지 Deadlock이 왜 발생하는지 알 수 있습니다.
해결 방법
낙관적 락X, Unique 제약 조건을 이용하여 데이터 정합성 유지
현재 서비스에서 낙관적 락은 사용할 수 없는 상태입니다.
Post와 Like의 Join을 안 하고 싶어서, 좋아요 개수를 더 빨리 불러오고 싶다는 이유로 likeCount를 사용하는 것은 서비스 장애에 이를 수 있기 때문에 적합하지 않습니다.
likeCount 컬럼을 삭제하면 version이 update 되지 않기 때문에 결과적으로 낙관적 락을 사용할 수 없습니다.
따라서 낙관적 락을 사용하지 않는 방법을 택했고, Unique 제약 조건을 활용해야 됩니다.
BoardPost 엔티티에 likeCount 컬럼을 사용하지 않기
likeCount 컬럼을 사용하지 않으면 데드락을 해결할 수 있습니다.
다음 포스팅엔 Lock을 사용하지 않고 데이터 정합성을 유지하고 데드락을 피할 수 있는 방법에 대해 포스팅하겠습니다.
과정에서 깨달은 점
1. BoardPost 엔티티에 likeCount 컬럼을 추가한 이유는 게시글 상세보기나 목록에 좋아요 개수를 빠르게 얻기 위함입니다. Post와 Like의 Join 비용이 감소하고 성능을 올리려고했던 제 생각이 서비스 장애를 초래할 수 있다는 것을 알았습니다.
2. 비관적 락과 낙관적 락에 대해 깊이 학습할 수 있었습니다.
3. Lock이 모든걸 해결해주는 정답은 아니라는 것을 알았습니다.
4. 낙관적 락을 이용하면 여러 계정의 좋아요 요청이 동시에 올 때 성능 저하가 뚜렷하기 때문에 Lock을 사용하지 않는 방법이 성능 측면에서 더 좋다는 것을 알게되었습니다.
- 다음 -
https://byungil.tistory.com/321
'데이터베이스' 카테고리의 다른 글
동시성) 싱글벙글 게시글 좋아요 유니크 제약 조건을 이용하여 동시성 이슈 해결하기 (0) | 2023.10.04 |
---|---|
Database - 과제4 JDBC (0) | 2022.07.15 |
Database - JDBC연동 (0) | 2022.07.14 |
Database - 데이터 삽입,수정,삭제 (0) | 2022.07.14 |
Database - 테이블 생성 (0) | 2022.07.14 |