동시성을 고려한 조회수 증가 기능

wannabeking·2022년 10월 22일
5

Spring

목록 보기
4/4
post-thumbnail

소스 코드는 깃허브에서 확인할 수 있습니다.


여러 트랜잭션이 동시에 수행될 때 동시성 문제가 생길 수 있다.

간단한 조회수 증가 기능을 구현하면서 동시성 문제를 어떻게 해결할지 탐구해보자.

테스트에 사용될 Post 엔터티는 다음과 같다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String title;

    @Column(nullable = false, length = 10000)
    private String content;

    @Column(columnDefinition = "integer default 0")
    private int hits;

    public void updateHits() {
        hits++;
    }
}

우선, 동시성을 고려하지 않은 코드를 살펴보자.


동시성 고려 X

Service 클래스에 @Transactional 달아줬음!

public void view(Long id) {
    Post post = postRepository.findById(id)
        .orElseThrow(RuntimeException::new);
    post.updateHits();
}

일반적으로 조회수 증가는 위와 같이 작성할 것이다.
find 후 엔터티의 필드 값을 업데이트하여 JPA의 변경 감지를 이용하는 방법이다.

이 방법에서 어떤 문제가 발생할지 포스트맨과 JMeter로 간단하게 테스트해보자.

우선 포스트맨을 사용하여 간단한 게시글을 생성한다.
조회수는 hits의 default인 0으로 생성될 것이다.

그럼 이제 Jmeter를 사용하여 여러 트랜잭션을 동시에 실행되도록 만들어보자!

500개의 스레드 요청이 발생하게 설정하고,

위와 같은 요청으로 부하를 건다.

과연 조회수가 500만큼 올랐을지...?

증발해버린 조회수...

데이터베이스를 직접 확인하거나 포스트맨을 통해 GET 요청을 보내면 500이어야 될 조회수가 163 밖에 되지 않는 것을 확인할 수 있다.

동시성 문제 때문에 무려 3분의 2가 넘는 조회수가 날아간 것이다.

문제의 이유는 트랜잭션 안에서 read -> update -> save(commit 전 변경 감지)의 순서로 흘러갔고, 여러 트랜잭션이 동시에 read하여 같은 결과를 save 했기 때문이다.

이제 왜 동시성 문제를 고려해야 하는지는 명백하게 알았으니, 조회수 증가 기능을 리팩토링 해보자.



UPDATE 문 사용

조회수만 1 증가 시키는 간단한 예제이기 때문에 단순 쿼리로 해결할 수 있다.

UPDATE 문을 사용하여 직접 hits를 1 증가시키는 쿼리를 날리는 것이다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @Modifying
    @Query("UPDATE Post p SET p.hits = p.hits + 1 WHERE p.id = :id")
    int updateHits(Long id);
}

Read가 아닌 C, U, D의 경우 @Modifying 어노테이션이 필요하다.
붙이지 않으면 에러가 발생!


기본적으로 RDBMS는 특정 격리 수준으로 동시성을 제어한다.

또한, 탐색에 사용된 인덱스가 Lock 걸릴 레코드를 결정한다.
위 쿼리는 PK로 탐색했기 때문에 단 하나의 레코드만 Lock 된다.

Service 코드는 다음과 같다.

public void view(Long id) {
    if (!postRepository.existsById(id)) {
        throw new RuntimeException();
    }
    postRepository.updateHits(id);
}

다시 한번 JMeter로 테스트 해보면...

동시성 문제가 해결된 것을 확인할 수 있다.

하지만 단순히 조회수만 증가하는 기능이기 때문에 단순한 UPDATE 문으로 해결 가능했지만, 그렇지 않은 경우가 많다.

그러므로 다음 단계로 나아가 비관적 Lock으로 해결해보자.



비관적 Lock

Data JPA에서 다음과 같이 비관적 Lock을 사용할 수 있다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :id")
Optional<Post> findByIdForUpdate(Long id);

LockModeType을 설정할 수 있는데,
PESSIMISTIC_WRITE이 배타락, PESSIMISTIC_READ가 공유락이다.

쉽게 MySQL FOR UPDATE, FOR SHARE 쿼리로 나간다고 생각하면 된다.

public void view(Long id) {
    Post post = postRepository.findByIdForUpdate(id)
        .orElseThrow(RuntimeException::new);
    post.updateHits();
}

다시 부하를 걸어보면 결과는...

500 조회수가 잘 확인되는 것을 볼 수 있다.



Lock이 성능에 미치는 영향

Lock을 사용하였기 때문에 동시성 문제는 해결했지만, 필연적으로 성능 저하가 발생한다.

동시성 고려 X, UPDATE 문, 비관적 Lock의 성능 차이를 비교해보자.

JMeter에서 우클릭이 안되는 오류로 TPS 측정이 불가해 소요 시간으로 비교...

테스트는 위와 같은 환경에서 진행했다. (10000번의 요청)

결과는 다음과 같다.

  • 동시성 고려 X

  • UPDATE 문

  • 비관적 Lock

확실히 동시성을 고려하지 않으면 아무런 Lock이 걸리지 않아 가장 빨랐고, UPDATE 문이 비관적 Lock(배타)보다 조금 빨랐다.

비관적 Lock 방법이 조금 더 느린 이유는 다음과 같다.

  • UPDATE 문에서는 existsById()를 사용
  • 비관적 Lock은 배타락을 사용하여 다른 트랜잭션의 읽기 자체를 막음

충격적이게도 동시성을 고려하지 않으면 10000 조회수에서 90% 정도 감소한 결과가 나왔다.



낙관적 Lock은?

낙관적 Lock은 @Version으로 사용하는데, 버전이 다르면 rollback 시키기 때문에 원하는 결과인 완벽한 조회수 증가를 얻을 수 없어 진행하지 않겠다.

만약 동시성 문제가 자주 발생하지 않고 롤백 시킨 뒤 클라이언트에게 에러 or 안내 문구를 띄워도 되는 비교적 덜 중요한 기능이라면 낙관적 Lock을 사용해도 된다.



profile
내일은 개발왕 😎

2개의 댓글

comment-user-thumbnail
2022년 11월 3일

안녕하세요. 글 잘 보았습니다. 궁금한 점이 하나 있는데요.
if 문 체크할 때는 디비에 값이 있었는데 업데이트 코드를 수행하기 직전에 로우가 delete 된다면 에러가 나지 않을까요?
그런 점에서 read 할 때 lock을 걸어주는 것이 좀 더 좋아보이는데 혹시 어떻게 생각하시나요?

1개의 답글