조회수 동시성 문제

조현근·2022년 11월 23일
1
post-thumbnail

팀 프로젝트 속닥속닥을 하면서 조회수를 구현했고 동시성 문제를 경험했다.
당시에는 JPQL이나 native query를 사용하면 잘 해결될거라 생각했는데, 크루들과 이야기를 나눠보니 내가 놓친 부분이 있는것 같았다. 학습테스트를 통해 조회수 동시성 문제를 한 번 고민해보자.

학습테스트를 위한 코드

Post

@Entity
public class Post {

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

    private String title;
    private String content;

    private Long viewCount = 0L;

    protected Post() {
    }

    public Post(String title, String content) {
        this.title = title;
        this.content = content;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }

    public Long getViewCount() {
        return viewCount;
    }

    public void increaseViewCount() {
        ++viewCount;
    }
}

PostService

영속성 컨텍스트에서 발생할 수 있는 동시성 문제

동시에 여러개의 사용자가 같은 글을 조회하면 조회수가 글을 읽은 만큼 오르지 않는 문제가 발생할 수 있다.
Post엔티티 객체의 필드인 viewCount를 변경하고 영속성 컨텍스트의 변경감지를 사용해 조회수를 db에 반영한다면 db에 미처 반영되지 못한 데이터가 소실되는 결과가 발생할 수 있다.

학습 테스트

PostService

@Transactional
public Post findPost(Long id) {
    Post post = postRepository.findById(id).orElseThrow(IllegalArgumentException::new);
    post.increaseViewCount();
    return post;
}

학습 테스트

@Test
void concurrent_persistence() throws Exception {
    int threadNumber = 10;
    Thread[] threads = new Thread[threadNumber];

    for (int i = 0; i<threadNumber; ++i) {
        threads[i] = new Thread(() -> persistence());
    }
    for (Thread thread : threads) {
        thread.start();
        Thread.sleep(500);
    }

    for (Thread thread : threads) {
        thread.join();
    }

    Post post = postRepository.findById(1L).get();
    System.out.println("@@@@viewCount: " + post.getViewCount());
}

void persistence() {
    RestAssured
            .given().log().all()
            .when().get("/post/1")
            .then().log().all()
            .extract();
}

Thread를 이용해 1번 게시글을 동시에 조회하도록 테스트를 작성했다.
중간에 Thread.sleep(500)을 주석처리 한 것과 하지 않을 것의 결과를 비교해보자.

Thread.sleep(500) 주석처리

Thread.sleep(500) 주석해제

10번 게시글을 조회했으니 viewCount가 10이 되어야 하지만 1 혹은 9로 동시성 문제가 발생했음을 알 수 있다.

JPQL로 동시성 문제 해결하기

영속성 컨텍스트를 거치지 않고 JPQL로 바로 viewCount를 update해보자. 그리고 update로 lock을 걸었을때 시간이 얼마나 소요되는지 측정해보자.

학습테스트

PostRepository

@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId")
void increaseViewCount(Long postId);

update쿼리를 통해 직접 DB에 접근해 데이터를 업데이트한다.
update쿼리는 lock을 걸기 때문에 동시성 문제는 해결이 될 것이다.

PostService

@Transactional
public Post findPost_JPQL(Long id) throws InterruptedException {
    postRepository.increaseViewCount(id);
    Thread.sleep(500);
    Post post = postRepository.findById(id).orElseThrow(IllegalArgumentException::new);
    return post;
}

하지만 트랜잭션이 commit 혹은 rollback될때 lock이 해제되기 때문에 만약 서비스 로직이 매우 복잡하다면 성능상 이슈가 발생한다. Thread.sleep(500)이 복잡한 비즈니스 로직을 수행하는데 걸리는 시간이라고 가정하자. 주석처리 했을때와 안했을때 총 소요시간이 얼마나 차이나는지 학습테스트를 통해 확인해보자

학습테스트

@Test
void concurrent_Jpql() throws InterruptedException {
    int threadNumber = 50;
    Thread[] threads = new Thread[threadNumber];

    for (int i = 0; i<threadNumber; ++i) {
        threads[i] = new Thread(this::jpql);
    }

    long start = System.currentTimeMillis();
    for (Thread thread : threads) {
        thread.start();
    }

    for (Thread thread : threads) {
        thread.join();
    }
    System.out.println("@@@Time: " + (System.currentTimeMillis() - start));

    Post post = postRepository.findById(1L).get();
    System.out.println("@@@@viewCount: " + post.getViewCount());
}

void jpql() {
    ExtractableResponse<Response> response = RestAssured
            .given().log().all()
            .when().get("/post/1/jpql")
            .then().log().all()
            .extract();
    assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
}

Thread.sleep(500)을 주석처리 했을 때

Thread.sleep(500)을 주석처리 안했을 때

두 경우 동시성 문제는 해결이 되어 viewCount가 50이 나오는걸 확인할 수 있다. 하지만 Thread.sleep(500)을 적용한 학습테스트가 시간이 훨씬 오래걸린것을 확인할 수 있다. 트랜잭션이 끝나야 lock을 해제하기 때문에 성능 저하가 발생하게 되는 것이다.

비동기로 성능 저하 개선하기

비즈니스 로직이 복잡해지면 트랜잭션이 commit, rollback되기 전까지 lock을 놓치않아 시간이 오래걸리는 것을 학습테스트를 통해 확인할 수 있었다.
조회수의 경우 조회수를 올리는 로직과 나머지 비즈니스 로직이 서로 의존적이지 않기때문에 비동기를 사용해 성능을 향상시킬 수 있다.

학습테스트

Application

@EnableAsync
@SpringBootApplication
public class ConcurrentApplication {
	public static void main(String[] args) {
		SpringApplication.run(ConcurrentApplication.class, args);
	}
}

spring에서 제공하는 비동기 기능을 사용하기 위해 @EnableAsync를 붙여주었다.

PostRepository

@Async
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId")
void increaseViewCount(Long postId);

비동기를 적용할 메서드에 @Async를 붙여주었다.

결과

이상하게 viewCount는 0이 나왔다. 처음에는 비동기처리라 db에 반영되기 전에 데이터를 읽어와서 그런줄 알았는데, 데이터 읽어오기 전에 Thread.sleep()을 아무리 길게 걸어도 똑같은 결과가 나왔다.
@Transactional의 전파레벨이 REQUIRED라 db에 update되기 전 트랜잭션이 끝나 이런 문제가 발생하는거라 예상했고 repository 메서드를 아래와 같이 바꿔주었다.

Repository

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId")
void increaseViewCount(Long postId);

결과

profile
안녕하세요!

0개의 댓글