-->

조회수 기준

  • 비회원도 조회수가 올라야 한다.
  • 상세 페이지 바로 접근 시 조회수가 올라야 한다.
  • 새로고침 시 조회수를 올리지 않아야 한다.
  • 일정 시간 주기로 조회 가능 여부를 초기화한다. →  10분 설정

 

방안

  1. 조회수 테이블로의 UpdateOrInsert
    1. 트래픽이 많아질 수록 DB 부하가 예상된다.
    2. 개발 공수가 적다.
    3. 중복 조회 이슈가 생긴다.
  2. Redis를 이용한 Wirte Back 전략
    1. Redis에 먼저 적재해두고 일정 주기로 DB에 insert 하는 배치 실행 
    2. 중복 조회를 막을 수 있다.
    3. 개발 공수가 적지 않다.
    4. 일관성 이슈가 발생할 수 있다.
  3. Redis 이용한 Write Through 전략
    1. Redis에 적재 후 DB에 적재
    2. 일정 시간 동안 Redis 에 있는 key 값으로는 DB insert 못하게 튕겨냄

 

중복 조회 이슈

중복 조회를 막기 위한 전략을 생각한다.

  • ip 주소 기반
    • 동일한 네트워크에 있는 사용자들이 동일한 ip를 사용하는 경우 사용자 식별에 어려움
  • Mac Address 기반
    • 저장 값이 매우 길다.
  • 쿠키 기반
    • 고의 삭제 가능
  • 세션 기반
    • 사용자별로 조회 기록 관리 가능
    • 서버가 여러 개인 분산 환경에서는 세션이 여러개 생겨날 수 있어 적절하지 않음.
  • 로그인 사용자 기반
    • 비회원 접속이 가능하기 때문에 적용하기 어려움

중복 조회가 서비스에 영향을 미치는 정도는 낮다.

 

구현

// Controller
    @GetMapping("")
    fun getPostByPostSeq(
        @Valid @ParameterObject postReq: PostReq,
        request: HttpServletRequest,
    ): PostDetailRes? {
        // 게시물을 조회하고 결과 반환
        val postRes = postQueryService.findPostByPostSeq(postReq) ?: return null

        // 환경 변수가 설정된 경우에만 조회수 체크 기능 활성화
        System.getenv(POST_VIEW_FEATURE_TOGGLE_ENV)?.takeIf { it.isNotBlank() }?.let {
            val clientIp = getClientIp(request)
            postViewCommandService.updatePostViews(clientIp, postReq)
        }

        return postRes
    }
// Service
    @Transactional
    fun updatePostViews(
        clientIp: String,
        req: PostReq,
    ) {
        // Redis 캐시에서 중복 확인 및 저장
        if (clientIp.isNotBlank() && req.postSeq != null) {
            // 이미 10분 내 동일한 조회가 있었다면 종료
            if (!redisService.insertViewIfNotRecent(clientIp, req, postViewExpireMinutes)) return

            // todo Redis 정상적으로 테스트 되면 삭제해도 되지 않을까
            // DB에서 한번 더 중복 확인 후 데이터 삽입
            val exists =
                repository.existsByPostSeqAndInfoIpAndInsDateAfter(
                    req.postSeq,
                    clientIp,
                    LocalDateTime.now().minusMinutes(postViewExpireMinutes),
                )

            if (!exists) { // 중복이 없으면 DB에 삽입
                repository.save(mapper.toEntity(clientIp, req))
            }
        }
    }
@Service
class RedisService(
    private val redisRepository: RedisRepository,
) {
    /**
     * @param postId 게시물의 고유 번호
     * @param clientIp 클라이언트 IP 주소
     * @param expireMinutes 캐시 만료 시간 (기본값 10분)
     * @return Boolean 해당 키가 이미 존재하면 false, 새로 저장되면 true
     */
    fun insertViewIfNotRecent(clientIp: String, req: RideReq, expireMinutes: Long): Boolean {
        val postId = req.postId ?: return false
        val key = "postView:$postId:$clientIp"

        // Redis에서 키 존재 여부 확인
        if (redisRepository.existsKey(key)) {
            // 키가 이미 존재하면 중복된 조회로 간주
            return false
        }

        val postViewDto = RideViewDto(
            postId = postId,
            infoIp = clientIp,
            paxId = req.paxId
        )

        // 키가 없으면 Redis에 저장하고 만료 시간 설정
        return redisRepository.saveKeyWithExpiration(postViewDto, expireMinutes)
    }

환경변수를 가지고 피쳐토글로 구현했다.

+ Recent posts