조회수 카운팅 시 동시성 문제

💡이 글은 조회수 카운팅 시 동시성 문제에 대한 내용을 담고 있습니다. 동시성 문제를 해결하고 조회수를 정확히 카운팅하기 위해 사용할 수 있는 방법들에 대해 살펴봅니다. 특히, 잠금을 통해 해당 문제를 해결하는 여러 방법을 탐구하고 있습니다. 끝으로, 테스트 결과와 선택한 방식을 소개합니다.

 

 현재, 게시글 조회 시 조회수를 +1 하는 형태로 카운팅하고 있습니다. 하지만, 동시에 여러 요청이 발생한다면 조회수 카운팅이 정확이 이루어지지 않을 수 있습니다. 이는 동일한 Row를 Update하는 과정에서 발생하는 Race Condition으로, 다양한 방법을 통해 해결할 수 있습니다.

 

 자바에서 제공하는 키워드를 사용할 수 있으며, DB를 통해 동시성을 보장할 수도 있습니다. 자바에서 제공하는 synchronized, volatile, Atomic과 같은 키워드는 다중 서버 환경에서 정확하게 동시성을 보장하지 못 할 수 있다는 한계가 있습니다. 따라서 다중 서버 환경에서도 올바르게 동시성을 처리할 수 있는 방법들에 대해 살펴보고자 합니다.

  1. 비관적 락(Pessimistic Lock),
  2. 낙관적 락(Optimistic Lock),
  3. 직접 Update 쿼리,
  4. 분산 락

위 4가지 방법 위주로 살펴보고, 각 방법의 장/단점을 검토해, 서비스 특성에 맞추어 선택하여 적용해보겠습니다.

 

조회수 카운팅 정책에 관한 내용은 여기에서 볼 수 있습니다!

1. 동시성 문제

0) 현재 상태

 비회원을 기준으로 현재 조회수 상승 코드를 살펴보겠습니다. 

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
@Table(name = "post")
public class Post extends BaseEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @Column(name = "title")
    private String title;

    @Column(name = "view_count")
    @Min(value = 0)
    private Integer viewCount;
    
    // ...

    public void incrementViewCount() {
        this.viewCount++;
    }
}

// Service 계층 코드
@Transactional
public SuccessResponse<AnonymousPostDetailRes> getPostDetail(PostDetailParams postDetailParams) {
    Post post = postRepository.findById(postDetailParams.getPostId())
            .orElseThrow(() -> new ResourceNotFoundException());

    String cookieName = POST_COOKIE_NAME_PREFIX + postDetailParams.getPostId();
    Cookie existingCookie = WebUtils.getCookie(postDetailParams.getRequest(), cookieName);
    if (existingCookie == null) {
        post.incrementViewCount();
        Cookie newCookie = new Cookie(cookieName, POST_COOKIE_VALUE);
        postDetailParams.getResponse().addCookie(newCookie);
        // ...
    }
}

 

 현재는 단순히 게시글 조회 후 viewCount++ 연산을 수행하고 저장하는 구조입니다. 

 이렇게 조회 쿼리와 update 쿼리가 각각 발생합니다. 이 경우 여러 요청이 동시에 들어왔을 때, 서로 다른 트랜잭션에서 동일한 viewCount 값을 기준으로 연산하여 조회수 카운팅이 제대로 되지 않을 수 있는 문제가 있습니다.

 위 그림처럼 User1의 트랜잭션이 커밋되어 조회수 상승이 DB에 반영되기 전에 새로운 트랜잭션에서 Select를 하게 되고, 이 값을 바탕으로 조회수를 상승시킵니다. 따라서  User1의 변경 사항이 먼저 반영되고, User2의 변경 사항(조회수 = 11)이 이를 덮어쓰게 되는 것입니다. 의도한 바는 조회수 12지만, 11이라는 결과가 나타나게 됩니다.

 100번의 조회를 테스트했을 때, 고작 11번만 카운팅되었습니다. 89번의 조회수 카운팅이 손실되었고, 요청이 더 많이 몰린다면 더 많은 양의 손실이 발생할 수 있습니다.

 

 성능, 비용과의 조율이 필요하겠지만, 동시성 문제만을 놓고 보았을 때 이를 해결하기 위해서는 락(잠금)이 필요합니다. 다양한 방식이 있으니, 이에 대해 알아보고 적절한 방안을 선택하는 과정을 살펴보겠습니다.

 

