좋아요 기능을 구현하면서 생긴 동시성 문제 해결 과정을
작성한 글입니다.
문제 상황
2명이 동시에 좋아요을 누르게 된 경우, 2명의 좋아요가 전부 반영되지 않는 상황
좋아요 기능을 구현한 코드
public Long like(Long postId) {
Post post = postRepository.findById(postId).orElseThrow(() -> new PostNotFound());
post.addLikeCnt();
postRepository.save(post);
return postId;
}
게시물 조회 후, 좋아요 갯수 증가 Service 계층 로직입니다.
breakPoint를 Thread기반으로 해서, 2개의 요청이 Thread애 동시에 접근할 수 있는 환경을 만들어줍니다.
현재 id가 1번인 게시물은 좋아요(like_cnt)가 0개입니다.
터미널 2개를 띄워서, 좋아요 요청을 보냅니다.
왼쪽 터미널 request 요청시 발생하는 breakPoint 입니다.
현재 사용되는 스레드는 http-nio-8080-exec-5입니다.
이 상태에서 오른쪽 터미널 request 요청을 보냅니다.
오른쪽 터미널 request 요청시 발생하는 breakPoint입니다.
사용되느 스레드는 http-nio-8080-exec-6입니다.
왼쪽 터미널에 있는 요청이 오른쪽 터미널의 있는 요청이랑 거의 동일하게 요청을 보냈다고 한다면(왼쪽 터미널 요청이 0.000001초 빠르게), 정상적인 상황에선 왼쪽 터미널 요청의 좋아요 횟수 증가가 먼저 반영이 되고, 오른쪽 터미널 요청의 좋아요 갯수가 반영이되어, 총 2개의 좋아요 횟수가 증가해야 하는데, 아래와 같이 좋아요 횟수는 기존 0개에서 1개로 증가했습니다.
문제 발생 원인
두번째 좋아요 증가 요청에서 이전 요청에서 증가되지 않은 좋아요 갯수를 가져 옵니다.
문제 해결 방법 1
비관적 락을 사용합니다.
select for update문을 실행하면 lock을 획득하고, 해당 세션이 update 쿼리 후 commit하기 전까지는 다른 세션들이 해당 row를 수정하지 못하도록 합니다.
public Optional<Post> findById(Long postId, Boolean lock) {
String sql = String.format("select * from %s where id = :postId", TABLE_NAME);
if(lock) {
sql += "for update";
}
SqlParameterSource params = new MapSqlParameterSource()
.addValue("postId", postId);
return Optional.of(namedParameterJdbcTemplate.queryForObject(sql,params,postRowMapper));
}
sql에 for update라는 문자열을 추가해줍니다.
아까 진행했던 동일한 환경으로 테스트를 해줍니다.
좋아요 갯수가 2개가 증가했습니다.
문제 해결 방법 2
낙관적 락을 사용합니다.
Version을 통해서, 조회한 version이 맞는 경우, 업데이트를 하고 그렇지 않은 경우 데이터를 다시 조회하고, 업데이트 하도록 구현했습니다.
public Post update(Post post) {
String sql = String.format("update %s set user_id = :userId, contents = :contents, created_date = :createdDate, like_cnt = :likeCnt, created_at = :createdAt, version = :version+1 where id = :id and version = :version", TABLE_NAME);
SqlParameterSource params = new BeanPropertySqlParameterSource(post);
int updatedCnt = namedParameterJdbcTemplate.update(sql, params);
if(updatedCnt == 0) {
post.addVersion(); // 이전 요청에 version을 더해주고 다시 좋아요 카운팅을 해준다.
post.addLikeCnt();
update(post);
//throw new RuntimeException("업데이트 실패!");
}
return post;
}
Vesion이 한단계씩 올라가기 때문에, 업데이트 실패시 버전에 1을 더한 뒤, 다시 업데이트를 하도록 로직을 구현했습니다.
낙관적 락은 어플리케이션 레이어에서 락을 관리해주기 때문에, @Transactional 어노테이션이 필요 없게 됩니다.
비교하기
비교 환경
비교환경은 동시에 좋아요 요청 100개를 누르는 경우입니다.
비관적 락
2s 644ms가 걸립니다.
DB에도 100개의 좋아요가 기록됩니다.
낙관적 락
3s 851ms가 걸립니다.
DB에 100개의 좋아요가 기록됩니다.
응답속도만 비교해 보았을 때, 비관적락이 낙관적락보다 1s 200ms정도 더 걸리게 됩니다.
결론
속도만 비교해보았을땐, 비관적 락이 낙관적 락보다 더 빠릅니다.
현재 상황에서 속도적인 측면을 고려하면, 비관적 락을 사용하는 것이 조금 더 빠른 응답을 위한 선택임을 알 수 있습니다.
'트러블슈팅' 카테고리의 다른 글
Redis사용시, 발생하는 데이터 정합성 문제 (0) | 2024.10.01 |
---|---|
당행 이체시 발생하는 이중 이체 문제(Double Spend Issue) (0) | 2024.07.17 |