ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링부트게시판 (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
Designed by Tistory.