1) 비관적 락

 비관적 락은 이름에서 알 수 있듯이 강력한 락을 통해 동시성을 제어합니다. 동일한 데이터에 대해 동시에 여러 작업이 수행되지 않도록 락을 거는 방법입니다. 

 내부적으로 SELECT ... FOR UPDATE 쿼리를 사용하는데, @Lock 어노테이션을 사용하여 간단하게 구현할 수 있습니다.

public interface PostRepository extends JpaRepository<Post, Long> {

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

 @Lock의 LockModeType를 통해 비관적 락으로 설정하고, @Query에 JPQL을 작성해줍니다.

 실제 쿼리를 살펴보면 이전과는 다르게 for update가 포함되어 있습니다. 

select ... for update를 사용하면 조회한 데이터(행)에 대해 트랜잭션이 종료될 때까지 X락을 걸어, 다른 트랜잭션에서 데이터를 읽거나 쓸 수 없게 됩니다. 단순 Select는 S락을 포함하지 않기 때문에 조회가 가능하지만, S락을 포함한 Select가 대기하게 됩니다.

 

 만약 다른 트랜잭션이 S락을 가지고 데이터를 읽으려고 한다면 잠금이 해제될 때까지 대기하고, 잠금을 획득한 트랜잭션이 커밋 혹은 롤백되면 잠금이 해제되어 접근이 가능해집니다.

 

 비관적 락을 사용하면 각 트랜잭션이 시작되고 조회할 때, 잠금이 걸리기 때문에 커밋 전 조회로 인해 발생하는 덮어쓰기 문제가 해결됩니다. 하지만, 한 번에 하나의 트랜잭션이 온전히 실행되기를 기다려야 하기 때문에 요청이 많은 경우 대기 시간이 길어져, 응답 시간이 길어질 수 있습니다. 

 

 데이터 정합성 측면에서는 가장 좋은 방법이라고 생각되나, 그만큼 성능을 포기해야 한다는 단점이 명확합니다. 데이터의 오차에 민감한 경우 성능을 조금 포기하더라도 정합성을 지키기 위해 사용하면 좋은 방법입니다.

 

2) 낙관적 락

 비관적 락은 충돌 상황을 허용하나, 이를 감지하고 충돌이 발생했다면 어떻게 할 지 처리를 결정하는 방식입니다. @Lock 어노테이션과 Post 엔티티의 version 컬럼을 추가한 후 @Version 어노테이션 붙여 구현할 수 있으며, 데이터의 버전 정보를 사용합니다.

public interface PostRepository extends JpaRepository<Post, Long> {

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

// Post 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
@Table(name = "post")
public class Post extends BaseEntity {

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

    @Column(name = "view_count")
    @Min(value = 0)
    private Integer viewCount;
    
    @Version
    private Integer version;
    
    // ...
}

 낙관적 락은 트랜잭션에서 조회 쿼리를 날릴 때 잠금을 걸지 않고, 트랜잭션이 커밋될 때 버전 정보를 비교하여 충돌 여부를 확인합니다.

 쿼리를 살펴보면 update 시 version과 함께 검색하는 것을 볼 수 있습니다. 따로 락을 걸지 않고, 버전 충돌이 발생하면ObjectOptimisticLockingFailureException 예외를 발생시킵니다. 따라서 무시되기를 원치 않는다면 재시도 로직을 작성하여 예외 처리 로직을 작성해주어야 합니다.

 

 낙관적 락을 사용하면 락을 걸지 않기 때문에 비관적 락에 비해 성능이 우수합니다. 하지만, 충돌 시 로직을 직접 작성해야 한다는 번거로움이 존재합니다. 또한, 충돌이 많을 경우 재시도 등 처리 로직을 여러 번 수행하게 되므로 충돌이 많지 않을 것으로 예상되는 상황일 때 사용하면 좋은 방법입니다.

 

3) Update 쿼리 직접 사용

 update를 쿼리를 직접 날리는 방식으로 현재의 동시성 문제를 해결할 수도 있습니다. 마찬가지로 @Query에 JPQL을 작성해주고, @Modifying 어노테이션을 붙여줍니다. 

