팀 프로젝트 속닥속닥을 하면서 조회수를 구현했고 동시성 문제를 경험했다.
당시에는 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로 바로 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);
결과