문제점이 있었던 코드
댓글을 저장하는 로직
@Transactional
public void create(CommentCreateRequestDto requestDto, User user) {
Long postId = requestDto.getPostId();
Post post = findPostByPostId(postId);
Comment child = requestDto.toEntity(user, post);
requestDto.getCommentIdOptional().ifPresent(commentId -> linkParent(commentId, child));
commentRepository.save(child);
post.increaseCommentCount();
}
- 게시글과 댓글의 관계에서 1개의 게시글의 댓글 개수를 조회할 때마다 가지고 와서 사용하기 때문에 비정규화를 해서
- 게시글(Post) 엔티티에 댓글의 개수(commentCount)라는 컬럼으로 추가하였다.
- post.increaseCommentCount()를 할 때 문제가 된다.
예상 시나리오는 이렇게 된다.
- "홍길동"과 "이상혁"은 DB에 동시에 접근을 해서 게시글 로우 한 건을 가져오게 된다.
- "홍길동"이라는 유저가 댓글을 등록한다.
- Id가 1인 게시글은 댓글의 개수가 1이 될 것이다.
- 이 후에 "이상혁"이라는 유저가 댓글을 등록한다.
- Id가 1인 게시글은 댓글의 개수가 2가 될 것이다.
이런 시나리오는 완전하게 틀리게 된다.
- "홍길동"과 "이상혁"은 DB에 동시에 접근을 해서 게시글 로우 한 건을 가져오게 된다.
- "홍길동"이라는 유저가 댓글을 등록한다.
- Id가 1인 게시글은 댓글의 개수가 1이 될 것이다.
- 이 후에 "이상혁"이라는 유저가 댓글을 등록한다.
- Id가 1인 게시글은 댓글의 개수가 1이 될 것이다.
Post 테이블의 로우를 가져올 때 동시에 같은 자원에 접근하게 되면서 문제가 생기게 된다.
각각의 트랜잭션은 조회를 한 이후에는 독립적으로 수행이 되며, "홍길동" 이라는 유저가 댓글을 등록한 지 "이상혁"이라는 유저는 모르고 있다는 것.
데이터가 손실되는 현상이 이루어지게 된다. (Race Condition)
아래와 같이 작동한다는 것.
실패한 테스트 코드
더보기
@DisplayName("동시성 확인 - Lock 미적용")
@Test
void createComments() throws InterruptedException {
// given
User user = createUser("test12@naver.com", "testpassword123"
, "01022222222", "개구리왕눈이");
User newUser = userRepository.save(user);
Post post = createPost(newUser);
Post newPost = postRepository.save(post);
String content = "안녕하세요. 댓글을 작성하겠습니다.";
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
CommentCreateRequestDto commentCreateRequestDto = CommentCreateRequestDto.builder()
.postId(newPost.getId())
.content(content)
.build();
// when
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
commentService.create(commentCreateRequestDto, newUser);
countDownLatch.countDown();
});
}
countDownLatch.await();
// then
List<Comment> comments = commentRepository.findAll();
List<Post> resultPosts = postRepository.findAll();
assertThat(comments).hasSize(100);
assertThat(resultPosts.get(0).getCommentCount()).isEqualTo(100);
}
해결방법
1. 낙관적 락(Optimistic Lock)
- 트랜잭션 충돌이 나지 않을 것이라고 낙관적으로 생각하는 것이 기본 생각이다.
- Application Level에서 잡아주는 Lock (DB Lock X)
- 낙관적 락은 Version이라는 구분 컬럼을 추가하여 충돌을 예방합니다.
@Entity
@Table(name = "Post")
@EqualsAndHashCode(of = {"id"}, callSuper = false)
public class Post extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer commentCount;
@Version
private Long version;
}
- 버전 컬럼을 추가하면서
Post post = findPostByPostId(postId);
- 게시글을 조회해올 때, version 정보도 가지고 오게 되고
- "홍길동"이 댓글을 등록했다면, version 컬럼에는 +1이 수행되고
- "이상혁"이 댓글을 등록한다면 version 컬럼이 1로 갱신되어 있기 때문에 해당 댓글을 등록하지 못한다.
- 예외(ObjectOptimisticLockingFailureException)가 발생하고, 이를 잡아주는 방법으로는
- 예외를 try catch로 받아서, 다시 요청하는 방법이 있다.
- 낙관적 락은 트랜잭션 커밋 전까지는 트랜잭션의 충돌을 알 수 없다.
2. 비관적 락(Pessimistic Lock)
- 트랜잭션간 충돌이 발생할 것이라고 가정하고 Lock을 거는 방법이다.
- 내가 선택한 방법
- 비정규화를 썼기 때문에, 트랜잭션에 문제가 생겨서 댓글 개수가 맞지 않으면 정합성에 문제가 생기게 된다.
- 정합성은 꼭 지켜줘야 하기 때문에. 비관적 락 방식을 사용하였다.
- 낙관적 락과 비관적 락의 경우 JPA에서 설정할 수 있는 옵션이 여러가지인데, 적용한 방법만 일단(다른 분들이 많이 정리해 두었음)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@EntityGraph(attributePaths = "user")
Optional<Post> findById(Long id);
- 이 방법은 하나의 자원에 접근하면 DB에서 지원하는 Lock을 수행하기 때문에 성능 상으로는 낙관적 Lock보다는 떨어지지만, 지금 케이스에서는 확실하게 정합성을 보장 받을 수 있는 방법이였다.
select
p1_0.id,p1_0.comment_count,
p1_0.content,
p1_0.created_at,
p1_0.favorite_count,
p1_0.title,
p1_0.updated_at,
u1_0.id,u1_0.address,
u1_0.address_detail,
u1_0.created_at,
u1_0.email,
u1_0.nickname,
u1_0.password,
u1_0.profile_image,
u1_0.tel_number,
u1_0.updated_at,
p1_0.view_count
from post p1_0
left join users u1_0 on u1_0.id=p1_0.email
where p1_0.id=? for update
- 자세히 보면 for update라는 구문을 볼 수 있다.
- 업데이트를 위한 조회 라는 의미로 받아들이면 된다.
테스트 케이스 통과
더보기
@DisplayName("동시성 확인 - Lock 적용")
@Test
void createComments() throws InterruptedException {
// given
User user = createUser("test12@naver.com", "testpassword123"
, "01022222222", "개구리왕눈이");
User newUser = userRepository.save(user);
Post post = createPost(newUser);
Post newPost = postRepository.save(post);
String content = "안녕하세요. 댓글을 작성하겠습니다.";
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
CommentCreateRequestDto commentCreateRequestDto = CommentCreateRequestDto.builder()
.postId(newPost.getId())
.content(content)
.build();
// when
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
commentService.create(commentCreateRequestDto, newUser);
countDownLatch.countDown();
});
}
countDownLatch.await();
// then
List<Comment> comments = commentRepository.findAll();
List<Post> resultPosts = postRepository.findAll();
assertThat(comments).hasSize(100);
assertThat(resultPosts.get(0).getCommentCount()).isEqualTo(100);
}
3. 메세지 큐(Message Queue)
- Message Queue
- "메시지 큐를 사용하면 프로듀서(데이터 생성자)와 컨슈머(데이터 소비자)가 독립적으로 실행될 수 있다."
- 일단 Queue의 특징으로는 FIFO으로 큐에 계속 쌓여서 순차적으로 수행되는 개념은 이해가 간다.
- Message Queue로 동시성 문제를 해결할 수 있다는 의견을 받아서
- 더 공부하고 다시 포스팅하겠습니다.
'프로젝트' 카테고리의 다른 글
리팩토링 함수 추출하기 (0) | 2024.02.19 |
---|---|
사이드 프로젝트 블로그 글 내용 글자 수 크기 지정에 대한 고민 (1) | 2023.12.31 |