public interface PostRepository extends JpaRepository<Post, Long> {

    @Modifying
    @Query("UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId")
    void incrementViewCount(@Param("postId") Long postId);
}

// Service 계층 코드
@Transactional
public SuccessResponse<AnonymousPostDetailRes> getPostDetail(PostDetailParams postDetailParams) {
    String cookieName = POST_COOKIE_NAME_PREFIX + postDetailParams.getPostId();
    Cookie existingCookie = WebUtils.getCookie(postDetailParams.getRequest(), cookieName);
    if (existingCookie == null) {
        postRepository.incrementViewCount(postDetailParams.getPostId());
        Cookie newCookie = new Cookie(cookieName, POST_COOKIE_VALUE);
        postDetailParams.getResponse().addCookie(newCookie);
    }
    
	// ...
}

repository를 통해 바로 DB에 쿼리를 날려줍니다.


 update 순간부터 락을 얻고, 커밋 후에 락을 해제하기 때문에 비교적 락 점유 시간이 짧습니다. 작성한 update 쿼리가 나타나는 것을 확인할 수 있습니다.


 앞의 두 방법은 게시글 조회 후 애플리케이션 단에서 값을 수정했습니다. 따라서 커밋 전 조회 시 값이 올바르지 않다는 문제가 발생할 수 있었습니다. 하지만, 이 방식은 데이터베이스 단에서 값을 바로 수정하기 때문에 조회로 인한 데이터 충돌 문제가 발생하지 않습니다. 따라서 update 시 레코드에 x락을 걸어 커밋 전까지 사용하고, 이를 통해 효과적으로 동시성 처리를 할 수 있습니다.

 

4) 분산 락

 분산 락은 중앙 시스템을 사용하여 경쟁 상황(Race Condition)에서의 충돌을 방지하는 방법입니다. 애플리케이션의 규모가 커지면서 DB가 여러 대로 늘어나게 된다면 다시 동시성 문제가 발생할 수 있는데, 이 때 분산락을 통해 해결할 수 있습니다.

 분산 락은 다양한 방식으로 구현 가능한데, 대표적으로 MySQL의 네임드 락과 Redis를 사용하여 구현할 수 있습니다.

 

네임드 락

 네임드 락은 데이터베이스를 통해 락을 획득하고, 다른 트랜잭션은 락이 해제된 이후에 획득할 수 있도록 합니다. MySQL에서 제공하는 GET_LOCK(lock_name, timeout) 함수를 사용하여 네임드 락을 획득할 수 있으며, 획득한 락은 RELEASE_LOCK(lock_name) 함수로 해제하거나 세션이 종료되면 자동으로 해제됩니다.

 

락 획득

  락을 획득하기 위해서는 획득하려는 락의 이름과 TIMEOUT을 설정해주어야 합니다.

@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);

 

락 해제

 락을 해제할 때는 해제할 락의 이름을 사용합니다.
@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(@Param("key") String key);

 네임드 락은 락을 획득하기 위해 커넥션을 유지해야 하므로 애플리케이션에서 데이터베이스에 대한 커넥션 풀이 부족할 수 있습니다.

 

Redis 사용

 MySQL은 메인 저장소로 사용하는 경우가 많기 때문에 Redis를 사용하여 분산 락을 구현하는 경우도 많습니다. Redis를 사용하는 방법은 Lettuce와 Redisson으로 나뉩니다.

 

Lettuce

 Lecttuce는 Spring에서 기본적으로 사용하는 Redis 클라이언트입니다. Lettuce를 사용 시에는 SETNX(SET if Not eXists)명령어를 사용해서 락을 생성하고, 성공한다면 락을 획득합니다. 락이 있는 동안 다른 노드가 똑같은 키로 SETNX를 시도하면 락을 획득하지 못하게 됩니다. 락이 필요한 작업을 마친 후에는 delete로 해당 키를 삭제합니다. 

// 락 획득
public Boolean lock(Long key) {
    return redisTemplate
            .opsForValue()
            .setIfAbsent(generateKey(key), "lock", Duration.ofSeconds(3));
}

// 락 해제
public Boolean unlock(Long key) {
    return redisTemplate.delete(generateKey(key));
}

