Notice
Recent Posts
Recent Comments
Link
관리 메뉴

look-forest

Redis (feat.조회 수) 본문

Architecture/대규모 시스템 설계

Redis (feat.조회 수)

studyHub 2026. 2. 9. 23:01

조회 수 기능 구현을 통해 대규모 시스템에서 Redis를 어떻게 활용하는지 알아보자.

조회수 어뷰징 방지 정책은 다음과 같다.

- 각 사용자는 게시글 1개 당 10분에 1번 조회 수 집계


조회수 설계

조회 수는 게시글 수나 좋아요 수와는 달리, 다른 데이터의 개수로 파생되는 것이 아니다.

좋아요 수에 비해 데이터 일관성이 덜 중요하고, 쓰기 트래픽이 비교적 많다.(게시글 조회만만 해도 트래픽이 증가하므로)

따라서 디스크 접근 비용과 트랜잭션 관리 비용을 감수할 필요가 없다.

따라서, In-memory Database를 사용해 볼 수 있고, 많이 사용되는 Redis를 사용하자.

인기글 선정 시 필요하므로, 자체적인 백업 시스템도 간단히 구축해본다.(Redis에 저장된 데이터를 MySQL에 직접 백업)

Redis

  • In-memory Database -> 고성능
  • NoSQL Database -> 키-값 저장소
  • 다양한 자료구조 지원(String, List, Set, Sorted Set, Hash 등)
  • TTL(Time To Live) 지원 -> Lock으로 활용 가능
  • Single Thread에서 순차 처리 -> 동시성 문제를 해결하는데 유리
    Redis의 싱글 스레드는 여러 요청이 동시에 들어와도 순차적으로 처리해 동시성 이슈 없이 안전하게 데이터를 갱신하거나 락을 확보할 수 있게 해준다.
  • 데이터 백업 지원 -> 디스크에 저장하는 방법 제공(AOF, RDB)
    • AOF(Append Only File): 수행된 명령어를 로그 파일에 기록하고, 데이터 복구를 위해 로그를 재실행
    • RDB(SnapShot): 저장된 데이터를 주기적으로 파일에 저장
    • 약간의 데이터 유실은 허용한다는 관점이기 때문에, 실시간으로 모든 데이터를 백업할 필요는 없다
      - 시간 단위 백업(배치) or 개수 단위 백업
  • Redis Cluster -> 확장성, 부하 분산, 고가용성을 위한 분산 시스템 구성 방법 제공
    • 수평 확장
    • 데이터 복제 기능 제공(고가용성)
    • 샤딩 지원: key의 hash 값으로 slot(Logical Shard)을 구하고, slot으로 shard(Physical Shard) 선택
      1. slot = hash_function(key)
      2. shard = select_shard(slot)

Redis Cluster는 샤딩을 지원하고, 16,384개의 논리적 샤드(=Slot=슬롯)로 분리된다. 이러한 슬롯은 각 물리적 샤드에 균등하게 분산될 수 있다. 이를 통해 확장성과 부하 분산의 이점을 가진다
Redis Cluster는 데이터 복제도 지원한다. 이를 통해 장애 시에도 유연하게 대처할 수 있는 고가용성을 제공한다

 

 

 


구현

Redis용 Repository와 DB 백업용 Repository를 만든다.

@Repository
@RequiredArgsConstructor
public class ArticleViewCountRepository {
	private final StringRedisTemplate redisTemplate; //기본적으로 만들어줌. 이걸 통해 redis와 통신

	// view::article::{article_id}::view_count
	private static final String KEY_FORMAT = "view::article::%s::view_count";

	public Long read(Long articleId) {
		String result = redisTemplate.opsForValue().get(generateKey(articleId));
		return result == null ? 0L : Long.valueOf(result);
	}

	public Long increase(Long articleId) {
		return redisTemplate.opsForValue().increment(generateKey(articleId));
	}

