-
스프링부트게시판 (4)이커머스 devops 2025. 11. 29. 14:33
조회수 요구사항
- 조회수
- 조회수 어뷰징 방지 정책
- 각 사용자는 게시글 1개 당 10분에 1번 조회수 집계
- 10분 동안 100번 조회하더라도 1번만 집계된다
조회수 설계

- 조회수는 데이터 일관성의 중요성이 떨어지고 다른 데이터에 의해 파생되는 데이터가 아니므로 트랜잭션이나 안전한 저장소가 반드시 필요하지 않다
- 하지만 트래픽이 많아 디스크의 접근 비용이 비싼 것을 고려해야 한다
- 따라서 in memeory database를 사용한다
- in memory database - 데이터를 메모리에 저장 및 관리하는 데이터베이스
Redis
- in memeory database
- 고성능
- NoSQL
- 키-값 저장소
- 다양한 자료구조 지원
- TTL(Time To Live) 지원 - 일정 시간이 지나면 데이터 자동 삭제
- single thread - 단일 스레드에서 순차적으로 처리하기 때문에 동시성 문제 해결에 유리
- 데이터 백업 지원
- Redis Cluster - 확장성, 부하 분산, 고가용성을 위한 분산 시스템 구성 방법 제공
- 메모리는 휘발성이지만 AOF, RDB 기능을 사용하면 데이터 영속성 관리가 충분하다
- AOF : 수행된 명령어를 로그 파일에 기록하고 데이터 복구를 위해 로그를 재실행한다
- RDB : snapshot, 저장된 데이터를 주기적으로 파일에 저장한다
Docker를 이용한 Redis 환경 세팅
$ docker run --name springboot-board-redis -d -p 6379:6379 redis:7.4

- 조회수 백업 용도 테이블
- Shard Key = article_id(게시글 ID) (적절한 분산의 단위로서 선정)
- redis는 NoSQL이기 때문에 데이터베이스에서 테이블 스키마를 직접 만들 필요 없다

- 백업용 테이블 생성
@Table(name = "article_view_count") @Getter @Entity @ToString @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ArticleViewCount { @Id private Long articleId; private Long viewCount; public static ArticleViewCount init(Long articleId, Long viewCount) { ArticleViewCount articleViewCount = new ArticleViewCount(); articleViewCount.articleId = articleId; articleViewCount.viewCount = viewCount; return articleViewCount; } }@Repository @RequiredArgsConstructor public class ArticleViewCountRepository { private final StringRedisTemplate stringRedisTemplate; // view::article::{article_ID}::view_count private static final String KEY_FORMAT = "view::article::%s::view_count"; public Long read(Long articleId) { String result = stringRedisTemplate.opsForValue().get(generateKey(articleId)); return result == null ? 0L : Long.valueOf(result); } private String generateKey(Long articleId) { return KEY_FORMAT.formatted(articleId); } public Long increase(Long articleId) { return stringRedisTemplate.opsForValue().increment(generateKey(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 ); }@Service @RequiredArgsConstructor public class ArticleViewService { private final ArticleViewCountRepository articleViewCountRepository; private final ArticleViewCountBackUpProcessor articleViewCountBackUpProcessor; private final ArticleViewDistributedLockRepository articleViewDistributedLockRepository; private static final int BACK_UP_BACH_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_BACH_SIZE == 0) { articleViewCountBackUpProcessor.backUp(articleId, count); } return count; } public Long count(Long articleId) { return articleViewCountRepository.read(articleId); } }public class ViewApiTest { RestClient restClient = RestClient.create("http://localhost:9003"); @Test void viewTest() throws InterruptedException { ExecutorService executorService = Executors.newFixedThreadPool(100); CountDownLatch latch = new CountDownLatch(10000); for(int i=0; i<10000; i++) { executorService.submit(() -> { restClient.post() .uri("/v1/article-views/articles/{articleId}/users/{userId}", 5L, 1L) .retrieve(); latch.countDown(); }); } latch.await(); Long count = restClient.get() .uri("/v1/article-views/articles/{articleId}/count", 5L) .retrieve() .body(Long.class); System.out.println("count = " + count); } }

조회수 어뷰징 방지 정책
- 게시글을 조회하면 조회수가 올라간다
- 어뷰저는 특정 게시글을 여러 번 조회해서 데이터 조작이 가능하다
- 어뷰징 방지 위한 조회 여부 식별 방법
- 로그인 사용자 - 사용자 별로 식별 가능
- 비로그인 사용자 - IP, User-Agent, 브라우저 쿠키, 토큰 등
- 스프링은 무상태 애플리케이션이지만 상태 관리가 필요하기 때문에 상태 저장소로 데이터베이스를 활용한다
- 게시글 조회수는 트래픽이 많을 수 있다
- 동시성 문제가 발생할 수 있다
- TTL 지원
- 이러한 이유로 Redis 선택
Redis 활용
- 조회수 증가 요청이 오면 Redis에 TTL=10분으로 데이터 저장한다
- 게시글 조회는 사용자 단위로 식별되므로 key=articleId+userId
- 이미 저장된 데이터가 있으면 저장에 실패하는 명령어 사용
- selfAbsent(데이터가 없을 때에만 저장)
- 데이터 저장 성공 여부에 따라 성공했으면 조회 내역이 없었으므로 조회수 증가한다
- 사용자의 게시글 조회수 증가에 따라 Lock을 획득한다고 볼 수 있다
- 이렇게 분산 시스템에서 락을 획득하는 것을 분산 락(Distributed Lock)이라고 한다
- 조회수 서비스의 여러 서버 애플리케이션들은 사용자의 게실 조회수 증가에 대해 10분 후 해제되기까지 락을 추가로 점유할 수 없다
@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::%s::user::%s::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 String.format(KEY_FORMAT, articleId, userId); } }
728x90'이커머스 devops' 카테고리의 다른 글
로그인/로그아웃 인증 개선 (0) 2026.01.07 동시성문제 - 비관적 락 (0) 2026.01.03 스프링부트 게시판 (3) (0) 2025.11.28 스프링부트 게시판 (2) (0) 2025.11.27 스프링부트 게시판 (1) (1) 2025.11.25