본문으로 바로가기

조회수 카운트 동시성 이슈 정리

category 백엔드/Java 2023. 11. 19. 00:37

현회사 서비스에서 포스트라는 도메인이 존재한다. 포스트의 조회수는 유저별로 하나씩 더해진다. 유저의 포스트 조회 로그 관련 테이블은 아래와 같이 구성되어 있다.

 

CREATE TABLE `user_view_creator_posts` (
        `id` int(11) NOT NULL AUTO_INCREMENT,
        `user_id` int(11) NOT NULL,
        `creator_post_id` int(11) NOT NULL,
        `created_at` datetime NOT NULL,
        PRIMARY KEY (`id`),
        UNIQUE KEY `index_user_view_creator_posts_on_user_id_and_creator_post_id` (`user_id`,`creator_post_id`),
        KEY `index_user_view_creator_posts_on_creator_post_id` (`creator_post_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

 

unique key를 (`user_id`, `creator_post_id`)로 설정했기 때문에 한 유저는 하나의 포스트에 대한 raw만 쌓일 것이다.

 

public void updateViewCount(long userId, CreatorPost creatorPost, String remoteAddr) {
    // 포스트 작성자일 경우는 업데이트하지않음
    if(creatorPost.getUserId() == userId) return;

    UserViewCreatorPost userViewCreatorPost = userViewCreatorPostRepository.findByUserIdAndCreatorPostId(userId, creatorPost.getId())
            .orElse(null);

    if (userViewCreatorPost == null) {
      creatorPost.setViewCountCache(creatorPost.getViewCountCache() + 1); // 포스트 조회수 증가
      creatorPostRepository.save(creatorPost);

      userViewCreatorPost = UserViewCreatorPost
              .buildUserViewCreatorPost(creatorPost.getId(), userId, remoteAddr);
    } else {
      userViewCreatorPost.setUpdatedAt(LocalDateTime.now());
    }

    userViewCreatorPostRepository.save(userViewCreatorPost);

}

포스트 조회수를 올리는 메소드인데, userViewCreatorPostRepository.save(userViewCreatorPost); 부분에서 unique key 제약조건에 걸리는 에러가 발생하였다. 고민을 좀 하다가, 멀티 쓰레드 환경으로 조회 테스트를 해보니 동일한 에러가 발생하는 게 확인되었다.

대충 아래와 같은 상황이라고 생각된다.

 

동시성 이슈를 해결하기 위한 방법으로 어떤게 있을지 한번 살펴보자.

 

1. Synchronized 키워드

자바의 동기화를 통해 여러 스레드 간에 임계 영역에 대한 동시 접근을 막는다. 멀티스레드 환경에서 공유 자원에 대한 안전성을 보장한다.

하지만 한 프로세스 내에서만 동기화가 유지되기 때문에 다수의 서버를 활용하면 동시성을 보장할 수 없다.

그리고 transactional 어노테이션을 같이 사용하는 경우 동기화 문제 역시 해결할 수 없다.

트랜잭션은 Begin - 로직 실행(count + 1) - Commit의 순서로 진행되는데, 하나의 트랜잭션이 커밋 되기 전 다른 트랜잭션이 실행될 수 있고, 이로 인해서 동시성 이슈가 발생한다.

 

2. Pessimistic Lock(비관적 락)

Pessimistic Lock은 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다. 자원 요청에 따른 동시성문제가 발생할 것이라고 예상하고 락을 걸어버리는 비관적 락 방식이다.

아래 그림과 같이 Server 1이 데이터를 가져올 때 해당 데이터 레코드에 Lock을 걸면, 다른 서버에서는 Server 1의 작업이 끝나 Lock이 풀릴 때까지 데이터에 접근할 수 없게 된다.

충돌 발생을 미연에 방지하고 데이터의 일관성을 유지할 수 있지만, DeadLock이 발생할 가능성이 높다.

 

3. Optimistic Lock(낙관적 락)

 

Optimistic Lock은 실제로 Lock을 사용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법이다. 자원에 Lock을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때 가서 처리하는 낙관적 락 방식이다.

 

아래 그림과 같이 Server 1에서 version1의 데이터베이스 레코드를 업데이트하고자 한다면, SELECT로 읽은 다음 해당 데이터를 업데이트 한 후에 version을 2로 업데이트시킨다. 그러면 Server 2에서는 version1을 동시에 읽었지만, 업데이트를 하고자 할 때 데이터베이스의 해당 레코드가 version2로 바뀌었기 때문에 레코드를 업데이트하지 못한다.

 

레코드를 업데이트하지 못할 경우(실패)에 무엇을 해야 하는 지에 대한 로직을 개발자가 직접 짜야 되기 때문에 Pessimistic Lock보다 번거롭다는 단점이 있다.

 

 

그 외에 Named Lock, Redis 분산락 등의 방법이 있지만 일단 생략...

 

결론적으로 조회수 카운트 올리는 메소드는 따로 수정을 하지 않아도 될것같다. 동시에 100번 요청이 들어오더라도 조회수가 1 증가하는 것이 확인이 되었고, 나머지 99개의 요청은 제약조건 에러가 발생하면서 rollback이 되기 때문에 기대 결과를 얻을 수 있었다.

'백엔드 > Java' 카테고리의 다른 글

자바의 Final 이란?  (0) 2020.07.30
java.util.Date와 java.sql.Date 차이  (0) 2019.12.17
Comparable, Comparator 인터페이스 차이  (0) 2019.02.25
다형성 & Upcasting & Downcasting  (0) 2018.11.10