	private String generateKey(Long articleId) {
		return KEY_FORMAT.formatted(articleId);
	}
}
@Repository
public interface ArticleViewCountBackUpRepository extends JpaRepository<ArticleViewCount, Long> {

	@Query(
		value = "update article_view_count set view_count = :viewCount " +
			"where article_id = :articleId and view_count < :viewCount", //동시 요청이 많아서 업데이터 쿼리 꼬이는 경우 방어
		nativeQuery = true
	)
	@Modifying
	int updateViewCount(@Param("articleId") Long articleId, @Param("viewCount") Long viewCount);
}

 

서비스에서는 Redis를 통해서만 조회수 조회, update 한다. (cache aside x)

그리고 100개 단위로 DB에 백업한다.

@Service
@RequiredArgsConstructor
public class ArticleViewService {
    private final ArticleViewCountRepository articleViewCountRepository;
    private final ArticleViewCountBackUpProcessor articleViewCountBackUpProcessor;
    private static final int BACK_UP_BATCH_SIZE = 100;

    public Long increase(Long articleId) {
       Long count = articleViewCountRepository.increase(articleId);
       if (count % BACK_UP_BATCH_SIZE == 0) {
          articleViewCountBackUpProcessor.backUp(articleId, count);
       }
       return count;
    }

    public Long count(Long articleId) {
       return articleViewCountRepository.read(articleId);
    }
}

@Component
@RequiredArgsConstructor
public class ArticleViewCountBackUpProcessor {
	private final ArticleViewCountBackUpRepository articleViewCountBackUpRepository;

	@Transactional
	public void backUp(Long articleId, Long viewCount) {
		int result = articleViewCountBackUpRepository.updateViewCount(articleId, viewCount);
		//0이면 백업 데이터가 없거나, 작은 수로 업데이트 시도한 경우
		if (result == 0) {
			articleViewCountBackUpRepository.findById(articleId)
				.ifPresentOrElse(ignore -> {},
                	//없을 경우 신규 저장
					() -> articleViewCountBackUpRepository.save(ArticleViewCount.init(articleId, viewCount))
				);
		}
	}
}

 


조회수 어뷰징 방지 정책 설계

어뷰저는 특정 게시글을 여러 번 조회해서 데이터를 조작할 수 있다.

조회수 기반으로 인기글이 산정되는 경우, 올바르지 않은 어뷰징 데이터로 인기글이 선정될 수 있기 때문에 방지가 필요하다.

10분 동안 100번 조회하더라도 조회수는 1회만 집계되어야 한다.

어뷰징을 방지하기 위한 조회 여부 식별

어뷰징을 방지하기 위한 조회 여부는 어떻게 식별할 수 있을까?

로그인 사용자라면 Id로 식별할 수 있고,

비로그인 사용자라면 IP, User-Agent, 브라우저 쿠키, 토큰 등 다양한 방법으로 식별할 수 있다.

여기서는 정책을 간소화하기 위해 로그인 사용자에 대해서만 식별하겠다.

 

조회 여부 저장

그렇다면, 각 사용자가 최근 10분 내에 게시글을 조회했었다는 사실을 어떻게 알 수 있을까?

스프링부트는 무상태(stateless) 애플리케이션이다. 하지만 우리는 이러한 상태를 관리해야 한다.

 

상태 저장소로 데이터베이스를 활용하면 어떨까? 조회 내역을 저장하고 매번 확인하는 것이다.

아래 이유로 데이터베이스가 아닌 Redis를 사용하는 것이 좋다.

  1. 게시글 조회 트래픽은 많을 수 있다. 그래서 조회수 집계는 성능을 위해 MySQL 대신 Redis를 선정한 것이다.
    여기서 다시 MySQL을 사용한다면, Redis를 선정한 의미가 없다.
  2. 동시성 문제가 발생할 수 있다. 조회 수 동시 요청이 들어온다면,
    Mysql에서는 락을 점유하는 상황이 필요할 수 있다.
    하지만 Redis는 Single Thread로 동작하므로, 하나의 명령어는 원자적으로 처리된다. 동시성 문제를 다루는데 유리하다.
  3. MySQL은 데이터 자동 삭제를 지원하지 않는다.
    게시글이 삭제되거나 더 이상 갱신될 일이 없다면, 직접 삭제를 위한 배치 등의 시스템을 구축해야 한다.
    반면 Redis는 TTL을 지원해서, 별도 삭제 시스템을 구축하지 않아도 자동으로 데이터가 삭제된다.

