본문 바로가기

프로젝트/개발

동시성 이슈 없는 조회수⏳ 기능 개발 고민

728x90

hmmmmmmmmmmm....

조회수는 많은 서비스에서 이용되며 사용자의 콘텐츠 선택에 많은 도움을 주는 요소 중 하나이다. 하지만 단순한 기능이라도 복잡한 요구사항이 추가된다면 재미난 기능을 만들 수 있다.

조회 시 증가

구현

가장 간단하게 구현하는 방법은 게시글 상세 조회 시 대상 데이터에 조회수를 의미하는 컬럼 데이터를 1 증가하는 것이다. 예를 JPA를 사용한다면 다음과 같이 구현할 수 있다.

final Post post = repository.findById(1L);
post.increaseViewCount();

문제

트랜잭션이 종료되면 변경 감지에 의해 변경 내용이 DB에 반영되는 구조로 흔히 구현할 수 있다. 하지만 조회수가 서비스에 중요한 요소라 누락이 되면 안 되는 경우, 이 구현은 예상하지 못한 동작을 할 수 있다.

    // given
    ...

    // when
    for (int i = 0; i < threadCount; i++) {
      executorService.submit(
          () -> {
            try {
              service.increaseViewCount(entity.getId());
            } finally {
              countDownLatch.countDown();
            }
          });
    }
    countDownLatch.await();

    // then
    final Entity updatedEntity = entityManager.find(Entity.class, entity.getId());
    assertThat(updatedEntity.getViewCount()).isEqualTo(threadCount);

 

만약 데이터 정합성을 만족하지 못하면 예외가 발생하는 낙관적 락을 통해 문제를 해결할 수 있을 것이다. 하지만 대부분의 서비스는 조회수가 오르지 않았다고 글이 조회되지 않는 서비스는 존재하지 않기 때문에 이는 다른 방법을 고민해야 한다.

 

테스트 코드에서는 서로 다른 스레드에서 동일한 데이터를 대상으로 동시에 조회수를 증가 처리한다. 하지만 결과는 항상 실패하게 되는데 이는 데이터베이스 트랜잭션 격리 수준과 관련이 있다. 현재 사용하는 데이터베이스의 경우 기본은 READ_COMMITED로 동작한다(벤더사마다 기본 격리 수준이 다를 수 있으니 확인 필요!). 따라서 동시에 시작한 데이터는 서로 다른 트랜잭션에서 독립적으로 현재 조회수를 읽어 +1 했기 때문에 테스트가 실패한다.

Database lock 이용

이 문제를 해결하기 위해선 업데이트할 데이터에 Lock을 걸어 다른 트랜잭션에서 해당 데이터를 조회할 경우 대기하도록 하는 방법이 있을 수 있다.

public interface EntityRepository extends JpaRepository<Entity, Long> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @Query("select e from Entity e where j.id = :id")
  Optional<Entity> findByIdForUpdate(@Param("id") Long id);
}

JPA를 사용한다면 비교적 간단하게 이를 구현할 수 있다. 하지만 빈번하게 일어나는 조회 기능에서 하나의 데이터에 동시에 1명만 접근할 수 있다고 한다면 굉장한 병목 지점이 발생하기 때문에 트래픽이 높지 않은 경우에만 사용 가능한 방법이다.

소소한 문제

final Post post = service.findById(id); // SELECT
service.increaseViewCount(id); // SELECT FOR UPDATE

여기서 소소한 문제가 있는데, 위 코드처럼 SELECT FOR UPDATE가 SELECT보다 뒤에 있는 경우 원하는 결과를 얻을 수 없는데, 운영체제 시간에 배웠던 임계 영역(Critical section)과 관련이 있다.

 

임계 영역을 설정하는 예시

처음 SELECT에 의해서 잠금 없이 조회가 이뤄지고, 조회된 데이터는 영속성 컨텍스트에 남게 된다. 그리고 SELECT FOR UPDATE로 조회된 결과를 업데이트하면, 영속성 컨텍스트에 캐시 된 객체가 변경되고, 트랜잭션이 만료가 되면 dirty check로 인해 데이터가 갱신된다.

소소하지 않은 문제

하지만 이 방식은 트랜잭션이 길어지는 작업을 하는 경우 문제가 생길 수 있다. 지금은 단 건 조회 및 업데이트에 가벼운 작업을 하지만, 만약 트랜잭션이 시작된 후 외부 서비스 처리를 위한 경우 트랜잭션이 길어지고 따라서 해당 데이터를 조회하는 다른 요청에 대해서는 작업을 수행할 수 없게 된다.

 

그리고 기능 장애로 인해 서버가 예기치 못한게 죽었을 경우 트랜잭션은 DB 설정에 따라 유지될 수 있는데 PostgreSQL의 경우 기본적으로 0(Disable)이다. 따라서 IDLE 상태로 유지될 수 있는 위험이 발생할 수 있어 또 하나의 관리 포인트가 발생하게 된다.

Redis lock 이용

많은 개발자들이 알 듯 Redis(이하 레디스)는 Single-thread로 동작하게 된다. 다시 말해 동시성 이슈가 발생하지 않는다고 할 수 있다.

 

대표적으로 SETNX(SET if Not eXists), EXPIRE 명령어를 이용해 Distribute lock을 달성할 수 있다. 만약 서버가 예기치 못한 게 죽더라도, 만료 시간 이후에 다른 작업들이 lock을 획득할 수 있다.

Soso한 문Je

하지만 Redis는 In-memory data store에 특징에 맞게 빠른 수정/접근이 가능하지만, 휘발성이란 단점이 있다. 따라서 Redis가 죽으면 이전 lock에 대한 정보가 휘발되어 동시에 두 작업이 수행될 수 있다.

 

이런 문제를 해결하기 위해서 분산 redis 환경에서 distibute lock을 달성하기 위해 redlock 알고리듬이 존재하지만, 클러스터딩 된 서버들의 환경과 lock 획득 시도/실패 시 발생하는 과정이 redis에 많은 traffic이 발생할 수 있다.

Sooooooooooso한 문je 22222222

그리고 Spin lock 문제도 고려해야 하는데, 만약 lock을 오래 점유하고, 대기하는 client가 많은 경우 spink lock으로 인한 redis 부하가 엄청나게 올라갈 수 있다. 고.로.타.묜 redisson을 고(구)려해봐도 좋을 것 같다.

 

redisson은 pub/sub 모델을 활용해 lock이 해체된 시점에 이벤트를 발행해 client가 재시도하도록 할 수 있다. 하지만 너무 많은 client가 대기하다 동시에 lock 획득 요청을 보내면 그것 또한 장애가 발생할 수 있으니 주의하자!

출처

 

'프로젝트 > 개발' 카테고리의 다른 글

Redisson 분산락  (1) 2024.01.02
I am메모리에요~ 🤗  (0) 2023.11.10
첫 번째, TL;DR  (0) 2023.11.10
프로젝트 회고 #작성중  (0) 2022.02.02