// 락 사용
Boolean isLockSuccess = redisUtil.lock(key);
if (Boolean.TRUE.equals(isLockSuccess)) {
    try {
        postRepository.incrementViewCount(postId);
    } finally {
        redisTemplate.delete(key); 
    }
}

 

Redisson

 Redisson은 Redis 기반의 고급 Java 클라이언트로, 락 획득, 유지, 연장, 해제 등 다양한 기능을 편리하게 제공합니다. Redisson을 사용하기 위해서는 우선 build.gradle에 redisson 의존성을 추가해야 합니다.

RLock lock = redissonClient.getLock("post_view:" + postId);
boolean isLocked = lock.tryLock(1, 3, TimeUnit.SECONDS); // 락 획득
if (isLocked) {
    try {
        postRepository.incrementViewCount(postId);
    } finally {
        if (lock.isLocked() && lock.isHeldByCurrentThread()) 
		lock.unlock();
    }
}

 getLock으로 락을 정의한 후, tryLock으로 락 획득을 시도할 시간과 획득 시 점유할 시간을 명시합니다.

점유한 시간이 끝나기 전에 작업이 종료되면 락을 해제해주어야 합니다. 락을 해제할 때에는 해제하려는 락이 현재 세션에서 사용 중인 것인지 확인하고 해제하는 것이 좋습니다.

 

2. 선택

  • 비관적 은 매번 락을 걸기 때문에 데이터 정합성은 훌륭하지만 성능 면에서 가장 떨어집니다.
  • 낙관적 락은 정합성은 보장되지만, 충돌 시 복잡한 예외 처리 및 재시도 로직이 필요하며, 실시간성이 중요한 조회수에는 다소 부적합합니다.
  • 직접 UPDATE 쿼리 방식은 명시적 쿼리를 통해 락을 짧게 유지하고, 성능과 정합성의 균형을 모두 갖춘 방식입니다.
  • 분산 락은 분산 환경을 고려한 전략이지만, 현재 단일 DB 구조에서는 크게 필요하지 않습니다.

 현재 동시성 처리를 하고자 하는 조회수 데이터는 정합성이 크게 중요하지 않고, 자원을 많이 투입하지 않아도 괜찮은 상태입니다. 따라서 데이터 정합성과 성능을 균형있게 챙길 수 있는 낙관적 락과 직접 Update 쿼리를 작성하는 방식 중 고민하였고, 아래와 같은 이유로 직접 Update 쿼리를 작성하는 방식을 선택했습니다. 

  • 낙관적 락
    • 장점: 평소 충돌이 적으면 락 없이 빠르게 처리함.
    • 단점: 충돌이 잦으면 여러 번 재시도해야 하므로 오히려 성능이 저하됨. 또한, 충돌 로직 핸들링(재시도, 예외 처리)을 직접 작성해야 함.
  • 직접 Update 쿼리
    • 장점: 구현이 단순하며, lock이 비교적 짧게 점유됨. DB 자체가 “view_count = view_count + 1” 연산 시 원자성을 보장하여 안전하게 처리함.
    • 단점: 트래픽이 많아질 경우 DB 락 경합이 올라갈 수 있음. 그러나 트래픽이 한 번에 몰릴만한 서비스가 아니며,  조회수 증가 정도라면 대체로 문제가 크지 않을 가능성이 높음.

 

직접 Update 쿼리 방식 선택

 충돌이 적을 것으로 예상되므로 낙관적 락도 괜찮았습니다. 하지만 충돌 시 재시도 및 예외 처리를 직접 작성해야 하며, 조회수를 활용하여 인기 글 노출 기능 등이 추가된다면 특정 글에서 충돌 시 성능 저하가 우려되었습니다. 특히, 최상단에 있는 게시글같이 접근하기 쉬운 경우 충돌 가능성이 높기 때문에 update 쿼리를 작성하는 방식으로 선택했습니다.

 

 

 데이터베이스 책과 운영체제를 공부했던 기억들과 함께 다시 책을 펼쳐보고 적용해보았습니다. 주로 사용하는 MySQL에서의 S락과 X락 그리고 넥스트 키락과 같이 다른 동시성 제어 방법에 대해서도 살펴볼 수 있는 좋은 시간이었습니다.