설계

따라서, Redis를 아래 프로세스로 활용한다.

  1. 조회수 증가 요청이 오면, Redis에 TTL=10분으로 데이터를 저장한다
    • 게시글 조회는 사용자 단위로 식별되므로, key=(articleId+userId)
    • 이미 저장된 데이터가 있으면 저장에 실패하는 명령어를 사용
      -> setIfAbsent: 데이터가 없을 때에만 저장. 성공하면 True, 실패하면 False 반환
  2. 데이터 저장 성공 여부에 따라 조회 수 증가
    • 성공 했으면, 조회 내역이 없었음을 의미 -> 조회수 증가
    • 실패 했으면, 조회 내역이 있었음을 의미 -> 조회수 증가 X

이러한 과정은, 조회수 증가에 대해서 일종의 Lock을 획득한다고 볼 수 있다.

우리 시스템은 확장성이 고려된 분산 시스템이고, 분산 시스템에서 락을 획득하는 것을, 분산 락(Distributed Lock)이라고 한다.

조회수 서비스의 여러 서버 애플리케이션들은 사용자의 게시글 조회수 증가에 대해서 10분 간 분산 락을 획득한다고 볼 수 있다.

동일 게시글에 동일한 사용자가 2개의 조회수 증가 요청을 동시에 호출한다고 가정. Redis는 Single Thread로 동작하고, setIfAbsent는 원자적으로 처리되기 때문에, 동시성 고려는 필요 없다.


구현

조회 이력이 있으면 데이터를 저장할 수 있도록 하고(set if absent), 10분의 TTL을 가진 데이터를 삽입해 일종의 Lock을 건다.

TTL은 Lock을 자동으로 해지해준다.

@Repository
@RequiredArgsConstructor
public class ArticleViewDistributedLockRepository {
	private final StringRedisTemplate redisTemplate;

	// view::article::{article_id}::user::{user_id}::lock
	private static final String KEY_FORMAT = "view::article::{article_id}::user::{user_id}::lock";

	public boolean lock(Long articleId, Long userId, Duration ttl) {
		String key = generateKey(articleId, userId);
		return redisTemplate.opsForValue().setIfAbsent(key, "", ttl);
	}

	private String generateKey(Long articleId, Long userId) {
		return KEY_FORMAT.formatted(articleId, userId);
	}
}


@Service
@RequiredArgsConstructor
public class ArticleViewService {
	private final ArticleViewCountRepository articleViewCountRepository;
	private final ArticleViewDistributedLockRepository articleViewDistributedLockRepository;
	private final ArticleViewCountBackUpProcessor articleViewCountBackUpProcessor;
	private static final int BACK_UP_BATCH_SIZE = 100;
	private static final Duration TTL = Duration.ofMinutes(10);

	public Long increase(Long articleId, Long userId) {
		if (!articleViewDistributedLockRepository.lock(articleId, userId, TTL)) {
			return articleViewCountRepository.read(articleId);
		}
		Long count = articleViewCountRepository.increase(articleId);
		if (count % BACK_UP_BATCH_SIZE == 0) {
			articleViewCountBackUpProcessor.backUp(articleId, count);
		}
		return count;
	}

	public Long count(Long articleId) {
		return articleViewCountRepository.read(articleId);
	}
}

 

테스트를 돌려보면, 1만 번 조회해도 조회수는 1로 나온다.

 



참고 자료 & 이미지 출처
스프링부트로 직접 만들면서 배우는 대규모 시스템 설계 - 게시판